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:
afiqzudinhadi 2026-08-03 16:29:02 +08:00
parent d2a6940270
commit c14da19c47
13 changed files with 140 additions and 83 deletions

View file

@ -22,6 +22,7 @@ tags: [books, book]
**Book** · 1949 · 328 p.
**Read Status:** Read
**Rating:** ⭐️⭐️⭐️⭐️⭐️ (5/5)
**Authors:** George Orwell

View file

@ -24,6 +24,7 @@ tags: [comics, comic]
**Comic** · Ongoing · 2024
**Read Status:** Reading
**Rating:** ⭐️⭐️⭐️⭐️ (4/5)
**Progress:** issue 8 / 10
## Synopsis

View file

@ -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

View file

@ -31,6 +31,7 @@ tags: [mangas, manga]
**Manga** · Publishing · 8.7
**Read Status:** Reading
**Rating:** ⭐️⭐️⭐️⭐️ (4/5)
**Progress:** ch. 210 / 213
## Synopsis

View file

@ -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)');

View file

@ -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:**');

View file

@ -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('![poster');
@ -433,6 +466,17 @@ describe('gameSpec.sync — steam enrich', () => {
const result = await gameSpec.sync(ctxFor({ steam_appid: '792100' }, '## My Notes\n\nco-op is great'), deps);
expect(result!.content).toContain('## My Notes\n\nco-op is great');
});
test('user-added ## Links section in body is owned/replaced by computed links; other custom sections unaffected', async () => {
const deps = makeDeps({ http: async () => steamFixture });
const body = '## Links\n- [Old link](https://example.com)\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', () => {

View file

@ -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']);
});
});