Files
portal/tools/night/plan-model.test.mjs
T

558 lines
37 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 } from 'vitest';
import { join } from 'node:path';
import { parsePlan, validatePlan, komandaProverokPunkta, razdvoenieProverok } from './plan-model.mjs';
// 🔴 Список мерных приборов ОДИН на всю затею и живёт в `claims-audit.mjs`. Здесь он
// только читается — чтобы сторож ниже краснел на РАСХОЖДЕНИИ ворот с общим списком,
// а не сторожил свою же копию.
import { MERNYE_PRIBORY } from './claims-audit.mjs';
import { REPO_ROOT } from './paths.mjs';
const HOROSHIY = `# План задачи
### Task 1: Первый
<!-- night: tests=tools/night/a.test.mjs; files=app/app/Models/A.php -->
текст первого пункта
### Task 2: Второй
<!-- night: tests=tools/night/b.test.mjs, tools/night/c.test.mjs; files=app/app/Models/B.php, app/resources/js/B.vue; long=yes -->
текст второго пункта
`;
describe('parsePlan — пункты плана и их пометы', () => {
it('находит все пункты по заголовкам, номера идут по порядку плана', () => {
expect(parsePlan(HOROSHIY).map(i => i.id)).toEqual(['1', '2']);
});
it('читает название пункта — владелец увидит его в отказе и в сводке', () => {
expect(parsePlan(HOROSHIY)[0].title).toBe('Первый');
});
it('читает список проверок пункта — им и меряется закрытие (проверка 39а)', () => {
expect(parsePlan(HOROSHIY)[1].tests).toEqual(['tools/night/b.test.mjs', 'tools/night/c.test.mjs']);
});
it('читает список ФАЙЛОВ пункта — на нём стоят три замка куска 3', () => {
// Не разберём files — и ворота пересечения, и `mayTouch`, и список чужих файлов
// в поручении работнику получат пустоту, то есть пропустят что угодно.
expect(parsePlan(HOROSHIY)[1].files).toEqual(['app/app/Models/B.php', 'app/resources/js/B.vue']);
});
it('files не написано — пустой список, а НЕ «все файлы мира»', () => {
expect(parsePlan('### Task 9: Без файлов\n<!-- night: tests=a.test.mjs -->\n')[0].files).toEqual([]);
});
it('tests не написано — пустой список, и пункт из плана НЕ пропадает', () => {
// 🔴 Пропади он — план прошёл бы проверку 39: пункта без проверок в списке нет,
// значит и жаловаться не на что. Пункт обязан остаться и обязан быть назван виновным.
const items = parsePlan('### Task 9: Голый\nтекст\n');
expect(items).toHaveLength(1);
expect(items[0].tests).toEqual([]);
});
it('читает пометку «длинный»', () => {
const items = parsePlan(HOROSHIY);
expect(items[0].long).toBe(false);
expect(items[1].long).toBe(true);
});
it('помета на ТРЕТЬЕЙ строке под заголовком — ещё найдена', () => {
const p = '### Task 3: Третий\nпервая строка\nвторая строка\n<!-- night: tests=a.test.mjs -->\n';
expect(parsePlan(p)[0].tests).toEqual(['a.test.mjs']);
});
it('помета на ЧЕТВЁРТОЙ строке — уже НЕ найдена, окно ровно три строки', () => {
// 🪤 Без границы помета нашлась бы где угодно ниже по тексту — например, помета
// СЛЕДУЮЩЕГО пункта досталась бы предыдущему, и проверки чужого пункта считались бы
// своими. Граница названа в договоре о стыках дословно: ближайшие три строки.
const p = '### Task 3: Третий\nстрока\nстрока\nстрока\n<!-- night: tests=a.test.mjs -->\n';
expect(parsePlan(p)[0].tests).toEqual([]);
});
it('🔴 окно обрывается на заголовке СЛЕДУЮЩЕГО пункта — чужая помета своей не станет', () => {
// 🔴 Одного числа «три строки» мало: пункты идут вплотную, и пункт БЕЗ пометы
// дотягивается до пометы соседа через его заголовок. Тогда пункт без единой
// проверки выглядит как пункт с проверками — проверка 39 обходится молча,
// а закрытие меряется зеленью ЧУЖОГО пункта (проверка 38 туда же).
const p = '### Task 1: Без пометы\nтекст\n### Task 2: С пометой\n<!-- night: tests=a.test.mjs -->\n';
const items = parsePlan(p);
expect(items[0].tests).toEqual([]);
expect(items[1].tests).toEqual(['a.test.mjs']);
});
it('🔴 «long=yes» в ТЕКСТЕ пункта пометкой не считается — её ставит пишущий план (проверка 41)', () => {
// 🔴 Проверка 41 требует дословно: «пометку ставит тот, кто пишет план, а не работник
// на ходу». Работник, дописавший в текст «long=yes», не должен уметь пометить свой
// пункт длинным — иначе датчик топтания выключается изнутри, теми же руками,
// за которыми он и следит.
const p = [
'### Task 7: Работник дописал сам',
'Работник посчитал пункт длинным и написал прямо в тексте long=yes — это не помета.',
'<!-- night: tests=a.test.mjs -->',
'',
].join('\n');
expect(parsePlan(p)[0].long).toBe(false);
});
it('«#### Task 5» — четыре решётки, это не пункт', () => {
expect(parsePlan('#### Task 5: Подзаголовок\n<!-- night: tests=a.test.mjs -->\n')).toEqual([]);
});
it('составной номер пункта читается целиком: «4.11», а не «4»', () => {
expect(parsePlan('### Task 4.11: Составной\n<!-- night: tests=a.test.mjs -->\n')[0].id).toBe('4.11');
});
it('лишние пробелы и пустые куски списка срезаются', () => {
const p = '### Task 1: A\n<!-- night: tests= a.test.mjs ,, b.test.mjs ; files= ; long= YES -->\n';
const item = parsePlan(p)[0];
expect(item.tests).toEqual(['a.test.mjs', 'b.test.mjs']);
expect(item.files).toEqual([]);
expect(item.long).toBe(true);
});
it('🔴 пункт ВНУТРИ рамки-образца — не пункт, а картинка пункта', () => {
// 🔴 Взято с живых планов: `docs/superpowers/plans/2026-08-01-kusok4-...md` и
// `...kusok3-...md` целиком состоят из таких образцов — там 17 и 4 заголовка,
// и НИ ОДНОГО настоящего пункта. Разбор брал их за работу: помощник соседней задачи
// получил куски примеров как пункты и чуть не записал ложный вывод о планах.
// Примета — РАЗМЕТКА: рамка ``` … ```, а не какое-то слово внутри неё.
const p = [
'# План',
'```javascript',
'const OBRAZEC = `',
'### Task 1: Первый из примера',
'<!-- night: tests=primer.test.mjs -->',
'`;',
'```',
'### Task 7: Настоящий',
'<!-- night: tests=nastoyashchiy.test.mjs -->',
'',
].join('\n');
const items = parsePlan(p);
expect(items.map(i => i.id)).toEqual(['7']);
expect(items[0].tests).toEqual(['nastoyashchiy.test.mjs']);
});
it('рамка на тильдах — тоже рамка', () => {
const p = '~~~md\n### Task 1: Из примера\n~~~\n### Task 2: Настоящий\n<!-- night: tests=a.test.mjs -->\n';
expect(parsePlan(p).map(i => i.id)).toEqual(['2']);
});
it('🪤 помета ВНУТРИ рамки настоящему пункту не достаётся', () => {
// 🪤 Мало не считать заголовок из образца пунктом: если помету из образца отдать
// настоящему пункту, тот получит ЧУЖИЕ проверки — проверка 38 меряет закрытие
// зеленью, которой этот пункт не касался.
const p = [
'### Task 1: Настоящий без пометы',
'```',
'<!-- night: tests=iz-obrazca.test.mjs -->',
'```',
'',
].join('\n');
expect(parsePlan(p)[0].tests).toEqual([]);
});
it('🔴 «````» с кавычками в подписи — НЕ рамка: пункт ниже не пропадает', () => {
// 🔴 Вырезание второго рода, и обман не выдуманный — он лежит в живом плане
// `docs/superpowers/plans/2026-06-12-router-mentor-activation-wrapper.md` строка 238.
// Простая примета «строка начинается с трёх кавычек» считает эту строку началом
// рамки, рамка не закрывается никогда — и ТРИ настоящих пункта ниже пропадают МОЛЧА.
// По разметке это не рамка, а вставка кода в строке: у рамки на кавычках в подписи
// кавычек быть не может.
const p = [
'Решение владельца: план несёт блок',
'```` ```verified-context-json ```` с массивом записей.',
'### Task 4: Настоящий после ловушки',
'<!-- night: tests=a.test.mjs -->',
'',
].join('\n');
expect(parsePlan(p).map(i => i.id)).toEqual(['4']);
});
it('рамка открыта четырьмя кавычками — тройка внутри её не закрывает', () => {
const p = [
'````md',
'```js',
'### Task 1: Всё ещё пример',
'```',
'````',
'### Task 2: Настоящий',
'<!-- night: tests=a.test.mjs -->',
'',
].join('\n');
expect(parsePlan(p).map(i => i.id)).toEqual(['2']);
});
it('🔴 рамку забыли закрыть, и она съела пункт — падаем ГРОМКО с номером строки', () => {
// 🔴 Самый дорогой случай: незакрытая рамка глотает ЧАСТЬ плана. Всё съесть —
// не беда, ворота скажут «в плане нет пунктов». А съесть половину — ночь отработает
// урезанный план, и владелец об этом не узнает НИКОГДА.
const p = [
'### Task 1: Настоящий',
'<!-- night: tests=a.test.mjs -->',
'```',
'кто-то забыл закрыть рамку',
'### Task 2: Съеденный',
'<!-- night: tests=b.test.mjs -->',
'',
].join('\n');
expect(() => parsePlan(p)).toThrow(/рамк/i);
expect(() => parsePlan(p)).toThrow(/3/); // номер строки, где рамка открыта
});
it('рамку забыли закрыть, но она ничего не съела — план не рушим', () => {
// 🪤 Так кончаются СЕМЬ живых планов в docs/superpowers/plans: лишняя тройка кавычек
// в самом конце. Ронять на этом целый план — ложная тревога: не потеряно ничего.
const p = [
'### Task 1: Настоящий',
'<!-- night: tests=a.test.mjs -->',
'```',
'хвост без закрытия',
'',
].join('\n');
expect(parsePlan(p).map(i => i.id)).toEqual(['1']);
});
it('🔴 подан не текст — падает ГРОМКО, а не «в плане нет пунктов»', () => {
// 🪤 Пустышка, тихо отвечающая «всё хорошо», за три куска ловилась пять раз.
// Отдай мы тут пустой список — ворота напечатали бы владельцу «в вашем плане
// нет ни одного пункта», хотя план цел, а сломалось чтение файла.
expect(() => parsePlan(null)).toThrow(/текст плана/i);
expect(() => parsePlan(undefined)).toThrow(/текст плана/i);
});
});
describe('validatePlan — проверка 39 (пункт без проверок) и проверка 41 (перебор длинных)', () => {
it('план без пунктов вовсе — не проходит', () => {
const v = validatePlan(parsePlan('# просто текст, заголовков пунктов нет'));
expect(v.ok).toBe(false);
expect(v.reason).toMatch(/ни одного пункта/i);
});
it('🔴 два пункта с ОДНИМ номером — план не проходит, номер назван', () => {
// 🔴 Номер пункта — это КЛЮЧ: по нему ведут очередь, по нему запирают файлы, по нему
// работник заявляет «закрыл». Два пункта с одним номером — двое работников закроют
// друг друга, и оба отчитаются правдой. Замолчать это нельзя.
// Живьём такое есть: docs/superpowers/plans/2026-05-20-observer-instrument-expansion.md
// несёт два настоящих «### Task 13» подряд.
const p = [
'### Task 13: Первый', '<!-- night: tests=a.test.mjs -->',
'### Task 13: Он же, переписанный', '<!-- night: tests=b.test.mjs -->', '',
].join('\n');
const v = validatePlan(parsePlan(p));
expect(v.ok).toBe(false);
expect(v.reason).toContain('13');
expect(v.reason).toMatch(/повтор|один и тот же номер/i);
});
it('🪤 повторяются несколько номеров — названы ВСЕ', () => {
const p = [
'### Task 1: A', '<!-- night: tests=a.test.mjs -->',
'### Task 1: B', '<!-- night: tests=b.test.mjs -->',
'### Task 2: C', '<!-- night: tests=c.test.mjs -->',
'### Task 2: D', '<!-- night: tests=d.test.mjs -->', '',
].join('\n');
const v = validatePlan(parsePlan(p));
expect(v.reason).toContain('1');
expect(v.reason).toContain('2');
});
it('🔴 повтор ловится РАНЬШЕ, чем «пункт без проверок»', () => {
// 🔴 Иначе владельцу назовут виновным «Task 1» — а таких в плане два, и он не поймёт,
// который чинить. Пока ключ не уникален, любое имя в отказе двусмысленно.
const p = [
'### Task 1: С проверкой', '<!-- night: tests=a.test.mjs -->',
'### Task 1: Без проверок', 'текст', '',
].join('\n');
const v = validatePlan(parsePlan(p));
expect(v.ok).toBe(false);
expect(v.reason).toMatch(/повтор|один и тот же номер/i);
expect(v.reason).not.toMatch(/ни одной проверки/i);
});
it('номера разные — ложной тревоги нет', () => {
const p = [
'### Task 1: A', '<!-- night: tests=a.test.mjs -->',
'### Task 1.1: B', '<!-- night: tests=b.test.mjs -->', '',
].join('\n');
expect(validatePlan(parsePlan(p)).ok).toBe(true);
});
it('пункт без единой проверки — план не проходит, и виновный НАЗВАН (проверка 39б)', () => {
const bad = '### Task 1: Без проверок\nтекст\n### Task 2: С проверкой\n<!-- night: tests=a.test.mjs -->\n';
const v = validatePlan(parsePlan(bad));
expect(v.ok).toBe(false);
expect(v.reason).toContain('Task 1');
expect(v.reason).toMatch(/ни одной проверки/i);
});
it('🪤 виновных несколько — названы ВСЕ, а не первый попавшийся', () => {
// 🪤 Владелец чинит план по этому списку. Назови мы один пункт — он поправит его,
// запустит снова, получит отказ про второй, и так каждую ночь по одному пункту.
const bad = '### Task 1: A\nтекст\n### Task 2: B\nтекст\n### Task 3: C\n<!-- night: tests=a.test.mjs -->\n';
const v = validatePlan(parsePlan(bad));
expect(v.reason).toContain('Task 1');
expect(v.reason).toContain('Task 2');
});
it('длинных ровно половина — проходит', () => {
const p = '### Task 1: A\n<!-- night: tests=a.test.mjs; long=yes -->\n### Task 2: B\n<!-- night: tests=b.test.mjs -->\n';
expect(validatePlan(parsePlan(p)).ok).toBe(true);
});
it('длинных больше половины — плана к запуску нет, и оба числа названы (проверка 41)', () => {
const p = [
'### Task 1: A', '<!-- night: tests=a.test.mjs; long=yes -->',
'### Task 2: B', '<!-- night: tests=b.test.mjs; long=yes -->',
'### Task 3: C', '<!-- night: tests=c.test.mjs -->', '',
].join('\n');
const v = validatePlan(parsePlan(p));
expect(v.ok).toBe(false);
expect(v.reason).toMatch(/больше половины/i);
expect(v.reason).toContain('2');
expect(v.reason).toContain('3');
});
it('один пункт, и он длинный — тоже перебор (граница «больше половины»)', () => {
const p = '### Task 1: A\n<!-- night: tests=a.test.mjs; long=yes -->\n';
expect(validatePlan(parsePlan(p)).ok).toBe(false);
});
it('план прошёл — числа отданы, их печатает владельцу задача 6', () => {
const v = validatePlan(parsePlan(HOROSHIY));
expect(v.ok).toBe(true);
expect(v.reason).toBeNull();
expect(v.items_count).toBe(2);
expect(v.long_count).toBe(1);
});
it('🔴 подан не список пунктов — падает ГРОМКО, а не «план хорош»', () => {
// 🔴 Это самый дорогой из тихих сбоев: зовущий (задача 6) промахнулся мимо разбора,
// а ворота прочли «ok» и пустили в работу план, которого никто не смотрел.
expect(() => validatePlan(null)).toThrow(/список пунктов/i);
expect(() => validatePlan({ items: [] })).toThrow(/список пунктов/i);
});
});
describe('komandaProverokPunkta — чем гонять проверки ИМЕННО ЭТОГО пункта (проверка 38)', () => {
it('🔴 команда строится ИЗ `tests` этого пункта, а не из общего набора', () => {
// 🔴🔴 Сердце проверки 38. Возьми правило общий набор — «зелень по этому пункту»
// стала бы «зеленью вообще», и пункт засчитывался бы за чужую работу при полностью
// зелёных проверках. Ломается ТИХО, поэтому сторожится отсюда и до самого замка.
expect(komandaProverokPunkta({ id: '1', tests: ['app/tests/Feature/PervyyTest.php'] }))
.toBe('php artisan test --filter=PervyyTest');
expect(komandaProverokPunkta({ id: '2', tests: ['tools/night/vtoroy.test.mjs'] }))
.toBe('npm run test:tools -- tools/night/vtoroy.test.mjs');
});
it('у пункта проверок нет — команды НЕТ, а не «весь набор на всякий случай»', () => {
// 🪤 Отдай тут общий набор — пункт без проверок закрылся бы чужой зеленью, то есть
// проверка 39 обходилась бы через замок.
expect(komandaProverokPunkta({ id: '3', tests: [] })).toBeNull();
expect(komandaProverokPunkta(null)).toBeNull();
});
// 🔴🔴 ЗДЕСЬ СТОЯЛ СТОРОЖ, ОХРАНЯВШИЙ БЕДУ. Он требовал дословно:
// komandaProverokPunkta({ tests: ['app/tests/Feature/A.php', 'tools/night/b.test.mjs'] })
// === 'npm run test:tools -- app/tests/Feature/A.php tools/night/b.test.mjs'
// и был зелёным. Довод при нём стоял такой: «`php artisan test` наш `.mjs` не запустит
// вовсе, поэтому смешанный набор идёт нашим прогоном». Довод верен наполовину — и
// ровно этой половиной и опасен: наш прогон php-шную проверку не запустит ТОЖЕ.
//
// 🔴 ЗАМЕРЕНО ЖИВЬЁМ 02.08.2026, не выведено рассуждением. Эта самая команда выполнена
// руками из корня хозяйства:
// npm run test:tools -- app/tests/Feature/ProjectSyncStatusFromPivotTest.php \
// tools/night/plan-fingerprint.test.mjs
// Ответ: «Test Files 1 passed (1) · Tests 15 passed (15)», код возврата 0. То есть
// из ДВУХ названных проверок отработала ОДНА — наша; php-шная не запускалась вовсе,
// и ни слова о ней сказано не было. Имя php-шного файла vitest берёт за строку-отбор,
// ни одного файла по ней не находит и молча идёт дальше — потому что второе имя
// что-то нашло.
//
// 🔴 ЧЕМ ЭТО КОНЧАЕТСЯ У ЗАМКА. Прогон отработал с нулём и что-то напечатал — значит
// и заслон «программа ничего не сказала» (item-audit.mjs) его не поймает. Замок
// объявляет проверки зелёными и засчитывает пункт, у которого половина проверок
// не запускалась. Владелец наутро читает «пункт закрыт, проверки прогнаны надзирателем
// и зелены» — про бэкенд, которого никто не мерил.
//
// 🪤 Отдельно замерено, что тихо это ТОЛЬКО у смешанного: набор из одних php-шных имён,
// поданный тому же прогону, отвечает кодом 1 («No test files found»). Беда рождается
// именно смешением, а не самим php-шным именем.
it('🔴 проверки пункта РАЗНОГО рода — команды НЕТ: половину молча не меряем', () => {
// Одной командой два рода не гоняются. Врать половиной хуже, чем не мерить вовсе:
// «замерить нечем» замок читает как «пункт не засчитан», а половинную зелень — как
// «пункт закрыт».
expect(komandaProverokPunkta({ id: '4', tests: ['app/tests/Feature/A.php', 'tools/night/b.test.mjs'] }))
.toBeNull();
// Порядок не важен: php-шная может стоять и второй.
expect(komandaProverokPunkta({ id: '5', tests: ['tools/night/b.test.mjs', 'app/tests/Feature/A.php'] }))
.toBeNull();
// 🪤 Вырезание второго рода: род считается по концу имени БЕЗ оглядки на регистр.
// Считай мы его строго — «A.PHP» рядом с нашей проверкой снова уехало бы в наш прогон,
// и беда вернулась бы целиком, с зелёным сторожем над ней.
expect(komandaProverokPunkta({ id: '6', tests: ['app/tests/Feature/A.PHP', 'tools/night/b.test.mjs'] }))
.toBeNull();
});
// 🔴🔴 «А ЧТО ПРИДЁТ СЮДА, ЕСЛИ ПРИШЛЮТ НЕ ДА И НЕ НЕТ, А ТРЕТЬЕ?»
//
// Пункт приходит к замку не только из разбора плана, но и с диска — из карточки работника
// (`next-item.json`). Порченый или правленный рукой JSON легко даёт `tests` СТРОКОЙ,
// а не списком. Замерено 02.08.2026: первый вид этой защиты падал на такой строке
// с `TypeError`, причём падал ВЫШЕ того места, где замок ловит сбои прогона, — то есть
// не «пункт не засчитан», а надзиратель насмерть посреди ночи.
//
// 🔴 Ответ выбран «не мой вопрос»: разного рода тут не видно, судить нечего, и правило
// молчит. Негодный `tests` ловят двое других — ворота плана (там `Array.isArray`
// прописан прямо) и сам замок, у которого этот сбой попадает в разбор «замерить нечем».
it('🪤 `tests` пришли не списком — правило не падает и никого не обвиняет', () => {
expect(() => razdvoenieProverok({ id: '1', tests: 'a.php,b.test.mjs' })).not.toThrow();
expect(razdvoenieProverok({ id: '1', tests: 'a.php,b.test.mjs' })).toBeNull();
expect(razdvoenieProverok({ id: '2', tests: 42 })).toBeNull();
expect(razdvoenieProverok(null)).toBeNull();
// А ворота плана такой пункт всё равно не пускают — своим отказом, не этим.
expect(validatePlan([{ id: '1', tests: 'a.php,b.test.mjs', files: [] }]).ok).toBe(false);
});
it('🪤 однородный набор по-прежнему считается — лишнего отказа тут нет', () => {
// Лишний отказ — тоже беда: пункт, который вправду можно померить, ночь бы не взяла.
expect(komandaProverokPunkta({ id: '7', tests: ['app/tests/Feature/A.php', 'app/tests/Unit/B.php'] }))
.toBe('php artisan test --filter=A|B');
expect(komandaProverokPunkta({ id: '8', tests: ['tools/night/a.test.mjs', 'tools/night/b.test.mjs'] }))
.toBe('npm run test:tools -- tools/night/a.test.mjs tools/night/b.test.mjs');
});
it('🪤 «php» ВНУТРИ имени нашей проверки — это наша проверка, а не php-шная', () => {
// 🪤 Вырезание второго рода: считай род по «встречается ли .php в имени» вместо
// «кончается ли имя на .php» — и наша же проверка про php-шную часть затеи
// (`php-shnye.test.mjs`, `A.php.test.mjs`) объявлялась бы чужого рода. Пункт с двумя
// нашими проверками получил бы отказ на ровном месте, и владелец правил бы план,
// в котором править нечего. Лишний отказ — тоже беда.
expect(komandaProverokPunkta({ id: '9', tests: ['tools/night/php-shnye.test.mjs', 'tools/night/b.test.mjs'] }))
.toBe('npm run test:tools -- tools/night/php-shnye.test.mjs tools/night/b.test.mjs');
expect(razdvoenieProverok({ id: '9', tests: ['tools/night/A.php.test.mjs', 'tools/night/b.test.mjs'] }))
.toBeNull();
});
});
// 🔴🔴 ВОРОТА ПЛАНА НЕ ПУСКАЮТ ПУНКТ СО СМЕШАННЫМИ ПРОВЕРКАМИ.
//
// 🔴 ЗАЧЕМ ЗДЕСЬ, А НЕ ТОЛЬКО У ЗАМКА — довод тот же, что и у мерного прибора в `files=`:
// это самое дешёвое из мест. Владелец правит план утром за минуту, и правка простая —
// разбить пункт надвое. Оставь мы одну защиту у замка — план бы прошёл, работник взял бы
// пункт, закрыл бы его, а замок каждый раз отвечал бы «замерить нечем»: пункт не
// засчитывается, работник берёт его снова, и так до утра. Это ровно та ночь, которая
// сгорает молча и выглядит как работа.
describe('🔴 ворота плана — проверки пункта разного рода', () => {
it('план со смешанным пунктом не годен, и названы пункт, обе проверки и что делать', () => {
const p = '### Task 1: И бэкенд, и затея\n<!-- night: tests=app/tests/Feature/A.php, tools/night/b.test.mjs -->\n';
const v = validatePlan(parsePlan(p));
expect(v.ok).toBe(false);
expect(v.reason).toContain('Task 1');
// Владелец не программист: ему нужны имена обеих проверок, а не слово «смешанный».
expect(v.reason).toContain('app/tests/Feature/A.php');
expect(v.reason).toContain('tools/night/b.test.mjs');
// И прямое указание, что делать: разбить пункт на два.
expect(v.reason).toMatch(/раздел|разбей|разбить|два пункта/i);
});
it('🪤 виновных несколько — названы ВСЕ разом, а не первый попавшийся', () => {
const p = [
'### Task 1: A', '<!-- night: tests=a.php, b.test.mjs -->',
'### Task 2: B', '<!-- night: tests=b.test.mjs, c.test.mjs -->',
'### Task 3: C', '<!-- night: tests=x.test.mjs, y.php -->', '',
].join('\n');
const v = validatePlan(parsePlan(p));
expect(v.ok).toBe(false);
expect(v.reason).toContain('Task 1');
expect(v.reason).toContain('Task 3');
expect(v.reason).not.toContain('Task 2');
});
it('🪤 однородные пункты план не роняют — ни php-шные, ни наши', () => {
const p = [
'### Task 1: Бэкенд', '<!-- night: tests=app/tests/Feature/A.php, app/tests/Unit/B.php -->',
'### Task 2: Затея', '<!-- night: tests=tools/night/a.test.mjs -->', '',
].join('\n');
expect(validatePlan(parsePlan(p)).ok).toBe(true);
});
});
// 🔴🔴 ВОРОТА ПЛАНА НЕ ПУСКАЮТ ПЛАН С МЕРНЫМ ПРИБОРОМ В `files=`.
//
// 🔴 ЗАЧЕМ ИМЕННО ЗДЕСЬ, А НЕ ТОЛЬКО В ЗАМКЕ. Замерено 02.08.2026: пункт с `package.json`
// в `files=` проходил ворота, работник честно занимал файл, правил его и коммитил, а сверка
// тронутых файлов (`claims-audit.mjs`) видела прибор, ОТКАТЫВАЛА правку и ОТБИРАЛА пункт —
// и так каждые полминуты до утра. Владелец наутро читал «работал восемь часов» и видел ноль
// сделанного. Это ХУЖЕ отказа: отказ виден сразу, а такая ночь сгорает молча и выглядит
// как работа. Ворота ловят ту же беду за минуту до её начала и словами, по которым владелец
// правит план утром.
describe('🔴 ворота плана — мерный прибор в `files=` пункта', () => {
it('план с `files=package.json` не годен, и названы пункт, файл и довод', () => {
const p = '### Task 1: Правка наряда\n<!-- night: tests=a.test.mjs; files=package.json -->\n';
const v = validatePlan(parsePlan(p));
expect(v.ok).toBe(false);
expect(v.reason).toContain('Task 1');
expect(v.reason).toContain('package.json');
// Довод берётся из самого списка — владельцу нужен не запрет, а причина запрета.
expect(v.reason).toContain('наряд');
});
it('🔴 запирается КАЖДАЯ позиция общего списка — второго списка тут не заведено', () => {
// 🪤 Заведись здесь свой список — он разошёлся бы с `claims-audit.mjs` молча: ворота
// пускали бы то, что сверка потом откатывает, и ночь сгорала бы ровно так же.
// Этот сторож покраснеет и на своём списке, и на позиции, забытой при пополнении общего.
for (const pribor of MERNYE_PRIBORY) {
const put = pribor.vetka ? `${pribor.put}/vglub.mjs` : pribor.put;
const p = `### Task 1: A\n<!-- night: tests=a.test.mjs; files=${put} -->\n`;
expect(validatePlan(parsePlan(p)).ok, `«${put}» прошёл мимо ворот`).toBe(false);
}
});
it('обходы написанием: точка, обратная косая, регистр, шаг назад через `..`', () => {
// 🪤 Вырезание второго рода: автор плана не пишет прибор в лоб, а называет его иначе.
for (const put of ['./package.json', '.\\package.json', 'PACKAGE.JSON',
'app/../package.json', 'tools/night/../../package.json', 'tools\\night\\supervisor.mjs']) {
const p = `### Task 1: A\n<!-- night: tests=a.test.mjs; files=${put} -->\n`;
expect(validatePlan(parsePlan(p)).ok, `обход «${put}» ПРОШЁЛ мимо ворот`).toBe(false);
}
});
it('полный путь от корня хозяйства — тот же прибор, а не другой файл', () => {
// 🪤 Владелец набирает пути из окна Windows целиком; несведённый полный путь ворота
// считали бы посторонним файлом и пустили бы план.
const put = join(REPO_ROOT, 'package.json');
const p = `### Task 1: A\n<!-- night: tests=a.test.mjs; files=${put} -->\n`;
expect(validatePlan(parsePlan(p)).ok).toBe(false);
});
it('🪤 похожее имя рядом — план ГОДЕН: ворота не смеют запирать лишнее', () => {
// Лишний запрет — тоже беда: владелец правит план, которого нечего править.
for (const put of ['tools/nightly/x.mjs', 'tools/night-plan.mjs', 'package.json.bak',
'app/package.json', 'docs/зам.md']) {
const p = `### Task 1: A\n<!-- night: tests=a.test.mjs; files=${put} -->\n`;
expect(validatePlan(parsePlan(p)).ok, `«${put}» заперт зря`).toBe(true);
}
});
it('🪤 виновных несколько — названы ВСЕ разом, а не первый попавшийся', () => {
// 🔴 Тот же довод, что и у пунктов без проверок: получать отказ по одному пункту
// за ночь владельцу незачем.
const p = [
'### Task 1: A', '<!-- night: tests=a.test.mjs; files=package.json -->',
'### Task 2: B', '<!-- night: tests=b.test.mjs; files=app/A.php -->',
'### Task 3: C', '<!-- night: tests=c.test.mjs; files=tools/night/supervisor.mjs -->', '',
].join('\n');
const v = validatePlan(parsePlan(p));
expect(v.ok).toBe(false);
expect(v.reason).toContain('Task 1');
expect(v.reason).toContain('Task 3');
expect(v.reason).not.toContain('Task 2');
});
it('🔴 прибор в `tests=` — это НЕ запрет: проверки затеи гонять и нужно', () => {
// 🪤 Запрети мы прибор в `tests=` — ни один пункт нельзя было бы померить проверками
// самой затеи, а именно ими она и меряется. Запрет стоит на ПРАВКЕ файла, а `tests=`
// говорит, чем ГОНЯТЬ, и файла не трогает.
const p = '### Task 1: A\n<!-- night: tests=tools/night/supervisor.test.mjs; files=app/A.php -->\n';
expect(validatePlan(parsePlan(p)).ok).toBe(true);
});
});