feat(library): two-line candidate picker entries with per-type detail
This commit is contained in:
parent
16b8b242b8
commit
1e4567e844
12 changed files with 219 additions and 18 deletions
|
|
@ -1,12 +1,14 @@
|
||||||
import type { App } from 'obsidian';
|
import type { App, FuzzyMatch } from 'obsidian';
|
||||||
import { FuzzySuggestModal } from 'obsidian';
|
import { FuzzySuggestModal } from 'obsidian';
|
||||||
import type { ResolveCandidate } from 'packages/obsidian/src/library/types';
|
import type { ResolveCandidate } from 'packages/obsidian/src/library/types';
|
||||||
|
|
||||||
const SKIP_LABEL = 'Skip';
|
const SKIP_LABEL = 'Skip';
|
||||||
const NEVER_LABEL = 'Never resolve (mark no_resolve)';
|
const NEVER_LABEL = 'Never resolve (mark no_resolve)';
|
||||||
|
const DETAIL_CLASS = 'media-db-sync-candidate-detail';
|
||||||
|
|
||||||
interface PickerItem {
|
interface PickerItem {
|
||||||
label: string;
|
label: string;
|
||||||
|
detail?: string; // optional muted second line (format/status/publisher/id, etc); absent for Skip/Never rows
|
||||||
index: number | 'never' | null; // null = Skip, 'never' = mark no_resolve
|
index: number | 'never' | null; // null = Skip, 'never' = mark no_resolve
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,13 +33,23 @@ export class CandidatePickerModal extends FuzzySuggestModal<PickerItem> {
|
||||||
}
|
}
|
||||||
|
|
||||||
getItems(): PickerItem[] {
|
getItems(): PickerItem[] {
|
||||||
return [...this.candidates.map((c, index) => ({ label: c.label, index })), { label: SKIP_LABEL, index: null }, { label: NEVER_LABEL, index: 'never' as const }];
|
return [
|
||||||
|
...this.candidates.map((c, index) => ({ label: c.label, detail: c.detail, index })),
|
||||||
|
{ label: SKIP_LABEL, index: null },
|
||||||
|
{ label: NEVER_LABEL, index: 'never' as const },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
getItemText(item: PickerItem): string {
|
getItemText(item: PickerItem): string {
|
||||||
return item.label;
|
return item.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Two-line suggestion row: label, plus a muted detail line when the candidate has one (Skip/Never never do). */
|
||||||
|
renderSuggestion(match: FuzzyMatch<PickerItem>, el: HTMLElement): void {
|
||||||
|
el.createDiv({ text: match.item.label });
|
||||||
|
if (match.item.detail) el.createDiv({ text: match.item.detail, cls: DETAIL_CLASS });
|
||||||
|
}
|
||||||
|
|
||||||
onChooseItem(item: PickerItem, _evt: MouseEvent | KeyboardEvent): void {
|
onChooseItem(item: PickerItem, _evt: MouseEvent | KeyboardEvent): void {
|
||||||
this.settle(item.index);
|
this.settle(item.index);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,16 @@ function candidateLabel(d: any): string {
|
||||||
return meta ? `${d.title ?? ''} (${meta})` : (d.title ?? '');
|
return meta ? `${d.title ?? ''} (${meta})` : (d.title ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Picker second line: first author · first-publish year · page count · olid -- whichever pieces the doc actually has. */
|
||||||
|
function candidateDetail(d: any): string | undefined {
|
||||||
|
const author = Array.isArray(d.author_name) && d.author_name.length ? String(d.author_name[0]) : '';
|
||||||
|
const year = d.first_publish_year != null ? String(d.first_publish_year) : '';
|
||||||
|
const pages = typeof d.number_of_pages_median === 'number' ? `${d.number_of_pages_median}p` : '';
|
||||||
|
const olid = String(d.key ?? '').replace(/^\/works\//, '');
|
||||||
|
const parts = [author, year, pages, olid].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(' · ') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const bookSpec: MediaTypeSpec = {
|
export const bookSpec: MediaTypeSpec = {
|
||||||
typeName: 'book',
|
typeName: 'book',
|
||||||
itemType: 'book_item',
|
itemType: 'book_item',
|
||||||
|
|
@ -177,7 +187,7 @@ export const bookSpec: MediaTypeSpec = {
|
||||||
deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`);
|
deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`);
|
||||||
const candidates = docs
|
const candidates = docs
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map(d => ({ label: candidateLabel(d), patches: { olid: String(d.key ?? '').replace(/^\/works\//, '') } }))
|
.map(d => ({ label: candidateLabel(d), detail: candidateDetail(d), patches: { olid: String(d.key ?? '').replace(/^\/works\//, '') } }))
|
||||||
.filter(c => c.patches.olid);
|
.filter(c => c.patches.olid);
|
||||||
return candidates.length ? { candidates } : null;
|
return candidates.length ? { candidates } : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,16 @@ function candidateLabel(r: any): string {
|
||||||
return meta ? `${r.name ?? ''} (${meta})` : (r.name ?? '');
|
return meta ? `${r.name ?? ''} (${meta})` : (r.name ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Picker second line: publisher · issue count · start year · cv:{id} -- whichever pieces the volume search result actually has. */
|
||||||
|
function candidateDetail(r: any): string | undefined {
|
||||||
|
const publisher = r.publisher?.name ?? '';
|
||||||
|
const issues = typeof r.count_of_issues === 'number' ? `${r.count_of_issues} issues` : '';
|
||||||
|
const startYear = r.start_year != null ? `start ${r.start_year}` : '';
|
||||||
|
const id = r.id != null ? `cv:${r.id}` : '';
|
||||||
|
const parts = [publisher, issues, startYear, id].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(' · ') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const comicSpec: MediaTypeSpec = {
|
export const comicSpec: MediaTypeSpec = {
|
||||||
typeName: 'comic',
|
typeName: 'comic',
|
||||||
itemType: 'comic_item',
|
itemType: 'comic_item',
|
||||||
|
|
@ -219,7 +229,7 @@ export const comicSpec: MediaTypeSpec = {
|
||||||
const candidates = results
|
const candidates = results
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.filter(r => r.id != null)
|
.filter(r => r.id != null)
|
||||||
.map(r => ({ label: candidateLabel(r), patches: { comicvine_id: String(r.id) } }));
|
.map(r => ({ label: candidateLabel(r), detail: candidateDetail(r), patches: { comicvine_id: String(r.id) } }));
|
||||||
return candidates.length ? { candidates } : null;
|
return candidates.length ? { candidates } : null;
|
||||||
}
|
}
|
||||||
return pick.id != null ? { patches: { comicvine_id: String(pick.id) } } : null;
|
return pick.id != null ? { patches: { comicvine_id: String(pick.id) } } : null;
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,12 @@ function steamCandidateLabel(it: any): string {
|
||||||
return String(it?.name ?? '');
|
return String(it?.name ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Picker second line for a Steam storesearch item -- id is the only reliably-present differentiator
|
||||||
|
* beyond name (the storesearch payload carries no release date, same limitation steamCandidateLabel notes). */
|
||||||
|
function steamCandidateDetail(it: any): string {
|
||||||
|
return `Steam appid ${it?.id ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSteamSearch(query: string, deps: SpecDeps): Promise<any[]> {
|
async function fetchSteamSearch(query: string, deps: SpecDeps): Promise<any[]> {
|
||||||
const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' });
|
const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' });
|
||||||
const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {});
|
const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {});
|
||||||
|
|
@ -234,7 +240,7 @@ export const gameSpec: MediaTypeSpec = {
|
||||||
steamCandidates = items
|
steamCandidates = items
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.filter((it: any) => it.id != null)
|
.filter((it: any) => it.id != null)
|
||||||
.map((it: any) => ({ label: steamCandidateLabel(it), patches: { steam_appid: String(it.id) } }));
|
.map((it: any) => ({ label: steamCandidateLabel(it), detail: steamCandidateDetail(it), patches: { steam_appid: String(it.id) } }));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
deps.log(`game steam storesearch failed for "${query}": ${String(e)}`);
|
deps.log(`game steam storesearch failed for "${query}": ${String(e)}`);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const ANILIST_MEDIA_FIELDS = `
|
||||||
id
|
id
|
||||||
idMal
|
idMal
|
||||||
title { romaji english }
|
title { romaji english }
|
||||||
|
format
|
||||||
status
|
status
|
||||||
chapters
|
chapters
|
||||||
volumes
|
volumes
|
||||||
|
|
@ -169,6 +170,15 @@ function stripAniListHtml(html: string | null | undefined): string {
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Story-role staff (author-equivalent) off an AniList `Media` payload -- shared by record-building
|
||||||
|
* (`buildMangaFromAniList`) and the ambiguous-resolve picker detail (`candidateAniListDetail`). */
|
||||||
|
function aniListStoryAuthors(media: any): string[] {
|
||||||
|
return ((media?.staff?.edges ?? []) as any[])
|
||||||
|
.filter(e => typeof e?.role === 'string' && e.role.includes('Story'))
|
||||||
|
.map(e => e?.node?.name?.full)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure mapper: AniList `Media` payload (the unwrapped `data.Media` object) + prev frontmatter ->
|
* Pure mapper: AniList `Media` payload (the unwrapped `data.Media` object) + prev frontmatter ->
|
||||||
* canonical MangaRecord. Mirrors `buildManga`'s user-field preservation contract exactly; only
|
* canonical MangaRecord. Mirrors `buildManga`'s user-field preservation contract exactly; only
|
||||||
|
|
@ -192,10 +202,7 @@ export function buildMangaFromAniList(media: any, prev: Record<string, string>):
|
||||||
const malId = media.idMal != null ? String(media.idMal) : stripQuotes(prev['mal_id']);
|
const malId = media.idMal != null ? String(media.idMal) : stripQuotes(prev['mal_id']);
|
||||||
const anilistId = media.id != null ? String(media.id) : stripQuotes(prev['anilist_id']);
|
const anilistId = media.id != null ? String(media.id) : stripQuotes(prev['anilist_id']);
|
||||||
|
|
||||||
const authors: string[] = ((media.staff?.edges ?? []) as any[])
|
const authors = aniListStoryAuthors(media);
|
||||||
.filter(e => typeof e?.role === 'string' && e.role.includes('Story'))
|
|
||||||
.map(e => e?.node?.name?.full)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: romaji,
|
title: romaji,
|
||||||
|
|
@ -263,6 +270,28 @@ function candidateAniListLabel(m: any): string {
|
||||||
return year ? `${title} (${year})` : title;
|
return year ? `${title} (${year})` : title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AniList `format` enum ("ONE_SHOT") -> Title Case ("One Shot"), or '' when absent. */
|
||||||
|
function aniListFormatLabel(format: unknown): string {
|
||||||
|
if (typeof format !== 'string' || !format) return '';
|
||||||
|
return format
|
||||||
|
.toLowerCase()
|
||||||
|
.split('_')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(w => w[0].toUpperCase() + w.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picker second line (AniList side): format · status · first-two story authors · anilist:{id} --
|
||||||
|
* whichever pieces the candidate actually has (search results are sparser than a full Media fetch). */
|
||||||
|
function candidateAniListDetail(m: any): string | undefined {
|
||||||
|
const format = aniListFormatLabel(m?.format);
|
||||||
|
const status = ANILIST_STATUS_MAP[m?.status as string] ?? '';
|
||||||
|
const authors = aniListStoryAuthors(m).slice(0, 2).join(', ');
|
||||||
|
const id = m?.id != null ? `anilist:${m.id}` : '';
|
||||||
|
const parts = [format, status, authors, id].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(' · ') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string {
|
export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string {
|
||||||
const fm = [
|
const fm = [
|
||||||
'---',
|
'---',
|
||||||
|
|
@ -468,7 +497,7 @@ export const mangaSpec: MediaTypeSpec = {
|
||||||
anilistCandidates = anilistResults.slice(0, 6).map(m => {
|
anilistCandidates = anilistResults.slice(0, 6).map(m => {
|
||||||
const patch: Record<string, string> = { anilist_id: String(m.id) };
|
const patch: Record<string, string> = { anilist_id: String(m.id) };
|
||||||
if (m.idMal != null) patch.mal_id = String(m.idMal);
|
if (m.idMal != null) patch.mal_id = String(m.idMal);
|
||||||
return { label: candidateAniListLabel(m), patches: patch };
|
return { label: candidateAniListLabel(m), detail: candidateAniListDetail(m), patches: patch };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface LibraryNoteCtx {
|
||||||
/** One candidate offered to the user when resolve() can't pick a unique match on its own. */
|
/** One candidate offered to the user when resolve() can't pick a unique match on its own. */
|
||||||
export interface ResolveCandidate {
|
export interface ResolveCandidate {
|
||||||
label: string; // human-readable (title + year/author/publisher/etc, whatever the spec already logs)
|
label: string; // human-readable (title + year/author/publisher/etc, whatever the spec already logs)
|
||||||
|
detail?: string; // optional second line for the picker (format/status/publisher/id, etc) -- muted, smaller text
|
||||||
patches: Record<string, string>; // full id-field patch for this candidate (e.g. anilist_id + mal_id)
|
patches: Record<string, string>; // full id-field patch for this candidate (e.g. anilist_id + mal_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -325,3 +325,8 @@ small.media-db-plugin-list-text {
|
||||||
.media-db-plugin-hidden {
|
.media-db-plugin-hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-db-sync-candidate-detail {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -326,8 +326,25 @@ describe('bookSpec.resolve', () => {
|
||||||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
candidates: [
|
candidates: [
|
||||||
{ label: 'Foo', patches: { olid: 'OL1W' } },
|
{ label: 'Foo', detail: 'OL1W', patches: { olid: 'OL1W' } },
|
||||||
{ label: 'Bar', patches: { olid: 'OL2W' } },
|
{ label: 'Bar', detail: 'OL2W', patches: { olid: 'OL2W' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('ambiguous candidate detail: first author · first-publish year · page count · olid', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => ({
|
||||||
|
docs: [
|
||||||
|
{ key: '/works/OL1W', title: 'Foo', author_name: ['Jane Doe', 'John Roe'], first_publish_year: 1990, number_of_pages_median: 250 },
|
||||||
|
{ key: '/works/OL2W', title: 'Bar' }, // sparse -- only olid survives
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||||
|
expect(result).toEqual({
|
||||||
|
candidates: [
|
||||||
|
{ label: 'Foo (Jane Doe, John Roe, 1990)', detail: 'Jane Doe · 1990 · 250p · OL1W', patches: { olid: 'OL1W' } },
|
||||||
|
{ label: 'Bar', detail: 'OL2W', patches: { olid: 'OL2W' } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,27 @@ const CANDIDATES = [
|
||||||
{ label: 'Bar (2021)', patches: { mal_id: '2' } },
|
{ label: 'Bar (2021)', patches: { mal_id: '2' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CANDIDATES_WITH_DETAIL = [
|
||||||
|
{ label: 'Foo (2020)', detail: 'Manga · Publishing · Author One · anilist:1', patches: { mal_id: '1' } },
|
||||||
|
{ label: 'Bar (2021)', patches: { mal_id: '2' } }, // no detail -- must render as a single-line row
|
||||||
|
];
|
||||||
|
|
||||||
function fakeApp(): any {
|
function fakeApp(): any {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimal Obsidian `HTMLElement.createDiv` stand-in: records every call's options instead of touching a real DOM. */
|
||||||
|
function fakeEl(): { calls: unknown[]; createDiv: (o?: unknown) => unknown } {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
createDiv(o?: unknown) {
|
||||||
|
calls.push(o);
|
||||||
|
return fakeEl();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('CandidatePickerModal', () => {
|
describe('CandidatePickerModal', () => {
|
||||||
test('getItems: candidate labels followed by Skip then Never resolve', () => {
|
test('getItems: candidate labels followed by Skip then Never resolve', () => {
|
||||||
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
||||||
|
|
@ -27,6 +44,44 @@ describe('CandidatePickerModal', () => {
|
||||||
expect(modal.getItemText({ label: 'Foo (2020)', index: 0 })).toBe('Foo (2020)');
|
expect(modal.getItemText({ label: 'Foo (2020)', index: 0 })).toBe('Foo (2020)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getItems: candidate detail threaded through, Skip/Never rows have no detail', () => {
|
||||||
|
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL);
|
||||||
|
const items = modal.getItems();
|
||||||
|
expect(items).toEqual([
|
||||||
|
{ label: 'Foo (2020)', detail: 'Manga · Publishing · Author One · anilist:1', index: 0 },
|
||||||
|
{ label: 'Bar (2021)', detail: undefined, index: 1 },
|
||||||
|
{ label: 'Skip', index: null },
|
||||||
|
{ label: 'Never resolve (mark no_resolve)', index: 'never' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderSuggestion: candidate with a detail -> label div + muted detail div', () => {
|
||||||
|
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL);
|
||||||
|
const item = modal.getItems()[0];
|
||||||
|
const el = fakeEl();
|
||||||
|
modal.renderSuggestion({ item, match: { score: 0, matches: [] } } as any, el as unknown as HTMLElement);
|
||||||
|
expect(el.calls).toEqual([{ text: 'Foo (2020)' }, { text: 'Manga · Publishing · Author One · anilist:1', cls: 'media-db-sync-candidate-detail' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderSuggestion: candidate with no detail -> label div only', () => {
|
||||||
|
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL);
|
||||||
|
const item = modal.getItems()[1];
|
||||||
|
const el = fakeEl();
|
||||||
|
modal.renderSuggestion({ item, match: { score: 0, matches: [] } } as any, el as unknown as HTMLElement);
|
||||||
|
expect(el.calls).toEqual([{ text: 'Bar (2021)' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderSuggestion: Skip/Never rows -> label div only, no detail row', () => {
|
||||||
|
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL);
|
||||||
|
const items = modal.getItems();
|
||||||
|
const skipEl = fakeEl();
|
||||||
|
modal.renderSuggestion({ item: items[2], match: { score: 0, matches: [] } } as any, skipEl as unknown as HTMLElement);
|
||||||
|
expect(skipEl.calls).toEqual([{ text: 'Skip' }]);
|
||||||
|
const neverEl = fakeEl();
|
||||||
|
modal.renderSuggestion({ item: items[3], match: { score: 0, matches: [] } } as any, neverEl as unknown as HTMLElement);
|
||||||
|
expect(neverEl.calls).toEqual([{ text: 'Never resolve (mark no_resolve)' }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('onChooseItem(candidate) -> pick() resolves to that candidate index', async () => {
|
test('onChooseItem(candidate) -> pick() resolves to that candidate index', async () => {
|
||||||
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
||||||
const result = modal.pick();
|
const result = modal.pick();
|
||||||
|
|
|
||||||
|
|
@ -291,8 +291,27 @@ describe('comicSpec.resolve', () => {
|
||||||
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
candidates: [
|
candidates: [
|
||||||
{ label: 'Batman', patches: { comicvine_id: '1' } },
|
{ label: 'Batman', detail: 'cv:1', patches: { comicvine_id: '1' } },
|
||||||
{ label: 'Batman Beyond', patches: { comicvine_id: '2' } },
|
{ label: 'Batman Beyond', detail: 'cv:2', patches: { comicvine_id: '2' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ambiguous candidate detail: publisher · issue count · start year · cv:{id}', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => ({
|
||||||
|
results: [
|
||||||
|
{ id: 1, name: 'Batman', publisher: { name: 'DC Comics' }, count_of_issues: 85, start_year: 2016 },
|
||||||
|
{ id: 2, name: 'Batman Beyond' }, // sparse -- only cv:id survives
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getKey: () => 'cvkey',
|
||||||
|
});
|
||||||
|
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||||
|
expect(result).toEqual({
|
||||||
|
candidates: [
|
||||||
|
{ label: 'Batman (DC Comics, 2016)', detail: 'DC Comics · 85 issues · start 2016 · cv:1', patches: { comicvine_id: '1' } },
|
||||||
|
{ label: 'Batman Beyond', detail: 'cv:2', patches: { comicvine_id: '2' } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -329,8 +329,8 @@ describe('gameSpec.resolve', () => {
|
||||||
const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps);
|
const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
candidates: [
|
candidates: [
|
||||||
{ label: 'Foo', patches: { steam_appid: '1' } },
|
{ label: 'Foo', detail: 'Steam appid 1', patches: { steam_appid: '1' } },
|
||||||
{ label: 'Bar', patches: { steam_appid: '2' } },
|
{ label: 'Bar', detail: 'Steam appid 2', patches: { steam_appid: '2' } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true);
|
expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true);
|
||||||
|
|
|
||||||
|
|
@ -873,8 +873,45 @@ describe('mangaSpec.resolve — AniList primary', () => {
|
||||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
candidates: [
|
candidates: [
|
||||||
{ label: 'Foo (2020)', patches: { anilist_id: '1', mal_id: '11' } },
|
{ label: 'Foo (2020)', detail: 'anilist:1', patches: { anilist_id: '1', mal_id: '11' } },
|
||||||
{ label: 'Bar (2021)', patches: { anilist_id: '2', mal_id: '22' } },
|
{ label: 'Bar (2021)', detail: 'anilist:2', patches: { anilist_id: '2', mal_id: '22' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AniList ambiguous candidate detail: format · status · first-two story authors · anilist:{id}', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => ({ data: [] }),
|
||||||
|
httpPostJson: async () =>
|
||||||
|
anilistPage([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
idMal: 11,
|
||||||
|
title: { romaji: 'Foo', english: '' },
|
||||||
|
startDate: { year: 2020 },
|
||||||
|
format: 'ONE_SHOT',
|
||||||
|
status: 'RELEASING',
|
||||||
|
staff: {
|
||||||
|
edges: [
|
||||||
|
{ role: 'Story & Art', node: { name: { full: 'Author One' } } },
|
||||||
|
{ role: 'Story', node: { name: { full: 'Author Two' } } },
|
||||||
|
{ role: 'Story', node: { name: { full: 'Author Three' } } }, // 3rd Story credit -- dropped, detail caps at 2
|
||||||
|
{ role: 'Illustration', node: { name: { full: 'Illustrator Only' } } }, // non-Story role -- excluded
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 2, idMal: 22, title: { romaji: 'Bar', english: '' }, startDate: { year: 2021 } }, // sparse -- only id survives
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||||
|
expect(result).toEqual({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
label: 'Foo (2020)',
|
||||||
|
detail: 'One Shot · Publishing · Author One, Author Two · anilist:1',
|
||||||
|
patches: { anilist_id: '1', mal_id: '11' },
|
||||||
|
},
|
||||||
|
{ label: 'Bar (2021)', detail: 'anilist:2', patches: { anilist_id: '2', mal_id: '22' } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue