diff --git a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php index d83e6cf2..6863fa70 100644 --- a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php +++ b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php @@ -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(), ]); } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 72a44ff3..04150d75 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -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 - diff --git a/app/resources/js/api/telegram.ts b/app/resources/js/api/telegram.ts index 27dc3fc1..99d10bb7 100644 --- a/app/resources/js/api/telegram.ts +++ b/app/resources/js/api/telegram.ts @@ -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; } /** Что спрашиваем у оценки — только про аудиторию, объявление на охват не влияет. */ diff --git a/app/resources/js/composables/podskazkiReklamy.ts b/app/resources/js/composables/podskazkiReklamy.ts index 37adafac..9fde3b99 100644 --- a/app/resources/js/composables/podskazkiReklamy.ts +++ b/app/resources/js/composables/podskazkiReklamy.ts @@ -11,6 +11,8 @@ * Показывает их компонент HelpHint (components/sales/HelpHint.vue): */ +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; + +/** «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)} ₽. Без картинки дешевле, но рекламу заметят хуже.` + ); +} diff --git a/app/resources/js/plugins/vuetify.ts b/app/resources/js/plugins/vuetify.ts index 4e9c5e3f..dd001965 100644 --- a/app/resources/js/plugins/vuetify.ts +++ b/app/resources/js/plugins/vuetify.ts @@ -221,6 +221,9 @@ export const lucideMap: Record = { '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, diff --git a/app/resources/js/views/advertising/TelegramCampaignWizard.vue b/app/resources/js/views/advertising/TelegramCampaignWizard.vue index 6ca52f26..b9edfd1d 100644 --- a/app/resources/js/views/advertising/TelegramCampaignWizard.vue +++ b/app/resources/js/views/advertising/TelegramCampaignWizard.vue @@ -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(null); +/** Цены за тысячу показов — как их назвал сервер. Экран своих чисел не хранит. */ +const ceny = ref(null); const ocenkaIdet = ref(false); const ocenkaOshibka = ref(''); @@ -210,6 +213,12 @@ async function obnovitOhvat(): Promise { 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 }); />
+ + + Скачать образец + diff --git a/app/resources/js/views/advertising/obrazecBazy.ts b/app/resources/js/views/advertising/obrazecBazy.ts new file mode 100644 index 00000000..3a64230f --- /dev/null +++ b/app/resources/js/views/advertising/obrazecBazy.ts @@ -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); +} diff --git a/app/tests/Feature/ClientTg/EstimateApiTest.php b/app/tests/Feature/ClientTg/EstimateApiTest.php index ec54a635..a7b56f0a 100644 --- a/app/tests/Feature/ClientTg/EstimateApiTest.php +++ b/app/tests/Feature/ClientTg/EstimateApiTest.php @@ -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'); +}); diff --git a/app/tests/Frontend/podskazki-na-ekranah.spec.ts b/app/tests/Frontend/podskazki-na-ekranah.spec.ts index 37ab902d..459f8a86 100644 --- a/app/tests/Frontend/podskazki-na-ekranah.spec.ts +++ b/app/tests/Frontend/podskazki-na-ekranah.spec.ts @@ -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 () => { diff --git a/app/tests/Frontend/podskazki-reklamy.spec.ts b/app/tests/Frontend/podskazki-reklamy.spec.ts index 8cf30531..58ece8af 100644 --- a/app/tests/Frontend/podskazki-reklamy.spec.ts +++ b/app/tests/Frontend/podskazki-reklamy.spec.ts @@ -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]/); + }); +}); diff --git a/app/tests/Frontend/telegram-media-cena.spec.ts b/app/tests/Frontend/telegram-media-cena.spec.ts index f0ca4f45..e548fc9a 100644 --- a/app/tests/Frontend/telegram-media-cena.spec.ts +++ b/app/tests/Frontend/telegram-media-cena.spec.ts @@ -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 }).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/); + }); +}); diff --git a/app/tests/Frontend/telegram-obrazec-bazy.spec.ts b/app/tests/Frontend/telegram-obrazec-bazy.spec.ts new file mode 100644 index 00000000..a30c571c --- /dev/null +++ b/app/tests/Frontend/telegram-obrazec-bazy.spec.ts @@ -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; + +function smontirovat(): Wrapper { + return mount(TelegramCampaignWizard, { + props: { balanceRub: '5000.00' }, + global: { plugins: [vuetify] }, + }); +} + +async function vybratBazu(w: Wrapper): Promise { + 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); + }); +});