fix: harden conversational rectification UI
This commit is contained in:
@@ -481,7 +481,9 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.conversational-session-actions { min-width: 0; display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); }
|
||||
.conversational-abandon { min-height: 44px; padding: 0 var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-danger); cursor: pointer; }
|
||||
.conversational-abandon.is-confirm { border-color: var(--color-danger); background: var(--color-danger); color: var(--color-on-dark); }
|
||||
.conversational-abandon-confirmation { border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); }
|
||||
.conversational-abandon-scrim { position: fixed; inset: 0; z-index: 80; display: grid; align-items: center; justify-items: center; padding: var(--space-4); overflow-y: auto; background: var(--color-scrim); }
|
||||
.conversational-abandon-confirmation { width: min(480px, 100%); border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); box-shadow: var(--shadow-elevated); }
|
||||
.conversational-abandon-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
|
||||
.conversational-abandon-confirmation > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
|
||||
.phrase-nowrap { white-space: nowrap; }
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import { ChatMessageContent } from "./chat-message-content.tsx";
|
||||
import {
|
||||
useConversationalRectification,
|
||||
@@ -85,10 +90,41 @@ export function ConversationalRectificationSurface({
|
||||
pendingConsultationQuestion,
|
||||
onContinueOriginalQuestion,
|
||||
}: SurfaceProps) {
|
||||
const [abandonArmed, setAbandonArmed] = useState(false);
|
||||
const [abandonArmedFor, setAbandonArmedFor] = useState<string | null>(null);
|
||||
const [localAnnouncement, setLocalAnnouncement] = useState<Readonly<{
|
||||
identity: string;
|
||||
message: string;
|
||||
}> | null>(null);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const abandonTrigger = useRef<HTMLButtonElement>(null);
|
||||
const abandonCancel = useRef<HTMLButtonElement>(null);
|
||||
const abandonConfirm = useRef<HTMLButtonElement>(null);
|
||||
const restoreAbandonFocus = useRef(false);
|
||||
const turn = controller.turn;
|
||||
const pendingQuestion = turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
|
||||
const abandonIdentity = turn
|
||||
? `${turn.caseId}:${turn.turnVersion}:${turn.status}`
|
||||
: null;
|
||||
const canAbandon = Boolean(
|
||||
turn?.actions.includes("abandon")
|
||||
&& turn.status !== "abandoned"
|
||||
&& turn.status !== "completed",
|
||||
);
|
||||
const abandonArmed = canAbandon && abandonArmedFor === abandonIdentity;
|
||||
const statusAnnouncement = localAnnouncement?.identity === abandonIdentity
|
||||
? localAnnouncement.message
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (abandonArmed) {
|
||||
abandonCancel.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (restoreAbandonFocus.current) {
|
||||
restoreAbandonFocus.current = false;
|
||||
abandonTrigger.current?.focus();
|
||||
}
|
||||
}, [abandonArmed]);
|
||||
|
||||
if (!turn) {
|
||||
return (
|
||||
@@ -115,6 +151,34 @@ export function ConversationalRectificationSurface({
|
||||
if (canAnswer && controller.draft.trim() && !controller.pending) safely(controller.answer());
|
||||
};
|
||||
const focusComposer = () => composer.current?.focus();
|
||||
const continueLocally = () => {
|
||||
if (abandonIdentity) {
|
||||
setLocalAnnouncement({
|
||||
identity: abandonIdentity,
|
||||
message: "现在可以继续填写真实经历,输入框已就绪;发送后才会推进校正进度。",
|
||||
});
|
||||
}
|
||||
focusComposer();
|
||||
};
|
||||
const closeAbandonDialog = () => {
|
||||
restoreAbandonFocus.current = true;
|
||||
setAbandonArmedFor(null);
|
||||
};
|
||||
const handleAbandonDialogKey = (event: ReactKeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeAbandonDialog();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
if (event.shiftKey && document.activeElement === abandonCancel.current) {
|
||||
event.preventDefault();
|
||||
abandonConfirm.current?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === abandonConfirm.current) {
|
||||
event.preventDefault();
|
||||
abandonCancel.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -241,6 +305,7 @@ export function ConversationalRectificationSurface({
|
||||
{turn.status === "abandoned" && <p>本次校正已放弃,候选时间没有应用。</p>}
|
||||
{turn.status === "completed" && turn.candidate.status === "confirmed"
|
||||
&& <p>候选时间已经过你的明确确认。</p>}
|
||||
{statusAnnouncement && <p>{statusAnnouncement}</p>}
|
||||
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -264,7 +329,7 @@ export function ConversationalRectificationSurface({
|
||||
className="button-secondary"
|
||||
disabled={controller.pending}
|
||||
type="button"
|
||||
onClick={() => safely(controller.resume())}
|
||||
onClick={continueLocally}
|
||||
>
|
||||
继续校正
|
||||
</button>
|
||||
@@ -279,12 +344,16 @@ export function ConversationalRectificationSurface({
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{turn.actions.includes("abandon") && !abandonArmed && (
|
||||
{canAbandon && !abandonArmed && (
|
||||
<button
|
||||
className="conversational-abandon"
|
||||
disabled={controller.pending}
|
||||
ref={abandonTrigger}
|
||||
type="button"
|
||||
onClick={() => setAbandonArmed(true)}
|
||||
onClick={() => {
|
||||
setLocalAnnouncement(null);
|
||||
setAbandonArmedFor(abandonIdentity);
|
||||
}}
|
||||
>
|
||||
放弃本次校正
|
||||
</button>
|
||||
@@ -292,27 +361,41 @@ export function ConversationalRectificationSurface({
|
||||
</footer>
|
||||
|
||||
{abandonArmed && (
|
||||
<section className="conversational-abandon-confirmation" role="alert">
|
||||
<p>放弃后会保留审计记录,但不会应用任何候选时间。确定继续吗?</p>
|
||||
<div>
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={controller.pending}
|
||||
type="button"
|
||||
onClick={() => setAbandonArmed(false)}
|
||||
>
|
||||
返回校正
|
||||
</button>
|
||||
<button
|
||||
className="conversational-abandon is-confirm"
|
||||
disabled={controller.pending}
|
||||
type="button"
|
||||
onClick={() => safely(controller.abandon())}
|
||||
>
|
||||
确认放弃且不应用候选
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="conversational-abandon-scrim">
|
||||
<section
|
||||
aria-describedby="conversational-abandon-description"
|
||||
aria-labelledby="conversational-abandon-title"
|
||||
aria-modal="true"
|
||||
className="conversational-abandon-confirmation"
|
||||
onKeyDown={handleAbandonDialogKey}
|
||||
role="alertdialog"
|
||||
>
|
||||
<h3 id="conversational-abandon-title">确认放弃本次校正?</h3>
|
||||
<p id="conversational-abandon-description">
|
||||
放弃后会保留审计记录,但不会应用任何候选时间。
|
||||
</p>
|
||||
<div>
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={controller.pending}
|
||||
ref={abandonCancel}
|
||||
type="button"
|
||||
onClick={closeAbandonDialog}
|
||||
>
|
||||
返回校正
|
||||
</button>
|
||||
<button
|
||||
className="conversational-abandon is-confirm"
|
||||
disabled={controller.pending}
|
||||
ref={abandonConfirm}
|
||||
type="button"
|
||||
onClick={() => safely(controller.abandon())}
|
||||
>
|
||||
确认放弃且不应用候选
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
|
||||
ConversationalRectificationRequestError,
|
||||
@@ -30,6 +30,7 @@ type MutationResult = Promise<ConversationalRectificationTurn | null>;
|
||||
export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{
|
||||
getSnapshot(): ConversationalRectificationControllerSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
synchronizeInitialTurn(turn: ConversationalRectificationTurn | null): void;
|
||||
setDraft(value: string): void;
|
||||
selectDomain(domain: EvidenceDomain | null): void;
|
||||
start(pendingConsultationQuestion?: string | null): MutationResult;
|
||||
@@ -53,6 +54,21 @@ type Mutation = Readonly<{
|
||||
clearDraftOnSuccess?: boolean;
|
||||
}>;
|
||||
|
||||
function createLatestControllerInput(initial: ControllerInput) {
|
||||
let current = initial;
|
||||
return {
|
||||
update(next: ControllerInput) {
|
||||
current = next;
|
||||
},
|
||||
send(command: ConversationalRectificationCommand) {
|
||||
return (current.send ?? sendConversationalRectificationCommand)(command);
|
||||
},
|
||||
onTurn(turn: ConversationalRectificationTurn) {
|
||||
current.onTurn?.(turn);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function displayError(error: unknown): string {
|
||||
return error instanceof ConversationalRectificationRequestError
|
||||
? error.message
|
||||
@@ -79,6 +95,7 @@ export function createConversationalRectificationController(
|
||||
error: "",
|
||||
};
|
||||
let activeMutation: MutationResult | null = null;
|
||||
let caseContext = 0;
|
||||
|
||||
const publish = (next: ConversationalRectificationControllerSnapshot) => {
|
||||
snapshot = next;
|
||||
@@ -90,13 +107,27 @@ export function createConversationalRectificationController(
|
||||
const acceptTurn = (
|
||||
turn: ConversationalRectificationTurn,
|
||||
clearDraft: boolean,
|
||||
expectedCaseContext: number,
|
||||
) => {
|
||||
const current = snapshot.turn;
|
||||
if (caseContext !== expectedCaseContext) return turn;
|
||||
if (current?.caseId === turn.caseId && current.turnVersion >= turn.turnVersion) return turn;
|
||||
const selectedDomain = clearDraft
|
||||
? null
|
||||
: snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
|
||||
? snapshot.selectedDomain
|
||||
: null;
|
||||
patch({
|
||||
turn,
|
||||
error: "",
|
||||
...(clearDraft ? { draft: "", selectedDomain: null } : {}),
|
||||
selectedDomain,
|
||||
...(clearDraft ? { draft: "" } : {}),
|
||||
});
|
||||
input.onTurn?.(turn);
|
||||
try {
|
||||
input.onTurn?.(turn);
|
||||
} catch {
|
||||
// A consumer callback is observational. It must never turn a durable success into a failure.
|
||||
}
|
||||
return turn;
|
||||
};
|
||||
const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({
|
||||
@@ -114,15 +145,20 @@ export function createConversationalRectificationController(
|
||||
if (activeMutation) return activeMutation;
|
||||
patch({ pending: true, error: "" });
|
||||
const turnAtStart = snapshot.turn;
|
||||
const caseContextAtStart = caseContext;
|
||||
const operation = registry.run(
|
||||
mutation.identity,
|
||||
(actionId) => send(mutation.command(actionId)),
|
||||
).then((turn) => acceptTurn(turn, mutation.clearDraftOnSuccess === true))
|
||||
).then((turn) => acceptTurn(
|
||||
turn,
|
||||
mutation.clearDraftOnSuccess === true,
|
||||
caseContextAtStart,
|
||||
))
|
||||
.catch(async (error: unknown) => {
|
||||
if (turnAtStart && staleTurn(error)) {
|
||||
try {
|
||||
const recovered = await recoverLatest(turnAtStart);
|
||||
return acceptTurn(recovered, false);
|
||||
return acceptTurn(recovered, false, caseContextAtStart);
|
||||
} catch (recoveryError) {
|
||||
patch({ error: displayError(recoveryError) });
|
||||
throw recoveryError;
|
||||
@@ -170,6 +206,29 @@ export function createConversationalRectificationController(
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
synchronizeInitialTurn(turn: ConversationalRectificationTurn | null) {
|
||||
const current = snapshot.turn;
|
||||
if (turn === null) {
|
||||
if (current === null) return;
|
||||
caseContext += 1;
|
||||
patch({ turn: null, draft: "", selectedDomain: null, error: "" });
|
||||
return;
|
||||
}
|
||||
if (current === null || current.caseId !== turn.caseId) {
|
||||
caseContext += 1;
|
||||
patch({ turn, draft: "", selectedDomain: null, error: "" });
|
||||
return;
|
||||
}
|
||||
if (turn.turnVersion <= current.turnVersion) return;
|
||||
patch({
|
||||
turn,
|
||||
error: "",
|
||||
selectedDomain: snapshot.selectedDomain
|
||||
&& turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
|
||||
? snapshot.selectedDomain
|
||||
: null,
|
||||
});
|
||||
},
|
||||
setDraft(value: string) {
|
||||
patch({ draft: value });
|
||||
},
|
||||
@@ -250,11 +309,23 @@ export function createConversationalRectificationController(
|
||||
export function useConversationalRectification(
|
||||
input: ControllerInput = {},
|
||||
): ConversationalRectificationController {
|
||||
const [controller] = useState(() => createConversationalRectificationController(input));
|
||||
const [latestInput] = useState(() => createLatestControllerInput(input));
|
||||
const [controller] = useState(() => createConversationalRectificationController({
|
||||
initialTurn: input.initialTurn,
|
||||
createActionId: input.createActionId,
|
||||
send: latestInput.send,
|
||||
onTurn: latestInput.onTurn,
|
||||
}));
|
||||
const snapshot = useSyncExternalStore(
|
||||
controller.subscribe,
|
||||
controller.getSnapshot,
|
||||
controller.getSnapshot,
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
latestInput.update(input);
|
||||
}, [input, latestInput]);
|
||||
useEffect(() => {
|
||||
controller.synchronizeInitialTurn(input.initialTurn ?? null);
|
||||
}, [controller, input.initialTurn]);
|
||||
return { ...controller, ...snapshot };
|
||||
}
|
||||
|
||||
@@ -83,16 +83,46 @@ export type ConversationalRectificationActionRegistry = ReturnType<
|
||||
typeof createConversationalRectificationActionRegistry
|
||||
>;
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function isRetryableTransportError(error: unknown): boolean {
|
||||
return !isAbortError(error) && (
|
||||
error instanceof TypeError
|
||||
|| error instanceof SyntaxError
|
||||
|| (error instanceof DOMException && error.name === "SyntaxError")
|
||||
);
|
||||
}
|
||||
|
||||
async function postCommandWithOneReplay(body: string) {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const result = await postJson({
|
||||
url: "/api/birth-time-conversation",
|
||||
body,
|
||||
retryLostResponse: false,
|
||||
});
|
||||
// postJson deliberately projects an unparseable non-ok body to null. Treating all null
|
||||
// error payloads as replayable also covers proxies that mislabel HTML as application/json.
|
||||
const nonJsonFailure = !result.response.ok && result.payload === null;
|
||||
if (attempt === 0 && (result.response.status === 502 || nonJsonFailure)) continue;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (attempt === 0 && isRetryableTransportError(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new TypeError("unreachable conversational rectification replay state");
|
||||
}
|
||||
|
||||
export async function sendConversationalRectificationCommand(
|
||||
command: ConversationalRectificationCommand,
|
||||
): Promise<ConversationalRectificationTurn> {
|
||||
const request = conversationalRectificationCommandSchema.parse(command);
|
||||
const body = JSON.stringify(request);
|
||||
try {
|
||||
const { response, payload } = await postJson({
|
||||
url: "/api/birth-time-conversation",
|
||||
body: JSON.stringify(request),
|
||||
retryLostResponse: true,
|
||||
});
|
||||
const { response, payload } = await postCommandWithOneReplay(body);
|
||||
if (!response.ok) {
|
||||
const parsed = publicErrorSchema.safeParse(payload);
|
||||
const safeServerMessage = response.status < 500 && parsed.success
|
||||
|
||||
@@ -75,6 +75,35 @@ test("action registry keeps one id for a failed canonical command and separates
|
||||
assert.deepEqual(seen, [firstActionId, firstActionId, secondActionId]);
|
||||
});
|
||||
|
||||
test("action registry releases a successful identity but keeps it for a manual failure retry", async () => {
|
||||
const ids = [firstActionId, secondActionId];
|
||||
const registry = createConversationalRectificationActionRegistry(
|
||||
() => ids.shift() ?? assert.fail("unexpected action id allocation"),
|
||||
);
|
||||
const identity = {
|
||||
caseId,
|
||||
turnVersion: 4,
|
||||
operation: "pause",
|
||||
payload: {},
|
||||
} as const;
|
||||
const seen: string[] = [];
|
||||
|
||||
await assert.rejects(registry.run(identity, async (actionId) => {
|
||||
seen.push(actionId);
|
||||
throw new TypeError("offline");
|
||||
}));
|
||||
await registry.run(identity, async (actionId) => {
|
||||
seen.push(actionId);
|
||||
return turn;
|
||||
});
|
||||
await registry.run(identity, async (actionId) => {
|
||||
seen.push(actionId);
|
||||
return turn;
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, [firstActionId, firstActionId, secondActionId]);
|
||||
});
|
||||
|
||||
test("client replays the exact action body after a lost response without adding a price", async (context) => {
|
||||
const bodies: string[] = [];
|
||||
let attempts = 0;
|
||||
@@ -103,11 +132,63 @@ test("client replays the exact action body after a lost response without adding
|
||||
assert.equal(Object.hasOwn(JSON.parse(bodies[0]) as object, "price"), false);
|
||||
});
|
||||
|
||||
test("502 and non-JSON failures expose one stable Chinese message", async (context) => {
|
||||
context.mock.method(globalThis, "fetch", async () => new Response("upstream html", {
|
||||
status: 502,
|
||||
headers: { "content-type": "text/html" },
|
||||
}));
|
||||
test("a JSON 502 is retried once with the exact action body before succeeding", async (context) => {
|
||||
const bodies: string[] = [];
|
||||
context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
bodies.push(String(init?.body));
|
||||
if (bodies.length === 1) {
|
||||
return Response.json({ code: "service_unavailable", message: "internal detail" }, { status: 502 });
|
||||
}
|
||||
return Response.json(turn);
|
||||
});
|
||||
|
||||
const result = await sendConversationalRectificationCommand({
|
||||
type: "pause",
|
||||
caseId,
|
||||
actionId: firstActionId,
|
||||
turnVersion: 4,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, turn);
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[0], bodies[1]);
|
||||
assert.equal(JSON.parse(bodies[0] ?? "{}").actionId, firstActionId);
|
||||
});
|
||||
|
||||
test("a non-ok non-JSON response is retried once even when a proxy mislabels it as JSON", async (context) => {
|
||||
const bodies: string[] = [];
|
||||
context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
bodies.push(String(init?.body));
|
||||
if (bodies.length === 1) {
|
||||
return new Response("proxy html", {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return Response.json(turn);
|
||||
});
|
||||
|
||||
const result = await sendConversationalRectificationCommand({
|
||||
type: "pause",
|
||||
caseId,
|
||||
actionId: firstActionId,
|
||||
turnVersion: 4,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, turn);
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[0], bodies[1]);
|
||||
});
|
||||
|
||||
test("502 and non-JSON failures retry only once before one stable Chinese message", async (context) => {
|
||||
const bodies: string[] = [];
|
||||
context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
bodies.push(String(init?.body));
|
||||
return new Response("upstream html", {
|
||||
status: 502,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
sendConversationalRectificationCommand({
|
||||
@@ -120,4 +201,6 @@ test("502 and non-JSON failures expose one stable Chinese message", async (conte
|
||||
&& error.status === 502
|
||||
&& error.message === CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
|
||||
);
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[0], bodies[1]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
@@ -53,6 +65,7 @@ function controller(overrides: Partial<ConversationalRectificationController> =
|
||||
error: "",
|
||||
getSnapshot: () => ({ turn, draft: "", selectedDomain: null, pending: false, error: "" }),
|
||||
subscribe: () => () => undefined,
|
||||
synchronizeInitialTurn: () => undefined,
|
||||
setDraft: () => undefined,
|
||||
selectDomain: () => undefined,
|
||||
start: async () => turn,
|
||||
@@ -132,3 +145,418 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
|
||||
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/);
|
||||
assert.match(component, /确认放弃且不应用候选/);
|
||||
});
|
||||
|
||||
type CdpResponse = Readonly<{
|
||||
id?: number;
|
||||
result?: unknown;
|
||||
error?: Readonly<{ message?: string }>;
|
||||
}>;
|
||||
|
||||
class CdpSession {
|
||||
private nextId = 0;
|
||||
private readonly pending = new Map<number, {
|
||||
resolve(value: unknown): void;
|
||||
reject(error: Error): void;
|
||||
}>();
|
||||
|
||||
private constructor(private readonly socket: WebSocket) {
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(String(event.data)) as CdpResponse;
|
||||
if (message.id === undefined) return;
|
||||
const request = this.pending.get(message.id);
|
||||
if (!request) return;
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) request.reject(new Error(message.error.message ?? "CDP command failed"));
|
||||
else request.resolve(message.result);
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string): Promise<CdpSession> {
|
||||
const socket = new WebSocket(url);
|
||||
await new Promise<void>((resolveConnection, rejectConnection) => {
|
||||
socket.addEventListener("open", () => resolveConnection(), { once: true });
|
||||
socket.addEventListener("error", () => rejectConnection(new Error("Unable to connect to Chromium CDP")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
return new CdpSession(socket);
|
||||
}
|
||||
|
||||
async send(method: string, params: unknown = {}): Promise<unknown> {
|
||||
const id = ++this.nextId;
|
||||
const response = new Promise<unknown>((resolveResponse, rejectResponse) => {
|
||||
this.pending.set(id, { resolve: resolveResponse, reject: rejectResponse });
|
||||
});
|
||||
this.socket.send(JSON.stringify({ id, method, params }));
|
||||
return response;
|
||||
}
|
||||
|
||||
async evaluate<T>(expression: string): Promise<T> {
|
||||
const response = await this.send("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
}) as {
|
||||
result?: { value?: T };
|
||||
exceptionDetails?: { text?: string };
|
||||
};
|
||||
if (response.exceptionDetails) {
|
||||
throw new Error(response.exceptionDetails.text ?? `Browser evaluation failed: ${expression}`);
|
||||
}
|
||||
return response.result?.value as T;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
function chromiumExecutable(): string {
|
||||
if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
|
||||
const systemCandidates = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
];
|
||||
|
||||
const cacheRoots = [
|
||||
join(homedir(), "Library/Caches/ms-playwright"),
|
||||
join(homedir(), ".cache/ms-playwright"),
|
||||
];
|
||||
for (const cacheRoot of cacheRoots) {
|
||||
if (!existsSync(cacheRoot)) continue;
|
||||
const headlessVersions = readdirSync(cacheRoot)
|
||||
.filter((entry) => entry.startsWith("chromium_headless_shell-"))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const version of headlessVersions) {
|
||||
const candidates = [
|
||||
join(cacheRoot, version, "chrome-headless-shell-mac-arm64/chrome-headless-shell"),
|
||||
join(cacheRoot, version, "chrome-headless-shell-mac-x64/chrome-headless-shell"),
|
||||
join(cacheRoot, version, "chrome-headless-shell-linux64/chrome-headless-shell"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
const versions = readdirSync(cacheRoot)
|
||||
.filter((entry) => entry.startsWith("chromium-"))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const version of versions) {
|
||||
const candidates = [
|
||||
join(cacheRoot, version, "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
|
||||
join(cacheRoot, version, "chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
|
||||
join(cacheRoot, version, "chrome-linux/chrome"),
|
||||
join(cacheRoot, version, "chrome-linux64/chrome"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const candidate of systemCandidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error("Chromium is required for the real DOM contract; set CHROME_PATH to its executable.");
|
||||
}
|
||||
|
||||
async function waitFor<T>(probe: () => Promise<T | null | false>, label: string): Promise<T> {
|
||||
const deadline = Date.now() + 8_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const value = await probe();
|
||||
if (value !== null && value !== false) return value;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 30));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}${lastError ? `: ${String(lastError)}` : ""}`);
|
||||
}
|
||||
|
||||
async function pressEscape(cdp: CdpSession) {
|
||||
await cdp.send("Input.dispatchKeyEvent", {
|
||||
type: "rawKeyDown",
|
||||
key: "Escape",
|
||||
code: "Escape",
|
||||
windowsVirtualKeyCode: 27,
|
||||
nativeVirtualKeyCode: 27,
|
||||
});
|
||||
await cdp.send("Input.dispatchKeyEvent", {
|
||||
type: "keyUp",
|
||||
key: "Escape",
|
||||
code: "Escape",
|
||||
windowsVirtualKeyCode: 27,
|
||||
nativeVirtualKeyCode: 27,
|
||||
});
|
||||
}
|
||||
|
||||
async function launchFixture(htmlPath: string, userDataDirectory: string): Promise<{
|
||||
browser: ChildProcess;
|
||||
cdp: CdpSession;
|
||||
}> {
|
||||
const executable = chromiumExecutable();
|
||||
const browser = spawn(executable, [
|
||||
executable.includes("chrome-headless-shell") ? "--headless" : "--headless=new",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-default-apps",
|
||||
"--disable-extensions",
|
||||
"--disable-features=Translate",
|
||||
"--disable-gpu",
|
||||
"--disable-sync",
|
||||
"--no-first-run",
|
||||
"--no-sandbox",
|
||||
"--remote-debugging-port=0",
|
||||
`--user-data-dir=${userDataDirectory}`,
|
||||
pathToFileURL(htmlPath).href,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const activePort = join(userDataDirectory, "DevToolsActivePort");
|
||||
const port = await waitFor(async () => {
|
||||
if (browser.exitCode !== null) {
|
||||
throw new Error(`Chromium exited before CDP was ready (${browser.exitCode})`);
|
||||
}
|
||||
if (!existsSync(activePort)) return null;
|
||||
return Number(readFileSync(activePort, "utf8").split("\n")[0]);
|
||||
}, "Chromium DevTools port");
|
||||
const target = await waitFor(async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
||||
const targets = await response.json() as Array<{ type?: string; webSocketDebuggerUrl?: string }>;
|
||||
return targets.find((candidate) => candidate.type === "page")?.webSocketDebuggerUrl ?? null;
|
||||
}, "Chromium page target");
|
||||
return { browser, cdp: await CdpSession.connect(target) };
|
||||
}
|
||||
|
||||
test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, dialog lifecycle, and live hook inputs", async () => {
|
||||
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
const directory = mkdtempSync(join(tmpdir(), "rectification-browser-"));
|
||||
const entryPath = join(directory, "fixture.tsx");
|
||||
const bundlePath = join(directory, "fixture.js");
|
||||
const htmlPath = join(directory, "fixture.html");
|
||||
const userDataDirectory = join(directory, "chrome-profile");
|
||||
const componentPath = join(frontendRoot, "src/components/conversational-birth-time-rectification.tsx");
|
||||
const hookPath = join(frontendRoot, "src/hooks/use-conversational-rectification.ts");
|
||||
const css = readFileSync(join(frontendRoot, "src/app/globals.css"), "utf8")
|
||||
.replace(/^@import[^;]+;\s*/gm, "");
|
||||
const fixture = `
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ConversationalRectificationSurface } from ${JSON.stringify(componentPath)};
|
||||
import { useConversationalRectification } from ${JSON.stringify(hookPath)};
|
||||
|
||||
const caseA = "00000000-0000-4000-8000-000000000821";
|
||||
const caseB = "00000000-0000-4000-8000-000000000829";
|
||||
const longWord = "D9SENSITIVEREFERENCE".repeat(70);
|
||||
const makeTurn = (caseId, turnVersion, status = "active") => ({
|
||||
caseId,
|
||||
journeyProtocol: "conversational-evidence-v3",
|
||||
status,
|
||||
turnVersion,
|
||||
narrative: "## 当前判断\\n\\n**05:18** 只是待验证候选。" + longWord,
|
||||
candidate: {
|
||||
status: "pending_validation",
|
||||
representativeTime: "05:18",
|
||||
rangeStart: "05:10",
|
||||
rangeEnd: "05:26",
|
||||
},
|
||||
technicalReceipt: {
|
||||
calculationVersion: "rectification-technical-v1",
|
||||
stableLayers: ["D1"],
|
||||
sensitiveLayers: ["D9", "D10"],
|
||||
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
|
||||
},
|
||||
evidenceRequest: status === "abandoned" ? null : {
|
||||
domains: ["career", "education", "relocation"],
|
||||
datePrecision: "month_preferred",
|
||||
freeTextAllowed: true,
|
||||
},
|
||||
evidenceRecap: [],
|
||||
actions: status === "abandoned" ? [] : status === "paused"
|
||||
? ["answer", "abandon"]
|
||||
: ["answer", "pause", "abandon"],
|
||||
pendingConsultationQuestion: null,
|
||||
});
|
||||
const turns = {
|
||||
activeA1: makeTurn(caseA, 1),
|
||||
activeA3: makeTurn(caseA, 3),
|
||||
activeB1: makeTurn(caseB, 1),
|
||||
abandonedB2: makeTurn(caseB, 2, "abandoned"),
|
||||
};
|
||||
const events = [];
|
||||
|
||||
function Harness() {
|
||||
const [initialTurn, setInitialTurn] = useState(null);
|
||||
const [transportLabel, setTransportLabel] = useState("first");
|
||||
const [callbackLabel, setCallbackLabel] = useState("first");
|
||||
const send = async (command) => {
|
||||
events.push("send:" + transportLabel + ":" + command.type);
|
||||
await new Promise((resolveSend) => setTimeout(resolveSend, 20));
|
||||
if (command.type === "pause") {
|
||||
return makeTurn(command.caseId, command.turnVersion + 1, "paused");
|
||||
}
|
||||
if (command.type === "abandon") {
|
||||
return makeTurn(command.caseId, command.turnVersion + 1, "abandoned");
|
||||
}
|
||||
return makeTurn(command.caseId ?? caseA, (command.turnVersion ?? 0) + 1);
|
||||
};
|
||||
const controller = useConversationalRectification({
|
||||
initialTurn,
|
||||
send,
|
||||
onTurn: (next) => events.push("turn:" + callbackLabel + ":" + next.status),
|
||||
});
|
||||
useEffect(() => {
|
||||
globalThis.__rectificationHarness = {
|
||||
events,
|
||||
setCallbackLabel,
|
||||
setTransportLabel,
|
||||
setTurn(name) { setInitialTurn(name === "none" ? null : turns[name]); },
|
||||
};
|
||||
globalThis.__rectificationReady = true;
|
||||
});
|
||||
return <ConversationalRectificationSurface controller={controller} />;
|
||||
}
|
||||
createRoot(document.getElementById("root")).render(<Harness />);
|
||||
`;
|
||||
let browser: ChildProcess | null = null;
|
||||
let cdp: CdpSession | null = null;
|
||||
try {
|
||||
writeFileSync(entryPath, fixture);
|
||||
await build({
|
||||
absWorkingDir: frontendRoot,
|
||||
bundle: true,
|
||||
define: { "process.env.NODE_ENV": '"test"' },
|
||||
entryPoints: [entryPath],
|
||||
format: "iife",
|
||||
jsx: "automatic",
|
||||
logLevel: "silent",
|
||||
nodePaths: [join(frontendRoot, "node_modules")],
|
||||
outfile: bundlePath,
|
||||
platform: "browser",
|
||||
});
|
||||
writeFileSync(htmlPath, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}\nhtml,body{height:auto;overflow:auto}body{padding:12px}#root{width:100%;min-width:0}</style></head><body><main id="root"></main><script src="${pathToFileURL(bundlePath).href}"></script></body></html>`);
|
||||
|
||||
({ browser, cdp } = await launchFixture(htmlPath, userDataDirectory));
|
||||
await cdp.send("Runtime.enable");
|
||||
await cdp.send("Page.bringToFront");
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 390,
|
||||
height: 844,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
});
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("globalThis.__rectificationReady === true") ?? Promise.resolve(false),
|
||||
"React fixture readiness",
|
||||
);
|
||||
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("document.body.textContent.includes('当前判断')") ?? Promise.resolve(false),
|
||||
"async initial turn",
|
||||
);
|
||||
|
||||
const layout = await cdp.evaluate<{
|
||||
viewport: number;
|
||||
scrollWidth: number;
|
||||
surfaceWidth: number;
|
||||
shortestButton: number;
|
||||
}>(`(() => {
|
||||
const buttons = [...document.querySelectorAll('.conversational-rectification button')];
|
||||
return {
|
||||
viewport: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
surfaceWidth: document.querySelector('.conversational-rectification').getBoundingClientRect().width,
|
||||
shortestButton: Math.min(...buttons.map((button) => button.getBoundingClientRect().height)),
|
||||
};
|
||||
})()`);
|
||||
assert.equal(layout.viewport, 390);
|
||||
assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
|
||||
assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`);
|
||||
assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`);
|
||||
|
||||
await cdp.evaluate("document.querySelector('[data-evidence-domain=career]').click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'conversational-rectification-answer'") ?? Promise.resolve(false),
|
||||
"domain-to-composer focus",
|
||||
);
|
||||
|
||||
await cdp.evaluate("globalThis.__rectificationHarness.setTransportLabel('second'); globalThis.__rectificationHarness.setCallbackLabel('second')");
|
||||
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('暂停,稍后继续')).click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("document.body.textContent.includes('继续校正')") ?? Promise.resolve(false),
|
||||
"paused response",
|
||||
);
|
||||
assert.deepEqual(
|
||||
await cdp.evaluate<string[]>("globalThis.__rectificationHarness.events.slice()"),
|
||||
["send:second:pause", "turn:second:paused"],
|
||||
);
|
||||
|
||||
const beforeContinue = await cdp.evaluate<number>("globalThis.__rectificationHarness.events.length");
|
||||
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('继续校正')).click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'conversational-rectification-answer' && document.body.textContent.includes('现在可以继续填写')") ?? Promise.resolve(false),
|
||||
"local paused continuation feedback",
|
||||
);
|
||||
assert.equal(await cdp.evaluate<number>("globalThis.__rectificationHarness.events.length"), beforeContinue);
|
||||
|
||||
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("document.body.textContent.includes('放弃本次校正') && !document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false),
|
||||
"newer same-case initial turn",
|
||||
);
|
||||
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()");
|
||||
const dialog = await waitFor<{ title: string; description: string; active: string }>(
|
||||
() => cdp!.evaluate<false | { title: string; description: string; active: string }>(`(() => {
|
||||
const dialog = document.querySelector('[role=alertdialog]');
|
||||
if (!dialog) return false;
|
||||
return {
|
||||
title: document.getElementById(dialog.getAttribute('aria-labelledby'))?.textContent ?? '',
|
||||
description: document.getElementById(dialog.getAttribute('aria-describedby'))?.textContent ?? '',
|
||||
active: document.activeElement?.textContent?.trim() ?? '',
|
||||
};
|
||||
})()`),
|
||||
"abandon alertdialog",
|
||||
);
|
||||
assert.match(dialog.title, /确认放弃/);
|
||||
assert.match(dialog.description, /不会应用任何候选时间/);
|
||||
assert.equal(dialog.active, "返回校正");
|
||||
|
||||
await pressEscape(cdp);
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("!document.querySelector('[role=alertdialog]') && document.activeElement?.textContent?.includes('放弃本次校正')") ?? Promise.resolve(false),
|
||||
"Escape close and trigger focus restoration",
|
||||
);
|
||||
|
||||
await cdp.evaluate("document.activeElement.click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
|
||||
"reopened abandon dialog",
|
||||
);
|
||||
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeB1')");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("!document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false),
|
||||
"case-switch dialog reset",
|
||||
);
|
||||
|
||||
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
|
||||
"case-B abandon dialog",
|
||||
);
|
||||
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('abandonedB2')");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("!document.querySelector('[role=alertdialog]') && document.body.textContent.includes('本次校正已放弃')") ?? Promise.resolve(false),
|
||||
"terminal dialog reset",
|
||||
);
|
||||
} finally {
|
||||
cdp?.close();
|
||||
browser?.kill("SIGKILL");
|
||||
browser?.stdout?.destroy();
|
||||
browser?.stderr?.destroy();
|
||||
browser?.unref();
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from "../src/lib/conversational-rectification/contracts.ts";
|
||||
|
||||
const caseId = "00000000-0000-4000-8000-000000000811";
|
||||
const otherCaseId = "00000000-0000-4000-8000-000000000819";
|
||||
const actionIds = [
|
||||
"00000000-0000-4000-8000-000000000812",
|
||||
"00000000-0000-4000-8000-000000000813",
|
||||
@@ -129,16 +130,39 @@ test("a stale answer resumes the latest turn and returns recovered state without
|
||||
},
|
||||
});
|
||||
controller.setDraft("2022 年 3 月搬到另一座城市");
|
||||
controller.selectDomain("relocation");
|
||||
|
||||
const result = await controller.answer("relocation");
|
||||
const result = await controller.answer();
|
||||
|
||||
assert.deepEqual(result, recovered);
|
||||
assert.deepEqual(commands.map((command) => command.type), ["answer", "resume"]);
|
||||
assert.equal(controller.getSnapshot().turn?.turnVersion, 5);
|
||||
assert.equal(controller.getSnapshot().draft, "2022 年 3 月搬到另一座城市");
|
||||
assert.equal(controller.getSnapshot().selectedDomain, null);
|
||||
assert.equal(controller.getSnapshot().error, "");
|
||||
});
|
||||
|
||||
test("a stale recovery retains the selected domain only while the recovered turn still requests it", async () => {
|
||||
const recovered = activeTurn(5);
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: activeTurn(2),
|
||||
createActionId: idFactory(),
|
||||
send: async (command) => {
|
||||
if (command.type === "answer") {
|
||||
throw new ConversationalRectificationRequestError(409, "stale_turn", "请加载最新进度后再试。");
|
||||
}
|
||||
return recovered;
|
||||
},
|
||||
});
|
||||
controller.setDraft("2021 年 7 月开始第一份工作");
|
||||
controller.selectDomain("career");
|
||||
|
||||
await controller.answer();
|
||||
|
||||
assert.equal(controller.getSnapshot().draft, "2021 年 7 月开始第一份工作");
|
||||
assert.equal(controller.getSnapshot().selectedDomain, "career");
|
||||
});
|
||||
|
||||
test("a changed payload receives a different action id after a failed send", async () => {
|
||||
const commands: ConversationalRectificationCommand[] = [];
|
||||
const controller = createConversationalRectificationController({
|
||||
@@ -157,3 +181,118 @@ test("a changed payload receives a different action id after a failed send", asy
|
||||
|
||||
assert.notEqual(commands[0]?.actionId, commands[1]?.actionId);
|
||||
});
|
||||
|
||||
test("a successful turn stays successful when the consumer onTurn callback throws", async () => {
|
||||
const next = activeTurn(3);
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: activeTurn(2),
|
||||
createActionId: idFactory(),
|
||||
send: async () => next,
|
||||
onTurn: () => {
|
||||
throw new Error("consumer render side effect failed");
|
||||
},
|
||||
});
|
||||
controller.setDraft("2021 年 7 月开始第一份工作");
|
||||
|
||||
const result = await controller.answer("career");
|
||||
|
||||
assert.deepEqual(result, next);
|
||||
assert.equal(controller.getSnapshot().turn?.turnVersion, 3);
|
||||
assert.equal(controller.getSnapshot().draft, "");
|
||||
assert.equal(controller.getSnapshot().error, "");
|
||||
});
|
||||
|
||||
test("external initial turns adopt only newer same-case state", () => {
|
||||
const controller = createConversationalRectificationController({ initialTurn: null });
|
||||
|
||||
controller.synchronizeInitialTurn(activeTurn(4));
|
||||
assert.equal(controller.getSnapshot().turn?.turnVersion, 4);
|
||||
|
||||
controller.setDraft("仍在填写的经历");
|
||||
controller.selectDomain("career");
|
||||
controller.synchronizeInitialTurn(activeTurn(3));
|
||||
assert.equal(controller.getSnapshot().turn?.turnVersion, 4);
|
||||
assert.equal(controller.getSnapshot().draft, "仍在填写的经历");
|
||||
assert.equal(controller.getSnapshot().selectedDomain, "career");
|
||||
|
||||
const newer = {
|
||||
...activeTurn(5),
|
||||
evidenceRequest: {
|
||||
domains: ["education", "relocation"],
|
||||
datePrecision: "month_preferred",
|
||||
freeTextAllowed: true,
|
||||
},
|
||||
} satisfies ConversationalRectificationTurn;
|
||||
controller.synchronizeInitialTurn(newer);
|
||||
assert.equal(controller.getSnapshot().turn?.turnVersion, 5);
|
||||
assert.equal(controller.getSnapshot().draft, "仍在填写的经历");
|
||||
assert.equal(controller.getSnapshot().selectedDomain, null);
|
||||
});
|
||||
|
||||
test("switching cases synchronizes immediately and an old in-flight response cannot overwrite it", async () => {
|
||||
let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined;
|
||||
const request = new Promise<ConversationalRectificationTurn>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: activeTurn(2),
|
||||
createActionId: idFactory(),
|
||||
send: async () => request,
|
||||
});
|
||||
controller.setDraft("旧案例输入");
|
||||
const pending = controller.answer("career");
|
||||
const newCase = { ...activeTurn(1), caseId: otherCaseId };
|
||||
|
||||
controller.synchronizeInitialTurn(newCase);
|
||||
|
||||
assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId);
|
||||
assert.equal(controller.getSnapshot().draft, "");
|
||||
resolveRequest?.(activeTurn(9));
|
||||
await pending;
|
||||
|
||||
assert.equal(controller.getSnapshot().turn?.caseId, otherCaseId);
|
||||
assert.equal(controller.getSnapshot().turn?.turnVersion, 1);
|
||||
assert.equal(controller.getSnapshot().pending, false);
|
||||
});
|
||||
|
||||
test("a newer external same-case turn wins over an older in-flight response", async () => {
|
||||
let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined;
|
||||
const request = new Promise<ConversationalRectificationTurn>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: activeTurn(2),
|
||||
createActionId: idFactory(),
|
||||
send: async () => request,
|
||||
});
|
||||
controller.setDraft("在途输入");
|
||||
const pending = controller.answer("career");
|
||||
|
||||
controller.synchronizeInitialTurn(activeTurn(5));
|
||||
resolveRequest?.(activeTurn(3));
|
||||
await pending;
|
||||
|
||||
assert.equal(controller.getSnapshot().turn?.turnVersion, 5);
|
||||
});
|
||||
|
||||
test("an external same-version turn is not replaced by a late response", async () => {
|
||||
let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined;
|
||||
const request = new Promise<ConversationalRectificationTurn>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: activeTurn(4),
|
||||
createActionId: idFactory(),
|
||||
send: async () => request,
|
||||
});
|
||||
controller.setDraft("在途输入");
|
||||
const pending = controller.answer("career");
|
||||
const external = { ...activeTurn(5), narrative: "外部已同步的最新轮次" };
|
||||
const lateResponse = { ...activeTurn(5), narrative: "较晚返回的旧请求" };
|
||||
|
||||
controller.synchronizeInitialTurn(external);
|
||||
resolveRequest?.(lateResponse);
|
||||
await pending;
|
||||
|
||||
assert.equal(controller.getSnapshot().turn?.narrative, "外部已同步的最新轮次");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user