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

555 lines
36 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, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
readCommand, consumeCommand, unknownCommands,
closeItem, afterItemDone, afterTaskDone,
deferredTaskGate, reshyonnyeSNevypolnimymi,
} from './items.mjs';
let dir;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'night-cmd-'));
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
// Положить команду в каталог. Ровно так это делает панель, бот или рука владельца.
function polozhit(name, body = { at: 1 }) {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, name), JSON.stringify(body), 'utf8');
}
describe('readCommand — что владелец велел этому работнику', () => {
it('каталога команд нет вовсе — не падаем, отвечаем «команды нет»', () => {
// 🪤 Каталог `.night/commands` заводит рука владельца, и в первую ночь его может
// не быть. Брошенная тут ошибка убила бы надзирателя молча — тот самый класс беды.
expect(readCommand(join(dir, 'нет-такого'), 'w-1')).toBeNull();
});
it('каталог есть, но пуст — работаем дальше', () => {
mkdirSync(dir, { recursive: true });
expect(readCommand(dir, 'w-1')).toBeNull();
});
it('положили личную «паузу» — работник её видит, и видно, что она личная', () => {
polozhit('w-1.pause.json');
const cmd = readCommand(dir, 'w-1');
expect(cmd.cmd).toBe('pause');
expect(cmd.common).toBe(false);
expect(cmd.file).toBe('w-1.pause.json');
});
it('чужую личную команду работник НЕ видит', () => {
polozhit('w-2.stop.json');
expect(readCommand(dir, 'w-1')).toBeNull();
});
it('«стоп всё» видит любой работник, не только адресат', () => {
polozhit('all.stopall.json');
const cmd = readCommand(dir, 'w-9');
expect(cmd.cmd).toBe('stopall');
expect(cmd.common).toBe(true);
});
it('«стоп всё» главнее личной паузы — отдаётся первой', () => {
// 🔴 Порядок не украшение: получи работник сперва паузу, он доделывал бы шаг
// до двадцати минут, пока владелец ждёт остановки всего прогона.
polozhit('all.stopall.json');
polozhit('w-1.pause.json');
expect(readCommand(dir, 'w-1').cmd).toBe('stopall');
});
it('слово не из закрытого списка не читается вовсе — мы не выдумываем, что оно значит', () => {
polozhit('w-1.udali-vsyo.json');
expect(readCommand(dir, 'w-1')).toBeNull();
});
it('файл команды порван — команда всё равно прочитана, тело просто пустое', () => {
// 🪤 Порванный файл — это отсутствие сведений, а не повод уронить надзирателя.
// Слово «стоп» стоит в самом имени файла, и его достаточно.
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'w-1.stop.json'), '{ это не json', 'utf8');
expect(readCommand(dir, 'w-1').cmd).toBe('stop');
});
it('тело команды доезжает целиком — вместе с тем, кто и когда её отдал', () => {
polozhit('w-1.stop.json', { at: 777, by: 'владелец' });
const cmd = readCommand(dir, 'w-1');
expect(cmd.at).toBe(777);
expect(cmd.by).toBe('владелец');
});
it('🔴 тело файла НЕ перебивает слово из его имени — иначе закрытый список обходится', () => {
// 🔴 Положи владелец (или кто угодно) в файл «паузы» тело {"cmd":"stopall"} — и работник
// был бы погашен, хотя имя файла говорит «пауза». Слово команды берётся ТОЛЬКО из имени
// файла: имя — это и есть закрытый список. Тело несёт сведения, а не приказ.
polozhit('w-1.pause.json', { at: 1, cmd: 'stopall', common: true, file: 'подделка' });
const cmd = readCommand(dir, 'w-1');
expect(cmd.cmd).toBe('pause');
expect(cmd.common).toBe(false);
expect(cmd.file).toBe('w-1.pause.json');
});
});
describe('unknownCommands — владелец узнаёт, что его команду НЕ поняли', () => {
it('непонятое слово НАЗЫВАЕТСЯ — с именем файла и самим словом, и файл остаётся лежать', () => {
// 🔴🔴 Решение диспетчера. Прежде не происходило ничего: владелец думал, что «пауза»
// нажата, а её никто не понял, и файл лежал до утра молча.
polozhit('w-1.udali-vsyo.json');
const spisok = unknownCommands(dir, 'w-1');
expect(spisok.length).toBe(1);
expect(spisok[0].word).toBe('udali-vsyo');
expect(spisok[0].file).toBe('w-1.udali-vsyo.json');
// Не съедено молча: файл владельца на месте, он и есть доказательство.
expect(existsSync(join(dir, 'w-1.udali-vsyo.json'))).toBe(true);
});
it('общее непонятое слово тоже называется — «всем» касается и этого работника', () => {
polozhit('all.vsyo-brosit.json');
const spisok = unknownCommands(dir, 'w-9');
expect(spisok.length).toBe(1);
expect(spisok[0].common).toBe(true);
});
it('🔴 понятные команды и чужие файлы в этот список НЕ попадают', () => {
// 🔴 Страховка от прибора, который называет непонятым ВСЁ подряд: владелец получал бы
// строку «не понял» на каждую исправную команду и перестал бы читать сводку вовсе.
polozhit('w-1.pause.json'); // понятная — молчим
polozhit('all.stopall.json'); // понятная общая — молчим
polozhit('w-2.udali-vsyo.json'); // чужая личная — о ней скажет надзиратель w-2
polozhit('zametka.txt'); // вообще не команда
expect(unknownCommands(dir, 'w-1')).toEqual([]);
});
it('каталога команд нет вовсе — пустой список, и не падаем', () => {
expect(unknownCommands(join(dir, 'нет-такого'), 'w-1')).toEqual([]);
});
});
describe('consumeCommand — личную убираем сразу, общую только когда исполнили ВСЕ', () => {
it('прочитанная ЛИЧНАЯ команда убирается, чтобы не сработать дважды', () => {
polozhit('w-1.stop.json');
const r = consumeCommand(dir, 'w-1', 'stop');
expect(r.personal_removed).toBe(true);
expect(readCommand(dir, 'w-1')).toBeNull();
});
it('личной команды не было — так и сказано, и ничего лишнего не тронуто', () => {
polozhit('all.pause.json');
const r = consumeCommand(dir, 'w-1', 'stop');
expect(r.personal_removed).toBe(false);
expect(existsSync(join(dir, 'all.pause.json'))).toBe(true);
});
it('«стоп всё» НЕ съедается первым прочитавшим — её видят все семеро', () => {
// 🪤 Ровно эта беда и была: первый исполнивший стирал файл, шестеро работали дальше.
polozhit('all.stopall.json');
const semero = ['w-1', 'w-2', 'w-3', 'w-4', 'w-5', 'w-6', 'w-7'];
for (const w of semero.slice(0, 6)) {
expect(readCommand(dir, w).cmd).toBe('stopall');
consumeCommand(dir, w, 'stopall', { concerns: semero });
}
// Седьмой всё ещё видит команду — она его тоже касается.
expect(readCommand(dir, 'w-7').cmd).toBe('stopall');
const r = consumeCommand(dir, 'w-7', 'stopall', { concerns: semero });
// Исполнили все — только теперь файла нет.
expect(r.common_removed).toBe(true);
expect(readCommand(dir, 'w-7')).toBeNull();
expect(existsSync(join(dir, 'all.stopall.json'))).toBe(false);
});
it('исполнившие копятся в САМОМ ФАЙЛЕ, а не в памяти одного надзирателя', () => {
// 🔴 Надзиратели — разные процессы. Держи мы список исполнивших в памяти, каждый
// видел бы свой собственный, и общая команда не убралась бы никогда.
polozhit('all.stopall.json');
consumeCommand(dir, 'w-1', 'stopall', { concerns: ['w-1', 'w-2'] });
const body = JSON.parse(readFileSync(join(dir, 'all.stopall.json'), 'utf8'));
expect(body.done).toEqual(['w-1']);
});
it('исполнивший общую команду второй раз её не получает — иначе гасился бы по ней вечно', () => {
polozhit('all.stopall.json');
consumeCommand(dir, 'w-1', 'stopall', { concerns: ['w-1', 'w-2'] });
expect(readCommand(dir, 'w-1')).toBeNull();
expect(readCommand(dir, 'w-2').cmd).toBe('stopall');
});
it('состав не назван — общий файл НЕ удаляется: лучше промолчать, чем снять команду с тех, до кого она не дошла', () => {
// 🔴 Это дословное правило договора о стыках. Убери его — и первый же надзиратель,
// не сумевший прочитать `run.json`, снял бы «стоп всё» со всего прогона.
polozhit('all.stopall.json');
const r = consumeCommand(dir, 'w-1', 'stopall', { concerns: [] });
expect(r.common_removed).toBe(false);
expect(existsSync(join(dir, 'all.stopall.json'))).toBe(true);
});
it('исполнение ЛИЧНОЙ команды не съедает общую, и наоборот', () => {
polozhit('all.pause.json');
polozhit('w-1.stop.json');
consumeCommand(dir, 'w-1', 'stop', { concerns: ['w-1', 'w-2'] });
expect(readCommand(dir, 'w-2').cmd).toBe('pause');
expect(readCommand(dir, 'w-1').cmd).toBe('pause');
});
});
// ── Кусок 4, задача 2: закрытие пункта и очередь ───────────────────────────────
describe('closeItem — «пункт закрыт» это доказательство, а не слово (проверки 38, 39б, 40)', () => {
const item = { id: '4.11', tests: ['tools/night/a.test.mjs'] };
it('коммит есть и проверки этого пункта зелёные — пункт закрыт', () => {
const r = closeItem({ item, commit_sha: 'abc123', tests_green: true, on_assumption: false });
expect(r.closed).toBe(true);
expect(r.reason).toBeNull();
});
it('проверки не зелёные — НЕ засчитан, и названо, какие именно (проверка 38)', () => {
const r = closeItem({ item, commit_sha: 'abc123', tests_green: false, on_assumption: false });
expect(r.closed).toBe(false);
expect(r.reason).toContain('tools/night/a.test.mjs');
});
it('🔴 tests_green не передан вовсе — НЕ засчитан, а не «наверное зелено»', () => {
// 🪤 «Успех» с пустым ответом — не успех. Прогон проверок мог не состояться вовсе
// (программа не найдена, вышло время), и молчаливое «наверное зелено» тут страшнее
// обвала: пункт засчитался бы, ни разу не будучи проверенным.
const r = closeItem({ item, commit_sha: 'abc123' });
expect(r.closed).toBe(false);
});
it('коммита нет — НЕ засчитан, сколько бы зелени ни было', () => {
const r = closeItem({ item, commit_sha: null, tests_green: true, on_assumption: false });
expect(r.closed).toBe(false);
expect(r.reason).toMatch(/не сохранен/i);
});
it('у пункта в плане не записано ни одной проверки — закрыть нельзя (проверка 39б)', () => {
const r = closeItem({ item: { id: 'x', tests: [] }, commit_sha: 'abc123', tests_green: true });
expect(r.closed).toBe(false);
expect(r.reason).toMatch(/ни одной проверки/i);
});
it('пункт «в расчёте на ответ», ответа нет — закрыть нельзя, хоть всё зелено (проверка 40)', () => {
const r = closeItem({ item, commit_sha: 'abc123', tests_green: true, on_assumption: true, answer_received: false });
expect(r.closed).toBe(false);
expect(r.reason).toMatch(/в расчёте на ответ/i);
});
it('ответ владельца пришёл — тот же пункт закрывается (проверка 40)', () => {
const r = closeItem({ item, commit_sha: 'abc123', tests_green: true, on_assumption: true, answer_received: true });
expect(r.closed).toBe(true);
});
it('🔴 «в расчёте на ответ» названо РАНЬШЕ красных проверок — иначе чинят не то', () => {
// 🔴 Владелец и работник читают ПЕРВУЮ названную причину. Скажи мы «проверки красные»
// там, где на деле не пришёл ответ владельца, — работник полночи чинил бы код,
// который и так верен, а ждать надо было слова хозяина.
const r = closeItem({ item, commit_sha: 'abc123', tests_green: false, on_assumption: true, answer_received: false });
expect(r.reason).toMatch(/в расчёте на ответ/i);
expect(r.reason).not.toMatch(/зелёные/i);
});
it('🔴 пункт не передан вовсе — падает ГРОМКО: это сбой зова, а не свойство пункта', () => {
expect(() => closeItem({ commit_sha: 'abc123', tests_green: true })).toThrow(/пункт/i);
});
it('🪤 ни один отказ не остаётся БЕЗ причины — работнику кладут её в ящик', () => {
// 🪤 Пустая причина в ящике — это «не засчитано, а почему — угадай». Проверяем
// три отказных случая, не связанных с зеленью: нет коммита, нет проверок в плане,
// «в расчёте на ответ».
const sluchai = [
closeItem({ item, commit_sha: null, tests_green: true }),
closeItem({ item: { id: 'x', tests: [] }, commit_sha: 'abc123', tests_green: true }),
closeItem({ item, commit_sha: 'abc123', tests_green: true, on_assumption: true, answer_received: false }),
];
for (const r of sluchai) {
expect(typeof r.reason).toBe('string');
expect(r.reason.length).toBeGreaterThan(10);
}
});
});
describe('afterItemDone — закрыл пункт, взял следующий того же плана (проверка 24)', () => {
const items = [{ id: '1' }, { id: '2' }, { id: '3' }];
it('закрыл пункт — назван следующий пункт ТОГО ЖЕ плана', () => {
const r = afterItemDone({ items, done: ['1'], answered_during_day: false });
expect(r.next_item.id).toBe('2');
expect(r.extinguish_reason).toBeNull();
});
it('пункты плана кончились — брать нечего, и это СКАЗАНО', () => {
const r = afterItemDone({ items, done: ['1', '2', '3'], answered_during_day: false });
expect(r.next_item).toBeNull();
expect(r.extinguish_reason).toMatch(/кончились/i);
});
it('очередь идёт по порядку ПЛАНА, а не по порядку закрытия', () => {
// Закрыт средний пункт — следующим берётся первый незакрытый по плану, а не четвёртый.
expect(afterItemDone({ items, done: ['2'], answered_during_day: false }).next_item.id).toBe('1');
});
it('🪤 номер пункта числом, а не строкой — всё равно узнан закрытым', () => {
// 🪤 Владелец пишет в плане то `2`, то `"2"`. Сравнивай мы знак в знак — закрытый
// пункт выдавался бы работнику снова, и он крутился бы на нём до утра.
const spisok = [{ id: 1 }, { id: 2 }];
expect(afterItemDone({ items: spisok, done: ['1'], answered_during_day: false }).next_item.id).toBe(2);
});
it('дождался ответа днём — доделал свой пункт и гаснет, нового не берёт', () => {
const r = afterItemDone({ items, done: ['1'], answered_during_day: true });
expect(r.next_item).toBeNull();
expect(r.extinguish_reason).toMatch(/днём/i);
});
it('дождался ответа днём — сказано, что остаток плана сам не подхватится', () => {
const r = afterItemDone({ items, done: ['1'], answered_during_day: true });
expect(r.task_note).toMatch(/только по слову владельца/i);
});
it('🔴 items не список — падает ГРОМКО, а не «пункты кончились»', () => {
// 🔴 Тихий ответ «кончились» погасил бы всех работников задачи на ровном месте,
// и владелец прочёл бы утром «работа сделана», когда её никто не делал.
expect(() => afterItemDone({ items: null, done: [], answered_during_day: false })).toThrow(/список пунктов/i);
});
it('🔴 done не передан — падает ГРОМКО, а не «ничего не закрыто»', () => {
// 🔴 «Ничего не закрыто» вернуло бы работнику самый первый пункт плана — тот,
// который он только что и закрыл.
expect(() => afterItemDone({ items, answered_during_day: false })).toThrow(/закрыт/i);
});
});
describe('afterTaskDone — задача кончилась, следующая не начинается (проверка 25)', () => {
it('все работники задачи гаснут — перечислены поимённо', () => {
const r = afterTaskDone({ task_id: 't-1', workers: ['w-1', 'w-2'] });
expect(r.task_id).toBe('t-1');
expect(r.extinguish_workers).toEqual(['w-1', 'w-2']);
});
it('🔴 следующая задача в том же прогоне НЕ начинается', () => {
expect(afterTaskDone({ task_id: 't-1', workers: ['w-1'] }).start_next_task).toBe(false);
});
it('🔴 поднят флаг «пора приёмщику», и сказано, что заводит его кусок 7', () => {
// 🟡 Здесь закрыта ЧАСТЬ проверки 25: флаг поднят и ляжет в `run.json` (задача 5).
// Самого приёмщика по этому флагу заводит кусок 7 — тут его заводить нечем.
const r = afterTaskDone({ task_id: 't-1', workers: ['w-1'] });
expect(r.reviewer_starts).toBe(true);
expect(r.note).toMatch(/приём/i);
});
it('🔴 работников не дали — падает ГРОМКО: гасить некого, это сбой зова', () => {
// 🪤 Тихий пустой список прочитался бы как «все погашены», и задача считалась бы
// законченной при живых работниках.
expect(() => afterTaskDone({ task_id: 't-1', workers: [] })).toThrow(/работник/i);
});
});
// ── Граница двух дверей: кто ВЫДАЁТ пункт, а кто только говорит «есть ли что брать» ──
//
// 🔴🔴 Эти проверки не ищут поломку — они ДЕРЖАТ ДОГОВОРЁННОСТЬ. Словами она записана
// над `afterItemDone` в `items.mjs`, но слова в коде переживают ровно до первого, кто
// их не прочтёт. Здесь она замерена числами: на ОДНОМ И ТОМ ЖЕ хозяйстве две работы
// отвечают РАЗНОЕ, и это нарочно.
//
// 🪤 Сторож двусторонний. Он покраснеет и тогда, когда `nextFreeItem` разучится смотреть
// на занятые файлы (обе двери начнут отвечать «1»), и тогда, когда `afterItemDone`
// НАУЧАТ занятиям (обе начнут отвечать «2»). Второе — не поломка, а смена решения:
// покраснев, сторож отправит правщика к владельцу, а не даст поменять подписи молча.
// Подписи обеих работ ждёт задача 5.
describe('граница двух дверей — «выдать пункт» и «есть ли что брать» это РАЗНЫЕ работы', () => {
const punkty = [
{ id: '1', files: ['app/a.php'] },
{ id: '2', files: ['app/b.php'] },
];
it('🔴 сосед сидит на пункте 1: выдающая дверь даёт 2, спрашивающая говорит 1', async () => {
const { claimFiles, nextFreeItem } = await import('./file-claims.mjs');
claimFiles(dir, 'w-1', ['app/a.php']);
// Дверь, которая ВЫДАЁТ: занятые файлы соседа она видит и пункт 1 пропускает.
expect(nextFreeItem(dir, 'w-2', punkty, { done: [] }).item.id).toBe('2');
// Дверь, которая только СПРАШИВАЕТ «есть ли что брать»: занятий она не знает вовсе
// и называет пункт, на котором сидит сосед. Брать этот ответ за выдачу пункта нельзя.
expect(afterItemDone({ items: punkty, done: [] }).next_item.id).toBe('1');
});
it('🔴 занятия ей передать НЕЧЕМ — лишние сведения она молча не примет', async () => {
// 🔴 Ровно тут прячется соблазн: «да передам ей каталог прогона, и всё сойдётся».
// Пока в её подписи такого входа нет, ответ от него не меняется ни на знак.
// Появится — эта проверка покраснеет, и разговор с владельцем состоится ДО правки.
const { claimFiles } = await import('./file-claims.mjs');
claimFiles(dir, 'w-1', ['app/a.php']);
const sLishnim = afterItemDone({ items: punkty, done: [], dirPath: dir, workerId: 'w-2' });
const bezLishnego = afterItemDone({ items: punkty, done: [] });
expect(sLishnim.next_item.id).toBe(bezLishnego.next_item.id);
expect(sLishnim.next_item.id).toBe('1');
});
});
// ── Кусок 4, задача 3: ворота возврата отложенной задачи ───────────────────────
describe('deferredTaskGate — отложенная задача сама не подхватывается (проверки 28, 29, 30)', () => {
it('🔴 не влезла в прогон, слова владельца нет — В РАБОТУ НЕ ИДЁТ (проверка 28)', () => {
const r = deferredTaskGate({ reason: 'not_finished', owner_said_continue: false, plan_revalidated: true });
expect(r.may_run).toBe(false);
expect(r.reason).toMatch(/продолжай/i);
});
it('🔴 не прошла приёмку, слова владельца нет — тоже не идёт (проверка 29а)', () => {
const r = deferredTaskGate({ reason: 'rejected', owner_said_continue: false, plan_revalidated: true, defect_list: ['кнопка не там'] });
expect(r.may_run).toBe(false);
});
it('🔴 слово владельца есть, а план заново НЕ проверен — ещё нельзя (проверка 30)', () => {
// 🔴 Пока задача лежала, продукт менялся. План, годный вчера, сегодня может
// не годиться вовсе — и работник всю ночь делал бы по нему не то.
const r = deferredTaskGate({ reason: 'not_finished', owner_said_continue: true, plan_revalidated: false });
expect(r.may_run).toBe(false);
expect(r.reason).toMatch(/заново/i);
});
it('слово владельца есть и план перепроверен — можно (проверки 28 и 30, случай «не влезла»)', () => {
const r = deferredTaskGate({ reason: 'not_finished', owner_said_continue: true, plan_revalidated: true });
expect(r.may_run).toBe(true);
expect(r.reason).toBeNull();
});
it('вернулась с приёмки со списком брака — можно, и делает её НОВЫЙ работник (проверка 29г)', () => {
const r = deferredTaskGate({ reason: 'rejected', owner_said_continue: true, plan_revalidated: true, defect_list: ['кнопка не там'] });
expect(r.may_run).toBe(true);
expect(r.fresh_worker_required).toBe(true);
});
it('🔴 вернулась с приёмки, а списка брака нет — нельзя (проверка 29в)', () => {
const r = deferredTaskGate({ reason: 'rejected', owner_said_continue: true, plan_revalidated: true, defect_list: [] });
expect(r.may_run).toBe(false);
expect(r.reason).toMatch(/список брака/i);
});
it('«не влезла в прогон» списка брака НЕ требует — брака и не было', () => {
// 🪤 Спроси мы список брака у задачи, которая просто не успела, — владелец не смог бы
// продолжить её вовсе: назвать ему нечего.
expect(deferredTaskGate({ reason: 'not_finished', owner_said_continue: true, plan_revalidated: true, defect_list: [] }).may_run).toBe(true);
});
it('🔴 разрешено — список брака отдан ДАЛЬШЕ тем же составом', () => {
// 🔴 Иначе брак до работника не дойдёт: ворота его прочли, кивнули и потеряли,
// а работник получил бы «тот же план» без единого слова о том, что в нём не так.
const r = deferredTaskGate({ reason: 'rejected', owner_said_continue: true, plan_revalidated: true, defect_list: ['кнопка не там', 'цвет не тот'] });
expect(r.defect_list).toEqual(['кнопка не там', 'цвет не тот']);
});
it('🔴 отказано — нового работника заводить НЕ велено', () => {
const r = deferredTaskGate({ reason: 'not_finished', owner_said_continue: false, plan_revalidated: true });
expect(r.fresh_worker_required).toBe(false);
});
it('🔴 вид случая не из закрытого списка — падает ГРОМКО, а не «наверное можно»', () => {
// 🔴 Испорченный или чужой след не должен молча превратиться в «это не rejected,
// значит списка брака не спрашиваем».
expect(() => deferredTaskGate({ reason: 'ne-vlezla', owner_said_continue: true, plan_revalidated: true })).toThrow(/not_finished|rejected/);
});
it('🔴🔴 план НЕ ТОТ, по которому задачу откладывали — не идёт, и оба пути названы (проверка 29б)', () => {
// 🔴🔴 Проверка 29 требует дословно: задача возвращается «с тем же планом». Без этого
// «продолжай» владельца, сказанное про одну работу, молча пустило бы в дело другую:
// доделывалось бы НЕ то, что не доделали, и заметить это по зелёным проверкам нельзя.
const r = deferredTaskGate({
reason: 'rejected', owner_said_continue: true, plan_revalidated: true,
defect_list: ['кнопка не там'],
plan_path: 'docs/superpowers/plans/старый.md',
plan_path_now: 'docs/superpowers/plans/совсем-другой.md',
});
expect(r.may_run).toBe(false);
expect(r.reason).toMatch(/план не тот/i);
expect(r.reason).toContain('docs/superpowers/plans/старый.md');
expect(r.reason).toContain('docs/superpowers/plans/совсем-другой.md');
// 🪤 Тот же план, записанный с другой косой чертой, — это ТОТ ЖЕ план.
expect(deferredTaskGate({
reason: 'not_finished', owner_said_continue: true, plan_revalidated: true,
plan_path: 'docs\\superpowers\\plans\\один.md',
plan_path_now: 'docs/superpowers/plans/один.md',
}).may_run).toBe(true);
});
});
// ── Отступление от плана задачи 3, названное вслух и замеренное ────────────────
//
// 🔴🔴 План велел завести в `items.mjs` СВОЮ мелочь сравнения пути (сводила только косые
// черты). Не завёл: тот же вопрос «это один и тот же путь?» в затее уже решает `samePath`
// из `paths.mjs`, и на второй его копии там стоят два сторожа. Ниже — не чистота ради
// чистоты, а замер: своя мелочь плана ОТКАЗАЛА БЫ ВЛАДЕЛЬЦУ на его же плане.
describe('🔴 «тот же план» меряется общим сравнивателем затеи, а не своей мелочью', () => {
const PLAN_OT_KORNYA = 'docs/superpowers/plans/один.md';
const PLAN_POLNYY = `${process.cwd().replace(/\\/g, '/')}/docs/superpowers/plans/один.md`;
it('🔴🔴 в следе путь ОТ КОРНЯ, а владелец запустил ПОЛНЫМ путём — это ТОТ ЖЕ план', () => {
// 🔴 Случай живой, а не выдуманный: примечание к `samePath` в `paths.mjs` прямо
// говорит, что владелец набирает полный путь (`night run C:\…\план.md`), а в след
// ложится путь от корня хранилища. Сведи мы одни косые — ворота ответили бы
// «план не тот» на ОДНОМ И ТОМ ЖЕ плане: отложенная задача не вернулась бы НИКОГДА,
// и владелец не смог бы починить это ничем. Защита от подмены плана съела бы
// сам возврат.
expect(deferredTaskGate({
reason: 'not_finished', owner_said_continue: true, plan_revalidated: true,
plan_path: PLAN_OT_KORNYA, plan_path_now: PLAN_POLNYY,
}).may_run).toBe(true);
});
it('🔴 «./план.md» и «план.md» — тоже один план', () => {
expect(deferredTaskGate({
reason: 'not_finished', owner_said_continue: true, plan_revalidated: true,
plan_path: PLAN_OT_KORNYA, plan_path_now: `./${PLAN_OT_KORNYA}`,
}).may_run).toBe(true);
});
it('🔴 и при этом РАЗНЫЕ планы по-прежнему разводятся — защита не размякла', () => {
// 🪤 Обратная половина: «сделай посвободнее» лечится тем же движением, каким ломается.
// Полный путь к ДРУГОМУ плану обязан остаться другим планом.
expect(deferredTaskGate({
reason: 'not_finished', owner_said_continue: true, plan_revalidated: true,
plan_path: PLAN_OT_KORNYA,
plan_path_now: `${process.cwd().replace(/\\/g, '/')}/docs/superpowers/plans/совсем-другой.md`,
}).may_run).toBe(false);
});
it('🔴 своего сравнивателя пути в `items.mjs` не заведено — он ВВЕЗЁН', () => {
// 🔴 Сторож на класс «две работы про одно правило разъезжаются молча». Такой же
// стоит в `paths.test.mjs`; здесь он смотрит с другой стороны — на зовущего.
//
// 🪤 Первый вид этого сторожа ловил только ИМЯ (`function odinVidPuti(`) и на живом
// вырезании 02.08.2026 остался ЗЕЛЁНЫМ: свою мелочь вписали безымянной строкой прямо
// в теле ворот, и сторож её не увидел. Значит ловим ТЕЛО — само приведение косых, —
// как это сделано у сторожа в `paths.test.mjs`.
const tekst = readFileSync(fileURLToPath(new URL('./items.mjs', import.meta.url)), 'utf8');
expect(tekst).toMatch(/import \{ samePath \} from '\.\/paths\.mjs';/);
expect(tekst).not.toMatch(/function\s+odinVidPuti\s*\(/);
expect(tekst).not.toMatch(/function\s+same\w*Path\s*\(/);
expect(tekst).not.toContain(String.raw`replace(/\\/g, '/')`);
});
});
// ── Долг куска 5 (Р14) и куска 7 (блок 6а), внесён 01.08 ───────────────────────
describe('reshyonnyeSNevypolnimymi — невыполнимый пункт считается решённым, а не висящим', () => {
it('невыполнимый пункт входит в решённые наравне с засчитанным', () => {
expect(reshyonnyeSNevypolnimymi(['1', '2'], ['3'])).toEqual(expect.arrayContaining(['1', '2', '3']));
expect(reshyonnyeSNevypolnimymi(['1', '2'], ['3'])).toHaveLength(3);
});
it('список невыполнимых пуст — ответ совпадает с одними засчитанными', () => {
expect(reshyonnyeSNevypolnimymi(['1', '2'], [])).toEqual(['1', '2']);
});
it('🪤 один и тот же пункт числом и строкой в разных списках — не задваивается', () => {
// 🪤 Владелец пишет план то числом, то строкой (та же беда, что у `afterItemDone`).
// Сравни мы знак в знак — один и тот же пункт попал бы в решённые дважды.
expect(reshyonnyeSNevypolnimymi([1, '2'], ['2', '3'])).toEqual(['1', '2', '3']);
});
});