2025-06-30 18:58:24 +02:00
|
|
|
/*
|
|
|
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
*/
|
2025-06-23 22:46:39 +02:00
|
|
|
import { ClientMap } from "~/utils/client-map";
|
|
|
|
import { ClientEntity } from "~/utils/client-entity";
|
|
|
|
import { ClientUser } from "~/utils/client-user";
|
2025-06-23 00:03:37 +02:00
|
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon";
|
2025-06-24 15:19:11 +02:00
|
|
|
import type { ApiTombstone, ApiUser } from "~/shared/types/api";
|
2025-06-23 00:03:37 +02:00
|
|
|
|
|
|
|
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()]]),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-06-24 15:19:11 +02:00
|
|
|
function fixtureApiMap(): (ApiUser | ApiTombstone)[] {
|
2025-06-23 18:17:23 +02:00
|
|
|
return [
|
|
|
|
{
|
|
|
|
id: 1,
|
|
|
|
updatedAt: nowIso,
|
|
|
|
name: "A",
|
|
|
|
type: "regular",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 2,
|
|
|
|
updatedAt: nowIso,
|
|
|
|
name: "B",
|
|
|
|
type: "regular",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 5,
|
|
|
|
updatedAt: nowIso,
|
|
|
|
deleted: true,
|
|
|
|
},
|
|
|
|
];
|
2025-06-23 00:03:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
describe("class ClientMap", () => {
|
|
|
|
test("load from api", () => {
|
2025-06-23 18:17:23 +02:00
|
|
|
const map = ClientMap.fromApi(ClientUser, fixtureApiMap(), { zone, locale })
|
2025-06-23 00:03:37 +02:00
|
|
|
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,
|
2025-06-23 22:46:39 +02:00
|
|
|
"n": DateTime.fromMillis(ClientEntity.newEntityMillis),
|
2025-06-23 00:03:37 +02:00
|
|
|
};
|
|
|
|
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
|
2025-06-24 15:19:11 +02:00
|
|
|
let update: ApiUser | ApiTombstone;
|
2025-06-23 00:03:37 +02:00
|
|
|
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}`)
|
|
|
|
}
|
2025-06-23 18:17:23 +02:00
|
|
|
map.apiUpdate([update], { zone, locale });
|
2025-06-23 00:03:37 +02:00
|
|
|
// 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
|
2025-06-24 15:19:11 +02:00
|
|
|
let update: ApiUser | ApiTombstone;
|
2025-06-23 00:03:37 +02:00
|
|
|
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}`)
|
|
|
|
}
|
2025-06-23 18:17:23 +02:00
|
|
|
map.apiUpdate([update], { zone, locale });
|
2025-06-23 00:03:37 +02:00
|
|
|
// 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);
|
|
|
|
});
|
|
|
|
});
|