/* SPDX-FileCopyrightText: © 2025 Hornwitser SPDX-License-Identifier: AGPL-3.0-or-later */ import { ClientEntity } from "~/utils/client-entity"; import { ClientSchedule, ClientScheduleEventSlot, ClientScheduleLocation, ClientScheduleShiftSlot, toIso } from "~/utils/client-schedule"; import { describe, expect, test } from "vitest"; import type { ApiEntity, ApiSchedule } from "~/shared/types/api"; import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon"; const locale = "en-GB"; const now = DateTime.now().setLocale(locale); const later = now.plus({ minutes: 2 }); const zone = now.zone; const nowIso = now.setZone(FixedOffsetZone.utcInstance).toISO(); const laterIso = later.setZone(FixedOffsetZone.utcInstance).toISO(); function fixtureClientSchedule(multiSlot = false) { const left = new ClientScheduleLocation(1, now, false, "Left", ""); const right = new ClientScheduleLocation(2, now, false, "Right", "This is the right place"); const events = [ new ClientScheduleEvent( 1, now, false, "Up", false, "", false, "What's Up?", 0, new Set(multiSlot ? [1, 2] : [1]), ), new ClientScheduleEvent( 2, now, false, "Down", false, "", false, "", 0, new Set(multiSlot ? [] : [2]), ), ]; const eventSlots = idMap([ new ClientScheduleEventSlot(1, false, false, 1, now, now.plus({ hours: 1 }), new Set([left.id]), new Set(), 0), new ClientScheduleEventSlot(2, false, false, multiSlot ? 1 : 2, now, now.plus({ hours: 2 }), new Set([right.id]), new Set(), 0), ]); const red = new ClientScheduleRole(1, now, false, "Red", "Is a color."); const blue = new ClientScheduleRole(2, now, false, "Blue", ""); const shifts = [ new ClientScheduleShift( 1, now, false, red.id, "White", "", new Set(multiSlot ? [1, 2] : [1]), ), new ClientScheduleShift( 2, now, false, blue.id, "Black", "Is dark.", new Set(multiSlot ? [] : [2]), ), ]; const shiftSlots = idMap([ new ClientScheduleShiftSlot(1, false, false, 1, now, now.plus({ hours: 1 }), new Set()), new ClientScheduleShiftSlot(2, false, false, multiSlot ? 1 : 2, now, now.plus({ hours: 2 }), new Set()), ]); const schedule = new ClientSchedule( 111, now, false, new ClientMap(ClientScheduleLocation, idMap([left, right]), new Map()), new ClientMap(ClientScheduleEvent, idMap(events), new Map()), eventSlots, new ClientMap(ClientScheduleRole, idMap([red, blue]), new Map()), new ClientMap(ClientScheduleShift, idMap(shifts), new Map()), shiftSlots, ); for (const event of events.values()) { event.schedule = schedule; } for (const eventSlot of eventSlots.values()) { eventSlot.schedule = schedule; } for (const shift of shifts.values()) { shift.schedule = schedule; } for (const shiftSlot of shiftSlots.values()) { shiftSlot.schedule = schedule; } return schedule; } function fixtureApiSchedule(): ApiSchedule { return { id: 111, updatedAt: nowIso, locations: [ { id: 1, updatedAt: nowIso, name: "Left", }, { id: 2, updatedAt: nowIso, name: "Right", description: "This is the right place", }, ], events: [ { id: 1, updatedAt: nowIso, name: "Up", description: "What's Up?", slots: [{ id: 1, start: nowIso, end: toIso(now.plus({ hours: 1 })), locationIds: [1], }], }, { id: 2, updatedAt: nowIso, name: "Down", slots: [{ id: 2, start: nowIso, end: toIso(now.plus({ hours: 2 })), locationIds: [2], }], }, ], roles: [ { id: 1, updatedAt: nowIso, name: "Red", description: "Is a color.", }, { id: 2, updatedAt: nowIso, name: "Blue", }, ], shifts: [ { id: 1, updatedAt: nowIso, name: "White", roleId: 1, slots: [{ id: 1, start: nowIso, end: toIso(now.plus({ hours: 1 })), }], }, { id: 2, updatedAt: nowIso, name: "Black", description: "Is dark.", roleId: 2, slots: [{ id: 2, start: nowIso, end: toIso(now.plus({ hours: 2 })), }], }, ], }; } describe("class ClientSchedule", () => { test("load from api", () => { const schedule = ClientSchedule.fromApi(fixtureApiSchedule(), { zone, locale }) expect(schedule).toStrictEqual(fixtureClientSchedule()); }); test("save to api", () => { const schedule = fixtureClientSchedule(); expect(schedule.toApi(false)).toEqual(fixtureApiSchedule()) }); const entityTests: [string, (schedule: ClientSchedule) => ClientEntity][] = [ [ "location", () => ClientScheduleLocation.create(3, "New location", "", { zone, locale }) ], [ "event", (schedule) => ClientScheduleEvent.create(schedule, 3, "New location", false, "", false, "", 0, new Set(), { zone, locale }) ], [ "role", () => ClientScheduleRole.create(3, "New location", "", { zone, locale }) ], [ "shift", (schedule) => ClientScheduleShift.create(schedule, 3, 1, "New location", "", new Set(), { zone, locale }) ], ] as const; for (const [name, create] of entityTests) { describe(name, () => { const Name = name[0].toUpperCase() + name.slice(1); test(`create`, () => { const schedule = fixtureClientSchedule(); const entity = create(schedule); // Create (schedule as any)[`${name}s`].add(entity); // Check expect(schedule.isModified()).toBe(true); expect((schedule as any)[`${name}s`].get(entity.id).isModified()).toBe(true); expect((schedule as any)[`${name}s`].get(entity.id).isNew()).toBe(true); expect((schedule as any)[`${name}s`].get(entity.id)).toBe(entity); }); test("edit", () => { const schedule = fixtureClientSchedule(); const entity = (schedule as any)[`${name}s`].get(1); expect(schedule.isModified()).toBe(false); expect(entity.isModified()).toBe(false); const originalName = entity.name; // Edit entity.name = `Modified ${name}`; // Check expect(schedule.isModified()).toBe(true); expect(entity.isModified()).toBe(true); expect(entity.serverName).toBe(originalName); }); if (name === "location") { test.skip("delete location in use throws", () => { const schedule = fixtureClientSchedule(); expect( () => { schedule.locations.get(1)!.deleted = true; } ).toThrow(new Error('Cannot delete location, event "Up" depends on it')); }); } else if (name === "role") { test.skip("delete role in use throws", () => { const schedule = fixtureClientSchedule(); expect( () => { schedule.roles.get(1)!.deleted = true; } ).toThrow(new Error('Cannot delete role, shift "White" depends on it')); }); } test("delete", () => { const schedule = fixtureClientSchedule(); const entity = (schedule as any)[`${name}s`].get(1); expect(schedule.isModified()).toBe(false); expect(entity.isModified()).toBe(false); // Delete if (name === "location") { schedule.events.get(1)!.deleted = true; } else if (name === "role") { schedule.shifts.get(1)!.deleted = true; } entity.deleted = true; // Check expect(schedule.isModified()).toBe(true); expect(entity.isModified()).toBe(true); expect(entity.serverDeleted).toBe(false); expect(entity.deleted).toBe(true); }); }); } describe("event slot", () => { test("edit", () => { const schedule = fixtureClientSchedule(); expect(schedule.isModified()).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); // Modify schedule.eventSlots.get(1)!.start = later; // Check expect(schedule.isModified()).toBe(true); expect(schedule.events.get(1)!.isModified()).toBe(true); expect(schedule.events.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(true); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1); }); test("add and apply", () => { const schedule = fixtureClientSchedule(); // Add const slot = ClientScheduleEventSlot.create( schedule, 3, 1, now, now.plus({ hours: 3 }), new Set(), new Set(), 0, ); schedule.eventSlots.set(slot.id, slot); schedule.events.get(1)!.slotIds.add(slot.id); // Sanity check expect(schedule.isModified()).toBe(true); // Apply change schedule.apiUpdate({ id: 111, updatedAt: laterIso, events: [{ id: 1, updatedAt: nowIso, name: "Up", description: "What's Up?", slots: [ { id: 1, start: nowIso, end: toIso(now.plus({ hours: 1 })), locationIds: [1], }, { id: 3, start: nowIso, end: toIso(now.plus({ hours: 3 })), locationIds: [], }, ], }], }, { zone, locale }); // Check expect(schedule.isModified()).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(3)!.isNewEntity).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(2); expect(schedule.events.get(2)!.slots.size).toBe(1); }); test("edit and apply", () => { const schedule = fixtureClientSchedule(); // Modify schedule.eventSlots.get(1)!.locationIds.add(2); schedule.eventSlots.get(1)!.locationIds.delete(1); // Sanity check expect(schedule.isModified()).toBe(true); // Apply change schedule.apiUpdate({ id: 111, updatedAt: laterIso, events: [{ id: 1, updatedAt: laterIso, name: "Up", description: "What's Up?", slots: [{ id: 1, start: nowIso, end: toIso(now.plus({ hours: 1 })), locationIds: [2], }], }], }, { zone, locale }); // Check expect(schedule.isModified()).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(1)!.locationIds).toEqual(new Set([2])); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1); }); test("delete and apply", () => { const schedule = fixtureClientSchedule(true); // delete schedule.eventSlots.get(1)!.deleted = true; // Sanity check expect(schedule.isModified()).toBe(true); // Apply change schedule.apiUpdate({ id: 111, updatedAt: laterIso, events: [{ id: 1, updatedAt: laterIso, name: "Up", description: "What's Up?", slots: [{ id: 2, start: nowIso, end: toIso(now.plus({ hours: 2 })), locationIds: [2], }], }], }, { zone, locale }); // Check expect(schedule.isModified()).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.has(1)).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(0); }); test("move to another event", () => { const schedule = fixtureClientSchedule(); expect(schedule.isModified()).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); // Modify schedule.eventSlots.get(1)!.setEventId(2); // Check expect(schedule.isModified()).toBe(true); expect(schedule.events.get(1)!.isModified()).toBe(true); expect(schedule.events.get(2)!.isModified()).toBe(true); expect(schedule.eventSlots.get(1)!.isModified()).toBe(true); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(0); expect(schedule.events.get(2)!.slots.size).toBe(2); // Move back schedule.eventSlots.get(1)!.setEventId(1); // Check expect(schedule.isModified()).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1); }); test("discard", () => { const schedule = fixtureClientSchedule(); schedule.eventSlots.get(1)!.start = later; schedule.eventSlots.get(1)!.discard(); // Check expect(schedule.isModified()).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1); }); test("discard from another event", () => { const schedule = fixtureClientSchedule(); schedule.eventSlots.get(1)!.setEventId(2); schedule.eventSlots.get(1)!.discard(); // Check expect(schedule.isModified()).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1); }); }); describe("shift slot", () => { test("edit", () => { const schedule = fixtureClientSchedule(); expect(schedule.isModified()).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); // Modify schedule.shiftSlots.get(1)!.start = later; // Check expect(schedule.isModified()).toBe(true); expect(schedule.shifts.get(1)!.isModified()).toBe(true); expect(schedule.shifts.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(true); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1); }); test("add and apply", () => { const schedule = fixtureClientSchedule(); // Add const slot = ClientScheduleShiftSlot.create( schedule, 3, 1, now, now.plus({ hours: 3 }), new Set(), ); schedule.shiftSlots.set(slot.id, slot); schedule.shifts.get(1)!.slotIds.add(slot.id); // Sanity check expect(schedule.isModified()).toBe(true); // Apply change schedule.apiUpdate({ id: 111, updatedAt: laterIso, shifts: [{ id: 1, updatedAt: nowIso, name: "White", roleId: 1, slots: [ { id: 1, start: nowIso, end: toIso(now.plus({ hours: 1 })), }, { id: 3, start: nowIso, end: toIso(now.plus({ hours: 3 })), }, ], }], }, { zone, locale }); // Check expect(schedule.isModified()).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(3)!.isNewEntity).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(2); expect(schedule.shifts.get(2)!.slots.size).toBe(1); }); test("edit and apply", () => { const schedule = fixtureClientSchedule(); // Modify schedule.shiftSlots.get(1)!.assigned.add(2); // Sanity check expect(schedule.isModified()).toBe(true); // Apply change schedule.apiUpdate({ id: 111, updatedAt: laterIso, shifts: [{ id: 1, updatedAt: nowIso, name: "White", roleId: 1, slots: [{ id: 1, start: nowIso, end: toIso(now.plus({ hours: 1 })), assigned: [2], }], }], }, { zone, locale }); // Check expect(schedule.isModified()).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(1)!.assigned).toEqual(new Set([2])); expect(schedule.shifts.get(1)!.slots.size).toBe(1); }); test("delete and apply", () => { const schedule = fixtureClientSchedule(true); // delete schedule.shiftSlots.get(1)!.deleted = true; // Sanity check expect(schedule.isModified()).toBe(true); // Apply change schedule.apiUpdate({ id: 111, updatedAt: laterIso, shifts: [{ id: 1, updatedAt: nowIso, name: "White", roleId: 1, slots: [{ id: 2, start: nowIso, end: toIso(now.plus({ hours: 2 })), }], }], }, { zone, locale }); // Check expect(schedule.isModified()).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.has(1)).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(0); }); test("move to another shift", () => { const schedule = fixtureClientSchedule(); expect(schedule.isModified()).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); // Modify schedule.shiftSlots.get(1)!.setShiftId(2); // Check expect(schedule.isModified()).toBe(true); expect(schedule.shifts.get(1)!.isModified()).toBe(true); expect(schedule.shifts.get(2)!.isModified()).toBe(true); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(true); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(0); expect(schedule.shifts.get(2)!.slots.size).toBe(2); // Move back schedule.shiftSlots.get(1)!.setShiftId(1); // Check expect(schedule.isModified()).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1); }); test("discard", () => { const schedule = fixtureClientSchedule(); schedule.shiftSlots.get(1)!.start = later; schedule.shiftSlots.get(1)!.discard(); // Check expect(schedule.isModified()).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1); }); test("discard from another shift", () => { const schedule = fixtureClientSchedule(); schedule.shiftSlots.get(1)!.setShiftId(2); schedule.shiftSlots.get(1)!.discard(); // Check expect(schedule.isModified()).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1); }); }); });