fix,телеграм: подсказка обещала цену вдвое ниже настоящей + образец файла для базы номеров

Приёмка владельца на боевом, два замечания из трёх (третье — про рекламный
кошелёк и заморозку — отложено, кусок большой).

1. Подсказка «?» у поля медиа обещала «600 ₽ за тысячу с картинкой, 680 ₽ с
   видео». Клиент платит 1008 и 1142,40 ₽. Числа были вбиты в текст руками и
   протухли в ту минуту, когда миграция client_tg_cena_po_media поменяла тариф.

   Лечение в корень, а не подстановкой верных чисел: цену называет тот, кто её
   считает. Ручка оценки отдаёт ceny_za_tysyachu по каждому виду медиа
   (себестоимость × наценка), подсказка собирается из них функцией
   podskazkaProMedia. Пока сервер не ответил — текст без единой цифры: подставить
   «примерные» числа значило бы вернуть ровно эту беду.

   Прайс держим отдельно от охвата: охват на ошибке гасим (устаревший хуже
   никакого), а цены от настроек аудитории не зависят — иначе подсказка мигала бы
   на каждой опечатке в поле.

2. Замечание дословно: «нету скачать файл с примером как надо заполнить для нас
   файл». Кнопка «Скачать образец» рядом с полем загрузки — подпись клиент читает
   уже ПОСЛЕ того, как файл отклонили. Пять строк, написания разные (с плюсом, с
   восьмёркой, со скобками), номера синтетические 7999.

   Заголовка-строки в образце намеренно нет: разборщик нормализует первый столбец
   КАЖДОЙ строки, и слово «Телефон» попало бы в «не похоже на номер» — наш
   собственный образец показал бы клиенту ошибку.

Проверено вырезанием, а не только зелёным:
- вернул в подсказку вбитые 600/680 — покраснели 4 датчика, включая тот, что
  прямо запрещает эти два числа;
- вставил в образец строку-заголовок — покраснели 3.

Полный прогон поймал две мои же поломки, обе настоящие:
- значок mdi-file-download-outline на новой кнопке ОТСУТСТВОВАЛ в карте Lucide —
  на экране стал бы вопросом в кружке. Поймал сторож значков, у которого вчера
  опустошили список поблажек. Добавлен (Download — точного «файла со стрелкой» в
  Lucide нет);
- датчик подсказок ждал PODSKAZKI.media строкой, а её больше нет.

