muse-agent-example/web/tests/交互/调用账目.test.tsx
zizi ae02fd1732 前端:工作台页面、事件标签与旅程用例
- 上下文清单、任务列表与详情、恢复控制、运行观察、创作对话与消息流、正式变更决策面板、正文写作与候选审阅、资料研究等页面同步本轮后端能力。
- 事件标签表补齐后端事件类型;查询键与生成客户端随 HTTP 契约重新生成。
- 依赖调整后 lockfile 同步;交互用例与浏览器旅程 spec 一并更新。
- 证据:make 前端检查(lint + typecheck + Pi 工具桥 tsc)exit 0;make 前端测试 37 文件 / 120 用例通过。
2026-09-18 01:15:07 +08:00

216 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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