/* * Tests for the pure dynamic-PM scheduling math: the lifespan wear curve, usage-rate inference, * usage-threshold projection, and the earliest-wins composite next-due. No DB — plain objects in, * dates out. */ const { ageFraction, computeNextDue, intervalToDays, lifespanFactor, projectUsageDue, usageRatePerDay } = require('./pmScheduling'); describe('pm scheduling — lifespan curve', () => { test('factor is 1 when new and minFactor at end of life', () => { expect(lifespanFactor(0, { minFactor: 0.5, exponent: 2 })).toBeCloseTo(1); expect(lifespanFactor(1, { minFactor: 0.5, exponent: 2 })).toBeCloseTo(0.5); }); test('factor decreases monotonically with age', () => { const early = lifespanFactor(0.25, { minFactor: 0.5, exponent: 2 }); const mid = lifespanFactor(0.5, { minFactor: 0.5, exponent: 2 }); const late = lifespanFactor(0.9, { minFactor: 0.5, exponent: 2 }); expect(early).toBeGreaterThan(mid); expect(mid).toBeGreaterThan(late); }); test('ageFraction clamps to [0,1] and handles missing life', () => { expect(ageFraction(null, 60, '2026-01-01')).toBe(0); expect(ageFraction('2020-01-01', 0, '2026-01-01')).toBe(0); // 12 years old against a 5-year (60mo) life → clamped to 1. expect(ageFraction('2014-01-01', 60, '2026-01-01')).toBe(1); }); }); describe('pm scheduling — usage', () => { test('intervalToDays converts units', () => { expect(intervalToDays(1, 'weeks')).toBe(7); expect(intervalToDays(2, 'months')).toBe(60); expect(intervalToDays(1, 'years')).toBe(365); }); test('usageRatePerDay needs ≥2 increasing readings over time', () => { expect(usageRatePerDay([{ reading: 100, reading_date: '2026-01-01' }])).toBeNull(); const rate = usageRatePerDay([ { reading: 100, reading_date: '2026-01-01' }, { reading: 200, reading_date: '2026-01-11' } ]); expect(rate).toBeCloseTo(10); // 100 units / 10 days }); test('projectUsageDue returns today when already past threshold', () => { const meter = { due_at_reading: 500, last_reading: 520, baseline_reading: 0 }; expect(projectUsageDue(meter, [], '2026-06-06')).toBe('2026-06-06'); }); test('projectUsageDue projects forward from the measured rate', () => { const meter = { due_at_reading: 500, last_reading: 400, baseline_reading: 0 }; const readings = [ { reading: 300, reading_date: '2026-01-01' }, { reading: 400, reading_date: '2026-01-11' } // 10/day → 100 remaining ≈ 10 days ]; expect(projectUsageDue(meter, readings, '2026-06-06')).toBe('2026-06-16'); }); }); describe('pm scheduling — composite next-due (earliest wins)', () => { const asset = { in_service_date: '2026-01-01', useful_life_months: 60 }; test('falls back to the calendar date when signals are disabled', () => { const out = computeNextDue( { lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: true }, asset, [], { usage_enabled: false, curve_enabled: false }, '2026-06-06' ); // 6 months ≈ 180 days out, no compression. expect(out.next_due_date).toBe('2026-12-03'); expect(out.driver).toBe('calendar'); }); test('a fast usage rate pulls the due date in before the calendar date', () => { const meters = [{ usage_interval: 500, due_at_reading: 500, last_reading: 450, baseline_reading: 0, readings: [ { reading: 0, reading_date: '2026-05-01' }, { reading: 450, reading_date: '2026-06-05' } // ~12.8/day → ~4 days to 500 ] }]; const out = computeNextDue( { lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: false }, asset, meters, { usage_enabled: true, curve_enabled: false }, '2026-06-06' ); expect(out.driver).toBe('usage'); expect(out.next_due_date < '2026-12-03').toBe(true); }); test('the lifespan curve compresses the interval for an aged asset', () => { const old = { in_service_date: '2021-06-06', useful_life_months: 60 }; // ~5yr old → end of life const settings = { usage_enabled: false, curve_enabled: true, min_factor: 0.5, curve_exponent: 1 }; const fresh = computeNextDue( { lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: true }, asset, [], settings, '2026-06-06' ); const aged = computeNextDue( { lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: true }, old, [], settings, '2026-06-06' ); expect(aged.next_due_date < fresh.next_due_date).toBe(true); expect(aged.driver).toBe('lifespan'); }); test('lifespan compression is ignored when the schedule opts out', () => { const old = { in_service_date: '2021-06-06', useful_life_months: 60 }; const settings = { usage_enabled: false, curve_enabled: true, min_factor: 0.5, curve_exponent: 1 }; const out = computeNextDue( { lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: false }, old, [], settings, '2026-06-06' ); expect(out.driver).toBe('calendar'); expect(out.signals.factor).toBe(1); }); });