feat(library): game spec — steam + rawg
This commit is contained in:
parent
0c8f897e46
commit
cdc653e6e4
5 changed files with 830 additions and 0 deletions
286
packages/obsidian/src/library/game.ts
Normal file
286
packages/obsidian/src/library/game.ts
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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<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;
|
||||||
|
// 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<string, string>): 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(``, '');
|
||||||
|
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<T>(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<any[]> {
|
||||||
|
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<any[]> {
|
||||||
|
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<string, string>): boolean {
|
||||||
|
return !!stripQuotes(fm['steam_appid']) || !!stripQuotes(fm['rawg_id']);
|
||||||
|
},
|
||||||
|
|
||||||
|
isActive(fm: Record<string, string>): 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<Record<string, string> | 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
|
||||||
|
},
|
||||||
|
};
|
||||||
36
tests/fixtures/canonical-game.md
vendored
Normal file
36
tests/fixtures/canonical-game.md
vendored
Normal file
|
|
@ -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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
17
tests/fixtures/rawg-game.json
vendored
Normal file
17
tests/fixtures/rawg-game.json
vendored
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
21
tests/fixtures/steam-appdetails.json
vendored
Normal file
21
tests/fixtures/steam-appdetails.json
vendored
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
470
tests/library-game.test.ts
Normal file
470
tests/library-game.test.ts
Normal file
|
|
@ -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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxFor(fm: Record<string, string>, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue