119 lines
5.1 KiB
JavaScript
119 lines
5.1 KiB
JavaScript
/*
|
|
* 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);
|
|
});
|
|
});
|