298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import {
|
|
createJourney,
|
|
decodeJourneySnapshot,
|
|
encodeJourneySnapshot,
|
|
endpointCity,
|
|
clearJourneySession,
|
|
journeyReducer,
|
|
loadJourneySession,
|
|
officeCity,
|
|
saveJourneySession,
|
|
transitionJourney,
|
|
type JourneyActor,
|
|
type JourneyEvent,
|
|
type JourneyState,
|
|
type JourneyStorageAdapter,
|
|
} from "../journey/index.ts";
|
|
|
|
const CROW: JourneyActor = {
|
|
id: "anon-115",
|
|
kind: "crow",
|
|
signedIn: false,
|
|
profile: { displayName: "Visitor", color: "midnight" },
|
|
};
|
|
|
|
const KARTI: JourneyActor = {
|
|
id: "user-karti",
|
|
kind: "humanoid",
|
|
signedIn: true,
|
|
profile: { displayName: "Karti", face: "profile:user-karti", color: "black" },
|
|
};
|
|
|
|
function playFromLosAngeles(): JourneyState {
|
|
let state = createJourney({ actor: CROW, location: { scale: "socal" }, mode: "play" });
|
|
state = journeyReducer(state, {
|
|
type: "select-route",
|
|
routeId: "la-sf-i-5",
|
|
direction: 1,
|
|
});
|
|
state = journeyReducer(state, { type: "enter-vehicle", vehicleId: "model-x-hero" });
|
|
return state;
|
|
}
|
|
|
|
describe("journey state machine", () => {
|
|
it("navigates explicitly between California and either detailed city board", () => {
|
|
const california = createJourney({ actor: CROW, mode: "play" });
|
|
const actor = california.actor;
|
|
const bayArea = journeyReducer(california, {
|
|
type: "navigate-to-city",
|
|
city: "bay-area",
|
|
});
|
|
assert.deepEqual(bayArea.location, { scale: "bay-area" });
|
|
assert.equal(bayArea.actor, actor);
|
|
|
|
const returned = journeyReducer(bayArea, { type: "return-to-california" });
|
|
assert.deepEqual(returned.location, { scale: "california" });
|
|
assert.equal(returned.actor, actor);
|
|
const southern = journeyReducer(returned, { type: "navigate-to-city", city: "socal" });
|
|
assert.deepEqual(southern.location, { scale: "socal" });
|
|
});
|
|
|
|
it("requires leaving an office or vehicle before board navigation", () => {
|
|
let driving = playFromLosAngeles();
|
|
assert.deepEqual(
|
|
transitionJourney(driving, { type: "navigate-to-city", city: "bay-area" }),
|
|
{ accepted: false, state: driving, reason: "exit-vehicle-first" },
|
|
);
|
|
|
|
driving = journeyReducer(driving, { type: "exit-vehicle" });
|
|
const bayArea = journeyReducer(driving, { type: "navigate-to-city", city: "bay-area" });
|
|
let office = journeyReducer(bayArea, {
|
|
type: "enter-office",
|
|
officeId: "lumbridge-hq",
|
|
});
|
|
assert.deepEqual(
|
|
transitionJourney(office, { type: "return-to-california" }),
|
|
{ accepted: false, state: office, reason: "leave-office-first" },
|
|
);
|
|
office = journeyReducer(office, { type: "leave-office" });
|
|
assert.equal(journeyReducer(office, { type: "return-to-california" }).location.scale, "california");
|
|
});
|
|
|
|
it("maps route endpoints honestly onto the detailed city boards", () => {
|
|
assert.equal(endpointCity("los-angeles"), "socal");
|
|
assert.equal(endpointCity("san-francisco"), "bay-area");
|
|
|
|
let northbound = playFromLosAngeles();
|
|
northbound = journeyReducer(northbound, { type: "update-route-progress", progress: 0.74 });
|
|
northbound = journeyReducer(northbound, {
|
|
type: "reach-route-endpoint",
|
|
endpoint: "san-francisco",
|
|
});
|
|
assert.equal(northbound.location.scale, "bay-area");
|
|
assert.equal(northbound.route?.progress, 1);
|
|
assert.equal(northbound.vehicle?.vehicleId, "model-x-hero");
|
|
|
|
let southbound = journeyReducer(northbound, { type: "exit-vehicle" });
|
|
southbound = journeyReducer(southbound, {
|
|
type: "select-route",
|
|
routeId: "la-sf-us-101",
|
|
direction: -1,
|
|
});
|
|
southbound = journeyReducer(southbound, { type: "enter-vehicle", vehicleId: "model-x-hero" });
|
|
southbound = journeyReducer(southbound, {
|
|
type: "reach-route-endpoint",
|
|
endpoint: "los-angeles",
|
|
});
|
|
assert.equal(southbound.location.scale, "socal");
|
|
assert.equal(southbound.route?.progress, 0);
|
|
});
|
|
|
|
it("preserves identity and prior city across office doors", () => {
|
|
let state = createJourney({ actor: KARTI, location: { scale: "bay-area" }, mode: "play" });
|
|
const actor = state.actor;
|
|
state = journeyReducer(state, { type: "enter-office", officeId: "frontier-valley" });
|
|
assert.deepEqual(state.location, {
|
|
scale: "office",
|
|
officeId: "frontier-valley",
|
|
priorCity: "bay-area",
|
|
});
|
|
assert.equal(state.actor, actor);
|
|
state = journeyReducer(state, { type: "leave-office" });
|
|
assert.deepEqual(state.location, { scale: "bay-area" });
|
|
assert.equal(state.actor, actor);
|
|
assert.equal(officeCity("mateo-court"), "socal");
|
|
});
|
|
|
|
it("swaps a signed-in actor without disturbing their journey", () => {
|
|
const before = playFromLosAngeles();
|
|
const after = journeyReducer(before, { type: "sign-in-actor-swap", actor: KARTI });
|
|
assert.deepEqual(after.actor, KARTI);
|
|
assert.notEqual(after.actor, KARTI, "the reducer owns a defensive actor copy");
|
|
assert.deepEqual(after.location, before.location);
|
|
assert.deepEqual(after.vehicle, before.vehicle);
|
|
assert.deepEqual(after.route, before.route);
|
|
});
|
|
|
|
it("rejects invalid and out-of-order transitions as exact no-ops", () => {
|
|
const state = createJourney({ actor: CROW });
|
|
const badEvents: JourneyEvent[] = [
|
|
{ type: "enter-vehicle", vehicleId: "model-x-hero" },
|
|
{ type: "enter-office", officeId: "lumbridge-hq" },
|
|
{ type: "leave-office" },
|
|
{ type: "update-route-progress", progress: 0.5 },
|
|
{ type: "reach-route-endpoint", endpoint: "san-francisco" },
|
|
{ type: "sign-in-actor-swap", actor: CROW },
|
|
];
|
|
for (const event of badEvents) {
|
|
const result = transitionJourney(state, event);
|
|
assert.equal(result.accepted, false);
|
|
assert.equal(result.state, state);
|
|
assert.equal(journeyReducer(state, event), state);
|
|
}
|
|
});
|
|
|
|
it("requires the endpoint that agrees with the selected direction", () => {
|
|
const state = playFromLosAngeles();
|
|
const rejected = transitionJourney(state, {
|
|
type: "reach-route-endpoint",
|
|
endpoint: "los-angeles",
|
|
});
|
|
assert.deepEqual(rejected, { accepted: false, state, reason: "wrong-endpoint" });
|
|
});
|
|
|
|
it("requires play mode, a route, and exiting the car before an office", () => {
|
|
const observer = createJourney({ actor: CROW, location: { scale: "socal" } });
|
|
assert.equal(
|
|
transitionJourney(observer, { type: "enter-vehicle", vehicleId: "x" }).accepted,
|
|
false,
|
|
);
|
|
|
|
let driving = playFromLosAngeles();
|
|
driving = journeyReducer(driving, {
|
|
type: "reach-route-endpoint",
|
|
endpoint: "san-francisco",
|
|
});
|
|
const result = transitionJourney(driving, {
|
|
type: "enter-office",
|
|
officeId: "lumbridge-hq",
|
|
});
|
|
assert.deepEqual(result, { accepted: false, state: driving, reason: "exit-vehicle-first" });
|
|
});
|
|
|
|
it("encodes and decodes an isolated versioned reconnect snapshot", () => {
|
|
const state = playFromLosAngeles();
|
|
const encoded = encodeJourneySnapshot(state);
|
|
const decoded = decodeJourneySnapshot(encoded);
|
|
assert.equal(decoded.ok, true);
|
|
if (!decoded.ok) return;
|
|
assert.equal(decoded.snapshot.version, 1);
|
|
assert.deepEqual(decoded.state, state);
|
|
assert.notEqual(decoded.state, state);
|
|
decoded.state.actor.profile.displayName = "Changed offline";
|
|
assert.equal(state.actor.profile.displayName, "Visitor");
|
|
});
|
|
|
|
it("fails closed on corrupt, inconsistent, and future snapshots", () => {
|
|
assert.deepEqual(decodeJourneySnapshot("{"), {
|
|
ok: false,
|
|
error: "journey: snapshot is not valid JSON",
|
|
});
|
|
assert.equal(decodeJourneySnapshot({ version: 2, state: {} }).ok, false);
|
|
assert.equal(
|
|
decodeJourneySnapshot({
|
|
version: 1,
|
|
state: {
|
|
...createJourney({ actor: CROW }),
|
|
location: {
|
|
scale: "office",
|
|
officeId: "mateo-court",
|
|
priorCity: "bay-area",
|
|
},
|
|
},
|
|
}).ok,
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("is deterministic for the same initial state and event log", () => {
|
|
const initial = createJourney({ actor: CROW, location: { scale: "socal" }, mode: "play" });
|
|
const events: JourneyEvent[] = [
|
|
{ type: "select-route", routeId: "la-sf-us-101", direction: 1 },
|
|
{ type: "enter-vehicle", vehicleId: "model-x-hero" },
|
|
{ type: "update-route-progress", progress: 0.25 },
|
|
{ type: "update-route-progress", progress: 0.75 },
|
|
{ type: "reach-route-endpoint", endpoint: "san-francisco" },
|
|
{ type: "exit-vehicle" },
|
|
{ type: "enter-office", officeId: "lumbridge-hq" },
|
|
{ type: "sign-in-actor-swap", actor: KARTI },
|
|
];
|
|
const run = (): JourneyState => events.reduce(journeyReducer, initial);
|
|
assert.deepEqual(run(), run());
|
|
});
|
|
});
|
|
|
|
class MemoryStorage implements JourneyStorageAdapter {
|
|
readonly values = new Map<string, string>();
|
|
|
|
getItem(key: string): string | null {
|
|
return this.values.get(key) ?? null;
|
|
}
|
|
|
|
setItem(key: string, value: string): void {
|
|
this.values.set(key, value);
|
|
}
|
|
|
|
removeItem(key: string): void {
|
|
this.values.delete(key);
|
|
}
|
|
}
|
|
|
|
describe("journey session persistence", () => {
|
|
it("round-trips through a caller-owned storage adapter and clears cleanly", () => {
|
|
const storage = new MemoryStorage();
|
|
const state = playFromLosAngeles();
|
|
assert.deepEqual(saveJourneySession(storage, "tera.journey", state), { ok: true });
|
|
const loaded = loadJourneySession(storage, "tera.journey");
|
|
assert.equal(loaded.status, "loaded");
|
|
if (loaded.status !== "loaded") return;
|
|
assert.deepEqual(loaded.state, state);
|
|
assert.notEqual(loaded.state, state);
|
|
assert.deepEqual(clearJourneySession(storage, "tera.journey"), { ok: true });
|
|
assert.deepEqual(loadJourneySession(storage, "tera.journey"), { status: "missing" });
|
|
});
|
|
|
|
it("rejects corrupt and future sessions without throwing or mutating storage", () => {
|
|
const storage = new MemoryStorage();
|
|
storage.values.set("corrupt", "{");
|
|
storage.values.set("future", JSON.stringify({ version: 2, state: {} }));
|
|
assert.equal(loadJourneySession(storage, "corrupt").status, "invalid");
|
|
assert.deepEqual(loadJourneySession(storage, "future"), {
|
|
status: "invalid",
|
|
error: "journey: unsupported snapshot version",
|
|
});
|
|
assert.equal(storage.values.has("future"), true);
|
|
});
|
|
|
|
it("contains adapter exceptions and invalid keys", () => {
|
|
const unavailable: JourneyStorageAdapter = {
|
|
getItem: () => {
|
|
throw new Error("blocked");
|
|
},
|
|
setItem: () => {
|
|
throw new Error("full");
|
|
},
|
|
removeItem: () => {
|
|
throw new Error("blocked");
|
|
},
|
|
};
|
|
const state = playFromLosAngeles();
|
|
assert.equal(loadJourneySession(unavailable, "tera.journey").status, "unavailable");
|
|
assert.equal(saveJourneySession(unavailable, "tera.journey", state).ok, false);
|
|
assert.equal(clearJourneySession(unavailable, "tera.journey").ok, false);
|
|
assert.equal(loadJourneySession(unavailable, " ").status, "invalid");
|
|
});
|
|
});
|