feat(library): manga spec — jikan enrich, rss/mangadex chapter flip
This commit is contained in:
parent
c398669ae6
commit
abc3489d27
5 changed files with 879 additions and 0 deletions
288
packages/obsidian/src/library/manga.ts
Normal file
288
packages/obsidian/src/library/manga.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
|
||||||
|
import { parseFeed, latestChapter } from 'packages/obsidian/src/library/rss';
|
||||||
|
import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
|
||||||
|
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
|
||||||
|
|
||||||
|
const JIKAN_BASE = 'https://api.jikan.moe/v4';
|
||||||
|
const MANGADEX_BASE = 'https://api.mangadex.org';
|
||||||
|
|
||||||
|
export interface MangaRecord {
|
||||||
|
title: string;
|
||||||
|
engName: string;
|
||||||
|
readStatus: string;
|
||||||
|
rating: string;
|
||||||
|
ratingStars: string;
|
||||||
|
lastReadChapter: string;
|
||||||
|
latestChapter: number | null;
|
||||||
|
lastChapterDate: string;
|
||||||
|
chapters: number | null;
|
||||||
|
volumes: number | null;
|
||||||
|
status: string;
|
||||||
|
authors: string[];
|
||||||
|
genre: string[];
|
||||||
|
score: number | null;
|
||||||
|
publishedFrom: string | null;
|
||||||
|
publishedTo: string | null;
|
||||||
|
malId: string;
|
||||||
|
mangadexId: string;
|
||||||
|
rss: string;
|
||||||
|
poster: string | null;
|
||||||
|
url: string;
|
||||||
|
synopsis: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChapterUpdate {
|
||||||
|
chapter: number | null;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumOrNull(raw: string | undefined): number | null {
|
||||||
|
const s = stripQuotes(raw);
|
||||||
|
if (!s || s === 'null') return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveReadStatus(prev: Record<string, string>): string {
|
||||||
|
const canonical = stripQuotes(prev['read_status']);
|
||||||
|
if (canonical) return canonical;
|
||||||
|
// skeleton conversion: legacy boolean `read` field
|
||||||
|
return stripQuotes(prev['read']) === 'true' ? 'Read' : 'Unread';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveRating(prev: Record<string, string>): { rating: string; ratingStars: string } {
|
||||||
|
const canonicalRating = stripQuotes(prev['rating']);
|
||||||
|
if (canonicalRating) return { rating: canonicalRating, ratingStars: stripQuotes(prev['rating_stars']) };
|
||||||
|
// skeleton conversion: legacy numeric `personalRating` field -> N stars
|
||||||
|
const legacy = Number(stripQuotes(prev['personalRating']));
|
||||||
|
if (Number.isFinite(legacy) && legacy > 0) {
|
||||||
|
return { rating: String(legacy), ratingStars: '⭐️'.repeat(legacy) };
|
||||||
|
}
|
||||||
|
return { rating: '0', ratingStars: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure mapper: Jikan `/manga/{id}/full` payload (the unwrapped `data` object) + prev
|
||||||
|
* frontmatter -> canonical MangaRecord. User-managed fields (read_status, rating,
|
||||||
|
* rating_stars, last_read_chapter, rss, mangadex_id, latest_chapter, last_chapter_date)
|
||||||
|
* are preserved from prev (with legacy-skeleton fallback); everything else is freshly
|
||||||
|
* derived from the Jikan response on every call.
|
||||||
|
*/
|
||||||
|
export function buildManga(jikan: any, prev: Record<string, string>): MangaRecord {
|
||||||
|
const title: string = jikan.title ?? '';
|
||||||
|
const titleEnglish: string = jikan.title_english ?? '';
|
||||||
|
const engName = titleEnglish && titleEnglish !== title ? titleEnglish : '';
|
||||||
|
|
||||||
|
const { rating, ratingStars } = deriveRating(prev);
|
||||||
|
const readStatus = deriveReadStatus(prev);
|
||||||
|
const lastReadChapter = stripQuotes(prev['last_read_chapter']);
|
||||||
|
const rss = stripQuotes(prev['rss']);
|
||||||
|
const mangadexId = stripQuotes(prev['mangadex_id']);
|
||||||
|
|
||||||
|
const score = typeof jikan.score === 'number' ? Math.round(jikan.score * 10) / 10 : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
engName,
|
||||||
|
readStatus,
|
||||||
|
rating,
|
||||||
|
ratingStars,
|
||||||
|
lastReadChapter,
|
||||||
|
latestChapter: parseNumOrNull(prev['latest_chapter']),
|
||||||
|
lastChapterDate: stripQuotes(prev['last_chapter_date']),
|
||||||
|
chapters: jikan.chapters ?? null,
|
||||||
|
volumes: jikan.volumes ?? null,
|
||||||
|
status: jikan.status ?? '',
|
||||||
|
authors: (jikan.authors ?? []).map((a: any) => a.name).filter(Boolean),
|
||||||
|
genre: (jikan.genres ?? []).map((g: any) => g.name).filter(Boolean),
|
||||||
|
score,
|
||||||
|
publishedFrom: jikan.published?.from ? String(jikan.published.from).slice(0, 10) : null,
|
||||||
|
publishedTo: jikan.published?.to ? String(jikan.published.to).slice(0, 10) : null,
|
||||||
|
malId: jikan.mal_id != null ? String(jikan.mal_id) : '',
|
||||||
|
mangadexId,
|
||||||
|
rss,
|
||||||
|
poster: jikan.images?.jpg?.large_image_url ?? null,
|
||||||
|
url: jikan.url ?? '',
|
||||||
|
synopsis: jikan.synopsis ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string {
|
||||||
|
const fm = [
|
||||||
|
'---',
|
||||||
|
'type: manga_item',
|
||||||
|
`title: ${yamlScalar(r.title)}`,
|
||||||
|
`eng_name: ${yamlScalar(r.engName)}`,
|
||||||
|
`read_status: ${r.readStatus}`,
|
||||||
|
`rating: ${r.rating}`,
|
||||||
|
`rating_stars: ${r.ratingStars}`,
|
||||||
|
`last_read_chapter: ${r.lastReadChapter}`,
|
||||||
|
`latest_chapter: ${r.latestChapter ?? 'null'}`,
|
||||||
|
`last_chapter_date: ${r.lastChapterDate ? r.lastChapterDate : 'null'}`,
|
||||||
|
`chapters: ${r.chapters ?? 'null'}`,
|
||||||
|
`volumes: ${r.volumes ?? 'null'}`,
|
||||||
|
`status: ${r.status}`,
|
||||||
|
`authors: ${yamlList(r.authors)}`,
|
||||||
|
`genre: ${yamlList(r.genre)}`,
|
||||||
|
`score: ${r.score ?? 'null'}`,
|
||||||
|
`published_from: ${r.publishedFrom ? r.publishedFrom : 'null'}`,
|
||||||
|
`published_to: ${r.publishedTo ? r.publishedTo : 'null'}`,
|
||||||
|
`mal_id: ${r.malId}`,
|
||||||
|
`mangadex_id: ${r.mangadexId}`,
|
||||||
|
`rss: ${quotedOrNull(r.rss)}`,
|
||||||
|
`poster: ${quotedOrNull(r.poster)}`,
|
||||||
|
`url: ${quotedOrNull(r.url)}`,
|
||||||
|
'tags: [mangas, manga]',
|
||||||
|
'---',
|
||||||
|
];
|
||||||
|
|
||||||
|
const b: string[] = ['', `# ${r.title}`];
|
||||||
|
if (r.engName) b.push(`*${r.engName}*`);
|
||||||
|
b.push('');
|
||||||
|
if (r.poster) b.push(``, '');
|
||||||
|
const meta = ['**Manga**', ...[r.status, r.score !== null ? String(r.score) : ''].filter(x => x)];
|
||||||
|
b.push(meta.join(' · '), '');
|
||||||
|
b.push(`**Read Status:** ${r.readStatus}`);
|
||||||
|
if (r.lastReadChapter) {
|
||||||
|
const denom = r.latestChapter ?? r.chapters ?? '?';
|
||||||
|
b.push(`**Progress:** ch. ${r.lastReadChapter} / ${denom}`);
|
||||||
|
}
|
||||||
|
b.push('');
|
||||||
|
if (r.synopsis) b.push('## Synopsis', r.synopsis, '');
|
||||||
|
if (r.authors.length) b.push(`**Authors:** ${r.authors.join(', ')}`, '');
|
||||||
|
const links: string[] = [];
|
||||||
|
if (r.url) links.push(`- [MAL page](${r.url})`);
|
||||||
|
if (r.rss) links.push(`- [RSS feed](${r.rss})`);
|
||||||
|
if (links.length) b.push('## Links', ...links, '');
|
||||||
|
for (const s of customSections) b.push(`## ${s.heading}`, s.content, '');
|
||||||
|
b.push('## My Notes', '', myNotes);
|
||||||
|
if (myNotes) b.push('');
|
||||||
|
return fm.join('\n') + '\n' + b.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chapter source cascade: rss (if set) -> mangadex_id (if set) -> null (leave unchanged). */
|
||||||
|
async function resolveChapterUpdate(fm: Record<string, string>, deps: SpecDeps): Promise<ChapterUpdate | null> {
|
||||||
|
const rss = stripQuotes(fm['rss']);
|
||||||
|
if (rss) {
|
||||||
|
try {
|
||||||
|
const xml = await deps.httpText(rss, {});
|
||||||
|
const latest = latestChapter(parseFeed(xml));
|
||||||
|
if (latest) return { chapter: latest.chapter, date: latest.date };
|
||||||
|
} catch (e) {
|
||||||
|
deps.log(`manga rss fetch failed (${rss}): ${String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mangadexId = stripQuotes(fm['mangadex_id']);
|
||||||
|
if (mangadexId) {
|
||||||
|
try {
|
||||||
|
const url = `${MANGADEX_BASE}/manga/${mangadexId}/feed?order[readableAt]=desc&limit=1&translatedLanguage[]=en`;
|
||||||
|
const json = await deps.http(url, {});
|
||||||
|
const attrs = json?.data?.[0]?.attributes;
|
||||||
|
if (attrs) {
|
||||||
|
const chapter = attrs.chapter ? parseFloat(attrs.chapter) : null;
|
||||||
|
const date = typeof attrs.readableAt === 'string' ? attrs.readableAt.slice(0, 10) : '';
|
||||||
|
return { chapter: Number.isFinite(chapter as number) ? chapter : null, date };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
deps.log(`manga mangadex fetch failed (${mangadexId}): ${String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultTitles(r: any): string[] {
|
||||||
|
const titles = [r.title, r.title_english, ...(r.titles ?? []).map((t: any) => t.title)];
|
||||||
|
return titles.filter(Boolean).map((t: string) => String(t).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMalId(query: string, deps: SpecDeps): Promise<string | null> {
|
||||||
|
const qs = new URLSearchParams({ q: query, limit: '10' });
|
||||||
|
const res = await deps.http(`${JIKAN_BASE}/manga?${qs.toString()}`, {});
|
||||||
|
const results: any[] = res?.data ?? [];
|
||||||
|
if (results.length === 0) return null;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const exacts = results.filter(r => resultTitles(r).includes(q));
|
||||||
|
const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null;
|
||||||
|
return pick ? String(pick.mal_id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mangaSpec: MediaTypeSpec = {
|
||||||
|
typeName: 'manga',
|
||||||
|
itemType: 'manga_item',
|
||||||
|
folderSettingKey: 'libraryMangaFolder',
|
||||||
|
enabledSettingKey: 'libraryMangaEnabled',
|
||||||
|
throttleMs: 350,
|
||||||
|
|
||||||
|
hasId(fm: Record<string, string>): boolean {
|
||||||
|
return !!stripQuotes(fm['mal_id']);
|
||||||
|
},
|
||||||
|
|
||||||
|
isActive(fm: Record<string, string>): boolean {
|
||||||
|
const malId = stripQuotes(fm['mal_id']);
|
||||||
|
const status = stripQuotes(fm['status']);
|
||||||
|
if (!malId || !status) return true; // never enriched -> needs first pass
|
||||||
|
if (status === 'Publishing' || status === 'On Hiatus') return true;
|
||||||
|
if (stripQuotes(fm['read_status']) === 'Reading') return true;
|
||||||
|
if (stripQuotes(fm['rss'])) return true;
|
||||||
|
return false; // Finished + Read/Unread/Dropped -> static
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null> {
|
||||||
|
const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, '');
|
||||||
|
if (!query) return null;
|
||||||
|
try {
|
||||||
|
const malId = await resolveMalId(query, deps);
|
||||||
|
return malId ? { mal_id: malId } : null;
|
||||||
|
} catch (e) {
|
||||||
|
deps.log(`manga resolve failed for "${query}": ${String(e)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> {
|
||||||
|
const fm = ctx.frontmatter;
|
||||||
|
const malId = stripQuotes(fm['mal_id']);
|
||||||
|
if (!malId) return null; // needs resolve() first
|
||||||
|
|
||||||
|
let jikanData: any;
|
||||||
|
try {
|
||||||
|
const res = await deps.http(`${JIKAN_BASE}/manga/${malId}/full`, {});
|
||||||
|
jikanData = res?.data;
|
||||||
|
} catch (e) {
|
||||||
|
deps.log(`manga jikan fetch failed (mal_id ${malId}): ${String(e)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!jikanData) return null;
|
||||||
|
|
||||||
|
const record = buildManga(jikanData, fm);
|
||||||
|
const prevStatus = stripQuotes(fm['status']);
|
||||||
|
const prevLatestChapter = parseNumOrNull(fm['latest_chapter']);
|
||||||
|
const prevLastChapterDate = stripQuotes(fm['last_chapter_date']);
|
||||||
|
|
||||||
|
let flipped = false;
|
||||||
|
const newInfo = await resolveChapterUpdate(fm, deps);
|
||||||
|
if (newInfo) {
|
||||||
|
const isUpdate = newInfo.chapter !== null ? prevLatestChapter === null || newInfo.chapter > prevLatestChapter : !!newInfo.date && newInfo.date > prevLastChapterDate;
|
||||||
|
if (isUpdate) {
|
||||||
|
record.latestChapter = newInfo.chapter;
|
||||||
|
if (newInfo.date) record.lastChapterDate = newInfo.date;
|
||||||
|
if (record.readStatus === 'Read') {
|
||||||
|
record.readStatus = 'Unread';
|
||||||
|
flipped = true;
|
||||||
|
deps.notify(`«${record.title}» ch. ${newInfo.chapter ?? '?'} out`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish-flip: series completed while user had marked it Read
|
||||||
|
if (prevStatus === 'Publishing' && record.status === 'Finished' && record.readStatus === 'Read') {
|
||||||
|
record.readStatus = 'Unread';
|
||||||
|
flipped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = renderManga(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
|
||||||
|
return { content, flipped };
|
||||||
|
},
|
||||||
|
};
|
||||||
46
tests/fixtures/canonical-manga.md
vendored
Normal file
46
tests/fixtures/canonical-manga.md
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
type: manga_item
|
||||||
|
title: Chainsaw Man
|
||||||
|
eng_name:
|
||||||
|
read_status: Reading
|
||||||
|
rating: 4
|
||||||
|
rating_stars: ⭐️⭐️⭐️⭐️
|
||||||
|
last_read_chapter: 210
|
||||||
|
latest_chapter: 213
|
||||||
|
last_chapter_date: 2026-07-16
|
||||||
|
chapters: null
|
||||||
|
volumes: null
|
||||||
|
status: Publishing
|
||||||
|
authors: ["Fujimoto, Tatsuki"]
|
||||||
|
genre: [Action, Horror, Sports]
|
||||||
|
score: 8.7
|
||||||
|
published_from: 2018-12-03
|
||||||
|
published_to: null
|
||||||
|
mal_id: 116778
|
||||||
|
mangadex_id: abc-123
|
||||||
|
rss: "https://example.com/csm-feed.xml"
|
||||||
|
poster: "https://cdn.myanimelist.net/images/manga/3/216464l.jpg"
|
||||||
|
url: "https://myanimelist.net/manga/116778/Chainsaw_Man"
|
||||||
|
tags: [mangas, manga]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Chainsaw Man
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Manga** · Publishing · 8.7
|
||||||
|
|
||||||
|
**Read Status:** Reading
|
||||||
|
**Progress:** ch. 210 / 213
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him.
|
||||||
|
|
||||||
|
**Authors:** Fujimoto, Tatsuki
|
||||||
|
|
||||||
|
## Links
|
||||||
|
- [MAL page](https://myanimelist.net/manga/116778/Chainsaw_Man)
|
||||||
|
- [RSS feed](https://example.com/csm-feed.xml)
|
||||||
|
|
||||||
|
## My Notes
|
||||||
|
|
||||||
40
tests/fixtures/jikan-manga-csm.json
vendored
Normal file
40
tests/fixtures/jikan-manga-csm.json
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"mal_id": 116778,
|
||||||
|
"url": "https://myanimelist.net/manga/116778/Chainsaw_Man",
|
||||||
|
"images": {
|
||||||
|
"jpg": {
|
||||||
|
"image_url": "https://cdn.myanimelist.net/images/manga/3/216464.jpg",
|
||||||
|
"large_image_url": "https://cdn.myanimelist.net/images/manga/3/216464l.jpg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Chainsaw Man",
|
||||||
|
"title_english": "Chainsaw Man",
|
||||||
|
"title_japanese": "チェンソーマン",
|
||||||
|
"titles": [
|
||||||
|
{ "type": "Default", "title": "Chainsaw Man" },
|
||||||
|
{ "type": "Japanese", "title": "チェンソーマン" },
|
||||||
|
{ "type": "English", "title": "Chainsaw Man" }
|
||||||
|
],
|
||||||
|
"type": "Manga",
|
||||||
|
"chapters": null,
|
||||||
|
"volumes": null,
|
||||||
|
"status": "Publishing",
|
||||||
|
"publishing": true,
|
||||||
|
"published": {
|
||||||
|
"from": "2018-12-03T00:00:00+00:00",
|
||||||
|
"to": null
|
||||||
|
},
|
||||||
|
"score": 8.73,
|
||||||
|
"scored_by": 42000,
|
||||||
|
"rank": 12,
|
||||||
|
"popularity": 3,
|
||||||
|
"synopsis": "Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him. Now, he'll hunt down devils with his devil-dog powers and try to survive in a world that sees him as a weapon.",
|
||||||
|
"authors": [{ "mal_id": 1, "type": "people", "name": "Fujimoto, Tatsuki" }],
|
||||||
|
"genres": [
|
||||||
|
{ "mal_id": 1, "type": "manga", "name": "Action" },
|
||||||
|
{ "mal_id": 8, "type": "manga", "name": "Horror" },
|
||||||
|
{ "mal_id": 30, "type": "manga", "name": "Sports" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tests/fixtures/mangadex-feed.json
vendored
Normal file
20
tests/fixtures/mangadex-feed.json
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"result": "ok",
|
||||||
|
"response": "collection",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "b1a2c3d4-0000-1111-2222-333344445555",
|
||||||
|
"type": "chapter",
|
||||||
|
"attributes": {
|
||||||
|
"chapter": "214",
|
||||||
|
"title": "",
|
||||||
|
"translatedLanguage": "en",
|
||||||
|
"publishAt": "2026-07-30T11:00:00+00:00",
|
||||||
|
"readableAt": "2026-07-30T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limit": 1,
|
||||||
|
"offset": 0,
|
||||||
|
"total": 214
|
||||||
|
}
|
||||||
485
tests/library-manga.test.ts
Normal file
485
tests/library-manga.test.ts
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { buildManga, renderManga, mangaSpec, type MangaRecord } from 'packages/obsidian/src/library/manga';
|
||||||
|
import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types';
|
||||||
|
import jikanFixture from 'tests/fixtures/jikan-manga-csm.json';
|
||||||
|
import mangadexFixture from 'tests/fixtures/mangadex-feed.json';
|
||||||
|
|
||||||
|
const JIKAN_DATA = jikanFixture.data;
|
||||||
|
|
||||||
|
const EMPTY_PREV: Record<string, string> = {};
|
||||||
|
|
||||||
|
function makeDeps(overrides: Partial<SpecDeps> = {}): SpecDeps & { notifyCalls: string[]; logCalls: string[] } {
|
||||||
|
const notifyCalls: string[] = [];
|
||||||
|
const logCalls: string[] = [];
|
||||||
|
return {
|
||||||
|
http: async () => ({}),
|
||||||
|
httpText: async () => '',
|
||||||
|
getKey: () => '',
|
||||||
|
log: (msg: string) => {
|
||||||
|
logCalls.push(msg);
|
||||||
|
},
|
||||||
|
notify: (msg: string) => {
|
||||||
|
notifyCalls.push(msg);
|
||||||
|
},
|
||||||
|
notifyCalls,
|
||||||
|
logCalls,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RSS_214 = `<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<item><title>Chainsaw Man Chapter 214</title><pubDate>Thu, 30 Jul 2026 12:00:00 GMT</pubDate><guid>214</guid></item>
|
||||||
|
<item><title>Chainsaw Man Chapter 213</title><pubDate>Thu, 23 Jul 2026 12:00:00 GMT</pubDate><guid>213</guid></item>
|
||||||
|
</channel></rss>`;
|
||||||
|
|
||||||
|
const RSS_NOISE_PLUS_214 = `<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<item><title>Chainsaw Man - #1 ranked this week!</title><pubDate>Thu, 30 Jul 2026 13:00:00 GMT</pubDate><guid>noise</guid></item>
|
||||||
|
<item><title>Chainsaw Man Chapter 214</title><pubDate>Thu, 30 Jul 2026 12:00:00 GMT</pubDate><guid>214</guid></item>
|
||||||
|
</channel></rss>`;
|
||||||
|
|
||||||
|
const RSS_NUMBERLESS = `<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<item><title>Chainsaw Man - Extra Announcement</title><pubDate>Thu, 30 Jul 2026 12:00:00 GMT</pubDate><guid>a</guid></item>
|
||||||
|
</channel></rss>`;
|
||||||
|
|
||||||
|
describe('buildManga field mapping', () => {
|
||||||
|
const r = buildManga(JIKAN_DATA, EMPTY_PREV);
|
||||||
|
test('core fields', () => {
|
||||||
|
expect(r.title).toBe('Chainsaw Man');
|
||||||
|
expect(r.malId).toBe('116778');
|
||||||
|
expect(r.status).toBe('Publishing');
|
||||||
|
expect(r.chapters).toBeNull();
|
||||||
|
expect(r.volumes).toBeNull();
|
||||||
|
expect(r.authors).toEqual(['Fujimoto, Tatsuki']);
|
||||||
|
expect(r.genre).toEqual(['Action', 'Horror', 'Sports']);
|
||||||
|
expect(r.publishedFrom).toBe('2018-12-03');
|
||||||
|
expect(r.publishedTo).toBeNull();
|
||||||
|
expect(r.poster).toBe('https://cdn.myanimelist.net/images/manga/3/216464l.jpg');
|
||||||
|
expect(r.url).toBe('https://myanimelist.net/manga/116778/Chainsaw_Man');
|
||||||
|
});
|
||||||
|
test('score rounds to 1dp (8.73 -> 8.7)', () => {
|
||||||
|
expect(r.score).toBe(8.7);
|
||||||
|
});
|
||||||
|
test('7.854 -> 7.9', () => {
|
||||||
|
expect(buildManga({ ...JIKAN_DATA, score: 7.854 }, EMPTY_PREV).score).toBe(7.9);
|
||||||
|
});
|
||||||
|
test('null score -> null', () => {
|
||||||
|
expect(buildManga({ ...JIKAN_DATA, score: null }, EMPTY_PREV).score).toBeNull();
|
||||||
|
});
|
||||||
|
test('eng_name empty when title_english === title', () => {
|
||||||
|
expect(r.engName).toBe('');
|
||||||
|
});
|
||||||
|
test('eng_name set when title_english differs from title', () => {
|
||||||
|
const jp = { ...JIKAN_DATA, title: 'Chainsaw Man', title_english: 'Chainsaw Man EN Alt' };
|
||||||
|
expect(buildManga(jp, EMPTY_PREV).engName).toBe('Chainsaw Man EN Alt');
|
||||||
|
});
|
||||||
|
test('status passthrough: Finished', () => {
|
||||||
|
expect(buildManga({ ...JIKAN_DATA, status: 'Finished' }, EMPTY_PREV).status).toBe('Finished');
|
||||||
|
});
|
||||||
|
test('status passthrough: On Hiatus', () => {
|
||||||
|
expect(buildManga({ ...JIKAN_DATA, status: 'On Hiatus' }, EMPTY_PREV).status).toBe('On Hiatus');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildManga user-field preservation', () => {
|
||||||
|
test('read_status defaults to Unread', () => {
|
||||||
|
expect(buildManga(JIKAN_DATA, EMPTY_PREV).readStatus).toBe('Unread');
|
||||||
|
});
|
||||||
|
test('read_status carried from prev', () => {
|
||||||
|
const prev = { read_status: 'Reading' };
|
||||||
|
expect(buildManga(JIKAN_DATA, prev).readStatus).toBe('Reading');
|
||||||
|
});
|
||||||
|
test('rating/rating_stars carried from prev', () => {
|
||||||
|
const prev = { rating: '4', rating_stars: '⭐️⭐️⭐️⭐️' };
|
||||||
|
const r = buildManga(JIKAN_DATA, prev);
|
||||||
|
expect(r.rating).toBe('4');
|
||||||
|
expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️');
|
||||||
|
});
|
||||||
|
test('last_read_chapter carried from prev', () => {
|
||||||
|
expect(buildManga(JIKAN_DATA, { last_read_chapter: '150' }).lastReadChapter).toBe('150');
|
||||||
|
});
|
||||||
|
test('rss carried from prev', () => {
|
||||||
|
expect(buildManga(JIKAN_DATA, { rss: 'https://x.y/feed.xml' }).rss).toBe('https://x.y/feed.xml');
|
||||||
|
});
|
||||||
|
test('mangadex_id kept even when unrelated to jikan resolve', () => {
|
||||||
|
expect(buildManga(JIKAN_DATA, { mangadex_id: 'abc-123' }).mangadexId).toBe('abc-123');
|
||||||
|
});
|
||||||
|
test('latest_chapter/last_chapter_date preserved verbatim (not touched by buildManga)', () => {
|
||||||
|
const prev = { latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const r = buildManga(JIKAN_DATA, prev);
|
||||||
|
expect(r.latestChapter).toBe(213);
|
||||||
|
expect(r.lastChapterDate).toBe('2026-07-16');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildManga skeleton conversion', () => {
|
||||||
|
test('read: true -> read_status Read', () => {
|
||||||
|
const prev = { read: 'true', personalRating: '' };
|
||||||
|
expect(buildManga(JIKAN_DATA, prev).readStatus).toBe('Read');
|
||||||
|
});
|
||||||
|
test('read: false -> read_status Unread', () => {
|
||||||
|
const prev = { read: 'false', personalRating: '' };
|
||||||
|
expect(buildManga(JIKAN_DATA, prev).readStatus).toBe('Unread');
|
||||||
|
});
|
||||||
|
test('personalRating 3 -> rating 3 + 3 stars', () => {
|
||||||
|
const prev = { read: 'false', personalRating: '3' };
|
||||||
|
const r = buildManga(JIKAN_DATA, prev);
|
||||||
|
expect(r.rating).toBe('3');
|
||||||
|
expect(r.ratingStars).toBe('⭐️⭐️⭐️');
|
||||||
|
});
|
||||||
|
test('empty personalRating -> rating 0, no stars', () => {
|
||||||
|
const prev = { read: 'false', personalRating: '' };
|
||||||
|
const r = buildManga(JIKAN_DATA, prev);
|
||||||
|
expect(r.rating).toBe('0');
|
||||||
|
expect(r.ratingStars).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderManga golden', () => {
|
||||||
|
const RECORD: MangaRecord = {
|
||||||
|
title: 'Chainsaw Man',
|
||||||
|
engName: '',
|
||||||
|
readStatus: 'Reading',
|
||||||
|
rating: '4',
|
||||||
|
ratingStars: '⭐️⭐️⭐️⭐️',
|
||||||
|
lastReadChapter: '210',
|
||||||
|
latestChapter: 213,
|
||||||
|
lastChapterDate: '2026-07-16',
|
||||||
|
chapters: null,
|
||||||
|
volumes: null,
|
||||||
|
status: 'Publishing',
|
||||||
|
authors: ['Fujimoto, Tatsuki'],
|
||||||
|
genre: ['Action', 'Horror', 'Sports'],
|
||||||
|
score: 8.7,
|
||||||
|
publishedFrom: '2018-12-03',
|
||||||
|
publishedTo: null,
|
||||||
|
malId: '116778',
|
||||||
|
mangadexId: 'abc-123',
|
||||||
|
rss: 'https://example.com/csm-feed.xml',
|
||||||
|
poster: 'https://cdn.myanimelist.net/images/manga/3/216464l.jpg',
|
||||||
|
url: 'https://myanimelist.net/manga/116778/Chainsaw_Man',
|
||||||
|
synopsis: 'Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him.',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('matches canonical manga fixture byte-for-byte', () => {
|
||||||
|
const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-manga.md'), 'utf-8');
|
||||||
|
expect(renderManga(RECORD, '')).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom section (Collection) placed after Links, before My Notes', () => {
|
||||||
|
const out = renderManga(RECORD, '', [{ heading: 'Collection', content: 'Part of [[Mangas]]' }]);
|
||||||
|
const linksIdx = out.indexOf('## Links');
|
||||||
|
const collectionIdx = out.indexOf('## Collection');
|
||||||
|
const myNotesIdx = out.indexOf('## My Notes');
|
||||||
|
expect(collectionIdx).toBeGreaterThan(linksIdx);
|
||||||
|
expect(myNotesIdx).toBeGreaterThan(collectionIdx);
|
||||||
|
expect(out).toContain('## Collection\nPart of [[Mangas]]\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('My Notes content preserved', () => {
|
||||||
|
const out = renderManga(RECORD, 'currently reading');
|
||||||
|
expect(out).toContain('## My Notes\n\ncurrently reading');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no last_read_chapter -> Progress line omitted', () => {
|
||||||
|
const r = { ...RECORD, lastReadChapter: '' };
|
||||||
|
expect(renderManga(r, '')).not.toContain('**Progress:**');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Progress denominator falls back to chapters when latest_chapter null', () => {
|
||||||
|
const r = { ...RECORD, latestChapter: null, chapters: 150 };
|
||||||
|
expect(renderManga(r, '')).toContain('**Progress:** ch. 210 / 150');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Progress denominator falls back to ? when both null', () => {
|
||||||
|
const r = { ...RECORD, latestChapter: null, chapters: null };
|
||||||
|
expect(renderManga(r, '')).toContain('**Progress:** ch. 210 / ?');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mangaSpec.hasId', () => {
|
||||||
|
test('mal_id set -> true', () => expect(mangaSpec.hasId({ mal_id: '116778' })).toBe(true));
|
||||||
|
test('mal_id empty -> false', () => expect(mangaSpec.hasId({ mal_id: '' })).toBe(false));
|
||||||
|
test('mal_id missing -> false', () => expect(mangaSpec.hasId({})).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mangaSpec.isActive', () => {
|
||||||
|
test('never enriched (no mal_id/status) -> active', () => {
|
||||||
|
expect(mangaSpec.isActive({})).toBe(true);
|
||||||
|
});
|
||||||
|
test('Publishing -> active', () => {
|
||||||
|
expect(mangaSpec.isActive({ mal_id: '1', status: 'Publishing', read_status: 'Unread' })).toBe(true);
|
||||||
|
});
|
||||||
|
test('On Hiatus -> active', () => {
|
||||||
|
expect(mangaSpec.isActive({ mal_id: '1', status: 'On Hiatus', read_status: 'Unread' })).toBe(true);
|
||||||
|
});
|
||||||
|
test('Finished + read_status Reading -> active', () => {
|
||||||
|
expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Reading' })).toBe(true);
|
||||||
|
});
|
||||||
|
test('Finished + rss set -> active', () => {
|
||||||
|
expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Unread', rss: 'https://x.y/f.xml' })).toBe(true);
|
||||||
|
});
|
||||||
|
test('Finished + Read -> static', () => {
|
||||||
|
expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Read' })).toBe(false);
|
||||||
|
});
|
||||||
|
test('Finished + Unread -> static', () => {
|
||||||
|
expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Unread' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function ctxFor(fm: Record<string, string>, body = '## My Notes\n\n'): LibraryNoteCtx {
|
||||||
|
return { frontmatter: fm, body, filename: 'Chainsaw Man.md' };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mangaSpec.sync — jikan enrich', () => {
|
||||||
|
test('no mal_id -> null (needs resolve first)', async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const result = await mangaSpec.sync(ctxFor({}), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jikan fetch failure -> log, return null (no throw)', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => {
|
||||||
|
throw new Error('network down');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await mangaSpec.sync(ctxFor({ mal_id: '116778' }), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(deps.logCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful enrich with no rss/mangadex_id -> latest_chapter unchanged, no flip', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture });
|
||||||
|
const fm = { mal_id: '116778', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('latest_chapter: 213');
|
||||||
|
expect(result!.content).toContain('read_status: Read');
|
||||||
|
expect(deps.notifyCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mangaSpec.sync — chapter source priority', () => {
|
||||||
|
test('rss set -> httpText fetched, latestChapter() used, new > stored -> update + flip + notify', async () => {
|
||||||
|
let httpTextCalledWith = '';
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => jikanFixture,
|
||||||
|
httpText: async url => {
|
||||||
|
httpTextCalledWith = url;
|
||||||
|
return RSS_214;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://example.com/csm-feed.xml', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(httpTextCalledWith).toBe('https://example.com/csm-feed.xml');
|
||||||
|
expect(result!.flipped).toBe(true);
|
||||||
|
expect(result!.content).toContain('latest_chapter: 214');
|
||||||
|
expect(result!.content).toContain('read_status: Unread');
|
||||||
|
expect(deps.notifyCalls).toEqual(['«Chainsaw Man» ch. 214 out']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rss not set, mangadex_id set -> mangadex feed endpoint used', async () => {
|
||||||
|
let httpCalls: string[] = [];
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async url => {
|
||||||
|
httpCalls.push(url);
|
||||||
|
return url.includes('mangadex.org') ? mangadexFixture : jikanFixture;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fm = { mal_id: '116778', mangadex_id: 'abc-123', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(httpCalls.some(u => u.includes('/manga/abc-123/feed'))).toBe(true);
|
||||||
|
expect(result!.flipped).toBe(true);
|
||||||
|
expect(result!.content).toContain('latest_chapter: 214');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neither rss nor mangadex_id -> latest_chapter unchanged, no flip', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture });
|
||||||
|
const fm = { mal_id: '116778', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('latest_chapter: 213');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new chapter <= stored -> no update, no flip', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_214 });
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '214', last_chapter_date: '2026-07-30' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('latest_chapter: 214');
|
||||||
|
expect(deps.notifyCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numberless feed result: new date > stored last_chapter_date -> update + flip, chapter stays null', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_NUMBERLESS });
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '', last_chapter_date: '2026-07-01' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(true);
|
||||||
|
expect(result!.content).toContain('latest_chapter: null');
|
||||||
|
expect(result!.content).toContain('last_chapter_date: 2026-07-30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numberless feed result: new date <= stored -> no update', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_NUMBERLESS });
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '', last_chapter_date: '2026-08-15' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('last_chapter_date: 2026-08-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read_status Reading -> chapter update happens but never flips', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_214 });
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Reading', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('read_status: Reading');
|
||||||
|
expect(result!.content).toContain('latest_chapter: 214');
|
||||||
|
expect(deps.notifyCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read_status Unread -> never flipped', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_214 });
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Unread', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('read_status: Unread');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read_status Dropped -> never flipped', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_214 });
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Dropped', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('read_status: Dropped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rss fetch failure -> log, proceed with mangadex fallback (no throw)', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async url => (url.includes('mangadex.org') ? mangadexFixture : jikanFixture),
|
||||||
|
httpText: async () => {
|
||||||
|
throw new Error('rss unreachable');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', mangadex_id: 'abc-123', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(deps.logCalls.some(m => m.includes('rss'))).toBe(true);
|
||||||
|
expect(result!.flipped).toBe(true);
|
||||||
|
expect(result!.content).toContain('latest_chapter: 214');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rss fetch failure, no mangadex_id -> log, jikan enrich still completes, latest_chapter unchanged', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => jikanFixture,
|
||||||
|
httpText: async () => {
|
||||||
|
throw new Error('rss unreachable');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('latest_chapter: 213');
|
||||||
|
expect(result!.content).toContain('mal_id: 116778');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mangaSpec.sync — finish-flip', () => {
|
||||||
|
test('prev Publishing + new Finished + read_status Read -> Unread, flipped', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => ({ data: { ...JIKAN_DATA, status: 'Finished' } }) });
|
||||||
|
const fm = { mal_id: '116778', status: 'Publishing', read_status: 'Read' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(true);
|
||||||
|
expect(result!.content).toContain('read_status: Unread');
|
||||||
|
expect(result!.content).toContain('status: Finished');
|
||||||
|
});
|
||||||
|
test('prev Publishing + new Finished + read_status Reading -> no flip', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => ({ data: { ...JIKAN_DATA, status: 'Finished' } }) });
|
||||||
|
const fm = { mal_id: '116778', status: 'Publishing', read_status: 'Reading' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('read_status: Reading');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mangaSpec.sync — carry-forward: noise item does not win over real chapter', () => {
|
||||||
|
test('feed w/ "#1 ranked" noise + real "Chapter 214", stored 213 -> latest becomes 214 (not 1), flips once', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_NOISE_PLUS_214 });
|
||||||
|
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const result = await mangaSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(true);
|
||||||
|
expect(result!.content).toContain('latest_chapter: 214');
|
||||||
|
expect(result!.content).not.toContain('latest_chapter: 1\n');
|
||||||
|
expect(deps.notifyCalls).toEqual(['«Chainsaw Man» ch. 214 out']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('second run with same feed -> no flip, no diff', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_NOISE_PLUS_214 });
|
||||||
|
const fm1 = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' };
|
||||||
|
const first = await mangaSpec.sync(ctxFor(fm1), deps);
|
||||||
|
expect(first!.flipped).toBe(true);
|
||||||
|
|
||||||
|
// re-parse first run's output frontmatter as prev state for second run
|
||||||
|
const fmMatch = /^---\n([\s\S]*?)\n---/.exec(first!.content)!;
|
||||||
|
const fm2: Record<string, string> = {};
|
||||||
|
for (const line of fmMatch[1].split('\n')) {
|
||||||
|
const m = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
|
||||||
|
if (m) fm2[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const second = await mangaSpec.sync(ctxFor(fm2), deps);
|
||||||
|
expect(second!.flipped).toBe(false);
|
||||||
|
expect(second!.content).toBe(first!.content);
|
||||||
|
expect(deps.notifyCalls.length).toBe(1); // only the first run notified
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mangaSpec.resolve', () => {
|
||||||
|
test('unique exact title match -> accepted', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }),
|
||||||
|
});
|
||||||
|
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||||
|
expect(result).toEqual({ mal_id: '116778' });
|
||||||
|
});
|
||||||
|
test('no exact match, sole result -> accepted', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => ({ data: [{ mal_id: 999, title: 'Some Other Title', title_english: '' }] }),
|
||||||
|
});
|
||||||
|
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||||
|
expect(result).toEqual({ mal_id: '999' });
|
||||||
|
});
|
||||||
|
test('ambiguous (multiple results, no exact match) -> null', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => ({
|
||||||
|
data: [
|
||||||
|
{ mal_id: 1, title: 'Foo' },
|
||||||
|
{ mal_id: 2, title: 'Bar' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
test('no results -> null', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => ({ data: [] }) });
|
||||||
|
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
test('http throws -> log, return null (no throw)', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => {
|
||||||
|
throw new Error('down');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(deps.logCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue