Files
jrshikoku/tests/currentTrainRequest.test.ts
harukin-expo-dev-env 1ecd026908 feat: Implement official position metadata handling and caching
- 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.
2026-09-15 13:48:21 +09:00

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);
});