feat(foreshadow): 写作台本章伏笔主动提醒(前端)
- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching) - useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉) - 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单, 分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
This commit is contained in:
104
apps/web/lib/foreshadow/chapterForeshadow.test.ts
Normal file
104
apps/web/lib/foreshadow/chapterForeshadow.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import {
|
||||
chapterForeshadowTotal,
|
||||
classifyChapterForeshadow,
|
||||
foreshadowStatusLabel,
|
||||
} from "./chapterForeshadow";
|
||||
|
||||
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
|
||||
code: "F-001",
|
||||
title: "线索",
|
||||
status: "OPEN",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("classifyChapterForeshadow", () => {
|
||||
it("puts items whose window covers the current chapter into 可回收", () => {
|
||||
const groups = classifyChapterForeshadow(
|
||||
[
|
||||
view({ code: "F-A", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
|
||||
view({ code: "F-B", status: "PARTIAL", expected_close_to: 50 }),
|
||||
],
|
||||
50,
|
||||
);
|
||||
expect(groups.recyclable.map((v) => v.code)).toEqual(["F-A", "F-B"]);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.overdue).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts OPEN/PARTIAL with a later or missing window into 待回收", () => {
|
||||
const groups = classifyChapterForeshadow(
|
||||
[
|
||||
view({ code: "F-LATER", status: "OPEN", expected_close_from: 80, expected_close_to: 90 }),
|
||||
view({ code: "F-NOWIN", status: "PARTIAL" }),
|
||||
],
|
||||
50,
|
||||
);
|
||||
expect(groups.pending.map((v) => v.code)).toEqual(["F-LATER", "F-NOWIN"]);
|
||||
expect(groups.recyclable).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts OVERDUE into 已逾期 even when a window would otherwise cover the chapter", () => {
|
||||
const groups = classifyChapterForeshadow(
|
||||
[view({ code: "F-OD", status: "OVERDUE", expected_close_from: 40, expected_close_to: 60 })],
|
||||
50,
|
||||
);
|
||||
expect(groups.overdue.map((v) => v.code)).toEqual(["F-OD"]);
|
||||
expect(groups.recyclable).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops CLOSED and unknown statuses from every group", () => {
|
||||
const groups = classifyChapterForeshadow(
|
||||
[
|
||||
view({ code: "F-CLOSED", status: "CLOSED", expected_close_to: 60 }),
|
||||
view({ code: "F-WEIRD", status: "WEIRD" }),
|
||||
],
|
||||
50,
|
||||
);
|
||||
expect(chapterForeshadowTotal(groups)).toBe(0);
|
||||
});
|
||||
|
||||
it("handles the window boundary: inclusive at from and to, excluded just outside", () => {
|
||||
const win = (code: string): ForeshadowView =>
|
||||
view({ code, status: "OPEN", expected_close_from: 40, expected_close_to: 60 });
|
||||
expect(classifyChapterForeshadow([win("lo")], 40).recyclable).toHaveLength(1);
|
||||
expect(classifyChapterForeshadow([win("hi")], 60).recyclable).toHaveLength(1);
|
||||
expect(classifyChapterForeshadow([win("before")], 39).pending).toHaveLength(1);
|
||||
expect(classifyChapterForeshadow([win("after")], 61).pending).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty groups for empty / undefined input without mutating a shared object", () => {
|
||||
const a = classifyChapterForeshadow(undefined, 1);
|
||||
const b = classifyChapterForeshadow([], 1);
|
||||
expect(chapterForeshadowTotal(a)).toBe(0);
|
||||
expect(chapterForeshadowTotal(b)).toBe(0);
|
||||
// 不可变:两次调用返回相互独立的空组
|
||||
a.pending.push(view({}));
|
||||
expect(b.pending).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapterForeshadowTotal", () => {
|
||||
it("sums the three lanes", () => {
|
||||
const groups = classifyChapterForeshadow(
|
||||
[
|
||||
view({ code: "r", status: "OPEN", expected_close_to: 50 }),
|
||||
view({ code: "p", status: "OPEN" }),
|
||||
view({ code: "o", status: "OVERDUE" }),
|
||||
],
|
||||
50,
|
||||
);
|
||||
expect(chapterForeshadowTotal(groups)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreshadowStatusLabel", () => {
|
||||
it("maps known statuses to author-facing labels and falls back for unknown", () => {
|
||||
expect(foreshadowStatusLabel("OPEN")).toBe("待推进");
|
||||
expect(foreshadowStatusLabel("PARTIAL")).toBe("推进中");
|
||||
expect(foreshadowStatusLabel("OVERDUE")).toBe("已逾期");
|
||||
expect(foreshadowStatusLabel("WEIRD")).toBe("WEIRD");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user