feat(planner): implement cancellation for planning calculations
- Refactor leave-planner.worker.ts to support cancellation of ongoing calculations. - Introduce `calculatePlanningAsyncInternal` for asynchronous planning calculations. - Add cancellation handling in the worker message event. - Update planning-date.ts with new utility functions for date manipulation. - Extend translation-service.ts and translation files (en.json, de.json, es.json, fr.json) to include new strings related to planning horizon and cancellation. - Create documentation for the cancellation feature in planner-cancellation.md. - Add unit tests for cancellation functionality in leave-planner-cancellation.spec.ts and leave-planner.worker.spec.ts. #57 #58
This commit is contained in:
1 parent
5c35a59805
commit
2edf2004f5
31 files changed
+2275
-304
No files matched your search
@@ -46,6 +46,68 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
|
||||
}
|
||||
|
||||
describe('planLeave', () => {
|
||||
it.each([
|
||||
['one leap day', '2024-02-29', '2024-03-01', '2024-02-29', '2024-02-29', '2024-02-29'],
|
||||
['historical day', '2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'],
|
||||
['two years', '2024-01-01', '2026-01-01', '2025-12-24', '2025-12-24', '2025-12-25'],
|
||||
['three years', '2024-01-01', '2027-01-01', '2026-12-24', '2026-12-24', '2026-12-27'],
|
||||
])(
|
||||
'plans a light %s horizon with dated provenance and French holidays',
|
||||
async (_, start, end, placementDate, periodStart, periodEnd) => {
|
||||
const result = isPlan(
|
||||
await runInNonBrowser({
|
||||
horizon: { start, end },
|
||||
balances: [
|
||||
{
|
||||
id: 'expired',
|
||||
type: 'rtt',
|
||||
quantity: 2,
|
||||
availableFrom: '2020-01-01',
|
||||
expiryDate: '2020-12-31',
|
||||
},
|
||||
{
|
||||
id: 'dated-cp',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: placementDate,
|
||||
expiryDate: placementDate,
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'chosen-date',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: placementDate,
|
||||
endDate: placementDate,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.horizon).toEqual({ start, end });
|
||||
expect(result.placements).toEqual([
|
||||
{
|
||||
date: placementDate,
|
||||
balanceId: 'dated-cp',
|
||||
type: 'cp',
|
||||
ruleIds: ['chosen-date'],
|
||||
supporting: false,
|
||||
},
|
||||
]);
|
||||
expect(result.timeOffPeriods).toHaveLength(1);
|
||||
expect(result.timeOffPeriods[0]).toMatchObject({ start: periodStart, end: periodEnd });
|
||||
expect(result.remainingBalances.find((item) => item.id === 'expired')).toMatchObject({
|
||||
quantity: 2,
|
||||
type: 'rtt',
|
||||
availableFrom: '2020-01-01',
|
||||
expiryDate: '2020-12-31',
|
||||
classification: 'unavoidable-expiry',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects malformed horizons before calculation', async () => {
|
||||
const result = await planLeave({
|
||||
horizon: { start: '2025-01-01', end: '2024-01-01' },
|
||||
@@ -57,6 +119,18 @@ describe('planLeave', () => {
|
||||
if (result.kind === 'InvalidInput') expect(result.reasons[0].code).toBe('horizon-order');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2024-02-30', '2025-01-01', 'horizon-start'],
|
||||
['2024-13-01', '2025-01-01', 'horizon-start'],
|
||||
['2024-01-01', '2024-00-01', 'horizon-end'],
|
||||
['not-a-date', '2025-01-01', 'horizon-start'],
|
||||
])('reports an invalid horizon date without throwing: %s to %s', async (start, end, code) => {
|
||||
const outcome = await planLeave({ horizon: { start, end }, balances: [], rules: [] });
|
||||
expect(outcome.kind).toBe('InvalidInput');
|
||||
if (outcome.kind === 'InvalidInput')
|
||||
expect(outcome.reasons.map((item) => item.code)).toContain(code);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'invalid balance quantity',
|
||||
@@ -620,6 +694,7 @@ describe('planLeave', () => {
|
||||
expect(result.timeOffPeriods[0].duration).toBe(9);
|
||||
});
|
||||
|
||||
// Async calculation yields to the test timer; allow the planner's ten-second budget.
|
||||
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
|
||||
const result = isPlan(
|
||||
await runInNonBrowser({
|
||||
@@ -677,7 +752,7 @@ describe('planLeave', () => {
|
||||
'2027-07-16',
|
||||
]),
|
||||
);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it('moves a satisfied time-window preference to complete a holiday week', async () => {
|
||||
const result = isPlan(
|
||||
@@ -1411,6 +1486,56 @@ describe('planLeave transport', () => {
|
||||
});
|
||||
|
||||
describe('planning calculation context', () => {
|
||||
it('keeps a multi-year horizon attached to its immutable outcome, explanation and original trace', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2026-12-24',
|
||||
expiryDate: '2026-12-24',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'final-year',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2026-12-24',
|
||||
endDate: '2026-12-24',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
};
|
||||
const calculation = createPlanningCalculation(request);
|
||||
request.horizon.end = '2025-01-01';
|
||||
request.balances[0].expiryDate = '2024-12-31';
|
||||
expect(calculation.snapshot.horizon).toEqual({
|
||||
start: '2024-01-01',
|
||||
end: '2027-01-01',
|
||||
});
|
||||
expect(isPlan(await calculation.outcome).horizon).toEqual(calculation.snapshot.horizon);
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind === 'PlanExplanation')
|
||||
expect(explanation.placements[0]).toMatchObject({
|
||||
placement: { date: '2026-12-24', balanceId: 'cp', ruleIds: ['final-year'] },
|
||||
balance: { availableFrom: '2026-12-24', expiryDate: '2026-12-24' },
|
||||
});
|
||||
expect((await calculation.trace)?.initialState.horizon).toEqual(
|
||||
calculation.snapshot.horizon,
|
||||
);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('replays full balance metadata when an existing balance changes', () => {
|
||||
const state = {
|
||||
balances: [
|
||||
|
||||
Reference in new issue
Block a user