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

691 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
billableMs, hoursLimitHit, morningCutoff, serviceWaitHit,
SERVICE_WAIT_LIMIT_HOURS, MORNING_CUTOFF_HOUR, PREDEL_CHASOV_SUTKI,
checkSelfLimits, resumeChecks,
} from './limits.mjs';
import { moskovskoeVremya } from './budget.mjs';
const H = 3600_000;
const M = 60_000;
// Живая настройка ночи. Берётся ФАЙЛОМ, а не переписанным сюда числом: проверка обязана
// краснеть, когда разойдутся файл и решение владельца, а не когда разойдутся две копии числа.
function zhivayaNastroyka() {
return JSON.parse(readFileSync(fileURLToPath(new URL('../../night.config.json', import.meta.url)), 'utf8'));
}
describe('billableMs — что съедает предел часов', () => {
it('обычная работа считается', () => {
expect(billableMs([{ kind: 'working', ms: 3 * H }])).toBe(3 * H);
});
it('доделывание шага перед остановкой считается', () => {
expect(billableMs([{ kind: 'stopping', ms: 1 * H }])).toBe(1 * H);
});
it('ожидание ответа С РАБОТОЙ считается наравне', () => {
expect(billableMs([{ kind: 'waiting_answer_busy', ms: 5 * H }])).toBe(5 * H);
});
it('чистый простой НЕ считается', () => {
expect(billableMs([{ kind: 'waiting_answer_idle', ms: 5 * H }])).toBe(0);
});
it('ожидание чужой службы НЕ считается — проверка 13, вторая половина', () => {
expect(billableMs([{ kind: 'waiting_service', ms: 2 * H }])).toBe(0);
});
it('пауза не считается — он молчит по команде владельца', () => {
expect(billableMs([{ kind: 'paused', ms: 10 * H }])).toBe(0);
});
it('смесь: работал 3 часа, ждал с работой 5 — предел выбран (проверка 5)', () => {
const ms = billableMs([{ kind: 'working', ms: 3 * H }, { kind: 'waiting_answer_busy', ms: 5 * H }]);
expect(hoursLimitHit(ms, 8)).toBe(true);
});
});
// 🔴 Само правило сравнения ЧИСЛА не знает — предел ему подают снаружи. Поэтому здесь оба:
// прежняя восьмёрка (правило обязано остаться годным для любого числа) и нынешние сутки.
describe('hoursLimitHit — предел часов (проверка 5)', () => {
it('ровно предел — он УЖЕ выбран', () => {
expect(hoursLimitHit(8 * H, 8)).toBe(true);
expect(hoursLimitHit(24 * H, 24)).toBe(true);
});
it('минутой меньше предела — ещё нет', () => {
expect(hoursLimitHit(8 * H - M, 8)).toBe(false);
expect(hoursLimitHit(24 * H - M, 24)).toBe(false);
});
it('🔴 решение 37: девятый час работы под суточным пределом — работник живёт', () => {
expect(hoursLimitHit(9 * H, 24)).toBe(false);
});
});
describe('hoursLimitHit — ПРЕДЕЛА НЕ ДАЛИ (дыра 3)', () => {
// 🔴 Раньше здесь была одна строка `ms >= hours * H`, и на потерянной настройке она давала
// `ms >= NaN` — то есть ВСЕГДА «предел не выбран». Предел часов исчезал молча.
// Вырежи проверки в hoursLimitHit — покраснеет весь этот раздел.
it('предела нет вовсе — падаем громко, а не отвечаем «ещё не выбрал»', () => {
expect(() => hoursLimitHit(100 * H, undefined)).toThrow(/hours_limit/);
});
it('предел пустой или не число — тоже громко', () => {
expect(() => hoursLimitHit(100 * H, null)).toThrow(/предел/i);
expect(() => hoursLimitHit(100 * H, NaN)).toThrow(/предел/i);
expect(() => hoursLimitHit(100 * H, '8')).toThrow(/предел/i);
});
it('отработанное время не посчитано — тоже громко, а не «предел не выбран»', () => {
expect(() => hoursLimitHit(undefined, 8)).toThrow(/billable_ms/);
expect(() => hoursLimitHit(NaN, 8)).toThrow(/сравнивать/i);
});
it('предел ноль — это настоящий ноль, а не «не дали»: выбран сразу', () => {
expect(hoursLimitHit(0, 0)).toBe(true);
});
});
describe('serviceWaitHit — проверка 13: «служба не ответила»', () => {
it('порог — два часа, и он назван числом, а не спрятан в коде', () => {
expect(SERVICE_WAIT_LIMIT_HOURS).toBe(2);
});
it('ждёт службу час — не гасим, служба ещё может ответить', () => {
const r = serviceWaitHit([{ kind: 'working', ms: 3 * H }, { kind: 'waiting_service', ms: 1 * H }]);
expect(r.extinguish).toBe(false);
});
it('ждёт службу два часа — ГАСНЕТ, и причина названа теми же словами, что в бумаге', () => {
const r = serviceWaitHit([{ kind: 'working', ms: 3 * H }, { kind: 'waiting_service', ms: 2 * H }]);
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/служба не ответила/i);
});
it('ждёт службу три часа — тоже гаснет', () => {
const r = serviceWaitHit([{ kind: 'waiting_service', ms: 3 * H }]);
expect(r.extinguish).toBe(true);
});
it('ждал службу два часа ДАВНО, а сейчас работает — не гасим: она ответила', () => {
// Порог считается по ТЕКУЩЕМУ непрерывному ожиданию. Иначе гасили бы за то,
// что уже кончилось хорошо, — и это была бы неправда в сводке.
const r = serviceWaitHit([{ kind: 'waiting_service', ms: 2 * H }, { kind: 'working', ms: 10 * M }]);
expect(r.extinguish).toBe(false);
});
it('восемь коротких ожиданий по десять минут — не «прождал два часа»', () => {
const spans = [];
for (let i = 0; i < 8; i += 1) {
spans.push({ kind: 'waiting_service', ms: 10 * M });
spans.push({ kind: 'working', ms: 20 * M });
}
spans.push({ kind: 'waiting_service', ms: 10 * M });
expect(serviceWaitHit(spans).extinguish).toBe(false);
});
it('отрезков нет вовсе — не гасим: он ещё ничего не ждал', () => {
expect(serviceWaitHit([]).extinguish).toBe(false);
expect(serviceWaitHit(null).extinguish).toBe(false);
});
it('эти два часа предел часов НЕ съели — вторая половина проверки 13', () => {
// Он проработал 7 часов и два часа ждал службу. По часам предел НЕ выбран,
// а погашен он по «служба не ответила» — и в сводке будет написано именно это.
const spans = [{ kind: 'working', ms: 7 * H }, { kind: 'waiting_service', ms: 2 * H }];
expect(hoursLimitHit(billableMs(spans), 8)).toBe(false);
expect(billableMs(spans)).toBe(7 * H);
expect(serviceWaitHit(spans).extinguish).toBe(true);
});
});
describe('morningCutoff — отсечка 8:00 (проверки 31, 34, 35)', () => {
const at = iso => Date.parse(iso);
it('работающего в 8:00 гасим (проверка 31)', () => {
const r = morningCutoff({ status: 'working', runStartedAt: at('2026-07-29T22:00:00+03:00') }, at('2026-07-30T08:00:00+03:00'));
expect(r.extinguish).toBe(true);
});
it('прогон целиком вне тихих часов — отсечка не применяется (проверка 34)', () => {
const r = morningCutoff({ status: 'working', runStartedAt: at('2026-07-30T09:00:00+03:00') }, at('2026-07-30T15:00:00+03:00'));
expect(r.extinguish).toBe(false);
expect(r.reason).toMatch(/вне тихих часов/i);
});
it('запущенный в 22:00 гаснет в 8:00, хотя предел часов не вышел (проверка 35)', () => {
const r = morningCutoff({ status: 'working', runStartedAt: at('2026-07-29T22:00:00+03:00') }, at('2026-07-30T08:00:00+03:00'));
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/8:00/);
});
it('до 8:00 не гасим', () => {
const r = morningCutoff({ status: 'working', runStartedAt: at('2026-07-29T22:00:00+03:00') }, at('2026-07-30T07:59:00+03:00'));
expect(r.extinguish).toBe(false);
});
it('ждущего ответа, который ЧТО-ТО ДЕЛАЕТ, тоже гасим', () => {
// Проверка 32 бумаги формально стоит в куске 5, но ветка живёт в этой же функции:
// без неё она позеленела бы «по умолчанию», а не по написанному правилу.
const r = morningCutoff({ status: 'waiting_answer_busy', runStartedAt: at('2026-07-29T22:00:00+03:00') }, at('2026-07-30T08:00:00+03:00'));
expect(r.extinguish).toBe(true);
});
it('стоящего в чистом простое НЕ гасим', () => {
// Проверка 33 бумаги — тоже кусок 5, ветка та же функция. Он ничего не тратит
// и доживает до ответа владельца.
const r = morningCutoff({ status: 'waiting_answer_idle', runStartedAt: at('2026-07-29T22:00:00+03:00') }, at('2026-07-30T08:00:00+03:00'));
expect(r.extinguish).toBe(false);
});
it('в 8:00 ПАУЗНОГО не гасим — пауза это команда самого владельца', () => {
// 🔴 Решение владельца. Пауза поставлена его же рукой: погасить по ней значит отменить
// его команду. Он вернётся и продолжит с того места, где остановил.
// Вырежи ветку `paused` — покраснеет здесь: паузного погасят в 8:00 вместе с работающими.
const r = morningCutoff({ status: 'paused', runStartedAt: at('2026-07-29T22:00:00+03:00') }, at('2026-07-30T08:00:00+03:00'));
expect(r.extinguish).toBe(false);
expect(r.reason).toMatch(/паузе|пауза/i);
});
it('в 8:00 ЖДУЩЕГО ЧУЖУЮ СЛУЖБУ не гасим — у него свой срок в два часа', () => {
// 🔴 Решение владельца. У ждущего службу уже есть собственный предел — два часа подряд
// (serviceWaitHit). Второй предел ему не нужен: он погаснет по своему, с честной причиной
// «служба не ответила», а не безымянно по отсечке.
// Вырежи ветку `waiting_service` — покраснеет здесь.
const r = morningCutoff({ status: 'waiting_service', runStartedAt: at('2026-07-29T22:00:00+03:00') }, at('2026-07-30T08:00:00+03:00'));
expect(r.extinguish).toBe(false);
expect(r.reason).toMatch(/служб/i);
});
it('в 8:00 РАБОТАЮЩИЙ всё равно ПОГАШЕН — послабления его не задели', () => {
// 🔴 Страховка к двум послаблениям выше. Расширяя список «кого не гасим», легко
// расширить его на всех — и отсечка 8:00 перестанет работать вовсе, молча и зелено.
// Эта проверка стережёт именно это: работающего в 8:00 гасят как гасили.
const r = morningCutoff({ status: 'working', runStartedAt: at('2026-07-29T22:00:00+03:00') }, at('2026-07-30T08:00:00+03:00'));
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/8:00/);
});
it('УЖЕ ПОГАШЕННОГО второй раз не гасим — проверка 5, вторая половина', () => {
// 🪤 Погашенный считается мёртвым. Без этой ветки его гасили бы каждый круг заново,
// и владелец получал бы вторую строку про то же самое — а на другое утро третью.
// Вырежи ветку — покраснеет здесь.
const r = morningCutoff({ status: 'extinguished', runStartedAt: at('2026-07-29T22:00:00+03:00') }, at('2026-07-30T08:00:00+03:00'));
expect(r.extinguish).toBe(false);
expect(r.reason).toMatch(/уже погашен/i);
});
it('прогон начат в 22:45, смотрим в 23:10 — тихие часы ЗАСЧИТАНЫ, а не проскочены', () => {
// 🪤 Обход идёт получасовыми шагами: 22:45, следом 23:15 — а он уже за «сейчас».
// Смотри только шаги — про этот прогон было бы сказано «идёт целиком вне тихих часов»,
// хотя тихие часы наступили десять минут назад. Гасить в 23:10 ещё рано (это сама ночь),
// но ВРАТЬ про тихие часы нельзя: назавтра в 8:00 на этом вранье он и уцелел бы.
// Вырежи осмотр концов промежутка — покраснеет здесь.
const r = morningCutoff(
{ status: 'working', runStartedAt: at('2026-07-30T22:45:00+03:00') },
at('2026-07-30T23:10:00+03:00'),
);
expect(r.extinguish).toBe(false);
expect(r.reason ?? '').not.toMatch(/вне тихих часов/i);
});
// 🆕 Долг перед куском 5 (решение Р9 / проверка П24): час отсечки не спрятан внутри
// функции, а торчит наружу постоянной — иначе кусок 5 не может сравнить свою восьмёрку
// (`TIHIE_CHASY.to`) с этой и заметить, если они когда-нибудь разъедутся. Числа
// намеренно НЕ сведены в один источник (тихие часы — про вопросы владельцу, отсечка —
// про гашение работающих), но каждое из них теперь названо, а не спрятано.
it('час отсечки — постоянная, а не спрятанное число, и функция считает по ней', () => {
expect(MORNING_CUTOFF_HOUR).toBe(8);
const r = morningCutoff(
{ status: 'working', runStartedAt: at('2026-07-29T22:00:00+03:00') },
at(`2026-07-30T${String(MORNING_CUTOFF_HOUR).padStart(2, '0')}:00:00+03:00`),
);
expect(r.extinguish).toBe(true);
});
});
describe('дорога к московскому часу — ОДНА (дыра 4)', () => {
const at = iso => Date.parse(iso);
it('час берётся у ЕДИНОГО прибора budget.moskovskoeVremya, а не у своей второй дороги', async () => {
// 🔴 Настоящий сторож второй дороги: подменяем ЕДИНЫЙ прибор и смотрим, послушался ли
// его этот файл. Заведи здесь свой `Intl.DateTimeFormat` обратно — подмена будет
// проигнорирована, и проверка покраснеет.
//
// Времена выбраны так, чтобы настоящий и подменённый приборы отвечали ПО-РАЗНОМУ:
// по-настоящему это полдень (тихих часов нет, гасить некого), а подменённый прибор
// говорит «начали в 23, сейчас 9» — то есть тихие часы были и настало утро.
const START = at('2026-07-30T12:00:00+03:00');
const NOW = at('2026-07-30T13:00:00+03:00');
// Сперва замеряем настоящий ответ — чтобы проверка не зеленела случайно.
expect(moskovskoeVremya(START).hour).toBe(12);
const realno = morningCutoff({ status: 'working', runStartedAt: START }, NOW);
expect(realno.extinguish).toBe(false);
expect(realno.reason).toMatch(/вне тихих часов/i);
vi.resetModules();
vi.doMock('./budget.mjs', async importOriginal => ({
...(await importOriginal()),
moskovskoeVremya: ms => ({ day: '2026-07-30', hour: ms >= NOW ? 9 : 23 }),
}));
try {
const { morningCutoff: podmenennyy } = await import('./limits.mjs');
const r = podmenennyy({ status: 'working', runStartedAt: START }, NOW);
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/8:00/);
} finally {
vi.doUnmock('./budget.mjs');
vi.resetModules();
}
});
it('тихие часы тоже считаются ПО ТОМУ ЖЕ прибору, а не по своему', async () => {
// Обратная сторона: подменённый прибор говорит «всегда полдень» — значит тихих часов
// не было вовсе, и работающего в настоящие 8 утра гасить не за что.
const START = at('2026-07-29T22:00:00+03:00');
const NOW = at('2026-07-30T08:00:00+03:00');
expect(morningCutoff({ status: 'working', runStartedAt: START }, NOW).extinguish).toBe(true);
vi.resetModules();
vi.doMock('./budget.mjs', async importOriginal => ({
...(await importOriginal()),
moskovskoeVremya: () => ({ day: '2026-07-30', hour: 12 }),
}));
try {
const { morningCutoff: podmenennyy } = await import('./limits.mjs');
const r = podmenennyy({ status: 'working', runStartedAt: START }, NOW);
expect(r.extinguish).toBe(false);
expect(r.reason).toMatch(/вне тихих часов/i);
} finally {
vi.doUnmock('./budget.mjs');
vi.resetModules();
}
});
});
describe('checkSelfLimits — пределы, видные ВО ВРЕМЯ прогона (проверка 14)', () => {
// 22:00 по Москве — прогон начат в тихие часы, как настоящая ночь.
const START = Date.parse('2026-07-29T22:00:00.000+03:00');
// Здоровая середина ночи: час отработан, места хватает, оба предела запаса целы.
const ok = {
billable_ms: 1 * H,
hours_limit: 8,
budget: { run_over_limit: false, worker_over_limit: false, may_start: true },
disk: { ok: true, reason: null },
worker: { status: 'working', runStartedAt: START },
now: START + 1 * H,
current_item: '4.11',
};
it('всё в норме — работаем дальше, и место работника названо', () => {
const r = checkSelfLimits(ok);
expect(r.extinguish).toBe(false);
expect(r.wake_owner).toBe(false);
expect(r.reason).toBeNull();
expect(r.at_item).toBe('4.11');
});
it('выбрал весь предел часов — гаснет, и место названо', () => {
// 🔴 Слово «восемь» из проверки убрано намеренно (решение владельца 37): предел стал
// сутками, и проверка, требующая в причине именно восьмёрку, закрепляла бы отменённое.
// Сама проверка на месте — она сторожит, что по выбранному пределу работник гаснет
// и что место работы названо.
const r = checkSelfLimits({ ...ok, billable_ms: 8 * H });
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/предел часов/i);
expect(r.at_item).toBe('4.11');
});
it('прогон выбрал свою седьмую часть — гаснет по запасу (проверка 14)', () => {
const r = checkSelfLimits({ ...ok, budget: { run_over_limit: true, worker_over_limit: false, may_start: true } });
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/запас/i);
expect(r.reason).toMatch(/седьмую часть/i);
});
it('по общему пределу гаснет и тот, кто не выбрал НИ своих часов, ни своей доли', () => {
// 🔴 Дословно из бумаги, проверка 14: «погасли все работники, даже те, кто не выбрал
// свои часы». У этого работника всего десять минут работы и своя доля цела —
// и он всё равно гаснет.
const r = checkSelfLimits({
...ok,
billable_ms: 10 * M,
budget: { run_over_limit: true, worker_over_limit: false, may_start: true },
});
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/седьмую часть/i);
});
it('упёрлись и в запас, и в предел часов — в сводке сказано про ЗАПАС, а не про время', () => {
// 🔴 Третья половина проверки 14. Если сказать «упёрся в часы», владелец
// решит, что дело во времени, и запустит следующую ночь — по пустому запасу.
const r = checkSelfLimits({
...ok,
billable_ms: 8 * H,
budget: { run_over_limit: true, worker_over_limit: false, may_start: true },
});
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/запас/i);
expect(r.reason).not.toMatch(/предел часов/i);
});
it('работник выбрал СВОЮ долю — гаснет он один, и причина названа его словами', () => {
const r = checkSelfLimits({ ...ok, budget: { run_over_limit: false, worker_over_limit: true, may_start: true } });
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/свою долю/i);
expect(r.wake_owner).toBe(false);
});
it('сосед свою долю не выбрал — работает дальше, хотя разогнавшийся уже погашен', () => {
const r = checkSelfLimits({ ...ok, budget: { run_over_limit: false, worker_over_limit: false, may_start: true } });
expect(r.extinguish).toBe(false);
});
it('общий предел важнее личного: названа причина, по которой гаснут ВСЕ', () => {
const r = checkSelfLimits({ ...ok, budget: { run_over_limit: true, worker_over_limit: true, may_start: true } });
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/седьмую часть/i);
expect(r.reason).not.toMatch(/свою долю/i);
});
it('кончилось место — гаснет сразу и просит будить владельца', () => {
const r = checkSelfLimits({ ...ok, disk: { ok: false, reason: 'На сервере кончается место: свободно 1.5 ГБ при пороге 5 ГБ.' } });
expect(r.extinguish).toBe(true);
expect(r.wake_owner).toBe(true);
expect(r.reason).toContain('1.5');
});
it('место смотрится РАНЬШЕ запаса: кончилось и то и другое — сказано про место', () => {
// Место чинит только человек, и чинит руками. Запас чинит время. Поэтому будят
// владельца именно за место, и причина должна быть про него.
const r = checkSelfLimits({
...ok,
disk: { ok: false, reason: 'Свободное место замерить не удалось — работать вслепую нельзя.' },
budget: { run_over_limit: true, worker_over_limit: true, may_start: true },
});
expect(r.extinguish).toBe(true);
expect(r.wake_owner).toBe(true);
expect(r.reason).toMatch(/место/i);
});
it('во всех прочих случаях владельца ночью НЕ будим', () => {
expect(checkSelfLimits({ ...ok, billable_ms: 8 * H }).wake_owner).toBe(false);
expect(checkSelfLimits({ ...ok, budget: { run_over_limit: true, may_start: true } }).wake_owner).toBe(false);
expect(checkSelfLimits({ ...ok, budget: { worker_over_limit: true, may_start: true } }).wake_owner).toBe(false);
});
it('настало 8:00 — гаснет по отсечке, и причина про 8:00', () => {
const r = checkSelfLimits({ ...ok, now: START + 10 * H });
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/8:00/);
});
it('замера запаса нет вовсе — по запасу посреди ночи НЕ гасим', () => {
// 🔴 Решение, названное вслух в шапке задачи. Прибор мог промолчать один круг.
// Слепой СТАРТ запрещён воротами (проверка 17), а начавшийся прогон от одного
// пропущенного замера не гибнет.
// 🔴 Как проверять эту проверку вырезанием: ветки «нет запаса — гасим» в коде НЕТ
// и быть не должно — вырезать тут нечего. Обратный ход: ВСТАВЬ в `checkSelfLimits`
// перед часами строку `if (!budget) return { extinguish: true, ... }` — эта проверка
// обязана покраснеть. Не покраснела — значит она смотрит не туда.
const r = checkSelfLimits({ ...ok, budget: null });
expect(r.extinguish).toBe(false);
expect(r.reason).toBeNull();
});
it('ответа про место нет ВОВСЕ — гаснем, будим владельца, и причина названа честно', () => {
// 🔴 Соседние ворота (задача 5) случай «ответа не дали вовсе» разбирают пятью
// проверками поимённо. Здесь тот же вход гасил работника и будил владельца ночью
// с причиной «Причина не названа» — то есть человека поднимали в три часа ночи
// и не говорили, из-за чего. Направление (гасить и будить) верное: вслепую
// работать нельзя. А вот молчание про причину — нет.
const r = checkSelfLimits({ ...ok, disk: undefined });
expect(r.extinguish).toBe(true);
expect(r.wake_owner).toBe(true);
expect(r.reason).toMatch(/ответа про место не было/i);
expect(r.reason).not.toMatch(/причина не названа/i);
});
it('своя доля смотрится РАНЬШЕ предела часов: упёрлись и в долю, и в часы — сказано про долю', () => {
// Четвёртый рубеж порядка. Без этой проверки пара «своя доля → предел часов»
// не закреплена ничем, и следующая смена свободно переставит их местами.
const r = checkSelfLimits({
...ok,
billable_ms: 8 * H,
budget: { run_over_limit: false, worker_over_limit: true, may_start: true },
});
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/свою долю/i);
expect(r.reason).not.toMatch(/предел часов/i);
});
it('предела часов не дали — ГАСНЕТ, и названа потерянная настройка (дыра 3)', () => {
// 🔴 Прежде этот вход давал `ms >= NaN` = «предел не выбран», работник не гас никогда
// и жёг запас до утра — молча и зелено. Вырежи ветку про hours_limit в checkSelfLimits —
// покраснеет здесь (и заодно надзиратель начнёт падать на `hoursLimitHit`).
const r = checkSelfLimits({ ...ok, hours_limit: undefined });
expect(r.extinguish).toBe(true);
expect(r.reason).toContain('hours_limit');
expect(r.at_item).toBe('4.11');
});
it('предела часов не дали — гасим, но владельца ночью НЕ будим: место тут ни при чём', () => {
expect(checkSelfLimits({ ...ok, hours_limit: null }).wake_owner).toBe(false);
expect(checkSelfLimits({ ...ok, hours_limit: NaN }).extinguish).toBe(true);
});
it('место и запас смотрятся РАНЬШЕ потерянного предела часов — порядок не сбит', () => {
// 🔴 Ветка про потерянную настройку встала В СЕРЕДИНУ порядка, поэтому её место
// закреплено с обеих сторон: сверху — место и запас, снизу — отсечка 8:00.
const bezPredela = { ...ok, hours_limit: undefined };
const poMestu = checkSelfLimits({ ...bezPredela, disk: { ok: false, reason: 'Свободное место замерить не удалось.' } });
expect(poMestu.wake_owner).toBe(true);
expect(poMestu.reason).toMatch(/место/i);
const poZapasu = checkSelfLimits({ ...bezPredela, budget: { run_over_limit: true, may_start: true } });
expect(poZapasu.reason).toMatch(/седьмую часть/i);
// А вот отсечка 8:00 уже ПОЗЖЕ: настало и то и другое — сказано про потерянный предел.
const poOtsechke = checkSelfLimits({ ...bezPredela, now: START + 10 * H });
expect(poOtsechke.reason).toContain('hours_limit');
expect(poOtsechke.reason).not.toMatch(/8:00/);
});
it('предел часов смотрится РАНЬШЕ отсечки 8:00: настало и то и другое — сказано про часы', () => {
// Пятый рубеж порядка. Работник выбрал весь свой предел И на дворе уже утро.
// Правильный ответ — «выбрал предел часов»: он свою работу выработал, а не был
// обрублен рассветом.
const r = checkSelfLimits({ ...ok, billable_ms: 8 * H, now: START + 10 * H });
expect(r.extinguish).toBe(true);
expect(r.reason).toMatch(/предел часов/i);
expect(r.reason).not.toMatch(/8:00/);
});
});
// ── решение владельца 37: предел работнику — ЕГО СУТКИ, а не восемь часов ────────────
//
// 🔴 Решение 34 (06.08): «работники работают пока не сделают без остановочно, главное чтобы
// не жгли трупы повисшие». Решение 37 (08.08) исполняет его в числах: предел равен суткам
// владельца (8:01 → 8:00) и становится ПОСЛЕДНЕЙ СЕТЬЮ, а не обрывом работы. Тормозом против
// пустой траты остаётся датчик пустого расхода (`pustoy-rashod.mjs`, решение 29/46/66) —
// его эта правка не касается ни знаком.
//
// 🔴 Отсечка 8:00 (`morningCutoff`) — ПЕРВЫЙ тормоз, и он остаётся: решение 37 отменяет
// второй, наложенный поверх. Проверки отсечки выше не тронуты нарочно.
describe('🔴🔴 решение владельца 37 — предел работнику равен суткам владельца', () => {
const START = Date.parse('2026-07-29T22:00:00.000+03:00');
const zhivoy = {
billable_ms: 1 * H,
hours_limit: zhivayaNastroyka().worker_hours_limit,
budget: { run_over_limit: false, worker_over_limit: false, may_start: true },
disk: { ok: true, reason: null },
worker: { status: 'working', runStartedAt: START },
now: START + 1 * H,
current_item: '4.11',
};
it('🔴 предел в ЖИВОЙ настройке — сутки владельца, а не восемь часов', () => {
// Вернёт кто-нибудь в night.config.json восьмёрку — покраснеет здесь, а не наутро
// в сводке владельца.
expect(zhivayaNastroyka().worker_hours_limit).toBe(24);
});
it('🔴 число суток названо в коде ОДИН раз и совпадает с живой настройкой', () => {
// Запасное число надзирателя и число в файле — это два места, где живёт один предел.
// Разъедутся молча — работник без настройки будет жить по прежней восьмёрке.
expect(PREDEL_CHASOV_SUTKI).toBe(24);
expect(zhivayaNastroyka().worker_hours_limit).toBe(PREDEL_CHASOV_SUTKI);
});
it('🔴 работник, двигающий дело девятый час, под живой настройкой НЕ гаснет (решение 34)', () => {
// Ровно то, что прежние восемь часов обрывали. 22:00 + 9 ч = 7:00 — до отсечки 8:00
// ещё час, значит гасить его было бы нечем, кроме отменённого предела.
const r = checkSelfLimits({ ...zhivoy, billable_ms: 9 * H, now: START + 9 * H });
expect(r.extinguish).toBe(false);
expect(r.reason).toBeNull();
});
it('предел остаётся ПОСЛЕДНЕЙ СЕТЬЮ: сутки отработаны — гаснет', () => {
const r = checkSelfLimits({ ...zhivoy, billable_ms: 24 * H, now: START + 24 * H });
expect(r.extinguish).toBe(true);
expect(r.at_item).toBe('4.11');
});
it('🔴 причина владельцу называет НАСТОЯЩИЙ предел, а не заученные «восемь часов»', () => {
// Текст, ставший ложью, вреден так же, как неверное число: владелец прочтёт «восемь
// часов» и решит, что работника оборвали на восьмом.
const r = checkSelfLimits({ ...zhivoy, billable_ms: 24 * H, now: START + 24 * H });
expect(r.reason).not.toMatch(/восемь часов/i);
expect(r.reason).toMatch(/24/);
expect(r.reason).toMatch(/предел/i);
});
it('🔴 причина «служба не ответила» тоже не говорит про восемь часов', () => {
const r = serviceWaitHit([{ kind: 'waiting_service', ms: 3 * H }]);
expect(r.extinguish).toBe(true);
expect(r.reason).not.toMatch(/восемь часов/i);
});
it('🔴 строка про продолжение не обещает владельцу восьми часов', () => {
const r = resumeChecks({ seal: true, budget: true, disk: true });
expect(r.summary_line).not.toMatch(/восемь часов/i);
expect(r.summary_line).toMatch(/предел часов/i);
});
});
// ── кусок 6, задача 5: трое ворот кнопки «продолжить» ────────────────────────────────
describe('resumeChecks — трое ворот кнопки «продолжить», и больше она не проверяет ничего', () => {
const HOROSHO = { seal: true, budget: true, disk: true };
it('П-12: все три ответа хороши — разрешает и ПРЯМО говорит, что это новый прогон', () => {
const r = resumeChecks(HOROSHO);
expect(r.ok).toBe(true);
expect(r.reason).toBe(null);
// 🔴 «Считается новым прогоном» — это про деньги и часы владельца: предел часов пойдёт
// заново, а запас будет тратиться дальше. Владелец обязан узнать это словами, а не
// догадаться. Молчаливое продолжение «как будто той же ночи» — обман по часам.
expect(r.counts_as_new_run).toBe(true);
expect(r.summary_line).toMatch(/новым прогоном|новый прогон/i);
});
it('П-14: отпечаток плана не сошёлся — отказ, и новым прогоном это НЕ считается', () => {
const r = resumeChecks({ ...HOROSHO, seal: false });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/отпечат|план/i);
expect(r.counts_as_new_run).toBe(false);
expect(r.summary_line).toMatch(/отпечат|план/i);
});
it('П-15: запас не позволяет — отказ, и в причине назван именно запас', () => {
const r = resumeChecks({ ...HOROSHO, budget: false });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/запас/i);
expect(r.counts_as_new_run).toBe(false);
});
it('П-16: места на сервере нет — отказ, и в причине названо именно место', () => {
const r = resumeChecks({ ...HOROSHO, disk: false });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/мест/i);
expect(r.counts_as_new_run).toBe(false);
});
it('П-40: плохи все три — названа ПЕРВАЯ по порядку: отпечаток → место → запас', () => {
// 🔴 Порядок причин — не украшение. Владелец читает ОДНУ строку и по ней решает,
// что чинить. Назови ему «мало запаса», когда на деле сменился план, — и он пойдёт
// покупать запас вместо того, чтобы посмотреть на план.
const vse = resumeChecks({ seal: false, budget: false, disk: false });
expect(vse.reason).toMatch(/отпечат|план/i);
expect(vse.reason).not.toMatch(/запас/i);
// Плох отпечаток, а остаток запаса пришёл ответом «не мерили» — причина всё равно отпечаток.
const nemerili = resumeChecks({ seal: false, budget: null, disk: true });
expect(nemerili.ok).toBe(false);
expect(nemerili.reason).toMatch(/отпечат|план/i);
// И место раньше запаса, когда отпечаток хорош.
const mestoIzapas = resumeChecks({ seal: true, budget: false, disk: false });
expect(mestoIzapas.reason).toMatch(/мест/i);
expect(mestoIzapas.reason).not.toMatch(/запас/i);
});
it('П-15 (продолжение): «не мерили» про запас — это «нельзя», а не «наверное можно»', () => {
// 🔴 Дешёвые ворота не сошлись, платный замер не жёгся — остаток НЕИЗВЕСТЕН.
// Принять неизвестность за разрешение значит поднять работника на пустом запасе.
const r = resumeChecks({ seal: true, disk: true, budget: null });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/запас/i);
expect(r.reason).toMatch(/не мерил|неизвест/i);
expect(r.counts_as_new_run).toBe(false);
});
it('Э7 (сверх плана): ворота не подали вовсе — отказ, а не молчаливое «можно»', () => {
// 🔴 Третий вопрос вырезания второго рода: что придёт, если пришлют не «да» и не «нет»,
// а НИЧЕГО? Зовущий может забыть довод — и правило, написанное через `if (!seal)`,
// на `undefined` отказало бы, а написанное через `seal === false` — разрешило БЫ.
// Отсутствие ответа воротам — это отсутствие разрешения.
for (const chego of ['seal', 'budget', 'disk']) {
const dovody = { ...HOROSHO };
delete dovody[chego];
const r = resumeChecks(dovody);
expect(r.ok, `без ворот ${chego}`).toBe(false);
expect(r.counts_as_new_run).toBe(false);
}
expect(resumeChecks({}).ok).toBe(false);
expect(resumeChecks().ok).toBe(false);
});
it('Э8 (сверх плана): ответ ворот не «да» и не «нет», а посторонний — отказ', () => {
// 🔴 Ловушка истинности: строка «нет», ноль, пустой объект. `if (seal)` на строке «нет»
// отдаёт ИСТИНУ — ворота пропустили бы отказ как разрешение. Здесь слово ворот
// превращается в подъём работника, поэтому годны только настоящие «да» и «нет».
for (const musor of ['нет', 'нельзя', 0, 1, '', 'да', {}, []]) {
const r = resumeChecks({ ...HOROSHO, seal: musor });
if (musor === true) continue;
expect(r.ok, `отпечаток = ${JSON.stringify(musor)}`).toBe(false);
}
});
it('Э9 (сверх плана): список ворот ЗАКРЫТ — четвёртые ворота ничего не решают', () => {
// 🔴 Дословный текст проверки 9 бумаги: «больше кнопка не проверяет ничего». Появится
// в доводах чужое поле — правило обязано его не заметить, а не начать им пользоваться.
// Иначе список тихо разрастётся, и кнопка перестанет срабатывать вообще.
const sChuzhim = resumeChecks({ ...HOROSHO, owner_responded: false, previous_workers_alive: true });
expect(sChuzhim.ok).toBe(true);
const otkazSChuzhim = resumeChecks({ ...HOROSHO, seal: false, owner_responded: true });
expect(otkazSChuzhim.ok).toBe(false);
expect(otkazSChuzhim.reason).toMatch(/отпечат|план/i);
});
it('Э10 (сверх плана): ворота отвечают ПОДРОБНО — своя причина не теряется', () => {
// Живая дорога подаёт не голое «нет», а ответ прибора с причиной словами.
// Потерять её значит сказать владельцу «места нет» вместо «на диске осталось 0.4 ГБ».
const r = resumeChecks({ seal: true, disk: { ok: false, reason: 'на диске осталось 0.4 ГБ' }, budget: true });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/мест/i);
expect(r.reason).toContain('0.4 ГБ');
});
});