重构为函数式通用 Markdown 修改库
This commit is contained in:
@@ -1,256 +0,0 @@
|
||||
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 {
|
||||
ComponentStageResponse,
|
||||
DocumentComparisonResponse,
|
||||
RunSummaryResponse,
|
||||
} 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 components = [
|
||||
{
|
||||
component_position: 0,
|
||||
component_id: "paper.rule",
|
||||
version: "1.0.0",
|
||||
parameters: [],
|
||||
applicability: "替换测试单词。",
|
||||
change_count: 1,
|
||||
},
|
||||
{
|
||||
component_position: 1,
|
||||
component_id: "paper.zero",
|
||||
version: "1.0.0",
|
||||
parameters: [],
|
||||
applicability: "不修改当前测试文档。",
|
||||
change_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const documentSummary = {
|
||||
document_id: "paper",
|
||||
source_label: "inputs/paper.md",
|
||||
status: "success" as const,
|
||||
input_sha256: "1".repeat(64),
|
||||
current_sha256: "2".repeat(64),
|
||||
change_count: 1,
|
||||
source_available: true,
|
||||
output_available: true,
|
||||
availability_error: null,
|
||||
};
|
||||
|
||||
const runResponse: RunSummaryResponse = {
|
||||
schema_version: 1,
|
||||
run: {
|
||||
run_id: "review-run",
|
||||
run_date: "2026-08-23",
|
||||
status: "success",
|
||||
started_at_utc: "2026-08-23T01:00:00Z",
|
||||
completed_at_utc: "2026-08-23T01:01:00Z",
|
||||
retention_until: "2026-09-22T01:01:00Z",
|
||||
},
|
||||
components,
|
||||
documents: [documentSummary],
|
||||
summary: {
|
||||
document_count: 1,
|
||||
success_count: 1,
|
||||
failed_count: 0,
|
||||
unstable_count: 0,
|
||||
change_count: 1,
|
||||
},
|
||||
original_run_location_changed: false,
|
||||
};
|
||||
|
||||
const change = {
|
||||
component_id: "paper.rule",
|
||||
component_version: "1.0.0",
|
||||
component_position: 0,
|
||||
proposal_ref: {
|
||||
component_position: 0,
|
||||
snapshot_sha256: "1".repeat(64),
|
||||
proposal_index: 0,
|
||||
},
|
||||
edit_index: 0,
|
||||
reason: "替换测试单词",
|
||||
location: { line: 1, column: 3 },
|
||||
editor_range: { start: 0, end: 3 },
|
||||
before: "old",
|
||||
after: "new",
|
||||
};
|
||||
|
||||
const documentResponse: DocumentComparisonResponse = {
|
||||
schema_version: 1,
|
||||
document: documentSummary,
|
||||
components,
|
||||
original_markdown: "old",
|
||||
cleaned_markdown: "new",
|
||||
changes: [change],
|
||||
errors: [],
|
||||
residual_proposals: [],
|
||||
};
|
||||
|
||||
const stageResponse: ComponentStageResponse = {
|
||||
schema_version: 1,
|
||||
document_id: "paper",
|
||||
component: components[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 the run, document comparison, component order and component 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/run") {
|
||||
return Promise.resolve(response(runResponse));
|
||||
}
|
||||
if (pathname.endsWith("/components/0")) {
|
||||
return Promise.resolve(response(stageResponse));
|
||||
}
|
||||
return Promise.resolve(response(documentResponse));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "review-run" })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("diff-view")).toHaveTextContent("清洗前 / 清洗后");
|
||||
expect(screen.getByText("paper.rule")).toBeInTheDocument();
|
||||
expect(screen.getByText("paper.zero")).toBeInTheDocument();
|
||||
expect(screen.getByText("替换测试单词")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /paper\.rule/ }));
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("components/0"), expect.anything());
|
||||
});
|
||||
expect(await screen.findByTestId("diff-view")).toHaveTextContent("组件 1 执行前 / 组件 1 执行后");
|
||||
});
|
||||
|
||||
it("shows API failures without rendering document text", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve(
|
||||
response({ error: { code: "unsupported_schema", message: "不支持这个产物版本。" } }, 409),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("不支持这个产物版本");
|
||||
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows failed audit evidence without inventing a cleaned result", async () => {
|
||||
const failedSummary = {
|
||||
...documentSummary,
|
||||
status: "failed" as const,
|
||||
current_sha256: documentSummary.input_sha256,
|
||||
change_count: 0,
|
||||
output_available: false,
|
||||
};
|
||||
const failedRun: RunSummaryResponse = {
|
||||
...runResponse,
|
||||
run: { ...runResponse.run, status: "failed" },
|
||||
documents: [failedSummary],
|
||||
summary: {
|
||||
document_count: 1,
|
||||
success_count: 0,
|
||||
failed_count: 1,
|
||||
unstable_count: 0,
|
||||
change_count: 0,
|
||||
},
|
||||
};
|
||||
const failedDocument: DocumentComparisonResponse = {
|
||||
schema_version: 1,
|
||||
document: failedSummary,
|
||||
components: components.map((component) => ({ ...component, change_count: 0 })),
|
||||
original_markdown: "原文",
|
||||
cleaned_markdown: null,
|
||||
changes: [],
|
||||
errors: [
|
||||
{
|
||||
component_id: "paper.rule",
|
||||
component_version: "1.0.0",
|
||||
component_position: 0,
|
||||
stage: "transform",
|
||||
error_type: "SyntheticError",
|
||||
message: "测试组件失败。",
|
||||
},
|
||||
],
|
||||
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/run" ? failedRun : failedDocument));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "失败文档只展示审计证据" })).toBeInTheDocument();
|
||||
expect(screen.getByText("测试组件失败。")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps audit details visible when a successful run has lost its original source", async () => {
|
||||
const unavailableSummary = {
|
||||
...documentSummary,
|
||||
source_available: false,
|
||||
availability_error: "原文路径已经失效。",
|
||||
};
|
||||
const unavailableRun: RunSummaryResponse = {
|
||||
...runResponse,
|
||||
documents: [unavailableSummary],
|
||||
};
|
||||
const unavailableDocument: DocumentComparisonResponse = {
|
||||
...documentResponse,
|
||||
document: unavailableSummary,
|
||||
original_markdown: null,
|
||||
changes: [{ ...change, editor_range: null }],
|
||||
};
|
||||
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/run" ? unavailableRun : unavailableDocument),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("原文路径已经失效");
|
||||
expect(screen.getByText("替换测试单词")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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="清洗后" />,
|
||||
);
|
||||
|
||||
// 模拟点击跨组件修改项:focusRange 先落在旧文本上,stage 文本随后到达。
|
||||
rerender(
|
||||
<DiffView
|
||||
before={longText("old")}
|
||||
after={longText("new")}
|
||||
beforeLabel="组件 1 执行前"
|
||||
afterLabel="组件 1 执行后"
|
||||
focusRange={{ start: 58, end: 61 }}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<DiffView
|
||||
before={longText("stage before")}
|
||||
after={longText("stage after")}
|
||||
beforeLabel="组件 2 执行前"
|
||||
afterLabel="组件 2 执行后"
|
||||
focusRange={{ start: 58, end: 71 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 最终视图必须是组件 2 的文本,且选区重新落在新文本的目标范围上。
|
||||
expect(screen.getByRole("region", { name: "组件 2 执行前与组件 2 执行后对比" })).toBeInTheDocument();
|
||||
expect(leftPaneSelection()).toEqual({ from: 58, to: 71 });
|
||||
});
|
||||
|
||||
it("applies the focus selection when it arrives with the initial texts", () => {
|
||||
render(
|
||||
<DiffView
|
||||
before={"a\nb"}
|
||||
after={"a\nc"}
|
||||
beforeLabel="清洗前"
|
||||
afterLabel="清洗后"
|
||||
focusRange={{ start: 0, end: 1 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(leftPaneSelection()).toEqual({ from: 0, to: 1 });
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchRun } 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(fetchRun()).rejects.toMatchObject({
|
||||
code: "invalid_response",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
Reference in New Issue
Block a user