Files
jrshikoku/tests/officialPositionService.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

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