CI / policy (push) Successful in 28s
CI / validate (push) Successful in 46s
CI / build (push) Successful in 25s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 26s
CI / benchmark (push) Successful in 1m28s
CI / security (push) Successful in 1m11s
CI / build (pull_request) Successful in 23s
CI / test (push) Successful in 2m27s
CI / benchmark (pull_request) Successful in 1m33s
CI / security (pull_request) Successful in 1m0s
CI / images (push) Successful in 1m0s
CI / required (push) Successful in 1s
CI / test (pull_request) Successful in 2m6s
CI / images (pull_request) Successful in 54s
CI / required (pull_request) Successful in 1s
Since assigning a date shares unchanged activations with the parent state, counterfactual analysis edited an activation it shared with the final plan, so a later alternative date could inherit an earlier one's dates and be judged eligible. It now replaces the moved activation. Every counterfactual over seeds 280000-289999 matches the engine before the sharing step again. When every one of several obligation solutions fails earned-entitlement chronology, the planner again reports the obligation conflict, as when every preview failed. No seeded request reaches that path, so the frozen engine is unchanged. Also marks the ranking's known limit as a shortcut and fixes stale documentation found in review. Part of #302. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
927 lines
27 KiB
TypeScript
927 lines
27 KiB
TypeScript
import { createOptimizationPresetRules } from './rule-service';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
import { vi } from '../../testing/vitest-compat';
|
|
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('judges each alternative producer date against the unchanged final plan', async () => {
|
|
const calculation = createPlanningCalculation({
|
|
horizon: { start: '2026-04-20', end: '2026-05-31' },
|
|
balances: [balance('cp', 'cp', 2, '2026-12-31', '2026-04-20')],
|
|
rules: [
|
|
{
|
|
id: 'friday',
|
|
type: 'if_then',
|
|
force: 'preference',
|
|
dayOfWeek: 5,
|
|
placeDays: 1,
|
|
placeType: 'cp',
|
|
placement: 'auto',
|
|
rewardDays: 1,
|
|
rewardType: 'rtt',
|
|
},
|
|
{
|
|
id: 'consumer',
|
|
type: 'time_window',
|
|
force: 'obligation',
|
|
startDate: '2026-05-12',
|
|
endDate: '2026-05-12',
|
|
placeDays: 1,
|
|
placeType: 'rtt',
|
|
},
|
|
],
|
|
});
|
|
const outcome = await calculation.outcome;
|
|
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
|
const producer = outcome.placements.find(
|
|
(placement) => placement.date === '2026-04-30' && placement.ruleIds.includes('friday'),
|
|
);
|
|
expect(producer).toBeDefined();
|
|
if (!producer) return;
|
|
|
|
const analysis = await calculation.analyzeCounterfactual(producer);
|
|
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual.');
|
|
// 2026-05-04 is evaluated first; the move to 2026-05-11 must not inherit its dates.
|
|
expect(analysis.results.find((result) => result.date === '2026-05-04')?.status).toBe(
|
|
'eligible',
|
|
);
|
|
expect(analysis.results.find((result) => result.date === '2026-05-11')).toMatchObject({
|
|
status: 'ineligible',
|
|
reason: { code: 'unavailable' },
|
|
});
|
|
});
|
|
|
|
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',
|
|
]);
|
|
});
|
|
});
|