feat(library): two-line candidate picker entries with per-type detail

This commit is contained in:
afiqzudinhadi 2026-08-05 16:00:33 +08:00
parent 16b8b242b8
commit 1e4567e844
12 changed files with 219 additions and 18 deletions

View file

@ -326,8 +326,25 @@ describe('bookSpec.resolve', () => {
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
expect(result).toEqual({
candidates: [
{ label: 'Foo', patches: { olid: 'OL1W' } },
{ label: 'Bar', patches: { olid: 'OL2W' } },
{ label: 'Foo', detail: 'OL1W', patches: { olid: 'OL1W' } },
{ 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' } },
],
});
});

View file

@ -6,10 +6,27 @@ const CANDIDATES = [
{ 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 {
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', () => {
test('getItems: candidate labels followed by Skip then Never resolve', () => {
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)');
});
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 () => {
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
const result = modal.pick();

View file

@ -291,8 +291,27 @@ describe('comicSpec.resolve', () => {
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
expect(result).toEqual({
candidates: [
{ label: 'Batman', patches: { comicvine_id: '1' } },
{ label: 'Batman Beyond', patches: { comicvine_id: '2' } },
{ label: 'Batman', detail: 'cv:1', patches: { comicvine_id: '1' } },
{ 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' } },
],
});
});

View file

@ -329,8 +329,8 @@ describe('gameSpec.resolve', () => {
const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps);
expect(result).toEqual({
candidates: [
{ label: 'Foo', patches: { steam_appid: '1' } },
{ label: 'Bar', patches: { steam_appid: '2' } },
{ label: 'Foo', detail: 'Steam appid 1', patches: { steam_appid: '1' } },
{ label: 'Bar', detail: 'Steam appid 2', patches: { steam_appid: '2' } },
],
});
expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true);

View file

@ -873,8 +873,45 @@ describe('mangaSpec.resolve — AniList primary', () => {
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
expect(result).toEqual({
candidates: [
{ label: 'Foo (2020)', patches: { anilist_id: '1', mal_id: '11' } },
{ label: 'Bar (2021)', patches: { anilist_id: '2', mal_id: '22' } },
{ label: 'Foo (2020)', detail: 'anilist:1', patches: { anilist_id: '1', mal_id: '11' } },
{ 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' } },
],
});
});