feat: expand intelligent investigation workspace
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { answerUiDemoBlock } from "./support/answer-ui-demo";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../../src/features/agent/api/answer-view-state";
|
||||
|
||||
const block = {
|
||||
...answerUiDemoBlock,
|
||||
spec: { ...answerUiDemoBlock.spec, elements: {
|
||||
...answerUiDemoBlock.spec.elements,
|
||||
root: { ...answerUiDemoBlock.spec.elements.root, children: ["metrics", "chart", "records", "evidence", "location", "form"] },
|
||||
records: { type: "Table", props: { title: "演练监测记录", columns: ["位置", "压力"], rows: [["北侧", "0.30"], ["南侧", "0.28"]] }, children: [] }
|
||||
} }
|
||||
};
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (typeof value === "object" && value !== null) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
const digest = createHash("sha256").update(canonical(block.spec)).digest("hex");
|
||||
const empty = (): AnswerViewStateRecord => ({ version: "answer-view-state@1", message_id: "state-answer", block_id: "pressure", revision: 0, schema_digest: digest, nodes: {}, block: { evidenceFilter: "all" }, updated_at: null });
|
||||
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
|
||||
|
||||
async function setup(page: Page) {
|
||||
const records = new Map<string, AnswerViewStateRecord>();
|
||||
const writes: AnswerViewStateWrite[] = [];
|
||||
const receipts = new Map<string, AnswerViewStateRecord>();
|
||||
const controls = { failAfterCommit: false };
|
||||
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
|
||||
await mockAgentApi(page, { sessions: ["A", "B"].map((name) => ({ session_id: `state-${name}`, title: `交互状态演练 ${name}`, created_at: Date.now(), updated_at: Date.now() })) });
|
||||
await page.route("**/api/v1/session/state-*?**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.endsWith("/ui-state")) return route.fallback();
|
||||
const session = url.pathname.split("/").at(-1)!;
|
||||
await route.fulfill({ headers, json: { session_id: session, title: `交互状态演练 ${session.slice(-1)}`, purpose: "ordinary_chat", status: "active", is_streaming: false,
|
||||
messages: [{ id: "state-answer", role: "assistant", parts: [
|
||||
{ type: "data-answer_ui", id: "pressure", data: block },
|
||||
{ type: "data-final_answer", data: { content: "演练交互状态,不涉及业务写入。", ui_block_ids: ["pressure"] } }
|
||||
] }] } });
|
||||
});
|
||||
await page.route("**/api/v1/session/state-*/ui-state**", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "OPTIONS") return route.fulfill({ status: 204, headers });
|
||||
const session = new URL(route.request().url()).pathname.split("/").at(-2)!;
|
||||
const current = records.get(session) ?? empty();
|
||||
if (method === "GET") return route.fulfill({ headers, json: current });
|
||||
const input = route.request().postDataJSON() as AnswerViewStateWrite;
|
||||
writes.push(input);
|
||||
const receipt = receipts.get(input.request_id);
|
||||
if (receipt) return route.fulfill({ headers, json: receipt });
|
||||
if (input.expected_revision !== current.revision) return route.fulfill({ status: 409, headers, json: { code: "ANSWER_VIEW_STATE_CONFLICT", detail: "演练并发修改", current } });
|
||||
const next = { ...current, nodes: input.nodes, block: input.block, revision: current.revision + 1, updated_at: new Date().toISOString() };
|
||||
records.set(session, next); receipts.set(input.request_id, next);
|
||||
if (controls.failAfterCommit) { controls.failAfterCommit = false; return route.abort("failed"); }
|
||||
await route.fulfill({ headers, json: next });
|
||||
});
|
||||
return { records, writes, controls };
|
||||
}
|
||||
async function openPanel(page: Page, width: number) {
|
||||
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
|
||||
}
|
||||
async function openSession(page: Page, name: string) {
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await panel.getByRole("button", { name: /历史/ }).click();
|
||||
await page.locator("button.agent-history-session-trigger").filter({ hasText: `交互状态演练 ${name}` }).click();
|
||||
const ui = panel.locator('[data-answer-ui="pressure"]');
|
||||
await expect(ui).toBeVisible();
|
||||
await expect(panel.locator('[data-answer-state="saved"]')).toBeVisible();
|
||||
return { panel, ui };
|
||||
}
|
||||
|
||||
for (const width of [1440, 320]) {
|
||||
test(`interactive state survives reload and isolates sessions at ${width}px`, async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const { records } = await setup(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" }); await openPanel(page, width);
|
||||
let { ui } = await openSession(page, "A");
|
||||
await ui.getByLabel("现场位置(必填)").fill("演练北侧草稿");
|
||||
const chart = ui.locator(".answer-ui-chart");
|
||||
await chart.getByRole("button", { name: "参考压力(MPa)", exact: true }).click();
|
||||
await chart.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await chart.getByRole("textbox", { name: /搜索/ }).fill("09:00");
|
||||
await ui.getByRole("textbox", { name: "搜索演练监测记录", exact: true }).fill("北侧");
|
||||
await ui.getByRole("button", { name: /查看演练证据/ }).click();
|
||||
await expect.poll(() => records.get("state-A")?.nodes).toMatchObject({
|
||||
form: { values: { location: "演练北侧草稿" } }, chart: { view: "table", hidden: [1], table: { query: "09:00", selection: [] } },
|
||||
records: { query: "北侧", selection: [] }, evidence: { open: true }
|
||||
});
|
||||
await page.reload({ waitUntil: "domcontentloaded" }); await openPanel(page, width);
|
||||
({ ui } = await openSession(page, "A"));
|
||||
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("演练北侧草稿");
|
||||
await expect(ui.locator(".answer-ui-chart").getByRole("tab", { name: "数据", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(ui.getByRole("textbox", { name: "搜索演练监测记录", exact: true })).toHaveValue("北侧");
|
||||
await expect(ui.getByText("仅用于组件验收,不代表已核验现场事实。", { exact: true })).toBeVisible();
|
||||
({ ui } = await openSession(page, "B"));
|
||||
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("");
|
||||
await ui.getByLabel("现场位置(必填)").fill("演练南侧草稿");
|
||||
await expect.poll(() => records.get("state-B")?.nodes.form).toMatchObject({ values: { location: "演练南侧草稿" } });
|
||||
({ ui } = await openSession(page, "A"));
|
||||
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("演练北侧草稿");
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: `test-results/answer-state-restored-${width}.png` });
|
||||
});
|
||||
}
|
||||
|
||||
test("failed writes retain drafts and retry idempotently; conflicts require an explicit choice", async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const { records, writes, controls } = await setup(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" }); await openPanel(page, 1440);
|
||||
const { panel, ui } = await openSession(page, "A");
|
||||
controls.failAfterCommit = true;
|
||||
await ui.getByLabel("现场位置(必填)").fill("网络失败仍保留");
|
||||
await expect(panel.getByRole("button", { name: "重试保存" })).toBeVisible();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
await panel.getByRole("button", { name: "重试保存" }).click();
|
||||
await expect(panel.locator('[data-answer-state="saved"]')).toBeVisible();
|
||||
expect(writes[1]).toEqual(writes[0]);
|
||||
expect(records.get("state-A")?.revision).toBe(1);
|
||||
const remote = records.get("state-A")!;
|
||||
records.set("state-A", { ...remote, revision: 2, nodes: { ...remote.nodes, form: { type: "FollowUpForm", values: { location: "另一个窗口" } } } });
|
||||
await ui.getByLabel("现场位置(必填)").fill("本地选择保留");
|
||||
await expect(panel.locator('[data-answer-state="conflict"]')).toBeVisible();
|
||||
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("本地选择保留");
|
||||
await panel.getByRole("button", { name: "保留本地并重新保存" }).click();
|
||||
await expect(panel.locator('[data-answer-state="saved"]')).toBeVisible();
|
||||
expect(records.get("state-A")?.revision).toBe(3);
|
||||
expect(records.get("state-A")?.nodes.form).toMatchObject({ values: { location: "本地选择保留" } });
|
||||
const next = records.get("state-A")!;
|
||||
records.set("state-A", { ...next, revision: 4, nodes: { ...next.nodes, form: { type: "FollowUpForm", values: { location: "远端已确认草稿" } } } });
|
||||
await ui.getByLabel("现场位置(必填)").fill("待丢弃草稿");
|
||||
await expect(panel.locator('[data-answer-state="conflict"]')).toBeVisible();
|
||||
await panel.getByRole("button", { name: "重新加载远端" }).click();
|
||||
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("待丢弃草稿");
|
||||
await panel.getByRole("button", { name: "确认替换本地草稿" }).click();
|
||||
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("远端已确认草稿");
|
||||
expect(records.get("state-A")?.revision).toBe(4);
|
||||
});
|
||||
Reference in New Issue
Block a user