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:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
-
|
||||
|
||||
@@ -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)} ₽. Без картинки дешевле, но рекламу заметят хуже.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user