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

532 lines
32 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, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { GATE_ORDER, startGates, capacityFree, filesDisjoint } from './gates.mjs';
import { diskState } from './disk.mjs';
import { budgetState, recordMeasure } from './budget.mjs';
import { sealPlan, checkSeal } from './plan-fingerprint.mjs';
import { specApproved, papersCommitted } from './paper-gates.mjs';
// 🔴 ЭТОТ ФАЙЛ НЕ ЗАПУСКАЕТ НИ ОДНОЙ ВНЕШНЕЙ ПРОГРАММЫ — и не должен начать.
// Раньше две проверки ниже заводили настоящее хранилище через `git init/add/commit`. Предмет
// у них другой: держат ли ВОРОТА, когда настоящее правило `papersCommitted` сказало «нет».
// Настоящий `git` этого не доказывал ничего, зато на занятой машине отвечал то за полсекунды,
// то за пять — и ворота краснели от соседей, а не от поломки. Теперь хранилище подставное,
// а само правило — настоящее, как и было. Живой `git` проверяют две отдельные проверки
// в `paper-gates.test.mjs`. Сторож `zhivoy-zapusk-v-proverkah.test.mjs` покраснеет, если
// внешнюю программу сюда вернут.
function podstavnoeHranilishche({ sohraneny = [] } = {}) {
return (_git, args) => {
const komanda = args[0];
const put = args[args.length - 1];
if (komanda === 'ls-files') {
if (!sohraneny.includes(put)) throw new Error(`нет в хранилище: ${put}`);
return `${put}\n`;
}
if (komanda === 'status') return '';
throw new Error(`подставное хранилище не знает команды «${komanda}»`);
};
}
// Бумага замысла, по которой «запускают» в этих проверках: отметка «замысел проверен»
// привязана к бумаге, и без пути к ней настоящий `specApproved` отказывает (задача 4).
const SPEC = 'docs/superpowers/specs/2026-07-29-dispetcher-tri-kuska-chto-i-zachem.md';
// Вход ворот — теми же именами полей и той же формы, что уже принимает `planRun` куска 1.
const good = {
budget: { may_start: true },
owner: { last_summary_at: null },
disk: { ok: true, free_gb: 40, min_gb: 5 },
spec: { ok: true },
papers: { ok: true },
planSeal: { ok: true },
previousWorkers: [],
capacity: { tasks_running: 0, max_tasks: 2 },
newTaskFiles: ['a.php'],
runningTasksFiles: [],
};
describe('startGates — состав и порядок ворот', () => {
it('ворот ровно девять, имена и порядок — дословно из договора о стыках', () => {
expect(GATE_ORDER).toEqual([
'запас',
'отклик владельца',
'место на сервере',
'замысел проверен',
'бумаги сохранены',
'отпечаток плана',
'от прошлого прогона никого',
'потолки задач и работников',
'задачи не пересекаются по файлам',
]);
});
it('все ответы даны и все хорошие — запуск разрешён', () => {
const r = startGates(good);
expect(r.ok).toBe(true);
expect(r.failed).toEqual([]);
expect(r.reason).toBeNull();
});
it('запас проверяется ПЕРВЫМ: при дыре в учёте назван он, а не место', () => {
const r = startGates({
...good,
budget: { may_start: false, reason: 'Остаток посчитать не удалось' },
disk: { ok: false, reason: 'На сервере кончается место' },
});
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/посчитать не удалось/i);
});
it('пять готовых ворот при плохих ответах держат ВСЕ ПЯТЬ — именами и в порядке договора', () => {
const r = startGates({
...good,
budget: { may_start: false, reason: 'запаса нет' },
disk: { ok: false, reason: 'места нет' },
spec: { ok: false, reason: 'замысел не проверен' },
papers: { ok: false, reason: 'бумаги не сохранены' },
planSeal: { ok: false, reason: 'план изменён после проверки' },
});
expect(r.failed.map(f => f.gate)).toEqual([
'запас',
'место на сервере',
'замысел проверен',
'бумаги сохранены',
'отпечаток плана',
]);
});
it('называет ВСЕ непройденные ворота списком, а не только первые', () => {
const r = startGates({
...good,
budget: { may_start: false, reason: 'нет запаса' },
disk: { ok: false, reason: 'нет места' },
});
expect(r.failed.length).toBe(2);
expect(r.all_reasons).toEqual(['запас: нет запаса', 'место на сервере: нет места']);
});
it('в `failed` лежат ПАРЫ «ворота — причина», а не строки и не одно имя', () => {
// 🔴 Шов с задачей 8: она печатает владельцу имя ворот и причину по отдельности.
// Приняла бы `failed` за одно имя — владелец увидел бы в отказе мусор вместо причины,
// и никакая проверка причин этого бы не показала.
const r = startGates({
...good,
budget: { may_start: false, reason: 'нет запаса' },
disk: { ok: false, reason: 'нет места' },
});
expect(Array.isArray(r.failed)).toBe(true);
for (const f of r.failed) {
expect(typeof f.gate).toBe('string');
expect(typeof f.reason).toBe('string');
}
expect(r.failed.map(f => f.gate)).toEqual(['запас', 'место на сервере']);
expect(r.reason).toBe('нет запаса');
});
it('двое ворот куска 7 ещё пропускают, а двое ворот куска 3 уже ДЕРЖАТ', () => {
// Кусок 2 оставил здесь честную запись «все четверо пропускают». Кусок 3 написал
// свои двое, и теперь запись другая: отклик владельца и «от прошлого прогона никого»
// по-прежнему пропускают (их пишет кусок 7), а потолки и пересечение по файлам держат.
const r = startGates({
...good,
owner: { ok: false, reason: 'владелец не отозвался' },
previousWorkers: [{ worker_id: 'w-2', status: 'waiting_answer_idle' }],
capacity: { tasks_running: 9, max_tasks: 2, workers_in_task: 0, max_workers: 7 },
newTaskFiles: ['a.php'],
runningTasksFiles: [['a.php']],
});
expect(r.ok).toBe(false);
expect(r.failed.map(f => f.gate)).toEqual([
'потолки задач и работников',
'задачи не пересекаются по файлам',
]);
});
});
describe('ответа не дали вовсе — отказ, а не пропуск', () => {
it('по запасу ответа нет — отказ, и названы эти ворота', () => {
const r = startGates({ ...good, budget: undefined });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('запас');
expect(r.reason).toMatch(/ответа не дали/i);
});
it('по месту на сервере ответа нет — отказ', () => {
const r = startGates({ ...good, disk: undefined });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('место на сервере');
});
it('по замыслу ответа нет — отказ', () => {
const r = startGates({ ...good, spec: undefined });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('замысел проверен');
});
it('по бумагам ответа нет — отказ', () => {
const r = startGates({ ...good, papers: undefined });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('бумаги сохранены');
});
it('по отпечатку плана ответа нет — отказ', () => {
const r = startGates({ ...good, planSeal: undefined });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('отпечаток плана');
});
});
describe('startGates с НАСТОЯЩИМИ ответами правил задач 1–4', () => {
let dir;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'night-gates-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('место замерить не удалось — настоящий diskState держит ворота места', () => {
const r = startGates({ ...good, disk: diskState(null, 5) });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('место на сервере');
expect(r.reason).toMatch(/замерить не удалось/i);
});
it('места хватает — настоящий diskState эти ворота не держит', () => {
expect(startGates({ ...good, disk: diskState(40, 5) }).ok).toBe(true);
});
it('замысел без рецензентов — настоящий specApproved держит ворота замысла (проверка 44)', () => {
const spec = specApproved({ spec_path: SPEC, reviewed_by: [], owner_said_yes_at: 123 }, SPEC);
const r = startGates({ ...good, spec });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('замысел проверен');
});
it('рецензенты прошли, а владелец молчит — те же ворота держат (вторая половина проверки 44)', () => {
const spec = specApproved({ spec_path: SPEC, reviewed_by: ['исполнитель', 'недоверчивый'], owner_said_yes_at: null }, SPEC);
const r = startGates({ ...good, spec });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('замысел проверен');
});
it('замысел проверен, но ПО ДРУГОЙ бумаге — те же ворота держат', () => {
// 🔴 Отметка одна на всё хозяйство: без сверки с бумагой вчерашнее «да» открывало бы
// запуск по любому другому замыслу.
const spec = specApproved(
{ spec_path: SPEC, reviewed_by: ['исполнитель', 'недоверчивый'], owner_said_yes_at: 123 },
'docs/superpowers/specs/2026-08-01-sovsem-drugaya-bumaga.md',
);
const r = startGates({ ...good, spec });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('замысел проверен');
});
it('есть и рецензенты, и слово владельца по ЭТОЙ бумаге — ворота замысла не держат', () => {
const spec = specApproved({ spec_path: SPEC, reviewed_by: ['исполнитель', 'недоверчивый'], owner_said_yes_at: 123 }, SPEC);
expect(startGates({ ...good, spec }).ok).toBe(true);
});
it('бумаги не сохранены — настоящий papersCommitted держит ворота бумаг (проверка 45)', () => {
// 🔴 Правило здесь настоящее — подставлено только хранилище, у которого оно спрашивает.
const run = podstavnoeHranilishche({ sohraneny: [] });
const r = startGates({ ...good, papers: papersCommitted(dir, ['docs/plan.md'], { run }) });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('бумаги сохранены');
expect(r.reason).toContain('docs/plan.md');
});
it('бумаги сохранены — ворота бумаг не держат', () => {
const run = podstavnoeHranilishche({ sohraneny: ['docs/plan.md'] });
expect(startGates({ ...good, papers: papersCommitted(dir, ['docs/plan.md'], { run }) }).ok).toBe(true);
});
it('план изменён после проверки — настоящий checkSeal держит ворота отпечатка (проверка 42)', () => {
const planPath = join(dir, 'plan.md');
const sealPath = join(dir, 'plan-seal.json');
writeFileSync(planPath, 'шаг 1: сделать хорошо\n');
sealPlan(sealPath, planPath, { approved_by: 'владелец', at: Date.parse('2026-08-01T10:00:00.000Z') });
writeFileSync(planPath, 'шаг 1: сделать хорошо\nшаг 2: и ещё немножко\n');
const r = startGates({ ...good, planSeal: checkSeal(sealPath, planPath) });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('отпечаток плана');
});
it('замеров запаса нет вовсе — настоящий budgetState держит ворота запаса (проверка 17)', () => {
const cfg = { rate_limit_type: 'seven_day', run_share: 1 / 7, start_floor_share: 5 / 7 };
const budget = budgetState(join(dir, 'нет-такого-журнала.jsonl'), cfg, Date.now(), {
runId: 'r-2026-08-01-0100',
workerId: 'w-1',
workersCount: 1,
});
const r = startGates({ ...good, budget });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('запас');
});
it('замер свежий и запаса хватает — настоящий budgetState ворота запаса НЕ держит', () => {
// 🔴 Без этой проверки ворота могли бы читать у `budgetState` не то поле: ответ вышел бы
// `undefined`, отказ случался бы ВСЕГДА, а обе проверки на отказ остались бы зелёными.
const cfg = { rate_limit_type: 'seven_day', run_share: 1 / 7, start_floor_share: 5 / 7 };
const now = Date.now();
const mpath = join(dir, 'budget-measures.jsonl');
// Светофор и вид предела с 03.08.2026 обязательны: решение о запуске принимает светофор,
// а доля его лишь уточняет. Здесь предмет проверки другой — что ворота запаса вправду
// питаются НАСТОЯЩИМ `budgetState`, — поэтому светофор берётся спокойный.
recordMeasure(mpath, {
utilization: 0.30, status: 'allowed_warning', kind: 'seven_day',
resets_at: now + 3 * 24 * 3600_000, at: now - 60_000,
});
const r = startGates({ ...good, budget: budgetState(mpath, cfg, now) });
expect(r.ok).toBe(true);
expect(r.failed).toEqual([]);
});
it('план не трогали после проверки — настоящий checkSeal ворота отпечатка НЕ держит', () => {
// Та же дыра с другой стороны: одна сторона отказа ничего не доказывает про пропуск.
const planPath = join(dir, 'plan.md');
const sealPath = join(dir, 'plan-seal.json');
writeFileSync(planPath, 'шаг 1: сделать хорошо\n');
sealPlan(sealPath, planPath, { approved_by: 'владелец', at: Date.parse('2026-08-01T10:00:00.000Z') });
const r = startGates({ ...good, planSeal: checkSeal(sealPath, planPath) });
expect(r.ok).toBe(true);
expect(r.failed).toEqual([]);
});
});
describe('capacityFree — правило потолков (проверки 26, 27)', () => {
it('идут две задачи — третья не запускается: две это потолок (проверка 26)', () => {
const r = capacityFree({ tasks_running: 2, max_tasks: 2, workers_in_task: 0, max_workers: 7 });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/задач/i);
expect(r.reason).toContain('2');
});
it('идёт одна задача — вторую можно', () => {
expect(capacityFree({ tasks_running: 1, max_tasks: 2, workers_in_task: 0, max_workers: 7 }).ok).toBe(true);
});
it('станет восемь работников внутри задачи — восьмой не запускается (проверка 27)', () => {
// 🔴 `workers_in_task` — это «сколько СТАНЕТ». Восемь на входе значит «семеро идут,
// просят восьмого». Он и есть лишний.
const r = capacityFree({ tasks_running: 1, max_tasks: 2, workers_in_task: 8, max_workers: 7 });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/работник/i);
expect(r.reason).toContain('7');
});
it('🔴 ГРАНИЦА: станет ровно семь — СЕДЬМОГО ПУСКАЕМ (проверка 46)', () => {
// 🔴 Сторож ошибки на единицу. Поставь в правиле `>=` вместо `>` — эта проверка
// покраснеет, и вот что она ловит: шестеро уже идут, просят седьмого, на входе стоит 7.
// При `>=` живой потолок «семь» молча превращается в «шесть», семеро одной задачи
// не соберутся никогда, а проверка 46 бумаги («семеро работников одной задачи шли
// разом») станет недостижимой. Отдельно от проверки выше — та стережёт восьмого,
// эта стережёт седьмого, и порознь они ловят разные беды.
expect(capacityFree({ tasks_running: 1, max_tasks: 2, workers_in_task: 7, max_workers: 7 }).ok).toBe(true);
});
it('станет шестеро — можно и подавно', () => {
expect(capacityFree({ tasks_running: 1, max_tasks: 2, workers_in_task: 6, max_workers: 7 }).ok).toBe(true);
});
it('потолки берутся из настроек владельца, а не из постоянной в коде', () => {
// 🔴 Правило куска 2: строка в файле владельца всегда главнее постоянной в коде.
expect(capacityFree({ tasks_running: 1, max_tasks: 1, workers_in_task: 0, max_workers: 7 }).ok).toBe(false);
expect(capacityFree({ tasks_running: 0, max_tasks: 2, workers_in_task: 4, max_workers: 3 }).ok).toBe(false);
});
it('потолков не дали — берутся запасные две задачи и семь работников', () => {
// Потолок — это решение владельца, а не замер живого мира: его отсутствие не «мы
// не знаем», а «владелец не сказал ничего особенного». Поэтому запасное значение.
expect(capacityFree({ tasks_running: 2, workers_in_task: 0 }).ok).toBe(false);
expect(capacityFree({ tasks_running: 1, workers_in_task: 8 }).ok).toBe(false);
expect(capacityFree({ tasks_running: 1, workers_in_task: 7 }).ok).toBe(true);
});
it('🔴 потолок написан строкой — ОТКАЗ, а не молча запасное число', () => {
// 🪤 `Number.isFinite("3")` — ложь, и запасная семёрка встала бы вместо тройки владельца:
// он написал «трое», а работать пошли бы СЕМЕРО, и никто бы ему об этом не сказал.
// Кавычки вокруг числа в `night.config.json` ставятся рукой на раз.
const r = capacityFree({ tasks_running: 1, max_tasks: 2, workers_in_task: 1, max_workers: '3' });
expect(r.ok).toBe(false);
expect(r.reason).toContain('max_workers_per_task');
expect(r.reason).toContain('3');
});
it('🔴 потолок ноль или отрицательный — ОТКАЗ, и владельцу сказано, что он написал', () => {
// 🪤 Ноль проходит разбор насквозь: `Number.isFinite(0)` — правда. Без этой проверки
// ночь просто не начиналась бы, а причина звучала бы «идёт задач: 0, а потолок — 0»,
// и владелец полночи искал бы поломку в коде вместо опечатки в своём файле.
const r = capacityFree({ tasks_running: 0, max_tasks: 0, workers_in_task: 0, max_workers: 7 });
expect(r.ok).toBe(false);
expect(r.reason).toContain('max_tasks_at_once');
expect(r.reason).toContain('0');
expect(capacityFree({ tasks_running: 0, max_tasks: 2, workers_in_task: 0, max_workers: -1 }).ok).toBe(false);
});
it('счётчика идущих задач не дали — ОТКАЗ, а не «наверное, ноль»', () => {
// 🔴 Ворота, которым нечем питаться, всегда отвечают «всё хорошо». Замер живого мира,
// которого не сделали, — это запрет, а не разрешение.
const r = capacityFree({ workers_in_task: 0, max_tasks: 2, max_workers: 7 });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/не посчитали|не дали/i);
});
it('счётчиков не дали вовсе — отказ', () => {
expect(capacityFree().ok).toBe(false);
expect(capacityFree(null).ok).toBe(false);
});
it('🟡 счётчика работников не дали — эта половина ворот молчит, и это названо вслух', () => {
// 🟡 Единственное место во всём куске 3, где отсутствие ответа НЕ отказ. Причина —
// чужие проверки куска 1 (`okInput` в `run.test.mjs`) подают потолки без этого числа,
// а переписывать их кусок 3 права не имеет. Дыру закрывает задача 6: `vhodVorot`
// кладёт `workers_in_task` ВСЕГДА, даже когда оно ноль.
expect(capacityFree({ tasks_running: 0, max_tasks: 2 }).ok).toBe(true);
});
});
describe('filesDisjoint — правило пересечения задач по файлам (проверка 50)', () => {
it('задачи не пересекаются по файлам — можно', () => {
expect(filesDisjoint(['app/A.php', 'app/B.php'], [['app/C.php']]).ok).toBe(true);
});
it('задачи задевают один файл — вторая не запускается, и файл НАЗВАН владельцу', () => {
const r = filesDisjoint(['app/A.php', 'app/B.php'], [['app/B.php', 'app/Z.php']]);
expect(r.ok).toBe(false);
expect(r.reason).toContain('app/B.php');
});
it('пересечений несколько — названы ВСЕ, а не только первое', () => {
// Починив одно и упёршись во второе, владелец потерял бы ночь.
const r = filesDisjoint(['app/A.php', 'app/B.php'], [['app/A.php'], ['app/B.php']]);
expect(r.reason).toContain('app/A.php');
expect(r.reason).toContain('app/B.php');
});
it('идущих задач нет вовсе — пустой список пропускает', () => {
expect(filesDisjoint(['app/A.php'], []).ok).toBe(true);
});
it('списка файлов НОВОЙ задачи не дали — отказ', () => {
const r = filesDisjoint(undefined, []);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/какие файлы/i);
});
it('список файлов новой задачи пуст — тоже отказ: задач без файлов не бывает', () => {
// 🪤 Пустой список выглядит как «ни с кем не пересекается» и пропускает что угодно.
// Это ровно та пустышка, что отвечает «всё хорошо».
expect(filesDisjoint([], [['app/A.php']]).ok).toBe(false);
});
it('списка файлов ИДУЩИХ задач не дали — отказ, а не «наверное, никто ничего не занял»', () => {
const r = filesDisjoint(['app/A.php'], undefined);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/не посчитали|не дали/i);
});
it('🔴 РАЗНЫЕ КОСЫЕ: владелец написал `app\\A.php`, идущая задача держит `app/A.php` — пересечение НАЙДЕНО', () => {
// 🔴🔴 Самая тихая беда во всей затее: сравни мы имена знак в знак — ворота
// ответили бы «не пересекаются», обе задачи пошли бы на один файл, и НИ ОДНА
// проверка не покраснела бы. Владелец пишет пути из окна Windows обратными косыми,
// `git` отдаёт прямые. Сводим обе стороны и только потом сравниваем.
const r = filesDisjoint(['app\\A.php'], [['app/A.php']]);
expect(r.ok).toBe(false);
expect(r.reason).toContain('app/A.php');
});
it('🔴 РАЗНЫЕ КОСЫЕ наоборот, и `./` спереди тоже сводится', () => {
// Обратная сторона: обратные косые пришли от идущей задачи, а `./app/A.php` — от новой.
const r = filesDisjoint(['./app/A.php'], [['app\\A.php']]);
expect(r.ok).toBe(false);
expect(r.reason).toContain('app/A.php');
});
});
describe('ворота ЗОВУТ оба новых правила, а не держат заглушки', () => {
it('идут две задачи — держат ворота потолков, и названы они своим именем (проверка 26)', () => {
const r = startGates({ ...good, capacity: { tasks_running: 2, max_tasks: 2, workers_in_task: 0, max_workers: 7 } });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('потолки задач и работников');
});
it('станет восемь — те же ворота держат восьмого (проверка 27)', () => {
const r = startGates({ ...good, capacity: { tasks_running: 1, max_tasks: 2, workers_in_task: 8, max_workers: 7 } });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('потолки задач и работников');
});
it('🔴 ГРАНИЦА: станет ровно семь — ворота ПРОПУСКАЮТ (проверка 46)', () => {
// Через ворота целиком, а не только правилом: тут ловится и знак сравнения,
// и то, что чужие ворота на этом же входе не держат седьмого по своей причине.
const r = startGates({ ...good, capacity: { tasks_running: 1, max_tasks: 2, workers_in_task: 7, max_workers: 7 } });
expect(r.ok).toBe(true);
});
it('новая задача задевает файл идущей — держат ворота пересечения (проверка 50)', () => {
const r = startGates({ ...good, newTaskFiles: ['app/A.php'], runningTasksFiles: [['app/A.php']] });
expect(r.ok).toBe(false);
expect(r.failed[0].gate).toBe('задачи не пересекаются по файлам');
});
it('владельцу сказано ПОЧЕМУ: в причине этих ворот назван сам файл (проверка 50)', () => {
const r = startGates({ ...good, newTaskFiles: ['app/A.php'], runningTasksFiles: [['app/A.php']] });
expect(r.reason).toContain('app/A.php');
});
it('не пройдены обои — потолки идут раньше пересечения, как в договоре о стыках', () => {
const r = startGates({
...good,
capacity: { tasks_running: 2, max_tasks: 2, workers_in_task: 0, max_workers: 7 },
newTaskFiles: ['app/A.php'],
runningTasksFiles: [['app/A.php']],
});
expect(r.failed.map(f => f.gate)).toEqual([
'потолки задач и работников',
'задачи не пересекаются по файлам',
]);
});
});
// 🔴🔴 СТОРОЖ НА КЛАСС, а не на место. Проверки «эти ворота зовут своё правило»
// стерегут места поимённо — и молчат про десятые ворота, которых ещё нет. Этот сторож
// стережёт САМ КЛАСС беды: «список ворот и список ответов разъехались» и «ворота тихо
// остались заглушкой». Смотрит он не на поведение, а на САМУ СТРОКУ в `gates.mjs`.
describe('🔴 СТОРОЖ НА КЛАСС: ворота и ответы не разъезжаются молча', () => {
const ISHODNIK = readFileSync(new URL('./gates.mjs', import.meta.url), 'utf8');
// Строки списка `answers` внутри `startGates` — как они написаны в файле.
function strokiOtvetov() {
const nachalo = ISHODNIK.indexOf('const answers = [');
expect(nachalo).toBeGreaterThan(-1); // список переименовали — сторож обязан упасть, а не смолчать
const konec = ISHODNIK.indexOf('\n ];', nachalo);
expect(konec).toBeGreaterThan(nachalo);
return ISHODNIK.slice(nachalo, konec)
.split('\n')
.slice(1)
.map(s => s.trim())
.filter(s => s.length > 0);
}
it('ответов ровно столько же, сколько ворот — лишний ответ не проверялся бы ВООБЩЕ', () => {
// 🪤 Тихая половина беды: `startGates` перебирает `GATE_ORDER`, а не `answers`.
// Допиши кто-нибудь десятый ответ, забыв имя в `GATE_ORDER`, — эти ворота считались бы
// каждый раз и НИ РАЗУ не смотрелись бы. Ни одна проверка поведения этого не увидит:
// отказ просто не случится. Обратная половина (имя без ответа) падает громко сама.
expect(strokiOtvetov().length).toBe(GATE_ORDER.length);
});
it('ни одни ворота не остались заглушкой МОЛЧА — у каждой сказано, чей это кусок', () => {
// 🔴 Заглушка пропускает всё. Значит она имеет право стоять только там, где вслух
// написано, кто её снимет. Безымянная заглушка — это ворота, которых нет, и по виду
// кода этого не отличить.
const zaglushki = strokiOtvetov().filter(s => s.startsWith('notYetWritten()'));
for (const s of zaglushki) {
expect(s).toMatch(/кусок \d+ подставит/);
}
// 🔴 И их ровно двое — обе куска 7. Кусок 3 свои двое СНЯЛ. Вернёт их кто-нибудь
// назад (или не снимет кусок 7, дописав правило мимо списка) — покраснеет здесь.
expect(zaglushki.length).toBe(2);
expect(zaglushki.every(s => /кусок 7 подставит/.test(s))).toBe(true);
});
});