owltide/utils/client-map.nuxt.test.ts
Hornwitser e3ff872b5c
All checks were successful
/ build (push) Successful in 1m30s
/ deploy (push) Successful in 16s
Refactor ClientSchedule to mutable types
Use a single mutable location, event, slot, etc, for each unique
resource that keeps track of the local editable client copy and
the server copy of the data contained in it.

This makes it much simpler to update these data structures as I can take
advantage of the v-model bindings in Vue.js and work with the system
instead of against it.
2025-06-24 00:07:18 +02:00

284 lines
7.6 KiB
TypeScript

import { ClientMap } from "~/utils/client-map";
import { ClientEntity } from "~/utils/client-entity";
import { ClientUser } from "~/utils/client-user";
import { describe, expect, test } from "vitest";
import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon";
import type { ApiUser } from "~/shared/types/api";
const locale = "en-GB";
const now = DateTime.now().setLocale(locale);
const earlier = now.minus({ minutes: 2 });
const later = now.plus({ minutes: 2 });
const zone = now.zone;
const earlierIso = earlier.setZone(FixedOffsetZone.utcInstance).toISO();
const nowIso = now.setZone(FixedOffsetZone.utcInstance).toISO();
const laterIso = later.setZone(FixedOffsetZone.utcInstance).toISO();
function fixtureClientMap() {
const userA = new ClientUser(1, now, false, "A", "regular");
const userB = new ClientUser(2, now, false, "B", "regular");
return new ClientMap(
ClientUser,
new Map([
[userA.id, userA],
[userB.id, userB],
]),
new Map([[5, now.toMillis()]]),
);
}
function fixtureApiMap(): ApiUser[] {
return [
{
id: 1,
updatedAt: nowIso,
name: "A",
type: "regular",
},
{
id: 2,
updatedAt: nowIso,
name: "B",
type: "regular",
},
{
id: 5,
updatedAt: nowIso,
deleted: true,
},
];
}
describe("class ClientMap", () => {
test("load from api", () => {
const map = ClientMap.fromApi(ClientUser, fixtureApiMap(), { zone, locale })
expect(map).toStrictEqual(fixtureClientMap());
});
test("save to api", () => {
const map = fixtureClientMap();
expect(map.toApi(false)).toEqual(fixtureApiMap())
});
// State: - = not deleted, x = deleted
// Timestamp: n = new, - = now, l = later,
function userFromPattern(pattern: string, laterIsNow: boolean) {
const [
serverState,
serverTs,
serverName,
clientState,
clientTs,
clientName,
] = pattern;
if (serverName === "-" && clientName === "-")
return undefined;
const timestamps: Record<string, DateTime> = {
"-": now,
"l": laterIsNow ? now : later,
"n": DateTime.fromMillis(ClientEntity.newEntityMillis),
};
const user = new ClientUser(1, now, false, "", "regular");
user.serverUpdatedAt = timestamps[serverTs];
user.serverDeleted = serverState === "x";
user.serverName = serverName.toUpperCase();
user.updatedAt = timestamps[clientTs];
user.deleted = clientState === "x";
user.name = clientName.toUpperCase();
return user;
}
const updatePatterns = [
// User pattern:
// --a--a
// |||||\- Client name: a, b, or - for no entity
// ||||\- Client timestamp: n for new, - for now, and l for later
// |||\- Client state: - not deleted, x marked as deleted or if no entity a tombstone
// ||\- Server name: a, b, or - for no entity
// |\- Server timestamp: n for new, - for now, and l for later
// \- Server state: - not deleted, x marked as deleted or if no entity a tombstone
// inital-user action expected-user
// Update with entity
"--a--a A -la-la",
"--b--a A -la-la",
"x-a--a A -la-la",
"x-b--a A -la-la",
"-na-na A -la-la",
"-nb-na A -la-la",
"--ax-a A -lax-a",
"--bx-a A -lax-a",
"x-ax-a A -lax-a",
"x-bx-a A -lax-a",
"-naxna A -laxna",
"-nbxna A -laxna",
"--a--b A -la--b",
"--b--b A -la-la",
"x-a--b A -la--b",
"x-b--b A -la--b",
"-na-nb A -la-nb",
"-nb-nb A -la-nb",
"--ax-b A -lax-b",
"--bx-b A -lax-b",
"x-ax-b A -lax-b",
"x-bx-b A -lax-b",
"-naxnb A -laxnb",
"-nbxnb A -laxnb",
"x----- A -la-la",
"------ A -la-la",
// Update with Tombstone
"--a--a T xl----",
"--b--a T xlb--a",
"x-a--a T xla--a",
"x-b--a T xlb--a",
"-na-na T xla-na",
"-nb-na T xlb-na",
"--ax-a T xl----",
"--bx-a T xl----",
"x-ax-a T xl----",
"x-bx-a T xl----",
"-naxna T xl----",
"-nbxna T xl----",
"--a--b T xla--b",
"--b--b T xl----",
"x-a--b T xla--b",
"x-b--b T xlb--b",
"-na-nb T xla-nb",
"-nb-nb T xlb-nb",
"--ax-b T xl----",
"--bx-b T xl----",
"x-ax-b T xl----",
"x-bx-b T xl----",
"-naxnb T xl----",
"-nbxnb T xl----",
"x----- T xl----",
"------ T xl----",
];
function tombFromPattern(pattern: string, laterIsNow: boolean) {
if (pattern[0] === "x") {
if (pattern[1] === "-")
return now.toMillis();
if (pattern[1] === "l")
return (laterIsNow ? now : later).toMillis();
}
}
for (const pattern of updatePatterns) {
test(`apply diff pattern ${pattern}`, () => {
for (const useSameTimestamp of [false, true]) {
const map = new ClientMap(ClientUser, new Map(), new Map());
const [startPattern, action, expectedPattern] = pattern.split(" ");
const user = userFromPattern(startPattern, useSameTimestamp);
const tomb = tombFromPattern(startPattern, useSameTimestamp);
if (user)
map.add(user);
if (tomb)
map.tombstones.set(1, tomb);
// Update
let update: ApiUser;
if (action === "A") {
update = {
id: 1,
updatedAt: useSameTimestamp ? nowIso : laterIso,
name: "A",
type: "regular",
}
} else if (action === "T") {
update = {
id: 1,
updatedAt: useSameTimestamp ? nowIso : laterIso,
deleted: true,
}
} else {
throw new Error(`Unknown action pattern ${action}`)
}
map.apiUpdate([update], { zone, locale });
// Check
const expectedUser = userFromPattern(expectedPattern, useSameTimestamp);
const expectedTomb = tombFromPattern(expectedPattern, useSameTimestamp);
expect(map.get(1)).toEqual(expectedUser);
expect(map.tombstones.get(1)).toEqual(expectedTomb);
}
});
}
for (const pattern of updatePatterns) {
if (pattern[1] === "n" || pattern[2] === "-")
continue; // Skip tests involving new or missing entities
test(`ignore diff pattern ${pattern.slice(0, 8)}`, () => {
for (const useSameTimestamp of [false, true]) {
const map = new ClientMap(ClientUser, new Map(), new Map());
const [startPattern, action] = pattern.split(" ");
const user = userFromPattern(startPattern, useSameTimestamp);
const tomb = tombFromPattern(startPattern, useSameTimestamp);
if (user)
map.add(user);
if (tomb)
map.tombstones.set(1, tomb);
// Update
let update: ApiUser;
if (action === "A") {
update = {
id: 1,
updatedAt: earlierIso,
name: "A",
type: "regular",
}
} else if (action === "T") {
update = {
id: 1,
updatedAt: earlierIso,
deleted: true,
}
} else {
throw new Error(`Unknown action pattern ${action}`)
}
map.apiUpdate([update], { zone, locale });
// Check
const expectedUser = userFromPattern(startPattern, useSameTimestamp);
const expectedTomb = tombFromPattern(startPattern, useSameTimestamp);
expect(map.get(1)).toEqual(expectedUser);
expect(map.tombstones.get(1)).toEqual(expectedTomb);
}
});
}
test("create", () => {
const map = fixtureClientMap();
const entity = ClientUser.create(3, "New user", "regular", { zone, locale });
expect(map.isModified()).toBe(false);
// Create
map.add(entity);
// Check
expect(map.isModified()).toBe(true);
expect(map.get(entity.id)!.isModified()).toBe(true);
});
test("edit", () => {
const map = fixtureClientMap();
expect(map.isModified()).toBe(false);
expect(map.get(1)!.isModified()).toBe(false);
// Edit
map.get(1)!.name = "Modified User";
// Check
expect(map.isModified()).toBe(true);
expect(map.get(1)!.isModified()).toBe(true);
});
test("delete", () => {
const map = fixtureClientMap();
expect(map.isModified()).toBe(false);
expect(map.get(1)!.isModified()).toBe(false);
// Delete
map.get(1)!.deleted = true;
// Check
expect(map.isModified()).toBe(true);
expect(map.get(1)!.isModified()).toBe(true);
});
});