Замеры: экраны 245 файлов / 1870 тестов / 0 падений; телеграм-модуль 327 / 0;
статанализ 0; формат чисто; типы 6 — все в чужих файлах, столько же было до;
сборка 3,80 с. Счётчик в phpstan-baseline сдвинут 13→15 (новые тесты на Pest),
diff проверен глазами: изменилась ровно эта строка.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-03 01:59:01 +03:00
parent ce4b9bde44
commit f2ac3f4c7f
12 changed files with 405 additions and 8 deletions
@@ -157,6 +157,15 @@ class CampaignController extends Controller
),
'min_count' => $min,
'enough' => $count >= $min,
// 🔴 Цену за тысячу называет тот, кто её считает. Подсказка «?» у поля медиа
// раньше держала числа в тексте («600 ₽ с картинкой, 680 ₽ с видео») и
// протухла в ту же минуту, когда миграция поменяла тариф: клиенту обещали
// вдвое дешевле, чем списывали. Экран не имеет права знать числа.
'ceny_za_tysyachu' => collect(Campaign::MEDIA_KINDS)
->mapWithKeys(fn (string $vid): array => [
$vid => $this->tariff->clientEstimateRub(1000, $vid),
])
->all(),
]);
}
+1 -1
View File
@@ -3033,7 +3033,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 13
count: 15
path: tests/Feature/ClientTg/EstimateApiTest.php
-
+9
View File
@@ -1,4 +1,5 @@
import { apiClient, ensureCsrfCookie } from './client';
import type { CenyZaTysyachu } from '../composables/podskazkiReklamy';
/**
* API-модуль клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.2).
@@ -78,6 +79,14 @@ export interface TelegramOcenka {
/** Минимум кандидатов у МТС Маркетолог — правило площадки, не наше. */
min_count: number;
enough: boolean;
/**
* Цена ЗА ТЫСЯЧУ показов по каждому виду медиа, ₽ — то, что заплатит клиент.
*
* 🔴 Экран не имеет права хранить эти числа у себя. Подсказка «?» у поля медиа
* держала их в тексте («600 ₽ с картинкой, 680 ₽ с видео») и протухла в ту минуту,
* когда миграция поменяла тариф: клиенту обещали вдвое дешевле, чем списывали.
*/
ceny_za_tysyachu: CenyZaTysyachu;
}
/** Что спрашиваем у оценки — только про аудиторию, объявление на охват не влияет. */
@@ -11,6 +11,8 @@
* Показывает их компонент HelpHint (components/sales/HelpHint.vue): <HelpHint :text="..." />
*/
import type { TgVidMedia } from '../views/advertising/mediaKind';
export const PODSKAZKI = {
// ——— Телеграм: разовая кампания ———
audienceDays:
@@ -20,7 +22,6 @@ export const PODSKAZKI = {
// Текст владельца из ответа З-4 (файл замечаний 02.08.2026) — менять только вместе с ним.
ordCategory:
'Так закон делит рекламу для маркировки. Обычно подходит «Размещение рекламы» — меняйте, только если ваша реклама особенная (лекарства, финансы, БАДы).',
media: 'С картинкой показ стоит дороже — 600 ₽ за тысячу, с видео 680 ₽. Без картинки дешевле, но рекламу заметят хуже.',
// ——— Телеграм: авто-реклама новым лидам ———
autoThreshold:
@@ -50,3 +51,34 @@ export const PODSKAZKI = {
} as const;
export type KlyuchPodskazki = keyof typeof PODSKAZKI;
/** Цена за тысячу показов по каждому виду медиа — так её отдаёт ручка оценки. */
export type CenyZaTysyachu = Record<TgVidMedia, string>;
/** «672.00» → «672», «1142.40» → «1142,40». Копейки показываем, только если они есть. */
function rubli(summa: string): string {
return summa.replace(/\.00$/, '').replace('.', ',');
}
/**
* Подсказка «?» у поля медиа — с ЖИВЫМИ ценами сервера.
*
* 🔴 Почему функция, а не строка в словаре. Раньше текст держал числа в себе («600 ₽
* за тысячу с картинкой, 680 ₽ с видео») и протух в ту минуту, когда миграция
* client_tg_cena_po_media поменяла тариф: клиенту обещали вдвое дешевле, чем с него
* списывали. Цену называет тот, кто её считает; экран числа не хранит.
*
* Датчики — tests/Frontend/podskazki-reklamy.spec.ts.
*/
export function podskazkaProMedia(ceny: CenyZaTysyachu | null): string {
// Сервер ещё не ответил — говорим «дороже» словами. Подставить сюда «примерные»
// числа значило бы вернуть ровно ту беду, из-за которой эта функция и появилась.
if (ceny === null) {
return 'С картинкой и с видео показ стоит дороже, чем без них. Точные цены посчитаем, как только выберете, кому показывать.';
}
return (
`Тысяча показов с картинкой стоит ${rubli(ceny.image)} ₽, с видео ${rubli(ceny.video)} ₽, ` +
`без них ${rubli(ceny.none)} ₽. Без картинки дешевле, но рекламу заметят хуже.`
);
}
+3
View File
@@ -221,6 +221,9 @@ export const lucideMap: Record<string, Component> = {
'mdi-delete-outline': Trash2,
'mdi-dots-vertical': MoreVertical,
'mdi-download': Download,
// Кнопка «Скачать образец» в «Моей базе номеров» (03.08.2026). Точного «файл со
// стрелкой вниз» в Lucide нет — берём стрелку вниз, смысл тот же.
'mdi-file-download-outline': Download,
'mdi-email-outline': Mail,
'mdi-eye': Eye,
'mdi-eye-off': EyeOff,
@@ -40,8 +40,9 @@ import {
type TelegramCreatePayload,
} from '../../api/telegram';
import { vidMediaFajla, type TgVidMedia } from './mediaKind';
import { skachatObrazecBazy } from './obrazecBazy';
import { formatPlain } from '../../composables/billingFormatters';
import { PODSKAZKI } from '../../composables/podskazkiReklamy';
import { PODSKAZKI, podskazkaProMedia, type CenyZaTysyachu } from '../../composables/podskazkiReklamy';
import HelpHint from '../../components/sales/HelpHint.vue';
const props = defineProps<{ balanceRub: string }>();
@@ -172,6 +173,8 @@ const nuzhenZagolovok = computed(() => {
// ——— Живой счётчик охвата ———
const ocenka = ref<TelegramOcenka | null>(null);
/** Цены за тысячу показов — как их назвал сервер. Экран своих чисел не хранит. */
const ceny = ref<CenyZaTysyachu | null>(null);
const ocenkaIdet = ref(false);
const ocenkaOshibka = ref('');
@@ -210,6 +213,12 @@ async function obnovitOhvat(): Promise<void> {
payload.phones = parsePhones();
}
ocenka.value = await estimateTelegram(payload);
// Прайс держим ОТДЕЛЬНО от охвата: охват на ошибке обнуляем (устаревшее число
// хуже никакого), а цены за тысячу от настроек аудитории не зависят — гасить
// их значило бы мигать подсказкой на каждой опечатке в поле.
if (ocenka.value.ceny_za_tysyachu) {
ceny.value = ocenka.value.ceny_za_tysyachu;
}
} catch {
// 🔴 Молчим, а не показываем прежнее число: устаревший охват хуже, чем никакого —
// клиент решил бы, что считает под нынешние настройки, и запустил вслепую.
@@ -263,6 +272,8 @@ function formatMoney(value: string): string {
}
const smetaRub = computed(() => ocenka.value?.estimated_cost_rub ?? null);
/** Текст подсказки «?» у поля медиа — с ценами сервера, пока их нет — без чисел. */
const podskazkaMedia = computed(() => podskazkaProMedia(ceny.value));
/** Смета больше баланса — клиент упрётся в отказ при запуске, скажем заранее. */
const maloDeneg = computed(() => {
@@ -437,6 +448,19 @@ defineExpose({ step, obnovitOhvat });
/>
<div class="d-flex align-center ga-3 mt-2">
<!--
Образец рядом с полем загрузки, а не в подписи: клиент
читает подпись уже ПОСЛЕ того, как файл отклонили.
-->
<v-btn
variant="text"
size="small"
prepend-icon="mdi-file-download-outline"
data-testid="tg-baza-obrazec"
@click="skachatObrazecBazy"
>
Скачать образец
</v-btn>
<v-btn
variant="text"
size="small"
@@ -579,7 +603,7 @@ defineExpose({ step, obnovitOhvat });
class="mb-2"
>
<template #append-inner>
<HelpHint :text="PODSKAZKI.media" />
<HelpHint :text="podskazkaMedia" />
</template>
</v-file-input>
@@ -0,0 +1,50 @@
/**
* Образец файла для «Моей базы номеров».
*
* 🔴 Замечание владельца 03.08.2026: «нету скачать файл с примером как надо заполнить
* для нас файл». Клиент видел поле «Файл с номерами» и подпись про первый столбец — и
* должен был догадаться сам. Догадка стоит дорого: неподходящий файл отклоняется
* целиком, а клиент решает, что портал сломан.
*
* 🪤 Заголовка-строки («Телефон») здесь намеренно НЕТ. Разборщик на сервере
* (TelegramBazaService) нормализует первый столбец КАЖДОЙ строки — слово попало бы в
* «не похоже на номер», и наш собственный образец показал бы клиенту ошибку.
*
* Написания нарочно разные: с плюсом, с восьмёркой, со скобками и пробелами. Так видно,
* что формат подгонять не нужно — приведём сами.
*
* Номера синтетические (7999…). Настоящие в репозиторий не кладём НИКОГДА — это ПДн.
*
* Датчики — tests/Frontend/telegram-obrazec-bazy.spec.ts.
*/
/** Имя скачиваемого файла: без пробелов и кириллицы — такое не ломает ни один браузер. */
export const IMYA_OBRAZCA = 'obrazec-bazy-nomerov.csv';
/** Содержимое образца: один номер в строке, разными написаниями. */
export const OBRAZEC_BAZY = [
'+7 999 000-00-01',
'+79990000002',
'89990000003',
'8 999 000 00 04',
'7 (999) 000-00-05',
].join('\r\n');
/**
* Отдать образец браузеру как скачиваемый файл.
*
* Файл собираем прямо здесь, а не просим у сервера: содержимое неизменное, лишний поход
* на сервер добавил бы ещё одну точку отказа ради пяти строк.
*
* Перевод строки — CRLF: Excel открывает такой файл ровно, LF он на части не делит.
*/
export function skachatObrazecBazy(): void {
const blob = new Blob([OBRAZEC_BAZY], { type: 'text/csv;charset=utf-8' });
const ssylka = document.createElement('a');
ssylka.href = URL.createObjectURL(blob);
ssylka.download = IMYA_OBRAZCA;
document.body.appendChild(ssylka);
ssylka.click();
document.body.removeChild(ssylka);
URL.revokeObjectURL(ssylka.href);
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Optout;
use App\Models\ClientTg\Tariff;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
@@ -181,3 +182,38 @@ it('без входа в кабинет ручка недоступна', functi
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertStatus(401);
});
/**
* Цена за тысячу показов ОТ СЕРВЕРА, а не вбитая в текст экрана.
*
* 🔴 Откуда взялось (приёмка владельца 03.08.2026). Подсказка «?» у поля медиа обещала
* «600 за тысячу с картинкой, 680 с видео». Настоящие клиентские цены на тот момент
* 1008 и 1142,40 : числа были вбиты в текст руками и протухли в ту же минуту, когда
* миграция client_tg_cena_po_media поменяла тариф. Обещать клиенту цену вдвое ниже
* настоящей это не опечатка, это неверная цена в кабинете.
*
* Лечение: цену называет тот же, кто её считает. Экран не имеет права знать числа.
*/
it('оценка отдаёт клиентскую цену за тысячу по каждому виду медиа', function () {
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
// Себестоимость 0.480 / 0.720 / 0.816 ₽ за показ × наценка 1.40 × 1000 показов.
expect($otvet->json('ceny_za_tysyachu.none'))->toBe('672.00');
expect($otvet->json('ceny_za_tysyachu.image'))->toBe('1008.00');
expect($otvet->json('ceny_za_tysyachu.video'))->toBe('1142.40');
});
it('цена за тысячу едет из таблицы тарифа — поменяли тариф, поменялся ответ', function () {
Tariff::where('media_kind', Campaign::MEDIA_IMAGE)->update(['price_rub' => '1.000']);
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
// 1.000 × 1.40 × 1000 = 1400. Если бы число было вбито в код, ответ не сдвинулся бы.
expect($otvet->json('ceny_za_tysyachu.image'))->toBe('1400.00');
// Соседние виды медиа при этом не поехали.
expect($otvet->json('ceny_za_tysyachu.none'))->toBe('672.00');
});
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { PODSKAZKI } from '../../resources/js/composables/podskazkiReklamy';
import { PODSKAZKI, podskazkaProMedia } from '../../resources/js/composables/podskazkiReklamy';
/**
* Подсказки «?» стоят у нужных полей на обоих рекламных экранах (пачка 3).
@@ -144,7 +144,9 @@ describe('мастер телеграм-кампании — подсказки
const w = await masterNaShage(2);
expect(podskazkaVnutri(w, '[data-testid="tg-ord"]')).toBe(PODSKAZKI.ordCategory);
expect(podskazkaVnutri(w, '[data-testid="tg-media"]')).toBe(PODSKAZKI.media);
// Подсказка про медиа больше не строка словаря: цены в ней — живые, от сервера.
// Здесь сервер ещё не ответил, значит показывается вариант без чисел.
expect(podskazkaVnutri(w, '[data-testid="tg-media"]')).toBe(podskazkaProMedia(null));
});
it('шаг «Деньги»: лимит на объявление объяснён', async () => {
+59 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { PODSKAZKI, type KlyuchPodskazki } from '../../resources/js/composables/podskazkiReklamy';
import { PODSKAZKI, podskazkaProMedia, type KlyuchPodskazki } from '../../resources/js/composables/podskazkiReklamy';
/**
* Тексты подсказок «?» на рекламных экранах (пачка 3).
@@ -16,7 +16,7 @@ const VSE_KLYUCHI: KlyuchPodskazki[] = [
'audienceDays',
'budgetCap',
'ordCategory',
'media',
'autoThreshold',
'autoBudget',
'autoDailyLimit',
@@ -91,3 +91,60 @@ describe('подсказки, у которых текст задан не на
expect(PODSKAZKI.walletFrozen.toLowerCase()).toContain('нельзя');
});
});
/**
* Подсказка про медиа собирается из ЖИВЫХ цен сервера (приёмка владельца 03.08.2026).
*
* 🔴 Откуда взялось. Текст обещал «600 ₽ за тысячу с картинкой, 680 ₽ с видео», а
* клиент платил 1008 и 1142,40 ₽. Числа были вбиты в строку руками и протухли в ту
* минуту, когда миграция поменяла тариф. Экран не имеет права знать цену — он обязан
* назвать ту, которую ему сказал сервер.
*/
describe('подсказка про медиа — цены не вбиты в текст', () => {
const CENY = { none: '672.00', image: '1008.00', video: '1142.40' };
it('называет ровно те цены, которые дал сервер', () => {
const tekst = podskazkaProMedia(CENY);
expect(tekst).toContain('1008');
expect(tekst).toContain('1142');
});
it('поменялся тариф — поменялся текст', () => {
const tekst = podskazkaProMedia({ none: '672.00', image: '1400.00', video: '1600.00' });
expect(tekst).toContain('1400');
expect(tekst).not.toContain('1008');
});
it('старых выдуманных чисел 600 и 680 в тексте нет', () => {
const tekst = podskazkaProMedia(CENY);
expect(tekst).not.toMatch(/\b600\b/);
expect(tekst).not.toMatch(/\b680\b/);
});
it('собранный текст живёт по тем же правилам языка, что и словарь', () => {
const tekst = podskazkaProMedia(CENY);
expect(tekst).not.toMatch(/[a-zA-Z]/);
expect(tekst.trim()).toBe(tekst);
expect(tekst.length).toBeGreaterThanOrEqual(40);
expect(tekst.length).toBeLessThanOrEqual(200);
const predlozheniy = tekst.split(/[.!?]+/).filter((ch) => ch.trim() !== '').length;
expect(predlozheniy).toBeGreaterThanOrEqual(2);
expect(predlozheniy).toBeLessThanOrEqual(3);
});
});
describe('подсказка про медиа — пока сервер не ответил', () => {
it('без цен говорит честно «дороже», но НИКАКИХ чисел не выдумывает', () => {
const tekst = podskazkaProMedia(null);
expect(tekst).not.toMatch(/\d/);
expect(tekst.length).toBeGreaterThanOrEqual(40);
expect(tekst.length).toBeLessThanOrEqual(200);
expect(tekst).not.toMatch(/[a-zA-Z]/);
});
});
@@ -119,3 +119,39 @@ describe('живой счётчик и вид медиа', () => {
expect(posledniyZapros().media_kind).toBe('video');
});
});
/**
* Подсказка «?» у поля медиа называет цены СЕРВЕРА (приёмка владельца 03.08.2026).
*
* 🔴 Обещала «600 ₽ за тысячу с картинкой, 680 ₽ с видео», а списывалось 1008 и
* 1142,40 ₽: числа жили в тексте экрана и протухли при первой же правке тарифа.
*/
describe('подсказка про медиа берёт цены у сервера', () => {
beforeEach(() => {
estimateMock.mockReset();
estimateMock.mockResolvedValue({
...OTVET,
ceny_za_tysyachu: { none: '672.00', image: '1008.00', video: '1142.40' },
});
});
it('до ответа сервера чисел не выдумывает', () => {
const w = smontirovat();
const tekst = (w.vm as unknown as { podskazkaMedia: string }).podskazkaMedia;
expect(tekst).not.toMatch(/\d/);
});
it('после ответа показывает цены сервера, а не 600 и 680', async () => {
const w = smontirovat();
await (w.vm as unknown as { obnovitOhvat: () => Promise<void> }).obnovitOhvat();
await flushPromises();
const tekst = (w.vm as unknown as { podskazkaMedia: string }).podskazkaMedia;
expect(tekst).toContain('1008');
expect(tekst).toContain('1142');
expect(tekst).not.toMatch(/\b600\b/);
expect(tekst).not.toMatch(/\b680\b/);
});
});
@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { OBRAZEC_BAZY, IMYA_OBRAZCA } from '../../resources/js/views/advertising/obrazecBazy';
/**
* Образец файла для «Моей базы номеров» (приёмка владельца 03.08.2026).
*
* 🔴 Замечание дословно: «нету скачать файл с примером как надо заполнить для нас файл».
* Клиент видел поле «Файл с номерами» и подпись про первый столбец — и должен был
* догадаться сам. Догадка стоит дорого: неверный файл отклоняется целиком, а клиент
* решает, что портал не работает.
*
* 🪤 Образец обязан проходить наш же разборщик БЕЗ единой отброшенной строки. Строка
* заголовка («Телефон») сюда не годится: TelegramBazaService нормализует первый столбец
* каждой строки, и слово попало бы в «не похоже на номер» — наш собственный образец
* показал бы клиенту ошибку.
*
* Номера синтетические (7999…) — настоящие в репозиторий не кладём НИКОГДА, это ПДн.
*/
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
const { estimateMock, fetchBazaMock, uploadBazaMock, clearBazaMock } = vi.hoisted(() => ({
estimateMock: vi.fn(),
fetchBazaMock: vi.fn(),
uploadBazaMock: vi.fn(),
clearBazaMock: vi.fn(),
}));
vi.mock('../../resources/js/api/telegram', () => ({
estimateTelegram: estimateMock,
fetchTgBaza: fetchBazaMock,
uploadTgBaza: uploadBazaMock,
clearTgBaza: clearBazaMock,
createTelegram: vi.fn(),
launchTelegram: vi.fn(),
uploadTelegramMedia: vi.fn(),
fetchTelegram: vi.fn(),
fetchAutoRule: vi.fn(),
resubmitTelegram: vi.fn(),
uploadAutoRuleMedia: vi.fn(),
saveAutoRule: vi.fn(),
MEDIA_ACCEPT: 'image/jpeg,image/png,video/mp4',
MEDIA_TREBOVANIYA: 'Картинка: JPEG или PNG, до 25 МБ.',
MEDIA_CENA: 'Картинка и видео делают показ дороже.',
}));
import TelegramCampaignWizard from '../../resources/js/views/advertising/TelegramCampaignWizard.vue';
const vuetify = createVuetify();
type Wrapper = ReturnType<typeof mount>;
function smontirovat(): Wrapper {
return mount(TelegramCampaignWizard, {
props: { balanceRub: '5000.00' },
global: { plugins: [vuetify] },
});
}
async function vybratBazu(w: Wrapper): Promise<void> {
await w.find('[data-testid="tg-audience-base"] input').setValue(true);
await flushPromises();
}
/** Непустые строки образца — то, что увидит разборщик. */
function stroki(): string[] {
return OBRAZEC_BAZY.split(/\r?\n/).filter((s) => s.trim() !== '');
}
describe('образец файла с номерами — содержимое', () => {
it('в образце только номера: ни одной строки со словами', () => {
for (const stroka of stroki()) {
expect(stroka, `строка «${stroka}» не похожа на номер`).toMatch(/^[\d+()\s-]+$/);
}
});
it('каждая строка — ровно 11 цифр, как ждёт разборщик', () => {
for (const stroka of stroki()) {
const cifr = (stroka.match(/\d/g) ?? []).length;
expect(cifr, `в строке «${stroka}» цифр ${cifr}`).toBe(11);
}
});
it('показывает разные написания — чтобы клиент не боялся формата', () => {
expect(stroki().some((s) => s.startsWith('+7'))).toBe(true);
expect(stroki().some((s) => s.startsWith('8'))).toBe(true);
expect(stroki().some((s) => /\s/.test(s))).toBe(true);
});
it('номера синтетические 7999… — настоящих ПДн в репозитории нет', () => {
for (const stroka of stroki()) {
const cifry = (stroka.match(/\d/g) ?? []).join('');
expect(cifry.slice(1, 4), `строка «${stroka}»`).toBe('999');
}
});
it('строк хватает, чтобы было понятно, но файл не простыня', () => {
expect(stroki().length).toBeGreaterThanOrEqual(3);
expect(stroki().length).toBeLessThanOrEqual(10);
});
it('имя файла говорит само за себя и открывается таблицей', () => {
expect(IMYA_OBRAZCA).toMatch(/\.csv$/);
// Без пробелов и кириллицы: такое имя не ломается при скачивании ни в одном браузере.
expect(IMYA_OBRAZCA).toMatch(/^[a-z0-9-]+\.csv$/);
});
});
describe('образец файла с номерами — на экране', () => {
beforeEach(() => {
estimateMock.mockReset();
estimateMock.mockResolvedValue({
planned_count: 0,
estimated_cost_rub: '0.00',
min_count: 367,
enough: false,
ceny_za_tysyachu: { none: '672.00', image: '1008.00', video: '1142.40' },
});
fetchBazaMock.mockReset();
fetchBazaMock.mockResolvedValue({ count: 0 });
});
it('в карточке «Моя база» есть чем скачать образец', async () => {
const w = smontirovat();
await vybratBazu(w);
expect(w.find('[data-testid="tg-baza-obrazec"]').exists()).toBe(true);
});
it('пока база не выбрана — образца на экране нет', () => {
const w = smontirovat();
expect(w.find('[data-testid="tg-baza-obrazec"]').exists()).toBe(false);
});
});