From cdc653e6e460eac6f200a01d7a975c50d911a728 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 16:02:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(library):=20game=20spec=20=E2=80=94=20stea?= =?UTF-8?q?m=20+=20rawg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/obsidian/src/library/game.ts | 286 ++++++++++++++++ tests/fixtures/canonical-game.md | 36 ++ tests/fixtures/rawg-game.json | 17 + tests/fixtures/steam-appdetails.json | 21 ++ tests/library-game.test.ts | 470 ++++++++++++++++++++++++++ 5 files changed, 830 insertions(+) create mode 100644 packages/obsidian/src/library/game.ts create mode 100644 tests/fixtures/canonical-game.md create mode 100644 tests/fixtures/rawg-game.json create mode 100644 tests/fixtures/steam-appdetails.json create mode 100644 tests/library-game.test.ts diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts new file mode 100644 index 0000000..7f6fd5a --- /dev/null +++ b/packages/obsidian/src/library/game.ts @@ -0,0 +1,286 @@ +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'; + +const STEAM_STORE_BASE = 'https://store.steampowered.com'; +const RAWG_BASE = 'https://api.rawg.io/api'; + +const STEAM_MONTHS: Record = { + jan: '01', + feb: '02', + mar: '03', + apr: '04', + may: '05', + jun: '06', + jul: '07', + aug: '08', + sep: '09', + oct: '10', + nov: '11', + dec: '12', +}; + +/** Steam release_date.date ("2 Mar, 2018") -> ISO "YYYY-MM-DD" via month-name lookup, no Date()/locale. */ +export function parseSteamDate(raw: string): string { + const m = /^(\d{1,2})\s+([A-Za-z]{3,})\s*,\s*(\d{4})$/.exec((raw ?? '').trim()); + if (!m) return ''; + const month = STEAM_MONTHS[m[2].slice(0, 3).toLowerCase()]; + if (!month) return ''; + return `${m[3]}-${month}-${m[1].padStart(2, '0')}`; +} + +export interface GameRecord { + title: string; + playStatus: string; + rating: string; + ratingStars: string; + developer: string[]; + publisher: string[]; + platforms: string[]; + genre: string[]; + releaseDate: string; // ISO or '' + metacritic: number | null; + steamAppid: string; + rawgId: string; + poster: string | null; + url: string; + description: string; +} + +function deriveRating(prev: Record): { 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 { + const canonical = stripQuotes(prev['play_status']); + if (canonical) return canonical; + // skeleton conversion: legacy boolean `played` field + return stripQuotes(prev['played']) === 'true' ? 'Played' : 'Unplayed'; +} + +/** + * Pure mapper: enrich-source payload (Steam appdetails `data` object, or RAWG game-detail + * object) + prev frontmatter -> canonical GameRecord. User-managed fields (play_status, + * rating, rating_stars) are preserved from prev (with legacy-skeleton fallback); id fields + * are preserved once set on either side, never dropped by the other source's enrich pass. + * Steam never reports a usable platform list here, so Steam-sourced records default to ['PC']. + */ +export function buildGame(source: 'steam' | 'rawg', data: any, prev: Record): GameRecord { + const { rating, ratingStars } = deriveRating(prev); + const playStatus = derivePlayStatus(prev); + const prevSteamAppid = stripQuotes(prev['steam_appid']); + const prevRawgId = stripQuotes(prev['rawg_id']); + + if (source === 'steam') { + const steamAppid = data.steam_appid != null ? String(data.steam_appid) : prevSteamAppid; + return { + title: data.name ?? '', + playStatus, + rating, + ratingStars, + developer: Array.isArray(data.developers) ? data.developers.filter(Boolean) : [], + publisher: Array.isArray(data.publishers) ? data.publishers.filter(Boolean) : [], + platforms: ['PC'], + genre: Array.isArray(data.genres) ? data.genres.map((g: any) => g.description).filter(Boolean) : [], + releaseDate: parseSteamDate(data.release_date?.date ?? ''), + metacritic: typeof data.metacritic?.score === 'number' ? data.metacritic.score : null, + steamAppid, + rawgId: prevRawgId, // enrich-by-steam never touches an existing rawg_id + poster: data.header_image ?? null, + url: `${STEAM_STORE_BASE}/app/${steamAppid}`, + description: data.short_description ?? '', + }; + } + + // rawg + const rawgId = data.id != null ? String(data.id) : prevRawgId; + const prevUrl = stripQuotes(prev['url']); + return { + title: data.name ?? '', + playStatus, + rating, + ratingStars, + developer: Array.isArray(data.developers) ? data.developers.map((d: any) => d.name).filter(Boolean) : [], + publisher: Array.isArray(data.publishers) ? data.publishers.map((p: any) => p.name).filter(Boolean) : [], + platforms: Array.isArray(data.platforms) ? data.platforms.map((p: any) => p.platform?.name).filter(Boolean) : [], + genre: Array.isArray(data.genres) ? data.genres.map((g: any) => g.name).filter(Boolean) : [], + releaseDate: typeof data.released === 'string' ? data.released.slice(0, 10) : '', + metacritic: typeof data.metacritic === 'number' ? data.metacritic : null, + steamAppid: prevSteamAppid, // enrich-by-rawg never touches an existing steam_appid + rawgId, + poster: data.background_image ?? null, + // RAWG's documented detail payload carries no site url/slug we can trust; keep whatever + // the note already had (usually the Steam store page from the vault skeleton), else fall + // back to an id-based RAWG link rather than inventing anything. + url: prevUrl || (rawgId ? `https://rawg.io/games/${rawgId}` : ''), + description: data.description_raw ?? '', + }; +} + +export function renderGame(r: GameRecord, myNotes: string, customSections: CustomSection[] = []): string { + const fm = [ + '---', + 'type: game_item', + `title: ${yamlScalar(r.title)}`, + `play_status: ${r.playStatus}`, + `rating: ${r.rating}`, + `rating_stars: ${r.ratingStars}`, + `developer: ${yamlList(r.developer)}`, + `publisher: ${yamlList(r.publisher)}`, + `platforms: ${yamlList(r.platforms)}`, + `genre: ${yamlList(r.genre)}`, + `release_date: ${r.releaseDate ? r.releaseDate : 'null'}`, + `metacritic: ${r.metacritic ?? 'null'}`, + `steam_appid: ${r.steamAppid}`, + `rawg_id: ${r.rawgId}`, + `poster: ${quotedOrNull(r.poster)}`, + `url: ${quotedOrNull(r.url)}`, + 'tags: [games, game]', + '---', + ]; + + const b: string[] = ['', `# ${r.title}`, '']; + if (r.poster) b.push(`![poster|200](${r.poster})`, ''); + const meta = ['**Game**', ...[r.releaseDate, r.metacritic !== null ? `Metacritic ${r.metacritic}` : ''].filter(x => x)]; + b.push(meta.join(' · '), ''); + b.push(`**Play Status:** ${r.playStatus}`, ''); + 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, ''); + 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'); +} + +function pickUniqueExact(items: T[], query: string, titleOf: (t: T) => string): T | null { + if (!items || items.length === 0) return null; + const q = query.toLowerCase(); + const exacts = items.filter(it => titleOf(it).toLowerCase() === q); + if (exacts.length === 1) return exacts[0]; + if (exacts.length === 0 && items.length === 1) return items[0]; + return null; +} + +async function fetchSteamSearch(query: string, deps: SpecDeps): Promise { + const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' }); + const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {}); + return Array.isArray(res?.items) ? res.items : []; +} + +async function fetchRawgSearch(query: string, key: string, deps: SpecDeps): Promise { + const qs = new URLSearchParams({ key, search: query, page_size: '10' }); + const res = await deps.http(`${RAWG_BASE}/games?${qs.toString()}`, {}); + return Array.isArray(res?.results) ? res.results : []; +} + +export const gameSpec: MediaTypeSpec = { + typeName: 'game', + itemType: 'game_item', + folderSettingKey: 'libraryGameFolder', + enabledSettingKey: 'libraryGameEnabled', + throttleMs: 250, + + hasId(fm: Record): boolean { + return !!stripQuotes(fm['steam_appid']) || !!stripQuotes(fm['rawg_id']); + }, + + isActive(fm: Record): boolean { + // static once either id is set; no chapter/issue-style automation exists for games -- + // only missing both ids ever makes a game note active again (full sync bypasses this) + return !stripQuotes(fm['steam_appid']) && !stripQuotes(fm['rawg_id']); + }, + + async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { + // fastest, no-network path: 28/31 real game notes already carry a Steam store url + const url = stripQuotes(ctx.frontmatter['url']); + const urlMatch = /store\.steampowered\.com\/app\/(\d+)/.exec(url); + if (urlMatch) return { steam_appid: urlMatch[1] }; + + const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); + if (!query) return null; + + try { + const items = await fetchSteamSearch(query, deps); + const pick = pickUniqueExact(items, query, (it: any) => String(it.name ?? '')); + if (pick) return { steam_appid: String(pick.id) }; + } catch (e) { + deps.log(`game steam storesearch failed for "${query}": ${String(e)}`); + } + + const key = deps.getKey('rawg'); + if (!key) { + deps.log(`game resolve: no RAWG key configured, skipping RAWG fallback for "${query}"`); + return null; + } + try { + const results = await fetchRawgSearch(query, key, deps); + const pick = pickUniqueExact(results, query, (it: any) => String(it.name ?? '')); + if (pick) return { rawg_id: String(pick.id) }; + } catch (e) { + deps.log(`game rawg search failed for "${query}": ${String(e)}`); + } + return null; + }, + + async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> { + const fm = ctx.frontmatter; + const steamAppid = stripQuotes(fm['steam_appid']); + const rawgId = stripQuotes(fm['rawg_id']); + if (!steamAppid && !rawgId) return null; // needs resolve() first + + if (steamAppid) { + let json: any; + try { + json = await deps.http(`${STEAM_STORE_BASE}/api/appdetails?appids=${steamAppid}&cc=us&l=en`, {}); + } catch (e) { + deps.log(`game steam appdetails fetch failed (appid ${steamAppid}): ${String(e)}`); + return null; + } + const entry = json?.[steamAppid]; + if (entry?.success && entry.data) { + const record = buildGame('steam', entry.data, fm); + const content = renderGame(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); + return { content, flipped: false }; + } + // Steam de-listed/invalid appid: identity-guard -- never silently swap to a + // different game; only fall through to RAWG when we have an id + key, else stop. + deps.log(`game steam appdetails returned success:false for appid ${steamAppid}`); + if (!rawgId) return null; + } + + if (rawgId) { + const key = deps.getKey('rawg'); + if (!key) { + deps.log(`game rawg enrich skipped (no key configured) for rawg_id ${rawgId}`); + return null; + } + let data: any; + try { + data = await deps.http(`${RAWG_BASE}/games/${rawgId}?key=${key}`, {}); + } catch (e) { + deps.log(`game rawg detail fetch failed (rawg_id ${rawgId}): ${String(e)}`); + return null; + } + if (!data || !data.name || (data.id != null && String(data.id) !== rawgId)) { + deps.log(`game rawg detail returned no usable data for rawg_id ${rawgId}`); + return null; + } + const record = buildGame('rawg', data, fm); + const content = renderGame(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); + return { content, flipped: false }; + } + + return null; // steam_appid set, success:false, no rawg fallback available + }, +}; diff --git a/tests/fixtures/canonical-game.md b/tests/fixtures/canonical-game.md new file mode 100644 index 0000000..14470f3 --- /dev/null +++ b/tests/fixtures/canonical-game.md @@ -0,0 +1,36 @@ +--- +type: game_item +title: 7 Billion Humans +play_status: Played +rating: 4 +rating_stars: ⭐️⭐️⭐️⭐️ +developer: [Tomorrow Corporation] +publisher: [Tomorrow Corporation] +platforms: [PC] +genre: [Casual, Indie, Strategy] +release_date: 2018-03-02 +metacritic: 79 +steam_appid: 792100 +rawg_id: +poster: "https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg" +url: "https://store.steampowered.com/app/792100" +tags: [games, game] +--- + +# 7 Billion Humans + +![poster|200](https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg) + +**Game** · 2018-03-02 · Metacritic 79 + +**Play Status:** Played + +## Synopsis +From the creators of Human Resource Machine! Program a workforce of dumb humans to do your bidding in this fiendish puzzle game. + +**Developer:** Tomorrow Corporation +**Publisher:** Tomorrow Corporation +**Platforms:** PC + +## My Notes + diff --git a/tests/fixtures/rawg-game.json b/tests/fixtures/rawg-game.json new file mode 100644 index 0000000..9704abb --- /dev/null +++ b/tests/fixtures/rawg-game.json @@ -0,0 +1,17 @@ +{ + "id": 4200, + "name": "Hollow Knight", + "developers": [{ "name": "Team Cherry" }], + "publishers": [{ "name": "Team Cherry" }], + "platforms": [ + { "platform": { "name": "PC" } }, + { "platform": { "name": "macOS" } }, + { "platform": { "name": "Linux" } }, + { "platform": { "name": "Nintendo Switch" } } + ], + "genres": [{ "name": "Action" }, { "name": "Adventure" }, { "name": "Indie" }], + "released": "2017-02-24", + "metacritic": 90, + "background_image": "https://media.rawg.io/media/games/4cf/4cfc6b7f1850590a4634b08bfab308ab.jpg", + "description_raw": "Hollow Knight is a classically styled 2D action adventure crafted with painstaking detail. Explore twisting caverns, battle tainted creatures, and befriend bizarre bugs." +} diff --git a/tests/fixtures/steam-appdetails.json b/tests/fixtures/steam-appdetails.json new file mode 100644 index 0000000..f074a43 --- /dev/null +++ b/tests/fixtures/steam-appdetails.json @@ -0,0 +1,21 @@ +{ + "792100": { + "success": true, + "data": { + "type": "game", + "name": "7 Billion Humans", + "steam_appid": 792100, + "developers": ["Tomorrow Corporation"], + "publishers": ["Tomorrow Corporation"], + "genres": [ + { "id": "4", "description": "Casual" }, + { "id": "23", "description": "Indie" }, + { "id": "2", "description": "Strategy" } + ], + "release_date": { "coming_soon": false, "date": "2 Mar, 2018" }, + "metacritic": { "score": 79, "url": "https://www.metacritic.com/game/pc/7-billion-humans" }, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg", + "short_description": "From the creators of Human Resource Machine! Program a workforce of dumb humans to do your bidding in this fiendish puzzle game." + } + } +} diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts new file mode 100644 index 0000000..a1fd0d2 --- /dev/null +++ b/tests/library-game.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildGame, renderGame, parseSteamDate, gameSpec, type GameRecord } from 'packages/obsidian/src/library/game'; +import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; +import steamFixture from 'tests/fixtures/steam-appdetails.json'; +import rawgFixture from 'tests/fixtures/rawg-game.json'; + +const STEAM_DATA = steamFixture['792100'].data; +const EMPTY_PREV: Record = {}; + +function makeDeps(overrides: Partial = {}): 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, + }; +} + +function ctxFor(fm: Record, body = '## My Notes\n\n'): LibraryNoteCtx { + return { frontmatter: fm, body, filename: '7 Billion Humans.md' }; +} + +describe('parseSteamDate', () => { + test('"2 Mar, 2018" -> 2018-03-02', () => { + expect(parseSteamDate('2 Mar, 2018')).toBe('2018-03-02'); + }); + test('"31 Dec, 2020" -> 2020-12-31', () => { + expect(parseSteamDate('31 Dec, 2020')).toBe('2020-12-31'); + }); + test('two-digit day, no leading zero needed', () => { + expect(parseSteamDate('15 Jan, 2019')).toBe('2019-01-15'); + }); + test('garbage -> empty string', () => { + expect(parseSteamDate('Coming soon')).toBe(''); + }); + test('empty -> empty string', () => { + expect(parseSteamDate('')).toBe(''); + }); + test('unrecognized month -> empty string', () => { + expect(parseSteamDate('2 Zzz, 2018')).toBe(''); + }); +}); + +describe('buildGame (steam source) field mapping', () => { + const r = buildGame('steam', STEAM_DATA, EMPTY_PREV); + test('core fields', () => { + expect(r.title).toBe('7 Billion Humans'); + expect(r.developer).toEqual(['Tomorrow Corporation']); + expect(r.publisher).toEqual(['Tomorrow Corporation']); + expect(r.genre).toEqual(['Casual', 'Indie', 'Strategy']); + expect(r.releaseDate).toBe('2018-03-02'); + expect(r.metacritic).toBe(79); + expect(r.steamAppid).toBe('792100'); + expect(r.poster).toBe('https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg'); + expect(r.url).toBe('https://store.steampowered.com/app/792100'); + }); + test('platforms default to [PC] regardless of Steam payload', () => { + expect(r.platforms).toEqual(['PC']); + }); + test('rawg_id empty when prev had none', () => { + expect(r.rawgId).toBe(''); + }); + test('rawg_id preserved from prev when steam-sourced (never dropped by steam enrich)', () => { + const r2 = buildGame('steam', STEAM_DATA, { rawg_id: '4200' }); + expect(r2.rawgId).toBe('4200'); + expect(r2.steamAppid).toBe('792100'); + }); + test('no metacritic block -> null', () => { + const r2 = buildGame('steam', { ...STEAM_DATA, metacritic: undefined }, EMPTY_PREV); + expect(r2.metacritic).toBeNull(); + }); + test('no header_image -> poster null', () => { + const r2 = buildGame('steam', { ...STEAM_DATA, header_image: undefined }, EMPTY_PREV); + expect(r2.poster).toBeNull(); + }); +}); + +describe('buildGame (rawg source) field mapping', () => { + const r = buildGame('rawg', rawgFixture, EMPTY_PREV); + test('core fields', () => { + expect(r.title).toBe('Hollow Knight'); + expect(r.developer).toEqual(['Team Cherry']); + expect(r.publisher).toEqual(['Team Cherry']); + expect(r.genre).toEqual(['Action', 'Adventure', 'Indie']); + expect(r.releaseDate).toBe('2017-02-24'); + expect(r.metacritic).toBe(90); + expect(r.rawgId).toBe('4200'); + expect(r.poster).toBe('https://media.rawg.io/media/games/4cf/4cfc6b7f1850590a4634b08bfab308ab.jpg'); + }); + test('platforms mapped from platform.name', () => { + expect(r.platforms).toEqual(['PC', 'macOS', 'Linux', 'Nintendo Switch']); + }); + test('steam_appid preserved from prev when rawg-sourced (never dropped by rawg enrich)', () => { + const r2 = buildGame('rawg', rawgFixture, { steam_appid: '792100' }); + expect(r2.steamAppid).toBe('792100'); + expect(r2.rawgId).toBe('4200'); + }); + test('steam_appid empty when prev had none', () => { + expect(r.steamAppid).toBe(''); + }); + test('url falls back to prev url when set', () => { + const r2 = buildGame('rawg', rawgFixture, { url: 'https://store.steampowered.com/app/999' }); + expect(r2.url).toBe('https://store.steampowered.com/app/999'); + }); + test('url falls back to rawg id-based link when no prev url', () => { + expect(r.url).toBe('https://rawg.io/games/4200'); + }); + test('no platforms array -> empty', () => { + const r2 = buildGame('rawg', { ...rawgFixture, platforms: undefined }, EMPTY_PREV); + expect(r2.platforms).toEqual([]); + }); +}); + +describe('buildGame user-field preservation', () => { + test('play_status defaults to Unplayed', () => { + expect(buildGame('steam', STEAM_DATA, EMPTY_PREV).playStatus).toBe('Unplayed'); + }); + test('play_status carried from prev', () => { + expect(buildGame('steam', STEAM_DATA, { play_status: 'Played' }).playStatus).toBe('Played'); + }); + test('rating/rating_stars carried from prev', () => { + const prev = { rating: '4', rating_stars: '⭐️⭐️⭐️⭐️' }; + const r = buildGame('steam', STEAM_DATA, prev); + expect(r.rating).toBe('4'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + }); +}); + +describe('buildGame skeleton conversion', () => { + test('played: true -> play_status Played', () => { + expect(buildGame('steam', STEAM_DATA, { played: 'true', personalRating: '' }).playStatus).toBe('Played'); + }); + test('played: false -> play_status Unplayed', () => { + expect(buildGame('steam', STEAM_DATA, { played: 'false', personalRating: '' }).playStatus).toBe('Unplayed'); + }); + test('personalRating 3 -> rating 3 + 3 stars', () => { + const r = buildGame('steam', STEAM_DATA, { played: 'false', personalRating: '3' }); + expect(r.rating).toBe('3'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️'); + }); + test('empty personalRating -> rating 0, no stars', () => { + const r = buildGame('steam', STEAM_DATA, { played: 'false', personalRating: '' }); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + }); +}); + +describe('renderGame golden', () => { + const RECORD: GameRecord = { + title: '7 Billion Humans', + playStatus: 'Played', + rating: '4', + ratingStars: '⭐️⭐️⭐️⭐️', + developer: ['Tomorrow Corporation'], + publisher: ['Tomorrow Corporation'], + platforms: ['PC'], + genre: ['Casual', 'Indie', 'Strategy'], + releaseDate: '2018-03-02', + metacritic: 79, + steamAppid: '792100', + rawgId: '', + poster: 'https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg', + url: 'https://store.steampowered.com/app/792100', + description: 'From the creators of Human Resource Machine! Program a workforce of dumb humans to do your bidding in this fiendish puzzle game.', + }; + + test('matches canonical game fixture byte-for-byte', () => { + const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-game.md'), 'utf-8'); + expect(renderGame(RECORD, '', [])).toBe(expected); + }); + + test('custom section (Collection) placed after facts, before My Notes', () => { + const out = renderGame(RECORD, '', [{ heading: 'Collection', content: 'Part of [[Games]]' }]); + const platformsIdx = out.indexOf('**Platforms:**'); + const collectionIdx = out.indexOf('## Collection'); + const myNotesIdx = out.indexOf('## My Notes'); + expect(collectionIdx).toBeGreaterThan(platformsIdx); + expect(myNotesIdx).toBeGreaterThan(collectionIdx); + expect(out).toContain('## Collection\nPart of [[Games]]\n'); + }); + + test('My Notes content preserved', () => { + const out = renderGame(RECORD, 'played co-op with a friend'); + expect(out).toContain('## My Notes\n\nplayed co-op with a friend'); + }); + + test('no poster -> poster line omitted', () => { + const r = { ...RECORD, poster: null }; + expect(renderGame(r, '')).not.toContain('![poster'); + }); + + test('no description -> Synopsis section omitted', () => { + const r = { ...RECORD, description: '' }; + expect(renderGame(r, '')).not.toContain('## Synopsis'); + }); + + test('no developer/publisher/platforms -> fact lines omitted entirely', () => { + const r = { ...RECORD, developer: [], publisher: [], platforms: [] }; + const out = renderGame(r, ''); + expect(out).not.toContain('**Developer:**'); + expect(out).not.toContain('**Publisher:**'); + expect(out).not.toContain('**Platforms:**'); + }); + + test('no release_date/metacritic -> meta line is just **Game**', () => { + const r = { ...RECORD, releaseDate: '', metacritic: null }; + const out = renderGame(r, ''); + expect(out).toContain('\n**Game**\n'); + }); +}); + +describe('gameSpec.hasId', () => { + test('steam_appid set -> true', () => expect(gameSpec.hasId({ steam_appid: '792100' })).toBe(true)); + test('rawg_id set -> true', () => expect(gameSpec.hasId({ rawg_id: '4200' })).toBe(true)); + test('both set -> true', () => expect(gameSpec.hasId({ steam_appid: '792100', rawg_id: '4200' })).toBe(true)); + test('both empty -> false', () => expect(gameSpec.hasId({ steam_appid: '', rawg_id: '' })).toBe(false)); + test('both missing -> false', () => expect(gameSpec.hasId({})).toBe(false)); +}); + +describe('gameSpec.isActive', () => { + test('both ids empty -> active (needs first pass)', () => { + expect(gameSpec.isActive({})).toBe(true); + }); + test('steam_appid set -> static', () => { + expect(gameSpec.isActive({ steam_appid: '792100' })).toBe(false); + }); + test('rawg_id set -> static', () => { + expect(gameSpec.isActive({ rawg_id: '4200' })).toBe(false); + }); + test('both set -> static, regardless of play_status', () => { + expect(gameSpec.isActive({ steam_appid: '792100', rawg_id: '4200', play_status: 'Played' })).toBe(false); + }); +}); + +describe('gameSpec.resolve', () => { + test('steam store url in frontmatter -> appid parsed, zero network calls', async () => { + let httpCalls = 0; + const deps = makeDeps({ + http: async () => { + httpCalls++; + return {}; + }, + }); + const result = await gameSpec.resolve(ctxFor({ url: 'https://store.steampowered.com/app/792100/7_Billion_Humans/', title: '7 Billion Humans' }), deps); + expect(result).toEqual({ steam_appid: '792100' }); + expect(httpCalls).toBe(0); + }); + + test('no url match, steam storesearch unique exact -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ items: [{ id: 792100, name: '7 Billion Humans' }] }), + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toEqual({ steam_appid: '792100' }); + }); + + test('no exact match, sole steam result -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ items: [{ id: 999, name: 'Some Other Game' }] }), + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toEqual({ steam_appid: '999' }); + }); + + test('steam storesearch ambiguous, no key -> null, logged', async () => { + const deps = makeDeps({ + http: async () => ({ + items: [ + { id: 1, name: 'Foo' }, + { id: 2, name: 'Bar' }, + ], + }), + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true); + }); + + test('steam storesearch throws -> falls through to RAWG', async () => { + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('steampowered')) throw new Error('steam down'); + return { results: [{ id: 4200, name: '7 Billion Humans' }] }; + }, + getKey: () => 'rawgkey', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toEqual({ rawg_id: '4200' }); + expect(deps.logCalls.some(m => m.includes('storesearch'))).toBe(true); + }); + + test('no steam results, RAWG key present, unique exact -> rawg_id accepted', async () => { + const deps = makeDeps({ + http: async (url: string) => (url.includes('steampowered') ? { items: [] } : { results: [{ id: 4200, name: '7 Billion Humans' }] }), + getKey: () => 'rawgkey', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toEqual({ rawg_id: '4200' }); + }); + + test('no steam results, RAWG key missing -> null, logged, no RAWG call attempted', async () => { + let rawgCalled = false; + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('rawg')) rawgCalled = true; + return { items: [] }; + }, + getKey: () => '', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + expect(rawgCalled).toBe(false); + expect(deps.logCalls.some(m => m.toLowerCase().includes('key'))).toBe(true); + }); + + test('RAWG throws -> log, return null (no throw)', async () => { + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('steampowered')) return { items: [] }; + throw new Error('rawg down'); + }, + getKey: () => 'rawgkey', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + }); + + test('RAWG ambiguous -> null', async () => { + const deps = makeDeps({ + http: async (url: string) => + url.includes('steampowered') + ? { items: [] } + : { + results: [ + { id: 1, name: 'Foo' }, + { id: 2, name: 'Bar' }, + ], + }, + getKey: () => 'rawgkey', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + }); + + test('no title, no filename fallback -> null (empty query guard)', async () => { + const deps = makeDeps(); + const ctx: LibraryNoteCtx = { frontmatter: {}, body: '', filename: '.md' }; + const result = await gameSpec.resolve(ctx, deps); + expect(result).toBeNull(); + }); +}); + +describe('gameSpec.sync — no ids', () => { + test('no steam_appid, no rawg_id -> null (needs resolve first)', async () => { + const deps = makeDeps(); + const result = await gameSpec.sync(ctxFor({}), deps); + expect(result).toBeNull(); + }); +}); + +describe('gameSpec.sync — steam enrich', () => { + test('success -> canonical fm rendered, platforms [PC], flipped always false', async () => { + const deps = makeDeps({ http: async () => steamFixture }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100', play_status: 'Played' }), deps); + expect(result).not.toBeNull(); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('type: game_item'); + expect(result!.content).toContain('steam_appid: 792100'); + expect(result!.content).toContain('platforms: [PC]'); + expect(result!.content).toContain('release_date: 2018-03-02'); + expect(result!.content).toContain('metacritic: 79'); + }); + + test('steam appdetails fetch throws -> log, return null (no throw)', async () => { + const deps = makeDeps({ + http: async () => { + throw new Error('network down'); + }, + }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); + + test('success:false, no rawg_id -> identity-guard: log, return null (no write)', async () => { + const deps = makeDeps({ http: async () => ({ '792100': { success: false } }) }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('792100'))).toBe(true); + }); + + test('success:false, rawg_id set but no key -> log, return null', async () => { + const deps = makeDeps({ http: async () => ({ '792100': { success: false } }) }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100', rawg_id: '4200' }), deps); + expect(result).toBeNull(); + }); + + test('success:false, rawg_id set + key -> falls to RAWG detail, steam_appid preserved in output', async () => { + const deps = makeDeps({ + http: async (url: string) => (url.includes('steampowered') ? { '792100': { success: false } } : rawgFixture), + getKey: () => 'rawgkey', + }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100', rawg_id: '4200' }), deps); + expect(result).not.toBeNull(); + expect(result!.content).toContain('steam_appid: 792100'); + expect(result!.content).toContain('rawg_id: 4200'); + expect(result!.content).toContain('platforms: [PC, macOS, Linux, Nintendo Switch]'); + }); + + test('play_status/rating preserved through steam enrich', async () => { + const deps = makeDeps({ http: async () => steamFixture }); + const fm = { steam_appid: '792100', play_status: 'Played', rating: '4', rating_stars: '⭐️⭐️⭐️⭐️' }; + const result = await gameSpec.sync(ctxFor(fm), deps); + expect(result!.content).toContain('play_status: Played'); + expect(result!.content).toContain('rating: 4'); + expect(result!.content).toContain('rating_stars: ⭐️⭐️⭐️⭐️'); + }); + + test('My Notes content preserved through sync', async () => { + const deps = makeDeps({ http: async () => steamFixture }); + 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'); + }); +}); + +describe('gameSpec.sync — rawg enrich', () => { + test('rawg_id only, key present -> success, canonical fm rendered', async () => { + const deps = makeDeps({ http: async () => rawgFixture, getKey: () => 'rawgkey' }); + const result = await gameSpec.sync(ctxFor({ rawg_id: '4200' }), deps); + expect(result).not.toBeNull(); + expect(result!.content).toContain('rawg_id: 4200'); + expect(result!.content).toContain('type: game_item'); + }); + + test('rawg_id only, no key -> log, return null', async () => { + const deps = makeDeps({ getKey: () => '' }); + const result = await gameSpec.sync(ctxFor({ rawg_id: '4200' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); + + test('rawg_id only, fetch throws -> log, return null', async () => { + const deps = makeDeps({ + http: async () => { + throw new Error('rawg down'); + }, + getKey: () => 'rawgkey', + }); + const result = await gameSpec.sync(ctxFor({ rawg_id: '4200' }), deps); + expect(result).toBeNull(); + }); + + test('rawg detail returns malformed/empty data -> null, no write', async () => { + const deps = makeDeps({ http: async () => ({}), getKey: () => 'rawgkey' }); + const result = await gameSpec.sync(ctxFor({ rawg_id: '4200' }), deps); + expect(result).toBeNull(); + }); +});