Files
writer-work-flow/apps/web/lib/foreshadow/chapterForeshadow.test.ts
Yaojia Wang b810a3fa3c feat(foreshadow): 写作台本章伏笔主动提醒(前端)
- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching)
- useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉)
- 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单,
  分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
2026-07-12 17:53:00 +02:00

105 lines
3.7 KiB
TypeScript

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");
});
});