CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
879 lines
26 KiB
TypeScript
879 lines
26 KiB
TypeScript
import { createOptimizationPresetRules } from './rule-service';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createPlanningCalculation } from './leave-planner.service';
|
|
|
|
const balance = (
|
|
id: string,
|
|
type: 'cp' | 'rtt',
|
|
quantity: number,
|
|
expiryDate: string,
|
|
availableFrom = '2024-01-01',
|
|
) => ({ id, type, quantity, expiryDate, availableFrom });
|
|
|
|
beforeEach(() => vi.stubGlobal('window', undefined));
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
const presets = createOptimizationPresetRules();
|
|
|
|
describe('planner explanation and alternative dates', () => {
|
|
it('delivers only the displayed plan, explanation and state needed for alternative dates', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-01-02', end: '2024-01-10' },
|
|
balances: [balance('cp', 'cp', 1, '2024-01-09')],
|
|
rules: [presets[0]],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
expect(Object.keys(calculation).sort()).toEqual([
|
|
'analyzeCounterfactual',
|
|
'cancel',
|
|
'explanation',
|
|
'explanationError',
|
|
'id',
|
|
'outcome',
|
|
'snapshot',
|
|
'timings',
|
|
]);
|
|
const core = await import('./leave-planner.core');
|
|
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
|
expect(
|
|
core.calculatePlanningInternal(
|
|
calculation.snapshot,
|
|
undefined,
|
|
(value) => (artifacts = value),
|
|
),
|
|
).toEqual(outcome);
|
|
expect(Object.keys(artifacts).sort()).toEqual([
|
|
'explanation',
|
|
'explanationError',
|
|
'finalState',
|
|
'timings',
|
|
]);
|
|
expect(artifacts.finalState?.placements).toEqual(outcome.placements);
|
|
expect(Object.keys(artifacts.timings ?? {})).toEqual(['planningWorkMs']);
|
|
const original = structuredClone(outcome);
|
|
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
|
expect(analysis.results).toHaveLength(8);
|
|
for (const result of analysis.results) {
|
|
expect(result).not.toHaveProperty('historicalStatus');
|
|
expect(result).not.toHaveProperty('historicalReason');
|
|
}
|
|
expect(outcome).toEqual(original);
|
|
});
|
|
|
|
it('excludes observers from the technical budget and keeps the outcome after explanation delivery fails', async () => {
|
|
const core = await import('./leave-planner.core');
|
|
let elapsed = 0;
|
|
const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
|
|
let delivered = false;
|
|
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
|
try {
|
|
const outcome = core.calculatePlanningInternal(
|
|
{
|
|
horizon: { start: '2024-01-02', end: '2024-01-10' },
|
|
balances: [],
|
|
rules: [],
|
|
deadlineMs: 10,
|
|
progress: () => (elapsed += 100),
|
|
},
|
|
() => (elapsed += 500),
|
|
(value) => (artifacts = value),
|
|
() => (delivered = true),
|
|
() => {
|
|
expect(delivered).toBe(true);
|
|
elapsed += 1000;
|
|
throw new Error('Explanation delivery failed.');
|
|
},
|
|
);
|
|
expect(outcome.kind).toBe('Plan');
|
|
expect(artifacts.explanationError).toMatchObject({
|
|
code: 'explanation-error',
|
|
message: 'Explanation delivery failed.',
|
|
});
|
|
expect(artifacts.finalState?.horizon).toEqual({
|
|
start: '2024-01-02',
|
|
end: '2024-01-10',
|
|
});
|
|
expect(artifacts.timings?.planningWorkMs).toBe(0);
|
|
} finally {
|
|
clock.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('keeps earned availability after a reported occurrence is extended', async () => {
|
|
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
|
|
const core = await import('./leave-planner.core');
|
|
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
|
const outcome = core.calculatePlanningInternal(
|
|
{
|
|
horizon: { start: '2024-04-29', end: '2024-05-08' },
|
|
declaredDays: [
|
|
{ date: '2024-04-29', type: 'cp' },
|
|
{ date: '2024-04-30', type: 'cp' },
|
|
{ date: '2024-05-03', type: 'other' },
|
|
],
|
|
balances: [balance('cp', 'cp', 1, '2025-12-31')],
|
|
rules: [
|
|
{
|
|
id: 'producer',
|
|
type: 'if_then',
|
|
force: 'obligation',
|
|
dayOfWeek: 3,
|
|
placement: 'before',
|
|
placeDays: 2,
|
|
placeType: 'cp',
|
|
rewardDays: 1,
|
|
rewardType: 'other',
|
|
},
|
|
{
|
|
id: 'extend',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2024-05-02',
|
|
endDate: '2024-05-02',
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
},
|
|
{
|
|
id: 'consumer',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2024-04-29',
|
|
endDate: '2024-05-07',
|
|
placeDays: 2,
|
|
placeType: 'other',
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
(value) => (artifacts = value),
|
|
);
|
|
if (outcome.kind !== 'Plan' || !artifacts.finalState)
|
|
throw new Error('Expected the extended generator to finance a plan.');
|
|
const earnedId = 'earned:producer:2024-05-01';
|
|
expect(artifacts.finalState.balances).toContainEqual(
|
|
expect.objectContaining({
|
|
id: earnedId,
|
|
availableFrom: '2024-05-06',
|
|
expiryDate: '2025-05-06',
|
|
remaining: 0,
|
|
}),
|
|
);
|
|
expect(outcome.placements.find(({ balanceId }) => balanceId === earnedId)?.date).toBe(
|
|
'2024-05-07',
|
|
);
|
|
expect(artifacts.finalState.placements).toEqual(outcome.placements);
|
|
});
|
|
|
|
it.each([5, 8])(
|
|
'keeps %s unrelated obligations valid without duplicating a reported entitlement',
|
|
async (count) => {
|
|
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
|
|
const core = await import('./leave-planner.core');
|
|
const dates = [
|
|
'2024-04-17',
|
|
'2024-04-18',
|
|
'2024-04-19',
|
|
'2024-04-22',
|
|
'2024-04-23',
|
|
'2024-04-24',
|
|
'2024-04-25',
|
|
'2024-04-26',
|
|
].slice(0, count);
|
|
for (const rewardDays of [0, 1]) {
|
|
const outcome = core.calculatePlanningInternal({
|
|
horizon: { start: '2024-04-17', end: '2024-05-08' },
|
|
declaredDays: [
|
|
{ date: '2024-04-29', type: 'cp' },
|
|
{ date: '2024-04-30', type: 'cp' },
|
|
],
|
|
balances: [
|
|
{
|
|
id: 'other',
|
|
type: 'other',
|
|
quantity: dates.length,
|
|
availableFrom: '2024-04-17',
|
|
expiryDate: '2025-12-31',
|
|
},
|
|
],
|
|
rules: [
|
|
...dates.map((date) => ({
|
|
id: date,
|
|
type: 'time_window' as const,
|
|
force: 'obligation' as const,
|
|
startDate: date,
|
|
endDate: date,
|
|
placeDays: 1,
|
|
placeType: 'other' as const,
|
|
})),
|
|
{
|
|
id: 'producer',
|
|
type: 'if_then',
|
|
force: 'preference',
|
|
dayOfWeek: 3,
|
|
placement: 'before',
|
|
placeDays: 2,
|
|
placeType: 'cp',
|
|
rewardDays,
|
|
rewardType: 'other',
|
|
},
|
|
],
|
|
});
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a completed plan.');
|
|
expect(outcome.placements.map(({ date }) => date)).toEqual(dates);
|
|
expect(outcome.remainingBalances).toEqual([]);
|
|
if (rewardDays)
|
|
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
|
|
availableFrom: '2024-05-02',
|
|
added: false,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
it('keeps reported entitlements out of the final state under explicit priorities', async () => {
|
|
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
|
|
const core = await import('./leave-planner.core');
|
|
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
|
const outcome = core.calculatePlanningInternal(
|
|
{
|
|
horizon: { start: '2024-05-01', end: '2024-05-11' },
|
|
balances: [
|
|
{
|
|
id: 'cp',
|
|
type: 'cp',
|
|
quantity: 1,
|
|
availableFrom: '2024-05-06',
|
|
expiryDate: '2024-05-07',
|
|
},
|
|
],
|
|
declaredDays: [
|
|
{ date: '2024-05-02', type: 'cp' },
|
|
{ date: '2024-05-03', type: 'cp' },
|
|
],
|
|
rules: [
|
|
presets[0],
|
|
...[
|
|
{
|
|
id: 'producer',
|
|
type: 'if_then',
|
|
force: 'preference',
|
|
dayOfWeek: 3,
|
|
placeDays: 2,
|
|
placeType: 'cp',
|
|
placement: 'after',
|
|
rewardDays: 1,
|
|
rewardType: 'other',
|
|
},
|
|
],
|
|
...presets.slice(1),
|
|
],
|
|
},
|
|
undefined,
|
|
(value) => (artifacts = value),
|
|
);
|
|
if (outcome.kind !== 'Plan' || !artifacts.finalState)
|
|
throw new Error('Expected a completed plan.');
|
|
expect(outcome.placements).toMatchObject([{ date: '2024-05-07', balanceId: 'cp' }]);
|
|
expect(outcome.remainingBalances).toEqual([]);
|
|
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
|
|
availableFrom: '2024-05-06',
|
|
added: false,
|
|
});
|
|
});
|
|
|
|
it('associates every joint obligation with its shared placement', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
|
balances: [balance('cp', 'cp', 2, '2024-12-31')],
|
|
rules: [
|
|
{
|
|
id: 'joint',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2024-01-02',
|
|
endDate: '2024-01-05',
|
|
placeDays: 2,
|
|
placeType: 'cp',
|
|
},
|
|
{
|
|
id: 'shared',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2024-01-03',
|
|
endDate: '2024-01-03',
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
},
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
expect(outcome.kind).toBe('Plan');
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
const sharedPlacement = outcome.placements.find(
|
|
(placement) =>
|
|
placement.ruleIds.includes('joint') && placement.ruleIds.includes('shared'),
|
|
);
|
|
expect(sharedPlacement).toBeDefined();
|
|
});
|
|
|
|
it('puts actual obligation validity first in stable plan explanations', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
|
balances: [balance('cp', 'cp', 1, '2025-01-01')],
|
|
rules: [
|
|
{
|
|
id: 'mandatory',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2024-01-03',
|
|
endDate: '2024-01-03',
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
},
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
const explanation = await calculation.explanation;
|
|
expect(outcome.kind).toBe('Plan');
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
expect(explanation?.kind).toBe('PlanExplanation');
|
|
if (explanation?.kind !== 'PlanExplanation') return;
|
|
|
|
const placement = explanation.placements[0];
|
|
expect(placement.objectiveVector.objectives[0]).toMatchObject({
|
|
id: 'obligation-validity',
|
|
direction: 'minimize',
|
|
value: 0,
|
|
});
|
|
expect(placement.comparison.before.objectives[0]).toMatchObject({
|
|
id: 'obligation-validity',
|
|
value: 1,
|
|
});
|
|
expect(placement.comparison.after.objectives[0]).toMatchObject({
|
|
id: 'obligation-validity',
|
|
value: 0,
|
|
});
|
|
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
|
|
});
|
|
|
|
it('uses the earlier-expiring balance for shared ANY preferences', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
|
balances: [
|
|
balance('cp', 'cp', 1, '2025-01-04'),
|
|
balance('rtt-first', 'rtt', 1, '2025-01-05'),
|
|
balance('rtt-second', 'rtt', 1, '2025-01-06'),
|
|
],
|
|
rules: [
|
|
{
|
|
id: 'any-preference',
|
|
type: 'time_window',
|
|
force: 'preference',
|
|
startDate: '2024-01-03',
|
|
endDate: '2024-01-03',
|
|
placeDays: 1,
|
|
placeType: 'any',
|
|
},
|
|
{
|
|
id: 'rtt-preference',
|
|
type: 'time_window',
|
|
force: 'preference',
|
|
startDate: '2024-01-03',
|
|
endDate: '2024-01-03',
|
|
placeDays: 1,
|
|
placeType: 'rtt',
|
|
},
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
expect(outcome.kind).toBe('Plan');
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
|
|
const sharedPlacement = outcome.placements.find((placement) =>
|
|
placement.ruleIds.includes('any-preference'),
|
|
);
|
|
expect(sharedPlacement).toMatchObject({
|
|
date: '2024-01-03',
|
|
balanceId: 'rtt-first',
|
|
ruleIds: ['any-preference', 'rtt-preference'],
|
|
});
|
|
});
|
|
|
|
it('keeps an earned obligation entitlement and its consumer chronological', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-04-25', end: '2024-05-08' },
|
|
balances: [
|
|
balance('cp', 'cp', 1, '2024-12-31'),
|
|
balance('rtt', 'rtt', 0, '2026-01-01'),
|
|
],
|
|
rules: [
|
|
{
|
|
id: 'producer',
|
|
type: 'if_then',
|
|
force: 'obligation',
|
|
dayOfWeek: 3,
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
placement: 'after',
|
|
rewardDays: 1,
|
|
rewardType: 'rtt',
|
|
},
|
|
{
|
|
id: 'consumer',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2024-05-07',
|
|
endDate: '2024-05-07',
|
|
placeDays: 1,
|
|
placeType: 'rtt',
|
|
},
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
expect(outcome.kind).toBe('Plan');
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
|
|
const earned = outcome.placements.find((placement) =>
|
|
placement.balanceId.startsWith('earned:'),
|
|
);
|
|
expect(earned?.ruleIds).toContain('consumer');
|
|
});
|
|
|
|
it('rejects a producer move that delays its earned entitlement past the consumer', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-04-25', end: '2024-05-08' },
|
|
balances: [
|
|
balance('cp', 'cp', 1, '2025-12-31'),
|
|
balance('rtt', 'rtt', 0, '2026-01-01'),
|
|
],
|
|
rules: [
|
|
{
|
|
id: 'producer',
|
|
type: 'if_then',
|
|
force: 'obligation',
|
|
dayOfWeek: 3,
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
placement: 'auto',
|
|
rewardDays: 1,
|
|
rewardType: 'rtt',
|
|
},
|
|
{
|
|
id: 'consumer',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2024-05-03',
|
|
endDate: '2024-05-03',
|
|
placeDays: 1,
|
|
placeType: 'rtt',
|
|
},
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
expect(outcome.kind).toBe('Plan');
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
const producer = outcome.placements.find((placement) =>
|
|
placement.ruleIds.includes('producer'),
|
|
);
|
|
expect(producer?.date).toBe('2024-04-30');
|
|
if (!producer) return;
|
|
|
|
const analysis = await calculation.analyzeCounterfactual(producer);
|
|
expect(analysis.kind).toBe('Counterfactual');
|
|
if (analysis.kind !== 'Counterfactual') return;
|
|
expect(analysis.results.find((result) => result.date === '2024-05-02')).toMatchObject({
|
|
status: 'ineligible',
|
|
reason: { code: 'unavailable' },
|
|
});
|
|
expect(
|
|
analysis.results.find((result) => result.date === '2024-05-02')?.comparison,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('rejects an at-risk move that would delay already-earned leave past its consumer', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-04-25', end: '2024-05-08' },
|
|
balances: [
|
|
balance('cp1', 'cp', 1, '2025-12-31'),
|
|
{ ...balance('cp2', 'cp', 2, '2024-05-07'), availableFrom: '2024-04-26' },
|
|
balance('rtt', 'rtt', 0, '2026-01-01'),
|
|
],
|
|
rules: [
|
|
presets[0],
|
|
...[
|
|
{
|
|
id: 'anchor',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2024-04-25',
|
|
endDate: '2024-04-25',
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
},
|
|
{
|
|
id: 'producer',
|
|
type: 'if_then',
|
|
force: 'obligation',
|
|
dayOfWeek: 3,
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
placement: 'after',
|
|
rewardDays: 1,
|
|
rewardType: 'rtt',
|
|
},
|
|
{
|
|
id: 'consumer',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2024-05-06',
|
|
endDate: '2024-05-06',
|
|
placeDays: 1,
|
|
placeType: 'rtt',
|
|
},
|
|
],
|
|
...presets.slice(1),
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
expect(outcome.kind).toBe('Plan');
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
const atRisk = outcome.placements.find(
|
|
(placement) =>
|
|
placement.balanceId === 'cp2' &&
|
|
placement.ruleIds.every((id) => id.startsWith('default-')),
|
|
);
|
|
expect(atRisk?.date).toBeDefined();
|
|
if (!atRisk) return;
|
|
|
|
const analysis = await calculation.analyzeCounterfactual(atRisk);
|
|
expect(analysis.kind).toBe('Counterfactual');
|
|
if (analysis.kind !== 'Counterfactual') return;
|
|
expect(analysis.results.find((result) => result.date === '2024-05-03')).toMatchObject({
|
|
status: 'ineligible',
|
|
reason: { code: 'unavailable' },
|
|
});
|
|
expect(
|
|
analysis.results.find((result) => result.date === '2024-05-03')?.comparison,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('keeps earned balance metadata consistent when a shared period extends', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
|
balances: [
|
|
balance('cp', 'cp', 3, '2024-12-31'),
|
|
balance('rtt', 'rtt', 0, '2026-01-01'),
|
|
],
|
|
rules: [
|
|
{
|
|
id: 'producer',
|
|
type: 'if_then',
|
|
force: 'obligation',
|
|
dayOfWeek: 3,
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
placement: 'after',
|
|
rewardDays: 1,
|
|
rewardType: 'rtt',
|
|
},
|
|
{
|
|
id: 'cp-preference',
|
|
type: 'time_window',
|
|
force: 'preference',
|
|
startDate: '2024-05-03',
|
|
endDate: '2024-05-03',
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
},
|
|
{
|
|
id: 'rtt-preference',
|
|
type: 'time_window',
|
|
force: 'preference',
|
|
startDate: '2024-05-07',
|
|
endDate: '2024-05-07',
|
|
placeDays: 1,
|
|
placeType: 'rtt',
|
|
},
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
expect(outcome.kind).toBe('Plan');
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
|
|
const earnedBalanceId = outcome.placements.find((placement) =>
|
|
placement.ruleIds.includes('rtt-preference'),
|
|
)?.balanceId;
|
|
expect(earnedBalanceId).toBeDefined();
|
|
});
|
|
|
|
it('compares every horizon date while preserving the selected placement provenance', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-12-27', end: '2025-01-07' },
|
|
balances: [balance('cp', 'cp', 2, '2025-01-03', '2024-12-30')],
|
|
rules: [
|
|
{
|
|
id: 'exact-date',
|
|
type: 'time_window',
|
|
force: 'preference',
|
|
startDate: '2024-12-30',
|
|
endDate: '2024-12-30',
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
},
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
expect(outcome.kind).toBe('Plan');
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
const selected = outcome.placements.find((placement) =>
|
|
placement.ruleIds.includes('exact-date'),
|
|
);
|
|
expect(selected).toBeDefined();
|
|
if (!selected) return;
|
|
const before = structuredClone(outcome);
|
|
const analysis = await calculation.analyzeCounterfactual(selected);
|
|
|
|
expect(analysis.kind).toBe('Counterfactual');
|
|
if (analysis.kind !== 'Counterfactual') return;
|
|
expect(analysis.results.map(({ date }) => date)).toEqual([
|
|
'2024-12-27',
|
|
'2024-12-28',
|
|
'2024-12-29',
|
|
'2024-12-30',
|
|
'2024-12-31',
|
|
'2025-01-01',
|
|
'2025-01-02',
|
|
'2025-01-03',
|
|
'2025-01-04',
|
|
'2025-01-05',
|
|
'2025-01-06',
|
|
]);
|
|
expect(analysis.placement).toEqual(selected);
|
|
expect(analysis.results.find((item) => item.date === '2024-12-27')?.reason?.code).toBe(
|
|
'unavailable',
|
|
);
|
|
expect(analysis.results.find((item) => item.date === '2025-01-01')?.reason?.code).toBe(
|
|
'non-working',
|
|
);
|
|
expect(analysis.results.find((item) => item.date === '2025-01-06')?.reason?.code).toBe(
|
|
'expired',
|
|
);
|
|
for (const item of analysis.results.filter((result) => result.status === 'ineligible'))
|
|
expect(item.comparison).toBeUndefined();
|
|
expect(outcome).toEqual(before);
|
|
});
|
|
|
|
it('keeps typed, joint-date, and earned-order conflicts separate from invalid input', async () => {
|
|
const scenarios = [
|
|
{
|
|
horizon: { start: '2024-04-01', end: '2024-04-06' },
|
|
balances: [balance('rtt', 'rtt', 1, '2025-01-01')],
|
|
rules: [
|
|
{
|
|
id: 'cp-only',
|
|
type: 'time_window' as const,
|
|
force: 'obligation' as const,
|
|
startDate: '2024-04-02',
|
|
endDate: '2024-04-02',
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
horizon: { start: '2024-04-01', end: '2024-04-06' },
|
|
balances: [
|
|
balance('cp', 'cp', 1, '2025-01-01'),
|
|
balance('rtt', 'rtt', 1, '2025-01-01'),
|
|
],
|
|
rules: ['cp', 'rtt'].map((type) => ({
|
|
id: `${type}-same-day`,
|
|
type: 'time_window' as const,
|
|
force: 'obligation' as const,
|
|
startDate: '2024-04-02',
|
|
endDate: '2024-04-02',
|
|
placeDays: 1,
|
|
placeType: type,
|
|
})),
|
|
},
|
|
{
|
|
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
|
balances: [
|
|
balance('cp', 'cp', 2, '2025-01-01'),
|
|
balance('rtt', 'rtt', 3, '2026-01-01'),
|
|
],
|
|
rules: [
|
|
{
|
|
id: 'producer',
|
|
type: 'if_then' as const,
|
|
force: 'obligation' as const,
|
|
dayOfWeek: 3,
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
placement: 'before',
|
|
rewardDays: 1,
|
|
rewardType: 'rtt',
|
|
},
|
|
{
|
|
id: 'consumer',
|
|
type: 'if_then' as const,
|
|
force: 'obligation' as const,
|
|
dayOfWeek: 4,
|
|
placeDays: 4,
|
|
placeType: 'rtt',
|
|
placement: 'before',
|
|
rewardDays: 0,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
const conflictCodes = [
|
|
'typed-capacity',
|
|
'joint-date-conflict',
|
|
'earned-entitlement-bottleneck',
|
|
];
|
|
for (const [index, scenario] of scenarios.entries()) {
|
|
const calculation = createPlanningCalculation(scenario);
|
|
const outcome = await calculation.outcome;
|
|
expect(outcome.kind).toBe('PlanningConflict');
|
|
if (outcome.kind !== 'PlanningConflict') continue;
|
|
expect(outcome.reasons[0].code).toBe(conflictCodes[index]);
|
|
expect((await calculation.explanation)?.kind).toBe('ConflictExplanation');
|
|
}
|
|
|
|
const invalid = createPlanningCalculation({
|
|
horizon: { start: '2025-01-01', end: '2024-01-01' },
|
|
balances: [],
|
|
rules: [],
|
|
});
|
|
expect((await invalid.outcome).kind).toBe('InvalidInput');
|
|
expect(await invalid.explanation).toBeUndefined();
|
|
});
|
|
|
|
it('does not classify a joint-date conflict as earned when only a preference generates leave', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2024-04-01', end: '2024-04-06' },
|
|
balances: [
|
|
balance('cp', 'cp', 1, '2025-01-01'),
|
|
balance('rtt', 'rtt', 1, '2025-01-01'),
|
|
],
|
|
rules: [
|
|
...(['cp', 'rtt'] as const).map((type) => ({
|
|
id: `${type}-required`,
|
|
type: 'time_window' as const,
|
|
force: 'obligation' as const,
|
|
startDate: '2024-04-02',
|
|
endDate: '2024-04-02',
|
|
placeDays: 1,
|
|
placeType: type,
|
|
})),
|
|
{
|
|
id: 'unrelated-reward-preference',
|
|
type: 'if_then',
|
|
force: 'preference',
|
|
dayOfWeek: 3,
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
placement: 'auto',
|
|
rewardDays: 1,
|
|
rewardType: 'rtt',
|
|
},
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
expect(outcome.kind).toBe('PlanningConflict');
|
|
if (outcome.kind === 'PlanningConflict')
|
|
expect(outcome.reasons[0].code).toBe('joint-date-conflict');
|
|
});
|
|
|
|
it('recovers an incumbent explanation from the exact delivered checkpoint state', async () => {
|
|
const core = await import('./leave-planner.core');
|
|
let checkpointPlan: import('./leave-planner.core').PlanOutcome | undefined;
|
|
let checkpointState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
|
|
const request = {
|
|
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
|
balances: [balance('cp', 'cp', 2, '2024-01-31')],
|
|
rules: presets,
|
|
progress: (stage: string) => {
|
|
if (stage === 'optimization:default-holiday-week-value')
|
|
throw new Error('interrupted after checkpoint');
|
|
},
|
|
};
|
|
const outcome = core.calculatePlanningInternal(request, (plan, state) => {
|
|
checkpointPlan = plan;
|
|
checkpointState = state;
|
|
});
|
|
expect(outcome.kind).toBe('PlanningIncomplete');
|
|
expect(checkpointPlan).toBeDefined();
|
|
expect(checkpointState).toBeDefined();
|
|
if (!checkpointPlan || !checkpointState) return;
|
|
|
|
const recovered = core.explainIncompleteCheckpoint(
|
|
request,
|
|
checkpointPlan,
|
|
checkpointState,
|
|
{ code: 'worker-error', message: 'The worker stopped after its checkpoint.' },
|
|
);
|
|
expect(recovered.explanation?.kind).toBe('PlanExplanation');
|
|
if (recovered.explanation?.kind !== 'PlanExplanation') return;
|
|
expect(recovered.explanation.incomplete?.reason.code).toBe('worker-error');
|
|
expect(recovered.explanation.placements).toHaveLength(checkpointPlan.placements.length);
|
|
|
|
let completedState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
|
|
const completed = core.calculatePlanningInternal(
|
|
{ ...request, progress: undefined },
|
|
(plan, state) => {
|
|
if (plan.completedStages.at(-1) === 'dates') completedState = state;
|
|
},
|
|
);
|
|
expect(completed.kind).toBe('Plan');
|
|
if (completed.kind !== 'Plan' || !completedState) return;
|
|
const completedRecovery = core.explainIncompleteCheckpoint(
|
|
{ ...request, progress: undefined },
|
|
completed,
|
|
completedState,
|
|
{ code: 'worker-error', message: 'The result arrived before the worker stopped.' },
|
|
);
|
|
expect(completedRecovery.explanation?.kind).toBe('PlanExplanation');
|
|
if (completedRecovery.explanation?.kind === 'PlanExplanation')
|
|
expect(completedRecovery.explanation.incomplete).toBeUndefined();
|
|
});
|
|
|
|
it('explains incomplete calculations before and after a valid plan', async () => {
|
|
const early = createPlanningCalculation({
|
|
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
|
balances: [balance('cp', 'cp', 1, '2024-01-31')],
|
|
rules: presets,
|
|
deadlineMs: 0,
|
|
});
|
|
const earlyOutcome = await early.outcome;
|
|
expect(earlyOutcome.kind).toBe('PlanningIncomplete');
|
|
if (earlyOutcome.kind === 'PlanningIncomplete')
|
|
expect(earlyOutcome.completedStages).toEqual(['validation']);
|
|
|
|
const quality = createPlanningCalculation({
|
|
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
|
balances: [balance('cp', 'cp', 1, '2024-01-31')],
|
|
rules: presets,
|
|
progress: (stage) => {
|
|
if (stage === 'optimization:default-holiday-week-value') {
|
|
throw new Error('deliberate interruption in quality phase');
|
|
}
|
|
},
|
|
});
|
|
const qualityOutcome = await quality.outcome;
|
|
expect(qualityOutcome.kind).toBe('PlanningIncomplete');
|
|
if (qualityOutcome.kind !== 'PlanningIncomplete') return;
|
|
expect(qualityOutcome.reasons[0].code).toBe('planner-error');
|
|
expect(qualityOutcome.completedStages).toEqual([
|
|
'validation',
|
|
'obligations',
|
|
'optimization:default-unplaced-at-risk',
|
|
]);
|
|
});
|
|
});
|