owltide/utils/client-schedule.nuxt.test.ts
Hornwitser 985b8e0950 Refactor base types for entities and tombstones
Rename the base Entity type to ApiEntity, and the base EntityToombstone
to ApiTombstone to better reflect the reality that its only used in the
API interface and that the client and server types uses its own base if
any.

Remove EntityLiving and pull EntityTombstone out of of the base entity
type so that the types based on ApiEntity are always living entities and
if it's possible for it to contain tombstone this will be explicitly
told with the type including a union with ApiTombstone.

Refactor the types of the ClientEntity and ClientMap to better reflect
the types of the entities it stores and converts to/from.
2025-06-24 15:19:11 +02:00

625 lines
20 KiB
TypeScript

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<ApiEntity>][] = [
[
"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);
});
});
});