feat(ux): R1+R2 审稿裁决人体学 — 冲突卡内联片段 + 分组/排序/跳下一条/主次按钮

R1: 新 lib/review/snippet.ts 从 where 解析段号取终稿命中段预览,ConflictCard 渲染引用块。
R2: 新 lib/review/grouping.ts 按 type 分组+严重度排序(仅改显示顺序,保留原始 index 守 conflict_index 不变量),组计数徽标、跳到下一条未裁决(环回)、采纳改法主按钮/忽略手改次级。
TDD: snippet 8 测 + grouping 10 测。前端门禁绿: lint/tsc/vitest 201/build。
This commit is contained in:
Yaojia Wang
2026-06-20 15:53:45 +02:00
parent 6f419b35ed
commit d81583d5d1
7 changed files with 329 additions and 22 deletions

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { conflictSnippet, parseParagraphNo } from "./snippet";
describe("parseParagraphNo", () => {
it("extracts 1-based paragraph number from 第 N 段 wording", () => {
expect(parseParagraphNo("第 3 段,主角忽然示弱")).toBe(3);
expect(parseParagraphNo("第9段老者道破来历")).toBe(9);
});
it("returns null when no paragraph number present", () => {
expect(parseParagraphNo("战斗场景")).toBeNull();
expect(parseParagraphNo("开篇提到三日后")).toBeNull();
expect(parseParagraphNo("")).toBeNull();
});
it("takes the first number for a range like 第 46 段", () => {
expect(parseParagraphNo("第 46 段反复描写天气")).toBe(4);
});
});
describe("conflictSnippet", () => {
const text = "第一段正文。\n\n第二段正文。\n\n第三段正文命中冲突的句子。";
it("returns the trimmed paragraph for a parseable where", () => {
expect(conflictSnippet(text, "第 3 段,主角忽然示弱")).toBe(
"第三段正文,命中冲突的句子。",
);
});
it("returns null when paragraph number is unparseable", () => {
expect(conflictSnippet(text, "战斗场景")).toBeNull();
});
it("returns null when paragraph index is out of range", () => {
expect(conflictSnippet(text, "第 9 段")).toBeNull();
});
it("returns null when the located paragraph is empty", () => {
expect(conflictSnippet("\n\n\n\n正文", "第 1 段")).toBeNull();
});
it("truncates long paragraphs with an ellipsis", () => {
const long = "甲".repeat(200);
const snippet = conflictSnippet(long, "第 1 段");
expect(snippet).not.toBeNull();
expect(snippet!.length).toBeLessThanOrEqual(81);
expect(snippet!.endsWith("…")).toBe(true);
});
});