owltide/utils/client-schedule.nuxt.test.ts
Hornwitser e52972853d License under AGPL version 3 or later
I firmly believe in free software.

The application I'm making here have capabilities that I've not seen in
any system.  It presents itself as an opportunity to collaborate on a
tool that serves the people rather than corporations.  Whose incentives
are to help people rather, not make the most money.  And whose terms
ensure that these freedoms and incentives cannot be taken back or
subverted.

I license this software under the AGPL.
2025-06-30 18:58:24 +02:00

629 lines
20 KiB
TypeScript

/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
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<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);
});
});
});