Password Policy/App rename/PM Scheduling Improvements
This commit is contained in:
118
server/services/pmScheduling.test.js
Normal file
118
server/services/pmScheduling.test.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user