/* SPDX-FileCopyrightText: © 2025 Hornwitser 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 = { "-": 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); }); });