845 lines
55 KiB
JavaScript
845 lines
55 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 } 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 } from './cli.mjs';
|
||
import { readLines } from './state.mjs';
|
||
import { razborDovodov, zamerDovody, vhodVorot, strokiOtkaza, odinVid } from './cli-parts.mjs';
|
||
// 🔴 Кусок 3, задача 6. `startGates` — НАСТОЯЩИЕ ворота: числа идут в них не рукой,
|
||
// а через `vhodVorot`. `odinVidPuti` — общая мелочь про пути (задача 2, `paths.mjs`),
|
||
// ввозится ради одной проверки: что она и `odinVid` куска 2 отвечают одинаково.
|
||
import { startGates } from './gates.mjs';
|
||
import { odinVidPuti } from './paths.mjs';
|
||
|
||
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: вид предела — НЕДЕЛЬНЫЙ; нет строки в настройках — всё равно недельный', () => {
|
||
// 🪤 Тихая беда: спросив пятичасовой предел, мы решали бы о недельных деньгах
|
||
// по чужому прибору — и число выглядело бы совершенно правдоподобно.
|
||
expect(zamerDovody({ ...CFG, rate_limit_type: 'five_hour' }).kind).toBe('five_hour');
|
||
expect(zamerDovody({ ...CFG, rate_limit_type: undefined }).kind).toBe('seven_day');
|
||
});
|
||
});
|
||
|
||
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([]);
|
||
});
|
||
});
|
||
|
||
// ============ блок 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: []` единственному пункту — обе половины останутся зелёными, а ночь молча
|
||
// вернётся к «ворота пересечения не держат никогда». Поэтому смотрим текстом.
|
||
const zovVorot = TEKST_CLI.match(/planRun\(vhodVorot\(\{([\s\S]*?)\}\)\)/)[1];
|
||
expect(zovVorot).toContain('taskId');
|
||
const punkt = TEKST_CLI.match(/const items = \[\{([\s\S]*?)\}\];/)[1];
|
||
expect(punkt).toMatch(/files:\s*razbor\.files/);
|
||
expect(punkt).not.toMatch(/files:\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 по месту разъехались бы молча: поправят один, забудут другой.
|
||
expect((TEKST_CLI.match(/idushchieProgony\(\)/g) ?? []).length).toBe(2);
|
||
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('всё цело — обёртка отдаёт список как есть (не глухая)', () => {
|
||
const spisok = [{ run_id: 'r-1', status: 'running' }];
|
||
const r = idushchieProgony(() => spisok);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.running).toBe(spisok);
|
||
expect(r.reason).toBeNull();
|
||
});
|
||
});
|