- Add `currentTrainMetadata.ts` to manage carrying forward official position metadata between train snapshots. - Introduce `lineDisplayName.ts` for mapping line IDs to display names. - Create `officialPositionCache.ts` for parsing and serializing official position data with cache validation. - Enhance `officialPositionService.ts` to support persistent caching of official positions and improve resolver functionality. - Update `requestCoordinator.ts` to allow cancellation of polling requests without disposing of the coordinator. - Modify `trainTimeFiltering.ts` to incorporate line ID context when checking for duplicate train data. - Refactor `useCurrentTrain.tsx` to support polling for current train data with official position enrichment. - Add tests for new features, including persistent cache behavior and handling of ambiguous train data.
262 lines
8.1 KiB
TypeScript
262 lines
8.1 KiB
TypeScript
import { strict as assert } from "node:assert";
|
|
import { test } from "node:test";
|
|
import {
|
|
loadWithCurrentTrainFallback,
|
|
mergeCurrentTrainFallback,
|
|
RequestCoordinator,
|
|
} from "../lib/requestCoordinator";
|
|
import { carryForwardOfficialPositionMetadata } from "../lib/currentTrainMetadata";
|
|
|
|
const deferred = <T>() => {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
};
|
|
|
|
test("same-source polling skips a second in-flight request", async () => {
|
|
const coordinator = new RequestCoordinator();
|
|
const firstResult = deferred<string>();
|
|
let calls = 0;
|
|
|
|
const first = coordinator.start("primary", () => {
|
|
calls += 1;
|
|
return firstResult.promise;
|
|
});
|
|
if (!first) throw new Error("first request was not started");
|
|
|
|
const duplicate = coordinator.start("primary", () => {
|
|
calls += 1;
|
|
return "duplicate";
|
|
});
|
|
assert.equal(duplicate, null);
|
|
assert.equal(calls, 0, "operations start on the next microtask");
|
|
|
|
firstResult.resolve("first");
|
|
assert.equal(await first.promise, "first");
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
|
|
const next = coordinator.start("primary", () => {
|
|
calls += 1;
|
|
return "next";
|
|
});
|
|
if (!next) throw new Error("next polling request was not started");
|
|
assert.equal(await next.promise, "next");
|
|
assert.equal(calls, 2);
|
|
});
|
|
|
|
test("source switch aborts the old request and only the newest response commits", async () => {
|
|
const coordinator = new RequestCoordinator();
|
|
const oldResult = deferred<string>();
|
|
const commits: string[] = [];
|
|
|
|
const oldRequest = coordinator.start("primary", () => oldResult.promise);
|
|
if (!oldRequest) throw new Error("old request was not started");
|
|
|
|
const newRequest = coordinator.start("mock", () => "new");
|
|
if (!newRequest) throw new Error("new request was not started");
|
|
assert.equal(oldRequest.signal.aborted, true);
|
|
|
|
const newResult = await newRequest.promise;
|
|
if (coordinator.isCurrent(newRequest.token)) commits.push(newResult);
|
|
|
|
oldResult.resolve("old");
|
|
const oldValue = await oldRequest.promise;
|
|
if (coordinator.isCurrent(oldRequest.token)) commits.push(oldValue);
|
|
|
|
assert.deepEqual(commits, ["new"]);
|
|
assert.equal(coordinator.isCurrent(oldRequest.token), false);
|
|
});
|
|
|
|
test("dispose aborts an in-flight request and rejects late commits", async () => {
|
|
const coordinator = new RequestCoordinator();
|
|
const result = deferred<string>();
|
|
const request = coordinator.start("primary", () => result.promise);
|
|
if (!request) throw new Error("request was not started");
|
|
|
|
coordinator.dispose();
|
|
assert.equal(request.signal.aborted, true);
|
|
result.resolve("late");
|
|
await request.promise;
|
|
assert.equal(coordinator.isCurrent(request.token), false);
|
|
});
|
|
|
|
test("cancel aborts polling without disposing the coordinator", async () => {
|
|
const coordinator = new RequestCoordinator();
|
|
const result = deferred<string>();
|
|
const request = coordinator.start("primary", () => result.promise);
|
|
if (!request) throw new Error("request was not started");
|
|
|
|
coordinator.cancel();
|
|
assert.equal(request.signal.aborted, true);
|
|
result.resolve("late");
|
|
await request.promise;
|
|
assert.equal(coordinator.isCurrent(request.token), false);
|
|
|
|
const next = coordinator.start("primary", () => "next");
|
|
if (!next) throw new Error("next request was not started");
|
|
assert.equal(await next.promise, "next");
|
|
});
|
|
|
|
test("primary success does not call the fallback", async () => {
|
|
let fallbackCalls = 0;
|
|
const result = await loadWithCurrentTrainFallback({
|
|
signal: new AbortController().signal,
|
|
primary: async () => "primary",
|
|
fallback: async () => {
|
|
fallbackCalls += 1;
|
|
return "fallback";
|
|
},
|
|
});
|
|
assert.deepEqual(result, { data: "primary", source: "primary" });
|
|
assert.equal(fallbackCalls, 0);
|
|
});
|
|
|
|
test("primary failure keeps the GAS fallback path", async () => {
|
|
let fallbackCalls = 0;
|
|
const result = await loadWithCurrentTrainFallback({
|
|
signal: new AbortController().signal,
|
|
primary: async () => {
|
|
throw new Error("primary unavailable");
|
|
},
|
|
fallback: async () => {
|
|
fallbackCalls += 1;
|
|
return "fallback";
|
|
},
|
|
});
|
|
assert.deepEqual(result, { data: "fallback", source: "gas_fallback" });
|
|
assert.equal(fallbackCalls, 1);
|
|
});
|
|
|
|
test("fallback failure remains an error", async () => {
|
|
await assert.rejects(
|
|
loadWithCurrentTrainFallback({
|
|
signal: new AbortController().signal,
|
|
primary: async () => {
|
|
throw new Error("primary unavailable");
|
|
},
|
|
fallback: async () => {
|
|
throw new Error("fallback unavailable");
|
|
},
|
|
}),
|
|
/fallback unavailable/,
|
|
);
|
|
});
|
|
|
|
test("aborted primary does not start the fallback", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
let fallbackCalls = 0;
|
|
|
|
await assert.rejects(
|
|
loadWithCurrentTrainFallback({
|
|
signal: controller.signal,
|
|
primary: async () => {
|
|
throw new Error("aborted");
|
|
},
|
|
fallback: async () => {
|
|
fallbackCalls += 1;
|
|
return "fallback";
|
|
},
|
|
}),
|
|
/aborted/,
|
|
);
|
|
assert.equal(fallbackCalls, 0);
|
|
});
|
|
|
|
test("partial fallback preserves fields from the last complete response", () => {
|
|
const previous = [
|
|
{ num: "D01", Pos: "高松", Direction: 1, Line: "dosan" },
|
|
{ num: "D02", Pos: "坂出", Direction: 0, Line: "yosan" },
|
|
];
|
|
const fallback = [{ num: "D01", Pos: "多度津" }, { num: "D03", Pos: "琴平" }];
|
|
|
|
assert.deepEqual(mergeCurrentTrainFallback(fallback, previous), [
|
|
{ num: "D01", Pos: "多度津", Direction: 1, Line: "dosan" },
|
|
{ num: "D03", Pos: "琴平" },
|
|
]);
|
|
});
|
|
|
|
test("recording and mock requests remain separate sources", async () => {
|
|
const coordinator = new RequestCoordinator();
|
|
const recording = coordinator.start("recording", () => "recorded");
|
|
if (!recording) throw new Error("recording request was not started");
|
|
assert.equal(await recording.promise, "recorded");
|
|
|
|
const mock = coordinator.start("mock", () => "mocked");
|
|
if (!mock) throw new Error("mock request was not started");
|
|
assert.equal(await mock.promise, "mocked");
|
|
});
|
|
|
|
test("ambiguous fallback keeps every previous line-specific row", () => {
|
|
const previous = [
|
|
{ num: "363D", Pos: "高松", PosNum: 279, Line: "yosan" },
|
|
{ num: "363D", Pos: "高松", PosNum: 279, Line: "koutoku" },
|
|
];
|
|
const fallback = [
|
|
{ num: "363D", Pos: "高松" },
|
|
{ num: "363D", Pos: "高松" },
|
|
];
|
|
|
|
assert.deepEqual(mergeCurrentTrainFallback(fallback, previous), previous);
|
|
});
|
|
|
|
test("line-aware fallback merges only the matching previous row", () => {
|
|
const previous = [
|
|
{ num: "363D", Pos: "高松", PosNum: 279, Line: "yosan", delay: 0 },
|
|
{ num: "363D", Pos: "高松", PosNum: 279, Line: "koutoku", delay: 0 },
|
|
];
|
|
const fallback = [{ num: "363D", Pos: "高松", Line: "koutoku", delay: 3 }];
|
|
|
|
assert.deepEqual(mergeCurrentTrainFallback(fallback, previous), [
|
|
{ num: "363D", Pos: "高松", PosNum: 279, Line: "koutoku", delay: 3 },
|
|
]);
|
|
});
|
|
|
|
test("refresh keeps official position metadata for the unchanged position tuple", () => {
|
|
const officialPosition = {
|
|
positionId: "position-yosan-takamatsu",
|
|
pos: "高松",
|
|
line: "yosan",
|
|
posNum: "279",
|
|
positionType: "station",
|
|
platform: "4",
|
|
track: "4",
|
|
note: null,
|
|
enrichmentStatus: "reviewed",
|
|
firstSeenAt: null,
|
|
lastSeenAt: null,
|
|
revision: 1,
|
|
} as const;
|
|
const previous = [
|
|
{
|
|
num: "1007M",
|
|
Pos: "高松",
|
|
PosNum: "279",
|
|
Line: "yosan",
|
|
officialPosition,
|
|
},
|
|
{
|
|
num: "1007M",
|
|
Pos: "高松",
|
|
PosNum: "279",
|
|
Line: "koutoku",
|
|
},
|
|
];
|
|
const next = [
|
|
{ num: "1007M", Pos: "高松", PosNum: "279", Line: "yosan" },
|
|
{ num: "1007M", Pos: "高松", PosNum: "279", Line: "koutoku" },
|
|
{ num: "1007M", Pos: "坂出", PosNum: "280", Line: "yosan" },
|
|
];
|
|
|
|
const result = carryForwardOfficialPositionMetadata(next, previous);
|
|
|
|
assert.equal(result[0]?.officialPosition, officialPosition);
|
|
assert.equal(result[1]?.officialPosition, undefined);
|
|
assert.equal(result[2]?.officialPosition, undefined);
|
|
});
|