Files
silo-server/web/src/lib/mangaChapters.test.ts
T
QuickandClaude Opus 4.8 b719292b3c fix(web): show "Resume Reading" CTA for a part-read manga volume
The series hero CTA chose its verb from a whole-list `anyRead` check, so a
volume partially read (e.g. 18%) — whose read flag is still false — fell
through to "Start Reading" even though the resume target already had
reading progress. A later volume read out of order would also mislabel an
in-progress earlier volume as "Continue".

Add resumeState() to the manga chapter library: it walks the flat reading
order once to resolve the resume target, derives "earlier read" only from
chapters before the target (so skip-ahead / manual mark-read doesn't flip
the verb), and flags the target as in-progress only for a reading position
strictly inside (0, 1) — the server marks a chapter read past its finished
threshold, so a full-progress target with a stale read flag must not show
"Resume Reading". The CTA verb is now hasEarlierRead ? "Continue"
: targetInProgress ? "Resume Reading" : "Start Reading"; a pristine series
still shows "Start Reading" and a fully read one "Read Again".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:44:05 -04:00

231 lines
8.7 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from "vitest";
import type { MangaChapter } from "@/api/types";
import { buildMangaList, prettifyVolumeLabel, resumeState } from "./mangaChapters";
function chapter(partial: Partial<MangaChapter>): MangaChapter {
return {
content_id: partial.content_id ?? "c",
title: partial.title ?? "Chapter",
chapter_index: partial.chapter_index,
volume: partial.volume,
read: partial.read,
progress: partial.progress,
};
}
describe("buildMangaList", () => {
it("renders a pure-volume series as flat volume units (no nested chapter)", () => {
const entries = buildMangaList([
chapter({ content_id: "v1", chapter_index: 1, volume: "v01" }),
chapter({ content_id: "v2", chapter_index: 2, volume: "v02" }),
]);
expect(entries).toEqual([
{ kind: "volume", chapter: expect.objectContaining({ content_id: "v1" }), label: "Volume 1" },
{ kind: "volume", chapter: expect.objectContaining({ content_id: "v2" }), label: "Volume 2" },
]);
});
it("renders a pure-chapter series as flat loose chapters ordered by index", () => {
const entries = buildMangaList([
chapter({ content_id: "c178", chapter_index: 178, volume: "" }),
chapter({ content_id: "c179", chapter_index: 179 }),
]);
expect(entries).toEqual([
{
kind: "chapter",
chapter: expect.objectContaining({ content_id: "c178" }),
label: "Chapter 178",
},
{
kind: "chapter",
chapter: expect.objectContaining({ content_id: "c179" }),
label: "Chapter 179",
},
]);
});
it("nests only when a single volume holds multiple chapters", () => {
const entries = buildMangaList([
chapter({ content_id: "v1-c2", chapter_index: 2, volume: "v01" }),
chapter({ content_id: "v1-c1", chapter_index: 1, volume: "v01" }),
]);
expect(entries).toHaveLength(1);
const entry = entries[0];
expect(entry?.kind).toBe("section");
if (entry?.kind === "section") {
expect(entry.label).toBe("Volume 1");
expect(entry.chapters.map((c) => c.content_id)).toEqual(["v1-c1", "v1-c2"]);
}
});
it("orders all top-level entries by representative index, loose chapters not forced last", () => {
const entries = buildMangaList([
chapter({ content_id: "loose-5", chapter_index: 5 }),
chapter({ content_id: "v-c10", chapter_index: 10, volume: "v02" }),
chapter({ content_id: "v-c1", chapter_index: 1, volume: "v01" }),
chapter({ content_id: "loose-3", chapter_index: 3, volume: "" }),
]);
expect(entries.map((e) => e.label)).toEqual(["Volume 1", "Chapter 3", "Chapter 5", "Volume 2"]);
});
it("orders a section by its minimum chapter index relative to other entries", () => {
const entries = buildMangaList([
chapter({ content_id: "v2-c9", chapter_index: 9, volume: "v02" }),
chapter({ content_id: "v2-c10", chapter_index: 10, volume: "v02" }),
chapter({ content_id: "v1", chapter_index: 1, volume: "v01" }),
]);
expect(entries.map((e) => e.label)).toEqual(["Volume 1", "Volume 2"]);
expect(entries[1]?.kind).toBe("section");
});
it("labels a loose chapter without an index by its trimmed title", () => {
const entries = buildMangaList([chapter({ content_id: "bonus", title: " Bonus " })]);
expect(entries).toEqual([
{
kind: "chapter",
chapter: expect.objectContaining({ content_id: "bonus" }),
label: "Bonus",
},
]);
});
it("places chapters with a null index last within a section", () => {
const entries = buildMangaList([
chapter({ content_id: "v1-cNull", volume: "v01" }),
chapter({ content_id: "v1-c1", chapter_index: 1, volume: "v01" }),
chapter({ content_id: "v1-c2", chapter_index: 2, volume: "v01" }),
]);
expect(entries[0]?.kind).toBe("section");
if (entries[0]?.kind === "section") {
expect(entries[0].chapters.map((c) => c.content_id)).toEqual(["v1-c1", "v1-c2", "v1-cNull"]);
}
});
it("returns an empty array for no chapters", () => {
expect(buildMangaList([])).toEqual([]);
});
});
describe("prettifyVolumeLabel", () => {
it("expands a v-prefixed token to a Volume label", () => {
expect(prettifyVolumeLabel("v13")).toBe("Volume 13");
expect(prettifyVolumeLabel("V2")).toBe("Volume 2");
});
it("expands a bare numeric token to a Volume label", () => {
expect(prettifyVolumeLabel("7")).toBe("Volume 7");
});
it("passes through non-numeric tokens unchanged", () => {
expect(prettifyVolumeLabel("Omnibus")).toBe("Omnibus");
});
});
describe("volume token normalization", () => {
it("buckets 'v01' and '1' into the same volume", () => {
const entries = buildMangaList([
{ content_id: "a", title: "Series v01", chapter_index: 1, volume: "v01" },
{ content_id: "b", title: "Series 1 extras", chapter_index: 2, volume: "1" },
]);
// One section labeled "Volume 1" holding both chapters — not two
// duplicate top-level entries.
expect(entries).toHaveLength(1);
const [entry] = entries;
if (!entry || entry.kind !== "section") {
throw new Error(`expected a section entry, got ${JSON.stringify(entry)}`);
}
expect(entry.label).toBe("Volume 1");
expect(entry.chapters.map((c) => c.content_id)).toEqual(["a", "b"]);
});
it("keeps non-numeric tokens distinct", () => {
const entries = buildMangaList([
{ content_id: "a", title: "Omnibus", chapter_index: 1, volume: "Omnibus" },
{ content_id: "b", title: "v2", chapter_index: 2, volume: "v2" },
]);
expect(entries).toHaveLength(2);
});
});
describe("resumeState", () => {
it("returns null for an empty list", () => {
expect(resumeState(buildMangaList([]))).toBeNull();
});
it("returns null once every chapter is read (caller restarts)", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", read: true }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02", read: true }),
]);
expect(resumeState(entries)).toBeNull();
});
it("targets the first volume with no earlier-read and no progress (Start Reading)", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01" }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02" }),
]);
expect(resumeState(entries)).toEqual({
target: expect.objectContaining({ chapter: expect.objectContaining({ content_id: "v01" }) }),
hasEarlierRead: false,
targetInProgress: false,
});
});
it("flags the target in progress when it is part-read but unfinished (Resume Reading)", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", progress: 0.18 }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02" }),
]);
const state = resumeState(entries);
expect(state?.target.chapter.content_id).toBe("v01");
expect(state?.hasEarlierRead).toBe(false);
expect(state?.targetInProgress).toBe(true);
});
it("does not treat a fully read (progress 1, read true) earlier chapter as in-progress (Continue)", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", read: true, progress: 1 }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02" }),
]);
const state = resumeState(entries);
expect(state?.target.chapter.content_id).toBe("v02");
expect(state?.hasEarlierRead).toBe(true);
expect(state?.targetInProgress).toBe(false);
});
it("scopes hasEarlierRead to chapters before the target: a later read volume does not flip an in-progress target to Continue", () => {
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", progress: 0.18 }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02", read: true }),
]);
const state = resumeState(entries);
expect(state?.target.chapter.content_id).toBe("v01");
expect(state?.hasEarlierRead).toBe(false);
expect(state?.targetInProgress).toBe(true);
});
it("does not flag a full-progress (1.0) target with a stale/absent read flag as in-progress", () => {
// The server marks a chapter read past its finished threshold, so a target
// at full progress with read still unset is finished, not "Resume Reading".
const entries = buildMangaList([
chapter({ content_id: "v01", chapter_index: 1, volume: "v01", progress: 1 }),
chapter({ content_id: "v02", chapter_index: 2, volume: "v02" }),
]);
const state = resumeState(entries);
expect(state?.target.chapter.content_id).toBe("v01");
expect(state?.targetInProgress).toBe(false);
});
});