diff --git a/tools/night/cli.mjs b/tools/night/cli.mjs index 58e9c6b0..cc80b1b9 100644 --- a/tools/night/cli.mjs +++ b/tools/night/cli.mjs @@ -1723,18 +1723,11 @@ export async function main(argv = process.argv, deps = {}) { console.error(`Почему — написано в карточках работников (поле finish_reason): ${runsDir()}`); console.error('Чаще всего это два пункта плана на один и тот же файл либо несобравшийся composer/npm в углу.'); console.error('Прогон закрыт сразу — места в потолке задач он не занимает, и следующая попытка ничем не заперта.'); - // 🔴🔴 НОЧЬ ЗДЕСЬ УЖЕ КОНЧИЛАСЬ, И ВЛАДЕЛЕЦ ОБЯЗАН ПОЛУЧИТЬ БУМАГУ. Ни один работник - // не завёлся — надзирателей нет, будить утро некому, а почта отменена. Не положи её - // здесь — владелец не узнает о провале НИЧЕГО, пока живой человек не наберёт команду - // руками. Ровно этот случай был живьём в ночь с 3 на 4 августа. - // 🪤 В успешной ветке ниже бумага НЕ кладётся нарочно: там работники ЖИВЫ, ночь идёт, - // и сводка сказала бы «не сделано ничего» про ещё не сделанную работу. Это было бы - // враньё, а не забота. - const diskR = await measureDisk(cfg); - const utroR = polozhitUtro({ disk_free_gb: typeof diskR?.free_gb === 'number' ? Number(diskR.free_gb.toFixed(1)) : null }); - console.error(utroR.ok - ? `Сводка владельцу записана: ${utroR.put}` - : `Сводку владельцу записать НЕ удалось: ${utroR.error}`); + // 🔴 БУМАГА ВЛАДЕЛЬЦУ ЗДЕСЬ НЕ КЛАДЁТСЯ, И ЭТО НЕ ЗАБЫВЧИВОСТЬ. Ночь тут вправду + // кончилась, но закрывает её `writeRunOutcome` внутри `startAll` — то же самое место, + // что закрывает ночь после последнего погасшего работника. Бумагу кладёт ОНО. + // Положи её ещё и здесь — в затее завелось бы второе правило «прогон кончился», + // ровно то, чего `run.mjs` избегает вслух. Одна дверь, один зов. return konec(1); } // След снят: задача пошла в работу. Не сними — следующий запуск отказал бы по нему снова, diff --git a/tools/night/supervisor.mjs b/tools/night/supervisor.mjs index 94b9d567..d07e7354 100644 --- a/tools/night/supervisor.mjs +++ b/tools/night/supervisor.mjs @@ -12,12 +12,16 @@ // вопросов и ответов владельца (кусок 5), приёмщика (кусок 7). import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; import { writeJsonAtomic, readJson, readLines } from './state.mjs'; import { touchLife, readLife } from './heartbeat.mjs'; import { workerInboxDir, lastRunPath, resolveBin, SUMMARY_LINES_FILE } from './paths.mjs'; // Русские слова для итоговых состояний живут в одном месте — в сводке (задача 9). import { STATUS_WORD } from './summary.mjs'; +// 🔴 Утренняя бумага ВЛАДЕЛЬЦУ. Ввозится ОДНОЙ строкой и ОДИН раз — второй ввоз того же +// имени валит `node` ещё до первой строки работы, и ночь не запускается, оставаясь зелёной +// в проверках. Кольца ввозов тут нет: `utro.mjs` ввозит только `summary.mjs`, а тот — ничего. +import { sobratDovody, zapisatUtro, utroPath } from './utro.mjs'; // --- дополнение к ввозам в шапке файла (задача 12) ---------------------------- import { spawn } from 'node:child_process'; import { mkdirSync, openSync } from 'node:fs'; @@ -904,8 +908,22 @@ export function amILast(runDirPath) { return cards.length > 0 && cards.every(c => FINAL_STATUSES.includes(c.status)); } +// Настоящее кладение бумаги. Отдельным именем, чтобы его можно было подменить в проверках +// и чтобы `writeRunOutcome` осталась читаемой. +export function zapisatUtroPoItogu({ koren, diskFreeGb = null }) { + const dovody = sobratDovody({ koren }); + return zapisatUtro({ ...dovody, disk_free_gb: diskFreeGb }, { put: utroPath(koren) }); +} + export function writeRunOutcome({ runDirPath, runId, lastRunFile = lastRunPath(), now = Date.now(), + // 🔴 Свободное место на утро. Замер СПРАШИВАЕТСЯ у зовущего, а не делается здесь: + // эта работа синхронная, а замер места — нет. Не дали — в сводке честное «неизвестно», + // выдуманного числа тут не появится никогда. + diskFreeGb = null, + // 🪤 Кладение бумаги — точка впрыска: иначе проверки этой работы писали бы файл + // в НАСТОЯЩЕЕ хозяйство улик рядом с боевым `last-run.json`. + polozhitUtro = zapisatUtroPoItogu, // 🪤 Умолчания НАСТОЯЩИЕ: подставка подаётся явно и только в проверках. Иначе // проверки писали бы след в боевой `.night/deferred/`, и следующая живая ночь // упёрлась бы в отказ, оставленный проверкой. @@ -951,6 +969,29 @@ export function writeRunOutcome({ // 🪤 `last-run.json` — другой файл, его пишет один надзиратель, и замка ему не нужно. writeJsonAtomic(lastRunFile, outcome); + // ━━━ 🔴🔴 ПОСЛЕДНЯЯ МИЛЯ ВСЕЙ ЗАТЕИ: НОЧЬ КОНЧИЛАСЬ — ВЛАДЕЛЬЦУ ЛОЖИТСЯ БУМАГА ━━━ + // + // Здесь и только здесь. Это ЕДИНСТВЕННАЯ дверь, через которую проходят ОБА конца ночи: + // и «не завёлся ни один работник» (зовёт `run.mjs`), и «погас последний работник» + // (зовёт надзиратель выше). Второй копии правила «прогон кончился» в затее нет нарочно — + // не заводим её и мы. + // + // 🔴 ЗАЧЕМ ЭТО ВООБЩЕ. Почта отменена решением владельца, телеграма нет, утреннего + // будильника не построено. Значит бумага, кладущаяся вот в эту секунду, — ЕДИНСТВЕННАЯ + // существующая дорога до человека. Без неё даже безупречная ночь утром молчит, а тишину + // владелец не отличит от «всё хорошо». + // + // 🪤 СТРОГО ПОСЛЕ `writeJsonAtomic`: сводка читает `last-run.json`. Положи её раньше — + // она рассказала бы про ПРОШЛУЮ ночь, и это было бы враньём страшнее молчания. + // + // 🔴 НИКОГДА НЕ РОНЯЕТ ИТОГ ПРОГОНА. Упади бумага — потерялся бы и след отложенной задачи + // ниже, и назавтра задача молча не подхватилась бы. Бумага важна, но не важнее итога. + try { + polozhitUtro({ koren: dirname(lastRunFile), diskFreeGb }); + } catch (err) { + logEvent(runDirPath, { kind: 'utro_ne_leglo', error: String(err?.message ?? err) }, now); + } + // 🔴 СЛЕД ОТЛОЖЕННОЙ ЗАДАЧИ (проверка 28а). Прогон кончился, а пункты остались — // задача не влезла в ночь. Назавтра она сама не подхватится: след её держит, и снимает // его только слово владельца (задача 6). Кладём след ЗДЕСЬ и только здесь. @@ -1140,6 +1181,9 @@ export async function supervise({ logEvent(runDirPath, { kind: 'run_started_at_missing', worker: workerId }, startedAt); } + // Последнее удавшееся число свободного места — для утренней бумаги (заполняется кругом ниже). + let posledniyZamerMesta = null; + function closeRunIfLast(at) { // 🔴 Прогон закрывает надзиратель ПОСЛЕДНЕГО погасшего работника — больше некому: // оркестратор к этому времени давно умер вместе с сессией помощника. @@ -1149,7 +1193,7 @@ export async function supervise({ // боевой `.night/deferred/`, — и проверки круга оставляли бы там настоящие отказы. // Поймано живьём 02.08.2026: вырез этой строки положил в хранилище восемь настоящих // отказов по задачам, которых никто не откладывал. - writeRunOutcome({ runDirPath, runId, lastRunFile, now: at, deferredPath }); + writeRunOutcome({ runDirPath, runId, lastRunFile, now: at, deferredPath, diskFreeGb: posledniyZamerMesta }); } // 🔴🔴 ЗАКРЫТИЕ ДЫРЫ 03.08.2026, ВТОРОЙ КРУГ, ВРЕЗАНО В ГОРЯЧЕЕ МЕСТО. `claimFiles` @@ -1592,6 +1636,15 @@ export async function supervise({ }); const disk = await probeDisk(); + // 🔴 Последний удавшийся замер места запоминается для УТРЕННЕЙ БУМАГИ. Конец ночи + // синхронный, а замер места — нет, и своего замера он сделать не может. Круг же меряет + // место каждые полминуты — берём готовое, вместо того чтобы мерить второй раз или + // подсовывать владельцу «неизвестно» там, где число вправду есть. + // 🪤 Только УДАВШИЙСЯ: осечку замера запоминать нельзя, иначе к утру она затрёт + // последнее настоящее число и владелец прочтёт «неизвестно» вместо правды. + if (typeof disk?.free_gb === 'number' && Number.isFinite(disk.free_gb)) { + posledniyZamerMesta = Number(disk.free_gb.toFixed(1)); + } // Осечка замера — не приговор с первого раза (см. DISK_BAD_PROBES_DEFAULT). Хороший // замер счётчик обнуляет: подтверждением считаются только осечки ПОДРЯД. diskBadInARow = disk?.ok ? 0 : diskBadInARow + 1; diff --git a/tools/night/utro.mjs b/tools/night/utro.mjs index 51f0b68d..fe610944 100644 --- a/tools/night/utro.mjs +++ b/tools/night/utro.mjs @@ -351,6 +351,24 @@ function shapka(ms) { return `СВОДКА ЗА НОЧЬ · собрана ${vremyaSutok(ms).tvoritelnyy} ${ru(ms)}`; } +// 🔴 НАЙДЕНО ГЛАЗАМИ 04.08.2026 НА УДАЧНОЙ НОЧИ, И ПРОВЕРКИ ЭТО ПРОПУСТИЛИ. Под заголовком +// «ЧТО СЛОМАЛОСЬ» стояли ОБА работника — «w-1: погашен», «w-2: погашен», — хотя ночь удалась +// целиком и они просто штатно кончили работу. Владелец читает заголовок, видит там своих +// работников и решает, что беда. Тот же класс, что «успех подан как поломка», только вёрсткой, +// а не переводом. Мой собственный сторож это охранял: он требовал, чтобы графа была непуста, +// — и непустой её делало ровно это враньё. +// +// Состояния, которые беда САМИ ПО СЕБЕ: работник встал или не родился вовсе. +export const TREVOZHNYE_SOSTOYANIYA = ['stalled', 'born_dead']; + +// Беда ли это. Штатно погасший работник («все пункты закрыты», «упёрся в восемь часов») — +// не поломка: первое это успех, второе предел, и оба видны в разделе «что сделано». +export function bedaLiRabotnik(w) { + if (TREVOZHNYE_SOSTOYANIYA.includes(w?.status)) return true; + const prichina = String(w?.reason ?? ''); + return PRIMETY_PRICHIN.some(p => p.primeta.test(prichina)); +} + function prigovorPo({ run_id, items }) { if (!run_id) return PRIGOVORY.ne_bylo; const vsego = Number(items?.vsego ?? 0); @@ -417,11 +435,21 @@ export function utrenniyOtchyot({ } } + // 🔴 Работники, кончившие ШТАТНО, названы здесь — а не в графе «что сломалось», где они + // пугали бы владельца ни за что. Молчать о них тоже нельзя: он вправе знать, кто работал. + const shtatnye = workers.filter(w => !bedaLiRabotnik(w)); + if (shtatnye.length) { + const kto = shtatnye.map(w => `${w.worker_id ?? 'без имени'} — ${slovoSostoyaniya(w.status)}`).join('; '); + v.push(`Работали ${shtatnye.length} ${sklonenie(shtatnye.length, 'работник', 'работника', 'работников')}, все кончили штатно: ${kto}.`); + } + // ━━━ 2. ЧТО СЛОМАЛОСЬ ━━━ v.push(''); v.push(ZAGOLOVKI.slomalos); const bedy = []; - for (const w of workers) { + // 🔴 Сюда попадают ТОЛЬКО настоящие беды. Штатно кончивший работник — не поломка, + // и его место в разделе «что сделано», где он никого не пугает. + for (const w of workers.filter(bedaLiRabotnik)) { const imya = String(w.worker_id ?? 'работник без имени'); const sostoyanie = slovoSostoyaniya(w.status); // Вот здесь приметы отказа уместны: `reason` — это ровно причина, по которой встали. diff --git a/tools/night/utro.test.mjs b/tools/night/utro.test.mjs index 904d1e3a..55b5ee5e 100644 --- a/tools/night/utro.test.mjs +++ b/tools/night/utro.test.mjs @@ -19,7 +19,7 @@ import { neponyatnyeKuski, ochelovechitPrichinu, ochelovechitNazvanie, merkaCheloveka, merkaAvariynoy, utrenniyOtchyot, zapisatUtro, slovaProZapas, slovoSostoyaniya, sobratDovody, avariynyyTekst, - vremyaSutok, moskovskiyChas, + vremyaSutok, moskovskiyChas, bedaLiRabotnik, PRIGOVORY, ZAGOLOVKI, IMYA_FAYLA, utroPath, METKA_MASTERA, RAZDELITEL_MASTERU, } from './utro.mjs'; // 🔴 Общий счёт времени затеи — только ради сверки с моим. Ввозится в ПРОВЕРКУ, а не @@ -28,6 +28,8 @@ import { import { moskovskoeVremya } from './budget.mjs'; // Точка входа затеи — её утренние дела обязаны класть бумагу владельцу сами. import { main } from './cli.mjs'; +// Единственная дверь, через которую проходят ОБА конца ночи. +import { writeRunOutcome } from './supervisor.mjs'; // 🔴 НЕ ВЫДУМАННЫЙ ОБРАЗЕЦ. Это дословная причина из настоящей улики владельца — // `.night/runs/r-2026-08-03-212935-f0b5d48a/summary-lines.jsonl`, ночь с 3 на 4 августа 2026. @@ -490,28 +492,74 @@ describe('🔴 сводка ложится САМА, а не только по log.mockRestore(); }); - it('🔴 ночь кончилась СРАЗУ (не завёлся ни один работник) — бумага ложится тут же', async () => { - // 🔴 НАЙДЕНО СУДЬЁЙ 04.08.2026. Зов стоял только в `summary` и `mail`, а ночь - // запускается командой `run`. Работников ноль → надзирателей нет → будить утро некому - // → почта отменена. Владелец не узнал бы о провале НИЧЕГО. Ровно этот случай был - // живьём в ночь с 3 на 4 августа. - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - const err = vi.spyOn(console, 'error').mockImplementation(() => {}); - let polozhili = false; - const code = await main(['node', 'cli.mjs', 'run', '--plan', 'нет-такого-плана.md'], { - ...obshchee, - polozhitUtro: () => { polozhili = true; return { ok: true, put: 'куда-то' }; }, + it('🔴 КОНЕЦ НОЧИ КЛАДЁТ БУМАГУ: зов стоит в единственной двери, через какую идут оба конца', () => { + // 🔴 НАЙДЕНО СУДЬЁЙ 04.08.2026. Зов стоял только в `summary` и `mail`, а ночь запускается + // командой `run`. Почта отменена, телеграма нет, утреннего будильника не построено — + // владелец не узнал бы об итоге НИЧЕГО, пока живой человек не наберёт команду руками. + const koren = vremenno(); + mkdirSync(join(koren, 'runs', 'r-5', 'workers'), { recursive: true }); + writeFileSync(join(koren, 'runs', 'r-5', 'run.json'), JSON.stringify({ + run_id: 'r-5', workers: ['w-1'], items: [{ id: '1', title: 'первый пункт' }], + }), 'utf8'); + + writeRunOutcome({ + runDirPath: join(koren, 'runs', 'r-5'), + runId: 'r-5', + lastRunFile: join(koren, 'last-run.json'), + now: T0, + deferredPath: join(koren, 'deferred'), + diskFreeGb: 12.5, }); - log.mockRestore(); - err.mockRestore(); - expect(code).not.toBe(0); - // 🪤 Сторож нарочно НЕ требует, чтобы отказ случился именно на нуле работников: до этой - // точки в `run` стоит десяток более ранних ворот, и они могут закрыться раньше. Он - // требует другого и главного — чтобы ветка нуля работников зов бумаги СОДЕРЖАЛА. + + // Бумага легла РЯДОМ с итогом прогона, в то же хозяйство. + const put = utroPath(koren); + expect(existsSync(put), 'ночь кончилась, а бумаги владельцу нет').toBe(true); + const text = readFileSync(put, 'utf8'); + expect(merkaCheloveka(text.split(RAZDELITEL_MASTERU)[0])).toEqual([]); + // 🪤 И место на сервере доехало замеренным: конец ночи синхронный и сам мерить не умеет. + expect(text).toMatch(/12,5/); + }); + + it('🪤 бумага кладётся СТРОГО ПОСЛЕ итога прогона — иначе рассказала бы про прошлую ночь', () => { + const koren = vremenno(); + mkdirSync(join(koren, 'runs', 'r-6', 'workers'), { recursive: true }); + writeFileSync(join(koren, 'runs', 'r-6', 'run.json'), JSON.stringify({ + run_id: 'r-6', workers: [], items: [{ id: '1', title: 'пункт' }], + }), 'utf8'); + // Кладём итог ПРОШЛОЙ ночи — если бумага соберётся до записи новой, она назовёт её. + writeFileSync(join(koren, 'last-run.json'), JSON.stringify({ run_id: 'r-STARYY', workers: [] }), 'utf8'); + + writeRunOutcome({ + runDirPath: join(koren, 'runs', 'r-6'), runId: 'r-6', + lastRunFile: join(koren, 'last-run.json'), now: T0, deferredPath: join(koren, 'deferred'), + }); + const text = readFileSync(utroPath(koren), 'utf8'); + expect(text, 'бумага назвала ПРОШЛУЮ ночь').not.toMatch(/r-STARYY/); + expect(text).toMatch(/r-6/); + }); + + it('🔴 бумага упала — итог прогона всё равно записан, ночь не потеряна', () => { + // Бумага важна, но не важнее итога: упади с ней весь `writeRunOutcome` — потерялся бы + // и след отложенной задачи, и назавтра она молча не подхватилась бы. + const koren = vremenno(); + mkdirSync(join(koren, 'runs', 'r-7', 'workers'), { recursive: true }); + writeFileSync(join(koren, 'runs', 'r-7', 'run.json'), JSON.stringify({ run_id: 'r-7', workers: [], items: [] }), 'utf8'); + const itog = writeRunOutcome({ + runDirPath: join(koren, 'runs', 'r-7'), runId: 'r-7', + lastRunFile: join(koren, 'last-run.json'), now: T0, deferredPath: join(koren, 'deferred'), + polozhitUtro: () => { throw new Error('бумага не легла'); }, + }); + expect(itog.run_id).toBe('r-7'); + expect(JSON.parse(readFileSync(join(koren, 'last-run.json'), 'utf8')).run_id).toBe('r-7'); + }); + + it('🪤 второй копии правила «прогон кончился» не завелось: в точке входа зова бумаги нет', () => { + // Ночь закрывает ОДНА дверь (`writeRunOutcome`), и бумагу кладёт она же. Заведись зов + // ещё и в ветке нуля работников — их стало бы два, ровно та беда, от которой `run.mjs` + // предостерегает вслух. const src = readFileSync(new URL('./cli.mjs', import.meta.url), 'utf8'); const vetka = src.split('НЕ начался: не завёлся НИ ОДИН работник')[1]?.split('return konec(1)')[0] ?? ''; - expect(vetka, 'в ветке «не завёлся ни один работник» нет зова утренней бумаги').toMatch(/polozhitUtro/); - void polozhili; + expect(vetka, 'в точке входа завелась вторая копия правила «прогон кончился»').not.toMatch(/polozhitUtro\(/); }); it('🪤 точка входа ЗАПУСКАЕТСЯ: второй ввоз того же имени валил бы её до первой строки', async () => { @@ -524,6 +572,113 @@ describe('🔴 сводка ложится САМА, а не только по }); }); +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('🟢 НОРМАЛЬНО ОТРАБОТАВШАЯ НОЧЬ — что владелец прочтёт утром', () => { + // Всю смену сводку проверяли на провале. Провал важнее — но удачная ночь случается чаще, + // и если она читается плохо, владелец перестанет открывать файл вовсе. + function udachnayaNoch() { + const koren = vremenno(); + const rdir = join(koren, 'runs', 'r-udachnaya'); + mkdirSync(join(rdir, 'workers', 'w-1'), { recursive: true }); + mkdirSync(join(rdir, 'workers', 'w-2'), { recursive: true }); + writeFileSync(join(koren, 'last-run.json'), JSON.stringify({ + run_id: 'r-udachnaya', + workers: [ + { worker_id: 'w-1', status: 'extinguished', reason: 'Все пункты закрыты, работы больше нет.', closed_items: ['1', '2'], touched: { money: false, rights: false, client_visible: true } }, + { worker_id: 'w-2', status: 'extinguished', reason: 'Все пункты закрыты, работы больше нет.', closed_items: ['3'], touched: { money: true, rights: false, client_visible: false } }, + ], + }), 'utf8'); + writeFileSync(join(rdir, 'run.json'), JSON.stringify({ + items: [ + { id: '1', title: 'починить кнопку «продолжить» на экране сделок' }, + { id: '2', title: 'завести письмо владельцу о вечерней заливке' }, + { id: '3', title: 'списание считать по тарифной ступени, а не по средней' }, + ], + }), 'utf8'); + writeFileSync(join(koren, 'budget-measures.jsonl'), [ + JSON.stringify({ run_id: 'r-udachnaya', utilization: 0.12 }), + JSON.stringify({ run_id: 'r-udachnaya', utilization: 0.29 }), + ].join('\n'), 'utf8'); + return koren; + } + + it('удачная ночь читается человеком и проходит мерку целиком', () => { + const koren = udachnayaNoch(); + const d = sobratDovody({ koren }); + const { vladeltsu, prigovor } = utrenniyOtchyot({ ...d, disk_free_gb: 210.4, now: T0 }); + expect(prigovor).toBe(PRIGOVORY.udalas); + expect(merkaCheloveka(vladeltsu)).toEqual([]); + }); + + it('владелец видит, ЧТО именно сделано — поимённо, а не только счётом', () => { + const koren = udachnayaNoch(); + const { vladeltsu } = utrenniyOtchyot({ ...sobratDovody({ koren }), disk_free_gb: 210.4, now: T0 }); + expect(vladeltsu).toMatch(/Сделано 3 пункта из 3/); + expect(vladeltsu).toMatch(/починить кнопку/); + expect(vladeltsu).toMatch(/тарифной ступени/); + }); + + it('🔴 в удачную ночь под «ЧТО СЛОМАЛОСЬ» НЕТ работников — ничего не сломалось', () => { + // 🔴 НАЙДЕНО ГЛАЗАМИ, А НЕ ПРОВЕРКОЙ. Прежде тут стояли оба работника — «w-1: погашен», + // «w-2: погашен», — при том что ночь удалась целиком. Владелец читает заголовок, видит + // своих работников и решает, что беда. + // 🪤 И мой СОБСТВЕННЫЙ прежний сторож это охранял: он требовал, чтобы графа была + // непуста, — а непустой её делало ровно это враньё. Проверка, требующая наличия + // строки, слепа к тому, ЧТО в этой строке написано. + const koren = udachnayaNoch(); + const { vladeltsu } = utrenniyOtchyot({ ...sobratDovody({ koren }), disk_free_gb: 210.4, now: T0 }); + const slomalos = vladeltsu.split(ZAGOLOVKI.slomalos)[1].split(ZAGOLOVKI.potracheno)[0]; + expect(slomalos).toMatch(/Ничего не сломалось/); + expect(slomalos, 'штатно кончивший работник подан владельцу как поломка').not.toMatch(/w-1|w-2|погашен/); + }); + + it('штатно кончившие работники всё равно НАЗВАНЫ — но там, где не пугают', () => { + // Молчать о них нельзя: владелец вправе знать, кто работал. + const koren = udachnayaNoch(); + const { vladeltsu } = utrenniyOtchyot({ ...sobratDovody({ koren }), disk_free_gb: 210.4, now: T0 }); + const sdelano = vladeltsu.split(ZAGOLOVKI.sdelano)[1].split(ZAGOLOVKI.slomalos)[0]; + expect(sdelano).toMatch(/Работали 2 работника, все кончили штатно/); + expect(sdelano).toMatch(/w-1/); + }); + + it('🔴 настоящая беда в графу «что сломалось» ПОПАДАЕТ — разбор не проглотил её заодно', () => { + // Иначе лечение было бы хуже болезни: тихая графа при мёртвом работнике. + const { vladeltsu } = utrenniyOtchyot({ + run_id: 'r-1', now: T0, items: { vsego: 2, zakryto: 1 }, + workers: [ + { worker_id: 'w-1', status: 'extinguished', reason: 'Все пункты закрыты, работы больше нет.', closed_items: ['1'] }, + { worker_id: 'w-2', status: 'stalled', reason: 'Работник молчит третий час.', at_item: '2' }, + ], + }); + const slomalos = vladeltsu.split(ZAGOLOVKI.slomalos)[1].split(ZAGOLOVKI.potracheno)[0]; + expect(slomalos).toMatch(/w-2/); + expect(slomalos, 'штатный работник затесался в поломки').not.toMatch(/w-1/); + }); + + it('КЛАСС: встал или не родился — всегда беда; штатный конец — никогда', () => { + for (const status of ['stalled', 'born_dead']) { + expect(bedaLiRabotnik({ status, reason: 'что угодно' }), `«${status}» не признано бедой`).toBe(true); + } + expect(bedaLiRabotnik({ status: 'extinguished', reason: 'Все пункты закрыты, работы больше нет.' })).toBe(false); + expect(bedaLiRabotnik({ status: 'extinguished', reason: 'Упёрся в восемь часов.' })).toBe(false); + // А отказ по примете беды — беда, даже если состояние обычное. + expect(bedaLiRabotnik({ status: 'extinguished', reason: 'Command failed: npm ci' })).toBe(true); + }); + + it('деньги за удачную ночь названы числом и оценкой', () => { + const koren = udachnayaNoch(); + const { vladeltsu } = utrenniyOtchyot({ ...sobratDovody({ koren }), disk_free_gb: 210.4, now: T0 }); + expect(vladeltsu).toMatch(/истрачено 17 процентов/); + expect(vladeltsu).toMatch(/меньше половины|запас есть/); + }); + + it('после удачной ночи владельцу не велят ничего чинить', () => { + const koren = udachnayaNoch(); + const { vladeltsu } = utrenniyOtchyot({ ...sobratDovody({ koren }), disk_free_gb: 210.4, now: T0 }); + expect(vladeltsu).toMatch(/Ничего делать не нужно/); + }); +}); + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ describe('сбор доводов из улик прогона', () => { function polozhitUliki(koren, { last, run, zamery }) {