fix(реклама): счётчик аудитории после загрузки списка и склейка одинаковых слов Яндекса

Две мелочи из приёмки 03.08.2026, обе со сторожами, принятыми красным.

1. Счётчик аудитории врал после загрузки списка номеров.
   Загрузили 1693 номера — под кнопкой оставалось «Наберётся ~15 человек»
   и красное «аудитория меньше 100». Правда появлялась, только если тронуть
   любое другое поле. Клиент делает единственный разумный вывод — список
   не принялся — и грузит его снова.
   Починка: после удачной загрузки счётчик пересчитывается сам.
   Сторож держит не «позвали ли пересчёт», а то, что видит клиент на экране.

2. Лента кампании показывала стену из одинакового текста.
   Яндекс отвечает по каждому объявлению отдельно, и на общую беду
   «предоставьте документы» приходит пятнадцать одинаковых слов сразу.
   Замер на кампании #6: 15 сообщений, разных из них всего два — второе
   терялось в стене.
   Починка только на показе: одинаковый текст подряд склеивается в одну
   строку с подписью «и ещё N объявлений». Запись по объявлениям не тронута —
   по ней разбирают, кого именно не пустили.
   Второй сторож держит границу склейки: сообщения с приложенным файлом
   не склеиваются никогда, иначе ссылка на документ исчезнет с экрана.

