3229 lines
222 KiB
JavaScript
3229 lines
222 KiB
JavaScript
// Кусок 1 — единственная точка входа затеи, и до этой правки у неё не было ни одной
|
||
// проверки. Главная причина, зачем этот файл вообще существует, названа в О-1: путь
|
||
// к письму (`cmd === 'mail'`) раньше падал МОЛЧА при сбое ДО самой отправки — сводка
|
||
// не строилась или транспорт не собирался, — и `mail-failures.jsonl` не получал строки.
|
||
// Владелец утром видел тишину, а тишину он не отличит от «всё хорошо».
|
||
//
|
||
// 🔴 Настоящих писем эти проверки не шлют, настоящих баз не заводят, платного помощника
|
||
// не рождают и в сеть не ходят — везде подставные `transport`/`sweep`/`readConfig` и
|
||
// временный каталог вместо `.night/`. В этом рабочем углу `.night/` уже живёт (второй
|
||
// правщик держит там свой прогон) — проверки его не трогают ни разу.
|
||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
main, runMailCommand, collectSummary, runningRuns, idushchieProgony, komandaProdolzheniya, kartochkiRabotnikov,
|
||
proverkaNastroekVladeltsa, vorotaPribora, vorotaZapasa,
|
||
progonPoTomuZhePlanu, vzyatZamokZapuska, otdatZamokZapuska, PREDEL_ZAMKA_MS,
|
||
besplatnyeVorotaDoDeneg, strokiOtkazaDoDeneg, VOROTA_DENEG, strokaProSvet,
|
||
} from './cli.mjs';
|
||
import { readLines } from './state.mjs';
|
||
import {
|
||
razborDovodov, zamerDovody, vhodVorot, strokiOtkaza, odinVid, vyborProdolzhaemogo, strokiPriborov,
|
||
} from './cli-parts.mjs';
|
||
// 🔴 Список продолжаемых в затее ОДИН и живёт в `resume.mjs`. Проверка ввозит его оттуда же,
|
||
// откуда его берёт правило: сверяйся я со своей копией слов — обе могли бы разъехаться
|
||
// с настоящей и остаться зелёными.
|
||
import { PRODOLZHAEMYE } from './resume.mjs';
|
||
// 🔴 Настоящие приборы запаса — для проверки шва между кусками 2 и 6. Подставка тут
|
||
// доказала бы только моё представление о том, что отдаёт `budgetState`.
|
||
import { recordMeasure as zapisatZamer, budgetState as sostoyanieZapasa } from './budget.mjs';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { resolveBin } from './paths.mjs';
|
||
// 🔴 Настоящий откатчик и настоящий смотритель замка: подставки доказали бы только себя.
|
||
import { otkatDoKommita } from './supervisor.mjs';
|
||
import { mayTouch } from './file-claims.mjs';
|
||
// 🔴 ПРЕДЕЛ ВРЕМЕНИ ВСЕМУ ФАЙЛУ. Здесь вправду заводится `git`, а при пределе по умолчанию
|
||
// (5000 мс) это краснеет от СОСЕДЕЙ по машине, а не от поломки. Число не набирается своё —
|
||
// берётся общее; откуда оно взято, написано в самом `predel-zhivogo-zapuska.mjs`.
|
||
import { PREDEL_ZHIVOGO_ZAPUSKA } from './predel-zhivogo-zapuska.mjs';
|
||
|
||
vi.setConfig({ testTimeout: PREDEL_ZHIVOGO_ZAPUSKA });
|
||
|
||
const GIT = resolveBin('git');
|
||
// 🔴 Кусок 3, задача 6. `startGates` — НАСТОЯЩИЕ ворота: числа идут в них не рукой,
|
||
// а через `vhodVorot`. `odinVidPuti` — общая мелочь про пути (задача 2, `paths.mjs`),
|
||
// ввозится ради одной проверки: что она и `odinVid` куска 2 отвечают одинаково.
|
||
// 🔴 `GATE_ORDER` ввозится ОТТУДА ЖЕ, где он написан, а не переписывается сюда списком имён.
|
||
// Своя копия состава ворот в проверке — это ровно тот класс, которым уже расходились
|
||
// счётчик работников и разбор каталога прогонов: добавь кто-нибудь десятые ворота, и сторож
|
||
// остался бы зелёным, сторожа девятерых.
|
||
import { startGates, GATE_ORDER } from './gates.mjs';
|
||
import { odinVidPuti, runDir, workerDir, summaryLinesPath } from './paths.mjs';
|
||
// 🔴 Кусок 6, задача 7 — ПОДЪЁМ ПРОДОЛЖЕНИЯ. Живой `zavestiProdolzhenie` из `run.mjs`.
|
||
import { zavestiProdolzhenie } from './run.mjs';
|
||
import { readJson } from './state.mjs';
|
||
// 🔴 «Съеденного времени ноль» меряется ТЕМ ЖЕ прибором, что и ночью (`limits.mjs`),
|
||
// а не своим счётом рядом: два счёта одного и того же разъехались бы молча.
|
||
import { billableMs } from './limits.mjs';
|
||
import { randomUUID } from 'node:crypto';
|
||
|
||
let dir, failPath, sentPath;
|
||
beforeEach(() => {
|
||
dir = mkdtempSync(join(tmpdir(), 'night-cli-'));
|
||
failPath = join(dir, 'mail-failures.jsonl');
|
||
sentPath = join(dir, 'last-summary.json');
|
||
});
|
||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||
|
||
const FAKE_CFG = { mail: { host: 'h', port: 587, user: 'u', pass: 'p', from: 'robot@liderra.ru', to: 'owner@example.com' } };
|
||
const OK_TRANSPORT = { sendMail: async () => ({ messageId: '1' }) };
|
||
const OK_SUMMARY = { subject: 'с', text: 'т' };
|
||
const noopSweep = () => [];
|
||
const okBuild = () => OK_SUMMARY;
|
||
|
||
describe('main — общий разбор команды', () => {
|
||
it('О-2: настроек нет — понятный отказ и код возврата, отличный от успеха', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const code = await main(['node', 'cli.mjs', 'summary'], { readConfig: () => null });
|
||
expect(code).not.toBe(0);
|
||
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/настроек нет/i));
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('О-2: неизвестная команда — понятный отказ и код возврата, отличный от успеха', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const code = await main(['node', 'cli.mjs', 'полетели-в-космос'], { readConfig: () => FAKE_CFG });
|
||
expect(code).not.toBe(0);
|
||
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/неизвестная команда/i));
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('команду не назвали вовсе — тот же честный отказ, а не падение', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const code = await main(['node', 'cli.mjs'], { readConfig: () => FAKE_CFG });
|
||
expect(code).not.toBe(0);
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('summary — печатает то, что отдала сводка, кодом успеха', async () => {
|
||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||
const code = await main(['node', 'cli.mjs', 'summary'], {
|
||
readConfig: () => FAKE_CFG,
|
||
summaryOf: () => ({ text: 'Сводка за сегодня' }),
|
||
sweep: () => [],
|
||
});
|
||
expect(code).toBe(0);
|
||
expect(logSpy).toHaveBeenCalledWith('Сводка за сегодня');
|
||
logSpy.mockRestore();
|
||
});
|
||
|
||
it('mail — успех передаётся наружу кодом 0 и словом об успехе', async () => {
|
||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||
const code = await main(['node', 'cli.mjs', 'mail'], {
|
||
readConfig: () => FAKE_CFG,
|
||
runMail: async () => ({ ok: true }),
|
||
});
|
||
expect(code).toBe(0);
|
||
expect(logSpy).toHaveBeenCalledWith('Сводка отправлена.');
|
||
logSpy.mockRestore();
|
||
});
|
||
|
||
it('mail — неудача передаётся наружу кодом, отличным от успеха, и словом о неудаче', async () => {
|
||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||
const code = await main(['node', 'cli.mjs', 'mail'], {
|
||
readConfig: () => FAKE_CFG,
|
||
runMail: async () => ({ ok: false, error: 'транспорт не собрался' }),
|
||
});
|
||
expect(code).not.toBe(0);
|
||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('транспорт не собрался'));
|
||
logSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
describe('collectSummary — О-2: сводка на пустом месте не молчит', () => {
|
||
it('прогона не было (нет last-run) — сводка всё равно есть и так и говорит', () => {
|
||
const s = collectSummary(FAKE_CFG, [], { readLastRun: () => null, readSummaryLines: () => [] });
|
||
expect(s.text).toMatch(/прогона не было/i);
|
||
});
|
||
|
||
it('строки сводки читаются, только когда есть run_id', () => {
|
||
const readSummaryLines = vi.fn(() => ['строка']);
|
||
collectSummary(FAKE_CFG, [], { readLastRun: () => null, readSummaryLines });
|
||
expect(readSummaryLines).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('runMailCommand — О-1: любой сбой на пути к письму записывается', () => {
|
||
it('дошли до отправки, транспорт настоящий — письмо уходит, неудачи нет', async () => {
|
||
const r = await runMailCommand({
|
||
cfg: FAKE_CFG, failPath, sentPath,
|
||
sweep: noopSweep, buildSummaryLines: okBuild, makeTransport: async () => OK_TRANSPORT,
|
||
});
|
||
expect(r.ok).toBe(true);
|
||
expect(readLines(failPath)).toEqual([]);
|
||
});
|
||
|
||
it('О-1 главная: сборка сводки упала ДО отправки — неудача всё равно легла в mail-failures.jsonl', async () => {
|
||
const r = await runMailCommand({
|
||
cfg: FAKE_CFG, failPath, sentPath,
|
||
sweep: noopSweep,
|
||
buildSummaryLines: () => { throw new Error('сводка развалилась'); },
|
||
makeTransport: async () => OK_TRANSPORT,
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
const rows = readLines(failPath);
|
||
expect(rows.length).toBe(1);
|
||
expect(rows[0].error).toContain('сводка развалилась');
|
||
});
|
||
|
||
it('О-1 главная: транспорт не собрался — неудача всё равно легла в mail-failures.jsonl', async () => {
|
||
const r = await runMailCommand({
|
||
cfg: FAKE_CFG, failPath, sentPath,
|
||
sweep: noopSweep, buildSummaryLines: okBuild,
|
||
makeTransport: async () => { throw new Error('SMTP не отвечает на соединение'); },
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
const rows = readLines(failPath);
|
||
expect(rows.length).toBe(1);
|
||
expect(rows[0].error).toContain('SMTP не отвечает на соединение');
|
||
});
|
||
|
||
it('О-1 главная: сама уборка старых углов упала — неудача всё равно легла в mail-failures.jsonl', async () => {
|
||
const r = await runMailCommand({
|
||
cfg: FAKE_CFG, failPath, sentPath,
|
||
sweep: () => { throw new Error('угол не читается'); },
|
||
buildSummaryLines: okBuild, makeTransport: async () => OK_TRANSPORT,
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(readLines(failPath)[0].error).toContain('угол не читается');
|
||
});
|
||
|
||
it('функция не бросает наружу — она возвращает отказ, даже когда всё внутри рухнуло', async () => {
|
||
await expect(runMailCommand({
|
||
cfg: FAKE_CFG, failPath, sentPath,
|
||
sweep: noopSweep,
|
||
buildSummaryLines: () => { throw new Error('x'); },
|
||
makeTransport: async () => OK_TRANSPORT,
|
||
})).resolves.toBeTruthy();
|
||
});
|
||
|
||
it('писать свою неудачу тоже не вышло — всё равно не бросает и сказано об этом (unrecorded)', async () => {
|
||
const badPath = join(dir, 'занято-каталогом');
|
||
mkdirSync(badPath, { recursive: true });
|
||
const r = await runMailCommand({
|
||
cfg: FAKE_CFG, failPath: badPath, sentPath,
|
||
sweep: noopSweep,
|
||
buildSummaryLines: () => { throw new Error('сводка развалилась'); },
|
||
makeTransport: async () => OK_TRANSPORT,
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(r.error).toContain('сводка развалилась');
|
||
expect(r.unrecorded).toBeTruthy();
|
||
});
|
||
|
||
it('сама отправка (sendMail) упала — это по-прежнему записывает mailer.mjs, здесь ничего не задвоилось', async () => {
|
||
const r = await runMailCommand({
|
||
cfg: FAKE_CFG, failPath, sentPath,
|
||
sweep: noopSweep, buildSummaryLines: okBuild,
|
||
makeTransport: async () => ({ sendMail: async () => { throw new Error('SMTP лёг'); } }),
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
const rows = readLines(failPath);
|
||
expect(rows.length).toBe(1); // ровно одна строка, а не две от двух ловушек подряд
|
||
expect(rows[0].error).toContain('SMTP лёг');
|
||
});
|
||
});
|
||
|
||
// 🪤 `sweepNow` (обёртка над `sweepStaleWorkspaces` из `workspace.mjs`) здесь НЕ проверяется
|
||
// напрямую: она зовёт настоящую уборку по настоящему REPO_ROOT этого рабочего угла — а там
|
||
// уже живёт реальный `.night/` второго правщика. Прогнать её вживую в проверке — риск
|
||
// тронуть чужие файлы. Проверки самой уборки — в workspace.test.mjs (чужой файл, не мой).
|
||
// В `main`/`runMailCommand` она всегда вызывается через внедряемый `sweep`, что и проверено выше.
|
||
|
||
// ============ кусок 2: три тихие части точки входа ============
|
||
//
|
||
// 🔴 Правило владельца: решая, писать ли проверку, спрашивать не «важно ли это», а «если
|
||
// сломается — заметим ли мы сами». Живой запуск ломается ГРОМКО (не запустится ничего) —
|
||
// он принимается глазами. А эти три вещи ломаются ТИХО и тихо делают не то:
|
||
// разбор доводов команды, раскладка ответов правил по кормушкам ворот и печать отказа.
|
||
//
|
||
// Чужие части подставляем, живьём не зовём: здесь проверяется НЕ то, что правила правы,
|
||
// а то, что их ответы дошли до ворот целыми и что владельцу сказали правду.
|
||
const ZAPAS = { may_start: true, renews_on: '2026-08-08', run_share_limit: 1 / 7 };
|
||
const MESTO = { ok: true, free_gb: 42.5 };
|
||
const PECHAT = { ok: true, seal: { fingerprint: 'aaa' } };
|
||
const BUMAGI = { ok: true };
|
||
const ZAMYSEL = { ok: true };
|
||
const CFG = {
|
||
base_branch: 'feat/night', probe_model: 'haiku', rate_limit_type: 'seven_day',
|
||
max_tasks_at_once: 2, max_workers_per_task: 7,
|
||
};
|
||
|
||
function argvFor(...rest) {
|
||
return ['node', 'tools/night/cli.mjs', ...rest];
|
||
}
|
||
|
||
describe('часть 1: доводы команды доходят до неё теми же, какими их набрали', () => {
|
||
it('run: три пути ложатся каждый в своё поле, а не путаются местами', () => {
|
||
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--priyomka', 'docs/priyomka.md', '--spec', 'docs/spec.md'));
|
||
expect(d.planPath).toBe('docs/plan.md');
|
||
expect(d.priyomkaPath).toBe('docs/priyomka.md');
|
||
expect(d.specPath).toBe('docs/spec.md');
|
||
expect(d.error).toBeUndefined();
|
||
});
|
||
|
||
it('run: ключи набраны в другом порядке — поля всё равно свои', () => {
|
||
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--spec', 'docs/spec.md', '--priyomka', 'docs/priyomka.md'));
|
||
expect(d.priyomkaPath).toBe('docs/priyomka.md');
|
||
expect(d.specPath).toBe('docs/spec.md');
|
||
});
|
||
|
||
it('run: обратные косые Windows приводятся к одному виду', () => {
|
||
// 🪤 Владелец копирует путь из окна Windows. Подай его как набран — хранилище ответит
|
||
// «нет такой бумаги», и владелец получит отказ по причине, которой нет.
|
||
const d = razborDovodov(argvFor('run', 'docs\\superpowers\\plans\\plan.md', '--priyomka', '.\\docs\\p.md', '--spec', 'docs\\s.md'));
|
||
expect(d.planPath).toBe('docs/superpowers/plans/plan.md');
|
||
expect(d.priyomkaPath).toBe('docs/p.md');
|
||
expect(d.specPath).toBe('docs/s.md');
|
||
});
|
||
|
||
it('run: не назван приёмочный лист — понятный отказ с образцом команды, а не молчание', () => {
|
||
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--spec', 'docs/spec.md'));
|
||
expect(d.error).toContain('--priyomka');
|
||
expect(d.error).toContain('--spec');
|
||
expect(d.planPath).toBeUndefined();
|
||
});
|
||
|
||
it('run: не назван план — понятный отказ, и ключ --spec планом НЕ становится', () => {
|
||
const d = razborDovodov(argvFor('run', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md'));
|
||
expect(d.error).toContain('node tools/night/cli.mjs run');
|
||
});
|
||
|
||
it('seal: путь плана берётся из довода', () => {
|
||
expect(razborDovodov(argvFor('seal', 'docs/plan.md')).planPath).toBe('docs/plan.md');
|
||
});
|
||
|
||
it('seal: путь не назван — понятный отказ с образцом, а не молчание', () => {
|
||
const d = razborDovodov(argvFor('seal'));
|
||
expect(d.error).toContain('seal <путь-к-плану>');
|
||
expect(d.planPath).toBeUndefined();
|
||
});
|
||
|
||
it('approve-spec: бумага и ВСЕ названные рецензенты доходят до отметки', () => {
|
||
// 🔴 Рецензентов называют дважды. Возьми разбор только последнего — отметка выйдет
|
||
// с одним именем, ворота замысла откажут, а причина будет непонятной.
|
||
const d = razborDovodov(argvFor('approve-spec', 'docs/spec.md', '--reviewer', 'Пётр', '--reviewer', 'Анна'));
|
||
expect(d.specPath).toBe('docs/spec.md');
|
||
expect(d.reviewers).toEqual(['Пётр', 'Анна']);
|
||
});
|
||
|
||
it('approve-spec: бумага не названа — понятный отказ с образцом', () => {
|
||
expect(razborDovodov(argvFor('approve-spec', '--reviewer', 'Пётр')).error).toContain('--reviewer');
|
||
});
|
||
|
||
it('budget: голова замера берётся из настройки probe_model, зашитого слова нет', () => {
|
||
expect(zamerDovody({ ...CFG, probe_model: 'самая-дешёвая' }).model).toBe('самая-дешёвая');
|
||
});
|
||
|
||
it('budget: настройки probe_model нет — головы нет, и подставлять её нечем', () => {
|
||
// Умолчания нет намеренно: пусть замер откажется понятной строкой с именем настройки,
|
||
// а не сходит за деньги владельца чужой головой.
|
||
expect(zamerDovody({ ...CFG, probe_model: undefined }).model).toBeUndefined();
|
||
});
|
||
|
||
it('🔴 budget: вид предела берётся ИЗ НАСТРОЙКИ, а нет строки — отказ с её именем', () => {
|
||
// 🔴🔴 ЭТА ПРОВЕРКА СТОРОЖИЛА МИНУ. Прежде вторая её строка требовала обратного:
|
||
// «нет строки в настройках — ВСЁ РАВНО недельный». Замерено живьём 03.08.2026: этому
|
||
// счёту приходит `five_hour`, недельного события не приходило ни разу — то есть молчаливое
|
||
// умолчание заставляло ворота ждать запас, которого не бывает, и они не пропустили
|
||
// ни одного прогона за всю жизнь затеи. Владелец при этом читал «события от системы
|
||
// не пришло», хотя оно приходило каждый раз.
|
||
// Теперь умолчания нет нигде: потеряна настройка — отказ, и в нём её имя.
|
||
expect(zamerDovody({ ...CFG, rate_limit_type: 'five_hour' }).kind).toBe('five_hour');
|
||
expect(() => zamerDovody({ ...CFG, rate_limit_type: undefined })).toThrow(/rate_limit_type/);
|
||
});
|
||
});
|
||
|
||
describe('часть 2: воротам поданы ВСЕ девять ответов, а не часть', () => {
|
||
const running = [
|
||
{ run_id: 'r-1', status: 'running', workers: ['w-1', 'w-2'], items: [{ id: '1', files: ['app/A.php'] }] },
|
||
{ run_id: 'r-2', status: 'running', workers: ['w-3'], items: [{ id: '2', files: ['app/B.php'] }] },
|
||
];
|
||
const vhod = () => vhodVorot({
|
||
cfg: CFG, items: [{ id: '1' }], budget: ZAPAS, disk: MESTO,
|
||
planSeal: PECHAT, papers: BUMAGI, spec: ZAMYSEL, running,
|
||
});
|
||
|
||
it('во входе есть все девять кормушек ворот — поимённо', () => {
|
||
// 🔴 Порядок ворот: запас · отклик владельца · место · замысел · бумаги · отпечаток ·
|
||
// от прошлого прогона никого · потолки · пересечение файлов.
|
||
const v = vhod();
|
||
for (const pole of ['budget', 'owner', 'disk', 'spec', 'papers', 'planSeal',
|
||
'previousWorkers', 'capacity', 'newTaskFiles', 'runningTasksFiles']) {
|
||
expect(v).toHaveProperty(pole);
|
||
}
|
||
});
|
||
|
||
it('ответы правил доходят до ворот ТЕМИ ЖЕ, какими пришли', () => {
|
||
// 🔴 Тихая беда: положи ответ запаса в поле места — ворота места прочтут `may_start`,
|
||
// не найдут `ok`, откажут, и владелец пойдёт чистить диск вместо ожидания запаса.
|
||
const v = vhod();
|
||
expect(v.budget).toBe(ZAPAS);
|
||
expect(v.disk).toBe(MESTO);
|
||
expect(v.planSeal).toBe(PECHAT);
|
||
expect(v.papers).toBe(BUMAGI);
|
||
expect(v.spec).toBe(ZAMYSEL);
|
||
});
|
||
|
||
it('ветка отсчёта и пункты берутся из настроек и из разбора плана', () => {
|
||
const v = vhod();
|
||
expect(v.base_branch).toBe('feat/night');
|
||
expect(v.items).toEqual([{ id: '1' }]);
|
||
});
|
||
|
||
it('число идущих прогонов настоящее, а не ноль', () => {
|
||
expect(vhod().capacity.tasks_running).toBe(2);
|
||
});
|
||
|
||
it('число работников в работе берётся из карточек прогонов, а не выдумывается', () => {
|
||
// 🔴 Состав прогона пишет кусок 1 (`startAll` кладёт `workers` в `run.json`) — заводить
|
||
// это поле заново не надо, его надо сложить. Ноль здесь означал бы «никого нет»,
|
||
// и ворота потолков пропустили бы восьмого работника.
|
||
//
|
||
// 🔴 ПОПРАВЛЕНО КУСКОМ 3 (задача 6) И НАЗВАНО ВСЛУХ. Проверка не выброшена и число
|
||
// в ней то же — 3, — но вход стал честнее. Кусок 2 складывал работников ВСЕХ идущих
|
||
// прогонов и на этом входе получал 3 (2+1). Проверка 27 говорит «внутри ЗАДАЧИ»,
|
||
// поэтому счёт теперь идёт по своей задаче плюс те, кого просят завести: у задачи `r-1`
|
||
// своих двое, просят одного — трое. Не назови мы задачу, вышла бы единица, и это было
|
||
// бы верно: чужих работников в свой потолок не берут. Место, где кусок 3 имел право
|
||
// тронуть чужую проверку, план назвал только одно (`gates.test.mjs`) — вот второе,
|
||
// и его пропустили: старый ответ сторожил ровно то правило, которое кусок 3 заменяет.
|
||
const v = vhodVorot({
|
||
cfg: CFG, items: [{ id: '1' }], budget: ZAPAS, disk: MESTO,
|
||
planSeal: PECHAT, papers: BUMAGI, spec: ZAMYSEL, running, taskId: 'r-1',
|
||
});
|
||
expect(v.capacity.workers_in_task).toBe(3);
|
||
});
|
||
|
||
it('карточка прогона без состава — считается ноль работников, а не обвал', () => {
|
||
const v = vhodVorot({ cfg: CFG, budget: ZAPAS, disk: MESTO, running: [{ run_id: 'r-1' }] });
|
||
expect(v.capacity.workers_in_task).toBe(0);
|
||
expect(v.capacity.tasks_running).toBe(1);
|
||
});
|
||
|
||
it('потолки берутся из настроек владельца', () => {
|
||
const v = vhodVorot({ cfg: { ...CFG, max_tasks_at_once: 1, max_workers_per_task: 3 }, running: [] });
|
||
expect(v.capacity.max_tasks).toBe(1);
|
||
expect(v.capacity.max_workers).toBe(3);
|
||
});
|
||
|
||
it('занятые файлы идущих прогонов подаются воротам пересечения', () => {
|
||
expect(vhod().runningTasksFiles).toEqual([['app/A.php'], ['app/B.php']]);
|
||
});
|
||
|
||
it('четверо ненаписанных ворот получают ПУСТОЕ, а не выдуманное', () => {
|
||
// Их правила пишут куски 3 и 7. Пустое значение — это честное «сказать нечего»;
|
||
// выдуманное «всё хорошо» было бы враньём, которое никто потом не найдёт.
|
||
const v = vhod();
|
||
expect(v.owner).toEqual({ last_summary_at: null });
|
||
expect(v.previousWorkers).toEqual([]);
|
||
expect(v.newTaskFiles).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('часть 3: в отказе владельцу названы ворота и человеческая причина', () => {
|
||
it('названы ИМЕНА непройденных ворот и причины словами', () => {
|
||
const stroki = strokiOtkaza({
|
||
ok: false, reason: 'Запас кончился.',
|
||
failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }],
|
||
});
|
||
expect(stroki.join('\n')).toContain('запас');
|
||
expect(stroki.join('\n')).toContain('Запас кончился.');
|
||
});
|
||
|
||
it('непройденных двое — названы ОБА, а не только первые', () => {
|
||
// Починив одно и упёршись во второе, владелец потерял бы ночь.
|
||
const stroki = strokiOtkaza({
|
||
ok: false, reason: 'Запас кончился.',
|
||
failed_gates: [
|
||
{ gate: 'запас', reason: 'Запас кончился.' },
|
||
{ gate: 'бумаги сохранены', reason: 'Не сохранено: docs/plan.md' },
|
||
],
|
||
});
|
||
const t = stroki.join('\n');
|
||
expect(t).toContain('запас');
|
||
expect(t).toContain('бумаги сохранены');
|
||
expect(t).toContain('docs/plan.md');
|
||
});
|
||
|
||
it('в отказе НЕТ месива: ни [object Object], ни склеенной строки all_reasons', () => {
|
||
// 🔴 Ровно на этом шве уже ломалось: `failed` — это ПАРЫ, а не строки. Разбери
|
||
// кто-нибудь `all_reasons` — владелец получит «запас: запас: Запас кончился.».
|
||
const t = strokiOtkaza({
|
||
ok: false, reason: 'Запас кончился.',
|
||
failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }],
|
||
all_reasons: ['запас: Запас кончился.'],
|
||
}).join('\n');
|
||
expect(t).not.toContain('[object Object]');
|
||
expect(t).not.toContain('запас: запас');
|
||
});
|
||
|
||
it('отказ есть, а имён ворот не дали — печатается общая причина, а не пустота', () => {
|
||
const t = strokiOtkaza({ ok: false, reason: 'Что-то пошло не так.', failed_gates: [] }).join('\n');
|
||
expect(t).toContain('Что-то пошло не так.');
|
||
});
|
||
|
||
it('запас кончился — сказано, КОГДА станет можно', () => {
|
||
const t = strokiOtkaza(
|
||
{ ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }] },
|
||
{ renews_on: '2026-08-08' },
|
||
).join('\n');
|
||
expect(t).toContain('2026-08-08');
|
||
});
|
||
|
||
it('🔴 дня обновления назвать не смогли — печатается ПРИЧИНА, а не пустое место', () => {
|
||
// Прежде строка про запас печаталась только при `renews_on`. Сбой прибора (день пуст,
|
||
// день в прошлом, число не в миллисекундах) не давал НИ ОДНОЙ строки, и владелец читал
|
||
// это как «ещё не считали». Слова сбоя готовит budget.mjs — здесь их обязаны донести.
|
||
const t = strokiOtkaza(
|
||
{ ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }] },
|
||
{ renews_on: null, renews_on_problem: 'прибор не назвал день обновления запаса вовсе (resets_at пуст)' },
|
||
).join('\n');
|
||
expect(t).toContain('сказать не удалось');
|
||
expect(t).toContain('resets_at пуст');
|
||
});
|
||
|
||
it('день назвали — печатается день, а причина сбоя не примешивается', () => {
|
||
const t = strokiOtkaza(
|
||
{ ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }] },
|
||
{ renews_on: '2026-08-08', renews_on_problem: null },
|
||
).join('\n');
|
||
expect(t).toContain('2026-08-08');
|
||
expect(t).not.toContain('сказать не удалось');
|
||
});
|
||
|
||
it('про запас не сказано ничего вовсе — лишней строки не появляется', () => {
|
||
// Отказ бывает не по запасу: тогда молчать про запас правильно.
|
||
const t = strokiOtkaza(
|
||
{ ok: false, reason: 'Место кончилось.', failed_gates: [{ gate: 'место', reason: 'Место кончилось.' }] },
|
||
{ renews_on: null, renews_on_problem: null },
|
||
).join('\n');
|
||
expect(t).not.toContain('недельный запас');
|
||
expect(t).not.toContain('Недельный запас');
|
||
});
|
||
|
||
it('ворота пройдены — печатать нечего вовсе', () => {
|
||
// Без этой проверки печать могла бы работать всегда, и владелец получал бы
|
||
// «Прогон НЕ запущен» в ночь, когда прогон как раз запустился.
|
||
expect(strokiOtkaza({ ok: true, failed_gates: [] })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 `strokiPriborov` — ЧТО ВЛАДЕЛЕЦ ПРОЧТЁТ ПРО ГОЛОВУ И МАСТЕРСКУЮ (03.08.2026)
|
||
//
|
||
// Класс беды: приговор построен и никуда не доходит. Датчики головы и мастерской работали
|
||
// правильно и молчали не они — их приговор выбрасывал слой выше. Здесь сторожатся слова,
|
||
// ниже (в блоке О-4 и в блоке продолжения) — то, что их вправду печатают.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
describe('🔴 строки про два прибора: молчания не бывает ни в одном из состояний', () => {
|
||
const HOROSHA = { sovpalo: true, zakazana: 'haiku', otvetila: ['claude-haiku-4-5'], soobshchenie: null };
|
||
const SREZANA = { otsecheno: true, sostav: { tools: 0, mcp_servers: 0 }, soobshchenie: null };
|
||
|
||
it('на каждый платный замер — РОВНО две строки, по одной на прибор', () => {
|
||
const s = strokiPriborov({ golova: HOROSHA, masterskaya: SREZANA });
|
||
expect(s).toHaveLength(2);
|
||
expect(s[0]).toMatch(/^Голова:/);
|
||
expect(s[1]).toMatch(/^Мастерская/);
|
||
});
|
||
|
||
it('сошлось — говорится вслух, а не молчанием', () => {
|
||
// 🔴 Молчи мы при «сошлось» — владелец не отличил бы «проверили, всё хорошо»
|
||
// от «печать опять отвалилась». Ровно эта неразличимость и была бедой.
|
||
const t = strokiPriborov({ golova: HOROSHA, masterskaya: SREZANA }).join('\n');
|
||
expect(t).toContain('haiku');
|
||
expect(t).toMatch(/сошлось/);
|
||
expect(t).toMatch(/срезана/);
|
||
});
|
||
|
||
it('🔴 беда — печатаются СЛОВА ДАТЧИКА, не переписанные здесь заново', () => {
|
||
const t = strokiPriborov({
|
||
golova: { sovpalo: false, zakazana: 'haiku', otvetila: ['opus'], soobshchenie: 'ВОТ ЭТИ САМЫЕ СЛОВА ДАТЧИКА' },
|
||
masterskaya: { otsecheno: false, sostav: { tools: 50, mcp_servers: 51 }, soobshchenie: 'И ЭТИ ТОЖЕ' },
|
||
}).join('\n');
|
||
expect(t).toContain('ВОТ ЭТИ САМЫЕ СЛОВА ДАТЧИКА');
|
||
expect(t).toContain('И ЭТИ ТОЖЕ');
|
||
});
|
||
|
||
it('🪤 «сверить было нечем» — это НЕ «сошлось», и говорится своими словами', () => {
|
||
const t = strokiPriborov({
|
||
golova: { sovpalo: null, zakazana: 'haiku', otvetila: [], soobshchenie: 'в потоке нет ни одной головы' },
|
||
masterskaya: { otsecheno: null, sostav: { naydeno: false }, soobshchenie: 'события system/init не было' },
|
||
}).join('\n');
|
||
expect(t).toContain('в потоке нет ни одной головы');
|
||
expect(t).not.toMatch(/сошлось|срезана, как и заказывали/);
|
||
});
|
||
|
||
it('🪤 приговора нет вовсе (прибор промолчал) — «не сошлось» не выдумывается, но и «сошлось» тоже', () => {
|
||
const t = strokiPriborov({ golova: null, masterskaya: null }).join('\n');
|
||
expect(t).toMatch(/не пришло вовсе/);
|
||
expect(t).toContain('НЕ «сошлось»');
|
||
});
|
||
|
||
it('🪤 ТРЕТЬЕ: ответ пришёл, а разобрать нечем — считается НЕ проверенным, а не хорошим', () => {
|
||
// Незнакомый ответ зелёным не бывает — тем же приёмом, каким незнакомый светофор
|
||
// не считается зелёным в `budgetState`. Иначе поломка датчика читалась бы как «всё ладно».
|
||
const t = strokiPriborov({ golova: { zakazana: 'haiku' }, masterskaya: { sostav: {} } }).join('\n');
|
||
expect(t).toMatch(/не разобран/);
|
||
expect(t).toMatch(/НЕ проверенным/);
|
||
expect(t).not.toMatch(/сошлось/);
|
||
});
|
||
|
||
it('🪤 ответа замера нет вовсе — две строки всё равно есть, а не пустота', () => {
|
||
expect(strokiPriborov(null)).toHaveLength(2);
|
||
expect(strokiPriborov(undefined).join('\n')).toMatch(/не пришло вовсе/);
|
||
});
|
||
});
|
||
|
||
// ============ блок 4: точка входа не оставлена звать удалённые имена ============
|
||
// 🔴 Зачем он. `cli.mjs` в проверки ввозится только тремя своими функциями, а сам разбор
|
||
// команд ими не покрыт — это решение владельца: живой запуск ломается громко. Но один
|
||
// класс поломки здесь как раз тихий: правка убирает из файла объявление, а зов остаётся.
|
||
// Такой файл падает не при загрузке, а позже — внутри той команды, куда в тот вечер никто
|
||
// не заглянул. Сторож не запускает точку входа и не проверяет её поведение: он читает её
|
||
// как текст и смотрит, что каждое зовомое имя откуда-то взято.
|
||
//
|
||
// 🔴 Список известных имён намеренно ЩЕДРЫЙ — в него идут и доводы функций. Пусть сторож
|
||
// пропустит спорное, зато не покраснеет зря: сторож, который врёт красным, отключают первым.
|
||
// Совсем оторванное имя вроде `odinVid` он ловит всё равно — это доказано вырезанием.
|
||
const OT_SISTEMY = new Set([
|
||
'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof', 'else', 'do',
|
||
'await', 'async', 'new', 'delete', 'void', 'throw', 'case', 'try', 'in', 'of', 'instanceof',
|
||
'String', 'Number', 'Boolean', 'Object', 'Array', 'JSON', 'Date', 'Math', 'Set', 'Map',
|
||
'Promise', 'Error', 'RegExp', 'console', 'process', 'parseInt', 'parseFloat', 'isNaN',
|
||
'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'require', 'import', 'super',
|
||
]);
|
||
|
||
// Убрать примечания и тексты для владельца: слова из них — не зовы.
|
||
function tolkoKod(text) {
|
||
return text
|
||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||
.replace(/\/\/[^\n]*/g, ' ')
|
||
.replace(/`(?:\\[\s\S]|[^`\\])*`/g, '``')
|
||
.replace(/'(?:\\[\s\S]|[^'\\])*'/g, "''")
|
||
.replace(/"(?:\\[\s\S]|[^"\\])*"/g, '""');
|
||
}
|
||
|
||
function imenaKotorykhFayluHvataet(kod) {
|
||
const est = new Set(OT_SISTEMY);
|
||
// 🔴 ПОПРАВЛЕНО ПРОТИВ ПЛАНА, вслух. В плане `dobav` стирал из куска ВСЕ не-буквы
|
||
// и добавлял остаток целиком. На куске `readConfig = () => readJson(configPath())`
|
||
// это давало слипшийся мусор `readConfigreadJsonconfigPath`, а само имя `readConfig`
|
||
// так и оставалось «ниоткуда» — сторож краснел бы на здоровом файле. Берём ПЕРВОЕ имя
|
||
// куска: оно и есть объявляемое. У ввоза с переименованием (`x as y`) имя даёт правая часть.
|
||
const dobav = s => {
|
||
const chistoe = String(s).replace(/^[\s{[(]+/, '').split(/\s+as\s+/).pop();
|
||
const m = String(chistoe).trim().match(/^[\w$]+/);
|
||
if (m) est.add(m[0]);
|
||
};
|
||
for (const m of kod.matchAll(/import\s*\{([^}]*)\}\s*from/g)) m[1].split(',').forEach(dobav);
|
||
for (const m of kod.matchAll(/import\s+([\w$]+)\s*(?:,|from)/g)) dobav(m[1]);
|
||
for (const m of kod.matchAll(/(?:function|class)\s+([\w$]+)/g)) dobav(m[1]);
|
||
for (const m of kod.matchAll(/(?:const|let|var)\s+([\w$]+)/g)) dobav(m[1]);
|
||
for (const m of kod.matchAll(/(?:const|let|var)\s*\{([^}]*)\}/g)) m[1].split(',').forEach(dobav);
|
||
// Доводы функций — тоже имена, которые у файла есть.
|
||
for (const m of kod.matchAll(/function\s*[\w$]*\s*\(([^)]*)\)/g)) m[1].split(',').forEach(dobav);
|
||
// 🔴 ВТОРАЯ ПОПРАВКА ПРОТИВ ПЛАНА. Доводы, разобранные прямо в скобках с пустым
|
||
// умолчанием — `function f({ a, b = c() } = {}) {` — прежней строкой не ловились вовсе:
|
||
// она обрывалась на первой же закрывающей скобке внутри (`mailFailuresPath()`), и семь
|
||
// доводов `runMailCommand` оставались «ниоткуда».
|
||
// 🪤 Один вложенный слой фигурных скобок внутри доводов разрешён намеренно: умолчание
|
||
// вида `x = async e => f(e, { disk: await g() })` иначе обрывает разбор на середине,
|
||
// и остаток доводов снова становится «ниоткуда». На этом сторож уже краснел живьём.
|
||
for (const m of kod.matchAll(/\{((?:[^{}]|\{[^{}]*\})*)\}\s*=\s*\{\s*\}\s*\)/g)) m[1].split(',').forEach(dobav);
|
||
for (const m of kod.matchAll(/\(([^()]*)\)\s*=>/g)) m[1].split(',').forEach(dobav);
|
||
for (const m of kod.matchAll(/([\w$]+)\s*=>/g)) dobav(m[1]);
|
||
return est;
|
||
}
|
||
|
||
describe('часть 4: точка входа не зовёт имён, которых у неё нет', () => {
|
||
it('каждое имя, которое зовёт `cli.mjs`, либо ввезено, либо объявлено в нём же', () => {
|
||
const kod = tolkoKod(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli.mjs'), 'utf8'));
|
||
const est = imenaKotorykhFayluHvataet(kod);
|
||
const bezymyannye = [];
|
||
for (const m of kod.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
|
||
if (!est.has(m[1]) && !bezymyannye.includes(m[1])) bezymyannye.push(m[1]);
|
||
}
|
||
// 🔴 Красное здесь читается прямо: вот имена, которые файл зовёт, а взять их неоткуда.
|
||
expect(bezymyannye).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ============ кусок 3: настоящие числа и списки для ворот ============
|
||
// 🔴 ПОПРАВЛЕНО ПРОТИВ ПЛАНА, вслух: план звал эти три блока «часть 4», «часть 5»
|
||
// и «часть 6», а «часть 4» в этом файле уже занята куском 2 («точка входа не зовёт имён,
|
||
// которых у неё нет»). Два блока с одним именем в одном файле — не поломка, но читать
|
||
// красное потом невозможно. Взяты следующие свободные номера: 5, 6, 7.
|
||
// 🔴 Ввозы (`startGates`, `odinVid`, `odinVidPuti`) дописаны в шапку файла, а не сюда;
|
||
// `readFileSync` там уже стоял с куска 1 — второго его имени не заводим.
|
||
|
||
const PUT_SYUDA = dirname(fileURLToPath(import.meta.url));
|
||
const TEKST_CLI = readFileSync(join(PUT_SYUDA, 'cli.mjs'), 'utf8');
|
||
const TEKST_VOROT = readFileSync(join(PUT_SYUDA, 'gates.mjs'), 'utf8');
|
||
|
||
// Две идущие задачи: у первой два работника и файл `app/A.php`, у второй один и `app/B.php`.
|
||
const IDUT = [
|
||
{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1', 'w-2'], items: [{ id: '1', files: ['app/A.php'] }] },
|
||
{ run_id: 'r-2', status: 'running', task_id: 'z-2', workers: ['w-3'], items: [{ id: '2', files: ['app/B.php'] }] },
|
||
];
|
||
|
||
const NASTROYKI = { ...CFG, max_tasks_at_once: 2, max_workers_per_task: 7 };
|
||
|
||
describe('часть 5: воротам ёмкости и пересечения поданы НАСТОЯЩИЕ числа и списки', () => {
|
||
it('newTaskFiles — настоящие файлы пунктов нового прогона, а не пустой список', () => {
|
||
// 🔴 Место, размеченное куском 2 словами «пусто, и это твоё». Пустой список означал бы,
|
||
// что ворота пересечения не держат никогда: правило без пищи пропускает всё.
|
||
const v = vhodVorot({
|
||
cfg: NASTROYKI, running: IDUT, taskId: null,
|
||
items: [{ id: '1', files: ['app/A.php'] }, { id: '2', files: ['app/C.php'] }],
|
||
});
|
||
expect(v.newTaskFiles).toEqual(['app/A.php', 'app/C.php']);
|
||
});
|
||
|
||
it('пунктов без файлов — список пустой, а не выдуманный', () => {
|
||
const v = vhodVorot({ cfg: NASTROYKI, running: [], items: [{ id: '1' }] });
|
||
expect(v.newTaskFiles).toEqual([]);
|
||
// 🔴 УСИЛЕНО ПРОТИВ ПЛАНА, вслух. В плане у этой проверки было только утверждение выше,
|
||
// и в красной поре она была ЗЕЛЁНОЙ: пустоты ждали, пустота там и стояла. Зелёная
|
||
// в красной поре не сторожит ничего. Вторая половина краснеет честно: пункт без файлов
|
||
// обязан пропасть из списка, а пункт с файлами — остаться. Свались они в одно
|
||
// (`items.map(i => i.files)`), в списке оказалось бы `undefined` — и ворота пересечения
|
||
// спотыкались бы о него на ровном месте.
|
||
const smeshanno = vhodVorot({
|
||
cfg: NASTROYKI, running: [], items: [{ id: '1' }, { id: '2', files: ['app/A.php'] }],
|
||
});
|
||
expect(smeshanno.newTaskFiles).toEqual(['app/A.php']);
|
||
});
|
||
|
||
it('workers_in_task — идущие работники СВОЕЙ задачи плюс те, кого просят завести', () => {
|
||
// 🔴 Считай мы только идущих — потолок стерёг бы уже начатое и пропустил бы восьмого.
|
||
// Своих у задачи `z-1` двое, просят завести ещё двоих — станет четверо, а не трое
|
||
// (трое — это ответ куска 2, который складывал работников ВСЕХ идущих прогонов).
|
||
const v = vhodVorot({
|
||
cfg: NASTROYKI, running: IDUT, taskId: 'z-1',
|
||
items: [{ id: '9', files: ['app/D.php'] }, { id: '10', files: ['app/E.php'] }],
|
||
});
|
||
expect(v.capacity.workers_in_task).toBe(4);
|
||
});
|
||
|
||
it('работники ЧУЖОЙ задачи в этот счёт не идут', () => {
|
||
// Иначе потолок «семь работников внутри задачи» превратился бы в «семь на весь сервер»,
|
||
// и вторая задача не запустилась бы никогда.
|
||
const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-2', items: [{ id: '9' }] });
|
||
expect(v.capacity.workers_in_task).toBe(2);
|
||
});
|
||
|
||
it('tasks_running считает ЗАДАЧИ, а не прогоны', () => {
|
||
const dvaProgonaOdnoyZadachi = [
|
||
{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1'], items: [] },
|
||
{ run_id: 'r-2', status: 'running', task_id: 'z-1', workers: ['w-2'], items: [] },
|
||
];
|
||
const v = vhodVorot({ cfg: NASTROYKI, running: dvaProgonaOdnoyZadachi, taskId: null, items: [] });
|
||
expect(v.capacity.tasks_running).toBe(1);
|
||
});
|
||
|
||
it('своя задача третьей не считается', () => {
|
||
// Продолжение уже идущей задачи новую задачу не заводит — иначе потолок «две задачи»
|
||
// запретил бы доводить до конца то, что уже начато.
|
||
const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-1', items: [] });
|
||
expect(v.capacity.tasks_running).toBe(1);
|
||
});
|
||
|
||
it('runningTasksFiles не включает файлы СВОЕЙ задачи', () => {
|
||
// Иначе задача пересекалась бы сама с собой и не запускалась бы никогда.
|
||
const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-1', items: [] });
|
||
expect(v.runningTasksFiles).toEqual([['app/B.php']]);
|
||
});
|
||
});
|
||
|
||
describe('часть 6: НАСТОЯЩИЕ ворота на этих числах отказывают и пропускают', () => {
|
||
const ostalnoe = {
|
||
budget: ZAPAS, disk: MESTO, planSeal: PECHAT, papers: BUMAGI, spec: ZAMYSEL,
|
||
};
|
||
|
||
it('восьмой работник внутри задачи — отказ (проверка 27)', () => {
|
||
const semero = [{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1', 'w-2', 'w-3', 'w-4', 'w-5', 'w-6', 'w-7'], items: [] }];
|
||
const r = startGates(vhodVorot({
|
||
cfg: NASTROYKI, ...ostalnoe, running: semero, taskId: 'z-1', items: [{ id: '8', files: ['app/Z.php'] }],
|
||
}));
|
||
expect(r.ok).toBe(false);
|
||
expect(r.failed.map(f => f.gate)).toContain('потолки задач и работников');
|
||
});
|
||
|
||
it('🔴 ГРАНИЦА: шестеро идут, просят седьмого — ПУСКАЮТ (проверка 46)', () => {
|
||
// 🔴🔴 Шов между задачей 2 и этой. `vhodVorot` подаёт «сколько СТАНЕТ»: шестеро идут
|
||
// плюс один просимый — семь. Правило задачи 2 сравнивает строго (`> 7`), поэтому
|
||
// семеро проходят. Стоял бы там `>=`, как в первом наброске, — эта проверка
|
||
// покраснела бы, а семеро одной задачи не собрались бы никогда, и проверку 46
|
||
// бумаги («семеро работников одной задачи шли разом») показать было бы нечем.
|
||
// Проверка ЖИВОГО пути: числа идут не рукой, а через `vhodVorot`.
|
||
const shestero = [{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1', 'w-2', 'w-3', 'w-4', 'w-5', 'w-6'], items: [] }];
|
||
const r = startGates(vhodVorot({
|
||
cfg: NASTROYKI, ...ostalnoe, running: shestero, taskId: 'z-1', items: [{ id: '7', files: ['app/Z.php'] }],
|
||
}));
|
||
expect(r.ok).toBe(true);
|
||
expect(r.failed).toEqual([]);
|
||
});
|
||
|
||
it('третья задача — отказ, а продолжение уже идущей — пропуск (проверка 26)', () => {
|
||
const r = startGates(vhodVorot({
|
||
cfg: NASTROYKI, ...ostalnoe, running: IDUT, taskId: 'z-3', items: [{ id: '1', files: ['app/Z.php'] }],
|
||
}));
|
||
expect(r.ok).toBe(false);
|
||
expect(r.failed.map(f => f.gate)).toContain('потолки задач и работников');
|
||
// 🔴 УСИЛЕНО ПРОТИВ ПЛАНА, вслух. План сам признал, что в красной поре эта проверка
|
||
// ЗЕЛЁНАЯ: кусок 2 складывал прогоны и на этом входе случайно давал тот же ответ.
|
||
// Зелёная в красной поре не сторожит ничего, поэтому здесь же стоит её зеркало —
|
||
// тот же вход, но задача СВОЯ. Отличить «третья» от «продолжение начатого» кусок 2
|
||
// не умел вовсе, и зеркало краснеет честно.
|
||
const svoya = startGates(vhodVorot({
|
||
cfg: NASTROYKI, ...ostalnoe, running: IDUT, taskId: 'z-1', items: [{ id: '1', files: ['app/Z.php'] }],
|
||
}));
|
||
expect(svoya.ok).toBe(true);
|
||
});
|
||
|
||
it('новая задача задевает файл идущей — отказ, и файл назван (проверка 50)', () => {
|
||
const r = startGates(vhodVorot({
|
||
cfg: { ...NASTROYKI, max_tasks_at_once: 9 }, ...ostalnoe,
|
||
running: IDUT, taskId: 'z-3', items: [{ id: '1', files: ['app/A.php'] }],
|
||
}));
|
||
expect(r.ok).toBe(false);
|
||
expect(r.failed.map(f => f.gate)).toContain('задачи не пересекаются по файлам');
|
||
expect(r.all_reasons.join(' ')).toContain('app/A.php');
|
||
});
|
||
|
||
it('вторая задача своими файлами — ворота пропускают: они не глухие', () => {
|
||
// 🔴 Страховка. Ворота, которые не пропускают НИКОГДА, зеленят три проверки выше
|
||
// и убивают затею целиком: ночь не запустится ни разу, а причина будет выглядеть верной.
|
||
const r = startGates(vhodVorot({
|
||
cfg: NASTROYKI, ...ostalnoe,
|
||
running: [IDUT[0]], taskId: 'z-3', items: [{ id: '1', files: ['app/Z.php'] }],
|
||
}));
|
||
expect(r.ok).toBe(true);
|
||
expect(r.failed).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('часть 7: два новых ключа команды run и правда про несторожащие ворота', () => {
|
||
it('--task и --files доходят до разбора доводов', () => {
|
||
const d = razborDovodov(argvFor(
|
||
'run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md',
|
||
'--task', 'z-1', '--files', 'app/A.php, app\\B.php',
|
||
));
|
||
expect(d.taskId).toBe('z-1');
|
||
expect(d.files).toEqual(['app/A.php', 'app/B.php']);
|
||
});
|
||
|
||
it('🔴 `odinVid` куска 2 и `odinVidPuti` куска 3 отвечают ОДИНАКОВО', () => {
|
||
// 🔴🔴 Две мелочи делают одну работу — сведение имени файла к одному виду. Разойдись
|
||
// они хоть в одном знаке, и ключ `--files` дал бы одно имя, а замок и ворота ждали бы
|
||
// другое: пересечения не находились бы, и все проверки остались бы зелёными.
|
||
// Свести их в одну совсем нельзя: `odinVid` куска 2 зовут и другие его места, а кусок 3
|
||
// чужое не переписывает. Значит их СВЕРЯЕМ — вот здесь.
|
||
for (const obrazets of ['app\\A.php', './app/A.php', 'app//A.php', ' app/A.php ', 'docs\\a\\b.md', '']) {
|
||
expect(odinVid(obrazets)).toBe(odinVidPuti(obrazets));
|
||
}
|
||
});
|
||
|
||
it('ключей не назвали — файлов нет и задача новая, а не выдуманные значения', () => {
|
||
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md'));
|
||
expect(d.files).toEqual([]);
|
||
expect(d.taskId).toBeNull();
|
||
});
|
||
|
||
it('строка «эти ворота пока НЕ сторожат» потеряла двоих', () => {
|
||
// 🪤 Тихая поломка: ворота начали сторожить, а владельцу в сводке по-прежнему пишут,
|
||
// что они пропускают всё. Он читает неправду и не узнает об этом никогда.
|
||
const spisok = TEKST_CLI.match(/VOROTA_POKA_NE_STOROZHAT = \[([\s\S]*?)\]/)[1];
|
||
expect(spisok).toContain('отклик владельца');
|
||
expect(spisok).toContain('от прошлого прогона никого');
|
||
expect(spisok).not.toContain('потолки задач и работников');
|
||
expect(spisok).not.toContain('задачи не пересекаются по файлам');
|
||
});
|
||
|
||
it('🔴 СВЕРХ ПЛАНА: несторожащих ворот названо владельцу РОВНО столько, сколько пустышек в самих воротах', () => {
|
||
// 🔴 Сторож на КЛАСС, а не на место: «ворота начали сторожить, а владельцу про них
|
||
// по-прежнему пишут „пропускают всё"». Проверка выше сторожит ровно сегодняшних двоих
|
||
// поимённо — то есть ровно сегодняшний день. Эта переживёт кусок 7: подставит он
|
||
// `ownerResponded` вместо пустышки и забудет вычеркнуть строку у владельца —
|
||
// покраснеет здесь.
|
||
const pustyshek = (TEKST_VOROT.match(/notYetWritten\(\),/g) ?? []).length;
|
||
const nazvano = TEKST_CLI.match(/VOROTA_POKA_NE_STOROZHAT = \[([\s\S]*?)\]/)[1]
|
||
.split('\n').filter(s => /['«][а-яё]/i.test(s)).length;
|
||
expect(nazvano).toBe(pustyshek);
|
||
});
|
||
|
||
it('🔴 СВЕРХ ПЛАНА: точка входа вправду ПОДАЁТ воротам номер задачи и настоящие файлы', () => {
|
||
// 🔴 Сторож на КЛАСС «правило написано, а зовущего нет» — ровно тот, ради которого
|
||
// весь этот кусок и затеян. Разбор доводов и `vhodVorot` проверены выше поодиночке,
|
||
// но между ними стоит `cli.mjs`, чьи команды проверками не покрыты намеренно (живой
|
||
// запуск ломается громко). Убери кто-нибудь `taskId` из зова ворот или верни
|
||
// `files: []` единственному пункту — обе половины останутся зелёными, а ночь молча
|
||
// вернётся к «ворота пересечения не держат никогда». Поэтому смотрим текстом.
|
||
// 🔄 ПЕРЕНАЦЕЛЕНО КРУГОМ Б 04.08.2026, называю вслух. Прежде здесь искалось
|
||
// `planRun(vhodVorot({...}))` одной строкой. Такой строки больше нет: вход ворот
|
||
// собирается ОДИН раз (`const vhod = vhodVorot({...})`) и служит обоим проходам —
|
||
// бесплатному до денег и полному после. Сторожимое свойство не ослаблено: смотрим
|
||
// ровно тот же зов `vhodVorot`, только по его новому имени.
|
||
const zovVorot = TEKST_CLI.match(/const vhod = vhodVorot\(\{([\s\S]*?)\}\);/)[1];
|
||
expect(zovVorot).toContain('taskId');
|
||
// 🔴 ПЕРЕНАЦЕЛЕНО КУСКОМ 4, называю вслух. Прежде здесь стояло `files: razbor.files`
|
||
// у единственного слепленного пункта «весь-план»: до разбора плана файлы называл
|
||
// владелец рукой ключом `--files`. Куска этого больше нет — файлы каждого пункта
|
||
// берутся из самого плана. Сторожимое СВОЙСТВО не ослаблено, а усилено: раньше
|
||
// сторожили «список файлов не пуст», теперь — что воротам идут вправду разобранные
|
||
// пункты плана. Верни кто-нибудь сюда выдуманный пункт — покраснеет.
|
||
expect(zovVorot).toMatch(/items:\s*punkty/);
|
||
expect(TEKST_CLI).toMatch(/punkty\s*=\s*vhodVorot\(\{\s*planPath\s*\}\)\.items/);
|
||
expect(TEKST_CLI).not.toMatch(/id:\s*'весь-план'/);
|
||
});
|
||
});
|
||
|
||
// ============ беда 2: порванная карточка прогона снимала ОБА замка разом ============
|
||
//
|
||
// 🔴 Найдено воротами ЖИВЬЁМ: одну запись порвали — и тут же завелась третья задача
|
||
// при потолке в две, а следом задача на файл, который порванный прогон держит. Оба отказа
|
||
// исчезли МОЛЧА: список идущих прогонов читался плоским `readJson`, а тот на порванном файле
|
||
// отдаёт «ничего», и прогон просто выпадал из счёта.
|
||
//
|
||
// 🔴 Правило на этот класс в хозяйстве УЖЕ ЕСТЬ и уже применено дважды: `file-claims.mjs`
|
||
// (замок на файлы) и `plan-lock-hook.mjs` (замок на правку плана) читают через общий прибор
|
||
// `readJsonOrBroken` и прямо пишут почему — испорченную запись нельзя считать за «никого нет».
|
||
// Здесь закрывается третья дверь тем же способом, а не выдумывается четвёртый.
|
||
describe('часть 8: порванная карточка прогона не превращается в «прогона нет»', () => {
|
||
function razlozhit(kartochki) {
|
||
const koren = join(dir, 'runs');
|
||
for (const [id, tekst] of Object.entries(kartochki)) {
|
||
mkdirSync(join(koren, id), { recursive: true });
|
||
writeFileSync(join(koren, id, 'run.json'), tekst, 'utf8');
|
||
}
|
||
return koren;
|
||
}
|
||
|
||
const idet = id => JSON.stringify({ run_id: id, status: 'running', task_id: id, workers: ['w-1'], items: [{ id: '1', files: ['app/A.php'] }] });
|
||
|
||
it('🔴 карточка порвана — счёт идущих прогонов падает ГРОМКО, а не теряет прогон молча', () => {
|
||
const koren = razlozhit({ 'r-1': idet('r-1'), 'r-2': '{"run_id": "r-2", "status": "runn' });
|
||
expect(() => runningRuns(koren)).toThrow(/испорчен|прочесть не удалось|не читается/i);
|
||
});
|
||
|
||
it('🔴 в отказе назван ФАЙЛ, который надо посмотреть глазами', () => {
|
||
const koren = razlozhit({ 'r-2': 'это не JSON вовсе' });
|
||
expect(() => runningRuns(koren)).toThrow(/r-2/);
|
||
});
|
||
|
||
it('всё цело — идущие считаются, а кончившиеся не считаются (правило не глухое)', () => {
|
||
const koren = razlozhit({
|
||
'r-1': idet('r-1'),
|
||
'r-3': JSON.stringify({ run_id: 'r-3', status: 'finished', workers: ['w-1'] }),
|
||
});
|
||
const idut = runningRuns(koren);
|
||
expect(idut.map(r => r.run_id)).toEqual(['r-1']);
|
||
});
|
||
|
||
it('каталога прогонов нет вовсе — пусто, а не обвал', () => {
|
||
expect(runningRuns(join(dir, 'такого-каталога-нет'))).toEqual([]);
|
||
});
|
||
|
||
it('каталог прогона есть, а карточки в нём нет — это НЕ прогон, и не обвал', () => {
|
||
mkdirSync(join(dir, 'runs2', 'r-9'), { recursive: true });
|
||
expect(runningRuns(join(dir, 'runs2'))).toEqual([]);
|
||
});
|
||
|
||
it('🔴 счёт идущих прогонов читает карточки ОБЩИМ прибором, а не плоским readJson', () => {
|
||
// 🔴 Сторож на КЛАСС, а не на место: «одну дверь закрыли, вторую забыли». Плоский
|
||
// `readJson` на порванном файле отдаёт `null`, и прогон молча выпадает из счёта.
|
||
const telo = TEKST_CLI.match(/export function runningRuns\([\s\S]*?\n\}/)[0];
|
||
expect(telo).toContain('readRuns');
|
||
expect(telo).not.toMatch(/readJson\(/);
|
||
});
|
||
|
||
it('🔴 обёртка одна на ОБОИХ зовущих: и `seal`, и `run` спрашивают счёт через неё', () => {
|
||
// Два try/catch по месту разъехались бы молча: поправят один, забудут другой.
|
||
// 🔄 ПОПРАВЛЕНО КРУГОМ Б 04.08.2026 — и поправка сама по себе находка. Прежде здесь
|
||
// стояло `toBe(2)`: `seal` звал обёртку напрямую, и `run` звал её ВТОРОЙ раз напрямую,
|
||
// хотя чуть выше уже спросил тот же счёт через точку впрыска `idushchie`. То есть один
|
||
// запуск читал карточки прогонов ДВАЖДЫ — два ответа могли разойтись между собой
|
||
// (за секунды между ними прогон успевает завестись или умереть), а проверки, подставлявшие
|
||
// первый зов, второго не видели вовсе. Теперь у `run` спрос ОДИН, через впрыск.
|
||
// 🪤 Примечания вырезаются ПЕРЕД счётом. Поймано на себе тут же: я объяснил в коде,
|
||
// что прямого `idushchieProgony()` в `run` больше нет, — и сторож покраснел на этом
|
||
// самом объяснении. Сторож, красный от собственного примечания, толкает вычеркнуть
|
||
// объяснение, а не починить дело. Та же беда уже ловилась в этом файле у проверки
|
||
// «план читается ровно один раз», и лечение берётся оттуда же.
|
||
const KOD_CLI = TEKST_CLI.split('\n').filter(s => !s.trim().startsWith('//')).join('\n');
|
||
expect((KOD_CLI.match(/idushchieProgony\(\)/g) ?? []).length).toBe(1); // это `seal`
|
||
expect(TEKST_CLI).toMatch(/idushchie = idushchieProgony/); // это `run`, через впрыск
|
||
expect((BLOK_RUN.match(/idushchie\(\)/g) ?? []).length).toBe(1); // и ровно один раз
|
||
expect((TEKST_CLI.match(/runningRuns\(\)/g) ?? []).length).toBe(0);
|
||
});
|
||
|
||
it('🔴 обёртка отдаёт владельцу СТРОКУ, а не роняет программу следом стека', () => {
|
||
const r = idushchieProgony(() => { throw new Error('Карточка прогона r-2 испорчена.'); });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toContain('r-2');
|
||
expect(r.running).toEqual([]);
|
||
});
|
||
|
||
it('всё цело — обёртка отдаёт список как есть (не глухая)', () => {
|
||
// 🔄 ИЗМЕНЕНО 03.08.2026 вместе с проверкой живости. Прежде здесь стояло `toBe(spisok)` —
|
||
// тот же самый список тем же предметом. Теперь обёртка каждый прогон СУДИТ (идёт он
|
||
// вправду или только помечен), поэтому предмет собирается заново. Смысл сторожа не
|
||
// изменился и проверяется по-прежнему: правило не глухое, целый прогон из счёта не
|
||
// пропадает. Каталога у выдуманного `r-1` нет, значит он «не прочесть», а «не прочесть»
|
||
// место за собой держит — потому и остаётся в списке.
|
||
const spisok = [{ run_id: 'r-1', status: 'running' }];
|
||
const r = idushchieProgony(() => spisok);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.running).toEqual(spisok);
|
||
expect(r.reason).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ============ кусок 4: план разбирается на пункты, отказы печатаются до замера ============
|
||
//
|
||
// 🔴 ТРИ ОТСТУПЛЕНИЯ ОТ ПЛАНА ЗАДАЧИ 6, НАЗВАННЫЕ ВСЛУХ. План описывал `cli.mjs` таким,
|
||
// каким тот был два куска назад, и списанные с него проверки сторожили бы пустоту:
|
||
// 1. План искал в тексте `probeUtilization(` — этого имени в `cli.mjs` НЕТ вовсе, замер
|
||
// давно идёт через `zamerSoSchyotom` (сторож `platnyy-zamer-tolko-cherez-schyot`).
|
||
// Проверка «отказ раньше замера» искала бы имя, которого нет, и была бы зелена всегда
|
||
// и при любом порядке — то есть не сторожила бы ровно того, ради чего написана.
|
||
// 2. План звал разобранные доводы `dovody`; в файле они зовутся `razbor`.
|
||
// 3. План велел сторожить «`const running = runningRuns()` объявлен один раз». Такой
|
||
// строки в `cli.mjs` нет: список идущих берётся через `idushchieProgony()` — обёртку,
|
||
// которая отличает «никого нет» от «карточка порвана». Переносить её вверх незачем:
|
||
// разбору плана она не нужна вовсе. Вместо этого сторожим то, что вправду опасно, —
|
||
// что план читается РОВНО ОДИН раз за запуск.
|
||
|
||
const PLAN_OBRAZETS = [
|
||
'# Пример плана',
|
||
'',
|
||
'### Task 1: Первый пункт',
|
||
'<!-- night: tests=app/tests/Feature/PervyyTest.php; files=app/A.php -->',
|
||
'Текст пункта.',
|
||
'',
|
||
'### Task 2: Второй пункт',
|
||
'<!-- night: tests=tools/night/vtoroy.test.mjs; files=app/B.php; long=yes -->',
|
||
'Текст пункта.',
|
||
].join('\n');
|
||
|
||
describe('часть 7: план разбирается на пункты на ВХОДЕ ворот', () => {
|
||
it('🔴 `vhodVorot` читает файл плана и отдаёт пункты с их проверками и файлами', () => {
|
||
// 🔴🔴 Здесь снимается долг куска 3, названный им вслух: через `cli.mjs run` больше
|
||
// одного работника за раз завести было нельзя — ключ `--files` клал все имена
|
||
// в один пункт. Теперь пункты берутся из плана, и их столько, сколько написал автор.
|
||
const v = vhodVorot({
|
||
cfg: NASTROYKI, running: [], taskId: null,
|
||
planPath: 'docs/пример.md',
|
||
readPlan: () => PLAN_OBRAZETS,
|
||
});
|
||
expect(v.items.map(i => i.id)).toEqual(['1', '2']);
|
||
expect(v.items[0].tests).toEqual(['app/tests/Feature/PervyyTest.php']);
|
||
expect(v.newTaskFiles).toEqual(['app/A.php', 'app/B.php']);
|
||
expect(v.items[1].long).toBe(true);
|
||
});
|
||
|
||
it('пути плана не дали — пункты берутся из довода, как прежде', () => {
|
||
// Умолчание честное: своих пунктов `vhodVorot` не выдумывает ни одного.
|
||
const v = vhodVorot({ cfg: NASTROYKI, running: [], items: [{ id: '9', files: ['app/Z.php'] }] });
|
||
expect(v.items.map(i => i.id)).toEqual(['9']);
|
||
});
|
||
|
||
it('🔴 пункты из плана идут и в счёт потолка работников, а не только в ответ', () => {
|
||
// 🪤 СВЕРХ ПЛАНА. В теле `vhodVorot` слово `items` стояло в ТРЁХ местах, и заменить
|
||
// надо все три. Пропусти третье — `workers_in_task` считал бы пункты, поданные доводом
|
||
// (то есть ноль), потолок в семь работников не сработал бы НИ РАЗУ, и проверка 27
|
||
// отменилась бы молча. Первые два места стерегут проверки выше, третье — эта.
|
||
const v = vhodVorot({
|
||
cfg: NASTROYKI, running: [], taskId: null,
|
||
planPath: 'docs/пример.md',
|
||
readPlan: () => PLAN_OBRAZETS,
|
||
});
|
||
expect(v.capacity.workers_in_task).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe('часть 8: ключи «продолжай» и «список брака»', () => {
|
||
it('`--continue` разобран как слово владельца', () => {
|
||
const d = razborDovodov(argvFor(
|
||
'run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--continue',
|
||
));
|
||
expect(d.ownerSaidContinue).toBe(true);
|
||
});
|
||
|
||
it('`--defects <файл>` разобран в список брака по строкам', () => {
|
||
const d = razborDovodov(
|
||
argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--defects', 'docs/brak.md'),
|
||
{ readDefects: () => 'кнопка не там\n\nсумма не сходится\n' },
|
||
);
|
||
expect(d.defectList).toEqual(['кнопка не там', 'сумма не сходится']);
|
||
});
|
||
|
||
it('ключей не дали — «продолжай» нет и список брака пуст, а не выдуманы', () => {
|
||
const d = razborDovodov(argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md'));
|
||
expect(d.ownerSaidContinue).toBe(false);
|
||
expect(d.defectList).toEqual([]);
|
||
});
|
||
|
||
it('🔴 файла со списком брака нет — список ПУСТ, а не выдуман непустым', () => {
|
||
// 🪤 СВЕРХ ПЛАНА. Непустой список на нечитаемом файле открыл бы ворота задаче, брак
|
||
// которой никто не назвал: работник пошёл бы переделывать неизвестно что.
|
||
const d = razborDovodov(
|
||
argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--defects', 'нет-такого-файла.md'),
|
||
{ readDefects: () => { throw new Error('нет такого файла'); } },
|
||
);
|
||
expect(d.defectList).toEqual([]);
|
||
expect(d.defectsPath).toBe('нет-такого-файла.md');
|
||
});
|
||
|
||
it('🔴 `--continue` не съедает следующее слово командной строки', () => {
|
||
// 🪤 СВЕРХ ПЛАНА. Спроси мы `--continue` тем же помощником, что и ключи со значением,
|
||
// он забрал бы следующее слово — то есть чужой ключ, и `--task` потерялся бы молча.
|
||
const d = razborDovodov(argvFor(
|
||
'run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--continue', '--task', 'z-7',
|
||
));
|
||
expect(d.ownerSaidContinue).toBe(true);
|
||
expect(d.taskId).toBe('z-7');
|
||
});
|
||
});
|
||
|
||
// 🔴🔴 СМОТРИМ ТОЛЬКО ТЕЛО КОМАНДЫ `run`, И ЭТО НЕ ПРИДИРКА. Первая моя попытка искала
|
||
// имена по ВСЕМУ файлу — и сторож молча смотрел не туда: у команды `budget` есть СВОЙ зов
|
||
// замера, и он стоит в файле выше всей команды `run`. «Отказ раньше замера» сравнивал
|
||
// отказ из `run` с замером из `budget`, то есть был красным при любом верном порядке
|
||
// и позеленел бы от перестановки чужой команды. Замерено, а не предположено.
|
||
const BLOK_RUN = TEKST_CLI.slice(
|
||
TEKST_CLI.indexOf("if (cmd === 'run') {"),
|
||
TEKST_CLI.indexOf("if (cmd === 'smoke-longrun') {"),
|
||
);
|
||
|
||
describe('часть 9: отказы стоят РАНЬШЕ замера запаса и раньше ворот', () => {
|
||
it('🔴 кусок текста, по которому судят проверки ниже, вправду вырезан', () => {
|
||
// 🪤 Без этой строки все проверки порядка ниже стали бы зелёными на ПУСТОЙ строке:
|
||
// переименуй кто-нибудь команду — и `indexOf` дал бы −1, срез вышел бы пустым,
|
||
// а `indexOf` в пустой строке равен −1 у всех имён сразу, то есть «порядок сошёлся».
|
||
expect(BLOK_RUN.length).toBeGreaterThan(2000);
|
||
expect(BLOK_RUN).toContain("if (cmd === 'run') {");
|
||
});
|
||
|
||
it('🔴 отказ по плану печатается ДО крошечного замера остатка', () => {
|
||
// 🔴🔴 Замер идёт платной головой из подписки владельца, то есть стоит денег. План, который
|
||
// 🪤 Имя той программы здесь НЕ НАБИРАЕТСЯ словом нарочно: сторож живого запуска считает
|
||
// всякое её называние заявкой на платный зов и требует вписать её в разрешение — а этот
|
||
// файл её не заводит НИ РАЗУ и заводить не должен.
|
||
// заведомо не пройдёт, не стоит ни одного платного зова. И только при этом порядке
|
||
// отказ можно посмотреть живьём глазами: запуск не рождает ни замера, ни угла,
|
||
// ни живого работника.
|
||
// 🔴 Ищем `priborZamera(`, а не `zamerSoSchyotom(`: задача О-4 (03.08.2026) вынесла разбор
|
||
// ответа замера в общую дверь `vorotaPribora`, и `run` теперь зовёт её, а не прежний код
|
||
// на месте — самого имени `zamerSoSchyotom(` внутри тела `run` больше нет, оно живёт
|
||
// внутри умолчания `vorotaPribora` (см. описание функции), а `smoke-longrun` зовёт ТУ ЖЕ дверь.
|
||
const iPlan = BLOK_RUN.indexOf('validatePlan(');
|
||
const iZamer = BLOK_RUN.indexOf('priborZamera(');
|
||
expect(iPlan).toBeGreaterThan(-1);
|
||
expect(iZamer).toBeGreaterThan(-1);
|
||
expect(iPlan).toBeLessThan(iZamer);
|
||
});
|
||
|
||
it('🔴 ворота отложенной задачи стоят ДО замера и ДО `planRun`', () => {
|
||
const iSled = BLOK_RUN.indexOf('deferredTaskGate(');
|
||
const iZamer = BLOK_RUN.indexOf('priborZamera(');
|
||
const iPlanRun = BLOK_RUN.indexOf('planRun(');
|
||
expect(iSled).toBeGreaterThan(-1);
|
||
expect(iZamer).toBeGreaterThan(-1);
|
||
expect(iPlanRun).toBeGreaterThan(-1);
|
||
expect(iSled).toBeLessThan(iZamer);
|
||
expect(iSled).toBeLessThan(iPlanRun);
|
||
});
|
||
|
||
it('🔴 воротам отложенной задачи подаётся перепроверка плана ИЗ ЭТОГО запуска (проверка 30)', () => {
|
||
// 🪤 Тихая поломка: подай сюда `true` постоянной — правило зеленело бы, а план
|
||
// отложенной задачи не перепроверялся бы ни разу, и работник шёл бы по бумаге,
|
||
// которая за неделю разошлась с продуктом.
|
||
expect(TEKST_CLI).toMatch(/plan_revalidated:\s*planOk\.ok/);
|
||
});
|
||
|
||
it('🔴 `startAll` получает ВСЕ пункты плана, а не только пункты семерых', () => {
|
||
expect(TEKST_CLI).toMatch(/items:\s*punkty/);
|
||
});
|
||
|
||
it('🔴🔴 воротам подан путь плана ИЗ СЛЕДА и путь плана ЭТОГО запуска (проверка 29б)', () => {
|
||
// 🔴🔴 Правило «тот же план» живёт в воротах, но без обоих путей оно стоит и молчит:
|
||
// задачу можно было бы «доделать» по совсем другому плану, и заметить это нечем.
|
||
expect(TEKST_CLI).toMatch(/plan_path:\s*sled\.plan_path/);
|
||
expect(TEKST_CLI).toMatch(/plan_path_now:\s*razbor\.planPath/);
|
||
});
|
||
|
||
it('🔴 команда проверок работника строится ИЗ ЕГО пункта, а не общим набором', () => {
|
||
// 🔴🔴 Кусок 1 клал сюда `() => "npm run test:tools"` — общий набор. Оставь его —
|
||
// работник читал бы в поручении, что его пункт меряется всем прогоном продукта.
|
||
// 🪤 Сама надёжность проверки 38 держится НЕ на этой строке: замок команду из карточки
|
||
// не берёт вовсе, он считает её заново на каждом судимом пункте (задача 5).
|
||
// Здесь сторожится только то, что говорят РАБОТНИКУ.
|
||
expect(TEKST_CLI).toMatch(/testCommandFor:\s*w\s*=>\s*komandaProverokPunkta\(w\.item\)/);
|
||
});
|
||
|
||
it('🔴 план читается РОВНО ОДИН раз за запуск', () => {
|
||
// 🔴🔴 Читай мы файл дважды, между двумя чтениями он мог бы измениться, и ворота
|
||
// судили бы не тот план, который пошёл в работу. Поэтому `planPath` подан
|
||
// единственному зову — тому, который его разбирает.
|
||
const chteniya = BLOK_RUN.match(/vhodVorot\(\{\s*planPath\s*\}\)/g) ?? [];
|
||
expect(chteniya.length).toBe(1);
|
||
// 🪤 И полный зов ворот путь плана НЕ получает: получи он его — файл прочли бы второй раз.
|
||
// 🔴 Примечания из зова вырезаются. Найдено на себе: я написал в самом зове словами,
|
||
// что путь плана сюда НЕ подаётся, — и эта проверка покраснела на СЛОВЕ «planPath»
|
||
// в примечании, хотя довода там нет. Сторож, красный от собственного объяснения,
|
||
// толкает вычеркнуть объяснение, а не починить дело.
|
||
const zovVorot = BLOK_RUN.match(/const vhod = vhodVorot\(\{([\s\S]*?)\}\);/)[1]
|
||
.split('\n').filter(s => !s.trim().startsWith('//')).join('\n');
|
||
expect(zovVorot).not.toMatch(/planPath/);
|
||
// 🔴 И третья половина, добавленная кругом Б: полный проход ворот питается ТЕМ ЖЕ
|
||
// собранным входом, а не вторым зовом `vhodVorot`. Собери его дважды — бесплатный
|
||
// проход судил бы одно хозяйство, а платный другое, и владелец получил бы «до денег
|
||
// было можно, после денег нельзя» без единого слова о том, что изменилось.
|
||
expect(BLOK_RUN).toMatch(/planRun\(\{\s*\.\.\.vhod,\s*budget\s*\}\)/);
|
||
expect((BLOK_RUN.match(/vhodVorot\(/g) ?? []).length).toBe(2);
|
||
// 🪤 И вторая половина: своего, второго чтения файла плана в `cli.mjs` не осталось.
|
||
// Кусок 1 читал первую строку плана сам (`readFileSync(planPath`), чтобы слепить
|
||
// единственный пункт «весь-план». Этот путь заменён разбором, и возвращать его нельзя:
|
||
// два места, читающих один файл, — это два разных плана в одном запуске.
|
||
expect(TEKST_CLI).not.toMatch(/readFileSync\(planPath/);
|
||
});
|
||
|
||
it('🔴 отказ по плану вправду ОСТАНАВЛИВАЕТ запуск, а не только произносится', async () => {
|
||
// 🔴🔴🔴 НАЙДЕНО ВЫРЕЗАНИЕМ ВТОРОГО РОДА, планом не предусмотрено — дыра была настоящая.
|
||
// Все проверки выше смотрят ТЕКСТ и доказывают, что ворота плана ЗОВУТСЯ. Но зов —
|
||
// это ещё не защита: я обвёл их умно (`if (false && !planOk.ok)`), оставив зов на месте,
|
||
// и ВСЕ 139 проверок остались зелёными, а негодный план пошёл бы в ночь. Это ровно тот
|
||
// класс, ради которого весь кусок и затеян, только этажом выше: не «правило написано,
|
||
// а зовущего нет», а «зовущий есть, а ответа он не слушает».
|
||
//
|
||
// 🔴 ПЛАТНОГО ЗОВА ЗДЕСЬ НЕ РОДИТСЯ НИ ПРИ КАКОЙ ПОЛОМКЕ, и это не надежда. В настройках
|
||
// намеренно НЕТ строки `probe_model`, а замер спрашивает её ПЕРВЫМ делом и падает
|
||
// до единого запуска чего бы то ни было. Убери кто-нибудь отказ по плану — проверка
|
||
// покраснеет на СЛОВАХ, а не потратит деньги владельца.
|
||
const vremenno = mkdtempSync(join(tmpdir(), 'night-plan-gate-'));
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
const planFile = join(vremenno, 'plan.md');
|
||
const priyomkaFile = join(vremenno, 'priyomka.md');
|
||
const specFile = join(vremenno, 'spec.md');
|
||
writeFileSync(planFile, '# Пробный план\n\nЗаголовков пунктов здесь нет ни одного.\n');
|
||
writeFileSync(priyomkaFile, '# приёмочный лист\n');
|
||
writeFileSync(specFile, '# замысел\n');
|
||
|
||
const code = await main(
|
||
argvFor('run', planFile, '--priyomka', priyomkaFile, '--spec', specFile),
|
||
{ readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7 }) },
|
||
);
|
||
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
||
expect(code).toBe(1);
|
||
// 🪤 Судим ПО НАПЕЧАТАННЫМ СЛОВАМ, а не по коду возврата: единица приходит и когда
|
||
// ворота честно отказали, и когда до них не дошло вовсе. Код возврата тут не улика.
|
||
expect(skazano).toMatch(/план проверку не прошёл/i);
|
||
expect(skazano).toMatch(/нет ни одного пункта/i);
|
||
} finally {
|
||
errSpy.mockRestore();
|
||
rmSync(vremenno, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('🔴 отказ по плану с повторяющимися номерами тоже ОСТАНАВЛИВАЕТ запуск', async () => {
|
||
// Второй вкус той же беды: номер пункта — ключ, по которому ведут очередь и запирают
|
||
// файлы. Двое под одним номером закроют друг друга, и оба отчитаются правдой.
|
||
const vremenno = mkdtempSync(join(tmpdir(), 'night-plan-gate-'));
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
const planFile = join(vremenno, 'plan.md');
|
||
const priyomkaFile = join(vremenno, 'priyomka.md');
|
||
const specFile = join(vremenno, 'spec.md');
|
||
writeFileSync(planFile, [
|
||
'# Пробный план',
|
||
'',
|
||
'### Task 1: Первый',
|
||
'<!-- night: tests=app/tests/Feature/A.php; files=app/A.php -->',
|
||
'',
|
||
'### Task 1: Второй под тем же номером',
|
||
'<!-- night: tests=app/tests/Feature/B.php; files=app/B.php -->',
|
||
].join('\n'));
|
||
writeFileSync(priyomkaFile, '# приёмочный лист\n');
|
||
writeFileSync(specFile, '# замысел\n');
|
||
|
||
const code = await main(
|
||
argvFor('run', planFile, '--priyomka', priyomkaFile, '--spec', specFile),
|
||
{ readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7 }) },
|
||
);
|
||
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
||
expect(code).toBe(1);
|
||
expect(skazano).toMatch(/повторяются номера пунктов/i);
|
||
// 🪤 И виновник назван поимённо: без имени владелец не поймёт, который пункт чинить.
|
||
expect(skazano).toMatch(/Task 1/);
|
||
} finally {
|
||
errSpy.mockRestore();
|
||
rmSync(vremenno, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('🔴 след отложенной задачи снимается ПОСЛЕ удачного запуска, а не до ворот', () => {
|
||
// 🪤 СВЕРХ ПЛАНА. Сними след раньше отказа — вторая попытка запуска прошла бы уже
|
||
// без ворот вовсе: следа нет, значит «задача не откладывалась», и проверки 28–30
|
||
// отменились бы молча. Сними позже некуда: не сними вовсе — владельцу пришлось бы
|
||
// говорить «продолжай» каждую ночь заново.
|
||
const iVorota = BLOK_RUN.indexOf('deferredTaskGate(');
|
||
const iStartAll = BLOK_RUN.indexOf('const started = startAll(');
|
||
const iSnyatie = BLOK_RUN.indexOf('clearDeferred(');
|
||
expect(iSnyatie).toBeGreaterThan(-1);
|
||
expect(iSnyatie).toBeGreaterThan(iVorota);
|
||
expect(iSnyatie).toBeGreaterThan(iStartAll);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// О-3 — ВОРОТА СВЕРКИ НАСТРОЕК С РЕШЕНИЯМИ ВЛАДЕЛЬЦА (sverka-nastroek-s-resheniyami.mjs)
|
||
// ПОЛУЧАЮТ ПЕРВОГО ЗОВУЩЕГО.
|
||
//
|
||
// 🔴 Датчик зовущих (schyot-zovushchih.mjs) нашёл этого сторожа без единого зова в рабочих
|
||
// файлах: построен, проверен 23 своими проверками, зелёный — и ночь его не спрашивала
|
||
// НИ РАЗУ. Правило метода 2: защита, которую никто не зовёт, не бывает проверенной; затея
|
||
// заплатила за это шесть раз.
|
||
//
|
||
// 🔴 ДВЕ ДОРОГИ, ОДИН ЗОВ. `komandaProdolzheniya` (продолжение, --resume) идёт МИМО девяти
|
||
// ворот обычного запуска нарочно (решение 8) — значит защита девяти ворот её не касается.
|
||
// Обе дороги команды `run` расходятся у проверки `razbor.resumeRunId` ВНУТРИ
|
||
// `if (cmd === 'run')`, а зов сверки стоит ДО этой развилки — там, где дорога у обеих ещё
|
||
// общая. Проверь я только один рукав — второй утёк бы мимо сторожа, и это была бы та же
|
||
// беда, что чинится этой правкой.
|
||
//
|
||
// 🔴 СВЕРКА ЧИТАЕТ НАСТОЯЩИЙ ФАЙЛ ЗАНОВО (через `configPath()`), А НЕ ДОВОД `cfg`, КОТОРЫМ
|
||
// ЖИВЁТ ОСТАЛЬНОЙ `main()`. Причина не лень: `cfg` в проверках команды `run` выше по файлу —
|
||
// подставной у ДЕСЯТКОВ проверок, и ни одна из них не про решения владельца из протокола
|
||
// 02.08 (Р9а и соседние) — им хватает своих нескольких полей. Требовать спавн- и проб-бюджет
|
||
// от КАЖДОЙ из них означало бы менять правку, которая испытывает совсем другое. В бою
|
||
// разницы нет: `readConfig()` по умолчанию читает тот же `configPath()`, что и сверка.
|
||
describe('О-3: ворота сверки настроек с решениями владельца — первый зовущий', () => {
|
||
describe('proverkaNastroekVladeltsa — сама обёртка', () => {
|
||
it('читает настройки доводом и подаёт их сверке как есть', () => {
|
||
const sverka = vi.fn(() => []);
|
||
proverkaNastroekVladeltsa({ sverka, chitatNastoyashchiyFayl: () => ({ a: 1 }) });
|
||
expect(sverka).toHaveBeenCalledWith({ a: 1 });
|
||
});
|
||
|
||
it('файла нет (или он порван) — readJson отдал бы null, а сверке уходит {}, не null', () => {
|
||
// 🔴 Пустой объект — это НЕ «пропускаем»: сверка сама увидит все решения владельца
|
||
// отсутствующими и упадёт вслух по каждому. «Сторож сам сломался» здесь не молчит.
|
||
const sverka = vi.fn(() => []);
|
||
proverkaNastroekVladeltsa({ sverka, chitatNastoyashchiyFayl: () => null });
|
||
expect(sverka).toHaveBeenCalledWith({});
|
||
});
|
||
|
||
it('исход сверки уходит наружу как есть', () => {
|
||
const spisok = [{ tekst: 'слово' }];
|
||
const r = proverkaNastroekVladeltsa({ sverka: () => spisok, chitatNastoyashchiyFayl: () => ({}) });
|
||
expect(r).toBe(spisok);
|
||
});
|
||
|
||
it('падение сверки не глотается — уходит наружу тем же текстом', () => {
|
||
expect(() => proverkaNastroekVladeltsa({
|
||
sverka: () => { throw new Error('решение владельца 9а'); },
|
||
chitatNastoyashchiyFayl: () => ({}),
|
||
})).toThrow(/решение владельца 9а/);
|
||
});
|
||
|
||
it('🔴 живьём настоящей sverkaIliBrosok: дорогое расхождение падает с её текстом', () => {
|
||
// Настоящий прибор, не подставка: доказывает, что связка работает, а не моё
|
||
// представление о том, что она отдаёт.
|
||
const dorogo = {
|
||
spawn_budget_per_shift: 999, probe_budget_per_shift: 30,
|
||
max_workers_per_task: 7, max_tasks_at_once: 2, base_branch: 'feat/noch-proba-1',
|
||
};
|
||
let poyman = null;
|
||
try {
|
||
proverkaNastroekVladeltsa({ chitatNastoyashchiyFayl: () => dorogo });
|
||
} catch (err) { poyman = err; }
|
||
expect(poyman, 'дорогое расхождение обязано падать').not.toBeNull();
|
||
expect(poyman.message).toContain('spawn_budget_per_shift');
|
||
expect(poyman.message).toContain('999');
|
||
expect(poyman.message).toContain('решение владельца 9а');
|
||
expect(poyman.message).toMatch(/БОЛЬШЕ траты/);
|
||
});
|
||
|
||
// 🆕 ПОПРАВЛЕНО 05.08.2026, круг F. Замысел проверки не тронут («дешёвое расхождение
|
||
// не падает, а отдаёт слова») — поправлен только образец настройки под него. Причина:
|
||
// сверка перестала быть списком из пяти полей. Прежний образец теперь падает по-настоящему
|
||
// и по делу — в нём `max_tasks_at_once: 2` при семерых работниках даёт ЧЕТЫРНАДЦАТЬ
|
||
// работников разом против решения владельца 24 («семеро ВСЕГО»), а трёх решений владельца
|
||
// (голова, порог запаса, тумблер доли) в нём нет вовсе. Дешёвым такое расхождение не было
|
||
// никогда — его просто не видел никто.
|
||
// 🪤 Число слов больше не пересчитывается: сверка спрашивает у хранилища живую ветку,
|
||
// и число слов законно зависит от того, двинута ли она. Проверяется то, что и проверялось:
|
||
// отступление в дешёвую сторону названо словами и работу не остановило.
|
||
it('🔴 живьём: дешёвое расхождение не падает, а отдаёт слова', () => {
|
||
const deshevo = {
|
||
spawn_budget_per_shift: 20, probe_budget_per_shift: 30,
|
||
max_workers_per_task: 2, max_tasks_at_once: 1, base_branch: 'feat/noch-proba-1',
|
||
worker_model: 'opus', start_floor_share: 0.99, bez_doli_ne_zapuskat: false,
|
||
};
|
||
const slova = proverkaNastroekVladeltsa({ chitatNastoyashchiyFayl: () => deshevo });
|
||
const svoy = slova.find(s => s.klyuch === 'max_workers_per_task');
|
||
expect(svoy, 'дешёвое отступление обязано быть названо словами').toBeTruthy();
|
||
expect(svoy.tekst).toContain('max_workers_per_task');
|
||
});
|
||
});
|
||
|
||
describe('main() — зов стоит ДО развилки, на обеих дорогах команды run', () => {
|
||
it('🔴 обычный запуск: дорогое расхождение останавливает ночь ДО «Плана нет»', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const code = await main(argvFor('run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y'), {
|
||
readConfig: () => CFG,
|
||
proverkaVladeltsa: () => { throw new Error('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ'); },
|
||
});
|
||
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
||
expect(code).toBe(1);
|
||
expect(skazano).toContain('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ');
|
||
expect(skazano).not.toMatch(/Плана нет/);
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('🔴 дорога продолжения (--resume): та же остановка, ДО существования плана', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const code = await main(argvFor(
|
||
'run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y', '--resume', 'r-1',
|
||
), {
|
||
readConfig: () => CFG,
|
||
proverkaVladeltsa: () => { throw new Error('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ'); },
|
||
});
|
||
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
||
expect(code).toBe(1);
|
||
expect(skazano).toContain('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ');
|
||
expect(skazano).not.toMatch(/Плана нет/);
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('дешёвое расхождение (слова) не останавливает обычный запуск — печатается, дорога идёт дальше', async () => {
|
||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const code = await main(argvFor('run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y'), {
|
||
readConfig: () => CFG,
|
||
proverkaVladeltsa: () => [{ tekst: 'МЯГКОЕ СЛОВО ВЛАДЕЛЬЦУ' }],
|
||
});
|
||
expect(logSpy.mock.calls.map(c => String(c[0]))).toContain('МЯГКОЕ СЛОВО ВЛАДЕЛЬЦУ');
|
||
// Дорога пошла дальше — упёрлась в следующую (честную) причину, план не существует.
|
||
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
||
expect(code).toBe(2);
|
||
expect(skazano).toMatch(/Плана нет/);
|
||
logSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('дешёвое расхождение и на дороге продолжения — тоже идёт дальше', async () => {
|
||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const code = await main(argvFor(
|
||
'run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y', '--resume', 'r-1',
|
||
), {
|
||
readConfig: () => CFG,
|
||
proverkaVladeltsa: () => [{ tekst: 'МЯГКОЕ СЛОВО ВЛАДЕЛЬЦУ' }],
|
||
});
|
||
expect(logSpy.mock.calls.map(c => String(c[0]))).toContain('МЯГКОЕ СЛОВО ВЛАДЕЛЬЦУ');
|
||
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
||
expect(code).toBe(2);
|
||
expect(skazano).toMatch(/Плана нет/);
|
||
logSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('🔴 сторож сам сломался (нет файла настроек) не сходит за «сходится» — ворота падают', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const code = await main(argvFor('run', join(dir, 'net-takogo-plana.md'), '--priyomka', 'x', '--spec', 'y'), {
|
||
readConfig: () => CFG,
|
||
proverkaVladeltsa: () => proverkaNastroekVladeltsa({ chitatNastoyashchiyFayl: () => null }),
|
||
});
|
||
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
||
expect(code).toBe(1);
|
||
expect(skazano).toMatch(/отсутствует в night\.config\.json/);
|
||
errSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
it('🔴 датчик зовущих: `main()` вправду зовёт `proverkaVladeltsa()` до развилки продолжения', () => {
|
||
// 🪤 Текстовый сторож — вторая нога рядом с поведенческими проверками выше: те доказывают
|
||
// ПОВЕДЕНИЕ через подставку, эта доказывает, что подставка вправду встаёт НА МЕСТО
|
||
// настоящего зова, а не осталась неиспользуемым доводом главной программы.
|
||
// 🪤 Найдено живьём: искать голое `razbor.resumeRunId` нельзя — эти же слова стоят
|
||
// и в объяснении ЭТОГО зова чуть выше (примечание над воротами), и находились раньше
|
||
// настоящего кода. Ищем КОД развилки — `if (razbor.resumeRunId)`, а не любое упоминание.
|
||
const iRun = TEKST_CLI.indexOf("if (cmd === 'run') {");
|
||
const iZov = TEKST_CLI.indexOf('proverkaVladeltsa()', iRun);
|
||
const iRazvilka = TEKST_CLI.indexOf('if (razbor.resumeRunId)', iRun);
|
||
expect(iRun).toBeGreaterThan(-1);
|
||
expect(iZov).toBeGreaterThan(iRun);
|
||
expect(iRazvilka).toBeGreaterThan(-1);
|
||
expect(iZov).toBeLessThan(iRazvilka);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// О-4 (03.08.2026) — ТРЕТЬЯ ДОРОГА БЕЗ ДЕНЕГ, НАЙДЕННАЯ ТРЕТЬИМ ПОМОЩНИКОМ СО СТОРОНЫ.
|
||
//
|
||
// `smoke-longrun` заводит ТОГО ЖЕ платного работника, что и `run`/`run --resume`, но стоит
|
||
// ОТДЕЛЬНОЙ веткой `if (cmd === 'smoke-longrun')` — развилку куска `run` (там, где сверка
|
||
// настроек и денежные ворота уже стоят) она не проходит вовсе, потому что `cmd` сравнивается
|
||
// заново, а не входит внутрь ветки `run`. Лечение: `smoke-longrun` зовёт ТЕ ЖЕ функции,
|
||
// что и `run` — `proverkaVladeltsa()` (тот же довод главной программы, без своей копии)
|
||
// и `vorotaPribora()` (кусок, вынесенный ИЗ `run` сюда же: раньше разбор ответа замера лежал
|
||
// ГОТОВЫМ КОДОМ только в теле `run`, и вторая копия для `smoke-longrun` разъехалась бы
|
||
// с первой молча — ровно та беда, что уже случалась у `readRuns`/`file-claims`, см. заметки
|
||
// в шапке `cli.mjs`). `run` теперь тоже зовёт `vorotaPribora()` — дверь одна на обоих, а не своя
|
||
// у каждого.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
describe('О-4: vorotaPribora — ворота ПРИБОРА замера, вынесенные из `run`, теперь зовут оба зовущих', () => {
|
||
it('замер бросил — отказ словами владельцу, а не следом стека', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const g = await vorotaPribora({
|
||
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
|
||
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
|
||
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
|
||
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {},
|
||
zamer: async () => { throw new Error('прибор недоступен'); },
|
||
});
|
||
expect(g.ok).toBe(false);
|
||
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('замер остатка не сделан');
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('замеров за смену больше нет — отказ, причина сторожа названа', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const g = await vorotaPribora({
|
||
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
|
||
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
|
||
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
|
||
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {},
|
||
zamer: async () => ({ ok: false, reason: 'исчерпан лимит замеров за смену' }),
|
||
});
|
||
expect(g.ok).toBe(false);
|
||
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('исчерпан лимит замеров за смену');
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('замер сошёлся, а остатка нет (`measure` пуст) — отказ, ноль не подставляется', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const g = await vorotaPribora({
|
||
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
|
||
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
|
||
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
|
||
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {},
|
||
zamer: async () => ({ ok: true, measure: null, problem: 'вид предела не совпал' }),
|
||
});
|
||
expect(g.ok).toBe(false);
|
||
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('вид предела не совпал');
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('замер вправду посчитан — успех отдаёт измерение как есть', async () => {
|
||
const izm = { utilization: 0.1, status: 'ok', kind: 'seven_day', resets_at: 123 };
|
||
const g = await vorotaPribora({
|
||
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
|
||
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
|
||
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
|
||
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {}, zamer: async () => ({ ok: true, measure: izm }), log: () => {},
|
||
});
|
||
expect(g.ok).toBe(true);
|
||
expect(g.measure).toBe(izm);
|
||
});
|
||
|
||
// ━━━ 🔴🔴 ДВА ПРИГОВОРА ПРИБОРА — ДО ВЛАДЕЛЬЦА, А НЕ В КОРЗИНУ (03.08.2026) ━━━
|
||
// Беда: датчики головы и мастерской работали, а их приговор терялся слоем ворот счёта
|
||
// и до владельца не доходил ни одной дорогой. Здесь сторожится САМАЯ ДОРОГАЯ из трёх
|
||
// дорог — та, по которой идут `run` и `smoke-longrun`.
|
||
it('🔴 голова не та — владелец читает ЭТО В ОКНЕ при УДАЧНОМ (то есть платном) замере', async () => {
|
||
const skazano = [];
|
||
const g = await vorotaPribora({
|
||
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
|
||
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
|
||
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
|
||
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {}, log: s => skazano.push(String(s)), err: s => skazano.push(String(s)),
|
||
zamer: async () => ({
|
||
ok: true,
|
||
measure: { utilization: 0.1, status: 'ok', kind: 'seven_day' },
|
||
golova: { sovpalo: false, zakazana: 'haiku', otvetila: ['claude-opus-4-6'], soobshchenie: 'ЗАКАЗАННОЙ ГОЛОВЫ В СЧЁТЕ НЕТ ВОВСЕ' },
|
||
masterskaya: { otsecheno: true, sostav: { tools: 0, mcp_servers: 0 }, soobshchenie: null },
|
||
}),
|
||
});
|
||
expect(g.ok, 'решение владельца 15: чужая голова прогон НЕ останавливает').toBe(true);
|
||
expect(skazano.join('\n')).toContain('ЗАКАЗАННОЙ ГОЛОВЫ В СЧЁТЕ НЕТ ВОВСЕ');
|
||
});
|
||
|
||
it('🔴 замер НЕ удался — приговоры всё равно печатаются: деньги за него уже потрачены', async () => {
|
||
const skazano = [];
|
||
await vorotaPribora({
|
||
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
|
||
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
|
||
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
|
||
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {}, log: s => skazano.push(String(s)), err: s => skazano.push(String(s)),
|
||
zamer: async () => ({
|
||
ok: true, measure: null, problem: 'вид предела не совпал',
|
||
golova: { sovpalo: true, zakazana: 'haiku', otvetila: ['claude-haiku-4-5'], soobshchenie: null },
|
||
masterskaya: { otsecheno: false, sostav: { tools: 50, mcp_servers: 51 }, soobshchenie: 'ОБВЕС НЕ СРЕЗАН' },
|
||
}),
|
||
});
|
||
expect(skazano.join('\n')).toContain('ОБВЕС НЕ СРЕЗАН');
|
||
});
|
||
|
||
it('🪤 отказ СЧЁТА (замера не было) — про приборы не говорится ни слова: приговаривать нечего', async () => {
|
||
// Денег не потрачено, прибор не запускался. Скажи мы тут «сверить не удалось» —
|
||
// владелец читал бы тревогу там, где ничего не происходило, и перестал бы её замечать.
|
||
const skazano = [];
|
||
await vorotaPribora({
|
||
// 🔴 `phase` и подставной писарь — с круга Б: дверь теперь САМА кладёт оплаченный замер
|
||
// в журнал, сразу как деньги ушли. Подставной писарь тут обязателен: живой писал бы
|
||
// в НАСТОЯЩИЙ общий журнал `.night/budget-measures.jsonl`, а он один на всех.
|
||
cfg: CFG, komanda: 'run', phase: 'before-run', zapisatZamer: () => {}, log: s => skazano.push(String(s)), err: s => skazano.push(String(s)),
|
||
zamer: async () => ({ ok: false, reason: 'замеров за смену больше нет' }),
|
||
});
|
||
expect(skazano.join('\n')).not.toMatch(/Голова:|Мастерская/);
|
||
});
|
||
|
||
it('🔴 ТРЕТЬЯ дорога — команда `budget` — тоже печатает приговоры (сторож по тексту)', () => {
|
||
// 🪤 Живьём эту дорогу проверить нечем: у команды `budget` замер не подставляется
|
||
// доводом, она зовёт настоящий `zamerSoSchyotom`, а он стоит денег владельца. Поэтому
|
||
// сторож читает текст точки входа: зов `strokiPriborov(` обязан стоять ВНУТРИ куска
|
||
// `budget` и ПОСЛЕ отказа по счёту (там прибор не запускался — приговаривать нечего).
|
||
const iCmd = TEKST_CLI.indexOf("if (cmd === 'budget') {");
|
||
const iSled = TEKST_CLI.indexOf("if (cmd === 'seal') {", iCmd);
|
||
const kusok = TEKST_CLI.slice(iCmd, iSled);
|
||
expect(iCmd, 'кусок `budget` пропал из точки входа').toBeGreaterThan(-1);
|
||
expect(iSled).toBeGreaterThan(iCmd);
|
||
expect(kusok, 'команда показа остатка перестала говорить о голове и мастерской').toContain('strokiPriborov(');
|
||
expect(kusok.indexOf('strokiPriborov('))
|
||
.toBeGreaterThan(kusok.indexOf('if (!z.ok)'));
|
||
});
|
||
|
||
it('🔴 ни одна из трёх платных дорог не осталась без приговоров (счёт зовущих по тексту)', () => {
|
||
// 🪤 Датчик зовущих врёт нулём, когда вещь передана значением, — потому имя считается
|
||
// здесь глазами и поимённо, а не «сколько-то раз встретилось». Три зова: `vorotaPribora`
|
||
// (за ней `run` и `smoke-longrun`), `komandaProdolzheniya` и кусок `budget`.
|
||
// 🪤 Ввоз имени в шапке скобки НЕ получает (`strokiPriborov,`) и сюда не попадает —
|
||
// счёт со скобкой считает именно зовы. Ровно на этом слепом пятне уже попадался
|
||
// датчик зовущих (`schyot-zovushchih.mjs`), потому число здесь названо и объяснено.
|
||
const zovy = TEKST_CLI.split('strokiPriborov(').length - 1;
|
||
expect(zovy, 'платных дорог три — зовов приговора должно быть три').toBe(3);
|
||
});
|
||
|
||
it('слова владельцу меняются доводом `otkaz` («Прогон»/«Проба»), а не выдуманы заново на месте', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
await vorotaPribora({
|
||
cfg: CFG, komanda: 'smoke-longrun', phase: 'before-smoke', zapisatZamer: () => {},
|
||
otkaz: 'Проба НЕ запущена',
|
||
zamer: async () => { throw new Error('бух'); },
|
||
});
|
||
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('Проба НЕ запущена: замер остатка не сделан');
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('довод передаётся замеру как есть: та же `cfg`, тот же путь попыток, то же имя команды', async () => {
|
||
const zamer = vi.fn(async () => ({ ok: true, measure: { utilization: 0 } }));
|
||
await vorotaPribora({
|
||
cfg: CFG, komanda: 'smoke-longrun', phase: 'before-smoke', zapisatZamer: () => {},
|
||
zamer, attemptsPath: '/тут/попытки.jsonl',
|
||
});
|
||
// 🪤 `phase` замеру НЕ передаётся и передаваться не должен: это имя для журнала, а не
|
||
// довод прибора. Приборы от него не зависят, и подмешай мы его сюда — сторож перестал бы
|
||
// ловить настоящую беду («двери подсунули не ту cfg»).
|
||
expect(zamer).toHaveBeenCalledWith({ cfg: CFG, attemptsPath: '/тут/попытки.jsonl', komanda: 'smoke-longrun' });
|
||
});
|
||
});
|
||
|
||
describe('О-4: `main()` — smoke-longrun идёт через ТЕ ЖЕ ворота, что и `run`', () => {
|
||
it('🔴 дорогое расхождение настроек останавливает пробу ДО денег: денежные ворота не зовутся вовсе', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const dengiSpy = vi.fn(async () => ({ ok: true, measure: { utilization: 0 } }));
|
||
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
|
||
readConfig: () => CFG,
|
||
proverkaVladeltsa: () => { throw new Error('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ ПРО ПРОБУ'); },
|
||
priborZamera: dengiSpy,
|
||
});
|
||
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
||
expect(code).toBe(1);
|
||
expect(skazano).toContain('РОВНО ЭТОТ ТЕКСТ ВЛАДЕЛЬЦУ ПРО ПРОБУ');
|
||
expect(dengiSpy).not.toHaveBeenCalled();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('дешёвое расхождение настроек — слова печатаются, дорога идёт дальше, к деньгам', async () => {
|
||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||
const dengiSpy = vi.fn(async () => ({ ok: false }));
|
||
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
|
||
readConfig: () => CFG,
|
||
proverkaVladeltsa: () => [{ tekst: 'МЯГКОЕ СЛОВО ПРО ПРОБУ' }],
|
||
priborZamera: dengiSpy,
|
||
});
|
||
expect(logSpy.mock.calls.map(c => String(c[0]))).toContain('МЯГКОЕ СЛОВО ПРО ПРОБУ');
|
||
expect(dengiSpy).toHaveBeenCalledWith(expect.objectContaining({ cfg: CFG, komanda: 'smoke-longrun' }));
|
||
expect(code).not.toBe(0);
|
||
logSpy.mockRestore();
|
||
});
|
||
|
||
it('🔴 денежные ворота отказали (`ok: false`) — проба НЕ заведена, код отличен от успеха', async () => {
|
||
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
|
||
readConfig: () => CFG,
|
||
proverkaVladeltsa: () => [],
|
||
priborZamera: async () => ({ ok: false }),
|
||
});
|
||
expect(code).not.toBe(0);
|
||
});
|
||
|
||
it('🔴 датчик зовущих: тело `smoke-longrun` зовёт `proverkaVladeltsa()` РАНЬШЕ `priborZamera(`', () => {
|
||
// 🪤 Та же вторая нога, что и у куска `run` (проверка выше, часть О-3): поведенческие
|
||
// проверки доказывают ПОВЕДЕНИЕ через подставку, эта доказывает, что подставка вправду
|
||
// встала НА МЕСТО настоящего зова, в настоящем порядке, а не осталась неиспользуемым доводом.
|
||
const iCmd = TEKST_CLI.indexOf("if (cmd === 'smoke-longrun') {");
|
||
const iSverka = TEKST_CLI.indexOf('proverkaVladeltsa()', iCmd);
|
||
const iDengi = TEKST_CLI.indexOf('priborZamera(', iCmd);
|
||
expect(iCmd).toBeGreaterThan(-1);
|
||
expect(iSverka).toBeGreaterThan(iCmd);
|
||
expect(iDengi).toBeGreaterThan(iSverka);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// О-4, ДАТЧИК КЛАССА — не «у smoke-longrun нет ворот», а «у КАКОЙ УГОДНО команды, заводящей
|
||
// платного работника, нет ворот». Сегодня дорог оказалось три, а помнили про две — второй
|
||
// такой случай за день (первый — сверка настроек с нулём зовущих). Ручное перечисление команд
|
||
// глазами эту беду не лечит: четвёртая дорога послезавтра снова пройдёт мимо, и снова
|
||
// придётся, чтобы кто-то вспомнил и проверил руками. Здесь — датчик, который САМ находит
|
||
// в `cli.mjs` все команды и сам решает, кто из них платный.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
// Вырезает тело каждого `if (cmd === '...') { … }` балансом фигурных скобок — а не до
|
||
// следующего `if (cmd ===`, чтобы вложенные `if`/функции внутри не обрывали вырезку раньше
|
||
// времени (внутри `run`, например, лежит целый вложенный `if (razbor.resumeRunId)`).
|
||
function komandnyeBloki(tekst) {
|
||
const bloki = [];
|
||
const re = /if \(cmd === '([\w-]+)'\) \{/g;
|
||
let m;
|
||
while ((m = re.exec(tekst))) {
|
||
const otkrytie = tekst.indexOf('{', m.index);
|
||
let depth = 0;
|
||
let j = otkrytie;
|
||
for (; j < tekst.length; j++) {
|
||
if (tekst[j] === '{') depth++;
|
||
else if (tekst[j] === '}') { depth--; if (depth === 0) break; }
|
||
}
|
||
bloki.push({ cmd: m[1], telo: tekst.slice(otkrytie, j + 1) });
|
||
}
|
||
return bloki;
|
||
}
|
||
|
||
// Имена, по которым датчик узнаёт «эта команда заводит живого ПЛАТНОГО работника» —
|
||
// а не просто мерит остаток (`budget`) или печатает бумагу (`seal`/`approve-spec`).
|
||
// 🪤 `probaProgon(` — то же самое `smokeLongRun`, только под именем точки впрыска. Без него
|
||
// датчик перестал бы видеть пробу платной в ту же минуту, когда точку впрыска завели: имя
|
||
// в тексте поменялось, трата — нет. Ровно так же тут уже живёт `podnyat(` (это
|
||
// `zavestiProdolzhenie` под именем довода). Псевдоним обязан попадать в список ВМЕСТЕ
|
||
// с настоящим именем, а не вместо него.
|
||
const ZAVODYAT_PLATNOGO_RABOTNIKA = ['startAll(', 'smokeLongRun(', 'probaProgon(', 'komandaProdolzheniya(', 'zavestiProdolzhenie(', 'podnyat('];
|
||
// Обе двери, обязательные у КАЖДОЙ такой команды.
|
||
const VOROTA_OBYAZATELNYE = [
|
||
{ imya: 'сверка настроек с решениями владельца', marker: 'proverkaVladeltsa(' },
|
||
// 🪤 Имя двери здесь ЧЕСТНОЕ, и это не мелочь: пока она звалась «денежные ворота»,
|
||
// зелёная строка «каждая платная команда зовёт денежные ворота» читалась как «деньги
|
||
// владельца сторожатся» — а сторожилась исправность прибора. Ворота ДЕНЕГ — в О-5 ниже.
|
||
{ imya: 'ворота прибора замера (НЕ деньги — деньги в О-5)', marker: 'priborZamera(' },
|
||
];
|
||
|
||
describe('О-4: датчик класса сам по себе — на синтетическом тексте, не на настоящем cli.mjs', () => {
|
||
it('поддельная команда со спавном и без единой двери — датчик её находит', () => {
|
||
const poddelnyj = "if (cmd === 'fake-fourth') {\n const r = startAll({ runId: 'x' });\n return 0;\n}\n";
|
||
const bloki = komandnyeBloki(poddelnyj);
|
||
expect(bloki.map(b => b.cmd)).toEqual(['fake-fourth']);
|
||
expect(bloki[0].telo).toContain('startAll(');
|
||
expect(bloki[0].telo).not.toContain('proverkaVladeltsa(');
|
||
expect(bloki[0].telo).not.toContain('priborZamera(');
|
||
});
|
||
|
||
it('та же поддельная команда, но с обеими дверями — датчик видит обе', () => {
|
||
const horoshaya = "if (cmd === 'fake-fourth') {\n proverkaVladeltsa();\n await priborZamera({ cfg, komanda: 'fake-fourth' });\n const r = startAll({ runId: 'x' });\n return 0;\n}\n";
|
||
const bloki = komandnyeBloki(horoshaya);
|
||
expect(bloki[0].telo).toContain('proverkaVladeltsa(');
|
||
expect(bloki[0].telo).toContain('priborZamera(');
|
||
});
|
||
|
||
it('вложенный `if` внутри команды не обрывает вырезку раньше закрывающей скобки', () => {
|
||
const vlozhennyj = "if (cmd === 'x') {\n if (a) {\n b();\n }\n startAll({});\n}\nif (cmd === 'y') {\n z();\n}\n";
|
||
const bloki = komandnyeBloki(vlozhennyj);
|
||
expect(bloki.map(b => b.cmd)).toEqual(['x', 'y']);
|
||
expect(bloki[0].telo).toContain('startAll(');
|
||
expect(bloki[0].telo).toContain('if (a)');
|
||
});
|
||
});
|
||
|
||
describe('О-4: датчик класса на НАСТОЯЩЕМ cli.mjs — каждая платная команда зовёт ОБЕ двери', () => {
|
||
const bloki = komandnyeBloki(TEKST_CLI);
|
||
const platnye = bloki.filter(b => ZAVODYAT_PLATNOGO_RABOTNIKA.some(marker => b.telo.includes(marker)));
|
||
|
||
it('датчик вправду нашёл все семь команд cli.mjs — не меньше и не какие-то другие', () => {
|
||
expect(bloki.map(b => b.cmd).sort()).toEqual(
|
||
['approve-spec', 'budget', 'mail', 'run', 'seal', 'smoke-longrun', 'summary'],
|
||
);
|
||
});
|
||
|
||
it('🔴 датчик вправду нашёл ОБЕ платных дороги — это класс, а не разбор одного места', () => {
|
||
expect(platnye.map(b => b.cmd).sort()).toEqual(['run', 'smoke-longrun']);
|
||
});
|
||
|
||
for (const vorota of VOROTA_OBYAZATELNYE) {
|
||
it(`каждая платная команда зовёт «${vorota.imya}»`, () => {
|
||
for (const b of platnye) {
|
||
expect(b.telo, `команда «${b.cmd}» заводит платного работника, но не зовёт ${vorota.imya}`).toContain(vorota.marker);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// О-5, 03.08.2026 — ВОРОТА ЗАПАСА. ВТОРАЯ ПОЛОВИНА ТОГО ЖЕ КЛАССА.
|
||
//
|
||
// 🔴🔴 ЧЕМ ЭТО ОТЛИЧАЕТСЯ ОТ О-4 ВЫШЕ, И ПОЧЕМУ О-4 БЕДУ НЕ ПОЙМАЛ. Датчик О-4 требует
|
||
// у каждой платной дороги «денежные ворота» — а «денежные ворота» (`vorotaPribora`) спрашивают
|
||
// ПРИБОР: сделан ли замер, пришло ли число. ХВАТАЕТ ЛИ ЗАПАСА они не спрашивают вовсе.
|
||
// Замерено живьём 03.08.2026: доля израсходованного 0,76 при светофоре `allowed_warning`
|
||
// (правило владельца — «пока меньше пяти седьмых», это 0,714), денежные ворота ответили
|
||
// «можно», и проба ЗАВЕЛА платного работника. Обе двери зовутся «денежными» — в этом
|
||
// и была беда: врезали ту, что про исправность прибора, и посчитали, что врезали ту,
|
||
// что про деньги.
|
||
//
|
||
// 🪤 ГЛАВНОЕ ДОПУЩЕНИЕ ЭТОГО ДАТЧИКА, НАЗВАННОЕ ВСЛУХ: он читает ТЕКСТ и доказывает
|
||
// «правило владельца у этой дороги СПРОШЕНО». Он НЕ доказывает «ответ УСЛЫШАН» — дорога
|
||
// могла бы спросить и выбросить приговор в никуда, и текст выглядел бы точно так же.
|
||
// Вторая половина доказывается только поведением, по одной проверке на дорогу; они ниже.
|
||
// Датчик без них — половина, ровно как О-4 оказался половиной.
|
||
//
|
||
// 🪤 ВТОРОЕ ДОПУЩЕНИЕ. Датчик режет дорогу ЦЕЛИКОМ и не различает путей ВНУТРИ неё.
|
||
// У куска `run` путей вправду два — обычный и продолжение (`--resume`), и второй уходит
|
||
// из куска РАНЬШЕ, чем доходит до ворот. Поэтому продолжение режется отдельной дорогой:
|
||
// `komandaProdolzheniya` — своя область, со своим счётом ворот. Кусок `run` без этого
|
||
// выглядел бы сторожимым за обе свои дороги, а сторожил бы одну.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
// Тело функции по её имени — тем же балансом скобок, что и у команд.
|
||
//
|
||
// 🪤 ЗДЕСЬ БЫЛА МОЯ ОШИБКА, И ОНА ПОКРАСНЕЛА САМА. Сперва тело искалось «первой `{` после
|
||
// имени» — а почти у каждой функции затеи доводы разобраны в скобках (`function f({ a, b })`),
|
||
// и первой `{` оказывался СПИСОК ДОВОДОВ, а не тело. Датчик тогда объявлял невиновными
|
||
// и `komandaProdolzheniya`, и `zapisatZamerIVzyatOstatok` — то есть врал ЗЕЛЁНЫМ на настоящем
|
||
// файле. Поэтому сначала честно закрывается список доводов балансом КРУГЛЫХ скобок,
|
||
// и только потом ищется `{` тела.
|
||
function teloFunkcii(tekst, imya) {
|
||
const re = new RegExp(`(?:export\\s+)?(?:async\\s+)?function ${imya}\\s*\\(`);
|
||
const m = re.exec(tekst);
|
||
if (!m) return null;
|
||
const skobka = tekst.indexOf('(', m.index);
|
||
let kruglye = 0;
|
||
let posleDovodov = -1;
|
||
for (let j = skobka; j < tekst.length; j++) {
|
||
if (tekst[j] === '(') kruglye++;
|
||
else if (tekst[j] === ')') { kruglye--; if (kruglye === 0) { posleDovodov = j; break; } }
|
||
}
|
||
if (posleDovodov < 0) return null;
|
||
const otkrytie = tekst.indexOf('{', posleDovodov);
|
||
if (otkrytie < 0) return null;
|
||
let depth = 0;
|
||
for (let j = otkrytie; j < tekst.length; j++) {
|
||
if (tekst[j] === '{') depth++;
|
||
else if (tekst[j] === '}') { depth--; if (depth === 0) return tekst.slice(otkrytie, j + 1); }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function imenaFunkciy(tekst) {
|
||
return [...tekst.matchAll(/(?:export\s+)?(?:async\s+)?function (\w+)\s*\(/g)].map(m => m[1]);
|
||
}
|
||
|
||
// Псевдонимы точек впрыска: строки вида `имя = НастоящееИмя,` в списке доводов.
|
||
// Без них датчик слеп ровно там, где затея прячет настоящий зов за доводом, — а прячет она
|
||
// его почти везде (`podnyat = zavestiProdolzhenie`, `zapisatIZapas = zapisatZamerIVzyatOstatok`).
|
||
function psevdonimy(tekst) {
|
||
return [...tekst.matchAll(/^\s*(\w+) = (\w+),\s*$/gm)].map(m => ({ imya: m[1], nastoyashchee: m[2] }));
|
||
}
|
||
|
||
// 🔴 ЗАМЫКАНИЕ, А НЕ ОДНА СТУПЕНЬ. Имя «спрашивает правило владельца о запасе», если оно
|
||
// само `budgetState`, либо его тело зовёт кого-то из уже набранных, либо оно псевдоним
|
||
// кого-то из уже набранных. Считается до тех пор, пока набор растёт: одной ступени мало —
|
||
// у пробы дорога до правила идёт через ДВЕ (`zapisatIZapas` → `zapisatZamerIVzyatOstatok`
|
||
// → `budgetState`), и датчик на одну ступень объявил бы пробу несторожимой ложно.
|
||
function imenaSprashivayushchieZapas(tekst) {
|
||
const nabor = new Set(['budgetState']);
|
||
const tela = new Map(imenaFunkciy(tekst).map(imya => [imya, teloFunkcii(tekst, imya) ?? '']));
|
||
const psev = psevdonimy(tekst);
|
||
for (let krug = 0; krug < 20; krug++) {
|
||
const bylo = nabor.size;
|
||
for (const [imya, telo] of tela) {
|
||
if (nabor.has(imya)) continue;
|
||
if ([...nabor].some(s => telo.includes(`${s}(`))) nabor.add(imya);
|
||
}
|
||
for (const p of psev) {
|
||
if (!nabor.has(p.imya) && nabor.has(p.nastoyashchee)) nabor.add(p.imya);
|
||
}
|
||
if (nabor.size === bylo) break;
|
||
}
|
||
return nabor;
|
||
}
|
||
|
||
function sprosheniPravilaZapasa(telo, nabor) {
|
||
return [...nabor].some(imya => telo.includes(`${imya}(`));
|
||
}
|
||
|
||
describe('О-5: датчик «спрошено ли правило запаса» сам по себе — на синтетическом тексте', () => {
|
||
it('дорога зовёт счёт запаса напрямую — датчик видит', () => {
|
||
const t = 'function doroga() {\n const b = budgetState(p, cfg, now);\n}\n';
|
||
expect(sprosheniPravilaZapasa(teloFunkcii(t, 'doroga'), imenaSprashivayushchieZapas(t))).toBe(true);
|
||
});
|
||
|
||
it('🔴 дорога не спрашивает правило вовсе — датчик её ловит', () => {
|
||
const t = 'function doroga() {\n const b = vorotaPribora({});\n startAll({});\n}\n';
|
||
expect(sprosheniPravilaZapasa(teloFunkcii(t, 'doroga'), imenaSprashivayushchieZapas(t))).toBe(false);
|
||
});
|
||
|
||
it('🔴 правило спрошено через ДВЕ ступени псевдонимов — датчик доходит, а не сдаётся на первой', () => {
|
||
const t = [
|
||
'function pravilo() {\n return budgetState(a, b, c);\n}',
|
||
'function sredniy({ x }) {\n return pravilo(x);\n}',
|
||
'function doroga({\n chuzhoe = sredniy,\n}) {\n const b = chuzhoe({});\n}',
|
||
'',
|
||
].join('\n');
|
||
const nabor = imenaSprashivayushchieZapas(t);
|
||
expect(nabor.has('sredniy')).toBe(true);
|
||
expect(nabor.has('chuzhoe')).toBe(true);
|
||
expect(sprosheniPravilaZapasa(teloFunkcii(t, 'doroga'), nabor)).toBe(true);
|
||
});
|
||
|
||
it('🪤 похожее имя не считается за зов: `budgetStateHelper` — не `budgetState`', () => {
|
||
const t = 'function doroga() {\n const b = budgetStateHelper;\n}\n';
|
||
expect(sprosheniPravilaZapasa(teloFunkcii(t, 'doroga'), imenaSprashivayushchieZapas(t))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('О-5: датчик класса на НАСТОЯЩЕМ cli.mjs — у каждой платной дороги спрошено правило запаса', () => {
|
||
// Дороги — это НЕ только куски `if (cmd === …)`. Продолжение живёт отдельной функцией,
|
||
// и кусок `run` уходит в неё раньше своих ворот.
|
||
const NABOR = imenaSprashivayushchieZapas(TEKST_CLI);
|
||
const dorogi = [
|
||
...komandnyeBloki(TEKST_CLI).map(b => ({ imya: `команда «${b.cmd}»`, telo: b.telo })),
|
||
...imenaFunkciy(TEKST_CLI).map(imya => ({ imya: `функция «${imya}»`, telo: teloFunkcii(TEKST_CLI, imya) ?? '' })),
|
||
];
|
||
const platnyeDorogi = dorogi.filter(d => ZAVODYAT_PLATNOGO_RABOTNIKA.some(mk => d.telo.includes(mk)));
|
||
|
||
it('🔴 датчик нашёл РОВНО те платные дороги, что известны — новая дорога роняет эту проверку', () => {
|
||
// 🔴 Если она покраснела — в затее завелась дорога к платному работнику, которой раньше
|
||
// не было. Мало дописать её сюда: у каждой дороги обязана быть ПОВЕДЕНЧЕСКАЯ проверка
|
||
// «запаса не хватает — работник не заведён», иначе текстовый датчик снова окажется
|
||
// половиной, как О-4.
|
||
expect(platnyeDorogi.map(d => d.imya).sort()).toEqual([
|
||
'команда «run»',
|
||
'команда «smoke-longrun»',
|
||
'функция «komandaProdolzheniya»',
|
||
'функция «main»',
|
||
]);
|
||
});
|
||
|
||
// 🔴 ОБХОД ИДЁТ ПО НАЙДЕННЫМ ДОРОГАМ, А НЕ ПО СПИСКУ ИМЁН. Списком имён это было сперва —
|
||
// и вырезанием вскрылось, что так новая дорога получает ТОЛЬКО красноту «список изменился»,
|
||
// без прямых слов о том, чего у неё не хватает. Ровно этой близорукостью и жил О-4: он
|
||
// сторожил названное, а не найденное.
|
||
for (const d of platnyeDorogi) {
|
||
it(`у дороги ${d.imya} спрошено правило владельца о запасе`, () => {
|
||
expect(
|
||
sprosheniPravilaZapasa(d.telo, NABOR),
|
||
`${d.imya} заводит платного работника, но правило владельца о запасе у неё не спрошено ни разу`,
|
||
).toBe(true);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('О-5: `vorotaZapasa` — приговор правила владельца превращается в отказ', () => {
|
||
const CFG_Z = { worker_model: 'г', disk_free_min_gb: 5 };
|
||
|
||
it('🔴 запаса не хватает — отказ, и словами САМОГО правила, а не выдуманными на месте', () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const g = vorotaZapasa({
|
||
budget: { may_start: false, reason: 'РОВНО ЭТИ СЛОВА ПРАВИЛА' },
|
||
otkaz: 'Проба НЕ запущена',
|
||
});
|
||
expect(g.ok).toBe(false);
|
||
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('Проба НЕ запущена: РОВНО ЭТИ СЛОВА ПРАВИЛА');
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('🪤 отказ БЕЗ причины — всё равно отказ, а не «раз причины нет, значит можно»', () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const g = vorotaZapasa({ budget: { may_start: false, reason: null } });
|
||
expect(g.ok).toBe(false);
|
||
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('причина не названа');
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('🪤 ТРЕТИЙ ответ — ответа нет вовсе: это «неизвестно», а не «можно»', () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
expect(vorotaZapasa({ budget: null }).ok).toBe(false);
|
||
expect(vorotaZapasa({ budget: undefined }).ok).toBe(false);
|
||
expect(errSpy.mock.calls.map(c => String(c[0])).join('\n')).toContain('запас владельца не посчитан вовсе');
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('запаса хватает — пускает', () => {
|
||
expect(vorotaZapasa({ budget: { may_start: true } }).ok).toBe(true);
|
||
});
|
||
|
||
it('🔴 сужение защиты (доля не пришла) говорится владельцу вслух, а не молчком', () => {
|
||
const log = vi.fn();
|
||
const g = vorotaZapasa({
|
||
budget: { may_start: true, dolya_ne_prishla: true, slova_vladeltsu: 'ВАША РАМКА СЕЙЧАС НЕ МЕРИТСЯ' },
|
||
log,
|
||
});
|
||
expect(g.ok).toBe(true);
|
||
expect(log).toHaveBeenCalledWith('ВАША РАМКА СЕЙЧАС НЕ МЕРИТСЯ');
|
||
});
|
||
|
||
it('🪤 доля пришла — лишних слов о сужении нет', () => {
|
||
const log = vi.fn();
|
||
vorotaZapasa({ budget: { may_start: true, dolya_ne_prishla: false, slova_vladeltsu: 'НЕ ДОЛЖНО ПРОЗВУЧАТЬ' } , log });
|
||
expect(log).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('О-5: ДОРОГА ЦЕЛИКОМ — `main(smoke-longrun)` на НАСТОЯЩЕМ правиле владельца', () => {
|
||
// 🔴 Правило здесь НЕ подставное: журнал замеров временный, а считают его настоящие
|
||
// `recordMeasure` и `budgetState` из budget.mjs. Подставлен только ПУТЬ журнала — иначе
|
||
// проверка писала бы в общий `.night/budget-measures.jsonl` и сдвигала бы чужой прирост
|
||
// (эта беда в затее уже случалась, см. `zapisatZamerIVzyatOstatok`).
|
||
//
|
||
// 🔴 И заведение пробы подставлено спаем — не ради удобства, а чтобы вырезание было
|
||
// безопасным: сломав ворота нарочно, проверка НЕ заведёт живого платного работника.
|
||
let dir;
|
||
let zhurnal;
|
||
|
||
beforeEach(() => {
|
||
dir = mkdtempSync(join(tmpdir(), 'o5-zapas-'));
|
||
zhurnal = join(dir, 'budget-measures.jsonl');
|
||
});
|
||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||
|
||
// 🔄 ПЕРЕСОБРАНО КРУГОМ Б. Прежде запись и вопрос о запасе шли одной подставной парой
|
||
// (`zapisatIZapas`). Теперь запись делает САМА дверь прибора, сразу как деньги ушли, —
|
||
// поэтому и здесь она стоит внутри подставного прибора, а вопрос о запасе отдельным
|
||
// доводом. Правило по-прежнему НЕ подставное: и писарь, и счёт запаса настоящие,
|
||
// подставлен только ПУТЬ журнала.
|
||
const priborChtoPishet = measure => async ({ phase }) => {
|
||
zapisatZamer(zhurnal, {
|
||
utilization: measure.utilization, status: measure.status, kind: measure.kind,
|
||
resets_at: measure.resets_at, at: Date.now(), phase,
|
||
});
|
||
return { ok: true, measure };
|
||
};
|
||
|
||
const proba = async (measure) => {
|
||
const probaSpy = vi.fn(async () => ({
|
||
worker_id: 'w-1', supervisor_pid: 1, heartbeat_path: '/тут/жив', notes_path: '/тут/след',
|
||
}));
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||
const code = await main(['node', 'cli.mjs', 'smoke-longrun'], {
|
||
readConfig: () => CFG,
|
||
proverkaVladeltsa: () => [],
|
||
priborZamera: priborChtoPishet(measure),
|
||
sostoyanieZapasa: (_put, cfg, at) => sostoyanieZapasa(zhurnal, cfg, at),
|
||
probaProgon: probaSpy,
|
||
});
|
||
const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
||
errSpy.mockRestore();
|
||
logSpy.mockRestore();
|
||
return { code, probaSpy, skazano };
|
||
};
|
||
|
||
it('🔴🔴 ТА САМАЯ БЕДА: израсходовано 0,76 при тревоге — проба НЕ заведена ни разу', async () => {
|
||
// Замер снят живьём 03.08.2026: доля 0,76, светофор `allowed_warning`. Правило владельца —
|
||
// пять седьмых (0,7142857…). До этой правки на этих же числах проба запускалась.
|
||
const { code, probaSpy, skazano } = await proba({
|
||
utilization: 0.76, status: 'allowed_warning', kind: 'seven_day', resets_at: null,
|
||
});
|
||
expect(probaSpy).not.toHaveBeenCalled();
|
||
expect(code).toBe(1);
|
||
expect(skazano).toContain('Проба НЕ запущена');
|
||
});
|
||
|
||
it('🪤 ровно на пороге (0,714285…) — тоже отказ: правило «МЕНЬШЕ пяти седьмых», не «не больше»', async () => {
|
||
const { code, probaSpy } = await proba({
|
||
utilization: 5 / 7, status: 'allowed', kind: 'seven_day', resets_at: null,
|
||
});
|
||
expect(probaSpy).not.toHaveBeenCalled();
|
||
expect(code).toBe(1);
|
||
});
|
||
|
||
it('🔴 система САМА отказала (`rejected`) при крошечном расходе — проба всё равно не заведена', async () => {
|
||
const { code, probaSpy } = await proba({
|
||
utilization: 0.03, status: 'rejected', kind: 'seven_day', resets_at: null,
|
||
});
|
||
expect(probaSpy).not.toHaveBeenCalled();
|
||
expect(code).toBe(1);
|
||
});
|
||
|
||
it('🪤 незнакомый светофор — не зелёный: проба не заводится по слову, которого затея не знает', async () => {
|
||
const { code, probaSpy } = await proba({
|
||
utilization: 0.03, status: 'какое-то-новое-слово', kind: 'seven_day', resets_at: null,
|
||
});
|
||
expect(probaSpy).not.toHaveBeenCalled();
|
||
expect(code).toBe(1);
|
||
});
|
||
|
||
it('🟢 запаса вправду хватает (0,10) — проба заводится РОВНО один раз', async () => {
|
||
const { code, probaSpy } = await proba({
|
||
utilization: 0.10, status: 'allowed', kind: 'seven_day', resets_at: null,
|
||
});
|
||
expect(probaSpy).toHaveBeenCalledTimes(1);
|
||
expect(code).toBe(0);
|
||
});
|
||
|
||
it('🔴 замер вправду лёг во ВРЕМЕННЫЙ журнал под своим именем — а не в общий', () => {
|
||
// Датчик того, что подставлен только путь: после отказного прогона строка обязана
|
||
// быть здесь, с фазой пробы. Если она уйдёт в общий журнал — тут будет пусто.
|
||
return proba({ utilization: 0.76, status: 'allowed_warning', kind: 'seven_day', resets_at: null })
|
||
.then(() => {
|
||
const stroki = readLines(zhurnal);
|
||
expect(stroki.length).toBe(1);
|
||
expect(stroki[0].phase).toBe('before-smoke');
|
||
expect(stroki[0].utilization).toBe(0.76);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// КУСОК 6, ЗАДАЧА 7 — ПОДЪЁМ ПРОДОЛЖЕНИЯ.
|
||
//
|
||
// 🔴 Зачем эти проверки вообще есть. Датчик зовущих (`schyot-zovushchih.mjs`) показал:
|
||
// у СЕМИ из семи правил куска 6 зовущих НОЛЬ. Построено, проверено, зелено — и не подключено
|
||
// ни к чему. Правило метода номер 2: защита, которую никто не зовёт, не бывает проверенной;
|
||
// затея заплатила за это шесть раз. Здесь строится тот, кто зовёт.
|
||
//
|
||
// 🪤 Карточки пишутся в НАСТОЯЩИЙ `.night/runs/`, потому что `runDir` считает от постоянной
|
||
// `NIGHT_ROOT` и подмены не принимает. Поэтому номер прогона у каждой проверки СВОЙ
|
||
// (случайный) и каталог сносится в `finally` — тот же приём, что у проверок `startAll`.
|
||
// В этом углу `.night/` живой, там лежит чужой прогон: своими номерами мы его не задеваем.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
describe('🔴 кусок 6, задача 7 — подъём продолжения (zavestiProdolzhenie)', () => {
|
||
// Восемь отрезков и три пометки «в расчёте на ответ» — ровно то, что П-25 велит подать.
|
||
const VOSEM_OTREZKOV = Array.from({ length: 8 }, (_, i) => ({ kind: 'working', ms: 3600_000, n: i }));
|
||
|
||
function staryj(over = {}) {
|
||
return {
|
||
worker_id: 'w-1',
|
||
run_id: `вчера-${randomUUID().slice(0, 8)}`,
|
||
item: { id: '3', title: 'третий', tests: ['npm run t'], files: ['app/A.php'] },
|
||
branch: 'night/вчера/w-1',
|
||
base_branch: 'feat/night-dispatcher-part1',
|
||
workspace: join(tmpdir(), 'угол-вчера-w-1'),
|
||
settings_path: join(tmpdir(), 'угол-вчера-w-1', '.night-worker', 'settings.json'),
|
||
db_name: 'night_w1',
|
||
model: 'старая-голова',
|
||
session_id: 'старая-сессия',
|
||
status: 'extinguished',
|
||
finish_reason: 'упёрся в восемь часов',
|
||
spans: VOSEM_OTREZKOV,
|
||
started_at: 1000,
|
||
resumed_at: null,
|
||
resumed_into: null,
|
||
...over,
|
||
};
|
||
}
|
||
|
||
// Кладём карточку старого работника на диск — её `zavestiProdolzhenie` обязан пометить.
|
||
function polozhitStarogo(w) {
|
||
const d = workerDir(w.run_id, w.worker_id);
|
||
mkdirSync(d, { recursive: true });
|
||
writeFileSync(join(d, 'worker.json'), JSON.stringify(w), 'utf8');
|
||
return d;
|
||
}
|
||
|
||
function ubrat(...runIds) {
|
||
for (const r of runIds) rmSync(runDir(r), { recursive: true, force: true });
|
||
}
|
||
|
||
it('🔴 П-13: у продолжения СВОЙ прогон, и обе карточки помнят, кого продолжают', () => {
|
||
const w = staryj();
|
||
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
|
||
polozhitStarogo(w);
|
||
try {
|
||
const r = zavestiProdolzhenie({
|
||
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова-из-настроек',
|
||
spawnSupervisorFn: () => ({ pid: 4242 }),
|
||
});
|
||
expect(r.ok).toBe(true);
|
||
expect(r.run_id).toBe(novyj);
|
||
expect(r.run_id).not.toBe(w.run_id); // свой номер, не вчерашний
|
||
expect(r.supervisor_pid).toBe(4242);
|
||
|
||
// Карточка НОВОГО прогона помнит, какой прогон и какого работника продолжаем.
|
||
const runCard = readJson(join(runDir(novyj), 'run.json'));
|
||
expect(runCard.resumed_from).toBe(w.run_id);
|
||
expect(runCard.resumed_worker).toBe('w-1');
|
||
expect(runCard.status).toBe('running');
|
||
|
||
// Карточка СТАРОГО работника получила обе отметки.
|
||
const staraya = readJson(join(workerDir(w.run_id, 'w-1'), 'worker.json'));
|
||
expect(Number.isFinite(staraya.resumed_at)).toBe(true);
|
||
expect(staraya.resumed_into).toBe(novyj);
|
||
} finally {
|
||
ubrat(novyj, w.run_id);
|
||
}
|
||
});
|
||
|
||
it('🔴 П-25: у продолжающего учётчик отрезков ПУСТ — восемь вчерашних не переносятся', () => {
|
||
const w = staryj();
|
||
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
|
||
polozhitStarogo(w);
|
||
try {
|
||
const r = zavestiProdolzhenie({
|
||
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова',
|
||
spawnSupervisorFn: () => ({ pid: 1 }),
|
||
});
|
||
const nov = readJson(join(workerDir(novyj, r.worker_id), 'worker.json'));
|
||
expect(nov.spans).toEqual([]);
|
||
expect(nov.spans).not.toHaveLength(8);
|
||
} finally {
|
||
ubrat(novyj, w.run_id);
|
||
}
|
||
});
|
||
|
||
it('🔴 П-26: съеденного времени ноль — восемь часов пошли ЗАНОВО', () => {
|
||
const w = staryj();
|
||
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
|
||
polozhitStarogo(w);
|
||
try {
|
||
const r = zavestiProdolzhenie({
|
||
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова',
|
||
spawnSupervisorFn: () => ({ pid: 1 }), now: () => 9_000_000,
|
||
});
|
||
const nov = readJson(join(workerDir(novyj, r.worker_id), 'worker.json'));
|
||
expect(billableMs(nov.spans ?? [])).toBe(0);
|
||
expect(nov.started_at).toBe(9_000_000); // отсчёт от подъёма, а не от вчерашней 1000
|
||
expect(nov.started_at).not.toBe(w.started_at);
|
||
} finally {
|
||
ubrat(novyj, w.run_id);
|
||
}
|
||
});
|
||
|
||
it('угол, ветка, точка ветвления, база и настройки — ВЗЯТЫ У СТАРОГО, сессия НОВАЯ', () => {
|
||
const w = staryj();
|
||
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
|
||
polozhitStarogo(w);
|
||
try {
|
||
const r = zavestiProdolzhenie({
|
||
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова',
|
||
spawnSupervisorFn: () => ({ pid: 1 }),
|
||
});
|
||
const nov = readJson(join(workerDir(novyj, r.worker_id), 'worker.json'));
|
||
expect(nov.workspace).toBe(w.workspace);
|
||
expect(nov.branch).toBe(w.branch);
|
||
expect(nov.base_branch).toBe(w.base_branch);
|
||
expect(nov.db_name).toBe(w.db_name);
|
||
expect(nov.settings_path).toBe(w.settings_path);
|
||
// 🔴 Сессия обязана быть НОВОЙ: та же вернула бы работника в оборванный разговор.
|
||
expect(nov.session_id).not.toBe(w.session_id);
|
||
expect(nov.session_id).toBeTruthy();
|
||
// Голова — из настроек владельца, а не вчерашняя из карточки.
|
||
expect(nov.model).toBe('голова');
|
||
expect(nov.model).not.toBe(w.model);
|
||
} finally {
|
||
ubrat(novyj, w.run_id);
|
||
}
|
||
});
|
||
|
||
it('🔴 П-45: порождатель надзирателя УПАЛ — карточка прогона не осталась идущей', () => {
|
||
// 🔴 Кусок про «пережить падение» обязан пережить и своё. Упади он между заведением
|
||
// карточки и подъёмом — прогон навсегда числился бы идущим, и закрыть его было бы некому.
|
||
const w = staryj();
|
||
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
|
||
polozhitStarogo(w);
|
||
try {
|
||
const r = zavestiProdolzhenie({
|
||
runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова',
|
||
spawnSupervisorFn: () => { throw new Error('надзиратель не поднялся'); },
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/надзиратель не поднялся/);
|
||
const runCard = readJson(join(runDir(novyj), 'run.json'));
|
||
expect(runCard.status).not.toBe('running');
|
||
expect(runCard.status).toBe('finished');
|
||
expect(runCard.finish_reason).toBeTruthy();
|
||
} finally {
|
||
ubrat(novyj, w.run_id);
|
||
}
|
||
});
|
||
|
||
it('🔴 головы не назвали — падаем ДО порождения надзирателя, а не после', () => {
|
||
const w = staryj();
|
||
const novyj = `продолж-${randomUUID().slice(0, 8)}`;
|
||
let zovov = 0;
|
||
try {
|
||
expect(() => zavestiProdolzhenie({
|
||
runId: novyj, worker: w, planPath: 'план.md', workerModel: null,
|
||
spawnSupervisorFn: () => { zovov += 1; return { pid: 1 }; },
|
||
})).toThrow(/worker_model/);
|
||
// 🔴 Ноль — не украшение: надзиратель родил бы живого работника, то есть ДЕНЬГИ.
|
||
expect(zovov).toBe(0);
|
||
} finally {
|
||
ubrat(novyj, w.run_id);
|
||
}
|
||
});
|
||
|
||
it('🔴 угол НЕ заводится заново: `createWorkspace` из этой дороги не зовётся ни разу', () => {
|
||
// 🔴 Решение 1: продолжение идёт в ТОМ ЖЕ углу. Заведись угол заново — работник
|
||
// потерял бы всё наработанное, а `composer install` съел бы десять минут ночи.
|
||
// 🪤 Сторож смотрит на КОД, а не на поведение: подать сюда подставной `createWorkspace`
|
||
// нечем — его в подписи нет вовсе, и это ровно то, что проверяется.
|
||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'run.mjs'), 'utf8');
|
||
const nachalo = src.indexOf('export function zavestiProdolzhenie');
|
||
expect(nachalo).toBeGreaterThan(-1);
|
||
const telo = src.slice(nachalo, src.indexOf('\n}', nachalo));
|
||
expect(telo).not.toMatch(/createWorkspace\w*\s*\(/);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// КУСОК 6, ЗАДАЧА 6 — ТИХИЕ ЧАСТИ ТОЧКИ ВХОДА. Снятие запора шагов 1 и 2.
|
||
//
|
||
// 🔴 Это те самые два шага, которые в прошлом круге заперли всю дорогу продолжения:
|
||
// у `razborDovodov` не было полей `--resume`/`--worker`, а `vyborProdolzhaemogo`
|
||
// не существовало вовсе. Тихое живёт здесь; в `cli.mjs` — только громкое.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
describe('🔴 кусок 6, задача 6 — разбор доводов продолжения', () => {
|
||
const bazovye = ['node', 'cli.mjs', 'run', 'план.md', '--priyomka', 'п.md', '--spec', 'з.md'];
|
||
|
||
it('ключи `--resume` и `--worker` доезжают до команды теми же, какими их набрали', () => {
|
||
const d = razborDovodov([...bazovye, '--resume', 'прогон-7', '--worker', 'w-3']);
|
||
expect(d.resumeRunId).toBe('прогон-7');
|
||
expect(d.resumeWorkerId).toBe('w-3');
|
||
});
|
||
|
||
it('🔴 не набрали — НЕ выдумываем: оба поля пусты, а не «первый попавшийся»', () => {
|
||
const d = razborDovodov([...bazovye]);
|
||
expect(d.resumeRunId).toBeNull();
|
||
expect(d.resumeWorkerId).toBeNull();
|
||
});
|
||
|
||
it('🔴 `--resume` без значения не съедает следующий ключ', () => {
|
||
// 🪤 Ровно та беда, от которой в этом файле уже стоит `estLiKlyuch`: ключ, спрошенный
|
||
// как ключ-со-значением, забирает следующее слово командной строки — то есть ЧУЖОЙ ключ.
|
||
const d = razborDovodov(['node', 'cli.mjs', 'run', 'план.md', '--priyomka', 'п.md', '--spec', 'з.md', '--resume']);
|
||
expect(d.resumeRunId).toBeNull();
|
||
const e = razborDovodov([...bazovye, '--resume', '--worker', 'w-3']);
|
||
expect(e.resumeRunId).not.toBe('--worker');
|
||
expect(e.resumeRunId).toBeNull();
|
||
});
|
||
|
||
it('прежний разбор не тронут: план, приёмка, замысел и `--files` на месте', () => {
|
||
const d = razborDovodov([...bazovye, '--resume', 'r-1', '--files', 'a.php,b.php']);
|
||
expect(d.planPath).toBe('план.md');
|
||
expect(d.priyomkaPath).toBe('п.md');
|
||
expect(d.specPath).toBe('з.md');
|
||
expect(d.files).toEqual(['a.php', 'b.php']);
|
||
});
|
||
});
|
||
|
||
describe('🔴 кусок 6, задача 6 — выбор продолжаемого работника', () => {
|
||
const rab = (over = {}) => ({
|
||
worker_id: 'w-1', run_id: 'вчера', status: 'extinguished', resumed_into: null, ...over,
|
||
});
|
||
|
||
it('🔴 П-36: продолжаемых нет вовсе — никого не берём и говорим почему', () => {
|
||
const r = vyborProdolzhaemogo({ workers: [rab({ status: 'working' }), rab({ worker_id: 'w-2', status: 'paused' })] });
|
||
expect(r.worker).toBeNull();
|
||
expect(r.reason).toBeTruthy();
|
||
expect(r.candidates).toEqual([]);
|
||
});
|
||
|
||
it('🔴 П-36: продолжаемых несколько, имени не назвали — НЕ берём первого попавшегося', () => {
|
||
const r = vyborProdolzhaemogo({
|
||
workers: [rab({ worker_id: 'w-1' }), rab({ worker_id: 'w-2', status: 'stalled' })],
|
||
});
|
||
expect(r.worker).toBeNull();
|
||
expect(r.candidates).toEqual(['w-1', 'w-2']);
|
||
expect(r.reason).toMatch(/w-1/);
|
||
expect(r.reason).toMatch(/w-2/);
|
||
});
|
||
|
||
it('продолжаемый ровно один и имени не назвали — берём его', () => {
|
||
const r = vyborProdolzhaemogo({ workers: [rab({ worker_id: 'w-9' }), rab({ worker_id: 'w-2', status: 'working' })] });
|
||
expect(r.worker?.worker_id).toBe('w-9');
|
||
expect(r.reason).toBeNull();
|
||
});
|
||
|
||
it('назвали имя — берём именно его, даже когда продолжаемых несколько', () => {
|
||
const r = vyborProdolzhaemogo({
|
||
workers: [rab({ worker_id: 'w-1' }), rab({ worker_id: 'w-2', status: 'stalled' })],
|
||
workerId: 'w-2',
|
||
});
|
||
expect(r.worker?.worker_id).toBe('w-2');
|
||
});
|
||
|
||
it('🔴 П-39: назвали ЖИВОГО — отказ, и про паузу сказано, чем она снимается', () => {
|
||
for (const zhivoy of ['working', 'paused', 'waiting_service', 'waiting_answer_idle', 'stopped']) {
|
||
const r = vyborProdolzhaemogo({ workers: [rab({ status: zhivoy })], workerId: 'w-1' });
|
||
expect(r.worker).toBeNull();
|
||
expect(r.reason).toMatch(/жив/i);
|
||
}
|
||
// Про паузу — своё слово: владелец обязан узнать, ЧЕМ она снимается.
|
||
const p = vyborProdolzhaemogo({ workers: [rab({ status: 'paused' })], workerId: 'w-1' });
|
||
expect(p.reason).toMatch(/resume/);
|
||
});
|
||
|
||
it('🔴 П-42: назвали УЖЕ ПРОДОЛЖЕННОГО — отказ, и назван прогон, куда его продолжили', () => {
|
||
const r = vyborProdolzhaemogo({
|
||
workers: [rab({ resumed_into: 'прогон-завтра' })], workerId: 'w-1',
|
||
});
|
||
expect(r.worker).toBeNull();
|
||
expect(r.reason).toMatch(/прогон-завтра/);
|
||
});
|
||
|
||
it('🔴 уже продолженный не считается продолжаемым и БЕЗ имени — иначе поднимут второго', () => {
|
||
// 🪤 Второй живой работник в том же углу и той же базе поверх первого — худшее,
|
||
// что эта кнопка может сделать.
|
||
const r = vyborProdolzhaemogo({ workers: [rab({ resumed_into: 'прогон-завтра' })] });
|
||
expect(r.worker).toBeNull();
|
||
expect(r.candidates).toEqual([]);
|
||
});
|
||
|
||
it('назвали того, кого в прогоне нет вовсе — своя причина, а не «он жив»', () => {
|
||
const r = vyborProdolzhaemogo({ workers: [rab()], workerId: 'w-404' });
|
||
expect(r.worker).toBeNull();
|
||
expect(r.reason).toMatch(/w-404/);
|
||
expect(r.reason).not.toMatch(/жив/i);
|
||
});
|
||
|
||
it('🔴🔴 ТРЕТЬЕ: у карточки НЕТ слова состояния — это не «жив» и не «продолжаем»', () => {
|
||
// 🔴 ВЫРЕЗАНИЕ ВТОРОГО РОДА. Допущение правила: «состояние — это одно из известных слов».
|
||
// Годный по форме случай, в него не попавший: карточка, у которой слова нет вовсе
|
||
// (запись оборвалась, файл неполон). Плоское «не продолжаемый ⇒ жив» сказало бы владельцу
|
||
// «он жив, пауза снимается командой resume» про работника, которого, может быть, нет.
|
||
// Замерено: живых слов у работника ВОСЕМЬ (working · paused · stalled · extinguished ·
|
||
// waiting_service · waiting_answer_idle · waiting_answer_busy · stopped), а состояний
|
||
// у этого входа девять — девятое и есть «слова нет».
|
||
for (const net of [undefined, null, '', 7]) {
|
||
const r = vyborProdolzhaemogo({ workers: [rab({ status: net })], workerId: 'w-1' });
|
||
expect(r.worker).toBeNull();
|
||
expect(r.reason).toMatch(/состояни/i);
|
||
expect(r.reason).not.toMatch(/жив/i);
|
||
}
|
||
});
|
||
|
||
it('карточек не дали вовсе — отказ, а не обвал', () => {
|
||
for (const pusto of [undefined, null, []]) {
|
||
const r = vyborProdolzhaemogo({ workers: pusto });
|
||
expect(r.worker).toBeNull();
|
||
expect(r.reason).toBeTruthy();
|
||
}
|
||
expect(() => vyborProdolzhaemogo()).not.toThrow();
|
||
});
|
||
|
||
it('🛡️ СТОРОЖ: список продолжаемых ВВЕЗЁН из resume.mjs, а не набран здесь второй раз', () => {
|
||
// 🔴 Два списка слов на один вопрос разъехались бы молча: `resumePlan` судил бы по одному,
|
||
// кнопка — по другому, и обе стороны остались бы зелёными.
|
||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli-parts.mjs'), 'utf8');
|
||
expect(src).toMatch(/PRODOLZHAEMYE/);
|
||
expect(src).toMatch(/from '\.\/resume\.mjs'/);
|
||
// Своей копии слов в файле нет.
|
||
expect(src).not.toMatch(/\[\s*'stalled'\s*,\s*'extinguished'\s*\]/);
|
||
// И правило вправду судит по ввезённому списку, а не по своему представлению:
|
||
// оба слова из него — продолжаемые.
|
||
for (const slovo of PRODOLZHAEMYE) {
|
||
expect(vyborProdolzhaemogo({ workers: [rab({ status: slovo })] }).worker?.worker_id).toBe('w-1');
|
||
}
|
||
});
|
||
|
||
it('🛡️ СТОРОЖ: сборщик карточек даёт ТРЕТИЙ ответ, а не пустоту', () => {
|
||
// 🔴 Долг М-181, который я же и назвал. Плоский `readJson` + `.filter(Boolean)` выбросил
|
||
// бы порванную карточку МОЛЧА — и продолжаемым оказался бы другой работник, а отказы
|
||
// «его уже продолжили» и «продолжаемых несколько» промолчали бы, оставаясь зелёными.
|
||
const d = mkdtempSync(join(tmpdir(), 'night-kart-'));
|
||
try {
|
||
const wdir = join(d, 'workers');
|
||
mkdirSync(join(wdir, 'w-1'), { recursive: true });
|
||
mkdirSync(join(wdir, 'w-2'), { recursive: true });
|
||
writeFileSync(join(wdir, 'w-1', 'worker.json'), JSON.stringify({ worker_id: 'w-1', status: 'stalled' }), 'utf8');
|
||
// 🪤 Порванная карточка: файл ЕСТЬ, разобрать нельзя. Это не «работника нет».
|
||
writeFileSync(join(wdir, 'w-2', 'worker.json'), '{ порвано', 'utf8');
|
||
const r = kartochkiRabotnikov('всё-равно', d);
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/w-2/);
|
||
expect(r.reason).toMatch(/прочесть не удалось|разобрать/i);
|
||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||
});
|
||
|
||
it('сборщик: карточек нет вовсе — отказ с номером прогона, а не обвал', () => {
|
||
const d = mkdtempSync(join(tmpdir(), 'night-kart2-'));
|
||
try {
|
||
const r = kartochkiRabotnikov('прогон-777', d);
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/прогон-777/);
|
||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||
});
|
||
|
||
it('🛡️ СТОРОЖ решения 11: в `vhodVorot` кусок 6 не встаёт ни одним доводом', () => {
|
||
// 🔴 Развилка «если продолжение — подавай пустышки» была снята диспетчером 01.08:
|
||
// это вторая дорога к правилу, открывающаяся молча при первой ошибке в признаке.
|
||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli-parts.mjs'), 'utf8');
|
||
const i = src.indexOf('export function vhodVorot');
|
||
const telo = src.slice(i, src.indexOf('\n}', i));
|
||
expect(telo).not.toMatch(/resume/i);
|
||
expect(telo).not.toMatch(/prodolzh/i);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 ЖИВАЯ ДОРОГА ПРОДОЛЖЕНИЯ — ТОТ САМЫЙ ЗОВУЩИЙ.
|
||
// До неё у ДЕСЯТИ правил куска 6 зовущих было ноль.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
describe('🔴 кнопка «продолжить» — живая дорога из двенадцати шагов', () => {
|
||
let ugol; let vcheraId; let kommitZachtennogo; let rechi; let novyjId;
|
||
// 🔴 Угол — НАСТОЯЩЕЕ хранилище, и прогон — настоящий каталог: дорога зовёт настоящие
|
||
// читалки (`closedItemsOfRun`, `kommitZachtennogoPunkta`, `rabotaPosleZachtennogo`),
|
||
// и подставить их нечем. Подставка доказала бы только моё представление о них — ровно
|
||
// тот класс, на котором в этой затее уже обжигались.
|
||
beforeEach(() => {
|
||
rechi = [];
|
||
novyjId = null;
|
||
ugol = mkdtempSync(join(tmpdir(), 'night-ugol-'));
|
||
execFileSync(GIT, ['init', '-q', '-b', 'baza'], { cwd: ugol });
|
||
execFileSync(GIT, ['config', 'user.email', 'r@liderra.local'], { cwd: ugol });
|
||
execFileSync(GIT, ['config', 'user.name', 'работник'], { cwd: ugol });
|
||
writeFileSync(join(ugol, 'A.php'), 'засчитанное\n', 'utf8');
|
||
execFileSync(GIT, ['add', '.'], { cwd: ugol });
|
||
execFileSync(GIT, ['commit', '-qm', 'пункт 1 засчитан'], { cwd: ugol });
|
||
kommitZachtennogo = String(execFileSync(GIT, ['rev-parse', 'HEAD'], { cwd: ugol, encoding: 'utf8' })).trim();
|
||
// Поверх засчитанного — работа, которую продолжение обязано снять откатом.
|
||
writeFileSync(join(ugol, 'B.php'), 'работа после засчитанного\n', 'utf8');
|
||
execFileSync(GIT, ['add', '.'], { cwd: ugol });
|
||
execFileSync(GIT, ['commit', '-qm', 'после засчитанного'], { cwd: ugol });
|
||
|
||
// Настоящий каталог поднимаемого прогона с приговором по пункту 1.
|
||
vcheraId = `вчера-${randomUUID().slice(0, 8)}`;
|
||
const wdir = workerDir(vcheraId, 'w-1');
|
||
mkdirSync(wdir, { recursive: true });
|
||
writeFileSync(join(runDir(vcheraId), 'run.json'), JSON.stringify({ run_id: vcheraId, items: [{ id: '1' }, { id: '2' }] }), 'utf8');
|
||
writeFileSync(join(wdir, 'items-verified.jsonl'),
|
||
`${JSON.stringify({ item: '1', at: 1000, commit_sha: kommitZachtennogo, tests_green: true, line: 'пункт 1 закрыт' })}\n`, 'utf8');
|
||
});
|
||
afterEach(() => {
|
||
rmSync(ugol, { recursive: true, force: true });
|
||
rmSync(runDir(vcheraId), { recursive: true, force: true });
|
||
// Новый прогон дорога заводит сама — за собой убираем тоже, чтобы не копить в хозяйстве.
|
||
if (novyjId) rmSync(runDir(novyjId), { recursive: true, force: true });
|
||
});
|
||
|
||
const CFG = { worker_model: 'голова-из-настроек', disk_free_min_gb: 5 };
|
||
const HOROSHO = { ok: true, reason: null };
|
||
const rabotnik = (over = {}) => ({
|
||
worker_id: 'w-1', run_id: vcheraId, status: 'extinguished', resumed_into: null,
|
||
workspace: ugol, branch: 'night/вчера/w-1', base_branch: 'baza',
|
||
db_name: 'night_w1', settings_path: 'нет.json', session_id: 'старая', spans: [], ...over,
|
||
});
|
||
|
||
// Всё вокруг дороги — подставное; предмет проверки сама дорога.
|
||
function dovody(over = {}) {
|
||
return {
|
||
cfg: CFG,
|
||
planPath: 'план.md',
|
||
resumeRunId: vcheraId,
|
||
readCards: () => ({ ok: true, cards: [rabotnik()], reason: null }),
|
||
pechat: () => HOROSHO,
|
||
measureDisk: async () => HOROSHO,
|
||
zamer: async () => ({ ok: true, measure: { utilization: 0.1, status: 'allowed', kind: 'five_hour', resets_at: Date.now() + 3600_000 }, problem: null }),
|
||
// 🪤 Настоящий остаток писал бы в ОБЩИЙ журнал замеров `.night/` — своя строка там
|
||
// сдвинула бы чужой прирост и погасила бы соседний прогон ни за что.
|
||
// 🪤 Заодно ловим номер НОВОГО прогона: он случайный, а замки живут в его каталоге.
|
||
// Шаг замера — самое раннее место, где номер уже известен.
|
||
ostatok: ({ runId }) => { novyjId = runId; return { may_start: true, reason: null, dolya_ne_prishla: false, slova_vladeltsu: null }; },
|
||
podnyat: () => ({ ok: true, reason: null, run_id: 'новый', worker_id: 'w-1', supervisor_pid: 42 }),
|
||
// 🔴 Откатчик — НАСТОЯЩИЙ. Подставной, отвечающий «получилось» без коммита, дорога
|
||
// законно отвергает («на слово не верим») — и это поймано живьём при первом прогоне:
|
||
// моя удобная подставка провалила happy-path, потому что защита работает.
|
||
// Угол здесь настоящее хранилище, так что настоящему откатчику есть с чем работать.
|
||
gryaz: () => false,
|
||
// Всё сказанное владельцу копится: по нему и судим, что он вправду прочтёт.
|
||
out: s => rechi.push(String(s)), err: s => rechi.push(String(s)),
|
||
...over,
|
||
};
|
||
}
|
||
|
||
it('🔴 П-24: `planRun` при продолжении не зовётся НИ РАЗУ', async () => {
|
||
// 🔴 Сторож решения 8. Приведи кнопку обратно на общую дорогу — краснеет.
|
||
// 🪤 Считаем зовы настоящего модуля: заглушка внутри чужой функции доказала бы себя.
|
||
vi.resetModules();
|
||
let zovov = 0;
|
||
vi.doMock('./run.mjs', async () => {
|
||
const nast = await vi.importActual('./run.mjs');
|
||
return { ...nast, planRun: (...a) => { zovov += 1; return nast.planRun(...a); } };
|
||
});
|
||
const { komandaProdolzheniya: kp } = await import('./cli.mjs');
|
||
await kp(dovody());
|
||
expect(zovov).toBe(0);
|
||
vi.doUnmock('./run.mjs');
|
||
vi.resetModules();
|
||
});
|
||
|
||
it('🔴 П-41: отпечаток плана негоден — ПЛАТНЫЙ ЗАМЕР НЕ ЗВАЛСЯ НИ РАЗУ', async () => {
|
||
// 🔴 Замер — платная программа, деньги владельца. Жечь его на попытку, которая заведомо
|
||
// провалится по дешёвым воротам, нельзя. Переставь замер вперёд — краснеет.
|
||
let zamerov = 0;
|
||
const d = mkdtempSync(join(tmpdir(), 'night-pechat-'));
|
||
try {
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
// Отпечаток негоден — дешёвые ворота не сошлись.
|
||
pechat: () => ({ ok: false, reason: 'отпечаток плана не сошёлся' }),
|
||
zamer: async () => { zamerov += 1; return { ok: true, measure: null, problem: null }; },
|
||
}));
|
||
expect(kod).toBe(1);
|
||
expect(zamerov).toBe(0);
|
||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||
});
|
||
|
||
it('🔴 приговор о чужой голове доходит до владельца и на дороге «продолжить»', async () => {
|
||
// 🔴 Третья платная дорога затеи. Её замер шёл мимо `vorotaPribora` своим куском кода —
|
||
// и молчал бы даже после починки двух других дорог. Слова идут через `skazat`,
|
||
// то есть и в окно, и в сводку поднимаемого прогона.
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
zamer: async () => ({
|
||
ok: true,
|
||
measure: { utilization: 0.1, status: 'allowed', kind: 'five_hour', resets_at: Date.now() + 3600_000 },
|
||
problem: null,
|
||
golova: { sovpalo: false, zakazana: 'haiku', otvetila: ['opus'], soobshchenie: 'ЧУЖАЯ ГОЛОВА В СЧЁТЕ ПРОДОЛЖЕНИЯ' },
|
||
masterskaya: { otsecheno: true, sostav: { tools: 0, mcp_servers: 0 }, soobshchenie: null },
|
||
}),
|
||
}));
|
||
expect(rechi.join('\n')).toContain('ЧУЖАЯ ГОЛОВА В СЧЁТЕ ПРОДОЛЖЕНИЯ');
|
||
// 🔴 Решение владельца 15: чужая голова говорится вслух и НЕ останавливает работу.
|
||
expect(kod).toBe(0);
|
||
});
|
||
|
||
it('🔴 П-17: живой ЧУЖОЙ работник и молчание владельца кнопку НЕ держат', async () => {
|
||
// 🔴 Проверка 9 дословно: «больше кнопка не проверяет ничего… иначе она не сработала бы
|
||
// никогда». Подать это в `resumeChecks` нечем — у неё таких кормушек нет и не будет.
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
readCards: () => ({
|
||
ok: true,
|
||
cards: [rabotnik(), rabotnik({ worker_id: 'w-9', status: 'working', workspace: ugol })],
|
||
reason: null,
|
||
}),
|
||
resumeWorkerId: 'w-1',
|
||
}));
|
||
expect(rechi.join(' | ')).toMatch(/продолжен прогоном/); // видно, ЧТО именно сказали
|
||
expect(kod).toBe(0); // дошли до подъёма
|
||
});
|
||
|
||
it('🔴 П-48: угла работника нет вовсе — понятный отказ, а не обвал глубже', async () => {
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
readCards: () => ({ ok: true, cards: [rabotnik({ workspace: join(tmpdir(), 'такого-угла-нет-совсем') })], reason: null }),
|
||
}));
|
||
expect(kod).toBe(1);
|
||
});
|
||
|
||
it('🔴 П-47: КАЖДАЯ причина отказа печатается владельцу В ОКНО', async () => {
|
||
// 🔴 Решение владельца 125: окно — единственная дорога до него, письма нет.
|
||
// Убери печать — краснеет; появись зов отправлялки — краснеет тоже.
|
||
const skazano = [];
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
readCards: () => ({ ok: true, cards: [], reason: 'продолжать некого' }),
|
||
err: s => skazano.push(String(s)),
|
||
out: s => skazano.push(String(s)),
|
||
}));
|
||
expect(kod).toBe(1);
|
||
// 🔴 Предмет — сам факт печати владельцу, а не точные слова: причину сочиняет правило
|
||
// `vyborProdolzhaemogo`, и приколачивать её текст здесь значило бы сторожить не то.
|
||
expect(skazano.length).toBeGreaterThan(0);
|
||
expect(skazano.join(' ')).toMatch(/Продолжение не начато/);
|
||
expect(skazano.join(' ')).toMatch(/карточки работника|продолжать/i);
|
||
});
|
||
|
||
it('🔴 П-29: встал, не закрыв НИ ОДНОГО пункта — не поднят, наработки показаны', async () => {
|
||
// 🔴 Настоящее условие проверки 117: продолжать не с чего. Убираем приговор по пункту —
|
||
// засчитанных не остаётся вовсе, и `resumePlan` отказывает САМ, в правиле.
|
||
rmSync(join(workerDir(vcheraId, 'w-1'), 'items-verified.jsonl'), { force: true });
|
||
let podnyatoRaz = 0;
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
podnyat: () => { podnyatoRaz += 1; return { ok: true, run_id: 'н', worker_id: 'w-1', supervisor_pid: 1 }; },
|
||
}));
|
||
expect(kod).toBe(1);
|
||
// 🔴 Работник НЕ поднят — владелец решает сам, что делать с наработками.
|
||
expect(podnyatoRaz).toBe(0);
|
||
expect(rechi.join(' ')).toMatch(/Отложено/);
|
||
});
|
||
|
||
it('🔴 грязный угол — отказа хватает, до отката и подъёма не доходим', async () => {
|
||
let otkatov = 0; let podnyatoRaz = 0;
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
gryaz: () => true,
|
||
otkatchik: () => { otkatov += 1; return { ok: true }; },
|
||
podnyat: () => { podnyatoRaz += 1; return { ok: true, run_id: 'н', worker_id: 'w-1', supervisor_pid: 1 }; },
|
||
}));
|
||
expect(kod).toBe(1);
|
||
expect(otkatov).toBe(0);
|
||
expect(podnyatoRaz).toBe(0);
|
||
});
|
||
|
||
it('🔴 «спросить про грязь не удалось» — тоже отказ: вслепую не откатываем', async () => {
|
||
// 🔴 ТРЕТЬЕ состояние прибора грязи. Слить его с «чисто» значило бы откатывать вслепую.
|
||
const kod = await komandaProdolzheniya(dovody({ gryaz: () => null }));
|
||
expect(kod).toBe(1);
|
||
});
|
||
|
||
it('🔴🔴 ШОВ: НАСТОЯЩИЙ ответ budgetState проходит ворота продолжения', async () => {
|
||
// 🔴 ЗАМЕРЕНО ЖИВЬЁМ: `budgetState` отвечает полем `may_start`, а `otvetVorot` внутри
|
||
// `resumeChecks` смотрит только на `ok` — и на заведомо хорошем запасе (израсходовано 5%)
|
||
// ворота отвечали «не мерили — остаток неизвестен». Кнопка не сработала бы НИ РАЗУ,
|
||
// а оба куска зелены, потому что ни один не видит другого.
|
||
// 🪤 Проверка нарочно подаёт НАСТОЯЩИЙ ответ настоящего `budgetState`, а не удобную
|
||
// выдумку: удобная выдумка и была причиной, по которой шов не находился.
|
||
const d = mkdtempSync(join(tmpdir(), 'night-shov-'));
|
||
try {
|
||
const p = join(d, 'm.jsonl');
|
||
zapisatZamer(p, { utilization: 0.05, status: 'allowed', kind: 'five_hour', resets_at: Date.now() + 3600_000, at: Date.now() });
|
||
const nastoyashchiy = sostoyanieZapasa(p, { rate_limit_type: 'five_hour' }, Date.now());
|
||
expect(nastoyashchiy.may_start).toBe(true);
|
||
const kod = await komandaProdolzheniya(dovody({ ostatok: () => nastoyashchiy }));
|
||
expect(kod).toBe(0); // дошли до подъёма, а не упёрлись в «не мерили»
|
||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||
});
|
||
|
||
// ═════════ ЗАМКИ НА ФАЙЛЫ: самое опасное место затеи ═════════
|
||
// 🔴 Продолжение не просто пишет — оно ОТКАТЫВАЕТ (`read-tree --reset -u` переписывает
|
||
// дерево целиком). Без замка откат сносит правку соседа в общей ветке и общей базе,
|
||
// МОЛЧА. Владелец обнаружит это не утром, а когда чего-то недосчитается.
|
||
|
||
it('🔴🔴 файл держит ДРУГОЙ идущий прогон — не откатываем и не поднимаем', async () => {
|
||
let otkatov = 0; let podnyatoRaz = 0;
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
// Соседний прогон занял тот же файл, что задел наш работник после засчитанного пункта.
|
||
idushchie: () => ({ ok: true, reason: null, running: [
|
||
{ run_id: 'сосед', status: 'running', items: [{ id: '9', files: ['B.php'] }] },
|
||
] }),
|
||
otkatchik: () => { otkatov += 1; return { ok: true }; },
|
||
podnyat: () => { podnyatoRaz += 1; return { ok: true, run_id: 'н', worker_id: 'w-1', supervisor_pid: 1 }; },
|
||
}));
|
||
expect(kod).toBe(1);
|
||
// 🔴 Главное: до отката НЕ дошли. Дойди — чужая правка была бы снесена.
|
||
expect(otkatov).toBe(0);
|
||
expect(podnyatoRaz).toBe(0);
|
||
// И владельцу сказано, КАКОЙ файл и что откат снёс бы чужое.
|
||
expect(rechi.join(' ')).toMatch(/B\.php/);
|
||
expect(rechi.join(' ')).toMatch(/чуж/i);
|
||
});
|
||
|
||
it('🔴 счёт идущих прогонов не сошёлся — тоже не откатываем (порванная карточка)', async () => {
|
||
// 🪤 «Сколько идёт сейчас — неизвестно» нельзя считать за «никого нет»: именно так
|
||
// замок открылся бы всем ровно в ту ночь, когда запись порвалась.
|
||
let otkatov = 0;
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
idushchie: () => ({ ok: false, running: [], reason: 'карточку прогона прочесть не удалось' }),
|
||
otkatchik: () => { otkatov += 1; return { ok: true }; },
|
||
}));
|
||
expect(kod).toBe(1);
|
||
expect(otkatov).toBe(0);
|
||
});
|
||
|
||
it('🔴 поднимаемый прогон САМ СЕБЕ не соперник — иначе продолжение невозможно никогда', async () => {
|
||
// 🪤 Вырезание второго рода: «файл занят идущим прогоном» — а поднимаемый прогон
|
||
// тоже есть в списке, если его карточку ещё не закрыли. Считай мы его соперником,
|
||
// кнопка отказывала бы ВСЕГДА и по причине, которой нет.
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
idushchie: () => ({ ok: true, reason: null, running: [
|
||
{ run_id: vcheraId, status: 'running', items: [{ id: '1', files: ['A.php', 'B.php'] }] },
|
||
] }),
|
||
}));
|
||
expect(kod).toBe(0);
|
||
});
|
||
|
||
it('🔴 файлы ВПРАВДУ заняты за продолжающим — и заняты ДО отката', async () => {
|
||
let zanyatyVMomentOtkata = null;
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
otkatchik: (a) => {
|
||
// Смотрим замок ИМЕННО в минуту отката: занять после было бы поздно.
|
||
// 🔴🔴 СПРАШИВАЕМ ЗА ЧУЖОГО, А НЕ ЗА СВОЕГО — и это не придирка, это вырезание
|
||
// второго рода, поймавшее мою же слабую проверку. `mayTouch` за СВОЕГО отвечает
|
||
// «можно» и когда файл занят им, и когда не занят НИКЕМ: пустой список занятий
|
||
// читается как «всё свободно». То есть прежняя форма этой проверки оставалась
|
||
// зелёной даже с полностью вырезанным занятием. Отказ ЧУЖОМУ — единственное,
|
||
// что доказывает, что замок вправду взят.
|
||
zanyatyVMomentOtkata = mayTouch(runDir(novyjId), 'кто-то-другой', 'B.php');
|
||
return otkatDoKommita(a);
|
||
},
|
||
}));
|
||
expect(kod).toBe(0);
|
||
expect(zanyatyVMomentOtkata?.ok).toBe(false);
|
||
});
|
||
|
||
it('🔴 отказ ПОСЛЕ занятия освобождает замок — мертвец файлы не держит', async () => {
|
||
// 🔴 Замок, переживший неудачную попытку, держал бы файлы за работником, которого нет,
|
||
// и следующая попытка упёрлась бы в него же.
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
podnyat: () => ({ ok: false, reason: 'надзиратель не поднялся' }),
|
||
}));
|
||
expect(kod).toBe(1);
|
||
// Файл снова свободен для КОГО УГОДНО.
|
||
expect(mayTouch(runDir(novyjId), 'кто-то-другой', 'B.php').ok).toBe(true);
|
||
});
|
||
|
||
it('🔴 голову не назвали — подъём отказывает, и это видно владельцу', async () => {
|
||
const skazano = [];
|
||
const kod = await komandaProdolzheniya(dovody({
|
||
cfg: { disk_free_min_gb: 5 },
|
||
podnyat: () => { throw new Error('в night.config.json нет worker_model'); },
|
||
err: s => skazano.push(String(s)), out: s => skazano.push(String(s)),
|
||
}));
|
||
expect(kod).toBe(1);
|
||
expect(skazano.join(' ')).toMatch(/worker_model/);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 КРУГ Б (04.08.2026): ВТОРОЙ ЗАПУСК ПЛАТИЛ ДЕНЬГАМИ ВЛАДЕЛЬЦА
|
||
//
|
||
// ━━━ ЗАМЕРЕНО В ЖИВОМ ЖУРНАЛЕ, А НЕ ПРЕДПОЛОЖЕНО ━━━
|
||
// `.night/budget-measures.jsonl`, время московское:
|
||
// before-run 21:54:07 · before-run 21:55:09 · start 21:55:10
|
||
// before-run 22:53:59 · before-run 22:55:28 · start 22:55:29 · before-run 22:55:37
|
||
// before-run 00:29:34 · start 00:29:35 · before-run 00:29:40
|
||
// Дважды замер «перед прогоном» лёг ПОСЛЕ старта — на 8 и на 5 секунд позже. Одному
|
||
// процессу это недоступно: он этот замер уже сделал и пошёл дальше. Значит запусков было
|
||
// несколько, и опоздавший ДОХОДИЛ ДО ПЛАТНОГО ЗАМЕРА, а до старта не доходил.
|
||
//
|
||
// ━━━ ПОЧЕМУ ЭТО НЕ ЛЕЧИТСЯ ПРЕДУПРЕЖДЕНИЕМ ━━━
|
||
// Порядок важнее самого замка. В `run` платный замер стоит ДЕСЯТЫМ шагом, а все ворота,
|
||
// которые могли бы узнать про идущий прогон (потолок задач, пересечение по файлам), —
|
||
// ПОСЛЕ него. Защита, стоящая после платного шага, не защита, а отчёт о потере.
|
||
// Поэтому обе двери ниже зовутся ДО `priborZamera`.
|
||
describe('🔴 второй запуск не платит: двери стоят ДО платного замера', () => {
|
||
let zamokDir;
|
||
beforeEach(() => { zamokDir = mkdtempSync(join(tmpdir(), 'night-zamok-')); });
|
||
afterEach(() => { rmSync(zamokDir, { recursive: true, force: true }); });
|
||
const zamok = () => join(zamokDir, 'zapusk.json');
|
||
|
||
// ── ДВЕРЬ ПЕРВАЯ: по этому же плану уже кто-то идёт ────────────────────────────
|
||
describe('живой прогон по тому же плану', () => {
|
||
it('идёт прогон по ЭТОМУ плану — второй запуск отбит', () => {
|
||
const nayden = progonPoTomuZhePlanu({
|
||
running: [{ run_id: 'r-1', plan_path: 'docs/plans/proba5-odin.md' }],
|
||
planPath: 'docs/plans/proba5-odin.md',
|
||
});
|
||
expect(nayden?.run_id).toBe('r-1');
|
||
});
|
||
|
||
it('идёт прогон по ДРУГОМУ плану — вторая задача законна и НЕ отбивается', () => {
|
||
// 🔴 Обратная сторона обязательна: владелец разрешил две задачи разом
|
||
// (`max_tasks_at_once: 2`). Замок, отбивающий любой второй запуск, отнял бы у него
|
||
// половину ночи — это была бы починка ценой работы.
|
||
expect(progonPoTomuZhePlanu({
|
||
running: [{ run_id: 'r-1', plan_path: 'docs/plans/drugoy.md' }],
|
||
planPath: 'docs/plans/proba5-odin.md',
|
||
})).toBeNull();
|
||
});
|
||
|
||
it('тот же план, записанный ИНАЧЕ (косые черты, регистр), — всё равно тот же', () => {
|
||
// 🪤 Иначе замок обходится опечаткой: `docs\\plans\\p.md` и `docs/plans/p.md` —
|
||
// один файл, а строки разные. Сравнение идёт тем же прибором путей, что и везде.
|
||
expect(progonPoTomuZhePlanu({
|
||
running: [{ run_id: 'r-1', plan_path: 'docs\\plans\\Proba5-Odin.md' }],
|
||
planPath: 'docs/plans/proba5-odin.md',
|
||
})?.run_id).toBe('r-1');
|
||
});
|
||
|
||
it('идущих нет вовсе — дверь молчит', () => {
|
||
expect(progonPoTomuZhePlanu({ running: [], planPath: 'docs/plans/p.md' })).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── ДВЕРЬ ВТОРАЯ: замок на сам запуск ─────────────────────────────────────────
|
||
// Первая дверь бессильна в самый опасный миг: пока НИ ОДИН из двух запусков ещё не
|
||
// написал карточку прогона, оба видят пустое хозяйство и оба идут платить.
|
||
describe('атомарный замок — на случай «а что, если этих же самых будет ДВА одновременно»', () => {
|
||
it('🔴 ГОЛОВНАЯ: двое разом — ровно один берёт замок, второй отбит', () => {
|
||
const zhiv = () => true;
|
||
const a = vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv });
|
||
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1000, zhiv });
|
||
expect(a.vzyal).toBe(true);
|
||
expect(b.vzyal).toBe(false);
|
||
expect(b.reason).toContain('1001'); // владельцу назван тот, кто держит
|
||
});
|
||
|
||
it('замок отдан — следующий запуск проходит', () => {
|
||
const zhiv = () => true;
|
||
expect(vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv }).vzyal).toBe(true);
|
||
otdatZamokZapuska({ path: zamok(), pid: 1001 });
|
||
expect(vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 2000, zhiv }).vzyal).toBe(true);
|
||
});
|
||
|
||
it('🪤 чужой замок не отдаётся: отдающий не смеет открыть дверь тому, кто её держит', () => {
|
||
const zhiv = () => true;
|
||
vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv });
|
||
otdatZamokZapuska({ path: zamok(), pid: 1002 }); // не мой — не трогаю
|
||
expect(vzyatZamokZapuska({ path: zamok(), pid: 1003, now: 1000, zhiv }).vzyal).toBe(false);
|
||
});
|
||
|
||
// ━━━ 🔴 «ЗАМОК, ПЕРЕЖИВШИЙ ПАДЕНИЕ ХОЗЯИНА, ХУЖЕ ОТСУТСТВИЯ ЗАМКА» ━━━
|
||
// Ночь просто не начнётся, и владелец узнает об этом утром. Все четыре дороги,
|
||
// которыми замок мог бы запереть хозяйство навсегда, обязаны самолечиться.
|
||
it('хозяин замка МЁРТВ — замок перехватывается, ночь идёт', () => {
|
||
vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv: () => true });
|
||
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 2000, zhiv: () => false });
|
||
expect(b.vzyal).toBe(true);
|
||
expect(b.perehvachen).toBe(true);
|
||
expect(b.reason).toMatch(/мёрт|не жив/i);
|
||
});
|
||
|
||
it('замок ПРОСРОЧЕН (хозяин жив по номеру, но номер мог достаться другому) — перехват', () => {
|
||
// 🪤 Номера процессов система выдаёт по кругу. Живой номер в замке — не доказательство,
|
||
// что жив ИМЕННО наш хозяин. Один только признак жизни запер бы ночь навсегда,
|
||
// достанься номер чему-нибудь долгоживущему. Поэтому есть и срок.
|
||
vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv: () => true });
|
||
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1000 + PREDEL_ZAMKA_MS + 1, zhiv: () => true });
|
||
expect(b.vzyal).toBe(true);
|
||
expect(b.perehvachen).toBe(true);
|
||
expect(b.reason).toMatch(/срок|устар|давн/i);
|
||
});
|
||
|
||
it('замок ПОРВАН (не читается) — перехват, и сказано вслух', () => {
|
||
writeFileSync(zamok(), '{это не json', 'utf8');
|
||
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1000, zhiv: () => true });
|
||
expect(b.vzyal).toBe(true);
|
||
expect(b.perehvachen).toBe(true);
|
||
expect(b.reason).toMatch(/проч|порван|разобрать/i);
|
||
});
|
||
|
||
it('в замке МОЙ ЖЕ номер — это не я себя запер, а старый след: перехват', () => {
|
||
// 🪤 Номер достался мне после смерти прежнего хозяина. Признак жизни ответит «жив»
|
||
// (это же я!), и без этой ветки запуск отказал бы САМ СЕБЕ, навсегда.
|
||
writeFileSync(zamok(), JSON.stringify({ pid: 1002, at: 1000 }), 'utf8');
|
||
const b = vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1500, zhiv: () => true });
|
||
expect(b.vzyal).toBe(true);
|
||
expect(b.perehvachen).toBe(true);
|
||
});
|
||
|
||
it('🪤 в замке номер, которого не бывает, — перехват, а не «жив навсегда»', () => {
|
||
// Ноль как номер процесса означает у системы «вся моя группа», то есть «жив всегда».
|
||
writeFileSync(zamok(), JSON.stringify({ pid: 0, at: 1000 }), 'utf8');
|
||
expect(vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1500, zhiv: () => true }).vzyal).toBe(true);
|
||
});
|
||
|
||
it('🔴 замок, который не заводится вовсе (нет каталога/прав), НЕ останавливает ночь', () => {
|
||
// 🪤 Четвёртая дорога к «ночь не началась». Замок — защита от лишней копейки; остановить
|
||
// из-за него всю работу значило бы обменять копейку на ночь. Пускаем и говорим вслух.
|
||
// 🪤 ПЕРВАЯ ПОПЫТКА ЭТОЙ ПРОВЕРКИ БЫЛА ПУСТОЙ: я взял путь «каталог/файл.json/ещё.json»
|
||
// и решил, что он негоден. А он годен — такого каталога просто не было, и он создался.
|
||
// Замок брался успешно, `reason` приходил пустым, и проверка падала не на том. Чтобы
|
||
// путь вправду стал негодным, на месте будущего каталога должен лежать ФАЙЛ.
|
||
const pomeha = join(zamokDir, 'pomeha');
|
||
writeFileSync(pomeha, 'я файл, а не каталог\n', 'utf8');
|
||
const r = vzyatZamokZapuska({ path: join(pomeha, 'nelzya.json'), pid: 1001, now: 1000, zhiv: () => true });
|
||
expect(r.vzyal).toBe(true);
|
||
expect(String(r.reason)).toMatch(/не вышло|БЕЗ него/i);
|
||
});
|
||
|
||
it('🔴 ответ двери НЕ берётся из того, кого она не пускает', () => {
|
||
// Дверь смотрит на две вещи, и обе НЕ подаёт отбиваемый: сам факт существования
|
||
// файла (его создаёт система неделимо) и номер хозяина (его записал ПОБЕДИТЕЛЬ).
|
||
// Проигравший до отказа не пишет НИ БАЙТА — проверяем это размером файла.
|
||
vzyatZamokZapuska({ path: zamok(), pid: 1001, now: 1000, zhiv: () => true });
|
||
const bylo = readFileSync(zamok(), 'utf8');
|
||
vzyatZamokZapuska({ path: zamok(), pid: 1002, now: 1000, zhiv: () => true });
|
||
expect(readFileSync(zamok(), 'utf8')).toBe(bylo);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 ШОВ: МЕРКА НА ДЕЛО, А НЕ НА ПРИБОР.
|
||
// Двери выше проверены поодиночке — но проверенная дверь, повешенная не в том месте, денег
|
||
// не бережёт. Здесь спрашивается ровно то, ради чего всё затеяно: ПОЗВАЛИ ЛИ ПЛАТНЫЙ ЗАМЕР.
|
||
// Судим по спаю на самом платном приборе, а не по коду возврата: единица приходит и когда
|
||
// дверь честно отбила, и когда до неё не дошло вовсе — код возврата тут не улика.
|
||
//
|
||
// 🪤 Живого платного зова здесь не родится ни при какой поломке: `priborZamera` подставной
|
||
// всегда. Вырезание дверей красит проверку СЧЁТОМ ЗОВОВ, а не тратит деньги владельца.
|
||
describe('🔴🔴 ШОВ: живая дорога `run` вторым запуском не доходит до денег', () => {
|
||
let ugol, zamokDir, planFile;
|
||
beforeEach(() => {
|
||
ugol = mkdtempSync(join(tmpdir(), 'night-shov-'));
|
||
zamokDir = mkdtempSync(join(tmpdir(), 'night-shov-zamok-'));
|
||
planFile = join(ugol, 'plan.md');
|
||
writeFileSync(planFile, [
|
||
'# Пробный план',
|
||
'',
|
||
'### Task 1: Единственный пункт',
|
||
'<!-- night: tests=app/tests/Feature/A.php; files=app/A.php -->',
|
||
].join('\n'), 'utf8');
|
||
writeFileSync(join(ugol, 'priyomka.md'), '# приёмочный лист\n', 'utf8');
|
||
writeFileSync(join(ugol, 'spec.md'), '# замысел\n', 'utf8');
|
||
});
|
||
afterEach(() => {
|
||
rmSync(ugol, { recursive: true, force: true });
|
||
rmSync(zamokDir, { recursive: true, force: true });
|
||
});
|
||
|
||
const putZamka = () => join(zamokDir, 'zapusk.json');
|
||
|
||
// Платный прибор — подставной и СЧИТАЮЩИЙ. Заодно смотрит, стоял ли замок В МИНУТУ ТРАТЫ:
|
||
// это и есть проверка ПОРЯДКА, а не только наличия замка.
|
||
function priborSoSchyotom() {
|
||
const zovy = [];
|
||
const f = async () => {
|
||
zovy.push({ zamokStoyal: existsSync(putZamka()) });
|
||
return { ok: false, measure: null };
|
||
};
|
||
f.zovy = zovy;
|
||
return f;
|
||
}
|
||
|
||
// 🔴 БЕСПЛАТНЫЙ ПРЕДПРОХОД ВОРОТ ПОДСТАВЛЕН ЗЕЛЁНЫМ, и это названо вслух. Здесь мерятся
|
||
// ДВЕ ДВЕРИ ПРОТИВ УДВОЕНИЯ (двойник и замок), а не ворота: план лежит во временном
|
||
// каталоге, он не опечатан, не закоммичен и замысел по нему не одобрен — настоящие ворота
|
||
// отказали бы всегда, и ни один из этих замеров не дошёл бы до места, которое он мерит.
|
||
// Свой сторож у предпрохода ниже, отдельным разделом, и там он НЕ подставной.
|
||
const VSE_PROSHLI = () => ({ ok: true, failed: [], unguarded: [], reason: null });
|
||
|
||
async function zapustit({ idushchie, zamer, besplatnyeVorota = VSE_PROSHLI }) {
|
||
const tiho = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const tiho2 = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||
try {
|
||
return await main(
|
||
argvFor('run', planFile, '--priyomka', join(ugol, 'priyomka.md'), '--spec', join(ugol, 'spec.md')),
|
||
{
|
||
readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7, probe_model: 'нет-такой-головы' }),
|
||
proverkaVladeltsa: () => [],
|
||
priborZamera: zamer,
|
||
idushchie,
|
||
zamokPut: putZamka,
|
||
besplatnyeVorota,
|
||
},
|
||
);
|
||
} finally { tiho.mockRestore(); tiho2.mockRestore(); }
|
||
}
|
||
|
||
it('🔴 ГОЛОВНАЯ МЕРКА: по этому плану уже идёт прогон — платного замера НЕ БЫЛО НИ РАЗУ', () => expect(
|
||
(async () => {
|
||
const zamer = priborSoSchyotom();
|
||
const kod = await zapustit({
|
||
zamer,
|
||
idushchie: () => ({ ok: true, running: [{ run_id: 'r-uzhe-idyot', plan_path: planFile }], mertvye: [], neyasnye: [], reason: null }),
|
||
});
|
||
return { kod, zovov: zamer.zovy.length };
|
||
})(),
|
||
).resolves.toEqual({ kod: 1, zovov: 0 }));
|
||
|
||
it('идёт прогон по ДРУГОМУ плану — вторая задача законна, замер делается', async () => {
|
||
// 🔴 Обратная сторона: починка, отбивающая всё подряд, отняла бы у владельца половину
|
||
// ночи (`max_tasks_at_once: 2`). Зелень одной стороны без другой ничего не доказывает.
|
||
const zamer = priborSoSchyotom();
|
||
await zapustit({
|
||
zamer,
|
||
idushchie: () => ({ ok: true, running: [{ run_id: 'r-drugoy', plan_path: join(ugol, 'sovsem-drugoy.md') }], mertvye: [], neyasnye: [], reason: null }),
|
||
});
|
||
expect(zamer.zovy.length).toBe(1);
|
||
});
|
||
|
||
it('🔴 ПОРЯДОК: в минуту траты замок УЖЕ стоял', async () => {
|
||
// Защита, стоящая ПОСЛЕ платного шага, — не защита, а отчёт о потере. Проверяем не
|
||
// «замок существует когда-нибудь», а «замок существовал в ту самую секунду, когда платили».
|
||
const zamer = priborSoSchyotom();
|
||
await zapustit({ zamer, idushchie: () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null }) });
|
||
expect(zamer.zovy[0].zamokStoyal).toBe(true);
|
||
});
|
||
|
||
it('на выходе замок ОТДАН — следующий честный запуск не упрётся в него', async () => {
|
||
await zapustit({ zamer: priborSoSchyotom(), idushchie: () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null }) });
|
||
expect(existsSync(putZamka())).toBe(false);
|
||
});
|
||
|
||
it('🔴 два запуска ПОДРЯД в одном процессе оба доходят до замера — замок не копится', () => expect(
|
||
(async () => {
|
||
const idushchie = () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null });
|
||
const a = priborSoSchyotom(); await zapustit({ zamer: a, idushchie });
|
||
const b = priborSoSchyotom(); await zapustit({ zamer: b, idushchie });
|
||
return a.zovy.length + b.zovy.length;
|
||
})(),
|
||
).resolves.toBe(2));
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 КРУГ Б, ГЛАВНАЯ ДЕНЕЖНАЯ ТРАТА ЗАТЕИ (04.08.2026): БЕСПЛАТНОЕ — ВЫШЕ ПЛАТНОГО.
|
||
//
|
||
// ━━━ ЗАМЕРЕНО ПО СЛЕДУ ОПЛАТЫ ━━━
|
||
// В `.night/probe-raw/` лежит по файлу на КАЖДЫЙ платный запуск прибора, и в каждом —
|
||
// строка `total_cost_usd`. Три замера «перед прогоном», после которых старта не случилось:
|
||
// 19:31:36 → 0,03505 $ · 21:54:07 → 0,03409 $ · 22:53:59 → 0,03768 $ ═ 0,10682 $
|
||
// Против 0,00767 $ на удвоении запуска (0,00408 + 0,00360), которое чинили выше. Вчетырнадцать
|
||
// раз дороже. 🪤 Средней ценой это считать нельзя: те же 15 запусков стоили от 0,0033 до
|
||
// 0,1483 $ за штуку — разброс в сорок пять раз, и «событий × среднюю» соврало бы втрое.
|
||
//
|
||
// ━━━ ЧТО СТОРОЖИМ — УСТРОЙСТВО, А НЕ ЧИСЛА ━━━
|
||
// 🪤 Проверка вида «поле A не равно полю B» зелена там, где равенство законно, и потому
|
||
// не сторожит ничего. Здесь спрашивается другое: КТО ВПРАВЕ СДЕЛАТЬ ПЛАТНЫЙ ШАГ И ПОСЛЕ ЧЕГО.
|
||
// Сторож обязан покраснеть от одной только перестановки, даже если код при этом рабочий.
|
||
describe('🔴🔴 бесплатные ворота стоят ВЫШЕ платного замера', () => {
|
||
// ── Отказ КАЖДЫХ бесплатных ворот, по одному входу на каждые ────────────────────
|
||
// 🔴 Список берётся из `GATE_ORDER` — единственного места, где записан состав ворот.
|
||
// Здесь только сказано, чем КОРМИТЬ каждые, чтобы они отказали.
|
||
const CHEM_LOMAT = {
|
||
'место на сервере': { disk: { ok: false, reason: 'на диске 2 ГБ, а надо 5' } },
|
||
'замысел проверен': { spec: { ok: false, reason: 'замысел никем не одобрен' } },
|
||
'бумаги сохранены': { papers: { ok: false, reason: 'план не закоммичен' } },
|
||
'отпечаток плана': { planSeal: { ok: false, reason: 'план правили после печати' } },
|
||
'потолки задач и работников': { capacity: { tasks_running: 9, max_tasks: 2, max_workers: 7, workers_in_task: 1 } },
|
||
'задачи не пересекаются по файлам': { newTaskFiles: ['app/A.php'], runningTasksFiles: [['app/A.php']] },
|
||
};
|
||
|
||
// Вход, при котором ВСЕ бесплатные ворота довольны. Денежных ответов в нём нет вовсе —
|
||
// их в эту минуту и не бывает.
|
||
const VSYO_HOROSHO = {
|
||
disk: { ok: true }, spec: { ok: true }, papers: { ok: true }, planSeal: { ok: true },
|
||
capacity: { tasks_running: 0, max_tasks: 2, max_workers: 7, workers_in_task: 1 },
|
||
newTaskFiles: ['app/A.php'], runningTasksFiles: [],
|
||
};
|
||
|
||
it('🔴 ГОЛОВНАЯ: без единого ответа про деньги предпроход всё равно ПРОПУСКАЕТ', () => {
|
||
// Иначе починка была бы хуже беды: ночь не начиналась бы никогда.
|
||
const r = besplatnyeVorotaDoDeneg({ vhod: VSYO_HOROSHO });
|
||
expect(r.ok).toBe(true);
|
||
expect(r.failed).toEqual([]);
|
||
});
|
||
|
||
it('🔴🔴 денежные ворота — ЕДИНСТВЕННЫЕ, чей приговор тут не считается', () => {
|
||
// 🪤 Сторож на подмену смысла: подставь кто-нибудь сюда выдуманный зелёный ответ про
|
||
// запас вместо «не спрашиваем» — и ночь пошла бы платить при кончившемся запасе.
|
||
// Проверяем не значение, а УСТРОЙСТВО: воротам запаса подан `null`, то есть их не
|
||
// спрашивали, и их отказ отброшен ИМЕНЕМ, а не потому, что они случайно смолчали.
|
||
const nastoyashchie = startGates({ ...VSYO_HOROSHO, budget: null });
|
||
expect(nastoyashchie.ok).toBe(false);
|
||
expect(nastoyashchie.failed.map(f => f.gate)).toEqual([VOROTA_DENEG]);
|
||
// А предпроход при том же входе — пропускает.
|
||
expect(besplatnyeVorotaDoDeneg({ vhod: VSYO_HOROSHO }).ok).toBe(true);
|
||
});
|
||
|
||
it('🔴 имя денежных ворот вправду есть в составе ворот', () => {
|
||
// 🪤 Переименуй кто-нибудь ворота в `gates.mjs` — отбрасывание по имени промолчало бы,
|
||
// и предпроход стал бы отказывать всегда (либо, что хуже, пропускать деньги).
|
||
expect(GATE_ORDER).toContain(VOROTA_DENEG);
|
||
});
|
||
|
||
it('🔴🔴 КАЖДЫЕ бесплатные ворота из состава вправду умеют отказать ДО денег', () => {
|
||
// 🔴 Ходим по `GATE_ORDER`, а не по своему списку. Добавят десятые ворота — этот сторож
|
||
// покраснеет и потребует сказать вслух, платные они или бесплатные. Молча проскочить
|
||
// мимо решения «а этим нужен замер?» больше нельзя.
|
||
const nestorozhat = besplatnyeVorotaDoDeneg({ vhod: VSYO_HOROSHO }).unguarded;
|
||
for (const vorota of GATE_ORDER) {
|
||
if (vorota === VOROTA_DENEG) continue;
|
||
if (nestorozhat.includes(vorota)) continue; // правило им пишет кусок 7, отказывать нечем
|
||
const chem = CHEM_LOMAT[vorota];
|
||
expect(chem, `для ворот «${vorota}» не сказано, чем их сломать: они новые и никто не решил, платные они или нет`).toBeTruthy();
|
||
const r = besplatnyeVorotaDoDeneg({ vhod: { ...VSYO_HOROSHO, ...chem } });
|
||
expect(r.ok, `ворота «${vorota}» не отказали до денег`).toBe(false);
|
||
expect(r.failed.map(f => f.gate)).toContain(vorota);
|
||
}
|
||
});
|
||
|
||
it('слова отказа говорят владельцу, что денег это не стоило', () => {
|
||
const slova = strokiOtkazaDoDeneg(besplatnyeVorotaDoDeneg({ vhod: { ...VSYO_HOROSHO, ...CHEM_LOMAT['место на сервере'] } })).join(' ');
|
||
expect(slova).toMatch(/не запущен/i);
|
||
expect(slova).toMatch(/2 ГБ/);
|
||
expect(slova).toMatch(/не стоило|не запускался/i);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 ШОВ ВТОРОЙ: ЖИВАЯ ДОРОГА `run` ПРИ ОТКАЗЕ БЕСПЛАТНЫХ ВОРОТ НЕ ДОХОДИТ ДО ДЕНЕГ.
|
||
// Проверенная дверь, повешенная не в том месте, денег не бережёт. Судим спаем на платном
|
||
// приборе, а не кодом возврата: единица приходит и когда дверь честно отбила, и когда
|
||
// до неё не дошло вовсе.
|
||
// 🪤 Живого платного зова здесь не родится ни при какой поломке: прибор подставной всегда.
|
||
describe('🔴🔴 ШОВ: отказ бесплатных ворот НЕ стоит ни копейки', () => {
|
||
let ugol, zamokDir, planFile;
|
||
beforeEach(() => {
|
||
ugol = mkdtempSync(join(tmpdir(), 'night-shov2-'));
|
||
zamokDir = mkdtempSync(join(tmpdir(), 'night-shov2-zamok-'));
|
||
planFile = join(ugol, 'plan.md');
|
||
writeFileSync(planFile, [
|
||
'# Пробный план',
|
||
'',
|
||
'### Task 1: Единственный пункт',
|
||
'<!-- night: tests=app/tests/Feature/A.php; files=app/A.php -->',
|
||
].join('\n'), 'utf8');
|
||
writeFileSync(join(ugol, 'priyomka.md'), '# приёмочный лист\n', 'utf8');
|
||
writeFileSync(join(ugol, 'spec.md'), '# замысел\n', 'utf8');
|
||
});
|
||
afterEach(() => {
|
||
rmSync(ugol, { recursive: true, force: true });
|
||
rmSync(zamokDir, { recursive: true, force: true });
|
||
});
|
||
|
||
const putZamka = () => join(zamokDir, 'zapusk.json');
|
||
|
||
// Платный прибор — подставной и СЧИТАЮЩИЙ. Заодно смотрит, спросили ли к минуте траты
|
||
// бесплатные ворота: это и есть проверка ПОРЯДКА, а не только наличия предпрохода.
|
||
function priborSoSchyotom(poryadok) {
|
||
const zovy = [];
|
||
const f = async () => { zovy.push(poryadok.slice()); poryadok.push('деньги'); return { ok: false, measure: null }; };
|
||
f.zovy = zovy;
|
||
return f;
|
||
}
|
||
|
||
async function zapustit({ vorotaOtvet, poryadok = [] }) {
|
||
const zamer = priborSoSchyotom(poryadok);
|
||
const vidennoe = [];
|
||
const tiho = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
const tiho2 = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||
let kod;
|
||
try {
|
||
kod = await main(
|
||
argvFor('run', planFile, '--priyomka', join(ugol, 'priyomka.md'), '--spec', join(ugol, 'spec.md')),
|
||
{
|
||
readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7, probe_model: 'нет-такой-головы' }),
|
||
proverkaVladeltsa: () => [],
|
||
priborZamera: zamer,
|
||
idushchie: () => ({ ok: true, running: [], mertvye: [], neyasnye: [], reason: null }),
|
||
zamokPut: putZamka,
|
||
besplatnyeVorota: ({ vhod }) => { poryadok.push('бесплатные'); vidennoe.push(vhod); return vorotaOtvet; },
|
||
},
|
||
);
|
||
} finally { tiho.mockRestore(); tiho2.mockRestore(); }
|
||
return { kod, zovov: zamer.zovy.length, poryadok, vhod: vidennoe[0] };
|
||
}
|
||
|
||
it('🔴 ГОЛОВНАЯ МЕРКА: бесплатные ворота отказали — платного замера НЕ БЫЛО НИ РАЗУ', async () => {
|
||
const r = await zapustit({
|
||
vorotaOtvet: { ok: false, failed: [{ gate: 'место на сервере', reason: 'на диске 2 ГБ' }], unguarded: [] },
|
||
});
|
||
expect(r.zovov).toBe(0);
|
||
expect(r.kod).toBe(1);
|
||
});
|
||
|
||
it('🔴 ПОРЯДОК: бесплатные ворота спрошены РАНЬШЕ денег, а не «где-то тоже есть»', async () => {
|
||
const r = await zapustit({ vorotaOtvet: { ok: true, failed: [], unguarded: [] } });
|
||
expect(r.zovov).toBe(1);
|
||
expect(r.poryadok).toEqual(['бесплатные', 'деньги']);
|
||
});
|
||
|
||
it('🔴🔴 предпроходу поданы ВСЕ бесплатные ответы, а не половина', async () => {
|
||
// 🪤 Самая тихая поломка из возможных: предпроход стоит на месте и зелен, а `papers`
|
||
// или `spec` в него не положили — тогда эти ворота молча уезжают обратно ВНИЗ,
|
||
// за платный шаг, и их отказ снова начинает стоить денег. Смотрим сам поданный вход.
|
||
const r = await zapustit({ vorotaOtvet: { ok: true, failed: [], unguarded: [] } });
|
||
for (const pole of ['disk', 'planSeal', 'papers', 'spec', 'capacity', 'newTaskFiles', 'runningTasksFiles']) {
|
||
expect(r.vhod[pole], `бесплатным воротам не подан ответ «${pole}» — эти ворота уехали за платный шаг`).toBeDefined();
|
||
expect(r.vhod[pole]).not.toBeNull();
|
||
}
|
||
// 🔴 И обратная сторона: ответа про деньги в предпроходе быть НЕ должно — его в эту
|
||
// минуту неоткуда взять, а положенный сюда старый ответ был бы решением по вчерашнему числу.
|
||
expect(r.vhod.budget).toBeNull();
|
||
});
|
||
});
|