import { activatedAt, boolToTriState, effectiveSlaHours, formatSlaHours, isRowOverdue, isValidSlaHours, parseSlaHours, slaHoursToInput, triStateToBool } from './sla'; import { APPROVAL_PHASE } from '../models'; // Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния. // Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет // неотличим от шага без настройки, и напоминания по нему просто не придут. describe('parseSlaHours', () => { it('пустая строка — «не задано», а не ноль', () => { expect(parseSlaHours('')).toBeNull(); expect(parseSlaHours(' ')).toBeNull(); }); it('ноль остаётся нулём', () => { expect(parseSlaHours('0')).toBe(0); }); it('число разбирается', () => { expect(parseSlaHours('24')).toBe(24); expect(parseSlaHours(' 48 ')).toBe(48); }); it('мусор не превращается в ноль', () => { expect(parseSlaHours('abc')).toBeNull(); expect(parseSlaHours('-5')).toBeNull(); expect(parseSlaHours('1.5')).toBeNull(); }); }); describe('isValidSlaHours', () => { it('пусто — допустимо, это «не задано»', () => { expect(isValidSlaHours('')).toBe(true); }); it('ноль и целые положительные — допустимы', () => { expect(isValidSlaHours('0')).toBe(true); expect(isValidSlaHours('24')).toBe(true); }); it('отрицательное, дробное и нечисловое — недопустимы', () => { expect(isValidSlaHours('-1')).toBe(false); expect(isValidSlaHours('1.5')).toBe(false); expect(isValidSlaHours('abc')).toBe(false); }); }); describe('slaHoursToInput', () => { it('ноль показывается нулём, а не пустотой', () => { expect(slaHoursToInput(0)).toBe('0'); }); it('«не задано» показывается пустым полем', () => { expect(slaHoursToInput(null)).toBe(''); expect(slaHoursToInput(undefined)).toBe(''); }); }); describe('formatSlaHours', () => { it('ноль часов остаётся нулём, а не прочерком', () => { expect(formatSlaHours(0)).toBe('0 ч'); }); it('не задано — прочерк', () => { expect(formatSlaHours(null)).toBe('—'); }); }); describe('triState', () => { it('false и «не задано» — разные значения', () => { expect(triStateToBool('no')).toBe(false); expect(triStateToBool('')).toBeNull(); }); it('преобразование в обе стороны сохраняет все три состояния', () => { expect(boolToTriState(true)).toBe('yes'); expect(boolToTriState(false)).toBe('no'); expect(boolToTriState(null)).toBe(''); expect(triStateToBool(boolToTriState(false))).toBe(false); expect(triStateToBool(boolToTriState(null))).toBeNull(); }); }); describe('effectiveSlaHours', () => { it('своё значение строки перекрывает тип', () => { expect(effectiveSlaHours({ slaHours: 8 }, 48)).toBe(8); }); it('0 на строке — это не "не задано", тип не подставляется', () => { expect(effectiveSlaHours({ slaHours: 0 }, 48)).toBe(0); }); it('строка не задана — берётся SLA типа', () => { expect(effectiveSlaHours({ slaHours: null }, 48)).toBe(48); }); it('не задано нигде — null', () => { expect(effectiveSlaHours({ slaHours: null }, null)).toBeNull(); }); }); // Повторяет ReminderService.ActivatedAt на бэкенде — та же формула, поэтому и // проверяется на тех же случаях (параллельные шаги, переход между фазами). describe('activatedAt', () => { const row = (order: number, phase: string, dateIso: string) => ({ order, phase, dateIso }); it('первый шаг согласования — от создания заявки', () => { const rows = [row(1, APPROVAL_PHASE.approval, '')]; const result = activatedAt(rows[0], rows, '2026-08-01T00:00:00Z'); expect(result?.toISOString()).toBe('2026-08-01T00:00:00.000Z'); }); it('второй шаг — от решения предыдущего, а не от создания заявки', () => { const rows = [ row(1, APPROVAL_PHASE.approval, '2026-08-10T12:00:00Z'), row(2, APPROVAL_PHASE.approval, '') ]; const result = activatedAt(rows[1], rows, '2026-01-01T00:00:00Z'); expect(result?.toISOString()).toBe('2026-08-10T12:00:00.000Z'); }); it('параллельный предыдущий шаг — от ПОСЛЕДНЕГО решения, а не от первого', () => { const rows = [ row(1, APPROVAL_PHASE.approval, '2026-08-10T08:00:00Z'), row(1, APPROVAL_PHASE.approval, '2026-08-10T20:00:00Z'), row(2, APPROVAL_PHASE.approval, '') ]; const result = activatedAt(rows[2], rows, '2026-01-01T00:00:00Z'); expect(result?.toISOString()).toBe('2026-08-10T20:00:00.000Z'); }); it('первый шаг исполнения — от последнего решения фазы согласования', () => { const rows = [ row(1, APPROVAL_PHASE.approval, '2026-08-10T08:00:00Z'), row(2, APPROVAL_PHASE.approval, '2026-08-11T09:00:00Z'), row(1, APPROVAL_PHASE.execution, '') ]; const result = activatedAt(rows[2], rows, '2026-01-01T00:00:00Z'); expect(result?.toISOString()).toBe('2026-08-11T09:00:00.000Z'); }); it('предыдущий шаг ещё не решён — активации нет (undefined)', () => { const rows = [ row(1, APPROVAL_PHASE.approval, ''), row(2, APPROVAL_PHASE.approval, '') ]; expect(activatedAt(rows[1], rows, '2026-01-01T00:00:00Z')).toBeUndefined(); }); }); describe('isRowOverdue', () => { const now = new Date('2026-08-12T12:00:00Z'); it('срок вышел — просрочено', () => { const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: 24 as number | null }]; expect(isRowOverdue(rows[0], rows, '2026-08-10T00:00:00Z', null, now)).toBe(true); }); it('срок не вышел — не просрочено', () => { const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: 24 as number | null }]; expect(isRowOverdue(rows[0], rows, '2026-08-12T00:00:00Z', null, now)).toBe(false); }); it('SLA не настроен нигде — не просрочено, хотя заявка старая', () => { const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: null as number | null }]; expect(isRowOverdue(rows[0], rows, '2020-01-01T00:00:00Z', null, now)).toBe(false); }); it('SLA типа используется, если на строке не задан', () => { const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: null as number | null }]; expect(isRowOverdue(rows[0], rows, '2026-08-10T00:00:00Z', 24, now)).toBe(true); }); });