Проверено: 2045 сторожей фронта зелёные, типы Vue чисты, стиль в изменённых
файлах чист. Бэкенд и схема не тронуты.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-03 23:40:12 +03:00
parent 75883700c0
commit 1908f84108
4 changed files with 176 additions and 2 deletions
@@ -3,7 +3,7 @@
* CampaignMessages — окно передачи между Яндексом и клиентом по одной кампании.
* Слова Яндекса показываем как есть, без своей трактовки.
*/
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { fetchCampaignMessages, sendCampaignMessage, type CampaignMessage } from '../../api/advertising';
const props = defineProps<{ campaignId: number | null }>();
@@ -55,6 +55,66 @@ async function send(): Promise<void> {
}
}
/**
* Одинаковый текст подряд — одна строка на экране.
*
* Яндекс отвечает по каждому объявлению отдельно, и на общую беду («предоставьте
* документы») приходит пятнадцать одинаковых слов сразу. Записаны они по объявлениям
* не зря — по записи разбирают, кого именно не пустили, — но клиенту показывать стену
* из одинакового текста нельзя: в ней теряется второе, РАЗНОЕ сообщение.
* Замер 03.08.2026 на кампании #6: 15 сообщений, из них разных всего два.
*
* 🪤 Склеиваем только соседей: разнести одинаковые ответы, между которыми клиент писал
* своё, значило бы переставить переписку местами. И никогда не склеиваем сообщения
* с приложенным файлом — иначе ссылка на документ исчезнет с экрана.
*/
type SklejennoeSoobshchenie = {
id: number;
author: CampaignMessage['author'];
body: string;
file_name: string | null;
povtory: number;
};
const sklejennye = computed<SklejennoeSoobshchenie[]>(() => {
const rows: SklejennoeSoobshchenie[] = [];
for (const m of messages.value) {
const last = rows[rows.length - 1];
const povtor =
last !== undefined &&
last.file_name === null &&
(m.file_name ?? null) === null &&
last.author === m.author &&
last.body === m.body;
if (povtor && last !== undefined) {
last.povtory += 1;
continue;
}
rows.push({ id: m.id, author: m.author, body: m.body, file_name: m.file_name ?? null, povtory: 0 });
}
return rows;
});
/** «и ещё 2 объявления» — с правильным русским окончанием. */
function povtoryuSlovami(n: number): string {
const posledn = n % 10;
const desyatki = n % 100;
const slovo =
desyatki >= 11 && desyatki <= 14
? 'объявлений'
: posledn === 1
? 'объявление'
: posledn >= 2 && posledn <= 4
? 'объявления'
: 'объявлений';
return `и ещё ${n} ${slovo}`;
}
watch(() => props.campaignId, reload, { immediate: true });
</script>
@@ -65,13 +125,20 @@ watch(() => props.campaignId, reload, { immediate: true });
</p>
<v-list v-else density="compact" class="pa-0">
<v-list-item v-for="m in messages" :key="m.id" class="px-0" data-testid="campaign-message">
<v-list-item v-for="m in sklejennye" :key="m.id" class="px-0" data-testid="campaign-message">
<v-list-item-title class="text-body-2 font-weight-medium">
{{ AUTHOR_LABEL[m.author] }}
</v-list-item-title>
<v-list-item-subtitle class="text-body-2" style="white-space: pre-wrap">
{{ m.body }}
</v-list-item-subtitle>
<v-list-item-subtitle
v-if="m.povtory > 0"
class="text-body-2 text-medium-emphasis"
data-testid="campaign-message-povtory"
>
{{ povtoryuSlovami(m.povtory) }}
</v-list-item-subtitle>
<template v-if="m.file_name">
<a
:href="`/api/advertising/campaigns/${props.campaignId}/messages/${m.id}/file`"
@@ -359,6 +359,10 @@ async function uploadPhones(): Promise<void> {
file: phonesFile.value,
text: phonesText.value,
});
// Список меняет размер аудитории — пересчитываем сразу, не дожидаясь, пока клиент
// тронет что-нибудь ещё. Приёмка 03.08.2026: загрузили 1693 номера, а под кнопкой
// осталось «Наберётся ~15 человек» — клиент решает, что список не принялся.
await refreshAudienceSize();
} catch (e) {
phonesUploadError.value = extractErrorMessage(e, 'Не удалось загрузить список номеров.');
} finally {
@@ -72,6 +72,63 @@ describe('CampaignMessages.vue — переписка по кампании', ()
expect(w.text()).toContain('Не получилось загрузить переписку');
});
/**
* Приёмка на бою 03.08.2026: у кампании #6 одно и то же слово Яндекса
* «Для показа в заданных регионах предоставьте документы» лежало в ленте 13 раз —
* по строке на каждое объявление. Клиент видит стену одинакового текста и не может
* найти в ней второе, РАЗНОЕ сообщение.
*
* Записывать по объявлению правильно — по этой записи разбирают, кого именно не пустили.
* Ошибка в показе: одинаковый текст подряд надо склеивать в одну строку и говорить,
* скольких объявлений он касается.
*/
it('одинаковое слово Яндекса по многим объявлениям склеивается в одну строку', async () => {
const documents = 'Для показа в заданных регионах предоставьте документы';
fetchMessagesMock.mockResolvedValue([
{ id: 1, author: 'yandex', banner_id: 101, body: documents, file_name: null, file_size: null, created_at: null },
{ id: 2, author: 'yandex', banner_id: 102, body: documents, file_name: null, file_size: null, created_at: null },
{ id: 3, author: 'yandex', banner_id: 103, body: documents, file_name: null, file_size: null, created_at: null },
{ id: 4, author: 'yandex', banner_id: 104, body: 'Нет предупреждения: финансовые услуги', file_name: null, file_size: null, created_at: null },
]);
const w = mount(CampaignMessages, {
props: { campaignId: 13 },
global: { plugins: [vuetify] },
});
await flush();
await w.vm.$nextTick();
const items = w.findAll('[data-testid="campaign-message"]');
expect(items).toHaveLength(2);
expect(items[0].text()).toContain(documents);
expect(items[0].text()).toContain('ещё 2 объявлени');
// Второе, РАЗНОЕ сообщение не должно потеряться в склейке.
expect(items[1].text()).toContain('Нет предупреждения');
expect(items[1].text()).not.toContain('ещё');
});
/**
* Склейка не должна съесть приложенный файл: у сообщения с документом всегда своя строка,
* иначе ссылка на него исчезнет с экрана.
*/
it('сообщения с приложенным файлом не склеиваются, даже если текст совпал', async () => {
fetchMessagesMock.mockResolvedValue([
{ id: 1, author: 'client', banner_id: null, body: 'Приложил документ', file_name: 'licenziya.pdf', file_size: 100, created_at: null },
{ id: 2, author: 'client', banner_id: null, body: 'Приложил документ', file_name: 'ustav.pdf', file_size: 100, created_at: null },
]);
const w = mount(CampaignMessages, {
props: { campaignId: 13 },
global: { plugins: [vuetify] },
});
await flush();
await w.vm.$nextTick();
expect(w.findAll('[data-testid="campaign-message"]')).toHaveLength(2);
const links = w.findAll('[data-testid="campaign-message-file"]');
expect(links.map((l) => l.text())).toEqual(['licenziya.pdf', 'ustav.pdf']);
});
it('отправляет ответ и перечитывает ленту', async () => {
fetchMessagesMock.mockResolvedValue([]);
sendMessageMock.mockResolvedValue(undefined);
@@ -401,6 +401,52 @@ describe('CampaignWizard.vue (T18 — загрузка «моего списка
expect(error.text()).toContain('Не удалось разобрать файл.');
});
/**
* Приёмка на бою 03.08.2026: загрузили 1693 номера, а под кнопкой по-прежнему висело
* «Наберётся ~15 человек» и красное «аудитория меньше 100». Правда появлялась, только
* если тронуть любое другое поле. Клиент делает единственный разумный вывод — список
* не принялся — и грузит его снова.
*
* Сторож держит не «позвали ли пересчёт», а то, ЧТО клиент видит на экране: после
* удачной загрузки там обязано стоять новое число.
*/
it('после удачной загрузки списка счётчик аудитории пересчитывается сам', async () => {
uploadCampaignPhonesMock.mockResolvedValue({ recognized: 1693, skipped: 0 });
fetchAudienceSizeMock.mockResolvedValue({ size: 15, min: 100, enough: false, hint: 'Маловато для запуска' });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
const form = (w.vm as unknown as { form: { mode: string; snapshot_from: string; snapshot_to: string } }).form;
form.mode = 'manual';
// Без обеих дат счётчик в ручном режиме вообще не считается — как на бою, ставим период.
form.snapshot_from = '2026-01-01';
form.snapshot_to = '2026-08-02';
await w.vm.$nextTick();
const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input');
await uploadSwitchInput.setValue(true);
await w.vm.$nextTick();
// Даём отработать отложенному пересчёту от смены режима (задержка ~400 мс), иначе он
// догонит нас уже после загрузки и сторож позеленеет по чужой причине.
await new Promise((r) => setTimeout(r, 500));
await w.vm.$nextTick();
expect(w.find('[data-testid="audience-size-result"]').text()).toContain('~15');
// Список ушёл на сервер — теперь тот же запрос вернёт настоящий размер аудитории.
fetchAudienceSizeMock.mockResolvedValue({ size: 1701, min: 100, enough: true, hint: null });
(w.vm as unknown as { phonesText: string }).phonesText = '79267654321';
await w.vm.$nextTick();
await w.find('[data-testid="phones-upload-btn"]').trigger('click');
await flush();
await w.vm.$nextTick();
expect(w.find('[data-testid="audience-size-result"]').text()).toContain('~1701');
});
it('кнопка «Загрузить список» выключена, пока и файл, и текст пустые', async () => {
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();