- 后端新增 GET /projects/{id}/chapters(列真实存在的章 + has_draft/accepted/reviewed_at),TDD 10 测
- gen:api 重生成 TS 客户端(ChapterListItem)
- 选章下拉去掉 hidden(手机/窄屏也可切章);选项以真实章为准 + 大纲结构,
无草稿章置灰(仅当前章例外),覆盖『写过但不在大纲』的章
- 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章
121 lines
4.2 KiB
TypeScript
121 lines
4.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
buildReviewChapterOptions,
|
|
defaultReviewChapter,
|
|
reviewChapterHref,
|
|
reviewChapterOptions,
|
|
} from "./chapterNav";
|
|
import type { ChapterListItem } from "@/lib/api/types";
|
|
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
|
|
|
const item = (
|
|
chapter_no: number,
|
|
over: Partial<ChapterListItem> = {},
|
|
): ChapterListItem => ({
|
|
chapter_no,
|
|
has_draft: true,
|
|
accepted: false,
|
|
reviewed_at: null,
|
|
...over,
|
|
});
|
|
|
|
describe("reviewChapterHref", () => {
|
|
it("builds the review route for a chapter number", () => {
|
|
expect(reviewChapterHref("proj-1", 3)).toBe(
|
|
"/projects/proj-1/review?chapter=3",
|
|
);
|
|
});
|
|
|
|
it("always targets the review page (not write)", () => {
|
|
expect(reviewChapterHref("p", 1)).toContain("/review?chapter=1");
|
|
});
|
|
});
|
|
|
|
describe("reviewChapterOptions (legacy, outline-only)", () => {
|
|
const chapters: ChapterEntry[] = [
|
|
{ no: 1, title: "开篇" },
|
|
{ no: 2 },
|
|
{ no: 3, title: "转折" },
|
|
];
|
|
|
|
it("merges outline chapters with the current chapter, sorted ascending", () => {
|
|
const opts = reviewChapterOptions(chapters, 2);
|
|
expect(opts.map((o) => o.no)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("includes the current chapter even when it is outside the outline", () => {
|
|
const opts = reviewChapterOptions(chapters, 9);
|
|
expect(opts.map((o) => o.no)).toEqual([1, 2, 3, 9]);
|
|
});
|
|
|
|
it("labels each option with chapter number and optional title (never disabled)", () => {
|
|
const opts = reviewChapterOptions(chapters, 1);
|
|
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇", disabled: false });
|
|
expect(opts[1]).toEqual({ no: 2, label: "第 2 章", disabled: false });
|
|
});
|
|
});
|
|
|
|
describe("buildReviewChapterOptions (real chapters + outline titles)", () => {
|
|
const outline: ChapterEntry[] = [
|
|
{ no: 1, title: "开篇" },
|
|
{ no: 2, title: "转折" },
|
|
];
|
|
|
|
it("enumerates real chapters, sorted, with outline titles + status suffix", () => {
|
|
const list = [
|
|
item(1, { accepted: true }),
|
|
item(2, { reviewed_at: "2026-01-01T00:00:00Z" }),
|
|
];
|
|
const opts = buildReviewChapterOptions(list, outline, 2);
|
|
expect(opts).toEqual([
|
|
{ no: 1, label: "第 1 章 · 开篇 · 已验收", disabled: false },
|
|
{ no: 2, label: "第 2 章 · 转折 · 已审", disabled: false },
|
|
]);
|
|
});
|
|
|
|
it("covers chapters written but not in the outline, and shows outline structure", () => {
|
|
const list = [item(1), item(5)]; // 第 5 章 写过但大纲没有;大纲有第 2 章但未写
|
|
const opts = buildReviewChapterOptions(list, outline, 1);
|
|
expect(opts.map((o) => o.no)).toEqual([1, 2, 5]);
|
|
expect(opts.find((o) => o.no === 5)?.label).toBe("第 5 章 · 草稿");
|
|
// 大纲有、未写的章出现在结构里但置灰(不可审)
|
|
expect(opts.find((o) => o.no === 2)?.disabled).toBe(true);
|
|
});
|
|
|
|
it("disables chapters with no draft, but never the current chapter", () => {
|
|
const list = [item(1), item(2, { has_draft: false })];
|
|
const optsOnOther = buildReviewChapterOptions(list, outline, 1);
|
|
expect(optsOnOther.find((o) => o.no === 2)?.disabled).toBe(true);
|
|
// 当前就在第 2 章时不置灰(否则无法停留)
|
|
const optsOnEmpty = buildReviewChapterOptions(list, outline, 2);
|
|
expect(optsOnEmpty.find((o) => o.no === 2)?.disabled).toBe(false);
|
|
});
|
|
|
|
it("always includes the current chapter even if absent from list and outline", () => {
|
|
const opts = buildReviewChapterOptions([item(1)], outline, 7);
|
|
expect(opts.map((o) => o.no)).toEqual([1, 2, 7]);
|
|
expect(opts.find((o) => o.no === 7)).toEqual({
|
|
no: 7,
|
|
label: "第 7 章",
|
|
disabled: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("defaultReviewChapter", () => {
|
|
it("prefers the latest chapter that has a draft", () => {
|
|
const list = [item(1), item(2), item(3, { has_draft: false })];
|
|
expect(defaultReviewChapter(list)).toBe(2);
|
|
});
|
|
|
|
it("falls back to the max chapter number when none have drafts", () => {
|
|
const list = [item(1, { has_draft: false }), item(4, { has_draft: false })];
|
|
expect(defaultReviewChapter(list)).toBe(4);
|
|
});
|
|
|
|
it("defaults to chapter 1 for an empty list", () => {
|
|
expect(defaultReviewChapter([])).toBe(1);
|
|
});
|
|
});
|