Files
vac-optimizer/src/app/services/calendar-service.spec.ts
T
nessarandClaude Opus 5.5 cee67ce3df
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m2s
CI / build (push) Successful in 1m27s
CI / policy (pull_request) Successful in 11s
CI / security (push) Successful in 1m12s
CI / validate (pull_request) Successful in 1m21s
CI / benchmark (push) Successful in 4m10s
CI / test (push) Successful in 4m19s
CI / security (pull_request) Successful in 1m3s
CI / test (pull_request) Successful in 3m23s
CI / images (push) Successful in 1m34s
CI / build (pull_request) Successful in 34s
CI / required (push) Successful in 3s
CI / benchmark (pull_request) Successful in 7m13s
CI / images (pull_request) Successful in 1m56s
CI / required (pull_request) Successful in 2s
refactor(calendar): drop the unused projection horizon
No caller read CalendarProjection.horizon, and with fixed months it no
longer described the listed months.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:36:19 +02:00

390 lines
14 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import {
CalendarService,
calendarProjectionHorizon,
DayType,
SelectedDate,
SelectedDates,
} from './calendar-service';
import { DateRange } from '@angular/material/datepicker';
describe('CalendarService', () => {
let service: CalendarService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
service = TestBed.inject(CalendarService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('derives the planning horizon from the instant in Europe/Paris', () => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Pacific/Auckland';
try {
expect(service.getDefaultPlanningHorizon(new Date('2026-08-28T01:00:00Z'))).toEqual({
start: '2026-08-28',
end: '2027-08-28',
});
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
}
});
it.each([
['2026-03-28', '2026-03-30', '2026-03-31'],
['2026-10-24', '2026-10-26', '2026-10-27'],
])(
'groups and counts calendar dates across the clock change starting %s',
(periodStart, date, end) => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Europe/Paris';
try {
const { selections: selected } = service.project({
horizon: { start: date, end },
placements: [
{ date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
],
});
const day = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.isStart(day(periodStart))).toBe(true);
expect(selected.isEnd(day(periodStart))).toBe(false);
expect(selected.isStart(day(date))).toBe(false);
expect(selected.isEnd(day(date))).toBe(true);
expect(selected.isSelected(day(end))).toBe(false);
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
}
},
);
it('projects adjacent non-working dates into a preceding month without moving leave outside the horizon', () => {
const horizon = { start: '2024-04-02', end: '2024-04-03' };
const { selections: selected } = service.project({
horizon,
placements: [
{ date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
],
timeOffPeriods: [
{
start: '2024-03-30',
end: '2024-04-02',
duration: 4,
placementDates: ['2024-04-02'],
nonWorkingDates: ['2024-03-30', '2024-03-31', '2024-04-01'],
nonWorkingDayGain: 3,
},
],
});
expect(selected.getClasses(new Date('2024-03-30T00:00:00'))).toContain(DayType.WEEKEND);
expect(selected.getClasses(new Date('2024-04-01T00:00:00'))).toContain(DayType.CLOSED_DAY);
expect(selected.getClasses(new Date('2024-04-02T00:00:00'))).toContain(DayType.CP);
expect(selected.getClasses(new Date('2024-04-03T00:00:00'))).not.toContain(DayType.CP);
expect(horizon).toEqual({ start: '2024-04-02', end: '2024-04-03' });
});
it('projects a declared-only period and adjacent outside declarations while leaving unrelated declarations unselected', async () => {
const { calculatePlanningInternal } = await import('./leave-planner.core');
const horizon = { start: '2024-05-31', end: '2024-06-01' };
const outcome = calculatePlanningInternal({
horizon,
balances: [],
rules: [],
declaredDays: [
{ date: '2024-05-31', type: 'other' },
{ date: '2024-06-03', type: 'cp' },
{ date: '2024-06-05', type: 'rtt' },
],
});
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
const selected = service.project(outcome).selections;
const date = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.getClasses(date('2024-05-31'))).toContain(DayType.OTHER);
expect(selected.getClasses(date('2024-06-01'))).toContain(DayType.WEEKEND);
expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP);
expect(selected.isSelected(date('2024-06-05'))).toBe(false);
expect(selected.isStart(date('2024-05-31'))).toBe(true);
expect(selected.isEnd(date('2024-06-03'))).toBe(true);
expect(selected.isEnd(date('2024-06-02'))).toBe(false);
expect(outcome.placements).toEqual([]);
expect(outcome.horizon).toEqual(horizon);
});
it('projects historical declarations from the actual anchors instead of the final plan periods', () => {
const horizon = { start: '2024-07-30', end: '2024-08-01' };
const declaredDays = [
{ date: '2024-08-01', type: 'cp' as const },
{ date: '2024-08-05', type: 'rtt' as const },
];
const before = { horizon, declaredDays, placements: [] };
expect(calendarProjectionHorizon(before)).toEqual(horizon);
expect(service.project(before).selections.isSelected(new Date('2024-08-01'))).toBe(false);
const after = {
...before,
placements: [
{
date: '2024-07-31',
balanceId: 'rtt',
type: 'rtt' as const,
ruleIds: [],
supporting: false,
},
],
};
const selected = service.project(after).selections;
expect(calendarProjectionHorizon(after)).toEqual({
start: horizon.start,
end: '2024-08-02',
});
expect(selected.getClasses(new Date('2024-08-01T00:00:00'))).toContain(DayType.CP);
expect(selected.isSelected(new Date('2024-08-05T00:00:00'))).toBe(false);
expect(horizon).toEqual({ start: '2024-07-30', end: '2024-08-01' });
});
describe('getHolidays', () => {
it('should return an array of holidays', () => {
const holidays = service.getHolidays();
expect(Array.isArray(holidays)).toBe(true);
expect(holidays.length).toBeGreaterThan(0);
});
it('should cache holidays for the same year', () => {
const holidays1 = service.getHolidays();
const holidays2 = service.getHolidays();
expect(holidays1).toEqual(holidays2);
});
});
describe('static date calculation methods', () => {
it('should calculate days in a week', () => {
const week = CalendarService.daysInWeek(0, 0, 2024);
expect(week.length).toBe(7);
});
it('should calculate weeks in a month', () => {
const weeks = CalendarService.weeksInMonth(0, 2024);
expect(weeks.length).toBeGreaterThan(0);
expect(weeks.length).toBeLessThanOrEqual(6);
});
it('should calculate months in a year', () => {
const months = CalendarService.monthsInYear(2024);
expect(months.length).toBe(12);
});
it('should calculate months from now', () => {
const months = service.monthsInAYearFromNow();
expect(months.length).toBe(12);
});
it('should return week number for a date', () => {
const date = new Date(2024, 0, 1);
const weekNumber = CalendarService.getWeekNumber(date);
expect(weekNumber).toBeGreaterThan(0);
expect(weekNumber).toBeLessThanOrEqual(53);
});
});
describe('project', () => {
const project = (horizon: { start: string; end: string }) =>
service.project({ placements: [], horizon });
const day = (key: string) => new Date(`${key}T00:00:00`);
it('projects weekends, holidays and today for the default planning horizon', () => {
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY);
const horizon = service.getDefaultPlanningHorizon();
expect(project(horizon).selections.getClasses(day(horizon.start))).toContain(
DayType.TODAY,
);
});
it('lists the covered months, including partial boundary months and year changes', () => {
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
{ monthIndex: 10, year: 2024 },
{ monthIndex: 11, year: 2024 },
{ monthIndex: 0, year: 2025 },
{ monthIndex: 1, year: 2025 },
]);
});
it('keeps fixed months when an edge declaration widens the projection', () => {
const plan = {
placements: [],
horizon: { start: '2024-07-01', end: '2025-01-04' },
declaredDays: [{ date: '2024-07-01', type: 'cp' as const }],
};
const months = (projection: ReturnType<typeof service.project>) =>
projection.months.map(({ monthIndex, year }) => `${year}-${monthIndex}`);
expect(months(service.project(plan))[0]).toBe('2024-5');
const fixed = service.project(plan, { fixedMonths: true });
expect(months(fixed)).toEqual([
'2024-6',
'2024-7',
'2024-8',
'2024-9',
'2024-10',
'2024-11',
'2025-0',
]);
expect(fixed.selections.dayTypes(day('2024-06-30'))).toContain(DayType.WEEKEND);
expect(fixed.selections.isStart(day('2024-07-01'))).toBe(false);
});
it('includes the final partial month of a half-open one-year horizon', () => {
const { selections } = project({ start: '2024-01-15', end: '2025-01-15' });
expect(selections.dayTypes(day('2025-01-04'))).toContain(DayType.WEEKEND);
});
it('projects holidays in every covered year and in both partial boundary months', () => {
const { selections } = project({ start: '2023-12-26', end: '2027-01-03' });
for (const date of [
'2023-12-25',
'2024-04-01',
'2025-04-21',
'2026-04-06',
'2027-01-01',
]) {
expect(selections.dayTypes(day(date))).toContain(DayType.CLOSED_DAY);
}
expect(selections.dayTypes(day('2027-01-02'))).toContain(DayType.WEEKEND);
});
it('does not mutate the submitted plan or its horizon', () => {
const plan = {
horizon: { start: '2024-04-02', end: '2024-04-03' },
placements: [
{
date: '2024-04-02',
balanceId: 'cp',
type: 'cp' as const,
ruleIds: [],
supporting: false,
},
],
declaredDays: [{ date: '2024-04-03', type: 'rtt' as const }],
};
const before = structuredClone(plan);
const frozen = JSON.parse(JSON.stringify(plan), (_, value) =>
typeof value === 'object' && value ? Object.freeze(value) : value,
);
const projection = service.project(frozen);
expect(projection.selections.dayTypes(new Date('2024-04-03T00:00:00'))).toContain(
DayType.RTT,
);
expect(frozen).toEqual(before);
});
});
});
describe('SelectedDate', () => {
it('should check if date is selected', () => {
const date = new Date(2024, 0, 1);
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(date, date));
expect(selectedDate.isSelected(date)).toBe(true);
});
it('should check if date is start', () => {
const date = new Date(2024, 0, 1);
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(date, date));
expect(selectedDate.isStart(date)).toBe(true);
});
it('should check if date is end', () => {
const date = new Date(2024, 0, 1);
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(date, date));
expect(selectedDate.isEnd(date)).toBe(true);
});
it('should return correct classes for date', () => {
const date = new Date(2024, 0, 1);
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(date, date));
const classes = selectedDate.getClasses(date);
expect(classes).toContain(DayType.CP);
});
it('should calculate length in days', () => {
const startDate = new Date(2024, 0, 1);
const endDate = new Date(2024, 0, 3);
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(startDate, endDate));
expect(selectedDate.getLengthInDays()).toBe(3);
});
it('should not mark TODAY as selected', () => {
const date = new Date(2024, 0, 1);
const selectedDate = new SelectedDate(DayType.TODAY, new DateRange<Date>(date, date));
expect(selectedDate.isSelected(date)).toBe(false);
});
});
describe('SelectedDates', () => {
const range = (type: DayType, start: Date, end = start) =>
new SelectedDate(type, new DateRange<Date>(start, end));
it('selects covered dates and reports their day types', () => {
const date = new Date(2024, 0, 1);
const selectedDates = new SelectedDates([range(DayType.CP, date)]);
expect(selectedDates.isSelected(date)).toBe(true);
expect(selectedDates.dayTypes(date)).toEqual([DayType.CP]);
expect(selectedDates.getClasses(date)).toContain(DayType.CP);
});
it('treats adjacent ranges as one period with shared boundaries', () => {
const first = new Date(2024, 0, 1);
const second = new Date(2024, 0, 2);
const third = new Date(2024, 0, 3);
const selectedDates = new SelectedDates([
range(DayType.CP, first),
range(DayType.RTT, second),
range(DayType.OTHER, third),
]);
expect(selectedDates.isStart(first)).toBe(true);
expect(selectedDates.isEnd(first)).toBe(false);
expect(selectedDates.isStart(second)).toBe(false);
expect(selectedDates.isEnd(second)).toBe(false);
expect(selectedDates.isEnd(third)).toBe(true);
expect(selectedDates.dayTypes(second)).toEqual([DayType.RTT]);
});
it('keeps separated ranges as separate periods', () => {
const first = new Date(2024, 0, 1);
const later = new Date(2024, 0, 5);
const selectedDates = new SelectedDates([
range(DayType.CP, first),
range(DayType.CP, later),
]);
expect(selectedDates.isStart(later)).toBe(true);
expect(selectedDates.isEnd(first)).toBe(true);
});
it('merges overlapping ranges and keeps every overlapping day type', () => {
const start = new Date(2024, 0, 1);
const overlap = new Date(2024, 0, 2);
const end = new Date(2024, 0, 3);
const selectedDates = new SelectedDates([
range(DayType.WEEKEND, start, overlap),
range(DayType.CLOSED_DAY, overlap, end),
]);
expect(selectedDates.dayTypes(overlap)).toEqual([DayType.WEEKEND, DayType.CLOSED_DAY]);
expect(selectedDates.isStart(start)).toBe(true);
expect(selectedDates.isEnd(end)).toBe(true);
expect(selectedDates.isStart(overlap)).toBe(false);
});
it('does not select today', () => {
const date = new Date(2024, 0, 1);
const selectedDates = new SelectedDates([range(DayType.TODAY, date)]);
expect(selectedDates.isSelected(date)).toBe(false);
expect(selectedDates.dayTypes(date)).toEqual([]);
expect(selectedDates.getClasses(date)).toContain(DayType.TODAY);
});
});