193 lines
13 KiB
JavaScript
193 lines
13 KiB
JavaScript
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');
|
||
});
|
||
});
|