From 437d50fe1e24e94ecf38f627bab592e93bb9c9d8 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 02:08:42 +0800 Subject: [PATCH] fix: complete dynamic engine cleanup --- .superpowers/sdd/task-3-report.md | 133 ++++++++---------- .../src/lib/birth-time-journey-adapters.ts | 21 ++- .../lib/birth-time-journey-engine-model.ts | 7 +- .../tests/birth-time-journey-engine.test.ts | 101 ++++++------- .../tests/birth-time-journey-memory-store.ts | 114 +++++++++++++++ .../tests/birth-time-journey-test-support.ts | 102 ++------------ 6 files changed, 240 insertions(+), 238 deletions(-) create mode 100644 frontend/tests/birth-time-journey-memory-store.ts diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md index a4e86e41..d4a72614 100644 --- a/.superpowers/sdd/task-3-report.md +++ b/.superpowers/sdd/task-3-report.md @@ -1,103 +1,84 @@ # Task 3 — TypeScript Engine Adapter and Trust Boundary -## Implementation +## Final design -- Added snake-case serializers for dynamic opportunity and deterministic choice-scoring requests. Only server-resolved `ServerChoiceEvidence` becomes `partition_id` / `candidate_scores`; client option IDs and confidence fields are never serialized. -- Added strict dynamic response adapters. Opportunity responses are versioned, endpoint-bound, exact-field parsed, and split into a model-safe `packet`, private `candidateModel`, and private `scoringPartitions`. -- Candidate score maps must contain finite nonnegative values keyed by exactly every minute in the submitted range, including cross-midnight ranges. Duplicate opportunity/partition IDs and unexpected response fields are rejected. -- Dynamic scoring responses require `birth-time-choice-scoring-v2`, `dynamic_choice` evidence mode, empty public evidence, matching compatibility/effective counts, and the existing deterministic candidate safety gates. -- Added authenticated server-only engine calls for both dynamic Python endpoints. They fail before fetch when `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` is absent and send the configured value only as a bearer header. Legacy scan, questionnaire score, and dated-event score calls remain unauthenticated. -- Extended the legacy-compatible engine contract with optional dynamic methods and added `DynamicBirthTimeJourneyEngine`, where both methods are required. The production factory returns the required dynamic subtype while existing legacy-only test doubles remain source-compatible. -- Raised only the shared candidate-result compatibility cap from 6 to 10 and changed the high-gate message to “effective evidence items.” The dated-event request contract remains capped at 6. -- Added source-contract coverage preventing snake-case or camel-case candidate model, partition, score, and token identifiers from entering client, request, response, component, or hook modules. +- `BirthTimeJourneyEngine` requires both dynamic operations: `buildDifferencePacket` and `scoreChoices`. Existing scan/score consumers depend on the explicit `LegacyBirthTimeJourneyEngine` pick instead of weakening the primary interface. +- The server-only engine factory owns `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN`. Both dynamic endpoints use an authenticated POST and a 45-second abort signal; all three legacy endpoints remain unauthenticated. +- Request serializers expose only server-resolved choice evidence. Client option IDs, confidence, applicability, and model-controlled safety gates are never sent as scoring authority. +- Dynamic v2 responses have a dedicated strict adapter. Root objects, ranges, opportunities, partitions, winning segments, score-map keys, counts, versions, modes, and duplicate identifiers are validated before mapping. +- Public difference packets omit private candidate score vectors. Private scoring partitions retain the exact server vector used by the later deterministic scoring call. +- Legacy response parsing remains compatibility-oriented: unknown server metadata is accepted and stripped. Only the shared result representation supports up to ten effective items; the legacy dated-event request remains capped at six. +- The HTTP wire accepts injected fetch and timeout-signal factories for executable contract tests. Production still defaults to `AbortSignal.timeout`. ## Files changed +Production: + +- `frontend/src/lib/birth-time-evidence.ts` - `frontend/src/lib/birth-time-journey-service.ts` - `frontend/src/lib/birth-time-journey-engine.ts` -- `frontend/src/lib/birth-time-journey-adapters.ts` - `frontend/src/lib/birth-time-journey-engine-model.ts` -- `frontend/src/lib/birth-time-evidence.ts` +- `frontend/src/lib/birth-time-journey-adapters.ts` +- `frontend/src/lib/birth-time-journey-dynamic-adapters.ts` +- `frontend/src/lib/birth-time-journey-assessment.ts` + +Tests and support: + - `frontend/tests/birth-time-journey-engine.test.ts` - `frontend/tests/birth-time-journey-adapters.test.ts` +- `frontend/tests/birth-time-journey-dynamic-adapters.test.ts` +- `frontend/tests/birth-time-journey-memory-store.ts` +- `frontend/tests/birth-time-journey-test-support.ts` +- `frontend/tests/birth-time-journey-service.test.ts` +- `frontend/tests/birth-time-agent-flow-test-support.ts` + +Documentation: + +- `docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md` - `.superpowers/sdd/task-3-report.md` -## RED +## RED evidence -1. Bundled Node focused run: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts` - - Failed during module instantiation because `parseCandidateDifferenceBuild` and `parseDynamicChoiceScoring` did not exist. Baseline result: 1 pass, 1 file-load failure. -2. After the first adapter implementation, the malformed score-key regression failed because a response containing `candidate_scores: { "not-a-time": 1 }` was accepted. -3. The exact-range regression then failed because an otherwise valid `05:34` score key could be added outside the `05:30—05:33` range. -4. The initial source-level legacy-auth assertion was too broad and matched the next dynamic method. It was narrowed to the exact legacy method section; production behavior did not change for this test-only correction. +1. The initial focused run failed to load because the dynamic response parsers did not exist. +2. Adapter regressions then exposed acceptance of malformed score keys, keys outside the submitted range, duplicate opportunity/partition identifiers, nested extra fields, and legacy evidence metadata incompatibility. +3. Interface and wire review probes exposed optional primary dynamic methods, source-regex authentication assertions, and a missing executable proof for exact URLs, bodies, authorization, and timeout behavior. +4. The final wire cleanup test injected a timeout factory and failed with `[]` instead of `[45000, 45000]`, proving that the seam was initially ignored. -## GREEN +## Final verification -1. Focused Task 3 suite: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts` - - 21 passed, 0 failed. -2. Complete birth-time frontend regression suite: +1. Focused adapter, wire, and evidence suite: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts tests/birth-time-journey-dynamic-adapters.test.ts tests/birth-time-evidence.test.ts` + - 29 passed, 0 failed. +2. Complete birth-time frontend suite: - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time*.test.ts` - - 207 passed, 0 failed. -3. Focused ESLint over all Task 3 source and test files: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node node_modules/eslint/bin/eslint.js ...` + - 208 passed, 0 failed. +3. Full frontend suite: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts` + - 283 passed, 0 failed. +4. ESLint across every changed production/test TypeScript module: - Passed with no diagnostics. -4. TypeScript diagnostic: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node node_modules/typescript/bin/tsc --noEmit --pretty false` - - No Task 3 diagnostics. It reproduces only the existing baseline `TS1501` in `tests/profile-persistence.test.ts:7` because the project targets ES2017 while that test uses an ES2018 regex flag. -5. `git diff --check` +5. TypeScript diagnostic: + - No Task 3 diagnostics. The only result is the known baseline `tests/profile-persistence.test.ts:7 TS1501`, caused by an ES2018 regex flag under the project's ES2017 target. +6. Pure-LOC audit: + - Every changed TypeScript file is at or below 250 pure LOC. The largest is `frontend/src/lib/birth-time-journey-service.ts` at 239; the split test-support modules are 171 and 111. +7. `git diff --check`: - Passed with no whitespace errors. ## Pre-work gate -- Ran `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` after reading both required sweep documents and the error ledger. -- The gate remained red only on the unrelated known fragment-governance baseline: `candidate_count` expected `0`, observed `1`. Remote visibility was blocked, so no cloud-sync claim is made. +- `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` remained red only on the unrelated known fragment-governance baseline: `candidate_count` expected `0`, observed `1`. +- Remote visibility was blocked, so no cloud-sync claim is made. ## Self-review -- Dynamic secrets and score vectors stay behind the existing server-only engine entrypoint. The public dynamic packet deliberately drops `candidateScores`, while the private partition map retains the exact server vector for later binding. -- Both response adapters are strict at their dynamic endpoint roots; model-controlled extra fields cannot be silently retained. -- The dynamic scorer cannot elevate confidence by mismatching effective counts, dimensions, evidence mode, evidence contents, algorithm version, or the existing high-confidence candidate gates. -- Cross-midnight ranges enumerate minutes modulo 24 hours, preventing a valid `23:59—00:00` result from being rejected or split. -- Legacy request payloads, endpoints, timeout, parsing behavior, and authentication remain unchanged. The event request schema remains at 6 even though the shared result compatibility schema can represent the v2 cap of 10. -- No dependency, logging, model prompt field, client response field, or persistence write was added. +- Dynamic secrets and candidate score vectors remain behind the server boundary. +- Wire tests use independent literal request bodies rather than production serializers and directly assert two `45_000` timeout calls and the exact injected signals. +- Missing-token tests prove both dynamic operations fail before fetch. Executable legacy tests prove no Authorization header reaches any legacy endpoint. +- Dynamic parsing is fail-closed; legacy parsing preserves its prior accept-and-strip behavior. +- Cross-midnight ranges enumerate minutes modulo 24 hours and bind score keys to the exact submitted interval. +- The extracted memory store has no dependency on the fixture module, so its re-export does not create a runtime cycle. +- No dependency, logging field, client response field, or persistence write was added. -## Concerns +## Known unrelated baseline -- The repository still lacks a standalone runtime `server-only` package for direct Node imports. Per the approved module-boundary decision, verification uses the production file's existing `import "server-only"` plus strict projection and source-contract tests rather than adding an unavailable dependency. -- The complete TypeScript diagnostic remains blocked by the unrelated ES2017/ES2018 regex baseline described above. - -## Review fixes - -- Restored byte-compatible legacy candidate parsing. Unknown nested evidence metadata is accepted and stripped exactly as before Task 3; dynamic response parsing no longer changes the legacy schema. -- Moved every v2 response schema, invariant, and mapping into `birth-time-journey-dynamic-adapters.ts`. Root responses, ranges, opportunities, partitions, and winning segments are strict; empty dynamic evidence is enforced before candidate construction. -- Split dynamic adapter regressions into `birth-time-journey-dynamic-adapters.test.ts`. Added duplicate opportunity/partition attacks, nested extra-field attacks, exact-range keys, cross-midnight mapping, and independent expected-value assertions. -- Made `buildDifferencePacket` and `scoreChoices` required on the primary `BirthTimeJourneyEngine`. Existing services use the explicit `LegacyBirthTimeJourneyEngine` pick, and legacy-only test doubles were narrowed without runtime behavior changes. -- Refactored HTTP execution into `createJourneyEngineWire`, whose `post` accepts one typed request object. The server-only factory remains the environment owner; engine assembly is injectable for executable fake-fetch verification without adding the unavailable `server-only` runtime dependency. -- Replaced greedy source-regex authentication tests with executable coverage for both exact dynamic URLs, serialized bodies, POST method, bearer header, 45-second abort signal, and missing-token zero-fetch behavior. All three legacy engine endpoints execute without an Authorization header. -- Removed newly introduced non-null assertions and narrowed the ownership scan to the client/request/component/hook boundary named by the plan. -- Kept every modified production and test TypeScript module at or below 250 pure LOC. The largest is `birth-time-journey-test-support.ts` at 249; Task 3 production modules range from 19 to 239. - -### Review RED - -1. The legacy compatibility regression failed with `unrecognized_keys` when an existing engine evidence item contained extra server metadata. -2. The new dynamic adapter suite failed to load because the protocol-specific module did not yet exist. -3. Independent review probes had shown nested dynamic `winning_segment` extras were silently stripped, optional primary engine methods weakened consumers, and the source-regex auth proof could remain green after removing authentication from one endpoint. - -### Review GREEN - -1. Focused legacy/dynamic/wire suite: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts tests/birth-time-journey-dynamic-adapters.test.ts` - - 24 passed, 0 failed. -2. Complete birth-time suite: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time*.test.ts` - - 210 passed, 0 failed. -3. Full frontend suite: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts` - - 285 passed, 0 failed. -4. Focused ESLint across all changed production/test TypeScript modules: - - Passed with no diagnostics. -5. TypeScript diagnostic: - - No Task 3 diagnostics; only the known `tests/profile-persistence.test.ts:7 TS1501` baseline remains. -6. Pure-LOC audit and `git diff --check`: - - Every changed TypeScript file is at or below 250 pure LOC; no whitespace errors. +- A clean TypeScript run is still blocked by `tests/profile-persistence.test.ts:7 TS1501`; Task 3 introduces no additional diagnostic. diff --git a/frontend/src/lib/birth-time-journey-adapters.ts b/frontend/src/lib/birth-time-journey-adapters.ts index 0d698e1b..4cc9517e 100644 --- a/frontend/src/lib/birth-time-journey-adapters.ts +++ b/frontend/src/lib/birth-time-journey-adapters.ts @@ -83,15 +83,7 @@ const eventDomainSchema = z.enum([ "career", "health_pressure", ]); -const candidateEvidenceApiSchema = z.object({ - event_id: z.string().uuid(), - domain: eventDomainSchema, - candidate_time: z.string(), - rule_ids: z.array(z.string()), - points: z.number(), -}); - -const candidateResultApiFields = { +const candidateResultApiSchema = z.object({ result_id: z.string().uuid(), confidence: z.enum(["low", "medium", "high"]), can_apply: z.boolean(), @@ -107,10 +99,15 @@ const candidateResultApiFields = { second_score: z.number(), margin_percent: z.number(), reasons: z.array(z.string()), - evidence: z.array(candidateEvidenceApiSchema), + evidence: z.array(z.object({ + event_id: z.string().uuid(), + domain: eventDomainSchema, + candidate_time: z.string(), + rule_ids: z.array(z.string()), + points: z.number(), + })), algorithm_version: z.string(), -} as const; -const candidateResultApiSchema = z.object(candidateResultApiFields).passthrough(); +}).passthrough(); class UnexpectedProfileSourceError extends Error { readonly name = "UnexpectedProfileSourceError"; diff --git a/frontend/src/lib/birth-time-journey-engine-model.ts b/frontend/src/lib/birth-time-journey-engine-model.ts index 070c92e9..40ad8deb 100644 --- a/frontend/src/lib/birth-time-journey-engine-model.ts +++ b/frontend/src/lib/birth-time-journey-engine-model.ts @@ -14,8 +14,6 @@ import type { JourneyEventScoreInput, } from "./birth-time-journey-service.ts"; -export const journeyEngineTimeoutMs = 45_000; - export class BirthTimeJourneyEngineError extends Error { readonly name = "BirthTimeJourneyEngineError"; readonly status: number; @@ -55,7 +53,10 @@ export function createJourneyEngineWire(options: { readonly apiBase: string; readonly dynamicToken: string | null; readonly fetchImpl: JourneyEngineFetch; + readonly signalFactory?: (timeoutMs: number) => AbortSignal; }): JourneyEngineWire { + const signalFactory = options.signalFactory + ?? ((timeoutMs: number) => AbortSignal.timeout(timeoutMs)); return { async post(input) { const token = options.dynamicToken?.trim(); @@ -69,7 +70,7 @@ export function createJourneyEngineWire(options: { ...(input.authentication === "dynamic" ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(input.body), - signal: AbortSignal.timeout(journeyEngineTimeoutMs), + signal: signalFactory(45_000), }); const payload = await response.json(); if (!response.ok) throw new BirthTimeJourneyEngineError(response.status); diff --git a/frontend/tests/birth-time-journey-engine.test.ts b/frontend/tests/birth-time-journey-engine.test.ts index 1a083103..23c0a124 100644 --- a/frontend/tests/birth-time-journey-engine.test.ts +++ b/frontend/tests/birth-time-journey-engine.test.ts @@ -6,10 +6,7 @@ import { BirthTimeJourneyEngineConfigurationError, createJourneyEngineMethods, createJourneyEngineWire, - differencePacketPayload, - dynamicChoiceScorePayload, eventScorePayload, - journeyEngineTimeoutMs, } from "../src/lib/birth-time-journey-engine-model.ts"; import type { JourneyEngineFetch } from "../src/lib/birth-time-journey-engine-model.ts"; @@ -69,53 +66,39 @@ const dynamicInput = { candidateModel: { version: "birth-time-choice-scoring-v2" }, } as const; -test("dynamic opportunity payload owns private evidence and candidate model server-side", () => { - const payload = differencePacketPayload(dynamicInput); - - assert.deepEqual(payload, { - case_id: "case-1", - as_of_date: "2026-07-18", - birth_date: "1990-01-01", - start_time: "05:30", - end_time: "05:33", - lat: 31.23, - lon: 121.47, - tz: 8, - evidence: [{ - question_id: "question-1", - opportunity_id: "career-window", - partition_id: "career-early", - dimension_code: "career", - candidate_scores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 }, - information_gain: 0.5, - }], - dismissed_opportunity_ids: ["dismissed-1"], - question_fingerprints: ["question-fingerprint-1"], - partition_fingerprints: ["partition-fingerprint-1"], - recent_ranges: [{ start_time: "05:30", end_time: "05:33" }], - candidate_model: { version: "birth-time-choice-scoring-v2" }, - }); - const evidence = payload.evidence[0]; - assert.ok(evidence); - assert.equal("option_id" in evidence, false); -}); - -test("dynamic score payload sends only server-resolved choice evidence", () => { - const payload = dynamicChoiceScorePayload(dynamicInput); - - assert.deepEqual(payload.choice_evidence, [{ - question_id: "question-1", - opportunity_id: "career-window", - partition_id: "career-early", - dimension_code: "career", - candidate_scores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 }, - information_gain: 0.5, - }]); - assert.equal("case_id" in payload, false); - assert.equal("candidate_model" in payload, false); - assert.equal("confidence" in payload, false); - assert.equal("can_apply" in payload, false); -}); +const expectedChoiceEvidence = [{ + question_id: "question-1", + opportunity_id: "career-window", + partition_id: "career-early", + dimension_code: "career", + candidate_scores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 }, + information_gain: 0.5, +}] as const; +const expectedOpportunityBody = { + case_id: "case-1", + as_of_date: "2026-07-18", + birth_date: "1990-01-01", + start_time: "05:30", + end_time: "05:33", + lat: 31.23, + lon: 121.47, + tz: 8, + evidence: expectedChoiceEvidence, + dismissed_opportunity_ids: ["dismissed-1"], + question_fingerprints: ["question-fingerprint-1"], + partition_fingerprints: ["partition-fingerprint-1"], + recent_ranges: [{ start_time: "05:30", end_time: "05:33" }], + candidate_model: { version: "birth-time-choice-scoring-v2" }, +} as const; +const expectedScoreBody = { + birth_date: "1990-01-01", + start_time: "05:30", + end_time: "05:33", + lat: 31.23, + lon: 121.47, + tz: 8, + choice_evidence: expectedChoiceEvidence, +} as const; const dynamicResponses: Readonly> = { "/api/dynamic_rectification_opportunities": { @@ -153,6 +136,8 @@ const dynamicResponses: Readonly> = { function engineHarness(dynamicToken: string | null) { const calls: { readonly url: string; readonly init: RequestInit }[] = []; + const timeoutCalls: number[] = []; + const timeoutSignal = new AbortController().signal; const fetchImpl: JourneyEngineFetch = async (url, init) => { calls.push({ url, init }); const payload = dynamicResponses[new URL(url).pathname]; @@ -162,8 +147,12 @@ function engineHarness(dynamicToken: string | null) { apiBase: "https://engine.invalid", dynamicToken, fetchImpl, + signalFactory(timeoutMs) { + timeoutCalls.push(timeoutMs); + return timeoutSignal; + }, }); - return { calls, engine: createJourneyEngineMethods(wire) }; + return { calls, timeoutCalls, timeoutSignal, engine: createJourneyEngineMethods(wire) }; } test("both dynamic endpoints send exact bodies with bearer auth and timeout signals", async () => { @@ -182,11 +171,11 @@ test("both dynamic endpoints send exact bodies with bearer auth and timeout sign assert.equal(new Headers(scoreCall.init.headers).get("authorization"), "Bearer server-secret"); assert.equal(opportunityCall.init.method, "POST"); assert.equal(scoreCall.init.method, "POST"); - assert.deepEqual(JSON.parse(String(opportunityCall.init.body)), differencePacketPayload(dynamicInput)); - assert.deepEqual(JSON.parse(String(scoreCall.init.body)), dynamicChoiceScorePayload(dynamicInput)); - assert.ok(opportunityCall.init.signal instanceof AbortSignal); - assert.ok(scoreCall.init.signal instanceof AbortSignal); - assert.equal(journeyEngineTimeoutMs, 45_000); + assert.deepEqual(JSON.parse(String(opportunityCall.init.body)), expectedOpportunityBody); + assert.deepEqual(JSON.parse(String(scoreCall.init.body)), expectedScoreBody); + assert.deepEqual(harness.timeoutCalls, [45_000, 45_000]); + assert.equal(opportunityCall.init.signal, harness.timeoutSignal); + assert.equal(scoreCall.init.signal, harness.timeoutSignal); }); test("missing dynamic token fails both endpoints before fetch", async () => { diff --git a/frontend/tests/birth-time-journey-memory-store.ts b/frontend/tests/birth-time-journey-memory-store.ts new file mode 100644 index 00000000..ed09735f --- /dev/null +++ b/frontend/tests/birth-time-journey-memory-store.ts @@ -0,0 +1,114 @@ +import type { + BirthTimeJourneyStore, + PersistedJourneyAssessment, + StoredRectificationCase, +} from "../src/lib/birth-time-journey-service.ts"; +import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts"; +import { createMemoryScoringJobs } from "./birth-time-scoring-memory-store.ts"; + +class MissingTestCaseError extends Error { + readonly name = "MissingTestCaseError"; +} + +export const journeyCaseId = "7299894c-10a8-4b45-91d1-339007282c50"; + +export function memoryStore(initialCase?: StoredRectificationCase) { + let savedAssessment: PersistedJourneyAssessment | null = null; + let savedCase = initialCase ?? null; + let committedTurnWrites = 0; + let legacyWrites = 0; + let guidedCandidateWrites = 0; + const scoringJobs = createMemoryScoringJobs({ + read: () => savedCase, + write: (value) => { + savedCase = value; + }, + committed: () => { + committedTurnWrites += 1; + }, + }); + const store: BirthTimeJourneyStore = { + async saveAssessment(value) { + savedAssessment = value; + return journeyCaseId; + }, + async loadCase() { + return savedCase; + }, + async saveScoring(value) { + legacyWrites += 1; + savedCase = value; + }, + async saveTurn(value, expectedVersion, actionId) { + if (!savedCase) { + throw new MissingTestCaseError(); + } + const processedActionIds = savedCase.processedActionIds ?? []; + if (processedActionIds.includes(actionId)) { + return savedCase; + } + if (savedCase.turnVersion !== expectedVersion) { + throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion ?? 0); + } + savedCase = { + ...value, + turnVersion: expectedVersion + 1, + processedActionIds: [...processedActionIds, actionId], + }; + committedTurnWrites += 1; + return savedCase; + }, + ...scoringJobs.methods, + async saveCandidateResult(value) { + legacyWrites += 1; + savedCase = value; + }, + async saveCandidate(value) { + legacyWrites += 1; + savedCase = value; + }, + async confirmCandidate(value) { + legacyWrites += 1; + savedCase = value; + }, + async commitGuidedCandidate(value, command) { + if (!savedCase) { + throw new MissingTestCaseError(); + } + const receipt = command.actionId.toLowerCase(); + const receipts = savedCase.processedActionIds ?? []; + if (receipts.includes(receipt)) { + return savedCase; + } + if (savedCase.turnVersion !== command.expectedVersion) { + throw new StaleJourneyTurnError( + savedCase.id, + command.expectedVersion, + savedCase.turnVersion ?? 0, + ); + } + savedCase = { + ...value, + turnVersion: command.expectedVersion + 1, + processedActionIds: [...receipts, receipt], + }; + guidedCandidateWrites += 1; + return savedCase; + }, + }; + return { + store, + savedAssessment: () => savedAssessment, + savedCase: () => savedCase, + committedTurnWrites: () => committedTurnWrites, + legacyWrites: () => legacyWrites, + guidedCandidateWrites: () => guidedCandidateWrites, + scoringJobStatus: scoringJobs.status, + scoringJobCount: scoringJobs.count, + setScoringJobAlgorithm: scoringJobs.setAlgorithm, + createdScoringCase: scoringJobs.createdCase, + replaceCase: (value: StoredRectificationCase) => { + savedCase = value; + }, + }; +} diff --git a/frontend/tests/birth-time-journey-test-support.ts b/frontend/tests/birth-time-journey-test-support.ts index 29259c59..daa92153 100644 --- a/frontend/tests/birth-time-journey-test-support.ts +++ b/frontend/tests/birth-time-journey-test-support.ts @@ -1,17 +1,20 @@ import { birthTimeAssessmentSchema, candidateResultSchema, lifeEventSchema } from "../src/lib/birth-time-journey.ts"; import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; -import type { BirthTimeJourneyStore, LegacyBirthTimeJourneyEngine, PersistedJourneyAssessment, StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts"; -import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts"; +import type { + LegacyBirthTimeJourneyEngine, + StoredRectificationCase, +} from "../src/lib/birth-time-journey-service.ts"; import type { EvidenceDomain } from "../src/lib/birth-time-question-planner.ts"; -import { createMemoryScoringJobs } from "./birth-time-scoring-memory-store.ts"; +import { + journeyCaseId, + memoryStore, +} from "./birth-time-journey-memory-store.ts"; -class UnexpectedTestCallError extends Error { readonly name = "UnexpectedTestCallError"; } - -class MissingTestCaseError extends Error { - readonly name = "MissingTestCaseError"; +class UnexpectedTestCallError extends Error { + readonly name = "UnexpectedTestCallError"; } -export const journeyCaseId = "7299894c-10a8-4b45-91d1-339007282c50"; +export { journeyCaseId, memoryStore }; export const draftActionId = "45857b75-4718-4590-aaf5-7113a03ea765"; export const confirmActionId = "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5"; export const secondActionId = "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea"; @@ -53,89 +56,6 @@ export function scanWithSigns(signs: readonly string[]) { }; } -export function memoryStore(initialCase?: StoredRectificationCase) { - let savedAssessment: PersistedJourneyAssessment | null = null; - let savedCase = initialCase ?? null; - let committedTurnWrites = 0; - let legacyWrites = 0; - let guidedCandidateWrites = 0; - const scoringJobs = createMemoryScoringJobs({ - read: () => savedCase, - write: (value) => { savedCase = value; }, - committed: () => { committedTurnWrites += 1; }, - }); - const store: BirthTimeJourneyStore = { - async saveAssessment(value) { - savedAssessment = value; - return journeyCaseId; - }, - async loadCase() { - return savedCase; - }, - async saveScoring(value) { - legacyWrites += 1; - savedCase = value; - }, - async saveTurn(value, expectedVersion, actionId) { - if (!savedCase) throw new MissingTestCaseError(); - const processedActionIds = savedCase.processedActionIds ?? []; - if (processedActionIds.includes(actionId)) return savedCase; - if (savedCase.turnVersion !== expectedVersion) { - throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion ?? 0); - } - savedCase = { - ...value, - turnVersion: expectedVersion + 1, - processedActionIds: [...processedActionIds, actionId], - }; - committedTurnWrites += 1; - return savedCase; - }, - ...scoringJobs.methods, - async saveCandidateResult(value) { - legacyWrites += 1; - savedCase = value; - }, - async saveCandidate(value) { - legacyWrites += 1; - savedCase = value; - }, - async confirmCandidate(value) { - legacyWrites += 1; - savedCase = value; - }, - async commitGuidedCandidate(value, command) { - if (!savedCase) throw new MissingTestCaseError(); - const receipt = command.actionId.toLowerCase(); - const receipts = savedCase.processedActionIds ?? []; - if (receipts.includes(receipt)) return savedCase; - if (savedCase.turnVersion !== command.expectedVersion) { - throw new StaleJourneyTurnError(savedCase.id, command.expectedVersion, savedCase.turnVersion ?? 0); - } - savedCase = { - ...value, - turnVersion: command.expectedVersion + 1, - processedActionIds: [...receipts, receipt], - }; - guidedCandidateWrites += 1; - return savedCase; - }, - }; - return { - store, - savedAssessment: () => savedAssessment, - savedCase: () => savedCase, - committedTurnWrites: () => committedTurnWrites, - legacyWrites: () => legacyWrites, - guidedCandidateWrites: () => guidedCandidateWrites, - scoringJobStatus: scoringJobs.status, - scoringJobCount: scoringJobs.count, - setScoringJobAlgorithm: scoringJobs.setAlgorithm, - createdScoringCase: scoringJobs.createdCase, - replaceCase: (value: StoredRectificationCase) => { savedCase = value; }, - }; -} - export const unusedJourneyEngine: LegacyBirthTimeJourneyEngine = { async scan() { throw new UnexpectedTestCallError(); }, async score() { throw new UnexpectedTestCallError(); },