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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user