fix(телеграм-реклама): заголовок объявления для рекламы сайта — кампания больше не встаёт

Приёмка глазами вскрыла: кабинет МТС требует «Заголовок объявления» (до 40 знаков),
когда в объявлении ссылка на САЙТ, а не на телеграм-канал. Робот про это поле не знал,
«Продолжить» молча не срабатывало, кампания вставала на шаге «Объявление» — в бою уже
ПОСЛЕ списания денег. Проверено живьём: 2234454 (сайт — встала) против 2234462 (канал —
дошла до подтверждения) и 2234490 (сайт с заголовком — дошла).

Портал спрашивает заголовок заранее, на создании черновика: обязателен только для
не-телеграмной ссылки (App\Support\TelegramLink), колонка ad_headline varchar(40),
поле на экране появляется по той же развилке. Робот заполняет его в кабинете.

Три ловушки, добытые живыми прогонами (описаны в коде):
- поле дорисовывается в ОТВЕТ на ссылку, с задержкой — надо ждать, а не спрашивать;
- под описание подходит несколько элементов — нужен .first();
- серая надпись внутри поля НЕ placeholder, а нарисованная подпись: поиск по атрибуту
  давал ноль совпадений при видимом на снимке поле. Опознаём по видимой надписи.

Тесты: робот 130/130, ClientTg 250/250, экран 23/23. Полный прогон бэкенда — те же
13 падающих классов до и после правки (ни одного в телеграм-части). В baseline
статанализа добавлен известный ложный класс Pest для нового файла тестов.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-01 11:41:31 +03:00
parent 24882ac16e
commit 32df332901
15 changed files with 425 additions and 19 deletions
@@ -15,6 +15,7 @@ use App\Services\ClientTg\TelegramAudienceService;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramTariffService;
use App\Support\PhoneNormalizer;
use App\Support\TelegramLink;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -73,6 +74,7 @@ class CampaignController extends Controller
'status' => Campaign::STATUS_DRAFT,
'ad_text' => $data['ad_text'],
'ad_link' => $data['ad_link'],
'ad_headline' => $data['ad_headline'] ?? null,
'ord_category' => $data['ord_category'] ?? 'Размещение рекламы',
'budget_cap_rub' => $data['budget_cap_rub'],
'audience_kind' => $data['audience_kind'],
@@ -325,9 +327,19 @@ class CampaignController extends Controller
*/
private function validatePayload(Request $request): array
{
// 🔴 Заголовок обязателен ТОЛЬКО для рекламы сайта: кабинет МТС рисует поле
// «Заголовок объявления» лишь тогда, а без него молча не пускает дальше шага
// «Объявление» — уже ПОСЛЕ списания денег. Для ссылки на канал/бота поля нет,
// и требовать заголовок было бы лишним вопросом клиенту. Проверено глазами
// 31.07.2026 (кампании 2234454 и 2234462).
$headlineRule = TelegramLink::isTelegram((string) $request->input('ad_link', ''))
? 'nullable|string|max:40'
: 'required|string|max:40'; // = колонка client_tg_campaigns.ad_headline varchar(40)
return $request->validate([
'ad_text' => 'required|string|max:1000',
'ad_link' => 'required|url|max:500', // = колонка client_tg_campaigns.ad_link varchar(500)
'ad_headline' => $headlineRule,
'audience_kind' => 'required|in:deals,base,list',
'audience_days' => 'required_if:audience_kind,deals|nullable|integer|min:1|max:365',
'budget_cap_rub' => 'required|numeric|min:1',
@@ -109,6 +109,9 @@ class RunTelegramCampaignJob implements ShouldQueue
payload: [
'adText' => $campaign->ad_text,
'buttonUrl' => $campaign->ad_link,
// Заголовок нужен кабинету только для рекламы сайта; у телеграм-
// ссылки он null, и робот его не трогает (поля на странице нет).
'adHeadline' => $campaign->ad_headline,
'budgetRub' => (string) $campaign->budget_cap_rub,
'ordCategory' => $campaign->ord_category,
'mediaFile' => $campaign->media_path,
@@ -137,6 +140,7 @@ class RunTelegramCampaignJob implements ShouldQueue
'phonesFile' => $phonesFile,
'adText' => $campaign->ad_text,
'buttonUrl' => $campaign->ad_link,
'adHeadline' => $campaign->ad_headline,
'budgetRub' => (string) $campaign->budget_cap_rub,
'ordCategory' => $campaign->ord_category,
'mediaFile' => $campaign->media_path,
+1
View File
@@ -106,6 +106,7 @@ class Campaign extends Model
'mts_campaign_id',
'ad_text',
'ad_link',
'ad_headline',
'media_path',
'moderator_file_path',
'ord_category',
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Телеграм ли это ссылка.
*
* 🔴 Нужно потому, что кабинет МТС ведёт себя по-разному: реклама телеграм-канала
* или бота идёт как есть, а реклама САЙТА требует дополнительно «Заголовок
* объявления» (до 40 знаков). Без заголовка кабинет молча не пускает дальше шага
* «Объявление» а деньги в бою к этому моменту уже списаны. Проверено глазами
* 31.07.2026 на кампаниях 2234454 (сайт встала) и 2234462 (канал дошла).
*/
final class TelegramLink
{
/** Домены телеграма, которые кабинет принимает как «канал/бот». */
private const HOSTS = ['t.me', 'telegram.me', 'telegram.dog'];
public static function isTelegram(string $url): bool
{
$url = trim($url);
// Схема tg: (tg://resolve?domain=...) — тоже телеграм, хоста в привычном виде нет.
if (str_starts_with(mb_strtolower($url), 'tg:')) {
return true;
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return false;
}
$host = preg_replace('/^www\./', '', mb_strtolower($host)) ?? '';
return in_array($host, self::HOSTS, true);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Заголовок объявления для рекламы САЙТА (находка приёмки глазами 31.07.2026).
*
* `ad_headline` «Название компании, сервиса, товара или услуги», до 40 знаков.
* Кабинет МТС показывает и ТРЕБУЕТ это поле, только когда в объявлении ссылка на
* сайт; для ссылки на телеграм-канал/бота поля на странице нет тогда NULL.
* Без заголовка кабинет молча не пускал дальше шага «Объявление», и кампания
* вставала уже ПОСЛЕ списания денег.
*
* RLS не меняется: колонка на существующей таблице client_tg_campaigns, политика
* tenant_isolation задана миграцией _create_client_tg_campaigns. В PostgreSQL
* табличный GRANT распространяется и на новые колонки ре-GRANT не нужен.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('client_tg_campaigns', function (Blueprint $table) {
$table->string('ad_headline', 40)->nullable()->after('ad_link');
});
}
public function down(): void
{
Schema::table('client_tg_campaigns', function (Blueprint $table) {
$table->dropColumn('ad_headline');
});
}
};
+30
View File
@@ -5795,3 +5795,33 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 7
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Offset ''adHeadline'' does not exist on string\.$#'
identifier: offsetAccess.notFound
count: 1
path: tests/Feature/ClientTg/AdHeadlineTest.php
+2
View File
@@ -54,6 +54,8 @@ export interface TelegramCreatePayload {
audience_days?: number;
ord_category?: string;
phones?: string[];
/** Заголовок объявления — кабинет МТС требует его для рекламы САЙТА (до 40 знаков). */
ad_headline?: string;
}
/** GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
@@ -28,6 +28,8 @@ const campaigns = ref<TelegramCampaign[]>([]);
const adText = ref('');
const adLink = ref('');
// Заголовок объявления — нужен ТОЛЬКО когда рекламируем сайт (см. nuzhenZagolovok).
const adHeadline = ref('');
const audienceKind = ref<TelegramAudienceKind>('deals');
const audienceDays = ref('30');
const phonesText = ref('');
@@ -73,10 +75,27 @@ const AUDIENCE_OPTIONS: { value: TelegramAudienceKind; label: string; testid: st
{ value: 'list', label: 'Свой список номеров', testid: 'tg-audience-list' },
];
/**
* Рекламируем сайт, а не телеграм-канал?
*
* 🔴 Кабинет МТС в этом случае требует «Заголовок объявления» (до 40 знаков).
* Без него кампания вставала на шаге «Объявление» — уже ПОСЛЕ списания денег
* (проверено глазами 31.07.2026). Поэтому спрашиваем заголовок здесь, заранее.
* Пустая ссылка заголовка не требует — человек ещё не дописал.
*/
const nuzhenZagolovok = computed(() => {
const link = adLink.value.trim().toLowerCase();
if (link === '') return false;
if (link.startsWith('tg:')) return false;
return !/^https?:\/\/(www\.)?(t\.me|telegram\.me|telegram\.dog)(\/|$)/.test(link);
});
/** Форма заполнена настолько, что можно запросить расчёт (смету/охват). */
const canCalc = computed(() => {
if (calculating.value) return false;
if (adText.value.trim() === '' || adLink.value.trim() === '' || budgetCap.value.trim() === '') return false;
// Реклама сайта без заголовка — кабинет не пустит, считать смету незачем.
if (nuzhenZagolovok.value && adHeadline.value.trim() === '') return false;
// «Свой список»: без номеров считать нечего.
if (audienceKind.value === 'list' && parsePhones().length === 0) return false;
return true;
@@ -110,6 +129,7 @@ function buildPayload(): TelegramCreatePayload {
ad_text: adText.value.trim(),
ad_link: adLink.value.trim(),
audience_kind: audienceKind.value,
...(nuzhenZagolovok.value ? { ad_headline: adHeadline.value.trim() } : {}),
budget_cap_rub: budgetCap.value,
ord_category: ordCategory.value.trim() || 'Размещение рекламы',
};
@@ -159,7 +179,7 @@ async function launch(): Promise<void> {
// Любая правка формы делает прежний расчёт недействительным — прячем смету и
// снова требуем «Рассчитать», чтобы запуск шёл строго по актуальным данным.
watch([adText, adLink, audienceKind, audienceDays, phonesText, budgetCap, ordCategory, mediaFile], () => {
watch([adText, adLink, adHeadline, audienceKind, audienceDays, phonesText, budgetCap, ordCategory, mediaFile], () => {
draft.value = null;
});
@@ -288,6 +308,21 @@ onUnmounted(() => {
variant="outlined"
/>
<v-text-field
v-if="nuzhenZagolovok"
v-model="adHeadline"
data-testid="tg-ad-headline"
label="Заголовок объявления"
placeholder="Название компании, услуги или товара"
hint="МТС требует заголовок, когда реклама ведёт на сайт. До 40 знаков."
persistent-hint
counter="40"
maxlength="40"
density="compact"
variant="outlined"
class="mb-2"
/>
<div class="text-subtitle-2 mt-2 mb-1">Кому показывать</div>
<v-radio-group v-model="audienceKind" density="compact">
<v-radio
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\RobotJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ClientTg\TelegramAudienceService;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* «Заголовок объявления» находка приёмки глазами 31.07.2026.
*
* 🔴 Кабинет МТС показывает ОБЯЗАТЕЛЬНОЕ поле «Заголовок объявления» (до 40 знаков),
* когда рекламируется САЙТ, а не телеграм-канал. Робот про него не знал: «Продолжить»
* не срабатывало, кампания вставала на шаге «Объявление» с невнятной причиной, а в
* бою деньги к этому моменту уже списаны. Проверено живьём на кампаниях 2234454
* (ссылка на сайт встала) и 2234462 (ссылка на канал дошла до подтверждения).
*
* Поэтому портал обязан спросить заголовок ЗАРАНЕЕ на создании черновика, до денег.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** @return array<string, mixed> */
function tgHeadlinePayload(array $overrides = []): array
{
return array_merge([
'ad_text' => 'Приходите к нам',
'ad_link' => 'https://t.me/example_channel',
'audience_kind' => 'list',
'budget_cap_rub' => '1000.00',
'phones' => ['79990000001', '79990000002'],
], $overrides);
}
it('реклама САЙТА без заголовка — не создаём кампанию', function () {
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
'ad_link' => 'https://liderra.ru',
]));
$response->assertStatus(422)->assertJsonValidationErrors('ad_headline');
expect(Campaign::count())->toBe(0);
});
it('реклама САЙТА с заголовком — кампания создаётся, заголовок сохранён', function () {
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
'ad_link' => 'https://liderra.ru',
'ad_headline' => 'Лидерра — заявки для бизнеса',
]));
$response->assertCreated();
expect(Campaign::first()->ad_headline)->toBe('Лидерра — заявки для бизнеса');
});
it('реклама телеграм-канала — заголовок не нужен', function () {
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload());
$response->assertCreated();
expect(Campaign::first()->ad_headline)->toBeNull();
});
it('заголовок длиннее 40 знаков не принимается — кабинет режет молча', function () {
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
'ad_link' => 'https://liderra.ru',
'ad_headline' => str_repeat('я', 41),
]));
$response->assertStatus(422)->assertJsonValidationErrors('ad_headline');
});
it('ссылка на бота t.me тоже считается телеграмом — заголовок не требуем', function () {
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
'ad_link' => 'https://t.me/liderra_bot?start=1',
]));
$response->assertCreated();
});
it('заголовок доезжает до задания робота', function () {
config(['client_tg.robot.transport' => 'poll', 'client_tg.sandbox' => true]);
Queue::fake();
$this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
'ad_link' => 'https://liderra.ru',
'ad_headline' => 'Лидерра',
]))->assertCreated();
$campaign = Campaign::first();
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertOk();
// Джоб в очереди подделан — выполняем его тело руками, как это делают
// соседние тесты канала: важно, что в задании робота есть заголовок.
(new RunTelegramCampaignJob($campaign->id, $this->tenant->id))->handle(
app(TelegramAudienceService::class),
app(TelegramRobotRunner::class),
);
expect(RobotJob::first()->payload['adHeadline'])->toBe('Лидерра');
});
@@ -477,3 +477,64 @@ describe('AdvertisingTelegramView — экран клиентской Telegram-
expect(wrapper.find('[data-testid="tg-error"]').text()).toContain('Не хватает денег');
});
});
// Находка приёмки глазами 31.07.2026: кабинет МТС требует «Заголовок объявления»
// (до 40 знаков), когда рекламируется САЙТ. Без него кампания вставала уже ПОСЛЕ
// списания денег. Значит, спросить заголовок надо здесь — до запуска.
describe('AdvertisingTelegramView — заголовок для рекламы сайта', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('со ссылкой на сайт показывает поле заголовка', async () => {
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://liderra.ru');
await flushPromises();
expect(wrapper.find('[data-testid="tg-ad-headline"]').exists()).toBe(true);
});
it('со ссылкой на телеграм-канал поле заголовка не показывает', async () => {
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://t.me/example');
await flushPromises();
expect(wrapper.find('[data-testid="tg-ad-headline"]').exists()).toBe(false);
});
it('со ссылкой на сайт без заголовка не даёт считать смету', async () => {
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Приходите к нам');
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://liderra.ru');
await wrapper.find('[data-testid="tg-audience-base"] input').setValue();
await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000');
await flushPromises();
expect(wrapper.find('[data-testid="tg-calc"]').classes()).toContain('v-btn--disabled');
});
it('заголовок уезжает на сервер вместе с кампанией', async () => {
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Приходите к нам');
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://liderra.ru');
await wrapper.find('[data-testid="tg-ad-headline"] input').setValue('Лидерра');
await wrapper.find('[data-testid="tg-audience-base"] input').setValue();
await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000');
await flushPromises();
await wrapper.find('[data-testid="tg-calc"]').trigger('click');
await flushPromises();
expect(createTelegramMock).toHaveBeenCalledWith(
expect.objectContaining({ ad_headline: 'Лидерра' }),
);
});
});
+44
View File
@@ -64,6 +64,18 @@ export function parseModerationStatus(text) {
// «Продолжить» остаётся неактивной. Подтверждено картой FLOW-FINDINGS.md.
export const MIN_NON_MTS = 367;
// 🔴 Приёмка глазами 31.07.2026: когда рекламируется САЙТ (а не телеграм-канал),
// кабинет показывает ОБЯЗАТЕЛЬНОЕ поле «Заголовок объявления» (до 40 знаков) и
// «Текст на кнопке». Робот их не знал: «Продолжить» жалось, но страница молча
// оставалась на шаге «Объявление», а наверх уходило невнятное «не перешли на
// /budget». Падать надо СРАЗУ и своими словами — иначе причина не видна.
export function assertHeadlineWhenRequired(fieldPresent, headline) {
if (!fieldPresent) return;
if (typeof headline !== 'string' || headline.trim() === '') {
throw new Error('Кабинет требует заголовок объявления (реклама сайта), а он не задан');
}
}
// 🔴 Ревью-фикс F6: кнопки, которые ТРАТЯТ живые деньги клиента / запускают
// кампанию — робот НЕ жмёт их НИКОГДА (ни в draft, ни в live). Реальная отправка/
// оплата на шаге /payment («Списать с баланса…») wire-ится в Сессии 6 под флаг.
@@ -427,6 +439,8 @@ export async function fillAd(page, config, task) {
await fillAdText(page, config, task.adText);
if (task.mediaFile) await fillAdMedia(page, config, task.mediaFile);
await fillAdLink(page, config, task.buttonUrl);
// Заголовок — ПОСЛЕ ссылки: поле появляется в ответ на ссылку на сайт.
await fillAdHeadline(page, config, task.adHeadline);
await selectOrdCategory(page, config, task.ordCategory);
// Документ модератору при ПЕРЕСДАЧЕ (задача 3.6): без файла — шаг не трогаем.
if (task.moderatorFile) await attachModeratorDoc(page, config, task.moderatorFile);
@@ -447,6 +461,36 @@ export async function fillAdMedia(page, config, mediaFile) {
await humanPause(config);
}
// Заголовок объявления — поле «Название компании, сервиса, товара или услуги»
// (до 40 знаков). Кабинет рисует его ТОЛЬКО для рекламы сайта, поэтому наличие
// поля СПРАШИВАЕМ У СТРАНИЦЫ, а не угадываем по виду ссылки: правило «сайт vs
// канал» — их, а не наше, и может поменяться без предупреждения.
export async function fillAdHeadline(page, config, headline) {
// 🪤 Ловушка, стоившая четырёх живых прогонов 31.07.2026: серая надпись
// «Название компании, сервиса, товара или услуги» внутри поля — это НЕ атрибут
// placeholder, а нарисованная подпись. Поиск `input[placeholder^="Название
// компании"]` находил НОЛЬ совпадений, при том что поле было на экране (видно
// на снимке), и робот молча его пропускал. Опознаём поле так же, как человек:
// «первый ввод после видимой надписи "Заголовок объявления"» — проверено
// замером: 1 совпадение, текст доезжает.
// Кабинет дорисовывает блок в ответ на ссылку с задержкой — поэтому ЖДЁМ.
const pole = page
.locator('xpath=//*[contains(normalize-space(text()),"Заголовок объявления")]/following::input[1]')
.first();
const estPole = await pole
.waitFor({ state: 'visible', timeout: 8000 })
.then(() => true)
.catch(() => false);
assertHeadlineWhenRequired(estPole, headline);
if (!estPole) return;
// Кабинет режет по 40 знаков молча — режем сами, чтобы отправленное совпадало
// с тем, что видит клиент в портале.
await pole.fill(headline.trim().slice(0, 40));
await humanPause(config);
}
// Ссылка «Что рекламируете» (канал/бот/сайт).
export async function fillAdLink(page, config, url) {
await page.locator('input[placeholder^="Ссылка на канал"]').fill(url);
+9 -4
View File
@@ -57,10 +57,15 @@ export function parseTask(raw) {
moderatorFile: raw.moderatorFile ?? null,
clientTag: raw.clientTag ?? null,
mode,
// Шаг «Объявление» (FLOW-FINDINGS.md §6): из обязательного — только категория
// ОРД (дефолт «Размещение рекламы», подтверждён живьём). Полей «Заголовок» и
// «Название компании» в кабинете НЕТ — рекламодатель (наш ИП) и маркировка
// подставляются автоматически, поэтому adTitle/ordCompanyName убраны.
// Шаг «Объявление» (FLOW-FINDINGS.md §6): из обязательного — категория ОРД
// (дефолт «Размещение рекламы», подтверждён живьём). Поля «Название компании»
// в кабинете нет — рекламодатель (наш ИП) подставляется автоматически.
ordCategory: raw.ordCategory ?? 'Размещение рекламы',
// 🔴 Проверено глазами 31.07.2026: «Заголовок объявления» (до 40 знаков)
// кабинет показывает и ТРЕБУЕТ, только когда рекламируется САЙТ. Для ссылки
// на телеграм-канал/бота этого поля на странице нет. Раньше здесь стояло
// «поля Заголовок нет» — это было верно лишь для канала.
// Соседнее «Текст на кнопке» не задаём: кабинет сам ставит «Перейти».
adHeadline: raw.adHeadline ?? null,
};
}
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isForbiddenButtonText, assertCostWithinCap, hrefMatchesCampaignId, isNoPaymentSubmitText } from '../src/cabinet.js';
import { isForbiddenButtonText, assertCostWithinCap, hrefMatchesCampaignId, isNoPaymentSubmitText, assertHeadlineWhenRequired } from '../src/cabinet.js';
// A-фикс: локатор строки кампании в СПИСКЕ. Живой прогон A1 показал: список
// использует href `/cabinet/campaigns/telegram/{id}`, а детальная/визард-страница —
@@ -101,3 +101,23 @@ test('без-оплаты не запрещена, «Списать…» зап
assert.equal(isForbiddenButtonText('Отправить на модерацию без оплаты'), false);
assert.equal(isForbiddenButtonText('Списать с баланса и отправить на модерацию'), true);
});
// 31.07.2026 (приёмка глазами): со ссылкой на САЙТ кабинет показывает обязательное
// поле «Заголовок объявления». Робот его не заполнял — «Продолжить» молча не
// срабатывало, и кампания вставала с непонятным «не перешли на /budget».
// Правило: есть поле на странице, а заголовка не дали — падаем ВНЯТНО и сразу.
test('поле заголовка есть и заголовок задан — не бросает', () => {
assert.doesNotThrow(() => assertHeadlineWhenRequired(true, 'Лидерра'));
});
test('поле заголовка есть, а заголовка нет — бросает про заголовок', () => {
assert.throws(() => assertHeadlineWhenRequired(true, null), /заголов/i);
});
test('поле заголовка есть, заголовок из пробелов — бросает', () => {
assert.throws(() => assertHeadlineWhenRequired(true, ' '), /заголов/i);
});
test('поля заголовка нет (рекламируем канал) — не бросает и без заголовка', () => {
assert.doesNotThrow(() => assertHeadlineWhenRequired(false, null));
});
+13
View File
@@ -58,3 +58,16 @@ test('parseTask: без moderatorFile → null (документ не прило
const t = parseTask(base);
assert.equal(t.moderatorFile, null);
});
// 31.07.2026, приёмка живьём: кабинет МТС показывает ОБЯЗАТЕЛЬНОЕ поле «Заголовок
// объявления» (до 40 знаков) и «Текст на кнопке», когда рекламируется САЙТ, а не
// телеграм-канал. Со ссылкой на сайт кампания вставала на шаге «Объявление».
test('parseTask пробрасывает adHeadline', () => {
const t = parseTask({ ...base, adHeadline: 'Лидерра' });
assert.equal(t.adHeadline, 'Лидерра');
});
test('parseTask: без заголовка → null (телеграм-ссылке он не нужен)', () => {
const t = parseTask(base);
assert.equal(t.adHeadline, null);
});
+7 -13
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-07-31T07:10:49.300Z
Last updated: 2026-08-01T08:41:49.271Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -15,7 +15,7 @@ Last updated: 2026-07-31T07:10:49.300Z
⚠️ **ПОСТ ПУСТОЙ** — не зарегистрированы: enforce-floor.mjs, enforce-supreme-gate.mjs, enforce-normative-content-rules.mjs, enforce-read-path-deny.mjs, enforce-mcp-classification.mjs, enforce-judge-gate.mjs, enforce-snapshot.mjs, enforce-floor-escape-consume.mjs, enforce-skill-journaler.mjs, enforce-verify-gate.mjs, enforce-criterion-gate.mjs, enforce-coverage-verify.mjs, enforce-todowrite-skill-verifier.mjs (оборона НЕ подтверждена; SE-B/Δ8)
Судья М4: **live-block** (inert $0 / shadow / floor-only / live-block)
Судья М4: **inert** (inert $0 / shadow / floor-only / live-block)
| Машина / страж | Хук | Зарегистрирован |
|---|---|---|
@@ -39,7 +39,7 @@ Last updated: 2026-07-31T07:10:49.300Z
- Observer evidence: 0 episodes this month, 0 observer_error markers, 0 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 0
- Last /brain-retro: 65 day(s) ago
- Last /brain-retro: 66 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Метрики дисциплины
@@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3544 | MsMpEng | 16.48ч | NaNч |
| 23936 | Code | 4.69ч | 0.0ч |
| 4 | System | 2.49ч | 0.0ч |
| 3544 | MsMpEng | 27.74ч | 0.0ч |
| 23936 | Code | 9.91ч | 221364.7ч |
| 4 | System | 4.07ч | NaNч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
@@ -130,13 +130,7 @@ Episodes since last run: 542 / threshold: 10
## Целостность журналов действий
🔴 Битые цепочки (3 из 120):
| session | broken at seq |
|---|---|
| `03437265-6d58-4622-aeed-c0eeac0f2c32` | 1 |
| `54594686-843c-4ea8-bcd3-5ae6a7244e30` | 14 |
| `9c02276d-dabb-40e4-9c04-44c18d47485a` | 14 |
Ключ подписанта не provisioned — проверка цепи недоступна (ключ — owner-шаг A3).
## Алерт-индикаторы