ночь: smoke-longrun проведён через сверку настроек и денежные ворота (дверь одна с run, не копия) + датчик класса ловит любую платную команду без ворот

This commit is contained in:
Дмитрий
2026-08-03 12:49:36 +03:00
parent 9bdffb9cda
commit 33c8e0f9e4
2 changed files with 317 additions and 31 deletions
+99 -28
View File
@@ -124,6 +124,58 @@ export function proverkaNastroekVladeltsa({
return sverka(chitatNastoyashchiyFayl() ?? {});
}
// ━━━ 🔴🔴 ВОРОТА ДЕНЕГ — ОДНА ДВЕРЬ ДЛЯ ВСЕХ, КТО ЗАВОДИТ ЖИВОГО ПЛАТНОГО РАБОТНИКА
// (задача О-4, 03.08.2026, находка третьего помощника со стороны).
//
// ДО ЭТОЙ ПРАВКИ разбор ответа замера — что считать отказом, что успехом, какими словами
// это сказать владельцу — лежал ГОТОВЫМ КУСКОМ КОДА ТОЛЬКО внутри `run`. У `smoke-longrun`
// (проба «работник живёт и через три часа», того же класса трат, что и обычный прогон) такой
// двери не было вовсе: единственная команда затеи БЕЗ денежных ворот оказалась ровно той,
// которой собирались делать первое живое рождение работника. Завести `smoke-longrun` своей
// копией этого куска значило бы построить ВТОРОЕ правило одного смысла — а два правила
// одного смысла расходятся молча, тем же классом беды, что уже нашёлся у `readRuns`
// и у замка на файлы (см. заметки в шапке файла, «две копии одного правила»). Поэтому кусок
// вынесен СЮДА, и `run` теперь тоже зовёт его вместо своей прежней копии — дверь одна
// на обоих зовущих, а не по одной на каждого.
//
// 🪤 `otkaz` — только слова владельцу («Прогон НЕ запущен» / «Проба НЕ запущена»): само
// правило (что считать отказом, что — успехом) от этих слов не зависит и не разветвляется.
// 🪤 Запись успешного замера в общий журнал (`recordMeasure`) СЮДА НЕ ВХОДИТ намеренно:
// у каждого зовущего свой `phase` в журнале («before-run» / «before-smoke» / …), и решать
// за зовущего, под каким именем его прирост ляжет в общую историю, эта дверь не должна.
// 🪤 РЕШЕНИЕ Р2 (наследуется от прежнего места в `run`, не переизобретается заново): причина
// неудачи называется ТА САМАЯ, что пришла от прибора (`z.problem`), а не одна общая строка
// на все случаи — здесь уже однажды платили десятью днями работы за придуманную причину.
export async function vorotaDeneg({
cfg, komanda, otkaz = 'Прогон НЕ запущен',
zamer = zamerSoSchyotom,
attemptsPath = probeAttemptsPath(),
err = console.error,
} = {}) {
let z = null;
try {
z = await zamer({ cfg, attemptsPath, komanda });
} catch (e) {
err(`${otkaz}: замер остатка не сделан. ${e?.message ?? e}`);
err('Это настройка в вашем night.config.json. Пример строки есть в night.config.example.json.');
return { ok: false };
}
if (!z.ok) {
err(`${otkaz}: замеров остатка за смену больше не осталось.`);
err(z.reason);
return { ok: false };
}
const m = z.measure;
if (!m) {
err(`${otkaz}: остаток запаса посчитать не удалось.`);
err(` · ${z.problem ?? 'Причина не названа.'}`);
err('Решать о деньгах вслепую нельзя, поэтому запуск не начинается вовсе. Прошлые замеры в журнале запуск не разрешают: они могли устареть за один день.');
err('Что делать: повторить `npm run night:budget` и посмотреть, отвечает ли прибор.');
return { ok: false };
}
return { ok: true, measure: m };
}
// 🔴 Место мерится по каталогу ХОЗЯЙСТВА, а не по текущему каталогу: у задачи планировщика
// рабочий каталог системный, и она мерила бы не тот диск.
async function diskNow(cfg = {}) {
@@ -598,6 +650,10 @@ export async function main(argv = process.argv, deps = {}) {
// 🔴 Ворота сверки настроек с решениями владельца (задача О-3) — умолчание НАСТОЯЩЕЕ,
// подставка подаётся явно и только в проверках.
proverkaVladeltsa = proverkaNastroekVladeltsa,
// 🔴 Денежные ворота (задача О-4) — та же дверь у `run` и у `smoke-longrun`. Умолчание
// НАСТОЯЩЕЕ (`vorotaDeneg`, платный зов); подставка подаётся явно и только в проверках —
// иначе проверить дорогу нечем без живого платного замера.
dengiNaZapusk = vorotaDeneg,
} = deps;
const cmd = argv[2];
@@ -883,37 +939,17 @@ export async function main(argv = process.argv, deps = {}) {
// Никакой «ещё не протухшей» строки из журнала: между тем замером и этой минутой могла
// пройти дневная работа и съесть весь запас, а решать о деньгах вслепую нельзя (проверка 17).
//
// 🔴 Обёртка обязательна: нет настройки `probe_model` — ворота падают понятной
// строкой с именем настройки, и её надо ПОКАЗАТЬ, а не уронить программу со следом стека.
//
// 🪤 Замер тут делается ДО ворот прогона, значит КАЖДЫЙ отказ ворот тоже стоит замера.
// Десяток попыток «поправил — перезапустил» — это десяток замеров, и ровно от такого
// расхода счёт за смену и защищает.
let z = null;
try {
z = await zamerSoSchyotom({ cfg, attemptsPath: probeAttemptsPath(), komanda: 'run' });
} catch (err) {
console.error(`Прогон НЕ запущен: замер остатка не сделан. ${err?.message ?? err}`);
console.error('Это настройка в вашем night.config.json. Пример строки есть в night.config.example.json.');
return 1;
}
if (!z.ok) {
console.error('Прогон НЕ запущен: замеров остатка за смену больше не осталось.');
console.error(z.reason);
return 1;
}
const m = z.measure;
if (!m) {
// 🔴🔴 РЕШЕНИЕ Р2. Здесь стояла строка «события "rate_limit_event" от системы не пришло»,
// и она была ВРАНЬЁМ: событие приходило исправно каждый раз, не совпадал ВИД предела.
// Владелец десять дней читал про поломку прибора, а поправить надо было одну строку
// в своих настройках. Теперь причина называется та самая, какая есть.
console.error('Прогон НЕ запущен: остаток запаса посчитать не удалось.');
console.error(` · ${z.problem ?? 'Причина не названа.'}`);
console.error('Решать о деньгах вслепую нельзя, поэтому прогон не начинается вовсе. Прошлые замеры в журнале запуск не разрешают: они могли устареть за один день.');
console.error('Что делать: повторить `npm run night:budget` и посмотреть, отвечает ли прибор.');
return 1;
}
//
// 🔴 РАЗБОР ОТВЕТА — ЧЕРЕЗ ОБЩУЮ ДВЕРЬ `vorotaDeneg` (задача О-4, 03.08.2026), НЕ СВОИМ
// КУСКОМ КОДА. Раньше здесь стоял готовый код (try/catch, `z.ok`, `z.measure`) — и когда
// `smoke-longrun` понадобилась та же дверь, копия вниз завела бы ВТОРОЕ правило одного
// смысла. Дверь вынесена выше по файлу, `run` и `smoke-longrun` зовут ОДНУ и ту же.
const dengi = await dengiNaZapusk({ cfg, komanda: 'run' });
if (!dengi.ok) return 1;
const m = dengi.measure;
recordMeasure(budgetMeasuresPath(), {
utilization: m.utilization, status: m.status, kind: m.kind,
resets_at: m.resets_at, at: Date.now(), phase: 'before-run',
@@ -1028,6 +1064,41 @@ export async function main(argv = process.argv, deps = {}) {
}
if (cmd === 'smoke-longrun') {
// ━━━ 🔴🔴 ВОРОТА, НАЙДЕННЫЕ ПРОПУЩЕННЫМИ 03.08.2026 (задача О-4, находка третьего
// помощника со стороны). Проба заводит ТОГО ЖЕ платного работника, что и `run`, а ворот
// у неё не было НИ ОДНИХ: сверка настроек и денежные ворота стояли только в куске `run`,
// ДО развилки на `--resume`, — а `smoke-longrun` сравнивает `cmd` ЗАНОВО, отдельной веткой,
// и внутрь ветки `run` не заходит вовсе. Единственная команда затеи без денежных ворот
// оказалась ровно той, которой собирались делать первое живое рождение работника.
//
// 🔴 ОБЕ ДВЕРИ — ТЕ ЖЕ САМЫЕ, а не вторые копии: `proverkaVladeltsa` — тот же довод главной
// программы, что уже стоит у `run` чуть выше по файлу; `dengiNaZapusk` — дверь, вынесенную
// ИЗ `run` специально ради этого (раньше разбор ответа замера был готовым куском кода
// только внутри `run`, и своя копия здесь завела бы второе правило одного смысла).
//
// 🟡 РЕШЕНО НЕ ВЕСТИ ПРОБУ ЧЕРЕЗ ВСЕ ДЕВЯТЬ ВОРОТ обычного прогона (план, приёмка, замысел,
// отпечаток, место на диске, потолки задач/работников, пересечение файлов, отложенная
// задача) — только через эти две. Причина: проба не план и не задачу заводит, а один
// работник на N минут вручную, руками, обычно когда что-то чинят; своего плана/приёмки/
// замысла у неё нет и не будет, и требовать их означало бы придумывать бумаги под
// диагностический инструмент. Сужение названо, а не спрятано: см. отчёт задачи.
try {
const slova = proverkaVladeltsa();
for (const s of slova) console.log(s.tekst);
} catch (err) {
console.error('Проба НЕ запущена: настройка ночи разошлась с решением владельца дороже обещанного (либо сверку сделать не вышло — это тоже не повод молча пускать платного работника).');
console.error(err?.message ?? String(err));
return 1;
}
const dengi = await dengiNaZapusk({ cfg, komanda: 'smoke-longrun', otkaz: 'Проба НЕ запущена' });
if (!dengi.ok) return 1;
const m = dengi.measure;
recordMeasure(budgetMeasuresPath(), {
utilization: m.utilization, status: m.status, kind: m.kind,
resets_at: m.resets_at, at: Date.now(), phase: 'before-smoke',
});
// Доказательство проверки 1: движение должно быть и через три часа после конца сессии
// помощника. Проверками кода это не доказывается — только живым прогоном.
const minutes = Number(flag(argv, '--minutes') ?? 200);
+218 -3
View File
@@ -15,7 +15,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
main, runMailCommand, collectSummary, runningRuns, idushchieProgony, komandaProdolzheniya, kartochkiRabotnikov,
proverkaNastroekVladeltsa,
proverkaNastroekVladeltsa, vorotaDeneg,
} from './cli.mjs';
import { readLines } from './state.mjs';
import { razborDovodov, zamerDovody, vhodVorot, strokiOtkaza, odinVid, vyborProdolzhaemogo } from './cli-parts.mjs';
@@ -1018,8 +1018,12 @@ describe('часть 9: отказы стоят РАНЬШЕ замера зап
// заведомо не пройдёт, не стоит ни одного платного зова. И только при этом порядке
// отказ можно посмотреть живьём глазами: запуск не рождает ни замера, ни угла,
// ни живого работника.
// 🔴 Ищем `dengiNaZapusk(`, а не `zamerSoSchyotom(`: задача О-4 (03.08.2026) вынесла разбор
// ответа замера в общую дверь `vorotaDeneg`, и `run` теперь зовёт её, а не прежний код
// на месте — самого имени `zamerSoSchyotom(` внутри тела `run` больше нет, оно живёт
// внутри умолчания `vorotaDeneg` (см. описание функции), а `smoke-longrun` зовёт ТУ ЖЕ дверь.
const iPlan = BLOK_RUN.indexOf('validatePlan(');
const iZamer = BLOK_RUN.indexOf('zamerSoSchyotom(');
const iZamer = BLOK_RUN.indexOf('dengiNaZapusk(');
expect(iPlan).toBeGreaterThan(-1);
expect(iZamer).toBeGreaterThan(-1);
expect(iPlan).toBeLessThan(iZamer);
@@ -1027,7 +1031,7 @@ describe('часть 9: отказы стоят РАНЬШЕ замера зап
it('🔴 ворота отложенной задачи стоят ДО замера и ДО `planRun`', () => {
const iSled = BLOK_RUN.indexOf('deferredTaskGate(');
const iZamer = BLOK_RUN.indexOf('zamerSoSchyotom(');
const iZamer = BLOK_RUN.indexOf('dengiNaZapusk(');
const iPlanRun = BLOK_RUN.indexOf('planRun(');
expect(iSled).toBeGreaterThan(-1);
expect(iZamer).toBeGreaterThan(-1);
@@ -1344,6 +1348,217 @@ describe('О-3: ворота сверки настроек с решениями
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// О-4 (03.08.2026) — ТРЕТЬЯ ДОРОГА БЕЗ ДЕНЕГ, НАЙДЕННАЯ ТРЕТЬИМ ПОМОЩНИКОМ СО СТОРОНЫ.
//
// `smoke-longrun` заводит ТОГО ЖЕ платного работника, что и `run`/`run --resume`, но стоит
// ОТДЕЛЬНОЙ веткой `if (cmd === 'smoke-longrun')` — развилку куска `run` (там, где сверка
// настроек и денежные ворота уже стоят) она не проходит вовсе, потому что `cmd` сравнивается
// заново, а не входит внутрь ветки `run`. Лечение: `smoke-longrun` зовёт ТЕ ЖЕ функции,
// что и `run` — `proverkaVladeltsa()` (тот же довод главной программы, без своей копии)
// и `vorotaDeneg()` (кусок, вынесенный ИЗ `run` сюда же: раньше разбор ответа замера лежал
// ГОТОВЫМ КОДОМ только в теле `run`, и вторая копия для `smoke-longrun` разъехалась бы
// с первой молча — ровно та беда, что уже случалась у `readRuns`/`file-claims`, см. заметки
// в шапке `cli.mjs`). `run` теперь тоже зовёт `vorotaDeneg()` — дверь одна на обоих, а не своя
// у каждого.
// ═══════════════════════════════════════════════════════════════════════════════
describe('О-4: vorotaDeneg — денежные ворота, вынесенные из `run`, теперь звонят оба зовущих', () => {
it('замер бросил — отказ словами владельцу, а не следом стека', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const g = await vorotaDeneg({
cfg: CFG, komanda: 'run',
zamer: async () => { throw new Error('прибор недоступен'); },
});
expect(g.ok).toBe(false);
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('замер остатка не сделан');
errSpy.mockRestore();
});
it('замеров за смену больше нет — отказ, причина сторожа названа', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const g = await vorotaDeneg({
cfg: CFG, komanda: 'run',
zamer: async () => ({ ok: false, reason: 'исчерпан лимит замеров за смену' }),
});
expect(g.ok).toBe(false);
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('исчерпан лимит замеров за смену');
errSpy.mockRestore();
});
it('замер сошёлся, а остатка нет (`measure` пуст) — отказ, ноль не подставляется', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const g = await vorotaDeneg({
cfg: CFG, komanda: 'run',
zamer: async () => ({ ok: true, measure: null, problem: 'вид предела не совпал' }),
});
expect(g.ok).toBe(false);
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('вид предела не совпал');
errSpy.mockRestore();
});
it('замер вправду посчитан — успех отдаёт измерение как есть', async () => {
const izm = { utilization: 0.1, status: 'ok', kind: 'seven_day', resets_at: 123 };
const g = await vorotaDeneg({ cfg: CFG, komanda: 'run', zamer: async () => ({ ok: true, measure: izm }) });
expect(g.ok).toBe(true);
expect(g.measure).toBe(izm);
});
it('слова владельцу меняются доводом `otkaz` («Прогон»/«Проба»), а не выдуманы заново на месте', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await vorotaDeneg({
cfg: CFG, komanda: 'smoke-longrun', otkaz: 'Проба НЕ запущена',
zamer: async () => { throw new Error('бух'); },
});
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('Проба НЕ запущена: замер остатка не сделан');
errSpy.mockRestore();
});
it('довод передаётся замеру как есть: та же `cfg`, тот же путь попыток, то же имя команды', async () => {
const zamer = vi.fn(async () => ({ ok: true, measure: { utilization: 0 } }));
await vorotaDeneg({ cfg: CFG, komanda: 'smoke-longrun', zamer, attemptsPath: '/тут/попытки.jsonl' });
expect(zamer).toHaveBeenCalledWith({ cfg: CFG, attemptsPath: '/тут/попытки.jsonl', komanda: 'smoke-longrun' });
});
});
describe('О-4: `main()` — smoke-longrun идёт через ТЕ ЖЕ ворота, что и `run`', () => {
it('🔴 дорогое расхождение настроек останавливает пробу ДО денег: денежные ворота не зовутся вовсе', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const dengiSpy = vi.fn(async () => ({ ok: true, measure: { utilization: 0 } }));
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
readConfig: () => CFG,
proverkaVladeltsa: () => { throw new Error('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ ПРО ПРОБУ'); },
dengiNaZapusk: dengiSpy,
});
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(code).toBe(1);
expect(skazano).toContain('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ ПРО ПРОБУ');
expect(dengiSpy).not.toHaveBeenCalled();
errSpy.mockRestore();
});
it('дешёвое расхождение настроек — слова печатаются, дорога идёт дальше, к деньгам', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const dengiSpy = vi.fn(async () => ({ ok: false }));
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
readConfig: () => CFG,
proverkaVladeltsa: () => [{ tekst: 'МЯГКОЕ СЛОВО ПРО ПРОБУ' }],
dengiNaZapusk: dengiSpy,
});
expect(logSpy.mock.calls.map(c => String(c[0]))).toContain('МЯГКОЕ СЛОВО ПРО ПРОБУ');
expect(dengiSpy).toHaveBeenCalledWith(expect.objectContaining({ cfg: CFG, komanda: 'smoke-longrun' }));
expect(code).not.toBe(0);
logSpy.mockRestore();
});
it('🔴 денежные ворота отказали (`ok: false`) — проба НЕ заведена, код отличен от успеха', async () => {
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
readConfig: () => CFG,
proverkaVladeltsa: () => [],
dengiNaZapusk: async () => ({ ok: false }),
});
expect(code).not.toBe(0);
});
it('🔴 датчик зовущих: тело `smoke-longrun` зовёт `proverkaVladeltsa()` РАНЬШЕ `dengiNaZapusk(`', () => {
// 🪤 Та же вторая нога, что и у куска `run` (проверка выше, часть О-3): поведенческие
// проверки доказывают ПОВЕДЕНИЕ через подставку, эта доказывает, что подставка вправду
// встала НА МЕСТО настоящего зова, в настоящем порядке, а не осталась неиспользуемым доводом.
const iCmd = TEKST_CLI.indexOf("if (cmd === 'smoke-longrun') {");
const iSverka = TEKST_CLI.indexOf('proverkaVladeltsa()', iCmd);
const iDengi = TEKST_CLI.indexOf('dengiNaZapusk(', iCmd);
expect(iCmd).toBeGreaterThan(-1);
expect(iSverka).toBeGreaterThan(iCmd);
expect(iDengi).toBeGreaterThan(iSverka);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// О-4, ДАТЧИК КЛАССА — не «у smoke-longrun нет ворот», а «у КАКОЙ УГОДНО команды, заводящей
// платного работника, нет ворот». Сегодня дорог оказалось три, а помнили про две — второй
// такой случай за день (первый — сверка настроек с нулём зовущих). Ручное перечисление команд
// глазами эту беду не лечит: четвёртая дорога послезавтра снова пройдёт мимо, и снова
// придётся, чтобы кто-то вспомнил и проверил руками. Здесь — датчик, который САМ находит
// в `cli.mjs` все команды и сам решает, кто из них платный.
// ═══════════════════════════════════════════════════════════════════════════════
// Вырезает тело каждого `if (cmd === '...') { … }` балансом фигурных скобок — а не до
// следующего `if (cmd ===`, чтобы вложенные `if`/функции внутри не обрывали вырезку раньше
// времени (внутри `run`, например, лежит целый вложенный `if (razbor.resumeRunId)`).
function komandnyeBloki(tekst) {
const bloki = [];
const re = /if \(cmd === '([\w-]+)'\) \{/g;
let m;
while ((m = re.exec(tekst))) {
const otkrytie = tekst.indexOf('{', m.index);
let depth = 0;
let j = otkrytie;
for (; j < tekst.length; j++) {
if (tekst[j] === '{') depth++;
else if (tekst[j] === '}') { depth--; if (depth === 0) break; }
}
bloki.push({ cmd: m[1], telo: tekst.slice(otkrytie, j + 1) });
}
return bloki;
}
// Имена, по которым датчик узнаёт «эта команда заводит живого ПЛАТНОГО работника» —
// а не просто мерит остаток (`budget`) или печатает бумагу (`seal`/`approve-spec`).
const ZAVODYAT_PLATNOGO_RABOTNIKA = ['startAll(', 'smokeLongRun(', 'komandaProdolzheniya(', 'zavestiProdolzhenie(', 'podnyat('];
// Обе двери, обязательные у КАЖДОЙ такой команды.
const VOROTA_OBYAZATELNYE = [
{ imya: 'сверка настроек с решениями владельца', marker: 'proverkaVladeltsa(' },
{ imya: 'денежные ворота', marker: 'dengiNaZapusk(' },
];
describe('О-4: датчик класса сам по себе — на синтетическом тексте, не на настоящем cli.mjs', () => {
it('поддельная команда со спавном и без единой двери — датчик её находит', () => {
const poddelnyj = "if (cmd === 'fake-fourth') {\n const r = startAll({ runId: 'x' });\n return 0;\n}\n";
const bloki = komandnyeBloki(poddelnyj);
expect(bloki.map(b => b.cmd)).toEqual(['fake-fourth']);
expect(bloki[0].telo).toContain('startAll(');
expect(bloki[0].telo).not.toContain('proverkaVladeltsa(');
expect(bloki[0].telo).not.toContain('dengiNaZapusk(');
});
it('та же поддельная команда, но с обеими дверями — датчик видит обе', () => {
const horoshaya = "if (cmd === 'fake-fourth') {\n proverkaVladeltsa();\n await dengiNaZapusk({ cfg, komanda: 'fake-fourth' });\n const r = startAll({ runId: 'x' });\n return 0;\n}\n";
const bloki = komandnyeBloki(horoshaya);
expect(bloki[0].telo).toContain('proverkaVladeltsa(');
expect(bloki[0].telo).toContain('dengiNaZapusk(');
});
it('вложенный `if` внутри команды не обрывает вырезку раньше закрывающей скобки', () => {
const vlozhennyj = "if (cmd === 'x') {\n if (a) {\n b();\n }\n startAll({});\n}\nif (cmd === 'y') {\n z();\n}\n";
const bloki = komandnyeBloki(vlozhennyj);
expect(bloki.map(b => b.cmd)).toEqual(['x', 'y']);
expect(bloki[0].telo).toContain('startAll(');
expect(bloki[0].telo).toContain('if (a)');
});
});
describe('О-4: датчик класса на НАСТОЯЩЕМ cli.mjs — каждая платная команда зовёт ОБЕ двери', () => {
const bloki = komandnyeBloki(TEKST_CLI);
const platnye = bloki.filter(b => ZAVODYAT_PLATNOGO_RABOTNIKA.some(marker => b.telo.includes(marker)));
it('датчик вправду нашёл все семь команд cli.mjs — не меньше и не какие-то другие', () => {
expect(bloki.map(b => b.cmd).sort()).toEqual(
['approve-spec', 'budget', 'mail', 'run', 'seal', 'smoke-longrun', 'summary'],
);
});
it('🔴 датчик вправду нашёл ОБЕ платных дороги — это класс, а не разбор одного места', () => {
expect(platnye.map(b => b.cmd).sort()).toEqual(['run', 'smoke-longrun']);
});
for (const vorota of VOROTA_OBYAZATELNYE) {
it(`каждая платная команда зовёт «${vorota.imya}»`, () => {
for (const b of platnye) {
expect(b.telo, `команда «${b.cmd}» заводит платного работника, но не зовёт ${vorota.imya}`).toContain(vorota.marker);
}
});
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// КУСОК 6, ЗАДАЧА 7 — ПОДЪЁМ ПРОДОЛЖЕНИЯ.
//