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

599 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest';
import {
billableMs, hoursLimitHit, morningCutoff, serviceWaitHit,
SERVICE_WAIT_LIMIT_HOURS, MORNING_CUTOFF_HOUR, checkSelfLimits, resumeChecks,
} from './limits.mjs';
import { moskovskoeVremya } from './budget.mjs';
const H = 3600_000;
const M = 60_000;
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);
});
it('семь часов пятьдесят девять минут — ещё нет', () => {
expect(hoursLimitHit(8 * H - M, 8)).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('выбрал восемь часов — гаснет, и место названо', () => {
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/);
});
});
// ── кусок 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 ГБ');
});
});