Implement tracking of time slots along with editing and restoration of singularly edited time slots. This provides a simpler interface to work with when rendering tables of time slots that can be edited than directly manipulating events and shifts containing an array of slots.
487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
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<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 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<string, ClientScheduleLocation> = {
|
|
a: new ClientScheduleLocation(1, now, false, "A", ""),
|
|
b: new ClientScheduleLocation(1, now, false, "B", ""),
|
|
x: new ClientScheduleLocation(1, now, true, "X", ""),
|
|
};
|
|
const fixtureServer: Record<string, ClientScheduleLocation> = {
|
|
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<Id, ClientScheduleShiftSlot> | Map<Id, ClientScheduleEventSlot>,
|
|
entites: Map<Id, ClientScheduleShift> | Map<Id, ClientScheduleEvent>,
|
|
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");
|
|
});
|
|
});
|
|
});
|