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 => ({ 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(); }); });