import { ClientEntity, ClientSchedule, ClientScheduleEventSlot, ClientScheduleLocation, ClientScheduleShiftSlot, toIso } from "./client-schedule"; import { describe, expect, test } from "vitest"; import type { ApiSchedule } from "~/shared/types/api"; import type { Id, Living } from "~/shared/types/common"; 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(); function fixtureClientSchedule() { 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, idMap([new ClientScheduleEventSlot(1, false, 1, now, now.plus({ hours: 1 }), [left], new Set(), 0)]), ), new ClientScheduleEvent( 2, now, false, "Down", false, "", false, "", 0, idMap([new ClientScheduleEventSlot(2, false, 2, now, now.plus({ hours: 2 }), [right], 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, "White", "", idMap([new ClientScheduleShiftSlot(1, false, 1, now, now.plus({ hours: 1 }), new Set())]), ), new ClientScheduleShift( 2, now, false, blue, "Black", "Is dark.", idMap([new ClientScheduleShiftSlot(2, false, 2, now, now.plus({ hours: 2 }), new Set())]), ), ]; return new ClientSchedule( 111, now, false, new Map([ [left.id, left], [right.id, right], ]), new Map(events.map(event => [event.id, event])), new Map([ [red.id, red], [blue.id, blue], ]), new Map(shifts.map(shift => [shift.id, shift])), ); } function fixtureApiSchedule(): Living { 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 updatePatterns = [ "aa a aa", "ba a aa", "-a a aa", "ab a ab", "bb a aa", "-b a ab", "ax a ax", "bx a ax", "-- a aa", "-x a ax", "aa x --", "ba x -a", "-a x -a", "ab x -b", "bb x --", "-b x -b", "ax x --", "bx x --", "-x x --", "-- x --", ]; for (const pattern of updatePatterns) { test(`apply diff pattern ${pattern}`, () => { const fixtureClient: Record = { a: new ClientScheduleLocation(1, now, false, "A", ""), b: new ClientScheduleLocation(1, now, false, "B", ""), x: new ClientScheduleLocation(1, now, true, "X", ""), }; const fixtureServer: Record = { a: new ClientScheduleLocation(1, later, false, "A", ""), b: new ClientScheduleLocation(1, later, false, "B", ""), x: new ClientScheduleLocation(1, later, true, "X", ""), }; const schedule = new ClientSchedule(111, now, false, new Map(), new Map(), new Map(), new Map()); if (fixtureClient[pattern[0]]) schedule.originalLocations.set(1, fixtureClient[pattern[0]]); if (fixtureClient[pattern[1]]) schedule.locations.set(1, fixtureClient[pattern[1]]); const update = fixtureServer[pattern[3]]; const expectedOriginalLocation = pattern[5] === "x" ? undefined : fixtureServer[pattern[5]]; const expectedLocation = pattern[5] === pattern[6] ? fixtureServer[pattern[6]] : fixtureClient[pattern[6]]; schedule.applyUpdate({ id: 111, updatedAt: nowIso, locations: [update.toApi()], }, { zone, locale }); expect(schedule.originalLocations.get(1)).toEqual(expectedOriginalLocation); expect(schedule.locations.get(1)).toEqual(expectedLocation); if (pattern.slice(5) === "aa") expect(schedule.originalLocations.get(1)).toBe(schedule.locations.get(1)); }); } const entityTests: [string, (schedule: ClientSchedule) => ClientEntity][] = [ [ "location", () => new ClientScheduleLocation(3, now, false, "New location", "") ], [ "event", () => new ClientScheduleEvent(3, now, false, "New location", false, "", false, "", 0, new Map()) ], [ "role", () => new ClientScheduleRole(3, now, false, "New location", "") ], [ "shift", (schedule) => new ClientScheduleShift(3, now, false, schedule.roles.get(1)!, "New location", "", new Map()) ], ] 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); expect(schedule.modified).toBe(false); // Create (schedule as any)[`set${Name}`](entity); // Check expect(schedule.modified).toBe(true); expect((schedule as any)[`isModified${Name}`](entity.id)).toBe(true); expect((schedule as any)[`original${Name}s`].get(entity.id)).toBe(undefined); expect((schedule as any)[`${name}s`].get(entity.id)).toBe(entity); }); test("edit", () => { const schedule = fixtureClientSchedule(); const original = (schedule as any)[`${name}s`].get(1); expect(schedule.modified).toBe(false); expect((schedule as any)[`isModified${Name}`](1)).toBe(false); // Edit (schedule as any)[`edit${Name}`](original, { name: `Modified ${name}` }) // Check expect(schedule.modified).toBe(true); expect((schedule as any)[`isModified${Name}`](1)).toBe(true); expect((schedule as any)[`original${Name}s`].get(1)).toBe(original); if (name === "location") { expect(schedule.events.get(1)!.slots.get(1)!.locations[0]).toBe(schedule.locations.get(1)); } else if (name === "role") { expect(schedule.shifts.get(1)!.role).toBe(schedule.roles.get(1)); } }); if (name === "location") { test("delete location in use throws", () => { const schedule = fixtureClientSchedule(); expect( () => { schedule.editLocation(schedule.locations.get(1)!, { deleted: true }); } ).toThrow(new Error('Cannot delete location, event "Up" depends on it')); }); } else if (name === "role") { test("delete role in use throws", () => { const schedule = fixtureClientSchedule(); expect( () => { schedule.editRole(schedule.roles.get(1)!, { deleted: true }); } ).toThrow(new Error('Cannot delete role, shift "White" depends on it')); }); } test("delete", () => { const schedule = fixtureClientSchedule(); const original = (schedule as any)[`${name}s`].get(1); expect(schedule.modified).toBe(false); expect((schedule as any)[`isModified${Name}`](1)).toBe(false); // Delete if (name === "location") { schedule.editEvent(schedule.events.get(1)!, { deleted: true }); } else if (name === "role") { schedule.editShift(schedule.shifts.get(1)!, { deleted: true }); } (schedule as any)[`edit${Name}`](original, { deleted: true }) // Check expect(schedule.modified).toBe(true); expect((schedule as any)[`isModified${Name}`](1)).toBe(true); expect((schedule as any)[`original${Name}s`].get(1)).toBe(original); expect((schedule as any)[`${name}s`].get(1).deleted).toBe(true); }); }); } function validateSlotRelations( slots: Map | Map, entites: Map | Map, prefix: string, ) { const remainingIds = new Set(slots.keys()) for (const shift of entites.values()) { for (const slot of shift.slots.values()) { if (!slots.has(slot.id)) { throw Error(`${prefix}: shift ${shift.name}:${shift.id} has slot ${slot.id} that is not in shiftSlots`); } if (slots.get(slot.id) !== slot) { throw Error(`${prefix}: shift ${shift.name}:${shift.id} has slot ${slot.id} which does not match the corresponding slot in shiftSlots.`); } if (!remainingIds.has(slot.id)) { throw Error(`${prefix}: shift ${shift.name}:${shift.id} has slot ${slot.id} that has been seen twice.`); } remainingIds.delete(slot.id); } } if (remainingIds.size) { throw Error(`${prefix}: shiftSlots ${[...remainingIds].join(", ")} does not have a corresponding shift`); } } describe("event slot", () => { test("edit", () => { const schedule = fixtureClientSchedule(); expect(schedule.modified).toBe(false); expect(schedule.isModifiedEvent(1)).toBe(false); expect(schedule.isModifiedEvent(2)).toBe(false); expect(schedule.isModifiedEventSlot(1)).toBe(false); expect(schedule.isModifiedEventSlot(2)).toBe(false); // Modify schedule.editEventSlot(schedule.eventSlots.get(1)!, { start: later }); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.isModifiedEvent(2)).toBe(false); expect(schedule.isModifiedEventSlot(1)).toBe(true); expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1); validateSlotRelations(schedule.eventSlots, schedule.events, "current"); validateSlotRelations(schedule.eventSlots, schedule.events, "original"); }); test("move to another event", () => { const schedule = fixtureClientSchedule(); expect(schedule.modified).toBe(false); expect(schedule.isModifiedEvent(1)).toBe(false); expect(schedule.isModifiedEvent(2)).toBe(false); expect(schedule.isModifiedEventSlot(1)).toBe(false); expect(schedule.isModifiedEventSlot(2)).toBe(false); // Modify schedule.editEventSlot(schedule.eventSlots.get(1)!, { eventId: 2 }); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.isModifiedEvent(2)).toBe(true); expect(schedule.isModifiedEventSlot(1)).toBe(true); expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(0); expect(schedule.events.get(2)!.slots.size).toBe(2); validateSlotRelations(schedule.eventSlots, schedule.events, "current"); validateSlotRelations(schedule.eventSlots, schedule.events, "original"); // Move back schedule.editEventSlot(schedule.eventSlots.get(1)!, { eventId: 1 }); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.isModifiedEvent(2)).toBe(true); expect(schedule.isModifiedEventSlot(1)).toBe(true); expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1); validateSlotRelations(schedule.eventSlots, schedule.events, "current"); validateSlotRelations(schedule.eventSlots, schedule.events, "original"); }); test("restore", () => { const schedule = fixtureClientSchedule(); schedule.editEventSlot(schedule.eventSlots.get(1)!, { start: later }); schedule.restoreEventSlot(1); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.isModifiedEvent(2)).toBe(false); expect(schedule.isModifiedEventSlot(1)).toBe(false); expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1); validateSlotRelations(schedule.eventSlots, schedule.events, "current"); validateSlotRelations(schedule.eventSlots, schedule.events, "original"); }); test("restore from another event", () => { const schedule = fixtureClientSchedule(); schedule.editEventSlot(schedule.eventSlots.get(1)!, { eventId: 2 }); schedule.restoreEventSlot(1); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.isModifiedEvent(2)).toBe(true); expect(schedule.isModifiedEventSlot(1)).toBe(false); expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1); validateSlotRelations(schedule.eventSlots, schedule.events, "current"); validateSlotRelations(schedule.eventSlots, schedule.events, "original"); }); }); describe("shift slot", () => { test("edit", () => { const schedule = fixtureClientSchedule(); expect(schedule.modified).toBe(false); expect(schedule.isModifiedShift(1)).toBe(false); expect(schedule.isModifiedShift(2)).toBe(false); expect(schedule.isModifiedShiftSlot(1)).toBe(false); expect(schedule.isModifiedShiftSlot(2)).toBe(false); // Modify schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { start: later }); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.isModifiedShift(2)).toBe(false); expect(schedule.isModifiedShiftSlot(1)).toBe(true); expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); }); test("move to another shift", () => { const schedule = fixtureClientSchedule(); expect(schedule.modified).toBe(false); expect(schedule.isModifiedShift(1)).toBe(false); expect(schedule.isModifiedShift(2)).toBe(false); expect(schedule.isModifiedShiftSlot(1)).toBe(false); expect(schedule.isModifiedShiftSlot(2)).toBe(false); // Modify schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { shiftId: 2 }); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.isModifiedShift(2)).toBe(true); expect(schedule.isModifiedShiftSlot(1)).toBe(true); expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(0); expect(schedule.shifts.get(2)!.slots.size).toBe(2); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); // Move back schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { shiftId: 1 }); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.isModifiedShift(2)).toBe(true); expect(schedule.isModifiedShiftSlot(1)).toBe(true); expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); }); test("restore", () => { const schedule = fixtureClientSchedule(); schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { start: later }); schedule.restoreShiftSlot(1); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.isModifiedShift(2)).toBe(false); expect(schedule.isModifiedShiftSlot(1)).toBe(false); expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); }); test("restore from another shift", () => { const schedule = fixtureClientSchedule(); schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { shiftId: 2 }); schedule.restoreShiftSlot(1); // Check expect(schedule.modified).toBe(true); expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.isModifiedShift(2)).toBe(true); expect(schedule.isModifiedShiftSlot(1)).toBe(false); expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); }); }); });