- 上下文清单、任务列表与详情、恢复控制、运行观察、创作对话与消息流、正式变更决策面板、正文写作与候选审阅、资料研究等页面同步本轮后端能力。 - 事件标签表补齐后端事件类型;查询键与生成客户端随 HTTP 契约重新生成。 - 依赖调整后 lockfile 同步;交互用例与浏览器旅程 spec 一并更新。 - 证据:make 前端检查(lint + typecheck + Pi 工具桥 tsc)exit 0;make 前端测试 37 文件 / 120 用例通过。
216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
import { webcrypto } from "node:crypto";
|
||
import { afterEach, expect, it, vi } from "vitest";
|
||
import {
|
||
act,
|
||
cleanup,
|
||
fireEvent,
|
||
render,
|
||
screen,
|
||
} from "@testing-library/react";
|
||
import { Ui依赖装配 } from "../../src/应用/依赖装配";
|
||
import { Ui任务详情 } from "../../src/功能/任务运行/任务详情";
|
||
import type { 任务 } from "../../src/功能/任务运行/数据";
|
||
vi.mock("../../src/功能/上下文", () => ({ Ui任务上下文: () => null }));
|
||
vi.mock("../../src/功能/作者经验/运行观察", () => ({ Ui运行观察: () => null }));
|
||
const task: 任务 = {
|
||
task_id: "cancelled-task",
|
||
state: "cancelled",
|
||
work_id: "work",
|
||
task_type: "生成正文",
|
||
supports_step_advance: false,
|
||
last_sequence: 9,
|
||
execution_final_sequence: 9,
|
||
accounting_revision: 0,
|
||
result_refs: [],
|
||
steps: [],
|
||
};
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.useRealTimers();
|
||
vi.unstubAllGlobals();
|
||
sessionStorage.clear();
|
||
});
|
||
// @muse-case {"case_id":"O08-accounting-after-cancel-replay","then":"取消任务未知账目补录在刷新后重放相同请求,回执后读到新账目版本,任务仍取消且无持续SSE或费用轮询"}
|
||
it("O08-accounting-after-cancel-replay:取消后补账与原请求恢复不复活执行", async () => {
|
||
vi.stubGlobal("crypto", webcrypto);
|
||
const eventSource = vi.fn(() => {
|
||
throw new Error("终态不得建立SSE");
|
||
});
|
||
vi.stubGlobal("EventSource", eventSource);
|
||
const sent: Record<string, unknown>[] = [];
|
||
let revision = 0,
|
||
reads = 0;
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async (request: Request) => {
|
||
const path = new URL(request.url).pathname;
|
||
let data: unknown;
|
||
if (path.endsWith("session")) data = { author_id: "author" };
|
||
else if (path.endsWith("/events"))
|
||
data = {
|
||
items: [],
|
||
reset_required: false,
|
||
first_sequence: 1,
|
||
last_sequence: 9,
|
||
execution_final_sequence: 9,
|
||
};
|
||
else if (path.endsWith("/settlements")) {
|
||
const body = await request.json();
|
||
sent.push(body);
|
||
if (sent.length === 1) throw new Error("lost response");
|
||
revision = 1;
|
||
data = {
|
||
...body,
|
||
author_id: "author",
|
||
task_id: task.task_id,
|
||
call_id: "call",
|
||
receipt_id: "settled-receipt",
|
||
state: "cancelled",
|
||
accounting_revision: 1,
|
||
execution_final_sequence: 9,
|
||
};
|
||
} else if (path.endsWith("/calls")) {
|
||
reads++;
|
||
data = {
|
||
task_id: task.task_id,
|
||
state: "cancelled",
|
||
accounting_revision: revision,
|
||
execution_final_sequence: 9,
|
||
calls: [
|
||
{
|
||
call_id: "call",
|
||
attempt_id: "attempt",
|
||
role_id: "writer",
|
||
state: revision ? "settled" : "unknown",
|
||
actual_amount: revision ? "0.12" : null,
|
||
reserved_amount: "1",
|
||
receipt_id: revision ? "settled-receipt" : null,
|
||
delivery_state: "missing",
|
||
response_evidence: [],
|
||
},
|
||
],
|
||
};
|
||
} else throw new Error(`非预期读取 ${path}`);
|
||
return new Response(JSON.stringify(data), {
|
||
headers: { "Content-Type": "application/json" },
|
||
});
|
||
}),
|
||
);
|
||
const mount = () =>
|
||
render(
|
||
<Ui依赖装配>
|
||
<Ui任务详情 task={task} scope="work" />
|
||
</Ui依赖装配>,
|
||
);
|
||
const first = mount();
|
||
fireEvent.change(await screen.findByLabelText("实际费用 USD"), {
|
||
target: { value: "0.12" },
|
||
});
|
||
fireEvent.change(screen.getByLabelText("费用来源引用"), {
|
||
target: { value: "provider-statement-1" },
|
||
});
|
||
fireEvent.change(screen.getByLabelText("费用依据原文"), {
|
||
target: { value: "调用call实际费用0.12 USD" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "确认并补录费用" }));
|
||
await screen.findByRole("button", { name: "重试原账目请求" });
|
||
await screen.findByText("暂时无法连接工作台,请稍后重试。");
|
||
first.unmount();
|
||
mount();
|
||
fireEvent.click(
|
||
await screen.findByRole("button", { name: "重试原账目请求" }),
|
||
);
|
||
await screen.findByText(/已结 0.12 USD/);
|
||
expect(sent).toHaveLength(2);
|
||
expect(sent[1]).toEqual(sent[0]);
|
||
expect(sent[0]).toMatchObject({
|
||
expected_revision: 0,
|
||
amount: "0.12",
|
||
source_kind: "author_attestation",
|
||
source_statement: "调用call实际费用0.12 USD",
|
||
});
|
||
expect(sent[0].source_hash).toMatch(/^[0-9a-f]{64}$/);
|
||
expect(screen.getByText(/账目版本 1/)).toBeTruthy();
|
||
expect(screen.getByText("已取消")).toBeTruthy();
|
||
expect(eventSource).not.toHaveBeenCalled();
|
||
const previous = reads;
|
||
vi.useFakeTimers();
|
||
await act(() => vi.advanceTimersByTimeAsync(60_000));
|
||
expect(reads).toBe(previous);
|
||
fireEvent.click(screen.getByRole("button", { name: "刷新调用账目" }));
|
||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||
expect(reads).toBe(previous + 1);
|
||
expect(screen.queryByRole("button", { name: "核对原调用响应" })).toBeNull();
|
||
expect(sessionStorage.length).toBe(0);
|
||
});
|
||
|
||
// @muse-case {"case_id":"O08-task-persisted-result-reference","then":"终态空历史仍从快照展示候选按钮,点击前不读候选全文,点击后读取单项详情,服务端拒绝时不绕过核验"}
|
||
it("O08-task-persisted-result-reference:不用重放事件找回生成候选", async () => {
|
||
const withResult: 任务 = {
|
||
...task,
|
||
result_refs: [
|
||
{
|
||
kind: "writing_candidate",
|
||
candidate_id: "persisted-candidate",
|
||
chapter_id: "chapter",
|
||
branch_id: "main",
|
||
},
|
||
],
|
||
};
|
||
const readCandidates: string[] = [];
|
||
vi.stubGlobal(
|
||
"EventSource",
|
||
vi.fn(() => {
|
||
throw new Error("终态不订阅");
|
||
}),
|
||
);
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async (request: Request) => {
|
||
const path = new URL(request.url).pathname;
|
||
let data: unknown;
|
||
if (path.endsWith("session")) data = { author_id: "author" };
|
||
else if (path.endsWith("/events"))
|
||
data = {
|
||
items: [],
|
||
reset_required: false,
|
||
first_sequence: 10,
|
||
last_sequence: 9,
|
||
};
|
||
else if (path.endsWith("/calls"))
|
||
data = {
|
||
task_id: task.task_id,
|
||
state: "cancelled",
|
||
accounting_revision: 0,
|
||
execution_final_sequence: 9,
|
||
calls: [],
|
||
};
|
||
else {
|
||
readCandidates.push(path);
|
||
return new Response(
|
||
JSON.stringify({
|
||
code: "SOURCE_STALE",
|
||
message: "合成候选当前需重新核验",
|
||
}),
|
||
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||
);
|
||
}
|
||
return new Response(JSON.stringify(data), {
|
||
headers: { "Content-Type": "application/json" },
|
||
});
|
||
}),
|
||
);
|
||
render(
|
||
<Ui依赖装配>
|
||
<Ui任务详情 task={withResult} scope="work" />
|
||
</Ui依赖装配>,
|
||
);
|
||
const open = await screen.findByRole("button", { name: "打开生成候选" });
|
||
expect(readCandidates).toEqual([]);
|
||
fireEvent.click(open);
|
||
await screen.findByRole("heading", { name: "候选检查与决定" });
|
||
expect(readCandidates).toContain(
|
||
"/api/v1/writing-candidates/persisted-candidate",
|
||
);
|
||
});
|