- 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.
263 lines
9.1 KiB
TypeScript
263 lines
9.1 KiB
TypeScript
import { strict as assert } from "node:assert";
|
|
import { test } from "node:test";
|
|
import {
|
|
BACKEND_API_BASE_URLS,
|
|
getOfficialPositionResolverConfig,
|
|
} from "../lib/jrDataSystemEnvironment";
|
|
import {
|
|
createOfficialPositionResolver,
|
|
parseOfficialPositionResponse,
|
|
type OfficialPositionCacheStore,
|
|
} from "../lib/officialPositionService";
|
|
import { officialPositionLookupKey } from "../lib/domain/railway";
|
|
|
|
const betaConfig = getOfficialPositionResolverConfig("experimental");
|
|
|
|
const backendPayload = (posNum: string | number = "109", extra: Record<string, unknown> = {}) => ({
|
|
data: {
|
|
position: {
|
|
position_id: "b754e5fc-789b-419c-b5bc-97585cba793f",
|
|
pos: "大歩危",
|
|
line: "dosan",
|
|
pos_num: posNum,
|
|
position_type: "station",
|
|
platform: "3",
|
|
track: "3",
|
|
note: null,
|
|
enrichment_status: "unreviewed",
|
|
first_seen_at: null,
|
|
last_seen_at: null,
|
|
revision: 2,
|
|
...extra,
|
|
},
|
|
},
|
|
});
|
|
|
|
const input = { line: "dosan", pos: "大歩危", posNum: "109" };
|
|
|
|
test("persistent cache renders the warm value before refreshing the API", async () => {
|
|
const cachedPosition = parseOfficialPositionResponse(backendPayload());
|
|
const cacheKey =
|
|
betaConfig.backendApiBaseUrl + ":" + officialPositionLookupKey(input);
|
|
let requestCount = 0;
|
|
let savedEntries = new Map<string, typeof cachedPosition>();
|
|
const cacheStore: OfficialPositionCacheStore = {
|
|
load: async () => new Map([[cacheKey, cachedPosition]]),
|
|
save: async (entries) => {
|
|
savedEntries = new Map(entries);
|
|
},
|
|
};
|
|
const resolver = createOfficialPositionResolver({
|
|
persistentCache: cacheStore,
|
|
fetchJson: async () => {
|
|
requestCount += 1;
|
|
return backendPayload("110");
|
|
},
|
|
});
|
|
|
|
const warmValue = await resolver.resolve(input, betaConfig);
|
|
assert.equal(warmValue?.posNum, "109");
|
|
assert.equal(requestCount, 0);
|
|
|
|
const refreshedValue = await resolver.resolve(input, betaConfig);
|
|
assert.equal(refreshedValue?.posNum, "110");
|
|
assert.equal(requestCount, 1);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
assert.equal(savedEntries.get(cacheKey)?.posNum, "110");
|
|
});
|
|
|
|
test("experimental resolver uses beta endpoint and preserves the canonical fields", async () => {
|
|
const requests: Array<{ url: string; source: string | undefined }> = [];
|
|
const resolver = createOfficialPositionResolver({
|
|
fetchJson: async (url, options) => {
|
|
requests.push({ url, source: options.source });
|
|
return backendPayload();
|
|
},
|
|
});
|
|
|
|
const position = await resolver.resolve(input, betaConfig);
|
|
assert.equal(position?.positionId, "b754e5fc-789b-419c-b5bc-97585cba793f");
|
|
assert.equal(position?.pos, "大歩危");
|
|
assert.equal(position?.line, "dosan");
|
|
assert.equal(position?.posNum, "109");
|
|
assert.equal(position?.platform, "3");
|
|
assert.equal(position?.track, "3");
|
|
assert.equal(position?.note, null);
|
|
assert.equal(requests.length, 1);
|
|
const url = new URL(requests[0].url);
|
|
assert.equal(url.origin, BACKEND_API_BASE_URLS.experimental);
|
|
assert.equal(url.pathname, "/api/v1/official-positions/resolve");
|
|
assert.equal(url.searchParams.get("line"), "dosan");
|
|
assert.equal(url.searchParams.get("pos"), "大歩危");
|
|
assert.equal(url.searchParams.get("pos_num"), "109");
|
|
assert.equal(requests[0].source, "backend_api");
|
|
});
|
|
|
|
test("resolveMany keeps Takamatsu observations separate by line", async () => {
|
|
const requests: string[] = [];
|
|
const resolver = createOfficialPositionResolver({
|
|
fetchJson: async (url) => {
|
|
requests.push(url);
|
|
const parsed = new URL(url);
|
|
return backendPayload(parsed.searchParams.get("pos_num") ?? "279", {
|
|
pos: parsed.searchParams.get("pos") ?? "高松",
|
|
line: parsed.searchParams.get("line") ?? "",
|
|
});
|
|
},
|
|
});
|
|
const inputs = [
|
|
{ line: "yosan", pos: "高松", posNum: 279 },
|
|
{ line: "koutoku", pos: "高松", posNum: 279 },
|
|
];
|
|
|
|
const result = await resolver.resolveMany(inputs, betaConfig);
|
|
assert.equal(result.size, 2);
|
|
assert.deepEqual(
|
|
requests.map((url) => new URL(url).searchParams.get("line")).sort(),
|
|
["koutoku", "yosan"],
|
|
);
|
|
assert.equal(
|
|
result.get(officialPositionLookupKey(inputs[0]))?.line,
|
|
"yosan",
|
|
);
|
|
assert.equal(
|
|
result.get(officialPositionLookupKey(inputs[1]))?.line,
|
|
"koutoku",
|
|
);
|
|
});
|
|
|
|
test("pos_num stays textual and the complete tuple forms the lookup key", () => {
|
|
const withLeadingZero = parseOfficialPositionResponse(
|
|
backendPayload("0279"),
|
|
);
|
|
const withLetters = parseOfficialPositionResponse(backendPayload("ABC"));
|
|
assert.equal(withLeadingZero.posNum, "0279");
|
|
assert.equal(withLetters.posNum, "ABC");
|
|
assert.notEqual(
|
|
officialPositionLookupKey({ ...input, pos: "大歩危" }),
|
|
officialPositionLookupKey({ ...input, pos: "小歩危" }),
|
|
);
|
|
assert.notEqual(
|
|
officialPositionLookupKey({ ...input, line: "dosan" }),
|
|
officialPositionLookupKey({ ...input, line: "dosan2" }),
|
|
);
|
|
});
|
|
|
|
test("ten simultaneous requests for one tuple share one HTTP request and later calls hit cache", async () => {
|
|
let requestCount = 0;
|
|
let release: (() => void) | undefined;
|
|
const pending = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const resolver = createOfficialPositionResolver({
|
|
fetchJson: async () => {
|
|
requestCount += 1;
|
|
await pending;
|
|
return backendPayload();
|
|
},
|
|
});
|
|
|
|
const requests = Array.from({ length: 10 }, () => resolver.resolve(input, betaConfig));
|
|
assert.equal(requestCount, 1);
|
|
release?.();
|
|
const positions = await Promise.all(requests);
|
|
assert.equal(positions.filter(Boolean).length, 10);
|
|
assert.equal(resolver.getDiagnostics().inFlightDedupeCount, 9);
|
|
await resolver.resolve(input, betaConfig);
|
|
assert.equal(requestCount, 1);
|
|
assert.equal(resolver.getDiagnostics().cacheHitCount, 1);
|
|
});
|
|
|
|
test("resolveMany limits initial fan-out to the configured concurrency", async () => {
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
let requestCount = 0;
|
|
const resolver = createOfficialPositionResolver({
|
|
maxConcurrency: 4,
|
|
fetchJson: async () => {
|
|
requestCount += 1;
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
active -= 1;
|
|
return backendPayload();
|
|
},
|
|
});
|
|
const inputs = Array.from({ length: 12 }, (_, index) => ({
|
|
line: "dosan",
|
|
pos: "大歩危" + index,
|
|
posNum: String(index),
|
|
}));
|
|
await resolver.resolveMany(inputs, betaConfig);
|
|
assert.equal(requestCount, 12);
|
|
assert.ok(maxActive <= 4);
|
|
});
|
|
|
|
test("404 becomes unresolved metadata and is cached without throwing", async () => {
|
|
let requestCount = 0;
|
|
const resolver = createOfficialPositionResolver({
|
|
fetchJson: async () => {
|
|
requestCount += 1;
|
|
throw Object.assign(new Error("not found"), { status: 404 });
|
|
},
|
|
});
|
|
assert.equal(await resolver.resolve(input, betaConfig), undefined);
|
|
assert.equal(await resolver.resolve(input, betaConfig), undefined);
|
|
assert.equal(requestCount, 1);
|
|
assert.equal(resolver.getDiagnostics().unresolvedCount, 1);
|
|
});
|
|
|
|
test("network failure keeps a cached value during forced refresh", async () => {
|
|
let shouldFail = false;
|
|
const resolver = createOfficialPositionResolver({
|
|
fetchJson: async () => {
|
|
if (shouldFail) throw new Error("temporary network failure");
|
|
return backendPayload();
|
|
},
|
|
});
|
|
const first = await resolver.resolve(input, betaConfig);
|
|
shouldFail = true;
|
|
const refreshed = await resolver.resolve(input, betaConfig, { forceRefresh: true });
|
|
assert.equal(refreshed?.positionId, first?.positionId);
|
|
assert.equal(resolver.getDiagnostics().networkFailureCount, 1);
|
|
});
|
|
|
|
test("description remains compatible when the resolver field is renamed", () => {
|
|
const parsed = parseOfficialPositionResponse(
|
|
backendPayload("109", { note: undefined, description: "西側ホーム" }),
|
|
);
|
|
assert.equal(parsed.note, "西側ホーム");
|
|
});
|
|
|
|
test("null metadata is accepted", () => {
|
|
const parsed = parseOfficialPositionResponse(
|
|
backendPayload("109", { platform: null, track: null, note: null }),
|
|
);
|
|
assert.equal(parsed.platform, null);
|
|
assert.equal(parsed.track, null);
|
|
assert.equal(parsed.note, null);
|
|
});
|
|
test("production release uses the production OfficialPosition Backend", async () => {
|
|
const requests: Array<{ url: string; source: string | undefined }> = [];
|
|
const resolver = createOfficialPositionResolver({
|
|
fetchJson: async (url, options) => {
|
|
requests.push({ url, source: options.source });
|
|
return backendPayload();
|
|
},
|
|
});
|
|
|
|
const metadata = await resolver.resolve(
|
|
input,
|
|
getOfficialPositionResolverConfig("production_release"),
|
|
);
|
|
assert.equal(metadata?.positionId, "b754e5fc-789b-419c-b5bc-97585cba793f");
|
|
assert.equal(requests.length, 1);
|
|
assert.equal(requests[0].source, "backend_api");
|
|
const url = new URL(requests[0].url);
|
|
assert.equal(url.origin, BACKEND_API_BASE_URLS.production);
|
|
assert.equal(url.pathname, "/api/v1/official-positions/resolve");
|
|
assert.equal(url.searchParams.get("line"), "dosan");
|
|
assert.equal(url.searchParams.get("pos"), "大歩危");
|
|
assert.equal(url.searchParams.get("pos_num"), "109");
|
|
});
|