495 lines
32 KiB
JavaScript
495 lines
32 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join, relative, resolve } from 'node:path';
|
||
import { planLockVerdict, readRuns } from './plan-lock-hook.mjs';
|
||
import { assertPlanEditable, sealPlan, checkSeal } from './plan-fingerprint.mjs';
|
||
import { runsDir, runDir, samePath } from './paths.mjs';
|
||
import { VIDY, schitatZanyatym, sostoyanieProgona } from './zhiv-li-progon.mjs';
|
||
|
||
const PLAN = 'docs/superpowers/plans/2026-08-01-пример.md';
|
||
const OTHER = 'docs/superpowers/plans/2026-08-01-другой.md';
|
||
|
||
const running = { run_id: 'r-2026-08-01-2200', status: 'running', plan_path: PLAN };
|
||
|
||
let dir;
|
||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'night-planlock-')); });
|
||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||
|
||
function makeRunCard(runsRoot, runId, text) {
|
||
mkdirSync(join(runsRoot, runId), { recursive: true });
|
||
writeFileSync(join(runsRoot, runId, 'run.json'), text, 'utf8');
|
||
}
|
||
|
||
describe('samePath — один и тот же файл, записанный по-разному', () => {
|
||
it('обратные косые и прямые — это один файл', () => {
|
||
expect(samePath('docs\\superpowers\\plans\\п.md', 'docs/superpowers/plans/п.md')).toBe(true);
|
||
});
|
||
|
||
it('точка со слешем впереди ничего не меняет', () => {
|
||
expect(samePath('./' + PLAN, PLAN)).toBe(true);
|
||
});
|
||
|
||
it('разные файлы остаются разными', () => {
|
||
expect(samePath(PLAN, OTHER)).toBe(false);
|
||
});
|
||
|
||
it('пустой путь ни с чем не совпадает — иначе замок ловил бы всё подряд', () => {
|
||
expect(samePath(null, PLAN)).toBe(false);
|
||
expect(samePath(PLAN, '')).toBe(false);
|
||
});
|
||
|
||
it('ПОЛНЫЙ путь и путь от корня хранилища — это один файл', () => {
|
||
// 🔴 Дыра 2. Без `resolve` внутри `samePath` эти две записи не сходились, и замок
|
||
// молча пропускал правку идущего плана. Вырежи `resolve` — покраснеет здесь.
|
||
expect(samePath(resolve(PLAN), PLAN)).toBe(true);
|
||
expect(samePath(PLAN, resolve(PLAN))).toBe(true);
|
||
});
|
||
|
||
it('полный путь с обратными косыми (как его пишет владелец) — тоже тот же файл', () => {
|
||
expect(samePath(resolve(PLAN).replace(/\//g, '\\'), PLAN)).toBe(true);
|
||
});
|
||
|
||
it('полные пути РАЗНЫХ планов остаются разными — замок не начал ловить всё подряд', () => {
|
||
expect(samePath(resolve(PLAN), resolve(OTHER))).toBe(false);
|
||
expect(samePath(resolve(PLAN), OTHER)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('замок и печать сравнивают путь ОДИНАКОВО (дыра 2)', () => {
|
||
it('что печать считает одним планом, то и замок считает одним планом', () => {
|
||
// 🔴 Сравнивателей было двое — `samePlanPath` у печати (plan-fingerprint.mjs) и
|
||
// `samePath` у замка, дословными копиями. Теперь он один и живёт в paths.mjs, но эта
|
||
// проверка остаётся: она сторожит не место жительства, а СОГЛАСИЕ двух сторон и идёт
|
||
// к печати через её единственный наружный вход `checkSeal`. Разведи поведение —
|
||
// сними `resolve` в `samePath` — и покраснеет здесь.
|
||
const planAbs = join(dir, 'план.md');
|
||
writeFileSync(planAbs, '# план\nтекст\n', 'utf8');
|
||
const planRel = relative(process.cwd(), planAbs);
|
||
const sealPath = join(dir, 'seal.json');
|
||
sealPlan(sealPath, planAbs, { approved_by: 'owner', at: 1 });
|
||
|
||
// Печать снята с ПОЛНОГО пути, а запускают по пути ОТ КОРНЯ — печать это принимает.
|
||
expect(checkSeal(sealPath, planRel).ok).toBe(true);
|
||
// Значит и замок обязан считать это одним и тем же файлом.
|
||
expect(samePath(planRel, planAbs)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('planLockVerdict — правку идущего плана не пропускаем (проверка 43, часть)', () => {
|
||
it('идущих прогонов нет — правь сколько хочешь', () => {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [] });
|
||
expect(v.ok).toBe(true);
|
||
expect(v.blocked).toEqual([]);
|
||
});
|
||
|
||
it('прогон идёт, но правят ДРУГОЙ файл — проходит', () => {
|
||
const v = planLockVerdict({ staged: [OTHER], runs: [running] });
|
||
expect(v.ok).toBe(true);
|
||
});
|
||
|
||
it('прогон идёт, правят ЕГО план — ОТКАЗ, и назван номер прогона', () => {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running] });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN]);
|
||
expect(v.reason).toContain('r-2026-08-01-2200');
|
||
});
|
||
|
||
it('путь записан обратными косыми — всё равно ОТКАЗ', () => {
|
||
// 🪤 Владелец зовёт запуск с `docs\...`, а git отдаёт хуку `docs/...`.
|
||
// Сравни как есть — замок не поймал бы ничего, зеленея на проверках.
|
||
const v = planLockVerdict({
|
||
staged: ['docs/superpowers/plans/2026-08-01-пример.md'],
|
||
runs: [{ ...running, plan_path: 'docs\\superpowers\\plans\\2026-08-01-пример.md' }],
|
||
});
|
||
expect(v.ok).toBe(false);
|
||
});
|
||
|
||
it('причина взята у САМОГО правила, а не написана рядом своими словами', () => {
|
||
// 🔴 Два текста про одно правило разошлись бы молча: правило поправили, хук говорит старое.
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running] });
|
||
const rule = assertPlanEditable({ status: 'running', run_id: 'r-2026-08-01-2200' });
|
||
expect(v.reason).toContain(rule.reason);
|
||
});
|
||
|
||
it('в отказе сказано, ЧТО делать: остановить прогон командой «стоп всё»', () => {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running] });
|
||
expect(v.reason).toMatch(/стоп вс[её]/i);
|
||
});
|
||
|
||
it('прогон кончился (finished) — замок снялся САМ, правка проходит', () => {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [{ ...running, status: 'finished' }] });
|
||
expect(v.ok).toBe(true);
|
||
});
|
||
|
||
it('прогон принят владельцем (accepted) — тоже проходит', () => {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [{ ...running, status: 'accepted' }] });
|
||
expect(v.ok).toBe(true);
|
||
});
|
||
|
||
it('идут двое — ловится план ЛЮБОГО из них, а не только первого', () => {
|
||
const second = { run_id: 'r-2026-08-01-2300', status: 'running', plan_path: OTHER };
|
||
const v = planLockVerdict({ staged: [OTHER], runs: [running, second] });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.reason).toContain('r-2026-08-01-2300');
|
||
});
|
||
|
||
it('в коммите и план идущего прогона, и посторонний файл — отказ, и назван ровно план', () => {
|
||
const v = planLockVerdict({ staged: ['README.md', PLAN, 'tools/night/disk.mjs'], runs: [running] });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN]);
|
||
});
|
||
|
||
it('идут двое по ОДНОМУ плану — файл назван один раз, а не дважды', () => {
|
||
// 🔴 Двенадцатая проверка замка — её не хватало, и число в шаге 4 не сходилось.
|
||
// Без защиты от повтора один и тот же файл лёг бы в `blocked` столько раз, сколько
|
||
// идёт прогонов по нему, и владелец прочёл бы «не принят план, не принят план».
|
||
// Номера обоих прогонов при этом назвать НАДО: остановить придётся оба.
|
||
const second = { run_id: 'r-2026-08-01-2300', status: 'running', plan_path: PLAN };
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running, second] });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN]);
|
||
expect(v.reason).toContain('r-2026-08-01-2200');
|
||
expect(v.reason).toContain('r-2026-08-01-2300');
|
||
});
|
||
|
||
it('у идущего прогона плана не записано вовсе — замок не выдумывает и пропускает', () => {
|
||
// 🪤 Ноль почти всегда сбой, но здесь запирать нечего: не зная, какой план идёт,
|
||
// мы заперли бы владельцу ЛЮБУЮ правку любого плана — и он снёс бы замок целиком.
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [{ run_id: 'r-9', status: 'running', plan_path: null }] });
|
||
expect(v.ok).toBe(true);
|
||
});
|
||
|
||
it('правят план ПОЛНЫМ путём, а в карточке он от корня — всё равно ОТКАЗ (дыра 2)', () => {
|
||
const v = planLockVerdict({ staged: [resolve(PLAN)], runs: [running] });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.reason).toContain('r-2026-08-01-2200');
|
||
});
|
||
|
||
it('сторож подал путь от корня, а прогон запускали ПОЛНЫМ — тоже ОТКАЗ (дыра 2, боевой случай)', () => {
|
||
// 🔴 ЗАМЕРЕНО: lefthook 2.1.8 подставляет `{staged_files}` путём ОТ КОРНЯ хранилища
|
||
// (`docs/superpowers/plans/…`). А владелец запускает прогон полным путём, и полный путь
|
||
// ложится в карточку прогона. Именно эта пара и расходилась.
|
||
const v = planLockVerdict({
|
||
staged: [PLAN],
|
||
runs: [{ ...running, plan_path: resolve(PLAN).replace(/\//g, '\\') }],
|
||
});
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN]);
|
||
});
|
||
});
|
||
|
||
describe('ПОКОЙНИК НЕ ДЕРЖИТ ПЛАН ЗАЛОЖНИКОМ (дыра 3: слову «running» верили на слово)', () => {
|
||
// Подставной судья: отвечает заранее назначенным видом, как настоящий прибор.
|
||
const sudyaOtvechayet = vid => (run, o) => ({ vid, run_id: run.run_id, pochemu: `прогон ${run.run_id}: ${vid}`, dano: o });
|
||
|
||
it('карточка говорит «идёт», а прогона на деле НЕТ — правка ПРОХОДИТ', () => {
|
||
// 🔴 Это и есть сегодняшняя беда владельца: прогон брошен в час ночи, процесса давно нет,
|
||
// а план по нему нельзя было поправить НИКОГДА. Вырезание: вернуть строку 28 к виду
|
||
// `runs.filter(… status === 'running' …)` без спроса о живости — покраснеет здесь.
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('mertv') });
|
||
expect(v.ok).toBe(true);
|
||
expect(v.blocked).toEqual([]);
|
||
});
|
||
|
||
it('прогон вправду ИДЁТ — правка по-прежнему НЕ проходит', () => {
|
||
// 🔴 Обратная половина, и она дороже первой: ошибись замок в сторону «мёртв» — правка
|
||
// легла бы под живого работника. Вырезание: пустить всех подряд — покраснеет здесь.
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('idyot') });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN]);
|
||
});
|
||
|
||
it('прогон заведён только что, надзиратель ещё не отметился — замок ЗАКРЫТ', () => {
|
||
// 🪤 Между записью карточки и подъёмом надзирателей прогон выглядит точь-в-точь как
|
||
// мертворождённый. Открыть замок в это окно — впустить правку под работника, который
|
||
// родится через секунду.
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('tolko_zavedyon') });
|
||
expect(v.ok).toBe(false);
|
||
});
|
||
|
||
it('живость прочесть не удалось — замок ЗАКРЫТ, «не знаю» это не «мёртв»', () => {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('ne_prochest') });
|
||
expect(v.ok).toBe(false);
|
||
});
|
||
|
||
it('замок открыт РОВНО на тех видах, что и потолок задач, — правило одно на затею', () => {
|
||
// 🔴 Своего второго списка «что считать занятым» у замка нет и быть не должно: два
|
||
// списка про одно разъехались бы молча. Вырезание: заменить `schitatZanyatym` на
|
||
// свой `vid !== 'mertv' && vid !== 'ne_prochest'` — покраснеет здесь.
|
||
expect(VIDY.length).toBeGreaterThan(1);
|
||
for (const vid of VIDY) {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet(vid) });
|
||
expect([vid, v.ok]).toEqual([vid, !schitatZanyatym(vid)]);
|
||
}
|
||
});
|
||
|
||
it('спрашивают про КАТАЛОГ ИЗ КАРТОЧКИ, а не про путь, собранный из номера прогона', () => {
|
||
// 🪤 Номер прогона живёт ВНУТРИ карточки и с именем папки совпадать не обязан.
|
||
// Собери замок путь из номера — он спросил бы про несуществующий каталог.
|
||
let sprosili = null;
|
||
planLockVerdict({
|
||
staged: [PLAN],
|
||
runs: [{ ...running, card_dir: 'X/папка-с-другим-именем' }],
|
||
sudit: (run, o) => { sprosili = o.dir; return { vid: 'mertv', run_id: run.run_id, pochemu: '' }; },
|
||
});
|
||
expect(sprosili).toBe('X/папка-с-другим-именем');
|
||
});
|
||
|
||
it('покойник НАЗВАН владельцу: карточку надо убрать рукой, сама затея её не тронет', () => {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [running], sudit: sudyaOtvechayet('mertv') });
|
||
expect(v.pokoyniki).toHaveLength(1);
|
||
expect(v.pokoyniki[0].run_id).toBe('r-2026-08-01-2200');
|
||
});
|
||
|
||
it('покойник по ЧУЖОМУ плану владельцу не поминается — иначе строка при каждом коммите', () => {
|
||
const v = planLockVerdict({ staged: [OTHER], runs: [running], sudit: sudyaOtvechayet('mertv') });
|
||
expect(v.ok).toBe(true);
|
||
expect(v.pokoyniki).toEqual([]);
|
||
});
|
||
|
||
it('покойник рядом с живым: живой держит свой план, покойник свой не держит', () => {
|
||
const zhivoy = { run_id: 'r-живой', status: 'running', plan_path: OTHER };
|
||
const v = planLockVerdict({
|
||
staged: [PLAN, OTHER],
|
||
runs: [running, zhivoy],
|
||
sudit: (run) => ({ vid: run.run_id === 'r-живой' ? 'idyot' : 'mertv', run_id: run.run_id, pochemu: '' }),
|
||
});
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([OTHER]);
|
||
});
|
||
|
||
it('битую карточку у прибора живости НЕ спрашивают вовсе — она заперта раньше и сама по себе', () => {
|
||
// 🪤 У битой карточки не прочесть ни номера прогона, ни плана. Спроси мы прибор — он
|
||
// честно ответил бы «не прочесть», но замок и так закрыт, а лишний зов путал бы счёт.
|
||
let zvali = 0;
|
||
const v = planLockVerdict({
|
||
staged: [PLAN],
|
||
runs: [{ run_id: 'r-порван', broken: true, card_path: 'X/run.json' }],
|
||
sudit: () => { zvali += 1; return { vid: 'mertv', pochemu: '' }; },
|
||
});
|
||
expect(zvali).toBe(0);
|
||
expect(v.ok).toBe(false);
|
||
});
|
||
|
||
it('НАСТОЯЩИЙ прибор живости на карточке сегодняшнего вида: покойник план не держит', () => {
|
||
// 🔴 Здесь подставного судьи НЕТ — работает живой `sostoyanieProgona` по настоящему
|
||
// журналу на диске. Карточка сложена ровно как сегодняшняя брошенная:
|
||
// «running», журнал есть, а записи о надзирателе в нём нет, и с начала прошло 18 часов.
|
||
const staryy = Date.now() - 18 * 3600_000;
|
||
const card = { run_id: 'r-брошенный', status: 'running', plan_path: PLAN, started_at: staryy };
|
||
makeRunCard(dir, 'r-брошенный', JSON.stringify(card));
|
||
writeFileSync(join(dir, 'r-брошенный', 'events.jsonl'), '{"kind":"run_start"}\n', 'utf8');
|
||
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
|
||
expect(v.ok).toBe(true);
|
||
expect(v.pokoyniki.map(p => p.run_id)).toEqual(['r-брошенный']);
|
||
});
|
||
|
||
it('НАСТОЯЩИЙ прибор живости: прогон, который ведёт ЖИВОЙ процесс, план держит', () => {
|
||
// Живой номер процесса на этой машине заведомо есть — наш собственный.
|
||
const card = { run_id: 'r-живой', status: 'running', plan_path: PLAN, started_at: Date.now() - 3600_000 };
|
||
makeRunCard(dir, 'r-живой', JSON.stringify(card));
|
||
writeFileSync(
|
||
join(dir, 'r-живой', 'events.jsonl'),
|
||
`${JSON.stringify({ kind: 'supervisor_start', supervisor_pid: process.pid })}\n`,
|
||
'utf8',
|
||
);
|
||
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN]);
|
||
});
|
||
});
|
||
|
||
describe('ТРЕТЬЯ ДВЕРЬ: прогон-ПРОДОЛЖЕНИЕ судится тем же прибором и тем же правилом', () => {
|
||
// ━━━ ЧТО ЗДЕСЬ БЫЛО РАНЬШЕ И ПОЧЕМУ ПЕРЕПИСАНО (03.08.2026) ━━━
|
||
// 🔴 Эти сторожа стерегли ЗАПЛАТКУ: замок не отдавал продолжение прибору на суд вовсе,
|
||
// потому что прибор искал в журнале только `supervisor_start`, а продолжение объявляет
|
||
// надзирателя словом `resume_start` (run.mjs ~688) — и живой прогон получал ответ «мёртв».
|
||
// Первый из них прямо ЗАКРЕПЛЯЛ БАГ: он требовал от прибора ответа «мёртв» при заведомо
|
||
// живом номере процесса в журнале, то есть чинить прибор было нельзя, не покрасив его.
|
||
//
|
||
// Прибор починен (`VIDY_ZAPISEY_NADZIRATELYA` в zhiv-li-progon.mjs читает обе записи),
|
||
// заплатка снята, и сторожа перенацелены: теперь они стерегут ПОЧИНКУ, а не обход.
|
||
// Правило у продолжения ровно то же, что у всех: закрыто на всём, кроме доказанно мёртвого.
|
||
const prodolzhenie = { ...running, run_id: 'r-продолжение', resumed_from: 'r-вчерашний' };
|
||
|
||
it('🔴 НАСТОЯЩИЙ прибор на продолжении говорит ПРАВДУ: живой надзиратель — прогон идёт', () => {
|
||
// 🔴 Тот же опыт, что доказывал беду, теперь доказывает починку: номер в журнале — наш
|
||
// собственный, заведомо живой, а запись — та, которой пользуется только продолжение.
|
||
// Вырезание: вернуть в прибор отбор `row?.kind !== 'supervisor_start'` — сторож краснеет,
|
||
// и вместе с ним правка плана легла бы ПОД ЖИВОГО РАБОТНИКА.
|
||
const card = { run_id: 'r-п', status: 'running', plan_path: PLAN, started_at: Date.now() - 3600_000 };
|
||
makeRunCard(dir, 'r-п', JSON.stringify(card));
|
||
writeFileSync(
|
||
join(dir, 'r-п', 'events.jsonl'),
|
||
`${JSON.stringify({ kind: 'resume_start', worker: 'w1', supervisor_pid: process.pid })}\n`,
|
||
'utf8',
|
||
);
|
||
const sud = sostoyanieProgona(card, { dir: join(dir, 'r-п') });
|
||
expect(sud.vid).toBe('idyot');
|
||
expect(schitatZanyatym(sud.vid)).toBe(true);
|
||
});
|
||
|
||
it('🔴 ЖИВОЕ продолжение целиком, карточка с диска: замок ЗАКРЫТ', () => {
|
||
// Живой номер процесса на этой машине заведомо есть — наш собственный.
|
||
const card = { ...prodolzhenie, started_at: Date.now() - 3600_000 };
|
||
makeRunCard(dir, 'r-продолжение', JSON.stringify(card));
|
||
writeFileSync(
|
||
join(dir, 'r-продолжение', 'events.jsonl'),
|
||
`${JSON.stringify({ kind: 'resume_start', worker: 'w1', supervisor_pid: process.pid })}\n`,
|
||
'utf8',
|
||
);
|
||
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN]);
|
||
});
|
||
|
||
it('🔴 БРОШЕННОЕ продолжение план больше НЕ держит — заплатка держала бы его вечно', () => {
|
||
// 🔴 Ровно та беда, ради которой всё затевалось, и ровно то, что заплатка вернула бы:
|
||
// при ней замок на брошенном продолжении оставался бы закрытым НАВСЕГДА.
|
||
// Вырезание: вернуть в замок строку `if (run.resumed_from) { going.push(…); continue; }` —
|
||
// сторож краснеет.
|
||
const card = { ...prodolzhenie, started_at: Date.now() - 18 * 3600_000 };
|
||
makeRunCard(dir, 'r-продолжение', JSON.stringify(card));
|
||
writeFileSync(join(dir, 'r-продолжение', 'events.jsonl'), '{"kind":"resume_start","supervisor_pid":999999}\n', 'utf8');
|
||
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
|
||
expect(v.ok).toBe(true);
|
||
expect(v.pokoyniki.map(p => p.run_id)).toEqual(['r-продолжение']);
|
||
});
|
||
|
||
it('🔴 про продолжение прибор ТЕПЕРЬ СПРАШИВАЮТ — как про любой другой прогон', () => {
|
||
// Прежде замок не звал прибор вовсе (`zvali === 0`). Второе мнение о живости, живущее
|
||
// рядом с прибором, разъезжается с ним молча — поэтому мнение должно быть одно.
|
||
let zvali = 0;
|
||
planLockVerdict({ staged: [PLAN], runs: [prodolzhenie], sudit: () => { zvali += 1; return { vid: 'mertv' }; } });
|
||
expect(zvali).toBe(1);
|
||
});
|
||
|
||
it('🔴 владельцу больше НЕ говорят «затея пока не умеет» — эта фраза стала ложью', () => {
|
||
// 🔴 Фраза объявляла, что проверить живость продолжения нечем. Прибор умеет, и владелец
|
||
// (не программист) проверить такую строку не может — ложь в его строке дороже заплатки.
|
||
// Вырезание: вернуть в замок кусок `nesudim ? ' Отдельно: … не умеет …' : ''` — краснеет.
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [prodolzhenie], sudit: () => ({ vid: 'idyot' }) });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.reason).not.toMatch(/не умеет/i);
|
||
expect(v.reason).toMatch(/стоп всё/);
|
||
});
|
||
|
||
it('продолжение и обычный прогон судятся ОДИНАКОВО — отдельной поблажки нет ни у кого', () => {
|
||
// 🪤 Прежде разделение шло по полю `resumed_from`. Теперь его нет вовсе, и это проверяется
|
||
// прямо: на одинаковом ответе прибора оба прогона получают одинаковый вердикт.
|
||
const otvet = () => ({ vid: 'mertv', pochemu: '' });
|
||
expect(planLockVerdict({ staged: [PLAN], runs: [running], sudit: otvet }).ok).toBe(true);
|
||
expect(planLockVerdict({ staged: [PLAN], runs: [prodolzhenie], sudit: otvet }).ok).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('битая карточка прогона — замок ЗАКРЫТ, а не открыт (дыра 1)', () => {
|
||
const brokenRun = { run_id: 'r-2026-08-01-2200', status: null, plan_path: null, broken: true, card_path: 'X/run.json' };
|
||
|
||
it('карточку не прочли — правка НЕ принимается', () => {
|
||
// 🔴 Раньше порванная карточка молча исчезала из списка прогонов, и замок отвечал
|
||
// «идущих прогонов нет». Порвётся она вероятнее всего в ту самую ночь, когда кончилось
|
||
// место, — то есть замок открывался бы ровно тогда, когда он нужнее всего.
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [brokenRun] });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN]);
|
||
expect(v.reason).toContain('r-2026-08-01-2200');
|
||
});
|
||
|
||
it('в отказе названо, ЧТО делать, и назван сам нечитаемый файл', () => {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [brokenRun] });
|
||
expect(v.reason).toContain('X/run.json');
|
||
expect(v.reason).toMatch(/стоп вс[её]/i);
|
||
});
|
||
|
||
it('битая карточка запирает ВСЁ поданное: какой план по ней идёт — неизвестно', () => {
|
||
const v = planLockVerdict({ staged: [PLAN, OTHER], runs: [brokenRun] });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN, OTHER]);
|
||
});
|
||
|
||
it('битая карточка есть, а правок нет — запирать нечего, отказа нет', () => {
|
||
const v = planLockVerdict({ staged: [], runs: [brokenRun] });
|
||
expect(v.ok).toBe(true);
|
||
expect(v.blocked).toEqual([]);
|
||
});
|
||
|
||
it('рядом с битой идёт целый прогон — оба названы, а файл в списке один раз', () => {
|
||
const v = planLockVerdict({ staged: [PLAN], runs: [brokenRun, { ...running, run_id: 'r-целый' }] });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.blocked).toEqual([PLAN]);
|
||
expect(v.reason).toContain('r-2026-08-01-2200');
|
||
expect(v.reason).toContain('r-целый');
|
||
});
|
||
});
|
||
|
||
describe('readRuns — что лежит на диске (дыра 1: её не проверяла ни одна строка)', () => {
|
||
it('каталога прогонов нет вовсе — прогонов не было', () => {
|
||
expect(readRuns(join(dir, 'такого-каталога-нет'))).toEqual([]);
|
||
});
|
||
|
||
it('целая карточка читается как есть', () => {
|
||
makeRunCard(dir, 'r-1', JSON.stringify(running));
|
||
expect(readRuns(dir)).toEqual([{ ...running, card_dir: join(dir, 'r-1') }]);
|
||
});
|
||
|
||
it('к каждой карточке приложен КАТАЛОГ прогона — иначе живость спросить не у чего', () => {
|
||
// 🔴 Замок обязан уметь спросить «а идёт ли этот прогон на самом деле». Спрашивают
|
||
// по журналу `events.jsonl`, который лежит в каталоге прогона, а карточка своего
|
||
// каталога не знает. Убери `card_dir` — покраснеет здесь, и живость станет непроверяемой.
|
||
makeRunCard(dir, 'r-1', JSON.stringify(running));
|
||
makeRunCard(dir, 'r-порван', '{{{');
|
||
const runs = readRuns(dir);
|
||
// 🪤 Ищем НЕ по номеру прогона: имя папки (`r-1`) и номер внутри карточки
|
||
// (`r-2026-08-01-2200`) — разные вещи, и именно поэтому каталог нельзя вычислить из номера.
|
||
expect(runs.find(r => !r.broken).card_dir).toBe(join(dir, 'r-1'));
|
||
expect(runs.find(r => r.broken).card_dir).toBe(join(dir, 'r-порван'));
|
||
});
|
||
|
||
it('своё поле карточки замок не затирает: `dir` внутри run.json остаётся как был', () => {
|
||
// 🪤 Карточку пишет ЧУЖОЙ файл (run.mjs). Пиши мы каталог в поле `dir`, а заведись оно
|
||
// однажды у карточки — мы бы молча подменили чужое значение. Имя `card_dir` — наше.
|
||
makeRunCard(dir, 'r-1', JSON.stringify({ ...running, dir: 'что-то-своё' }));
|
||
expect(readRuns(dir)[0].dir).toBe('что-то-своё');
|
||
});
|
||
|
||
it('ПОРВАННАЯ карточка не исчезает: приходит с признаком broken и номером прогона', () => {
|
||
// 🔴 Дыра 1. Прежде здесь стоял `readJson` + `.filter(Boolean)`, и порванная карточка
|
||
// молча превращалась в «такого прогона нет». Верни `readJson` с отсевом — покраснеет здесь.
|
||
makeRunCard(dir, 'r-порван', '{"run_id": "r-порван", "status": "runn');
|
||
const runs = readRuns(dir);
|
||
expect(runs).toHaveLength(1);
|
||
expect(runs[0].broken).toBe(true);
|
||
expect(runs[0].run_id).toBe('r-порван');
|
||
expect(runs[0].card_path).toContain('run.json');
|
||
});
|
||
|
||
it('порванная карточка рядом с целой — целую видно, порванную тоже', () => {
|
||
makeRunCard(dir, 'r-1', JSON.stringify(running));
|
||
makeRunCard(dir, 'r-2', 'не json вовсе');
|
||
const runs = readRuns(dir);
|
||
expect(runs).toHaveLength(2);
|
||
expect(runs.filter(r => r.broken)).toHaveLength(1);
|
||
});
|
||
|
||
it('каталог прогона есть, а карточки в нём нет — это НЕ прогон и НЕ сбой', () => {
|
||
// «Файла нет» и «файл есть, но не читается» — разные вещи (общий прибор readJsonOrBroken).
|
||
mkdirSync(join(dir, 'r-пустой'), { recursive: true });
|
||
expect(readRuns(dir)).toEqual([]);
|
||
});
|
||
|
||
it('порванная карточка на диске доходит до отказа замка целиком, а не только по частям', () => {
|
||
makeRunCard(dir, 'r-порван', '{{{');
|
||
const v = planLockVerdict({ staged: [PLAN], runs: readRuns(dir) });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.reason).toContain('r-порван');
|
||
});
|
||
|
||
it('раскладка каталогов здесь та же, что в paths.mjs — второй раскладки в затее нет', () => {
|
||
// readRuns строит путь сам (`каталог/номер/run.json`), чтобы проверки могли подать свой
|
||
// каталог. Разъедься эта сборка с paths.runDir — живой хук читал бы не тот каталог.
|
||
expect(runDir('r-1')).toBe(join(runsDir(), 'r-1'));
|
||
});
|
||
});
|