feat: expose dynamic rectification actions
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { birthTimeGuideRequestSchema } from "../src/lib/birth-time-guide-agent.ts";
|
||||
import { birthTimeJourneyRequestSchema } from "../src/lib/birth-time-journey-request.ts";
|
||||
import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts";
|
||||
import { storedDynamicJourneyResponse } from "../src/lib/birth-time-journey-response.ts";
|
||||
import { dynamicCase, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
|
||||
const caseId = "45857b75-4718-4590-aaf5-7113a03ea765";
|
||||
const actionId = "a9890e09-d535-46f0-9a36-86017515a5a1";
|
||||
|
||||
test("choice commands accept only public ids", () => {
|
||||
const valid = {
|
||||
type: "answer_dynamic_choice",
|
||||
caseId,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: persistedQuestion.options[0].optionId,
|
||||
};
|
||||
assert.equal(birthTimeJourneyRequestSchema.safeParse(valid).success, true);
|
||||
for (const field of ["partitionId", "candidateScores", "confidence", "time"] as const) {
|
||||
assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, [field]: "forged" }).success, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("unmatched context is optional, trimmed, and bounded", () => {
|
||||
const valid = {
|
||||
type: "reframe_unmatched",
|
||||
caseId,
|
||||
actionId,
|
||||
turnVersion: 8,
|
||||
questionId: persistedQuestion.questionId,
|
||||
note: " 更像是 2017 年 ",
|
||||
};
|
||||
const parsed = birthTimeGuideRequestSchema.parse(valid);
|
||||
assert.equal(parsed.type === "reframe_unmatched" ? parsed.note : null, "更像是 2017 年");
|
||||
assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, note: "字".repeat(241) }).success, false);
|
||||
assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, partitionId: "private" }).success, false);
|
||||
});
|
||||
|
||||
test("dynamic responses preserve the protocol discriminant without private scoring data", () => {
|
||||
const response = storedDynamicJourneyResponse(dynamicCase());
|
||||
const parsed = parseJourneyResponse(response);
|
||||
|
||||
assert.equal(parsed.journeyProtocol, "dynamic-choice-v2");
|
||||
assert.equal(parsed.nextAction.kind, "ask_dynamic_choice");
|
||||
const serialized = JSON.stringify(parsed);
|
||||
assert.doesNotMatch(serialized, /partitionId|candidateScores|agentContext|candidateModel/);
|
||||
assert.throws(() => parseJourneyResponse({ ...response, partitionId: "forged" }));
|
||||
});
|
||||
|
||||
test("dynamic routes authenticate before parsing and dispatch scoped methods", () => {
|
||||
const journeyRoute = readFileSync(new URL("../src/app/api/birth-time-journey/route.ts", import.meta.url), "utf8");
|
||||
const guideRoute = readFileSync(new URL("../src/app/api/birth-time-guide/route.ts", import.meta.url), "utf8");
|
||||
assert.ok(journeyRoute.indexOf("auth.getUser") < journeyRoute.indexOf("requestPayload(request)"));
|
||||
assert.ok(guideRoute.indexOf("auth.getUser") < guideRoute.indexOf("requestPayload(request)"));
|
||||
assert.match(journeyRoute, /answerDynamicChoice/);
|
||||
assert.match(journeyRoute, /pollDynamicScoringJob/);
|
||||
assert.match(guideRoute, /generateQuestion/);
|
||||
assert.match(guideRoute, /submitUnmatchedContext/);
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
draftBirthTimeEvidence,
|
||||
generateDynamicBirthTimeQuestion,
|
||||
reframeUnmatchedBirthTimeAnswer,
|
||||
requestBirthTimeGuidePrompt,
|
||||
} from "../src/lib/birth-time-journey-client.ts";
|
||||
import { highConfirmationTurn } from "./birth-time-journey-client-test-support.ts";
|
||||
@@ -94,3 +96,35 @@ test("guide client rejects raw model metadata and malformed nested turns", async
|
||||
|
||||
await assert.rejects(requestBirthTimeGuidePrompt(caseId));
|
||||
});
|
||||
|
||||
test("dynamic guide commands send only public coordination fields", async (context) => {
|
||||
const payloads: unknown[] = [];
|
||||
context.mock.method(globalThis, "fetch", async (
|
||||
_input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
payloads.push(JSON.parse(String(init?.body)));
|
||||
return new Response(JSON.stringify(highConfirmationTurn), { status: 200 });
|
||||
});
|
||||
|
||||
await generateDynamicBirthTimeQuestion(caseId, actionId, 4);
|
||||
await reframeUnmatchedBirthTimeAnswer({
|
||||
caseId,
|
||||
actionId,
|
||||
turnVersion: 5,
|
||||
questionId: "11111111-1111-4111-8111-111111111111",
|
||||
note: " 时间更早 ",
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads, [
|
||||
{ type: "generate_dynamic_question", caseId, actionId, turnVersion: 4 },
|
||||
{
|
||||
type: "reframe_unmatched",
|
||||
caseId,
|
||||
actionId,
|
||||
turnVersion: 5,
|
||||
questionId: "11111111-1111-4111-8111-111111111111",
|
||||
note: "时间更早",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const turnSource = read("../src/components/birth-time-guide-turn.tsx");
|
||||
const draftSource = read("../src/components/birth-time-evidence-draft-card.tsx");
|
||||
const candidateSource = read("../src/components/birth-time-candidate-result.tsx");
|
||||
const hookSource = read("../src/hooks/use-birth-time-guided-journey.ts");
|
||||
const automaticEffectsSource = read("../src/hooks/use-birth-time-automatic-journey-effects.ts");
|
||||
|
||||
test("guided rectification renders exactly the persisted action, never a questionnaire slice", () => {
|
||||
assert.match(rectificationSource, /journey\.nextAction/);
|
||||
@@ -36,12 +37,12 @@ test("draft review is explicit, domain locked, and incomplete confirmation stays
|
||||
});
|
||||
|
||||
test("guided orchestration owns fallback copy, unique actions, polling, and retry", () => {
|
||||
assert.match(hookSource, /fallbackQuestionCopy/);
|
||||
assert.match(hookSource, /requestBirthTimeGuidePrompt/);
|
||||
assert.match(hookSource, /crypto\.randomUUID\(\)/);
|
||||
assert.match(hookSource, /runBirthTimeScoringPoll/);
|
||||
assert.match(automaticEffectsSource, /fallbackQuestionCopy/);
|
||||
assert.match(automaticEffectsSource, /requestBirthTimeGuidePrompt/);
|
||||
assert.match(automaticEffectsSource, /crypto\.randomUUID\(\)/);
|
||||
assert.match(automaticEffectsSource, /runBirthTimeScoringPoll/);
|
||||
assert.match(hookSource, /retry_scoring/);
|
||||
assert.match(hookSource, /AbortController/);
|
||||
assert.match(automaticEffectsSource, /AbortController/);
|
||||
});
|
||||
|
||||
test("candidate UI is nextAction-gated and keeps application boundary explicit", () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { runBirthTimeScoringPoll } from "../src/lib/birth-time-guided-polling.ts";
|
||||
import { claimMutation, publishCurrentJourney } from "../src/lib/birth-time-guided-effect-coordinator.ts";
|
||||
import { storedDynamicJourneyResponse } from "../src/lib/birth-time-journey-response.ts";
|
||||
import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts";
|
||||
import { highConfirmationTurn } from "./birth-time-journey-client-test-support.ts";
|
||||
|
||||
@@ -94,3 +97,43 @@ test("bounded polling preserves the pending turn instead of inventing completion
|
||||
assert.equal(result.kind, "exhausted");
|
||||
assert.equal(result.turn.nextAction.kind, "score_pending");
|
||||
});
|
||||
|
||||
test("duplicate option clicks publish one advanced turn", async () => {
|
||||
const gate = Promise.withResolvers<typeof completedTurn>();
|
||||
const sent: string[] = [];
|
||||
const published: typeof completedTurn[] = [];
|
||||
const lock = { current: false };
|
||||
const select = async (optionId: string) => {
|
||||
const release = claimMutation(lock);
|
||||
if (release === null) return;
|
||||
sent.push(optionId);
|
||||
const turn = await gate.promise;
|
||||
published.push(turn);
|
||||
release();
|
||||
};
|
||||
|
||||
const first = select("primary-option");
|
||||
const duplicate = select("primary-option");
|
||||
gate.resolve(completedTurn);
|
||||
await Promise.all([first, duplicate]);
|
||||
|
||||
assert.deepEqual(sent, ["primary-option"]);
|
||||
assert.deepEqual(published, [completedTurn]);
|
||||
});
|
||||
|
||||
test("a stale generated question cannot replace a newer turn", () => {
|
||||
const expected = parseJourneyResponse(storedDynamicJourneyResponse(dynamicCase()));
|
||||
const current = parseJourneyResponse({ ...expected, turnVersion: expected.turnVersion + 1 });
|
||||
let published = 0;
|
||||
|
||||
const accepted = publishCurrentJourney({
|
||||
expected,
|
||||
current,
|
||||
next: expected,
|
||||
publish: () => { published += 1; },
|
||||
});
|
||||
|
||||
assert.equal(accepted, false);
|
||||
assert.equal(current.turnVersion, 8);
|
||||
assert.equal(published, 0);
|
||||
});
|
||||
|
||||
@@ -72,6 +72,21 @@ test("request identity cache and scheduled polling deduplicate Strict Mode start
|
||||
assert.equal(starts, 1);
|
||||
});
|
||||
|
||||
test("a failed generation identity remains retryable", async () => {
|
||||
const cache = createIdentityRequestCache<number>();
|
||||
let attempts = 0;
|
||||
await assert.rejects(cache.run("case:4", async () => {
|
||||
attempts += 1;
|
||||
throw new TypeError("offline");
|
||||
}));
|
||||
|
||||
assert.equal(await cache.run("case:4", async () => {
|
||||
attempts += 1;
|
||||
return 8;
|
||||
}), 8);
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test("a resolved mutation cannot publish over a changed case or version", () => {
|
||||
const expected = guidedBirthTimePreview("birth-time-rectification");
|
||||
const current = parseJourneyResponse({ ...expected, turnVersion: expected.turnVersion + 1 });
|
||||
|
||||
Reference in New Issue
Block a user