feat: 增加项目无关的本地清洗评审器

This commit is contained in:
2026-08-28 19:06:10 +08:00
parent 10c026c7ad
commit 6c0dd5974b
82 changed files with 9335 additions and 48 deletions
+235
View File
@@ -0,0 +1,235 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "../src/client/App.js";
import type {
CollectionSummaryResponse,
DocumentComparisonResponse,
ModifierStageResponse,
} from "../src/shared/api.js";
vi.mock("../src/client/DiffView.js", () => ({
DiffView: ({ beforeLabel, afterLabel }: { beforeLabel: string; afterLabel: string }) => (
<div data-testid="diff-view">
{beforeLabel} / {afterLabel}
</div>
),
}));
const modifiers = [
{
modifier_position: 0,
modifier_id: "paper.rule",
modifier_version: "1.0.0",
parameters: [],
applicability: "替换测试单词。",
change_count: 1,
stage_available: true,
},
{
modifier_position: 1,
modifier_id: "paper.zero",
modifier_version: "1.0.0",
parameters: [],
applicability: "不修改当前测试文档。",
change_count: 0,
stage_available: true,
},
];
const documentSummary = {
document_id: "paper",
source_label: "paper.md",
status: "success" as const,
current_kind: "success_output" as const,
input_sha256: "1".repeat(64),
current_sha256: "2".repeat(64),
modifier_count: 2,
completed_stage_count: 2,
change_count: 1,
error_count: 0,
residual_proposal_count: 0,
};
const collectionResponse: CollectionSummaryResponse = {
schema_version: 1,
collection: { label: "合成评审", status: "success" },
documents: [documentSummary],
summary: {
document_count: 1,
success_count: 1,
failed_count: 0,
unstable_count: 0,
change_count: 1,
},
};
const change = {
modifier_position: 0,
modifier_id: "paper.rule",
modifier_version: "1.0.0",
proposal_index: 0,
edit_index: 0,
reason: "替换测试单词",
location: { line: 1, column: 1 },
span: { start: 0, end: 3 },
editor_range: { start: 0, end: 3 },
before: "old",
after: "new",
before_sha256: "1".repeat(64),
after_sha256: "2".repeat(64),
};
const documentResponse: DocumentComparisonResponse = {
schema_version: 1,
document: documentSummary,
modifiers,
input_markdown: "old",
current_markdown: "new",
changes: [change],
errors: [],
residual_proposals: [],
};
const stageResponse: ModifierStageResponse = {
schema_version: 1,
document_id: "paper",
modifier: modifiers[0]!,
before_sha256: "1".repeat(64),
after_sha256: "2".repeat(64),
before_markdown: "old",
after_markdown: "new",
changes: [change],
};
function response(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("App", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("shows documents, Modifier order and the selected stage", async () => {
const fetchMock = vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (pathname === "/api/v1/collection") {
return Promise.resolve(response(collectionResponse));
}
if (pathname.endsWith("/modifiers/0")) {
return Promise.resolve(response(stageResponse));
}
return Promise.resolve(response(documentResponse));
});
vi.stubGlobal("fetch", fetchMock);
render(<App />);
expect(await screen.findByRole("heading", { name: "合成评审" })).toBeInTheDocument();
expect(await screen.findByTestId("diff-view")).toHaveTextContent("清洗前 / 清洗后");
expect(screen.getByText("paper.rule")).toBeInTheDocument();
expect(screen.getByText("paper.zero")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /paper\.rule/ }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("modifiers/0"), expect.anything());
});
expect(await screen.findByTestId("diff-view")).toHaveTextContent(
"Modifier 1 执行前 / Modifier 1 执行后",
);
});
it("clicks a change through its Modifier stage", async () => {
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (pathname === "/api/v1/collection") {
return Promise.resolve(response(collectionResponse));
}
if (pathname.endsWith("/modifiers/0")) {
return Promise.resolve(response(stageResponse));
}
return Promise.resolve(response(documentResponse));
}),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: /替换测试单词/ }));
expect(await screen.findByTestId("diff-view")).toHaveTextContent(
"Modifier 1 执行前 / Modifier 1 执行后",
);
});
it("shows failed diagnostics without a cleaned comparison", async () => {
const failedSummary = {
...documentSummary,
status: "failed" as const,
current_kind: "partial_output" as const,
current_sha256: documentSummary.input_sha256,
completed_stage_count: 0,
change_count: 0,
error_count: 1,
};
const failedCollection: CollectionSummaryResponse = {
...collectionResponse,
collection: { ...collectionResponse.collection, status: "failed" },
documents: [failedSummary],
summary: {
...collectionResponse.summary,
success_count: 0,
failed_count: 1,
change_count: 0,
},
};
const failedDocument: DocumentComparisonResponse = {
schema_version: 1,
document: failedSummary,
modifiers: modifiers.map((modifier) => ({
...modifier,
change_count: 0,
stage_available: false,
})),
input_markdown: "原文",
current_markdown: null,
changes: [],
errors: [
{
code: "run.transform_failed",
stage: "transform",
modifier_position: 0,
modifier_id: "paper.rule",
modifier_version: "1.0.0",
diagnostic_type: "SyntheticError",
message: "测试 Modifier 失败。",
},
],
residual_proposals: [],
};
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return Promise.resolve(
response(
pathname === "/api/v1/collection" ? failedCollection : failedDocument,
),
);
}),
);
render(<App />);
expect(await screen.findByRole("heading", { name: "失败文档只展示审计证据" })).toBeInTheDocument();
expect(screen.getByText("测试 Modifier 失败。")).toBeInTheDocument();
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { EditorView } from "@codemirror/view";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DiffView } from "../src/client/DiffView.js";
function leftPaneSelection(): { from: number; to: number } | null {
const pane = document.querySelector(".diff-host .cm-editor");
if (pane === null) {
return null;
}
const view = EditorView.findFromDOM(pane as HTMLElement);
if (view === null) {
return null;
}
const { from, to } = view.state.selection.main;
return { from, to };
}
describe("DiffView", () => {
it("keeps Markdown and raw HTML as inert editor text", () => {
render(
<DiffView
before={'# title\n<img src="https://example.com/private.png" onerror="alert(1)">'}
after={'# title\n<script>alert("x")</script>'}
beforeLabel="清洗前"
afterLabel="清洗后"
/>,
);
expect(screen.getByRole("region", { name: "清洗前与清洗后对比" })).toBeInTheDocument();
expect(document.querySelector("img")).toBeNull();
expect(document.querySelector("script")).toBeNull();
});
it("keeps long unchanged sections available in the full document view", () => {
const before = Array.from({ length: 30 }, (_, index) => `line ${index + 1}`);
const after = [...before];
after[14] = "changed line 15";
render(
<DiffView
before={before.join("\n")}
after={after.join("\n")}
beforeLabel="清洗前"
afterLabel="清洗后"
/>,
);
expect(document.querySelector(".cm-collapsedLines")).toBeNull();
expect(document.querySelector(".cm-mergeView")).toBeInTheDocument();
});
it("re-applies the focus selection after the compared texts change", () => {
const longText = (mark: string) =>
Array.from({ length: 30 }, (_, index) => (index === 14 ? mark : `line ${index + 1}`)).join("\n");
const { rerender } = render(
<DiffView before={longText("old")} after={longText("new")} beforeLabel="清洗前" afterLabel="清洗后" />,
);
rerender(
<DiffView
before={longText("old")}
after={longText("new")}
beforeLabel="Modifier 1 执行前"
afterLabel="Modifier 1 执行后"
focusRange={{ start: 58, end: 61 }}
/>,
);
rerender(
<DiffView
before={longText("stage before")}
after={longText("stage after")}
beforeLabel="Modifier 2 执行前"
afterLabel="Modifier 2 执行后"
focusRange={{ start: 58, end: 71 }}
/>,
);
expect(
screen.getByRole("region", { name: "Modifier 2 执行前与Modifier 2 执行后对比" }),
).toBeInTheDocument();
expect(leftPaneSelection()).toEqual({ from: 58, to: 71 });
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { fetchCollection } from "../src/client/api-client.js";
describe("API response validation", () => {
it("rejects a successful HTTP response with an unknown schema", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ schema_version: 2 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
),
);
await expect(fetchCollection()).rejects.toMatchObject({ code: "invalid_response" });
});
it("uses the server error instead of guessing a partial schema", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "unsupported_review_schema",
message: "reviewer 与 JSON schema 不匹配。",
},
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
),
),
);
await expect(fetchCollection()).rejects.toMatchObject({
code: "unsupported_review_schema",
message: "reviewer 与 JSON schema 不匹配。",
});
});
});
+6
View File
@@ -0,0 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => cleanup());