owltide/utils/client-map.nuxt.test.ts

289 lines
7.7 KiB
TypeScript
Raw Normal View History

/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
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 { ApiTombstone, 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 | ApiTombstone)[] {
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 | ApiTombstone;
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 | ApiTombstone;
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);
});
});