Rename the base Entity type to ApiEntity, and the base EntityToombstone to ApiTombstone to better reflect the reality that its only used in the API interface and that the client and server types uses its own base if any. Remove EntityLiving and pull EntityTombstone out of of the base entity type so that the types based on ApiEntity are always living entities and if it's possible for it to contain tombstone this will be explicitly told with the type including a union with ApiTombstone. Refactor the types of the ClientEntity and ClientMap to better reflect the types of the entities it stores and converts to/from.
284 lines
7.6 KiB
TypeScript
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 { 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);
|
|
});
|
|
});
|