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) => 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)); 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)); 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)); 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)); 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(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)); expect(selectedDate.isSelected(date)).toBe(false); }); }); describe('SelectedDates', () => { const range = (type: DayType, start: Date, end = start) => new SelectedDate(type, new DateRange(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); }); });