feat(library): rating line, manga finish-flip notify, game links, dedupe
- render **Rating:** line in manga/book/game/comic body (after read/play status, gated on rating != '0' + stars non-empty), matching watchlist's render.ts convention - manga finish-flip now notifies user (final chapters out), matching chapter-flip's existing notify path - game renderGame emits ## Links (Steam by appid, RAWG by url) — fixes latent owned-heading strip where user-added Links sections silently vanished since games never re-rendered the heading - move duplicated deriveReadStatus/deriveRating/parseNumOrNull into library/convert.ts, import across specs; zero behavior change
This commit is contained in:
parent
d2a6940270
commit
c14da19c47
13 changed files with 140 additions and 83 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
|
||||
import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
|
||||
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
|
||||
import { deriveReadStatus, deriveRating } from 'packages/obsidian/src/library/convert';
|
||||
|
||||
const OPENLIBRARY_BASE = 'https://openlibrary.org';
|
||||
|
||||
|
|
@ -19,24 +20,6 @@ export interface BookRecord {
|
|||
url: string;
|
||||
}
|
||||
|
||||
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: '' };
|
||||
}
|
||||
|
||||
function deriveAuthors(doc: any, prev: Record<string, string>): string[] {
|
||||
const fresh: string[] = Array.isArray(doc.author_name) ? doc.author_name.filter(Boolean) : [];
|
||||
if (fresh.length) return fresh;
|
||||
|
|
@ -111,7 +94,9 @@ export function renderBook(r: BookRecord, myNotes: string, customSections: Custo
|
|||
if (r.poster) b.push(``, '');
|
||||
const meta = ['**Book**', ...[r.year !== null ? String(r.year) : '', r.pages !== null ? `${r.pages} p.` : ''].filter(x => x)];
|
||||
b.push(meta.join(' · '), '');
|
||||
b.push(`**Read Status:** ${r.readStatus}`, '');
|
||||
b.push(`**Read Status:** ${r.readStatus}`);
|
||||
if (r.rating !== '0' && r.rating !== '' && r.ratingStars) b.push(`**Rating:** ${r.ratingStars} (${r.rating}/5)`);
|
||||
b.push('');
|
||||
if (r.authors.length) b.push(`**Authors:** ${r.authors.join(', ')}`, '');
|
||||
const links: string[] = [];
|
||||
if (r.url) links.push(`- [Open Library](${r.url})`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
|
||||
import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
|
||||
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
|
||||
import { deriveReadStatus, deriveRating, parseNumOrNull } from 'packages/obsidian/src/library/convert';
|
||||
|
||||
const COMICVINE_BASE = 'https://comicvine.gamespot.com/api';
|
||||
|
||||
|
|
@ -22,31 +23,6 @@ export interface ComicRecord {
|
|||
description: 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: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* status is a manual/passthrough field -- Comic Vine's volume payload carries no reliable
|
||||
* "still publishing" signal (no last-issue date), so we deliberately do NOT invent any
|
||||
|
|
@ -143,6 +119,7 @@ export function renderComic(r: ComicRecord, myNotes: string, customSections: Cus
|
|||
const meta = ['**Comic**', ...[r.status, r.startYear].filter(x => x)];
|
||||
b.push(meta.join(' · '), '');
|
||||
b.push(`**Read Status:** ${r.readStatus}`);
|
||||
if (r.rating !== '0' && r.rating !== '' && r.ratingStars) b.push(`**Rating:** ${r.ratingStars} (${r.rating}/5)`);
|
||||
if (r.lastReadIssue) {
|
||||
const denom = r.latestIssue ?? r.issues ?? '?';
|
||||
b.push(`**Progress:** issue ${r.lastReadIssue} / ${denom}`);
|
||||
|
|
|
|||
29
packages/obsidian/src/library/convert.ts
Normal file
29
packages/obsidian/src/library/convert.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { stripQuotes } from 'packages/obsidian/src/watchlist/parse';
|
||||
|
||||
/** Shared across manga/comic: parse a stored numeric frontmatter field, tolerating 'null'/empty -> null. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** Shared across manga/book/comic: canonical read_status, with legacy boolean `read` skeleton fallback. */
|
||||
export 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';
|
||||
}
|
||||
|
||||
/** Shared across manga/book/game/comic: canonical rating + stars, with legacy numeric `personalRating` skeleton fallback. */
|
||||
export 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: '' };
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
|
||||
import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
|
||||
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
|
||||
import { deriveRating } from 'packages/obsidian/src/library/convert';
|
||||
|
||||
const STEAM_STORE_BASE = 'https://store.steampowered.com';
|
||||
const RAWG_BASE = 'https://api.rawg.io/api';
|
||||
|
|
@ -47,17 +48,6 @@ export interface GameRecord {
|
|||
description: string;
|
||||
}
|
||||
|
||||
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: '' };
|
||||
}
|
||||
|
||||
function derivePlayStatus(prev: Record<string, string>): string {
|
||||
const canonical = stripQuotes(prev['play_status']);
|
||||
if (canonical) return canonical;
|
||||
|
|
@ -150,13 +140,19 @@ export function renderGame(r: GameRecord, myNotes: string, customSections: Custo
|
|||
if (r.poster) b.push(``, '');
|
||||
const meta = ['**Game**', ...[r.releaseDate, r.metacritic !== null ? `Metacritic ${r.metacritic}` : ''].filter(x => x)];
|
||||
b.push(meta.join(' · '), '');
|
||||
b.push(`**Play Status:** ${r.playStatus}`, '');
|
||||
b.push(`**Play Status:** ${r.playStatus}`);
|
||||
if (r.rating !== '0' && r.rating !== '' && r.ratingStars) b.push(`**Rating:** ${r.ratingStars} (${r.rating}/5)`);
|
||||
b.push('');
|
||||
if (r.description) b.push('## Synopsis', r.description, '');
|
||||
const facts: string[] = [];
|
||||
if (r.developer.length) facts.push(`**Developer:** ${r.developer.join(', ')}`);
|
||||
if (r.publisher.length) facts.push(`**Publisher:** ${r.publisher.join(', ')}`);
|
||||
if (r.platforms.length) facts.push(`**Platforms:** ${r.platforms.join(', ')}`);
|
||||
if (facts.length) b.push(...facts, '');
|
||||
const links: string[] = [];
|
||||
if (r.steamAppid) links.push(`- [Steam](${STEAM_STORE_BASE}/app/${r.steamAppid}/)`);
|
||||
if (r.rawgId && r.url) links.push(`- [RAWG](${r.url})`);
|
||||
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('');
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/
|
|||
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';
|
||||
import { deriveReadStatus, deriveRating, parseNumOrNull } from 'packages/obsidian/src/library/convert';
|
||||
|
||||
const JIKAN_BASE = 'https://api.jikan.moe/v4';
|
||||
const MANGADEX_BASE = 'https://api.mangadex.org';
|
||||
|
|
@ -36,31 +37,6 @@ interface ChapterUpdate {
|
|||
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,
|
||||
|
|
@ -143,6 +119,7 @@ export function renderManga(r: MangaRecord, myNotes: string, customSections: Cus
|
|||
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.rating !== '0' && r.rating !== '' && r.ratingStars) b.push(`**Rating:** ${r.ratingStars} (${r.rating}/5)`);
|
||||
if (r.lastReadChapter) {
|
||||
const denom = r.latestChapter ?? r.chapters ?? '?';
|
||||
b.push(`**Progress:** ch. ${r.lastReadChapter} / ${denom}`);
|
||||
|
|
@ -280,6 +257,7 @@ export const mangaSpec: MediaTypeSpec = {
|
|||
if (prevStatus === 'Publishing' && record.status === 'Finished' && record.readStatus === 'Read') {
|
||||
record.readStatus = 'Unread';
|
||||
flipped = true;
|
||||
deps.notify(`«${record.title}» finished — final chapters out`);
|
||||
}
|
||||
|
||||
const content = renderManga(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
|
||||
|
|
|
|||
1
tests/fixtures/canonical-book.md
vendored
1
tests/fixtures/canonical-book.md
vendored
|
|
@ -22,6 +22,7 @@ tags: [books, book]
|
|||
**Book** · 1949 · 328 p.
|
||||
|
||||
**Read Status:** Read
|
||||
**Rating:** ⭐️⭐️⭐️⭐️⭐️ (5/5)
|
||||
|
||||
**Authors:** George Orwell
|
||||
|
||||
|
|
|
|||
1
tests/fixtures/canonical-comic.md
vendored
1
tests/fixtures/canonical-comic.md
vendored
|
|
@ -24,6 +24,7 @@ tags: [comics, comic]
|
|||
**Comic** · Ongoing · 2024
|
||||
|
||||
**Read Status:** Reading
|
||||
**Rating:** ⭐️⭐️⭐️⭐️ (4/5)
|
||||
**Progress:** issue 8 / 10
|
||||
|
||||
## Synopsis
|
||||
|
|
|
|||
4
tests/fixtures/canonical-game.md
vendored
4
tests/fixtures/canonical-game.md
vendored
|
|
@ -24,6 +24,7 @@ tags: [games, game]
|
|||
**Game** · 2018-03-02 · Metacritic 79
|
||||
|
||||
**Play Status:** Played
|
||||
**Rating:** ⭐️⭐️⭐️⭐️ (4/5)
|
||||
|
||||
## Synopsis
|
||||
From the creators of Human Resource Machine! Program a workforce of dumb humans to do your bidding in this fiendish puzzle game.
|
||||
|
|
@ -32,5 +33,8 @@ From the creators of Human Resource Machine! Program a workforce of dumb humans
|
|||
**Publisher:** Tomorrow Corporation
|
||||
**Platforms:** PC
|
||||
|
||||
## Links
|
||||
- [Steam](https://store.steampowered.com/app/792100/)
|
||||
|
||||
## My Notes
|
||||
|
||||
|
|
|
|||
1
tests/fixtures/canonical-manga.md
vendored
1
tests/fixtures/canonical-manga.md
vendored
|
|
@ -31,6 +31,7 @@ tags: [mangas, manga]
|
|||
**Manga** · Publishing · 8.7
|
||||
|
||||
**Read Status:** Reading
|
||||
**Rating:** ⭐️⭐️⭐️⭐️ (4/5)
|
||||
**Progress:** ch. 210 / 213
|
||||
|
||||
## Synopsis
|
||||
|
|
|
|||
|
|
@ -149,6 +149,15 @@ describe('renderBook golden', () => {
|
|||
expect(out).toContain('## My Notes\n\nreread every few years');
|
||||
});
|
||||
|
||||
test('rating line present when rating set', () => {
|
||||
expect(renderBook(RECORD, '')).toContain('**Rating:** ⭐️⭐️⭐️⭐️⭐️ (5/5)');
|
||||
});
|
||||
|
||||
test('rating 0 -> Rating line absent', () => {
|
||||
const r = { ...RECORD, rating: '0', ratingStars: '' };
|
||||
expect(renderBook(r, '')).not.toContain('**Rating:**');
|
||||
});
|
||||
|
||||
test('no goodreads url -> Links has only Open Library entry', () => {
|
||||
const out = renderBook(RECORD, '');
|
||||
expect(out).toContain('- [Open Library](https://openlibrary.org/works/OL1168083W)');
|
||||
|
|
|
|||
|
|
@ -171,6 +171,15 @@ describe('renderComic golden', () => {
|
|||
expect(out).toContain('## My Notes\n\nlove this reboot');
|
||||
});
|
||||
|
||||
test('rating line present when rating set', () => {
|
||||
expect(renderComic(RECORD, '')).toContain('**Rating:** ⭐️⭐️⭐️⭐️ (4/5)');
|
||||
});
|
||||
|
||||
test('rating 0 -> Rating line absent', () => {
|
||||
const r = { ...RECORD, rating: '0', ratingStars: '' };
|
||||
expect(renderComic(r, '')).not.toContain('**Rating:**');
|
||||
});
|
||||
|
||||
test('no last_read_issue -> Progress line omitted', () => {
|
||||
const r = { ...RECORD, lastReadIssue: '' };
|
||||
expect(renderComic(r, '')).not.toContain('**Progress:**');
|
||||
|
|
|
|||
|
|
@ -196,6 +196,39 @@ describe('renderGame golden', () => {
|
|||
expect(out).toContain('## My Notes\n\nplayed co-op with a friend');
|
||||
});
|
||||
|
||||
test('rating line present when rating set', () => {
|
||||
expect(renderGame(RECORD, '')).toContain('**Rating:** ⭐️⭐️⭐️⭐️ (4/5)');
|
||||
});
|
||||
|
||||
test('rating 0 -> Rating line absent', () => {
|
||||
const r = { ...RECORD, rating: '0', ratingStars: '' };
|
||||
expect(renderGame(r, '')).not.toContain('**Rating:**');
|
||||
});
|
||||
|
||||
test('links present w/ steam appid', () => {
|
||||
expect(renderGame(RECORD, '')).toContain('- [Steam](https://store.steampowered.com/app/792100/)');
|
||||
});
|
||||
|
||||
test('links absent when no ids', () => {
|
||||
const r = { ...RECORD, steamAppid: '', rawgId: '', url: '' };
|
||||
const out = renderGame(r, '');
|
||||
expect(out).not.toContain('## Links');
|
||||
});
|
||||
|
||||
test('rawg link uses url field verbatim when rawg_id set', () => {
|
||||
const r = { ...RECORD, steamAppid: '', rawgId: '4200', url: 'https://rawg.io/games/4200' };
|
||||
const out = renderGame(r, '');
|
||||
expect(out).toContain('- [RAWG](https://rawg.io/games/4200)');
|
||||
expect(out).not.toContain('[Steam]');
|
||||
});
|
||||
|
||||
test('both ids set -> both Steam and RAWG links present', () => {
|
||||
const r = { ...RECORD, rawgId: '4200', url: 'https://rawg.io/games/4200' };
|
||||
const out = renderGame(r, '');
|
||||
expect(out).toContain('- [Steam](https://store.steampowered.com/app/792100/)');
|
||||
expect(out).toContain('- [RAWG](https://rawg.io/games/4200)');
|
||||
});
|
||||
|
||||
test('no poster -> poster line omitted', () => {
|
||||
const r = { ...RECORD, poster: null };
|
||||
expect(renderGame(r, '')).not.toContain('\n\n## Collection\nPart of [[Games]]\n\n## My Notes\n\n';
|
||||
const result = await gameSpec.sync(ctxFor({ steam_appid: '792100' }, body), deps);
|
||||
const content = result!.content;
|
||||
expect((content.match(/## Links/g) ?? []).length).toBe(1);
|
||||
expect(content).toContain('- [Steam](https://store.steampowered.com/app/792100/)');
|
||||
expect(content).not.toContain('Old link');
|
||||
expect(content).toContain('## Collection\nPart of [[Games]]\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gameSpec.sync — rawg enrich', () => {
|
||||
|
|
|
|||
|
|
@ -185,6 +185,15 @@ describe('renderManga golden', () => {
|
|||
expect(out).toContain('## My Notes\n\ncurrently reading');
|
||||
});
|
||||
|
||||
test('rating line present when rating set', () => {
|
||||
expect(renderManga(RECORD, '')).toContain('**Rating:** ⭐️⭐️⭐️⭐️ (4/5)');
|
||||
});
|
||||
|
||||
test('rating 0 -> Rating line absent', () => {
|
||||
const r = { ...RECORD, rating: '0', ratingStars: '' };
|
||||
expect(renderManga(r, '')).not.toContain('**Rating:**');
|
||||
});
|
||||
|
||||
test('no last_read_chapter -> Progress line omitted', () => {
|
||||
const r = { ...RECORD, lastReadChapter: '' };
|
||||
expect(renderManga(r, '')).not.toContain('**Progress:**');
|
||||
|
|
@ -391,20 +400,34 @@ describe('mangaSpec.sync — chapter source priority', () => {
|
|||
});
|
||||
|
||||
describe('mangaSpec.sync — finish-flip', () => {
|
||||
test('prev Publishing + new Finished + read_status Read -> Unread, flipped', async () => {
|
||||
test('prev Publishing + new Finished + read_status Read -> Unread, flipped, notify', 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');
|
||||
expect(deps.notifyCalls).toEqual(['«Chainsaw Man» finished — final chapters out']);
|
||||
});
|
||||
test('prev Publishing + new Finished + read_status Reading -> no flip', async () => {
|
||||
test('prev Publishing + new Finished + read_status Reading -> no flip, no notify', 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');
|
||||
expect(deps.notifyCalls).toEqual([]);
|
||||
});
|
||||
test('chapter-flip and finish-flip both eligible in same sync -> notify fires once (chapter message only, no double-fire)', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({ data: { ...JIKAN_DATA, status: 'Finished' } }),
|
||||
httpText: async () => RSS_214,
|
||||
});
|
||||
const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', status: 'Publishing', 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('read_status: Unread');
|
||||
expect(result!.content).toContain('status: Finished');
|
||||
expect(deps.notifyCalls).toEqual(['«Chainsaw Man» ch. 214 out']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue