Files
portal/tools/night/limits.test.mjs
T
Дмитрий 6d095d582c fix: шесть дыр из ворот К2 в правилах ночи — замок, сравнение путей, потерянные пороги, единый час, враньё про полночь, день обновления
1. Битая карточка прогона больше не считается отсутствующей: readRuns различает файл-нет и файл-есть-но-не-читается через readJsonOrBroken, и на нечитаемой карточке замок ЗАКРЫТ. Раньше порванный run.json молча выпадал из списка — замок открывался ровно в ту ночь, когда кончилось место.

2. samePath у замка сведён к поведению samePlanPath у печати через resolve: полный путь владельца и путь от корня хранилища от сторожа теперь один файл. Замерено: lefthook 2.1.8 подставляет staged_files путём ОТ КОРНЯ хранилища.

3. Потерянная настройка больше не читается как можно: нет disk_free_min_gb — отказ; нет hours_limit — hoursLimitHit падает громко, а checkSelfLimits гасит работника с названной причиной.

4. Дорога к московскому часу правда одна: limits берёт час у budget.moskovskoeVremya, своего Intl там больше нет.

5. Запись про полночь-как-24 переписана по замеру: этот Node отдаёт 00. Подчистку сторожит прямая проверка normalizeMskHour, а не случайная зелень.

6. Отказ по запасу без дня обновления и с бессмысленной датой от невиданного прибора теперь виден как сбой, а не как нормальный ответ: renewalDay плюс поле renews_on_problem.

Каждая починка накрыта проверкой, краснеющей при её вырезании. Проверок в четырёх файлах 155, было 108. Полный набор tools: 619 зелёных в 31 файле.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 02:29:40 +03:00

487 lines
30 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,
} 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/);
});
});