CI / policy (push) Successful in 26s
CI / validate (push) Successful in 28s
CI / build (push) Successful in 23s
CI / benchmark (push) Successful in 1m26s
CI / security (push) Successful in 1m14s
CI / test (push) Successful in 1m58s
CI / images (push) Successful in 57s
CI / required (push) Successful in 3s
CI / policy (pull_request) Failing after 10s
CI / validate (pull_request) Successful in 26s
CI / build (pull_request) Successful in 21s
CI / benchmark (pull_request) Successful in 1m0s
CI / security (pull_request) Successful in 58s
CI / test (pull_request) Successful in 1m52s
CI / images (pull_request) Skipped
CI / required (pull_request) Failing after 1s
The test of the today marker read the wall clock, so it only exercised the Paris/local date mismatch between 22:00 and 24:00 UTC and passed with or without the fix the rest of the day. Pin the clock to 22:16 UTC, already the next day in Paris, so a UTC runner fails without the fix at any time. Refs #301 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
402 lines
14 KiB
TypeScript
402 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';
|
|
import { vi } from '../../testing/vitest-compat';
|
|
|
|
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`);
|
|
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
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('marks today on the Europe/Paris date when the runtime date is still the day before', () => {
|
|
vi.setSystemTime(new Date('2026-10-17T22:16:00Z'));
|
|
const horizon = service.getDefaultPlanningHorizon();
|
|
expect(horizon.start).toBe('2026-10-18');
|
|
const { selections } = project(horizon);
|
|
expect(selections.getClasses(day('2026-10-18'))).toContain(DayType.TODAY);
|
|
expect(selections.getClasses(day('2026-10-17'))).not.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);
|
|
});
|
|
});
|