Files
portal/tools/night/deferred.test.mjs
T

193 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { NIGHT_ROOT } from './paths.mjs';
import { markDeferred, readDeferred, clearDeferred, deferredDir } from './deferred.mjs';
let dir;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'night-deferred-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
const SLED = {
task_id: 'run-2026-08-01-01',
plan_path: 'docs/superpowers/plans/2026-08-01-proba.md',
reason: 'not_finished',
left_items: ['3', '4'],
at: 1,
};
describe('markDeferred — след отложенной задачи ложится и переживает прогон', () => {
it('пометил — след читается, и все поля на месте', () => {
markDeferred(dir, SLED);
const s = readDeferred(dir, SLED.task_id);
expect(s.task_id).toBe(SLED.task_id);
expect(s.plan_path).toBe(SLED.plan_path);
expect(s.reason).toBe('not_finished');
expect(s.left_items).toEqual(['3', '4']);
expect(s.at).toBe(1);
});
it('🟡 defect_list заведён ПУСТЫМ списком — его наполняет кусок 7, вида файла не меняя', () => {
markDeferred(dir, SLED);
expect(readDeferred(dir, SLED.task_id).defect_list).toEqual([]);
});
it('след лежит именно в `<dirPath>/<task_id>.json` — это увидит рука владельца', () => {
markDeferred(dir, SLED);
expect(existsSync(join(dir, `${SLED.task_id}.json`))).toBe(true);
});
it('🔴 слово случая не из закрытого списка — падает ГРОМКО', () => {
// 🔴 Список закрыт и в нём двое: `not_finished` и `rejected`. Незнакомое слово
// ворота возврата прочли бы как «не rejected», то есть НЕ спросили бы списка брака,
// и задача вернулась бы в работу без него — проверка 29 отменилась бы молча.
expect(() => markDeferred(dir, { ...SLED, reason: 'ne-vlezla' })).toThrow(/not_finished|rejected/);
});
it('🔴 слово случая не передано вовсе — падает ГРОМКО', () => {
expect(() => markDeferred(dir, { ...SLED, reason: undefined })).toThrow(/not_finished|rejected/);
});
it('🔴 имени задачи нет — падает ГРОМКО, а не кладёт след в файл без имени', () => {
expect(() => markDeferred(dir, { ...SLED, task_id: '' })).toThrow(/имя задачи/i);
});
it('🪤 имя задачи с косой чертой или с «..» — падает ГРОМКО', () => {
// 🪤 Имя задачи становится именем файла. «../беда» увело бы запись из каталога
// следов вовсе, и след потерялся бы молча — то есть отложенная задача назавтра
// подхватилась бы сама.
expect(() => markDeferred(dir, { ...SLED, task_id: '../беда' })).toThrow(/имя задачи/i);
expect(() => markDeferred(dir, { ...SLED, task_id: 'а/б' })).toThrow(/имя задачи/i);
});
it('🪤 имя из одних точек — падает ГРОМКО', () => {
expect(() => markDeferred(dir, { ...SLED, task_id: '..' })).toThrow(/имя задачи/i);
expect(() => markDeferred(dir, { ...SLED, task_id: '.' })).toThrow(/имя задачи/i);
});
it('🔴 две точки ПОСРЕДИ имени — имя годное, след ложится', () => {
// 🔴 Строгость шире нужной ломает то, что стережёт. Черты уже запрещены, к имени
// дописывается «.json» — шага вверх из «..» посреди имени не выйдет никак. Руби мы
// и такие имена, владелец получил бы громкий отказ на своём же имени задачи,
// след не лёг бы вовсе, и отложенная задача назавтра подхватилась бы САМА —
// ровно то, что запрещают проверки 28 и 29.
markDeferred(dir, { ...SLED, task_id: 'run-2026-08-01..02' });
expect(readDeferred(dir, 'run-2026-08-01..02').task_id).toBe('run-2026-08-01..02');
expect(existsSync(join(dir, 'run-2026-08-01..02.json'))).toBe(true);
});
});
describe('readDeferred — следа нет и след порван это РАЗНЫЕ ответы', () => {
it('следа нет — отдаёт null: это норма первой ночи любой задачи', () => {
expect(readDeferred(dir, 'run-которого-не-было')).toBeNull();
});
it('🔴 след ПОРВАН — падает ГРОМКО, а не «следа нет»', () => {
// 🔴🔴 Самое дорогое место задачи. Тихий null здесь означал бы: ворота решили,
// что задача не откладывалась, и пустили её в работу САМИ — ровно то, что запрещают
// проверки 28 и 29, и ровно в ту ночь, когда запись порвалась.
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'run-порван.json'), '{ это не разбирается', 'utf8');
expect(() => readDeferred(dir, 'run-порван')).toThrow(/след/i);
});
// ── Найдено ВЫРЕЗАНИЕМ ВТОРОГО РОДА 02.08.2026, планом не предусмотрено ──────
//
// 🔴🔴 Вопрос был: как ночь могла бы подхватить отложенную задачу В ОБХОД защиты?
// Ответ: заставить чтение сказать «следа нет» там, где след ЕСТЬ. Перебрал восемь
// написаний живьём; семь не пролезли, ОДНО пролезло насквозь.
//
// 🪤 Слово `null` — ГОДНЫЙ json. Разбор его не роняет, он честно отдаёт `null`, а `null`
// у этого чтения означает «следа нет». То есть защита от порванного следа стояла ровно
// на том, что порванное НЕ РАЗБИРАЕТСЯ, — а тут разбирается. Оборвись запись на четырёх
// знаках, ляг в файл чужая заглушка, сотри кто-нибудь тело руками — и ворота решили бы,
// что задача не откладывалась, и пустили бы её в работу САМИ, без слова владельца.
// Это ровно проверки 28 и 29 разом, и молча.
//
// 🔴 Лечится не проверкой на слово `null`, а вопросом «а это вообще ЗАПИСЬ?»: число,
// список и строка — тоже годный json и тоже не след. Их прежнее чтение отдавало как есть,
// и они уходили дальше в ворота, где падали уже с чужим, непонятным словом.
it('🔴 в следе лежит слово null — годный json, но НЕ запись: падает ГРОМКО', () => {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'run-null.json'), 'null', 'utf8');
expect(() => readDeferred(dir, 'run-null')).toThrow(/след/i);
});
it('🔴 в следе число, список или строка — тоже не запись, тоже ГРОМКО', () => {
mkdirSync(dir, { recursive: true });
for (const [imya, telo] of [['run-chislo', '0'], ['run-spisok', '[]'], ['run-stroka', '""']]) {
writeFileSync(join(dir, `${imya}.json`), telo, 'utf8');
expect(() => readDeferred(dir, imya), `${imya}: ${telo} прошло как след`).toThrow(/след/i);
}
});
it('🪤 а годная запись по-прежнему читается — строгость не съела своё же дело', () => {
// 🪤 Обратная половина: «падай на всём, что не нравится» лечится тем же движением,
// каким ломается. Настоящий след обязан пройти.
markDeferred(dir, SLED);
expect(readDeferred(dir, SLED.task_id).reason).toBe('not_finished');
});
it('следы разных задач не путаются', () => {
markDeferred(dir, SLED);
markDeferred(dir, { ...SLED, task_id: 'run-второй', reason: 'rejected' });
expect(readDeferred(dir, SLED.task_id).reason).toBe('not_finished');
expect(readDeferred(dir, 'run-второй').reason).toBe('rejected');
});
});
describe('clearDeferred — след снимается, когда задача вернулась в работу', () => {
it('снял — следа больше нет', () => {
markDeferred(dir, SLED);
clearDeferred(dir, SLED.task_id);
expect(readDeferred(dir, SLED.task_id)).toBeNull();
});
it('снял один — второй остался на месте', () => {
markDeferred(dir, SLED);
markDeferred(dir, { ...SLED, task_id: 'run-второй' });
clearDeferred(dir, SLED.task_id);
expect(readDeferred(dir, 'run-второй')).not.toBeNull();
});
it('снимать нечего — молчим и файла на пустом месте не заводим', () => {
clearDeferred(dir, 'run-которого-не-было');
expect(existsSync(join(dir, 'run-которого-не-было.json'))).toBe(false);
});
});
describe('deferredDir — где лежат следы, знает ровно этот файл', () => {
it('🔴 путь к каталогу следов вывезен НАРУЖУ и ведёт в `.night/deferred`', () => {
// 🔴🔴 Шов с задачами 5 и 6. Обе ввозят это имя дословно и кладут/ищут след по нему.
// Не вывези — каждая написала бы свой путь; разойдись они хоть на знак, след клался бы
// в одно место, а искался в другом, и отложенная задача подхватилась бы назавтра САМА.
expect(typeof deferredDir).toBe('function');
expect(String(deferredDir()).replace(/\\/g, '/')).toMatch(/\.night\/deferred$/);
});
it('🔴🔴 корень `.night/` НЕ заведён здесь заново — он ВВЕЗЁН из `paths.mjs`', () => {
// 🔴🔴 Эта проверка стережёт второй корень `.night/` (договор о стыках, п. 24).
// Сойдись путь по значению, но окажись он посчитан в `deferred.mjs` своими руками —
// первая же правка `paths.mjs` разведёт их, след ляжет в одно место, а искаться
// будет в другом, и отложенная задача подхватится назавтра САМА.
// Поэтому мало сравнить путь: сверяем и то, ОТКУДА он взят.
expect(deferredDir()).toBe(join(NIGHT_ROOT, 'deferred'));
const tekst = readFileSync(fileURLToPath(new URL('./deferred.mjs', import.meta.url)), 'utf8');
expect(tekst).toMatch(/import \{ NIGHT_ROOT \} from '\.\/paths\.mjs';/);
// Второго корня в этом файле быть не должно ни в каком виде.
expect(tekst).not.toMatch(/['"`]\.night['"`]/);
expect(tekst).not.toMatch(/nightRoot/);
});
});
// ── Долг куска 5 (Р14) и куска 7 (блок 6а), внесён 01.08 ───────────────────────
describe('markDeferred — третье слово impossible из закрытого списка', () => {
it('impossible — след кладётся, а не бросает (список теперь из трёх)', () => {
markDeferred(dir, { ...SLED, reason: 'impossible' });
expect(readDeferred(dir, SLED.task_id).reason).toBe('impossible');
});
});