diff --git a/tools/night/cli.mjs b/tools/night/cli.mjs index c43e00c0..6fdcf045 100644 --- a/tools/night/cli.mjs +++ b/tools/night/cli.mjs @@ -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); diff --git a/tools/night/cli.test.mjs b/tools/night/cli.test.mjs index 2baccf0f..f021bc7b 100644 --- a/tools/night/cli.test.mjs +++ b/tools/night/cli.test.mjs @@ -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 — ПОДЪЁМ ПРОДОЛЖЕНИЯ. //