Files
vac-optimizer/src/app/services/calendar-service.spec.ts
T

508 lines
16 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { provideZonelessChangeDetection } from '@angular/core';
import {
CalendarService,
DayType,
SelectedDate,
SelectedDates,
SelectableDayType,
VacDay,
} from './calendar-service';
import { DateRange } from '@angular/material/datepicker';
import { IfThenRule, TimeWindowRule, RuleType } from './rule-service';
describe('CalendarService', () => {
let service: CalendarService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
service = TestBed.inject(CalendarService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getHolidaysForPeriod', () => {
it('should return an array of holidays', () => {
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const holidays = service.getHolidaysForPeriod(startDate, endDate);
expect(Array.isArray(holidays)).toBe(true);
expect(holidays.length).toBeGreaterThan(0);
});
});
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 in period', () => {
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const months = service.monthsInPeriod(startDate, endDate);
expect(months.length).toBeGreaterThanOrEqual(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('getSelectedDatesForPeriod', () => {
it('should return selected dates for the period', () => {
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
expect(selectedDates).toBeDefined();
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
});
it('should include weekends', () => {
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
const hasWeekend = selectedDates._datesSelected.some(
(date) => date.type === DayType.WEEKEND,
);
expect(hasWeekend).toBe(true);
});
it('should include holidays', () => {
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
const hasHoliday = selectedDates._datesSelected.some(
(date) => date.type === DayType.CLOSED_DAY,
);
expect(hasHoliday).toBe(true);
});
it('should include today', () => {
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
const hasToday = selectedDates._datesSelected.some(
(date) => date.type === DayType.TODAY,
);
expect(hasToday).toBe(true);
});
});
describe('setWeekendSelection', () => {
it('should add weekend for Saturday', () => {
const selectedDates = new SelectedDates();
const saturday = new Date(2024, 0, 6); // A Saturday
service.setWeekendSelection(saturday, selectedDates);
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
});
});
});
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 return tooltip for selected date', () => {
const date = new Date(2024, 0, 1);
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(date, date));
const tooltip = selectedDate.getTooltip(date);
expect(tooltip).toBeTruthy();
});
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', () => {
it('should push selected dates', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
expect(selectedDates._datesSelected.length).toBe(1);
});
it('should check if date is selected', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
expect(selectedDates.isSelected(date)).toBe(true);
});
it('should group adjacent dates', () => {
const selectedDates = new SelectedDates();
const date1 = new Date(2024, 0, 1);
const date2 = new Date(2024, 0, 2);
selectedDates._datesSelected.push(
new SelectedDate(DayType.CP, new DateRange<Date>(date1, date1)),
);
selectedDates._datesSelected.push(
new SelectedDate(DayType.RTT, new DateRange<Date>(date2, date2)),
);
selectedDates.update();
expect(selectedDates.datesSelected.length).toBeGreaterThan(2);
});
it('should compute heuristics', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const heuristic = selectedDates.computeHeuristics();
expect(typeof heuristic).toBe('number');
});
it('should return correct classes', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const classes = selectedDates.getClasses(date);
expect(classes).toContain(DayType.CP);
});
it('should return tooltip', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const tooltip = selectedDates.getTooltip(date);
expect(tooltip).toBeTruthy();
});
describe('optimizeVacations and rules engine', () => {
let calendarService: CalendarService;
let selectedDates: SelectedDates;
beforeEach(() => {
calendarService = TestBed.inject(CalendarService);
selectedDates = new SelectedDates();
});
it('should execute rules sequentially in order', async () => {
const vacationDays: VacDay[] = [];
const rules: (TimeWindowRule | IfThenRule)[] = [
{ id: '1', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
{ id: '2', type: RuleType.TIME_WINDOW, enabled: true } as TimeWindowRule,
{ id: '3', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
];
const applyTimeWindowRulesSpy = vi.spyOn(selectedDates, 'applyTimeWindowRules');
const applyIfThenRulesSpy = vi.spyOn(selectedDates, 'applyIfThenRules');
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 1);
await selectedDates.optimizeVacations(
vacationDays,
rules,
calendarService,
startDate,
endDate,
);
expect(applyIfThenRulesSpy.mock.calls.length).toBe(2);
expect(applyTimeWindowRulesSpy.mock.calls.length).toBe(1);
// First rule is IF_THEN
expect(applyIfThenRulesSpy.mock.calls[0][1][0].id).toBe('1');
// Second rule is TIME_WINDOW
expect(applyTimeWindowRulesSpy.mock.calls[0][1][0].id).toBe('2');
// Third rule is IF_THEN
expect(applyIfThenRulesSpy.mock.calls[1][1][0].id).toBe('3');
});
it('should apply time window rules', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 5,
expiryDate: new Date(futureYear + 1, 11, 31),
},
];
const rules: TimeWindowRule[] = [
{
id: 't1',
name: 'Time Window',
type: RuleType.TIME_WINDOW,
enabled: true,
startDate: new Date(futureYear, 11, 20),
endDate: new Date(futureYear, 11, 25),
placeDays: 3,
placeType: SelectableDayType.CP,
},
];
// Mock monthsInPeriod to return a controlled range of days
vi.spyOn(calendarService, 'monthsInPeriod').mockReturnValue([
[
[
new Date(futureYear, 11, 20),
new Date(futureYear, 11, 21),
new Date(futureYear, 11, 22),
new Date(futureYear, 11, 23),
new Date(futureYear, 11, 24),
],
],
]);
const startDate = new Date(futureYear, 11, 20);
const endDate = new Date(futureYear, 11, 25);
selectedDates.applyTimeWindowRules(
vacationDays,
rules,
calendarService,
startDate,
endDate,
);
// Should have placed 3 days
const placedCount = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedCount).toBe(3);
// Should have consumed 3 days from the pool
expect(vacationDays[0].numberOfDays).toBe(2);
});
it('should apply if-then rules', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 5,
expiryDate: new Date(futureYear + 1, 11, 31),
},
];
const rules: IfThenRule[] = [
{
id: 'i1',
name: 'Samedi Malin',
type: RuleType.IF_THEN,
enabled: true,
dayOfWeek: 6, // Saturday
placeDays: 2,
placeType: SelectableDayType.CP,
placement: 'auto',
rewardDays: 1,
rewardType: SelectableDayType.OTHER,
},
];
// Setup a mock holiday on a Saturday
// Use a date far in the future so it doesn't fail the "past date" check
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5); // Dec 5 is usually Saturday or close, let's just make sure we get a Saturday
// To guarantee it's a Saturday for the test:
while (saturdayHoliday.getDay() !== 6) {
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
}
selectedDates._datesSelected.push(
new SelectedDate(
DayType.CLOSED_DAY,
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
),
);
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 3);
selectedDates.applyIfThenRules(vacationDays, rules, startDate, endDate);
// Should have placed 2 CP days (e.g. Thu/Fri, Mon/Tue, or Fri/Mon)
const placedCP = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedCP).toBe(2);
// Should have consumed 2 CP days
expect(vacationDays[0].numberOfDays).toBe(3);
// Should have added 1 OTHER reward day
const rewardDay = vacationDays.find((vd) => vd.type === SelectableDayType.OTHER);
expect(rewardDay).toBeDefined();
expect(rewardDay?.numberOfDays).toBe(1);
});
it('should apply time window rules with ANY type', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 2,
expiryDate: new Date(futureYear, 11, 31),
},
{
id: '2',
type: SelectableDayType.RTT,
numberOfDays: 2,
expiryDate: new Date(futureYear - 1, 11, 31), // Expires earlier
},
];
const rules: TimeWindowRule[] = [
{
id: 'tw1',
name: 'Vacances',
type: RuleType.TIME_WINDOW,
enabled: true,
startDate: new Date(futureYear, 6, 1),
endDate: new Date(futureYear, 6, 15),
placeDays: 3,
placeType: SelectableDayType.ANY,
},
];
vi.spyOn(calendarService, 'monthsInPeriod').mockReturnValue([
[
[
new Date(futureYear, 6, 1),
new Date(futureYear, 6, 2),
new Date(futureYear, 6, 3),
new Date(futureYear, 6, 4),
new Date(futureYear, 6, 5),
],
],
]);
const startDate = new Date(futureYear, 6, 1);
const endDate = new Date(futureYear, 6, 15);
selectedDates.applyTimeWindowRules(
vacationDays,
rules,
calendarService,
startDate,
endDate,
);
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
const placedRTT = selectedDates._datesSelected.filter(
(d) => d.type === DayType.RTT,
).length;
const placedCP = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedRTT).toBe(2);
expect(placedCP).toBe(1);
// Should have consumed 2 RTT and 1 CP
expect(vacationDays[0].numberOfDays).toBe(1); // CP remaining
expect(vacationDays[1].numberOfDays).toBe(0); // RTT remaining
});
it('should apply if-then rules with ANY type', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 2,
expiryDate: new Date(futureYear + 2, 11, 31),
},
{
id: '2',
type: SelectableDayType.RTT,
numberOfDays: 2,
expiryDate: new Date(futureYear + 1, 11, 31), // Expires earlier
},
];
const rules: IfThenRule[] = [
{
id: 'i1',
name: 'Rule',
type: RuleType.IF_THEN,
enabled: true,
dayOfWeek: 6, // Saturday
placeDays: 3,
placeType: SelectableDayType.ANY,
placement: 'auto',
rewardDays: 0,
rewardType: SelectableDayType.CP,
},
];
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5);
while (saturdayHoliday.getDay() !== 6) {
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
}
selectedDates._datesSelected.push(
new SelectedDate(
DayType.CLOSED_DAY,
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
),
);
const startDate = new Date();
const endDate = new Date();
endDate.setFullYear(endDate.getFullYear() + 3);
selectedDates.applyIfThenRules(vacationDays, rules, startDate, endDate);
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
const placedRTT = selectedDates._datesSelected.filter(
(d) => d.type === DayType.RTT,
).length;
const placedCP = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedRTT).toBe(2);
expect(placedCP).toBe(1);
});
});
});