Files
writer-work-flow/apps/web/lib/foreshadow/board.test.ts
2026-06-28 07:31:20 +02:00

196 lines
5.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ForeshadowView } from "@/lib/api/types";
import {
buildRegisterRequest,
buildTransitionRequest,
countByStatus,
extractReason,
groupByStatus,
isWindowApproaching,
LANE_LABELS,
nextFreeForeshadowCode,
suggestForeshadowCode,
validationReasonMessage,
} from "./board";
describe("suggestForeshadowCode", () => {
it("prefers the suggestion's own code when present", () => {
expect(suggestForeshadowCode("F-007", 3)).toBe("F-007");
expect(suggestForeshadowCode(" F-008 ", 3)).toBe("F-008");
});
it("falls back to FS-<2-digit index> when code is empty/null", () => {
expect(suggestForeshadowCode(null, 0)).toBe("FS-01");
expect(suggestForeshadowCode("", 4)).toBe("FS-05");
expect(suggestForeshadowCode(" ", 11)).toBe("FS-12");
});
});
describe("nextFreeForeshadowCode", () => {
it("keeps the preferred code when it is free", () => {
expect(nextFreeForeshadowCode("FS-04", new Set(["FS-01", "FS-02"]))).toBe(
"FS-04",
);
});
it("skips taken codes and returns the first free FS-NN", () => {
const taken = new Set(["FS-01", "FS-02", "FS-03", "FS-05", "FS-OLD"]);
// preferred FS-01 已占 → 取首个空闲 FS-04
expect(nextFreeForeshadowCode("FS-01", taken)).toBe("FS-04");
});
it("returns FS-01 when nothing is taken", () => {
expect(nextFreeForeshadowCode("FS-01", new Set())).toBe("FS-01");
});
});
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001",
title: "线索",
status: "OPEN",
...over,
});
describe("groupByStatus", () => {
it("splits items into four lanes preserving order", () => {
const lanes = groupByStatus([
view({ code: "F-001", status: "OPEN" }),
view({ code: "F-002", status: "OVERDUE" }),
view({ code: "F-003", status: "OPEN" }),
view({ code: "F-004", status: "CLOSED" }),
view({ code: "F-005", status: "PARTIAL" }),
]);
expect(lanes.OPEN.map((v) => v.code)).toEqual(["F-001", "F-003"]);
expect(lanes.OVERDUE.map((v) => v.code)).toEqual(["F-002"]);
expect(lanes.CLOSED.map((v) => v.code)).toEqual(["F-004"]);
expect(lanes.PARTIAL.map((v) => v.code)).toEqual(["F-005"]);
});
it("falls back unknown status to OPEN and handles undefined", () => {
const lanes = groupByStatus([view({ status: "WEIRD" })]);
expect(lanes.OPEN).toHaveLength(1);
expect(groupByStatus(undefined).OPEN).toEqual([]);
});
});
describe("countByStatus", () => {
it("counts items in the same lane model used by the board", () => {
expect(
countByStatus([
view({ status: "OPEN" }),
view({ status: "OPEN" }),
view({ status: "PARTIAL" }),
view({ status: "CLOSED" }),
view({ status: "OVERDUE" }),
view({ status: "WEIRD" }),
]),
).toEqual({ OPEN: 3, PARTIAL: 1, CLOSED: 1, OVERDUE: 1 });
});
});
describe("LANE_LABELS", () => {
it("keeps author-facing labels distinct from status codes", () => {
expect(LANE_LABELS).toEqual({
OPEN: "待推进",
PARTIAL: "推进中",
CLOSED: "已回收",
OVERDUE: "已逾期",
});
});
});
describe("isWindowApproaching", () => {
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
const f = view({
status: "PARTIAL",
expected_close_from: 40,
expected_close_to: 60,
});
expect(isWindowApproaching(f, 50)).toBe(true);
expect(isWindowApproaching(f, 39)).toBe(false);
expect(isWindowApproaching(f, 61)).toBe(false);
});
it("never flags CLOSED/OVERDUE or items without a window", () => {
expect(
isWindowApproaching(
view({ status: "CLOSED", expected_close_to: 60 }),
50,
),
).toBe(false);
expect(isWindowApproaching(view({ status: "OPEN" }), 50)).toBe(false);
});
});
describe("buildRegisterRequest", () => {
it("trims and omits empty optionals", () => {
expect(
buildRegisterRequest({
projectId: "p1",
code: " F-007 ",
title: " 旧誓约 ",
content: " ",
expectedCloseTo: 110,
}),
).toEqual({ code: "F-007", title: "旧誓约", expected_close_to: 110 });
});
it("keeps numeric and non-empty string optionals", () => {
expect(
buildRegisterRequest({
projectId: "p1",
code: "F-1",
title: "t",
plantedAt: 8,
content: "线索",
expectedCloseFrom: 40,
importance: "主线",
}),
).toEqual({
code: "F-1",
title: "t",
planted_at: 8,
content: "线索",
expected_close_from: 40,
importance: "主线",
});
});
});
describe("buildTransitionRequest", () => {
it("includes only provided fields", () => {
expect(buildTransitionRequest({ projectId: "p1", toStatus: "PARTIAL" })).toEqual({
to_status: "PARTIAL",
});
expect(
buildTransitionRequest({
projectId: "p1",
progressEntry: { chapter: 23, note: "发光" },
}),
).toEqual({ progress_entry: { chapter: 23, note: "发光" } });
expect(buildTransitionRequest({ projectId: "p1" })).toEqual({});
expect(
buildTransitionRequest({ projectId: "p1", progressEntry: {} }),
).toEqual({});
});
});
describe("validationReasonMessage / extractReason", () => {
it("maps known reasons to friendly text", () => {
expect(validationReasonMessage("duplicate")).toContain("已存在");
expect(validationReasonMessage("invalid_transition")).toContain("终态");
expect(validationReasonMessage("empty_update")).toContain("进展");
expect(validationReasonMessage("invalid_status")).toContain("筛选");
expect(validationReasonMessage(undefined)).toContain("校验");
});
it("extracts details.reason from envelope", () => {
expect(
extractReason({ error: { details: { reason: "duplicate" } } }),
).toBe("duplicate");
expect(extractReason({ error: { details: null } })).toBeUndefined();
expect(extractReason("oops")).toBeUndefined();
});
});