feat(library): interactive candidate picker for ambiguous resolves
This commit is contained in:
parent
3eb89772c7
commit
d7162d3ac2
16 changed files with 516 additions and 87 deletions
|
|
@ -305,16 +305,16 @@ describe('bookSpec.resolve', () => {
|
|||
test('unique exact title match -> accepted', async () => {
|
||||
const deps = makeDeps({ http: async () => olFixture });
|
||||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||
expect(result).toEqual({ olid: 'OL1168083W' });
|
||||
expect(result).toEqual({ patches: { olid: 'OL1168083W' } });
|
||||
});
|
||||
test('no exact match, sole result -> accepted', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({ docs: [{ key: '/works/OL999W', title: 'Some Other Title' }] }),
|
||||
});
|
||||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||
expect(result).toEqual({ olid: 'OL999W' });
|
||||
expect(result).toEqual({ patches: { olid: 'OL999W' } });
|
||||
});
|
||||
test('ambiguous (multiple results, no exact match) -> null', async () => {
|
||||
test('ambiguous (multiple results, no exact match) -> candidates (top ≤6, label + full patches)', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({
|
||||
docs: [
|
||||
|
|
@ -324,7 +324,12 @@ describe('bookSpec.resolve', () => {
|
|||
}),
|
||||
});
|
||||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(result).toEqual({
|
||||
candidates: [
|
||||
{ label: 'Foo', patches: { olid: 'OL1W' } },
|
||||
{ label: 'Bar', patches: { olid: 'OL2W' } },
|
||||
],
|
||||
});
|
||||
});
|
||||
test('ambiguous -> logs top candidates with olid + title', async () => {
|
||||
const deps = makeDeps({
|
||||
|
|
@ -336,7 +341,7 @@ describe('bookSpec.resolve', () => {
|
|||
}),
|
||||
});
|
||||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(result && 'candidates' in result).toBe(true);
|
||||
expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('olid=OL1W') && m.includes('Foo') && m.includes('olid=OL2W') && m.includes('Bar'))).toBe(true);
|
||||
});
|
||||
test('no results -> null', async () => {
|
||||
|
|
@ -368,7 +373,7 @@ describe('bookSpec.resolve — author hint (stock `author` / canonical `authors`
|
|||
const result = await bookSpec.resolve(ctxFor({ title: '1984', author: 'George Orwell' }, ''), deps);
|
||||
const q = new URL(capturedUrl).searchParams.get('q');
|
||||
expect(q).toBe('1984 George Orwell');
|
||||
expect(result).toEqual({ olid: 'OL1168083W' });
|
||||
expect(result).toEqual({ patches: { olid: 'OL1168083W' } });
|
||||
});
|
||||
|
||||
test('canonical `authors` bracketed list -> first author used in query', async () => {
|
||||
|
|
@ -382,7 +387,7 @@ describe('bookSpec.resolve — author hint (stock `author` / canonical `authors`
|
|||
const result = await bookSpec.resolve(ctxFor({ title: '1984', authors: '[George Orwell, Someone Else]' }, ''), deps);
|
||||
const q = new URL(capturedUrl).searchParams.get('q');
|
||||
expect(q).toBe('1984 George Orwell');
|
||||
expect(result).toEqual({ olid: 'OL1168083W' });
|
||||
expect(result).toEqual({ patches: { olid: 'OL1168083W' } });
|
||||
});
|
||||
|
||||
test('no author anywhere in frontmatter -> query is title only (unchanged behavior)', async () => {
|
||||
|
|
@ -396,6 +401,6 @@ describe('bookSpec.resolve — author hint (stock `author` / canonical `authors`
|
|||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||
const q = new URL(capturedUrl).searchParams.get('q');
|
||||
expect(q).toBe('1984');
|
||||
expect(result).toEqual({ olid: 'OL1168083W' });
|
||||
expect(result).toEqual({ patches: { olid: 'OL1168083W' } });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
57
tests/library-candidate-picker-modal.test.ts
Normal file
57
tests/library-candidate-picker-modal.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { CandidatePickerModal } from 'packages/obsidian/src/library/CandidatePickerModal';
|
||||
|
||||
const CANDIDATES = [
|
||||
{ label: 'Foo (2020)', patches: { mal_id: '1' } },
|
||||
{ label: 'Bar (2021)', patches: { mal_id: '2' } },
|
||||
];
|
||||
|
||||
function fakeApp(): any {
|
||||
return {};
|
||||
}
|
||||
|
||||
describe('CandidatePickerModal', () => {
|
||||
test('getItems: candidate labels followed by a trailing Skip item (null index)', () => {
|
||||
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
||||
const items = modal.getItems();
|
||||
expect(items).toEqual([
|
||||
{ label: 'Foo (2020)', index: 0 },
|
||||
{ label: 'Bar (2021)', index: 1 },
|
||||
{ label: 'Skip', index: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('getItemText returns the item label', () => {
|
||||
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
||||
expect(modal.getItemText({ label: 'Foo (2020)', index: 0 })).toBe('Foo (2020)');
|
||||
});
|
||||
|
||||
test('onChooseItem(candidate) -> pick() resolves to that candidate index', async () => {
|
||||
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
||||
const result = modal.pick();
|
||||
modal.onChooseItem({ label: 'Bar (2021)', index: 1 }, {} as MouseEvent);
|
||||
expect(await result).toBe(1);
|
||||
});
|
||||
|
||||
test('onChooseItem(Skip) -> pick() resolves to null', async () => {
|
||||
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
||||
const result = modal.pick();
|
||||
modal.onChooseItem({ label: 'Skip', index: null }, {} as MouseEvent);
|
||||
expect(await result).toBeNull();
|
||||
});
|
||||
|
||||
test('onClose without a prior choice (Esc / dismiss) -> pick() resolves to null', async () => {
|
||||
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
||||
const result = modal.pick();
|
||||
modal.onClose();
|
||||
expect(await result).toBeNull();
|
||||
});
|
||||
|
||||
test('onClose firing after onChooseItem does not override the already-settled choice', async () => {
|
||||
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
|
||||
const result = modal.pick();
|
||||
modal.onChooseItem({ label: 'Foo (2020)', index: 0 }, {} as MouseEvent);
|
||||
modal.onClose(); // Obsidian calls onClose() after a choice too -- must not clobber the resolved value
|
||||
expect(await result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -266,7 +266,7 @@ describe('comicSpec.resolve', () => {
|
|||
getKey: () => 'cvkey',
|
||||
});
|
||||
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||
expect(result).toEqual({ comicvine_id: '195824' });
|
||||
expect(result).toEqual({ patches: { comicvine_id: '195824' } });
|
||||
});
|
||||
|
||||
test('no exact match, sole result -> accepted', async () => {
|
||||
|
|
@ -275,10 +275,10 @@ describe('comicSpec.resolve', () => {
|
|||
getKey: () => 'cvkey',
|
||||
});
|
||||
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||
expect(result).toEqual({ comicvine_id: '999' });
|
||||
expect(result).toEqual({ patches: { comicvine_id: '999' } });
|
||||
});
|
||||
|
||||
test('ambiguous (multiple results, no exact match) -> null', async () => {
|
||||
test('ambiguous (multiple results, no exact match) -> candidates (top ≤6, label + full patches)', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({
|
||||
results: [
|
||||
|
|
@ -289,7 +289,12 @@ describe('comicSpec.resolve', () => {
|
|||
getKey: () => 'cvkey',
|
||||
});
|
||||
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(result).toEqual({
|
||||
candidates: [
|
||||
{ label: 'Batman', patches: { comicvine_id: '1' } },
|
||||
{ label: 'Batman Beyond', patches: { comicvine_id: '2' } },
|
||||
],
|
||||
});
|
||||
});
|
||||
test('ambiguous -> logs top candidates with id + name', async () => {
|
||||
const deps = makeDeps({
|
||||
|
|
@ -302,7 +307,7 @@ describe('comicSpec.resolve', () => {
|
|||
getKey: () => 'cvkey',
|
||||
});
|
||||
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(result && 'candidates' in result).toBe(true);
|
||||
expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('id=1') && m.includes('Batman') && m.includes('id=2') && m.includes('Batman Beyond'))).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -461,3 +461,114 @@ describe('sync summary transparency', () => {
|
|||
expect(notices[0]).toContain('1 no-data (see console)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveType: interactive candidate picker (needsChoice)', () => {
|
||||
const CANDIDATES = [
|
||||
{ label: 'Foo', patches: { mal_id: '1' } },
|
||||
{ label: 'Bar', patches: { mal_id: '2' } },
|
||||
];
|
||||
|
||||
const candidateSpec: MediaTypeSpec = {
|
||||
typeName: 'manga',
|
||||
itemType: 'manga_item',
|
||||
folderSettingKey: 'libraryMangaFolder',
|
||||
enabledSettingKey: 'libraryMangaEnabled',
|
||||
throttleMs: 0,
|
||||
hasId: () => false,
|
||||
isActive: () => true,
|
||||
resolve: async () => ({ candidates: CANDIDATES }),
|
||||
sync: async () => null,
|
||||
};
|
||||
|
||||
function makeCandidateDeps(writes: { path: string; content: string }[]): LibraryEngineDeps {
|
||||
return {
|
||||
listNotes: async () => [{ path: 'Mangas/Foo.md' }],
|
||||
readNote: async () => '---\ntype: manga_item\ntitle: Foo\n---\n\nbody\n',
|
||||
writeNote: async (path: string, content: string) => {
|
||||
writes.push({ path, content });
|
||||
},
|
||||
sleep: async () => {},
|
||||
log: () => {},
|
||||
specDeps: fakeSpecDeps(),
|
||||
};
|
||||
}
|
||||
|
||||
test('pickCandidate fake selects index 0 -> patch applied via patchFrontmatter, counted resolved + picked', async () => {
|
||||
const c = new LibraryController(fakePlugin());
|
||||
const notices: string[] = [];
|
||||
(c as any).notify = (msg: string) => notices.push(msg);
|
||||
const writes: { path: string; content: string }[] = [];
|
||||
(c as any).makeDeps = () => makeCandidateDeps(writes);
|
||||
let pickedFilename = '';
|
||||
let pickedCandidates: unknown = undefined;
|
||||
(c as any).pickCandidate = async (filename: string, candidates: unknown) => {
|
||||
pickedFilename = filename;
|
||||
pickedCandidates = candidates;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const report = await c.resolveType(candidateSpec);
|
||||
|
||||
expect(report.needsChoice).toEqual([{ path: 'Mangas/Foo.md', filename: 'Foo.md', candidates: CANDIDATES }]);
|
||||
expect(report.resolved).toEqual(['Mangas/Foo.md']);
|
||||
expect(writes.length).toBe(1);
|
||||
expect(writes[0].path).toBe('Mangas/Foo.md');
|
||||
expect(writes[0].content).toContain('mal_id: 1');
|
||||
expect(pickedFilename).toBe('Foo.md');
|
||||
expect(pickedCandidates).toEqual(CANDIDATES);
|
||||
expect(notices[0]).toContain('1 picked, 0 skipped');
|
||||
});
|
||||
|
||||
test('pickCandidate fake returns null (Skip) -> no write, counted skipped, not resolved', async () => {
|
||||
const c = new LibraryController(fakePlugin());
|
||||
const notices: string[] = [];
|
||||
(c as any).notify = (msg: string) => notices.push(msg);
|
||||
const writes: { path: string; content: string }[] = [];
|
||||
(c as any).makeDeps = () => makeCandidateDeps(writes);
|
||||
(c as any).pickCandidate = async () => null;
|
||||
|
||||
const report = await c.resolveType(candidateSpec);
|
||||
|
||||
expect(report.resolved).toEqual([]);
|
||||
expect(writes.length).toBe(0);
|
||||
expect(notices[0]).toContain('0 picked, 1 skipped');
|
||||
});
|
||||
|
||||
test('dryRun: needsChoice collected but pickCandidate never invoked, no write', async () => {
|
||||
const c = new LibraryController(fakePlugin());
|
||||
const notices: string[] = [];
|
||||
(c as any).notify = (msg: string) => notices.push(msg);
|
||||
const writes: { path: string; content: string }[] = [];
|
||||
(c as any).makeDeps = () => makeCandidateDeps(writes);
|
||||
let pickCalled = false;
|
||||
(c as any).pickCandidate = async () => {
|
||||
pickCalled = true;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const report = await c.resolveType(candidateSpec, true);
|
||||
|
||||
expect(pickCalled).toBe(false);
|
||||
expect(report.needsChoice.length).toBe(1);
|
||||
expect(writes.length).toBe(0);
|
||||
expect(notices[0]).toContain('0 picked, 0 skipped'); // dry-run never picks/skips; needsChoice.length is still visible on the report
|
||||
});
|
||||
|
||||
test('no needsChoice entries -> summary omits the picked/skipped clause entirely', async () => {
|
||||
const c = new LibraryController(fakePlugin());
|
||||
const notices: string[] = [];
|
||||
(c as any).notify = (msg: string) => notices.push(msg);
|
||||
(c as any).makeDeps = () => ({
|
||||
listNotes: async () => [],
|
||||
readNote: async () => '',
|
||||
writeNote: async () => {},
|
||||
sleep: async () => {},
|
||||
log: () => {},
|
||||
specDeps: fakeSpecDeps(),
|
||||
});
|
||||
|
||||
await c.resolveType(mangaSpec);
|
||||
|
||||
expect(notices[0]).not.toContain('picked');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@ describe('libraryFolderSync: throttle-after-success', () => {
|
|||
|
||||
describe('libraryFolderResolve', () => {
|
||||
test('missing id -> spec.resolve patches written via patchFrontmatter', async () => {
|
||||
const { spec } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) });
|
||||
const { spec } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) });
|
||||
const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
|
||||
const report = await libraryFolderResolve(spec, deps, {});
|
||||
expect(report.resolved).toEqual(['A.md']);
|
||||
|
|
@ -326,7 +326,7 @@ describe('libraryFolderResolve', () => {
|
|||
});
|
||||
|
||||
test('hasId already true -> resolve() not called, note excluded from both lists', async () => {
|
||||
const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) });
|
||||
const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) });
|
||||
const { deps } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]);
|
||||
const report = await libraryFolderResolve(spec, deps, {});
|
||||
expect(resolveCalls.length).toBe(0);
|
||||
|
|
@ -335,7 +335,7 @@ describe('libraryFolderResolve', () => {
|
|||
});
|
||||
|
||||
test('dryRun: resolved counted but no writeNote call', async () => {
|
||||
const { spec } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) });
|
||||
const { spec } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) });
|
||||
const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
|
||||
const report = await libraryFolderResolve(spec, deps, { dryRun: true });
|
||||
expect(report.resolved).toEqual(['A.md']);
|
||||
|
|
@ -348,7 +348,7 @@ describe('libraryFolderResolve', () => {
|
|||
resolve: async () => {
|
||||
n++;
|
||||
if (n === 1) throw new Error('boom');
|
||||
return { fake_id: '99' };
|
||||
return { patches: { fake_id: '99' } };
|
||||
},
|
||||
});
|
||||
const { deps } = makeDeps([
|
||||
|
|
@ -362,7 +362,7 @@ describe('libraryFolderResolve', () => {
|
|||
});
|
||||
|
||||
test('`_`-prefixed / non-entry notes excluded from resolve pass', async () => {
|
||||
const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) });
|
||||
const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) });
|
||||
const note = `---\ntype: folder_index\n---\n\n# Index\n`;
|
||||
const { deps } = makeDeps([{ path: 'Index.md', content: note }]);
|
||||
const report = await libraryFolderResolve(spec, deps, {});
|
||||
|
|
@ -381,7 +381,7 @@ describe('libraryFolderResolve', () => {
|
|||
e.retryAfterMs = 1200;
|
||||
throw e;
|
||||
}
|
||||
return { fake_id: '99' };
|
||||
return { patches: { fake_id: '99' } };
|
||||
},
|
||||
});
|
||||
const { deps, slept } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
|
||||
|
|
@ -391,3 +391,52 @@ describe('libraryFolderResolve', () => {
|
|||
expect(report.resolved).toEqual(['A.md']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('libraryFolderResolve: needsChoice (candidates outcome)', () => {
|
||||
const CANDIDATES = [
|
||||
{ label: 'Foo (2020)', patches: { fake_id: '1' } },
|
||||
{ label: 'Bar (2021)', patches: { fake_id: '2' } },
|
||||
];
|
||||
|
||||
test('spec.resolve returns candidates -> collected into report.needsChoice, no write, not counted resolved/ambiguous', async () => {
|
||||
const { spec } = makeFakeSpec({ resolve: async () => ({ candidates: CANDIDATES }) });
|
||||
const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
|
||||
const report = await libraryFolderResolve(spec, deps, {});
|
||||
expect(report.needsChoice).toEqual([{ path: 'A.md', filename: 'A.md', candidates: CANDIDATES }]);
|
||||
expect(report.resolved).toEqual([]);
|
||||
expect(report.ambiguous).toEqual([]);
|
||||
expect(writes.length).toBe(0);
|
||||
});
|
||||
|
||||
test('dryRun -> candidates still collected (collect too, no write either way)', async () => {
|
||||
const { spec } = makeFakeSpec({ resolve: async () => ({ candidates: CANDIDATES }) });
|
||||
const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
|
||||
const report = await libraryFolderResolve(spec, deps, { dryRun: true });
|
||||
expect(report.needsChoice.length).toBe(1);
|
||||
expect(writes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('libraryFolderResolve: no_resolve flag', () => {
|
||||
test('no_resolve: true -> note skipped entirely, counted skippedNoResolve, resolve() never called', async () => {
|
||||
const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) });
|
||||
const note = NO_ID_NOTE.replace('fake_id: ', 'fake_id: \nno_resolve: true');
|
||||
const { deps, writes } = makeDeps([{ path: 'A.md', content: note }]);
|
||||
const report = await libraryFolderResolve(spec, deps, {});
|
||||
expect(resolveCalls.length).toBe(0);
|
||||
expect(report.skippedNoResolve).toBe(1);
|
||||
expect(report.resolved).toEqual([]);
|
||||
expect(report.ambiguous).toEqual([]);
|
||||
expect(report.needsChoice).toEqual([]);
|
||||
expect(writes.length).toBe(0);
|
||||
});
|
||||
|
||||
test('no_resolve absent/false -> resolve() runs normally', async () => {
|
||||
const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) });
|
||||
const { deps } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
|
||||
const report = await libraryFolderResolve(spec, deps, {});
|
||||
expect(resolveCalls.length).toBe(1);
|
||||
expect(report.skippedNoResolve).toBe(0);
|
||||
expect(report.resolved).toEqual(['A.md']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ describe('gameSpec.resolve', () => {
|
|||
},
|
||||
});
|
||||
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(result).toEqual({ patches: { steam_appid: '792100' } });
|
||||
expect(httpCalls).toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -306,7 +306,7 @@ describe('gameSpec.resolve', () => {
|
|||
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' });
|
||||
expect(result).toEqual({ patches: { steam_appid: '792100' } });
|
||||
});
|
||||
|
||||
test('no exact match, sole steam result -> accepted', async () => {
|
||||
|
|
@ -314,10 +314,10 @@ describe('gameSpec.resolve', () => {
|
|||
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' });
|
||||
expect(result).toEqual({ patches: { steam_appid: '999' } });
|
||||
});
|
||||
|
||||
test('steam storesearch ambiguous, no key -> null, logged', async () => {
|
||||
test('steam storesearch ambiguous, no key -> steam candidates returned (top ≤6, label + full patches), RAWG skip still logged', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({
|
||||
items: [
|
||||
|
|
@ -327,7 +327,12 @@ describe('gameSpec.resolve', () => {
|
|||
}),
|
||||
});
|
||||
const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(result).toEqual({
|
||||
candidates: [
|
||||
{ label: 'Foo', patches: { steam_appid: '1' } },
|
||||
{ label: 'Bar', patches: { steam_appid: '2' } },
|
||||
],
|
||||
});
|
||||
expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true);
|
||||
});
|
||||
test('steam storesearch ambiguous -> logs top candidates with appid + name', async () => {
|
||||
|
|
@ -340,7 +345,7 @@ describe('gameSpec.resolve', () => {
|
|||
}),
|
||||
});
|
||||
const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(result && 'candidates' in result).toBe(true);
|
||||
expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('appid=1') && m.includes('Foo') && m.includes('appid=2') && m.includes('Bar'))).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -353,7 +358,7 @@ describe('gameSpec.resolve', () => {
|
|||
getKey: () => 'rawgkey',
|
||||
});
|
||||
const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps);
|
||||
expect(result).toEqual({ rawg_id: '4200' });
|
||||
expect(result).toEqual({ patches: { rawg_id: '4200' } });
|
||||
expect(deps.logCalls.some(m => m.includes('storesearch'))).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -363,7 +368,7 @@ describe('gameSpec.resolve', () => {
|
|||
getKey: () => 'rawgkey',
|
||||
});
|
||||
const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps);
|
||||
expect(result).toEqual({ rawg_id: '4200' });
|
||||
expect(result).toEqual({ patches: { rawg_id: '4200' } });
|
||||
});
|
||||
|
||||
test('no steam results, RAWG key missing -> null, logged, no RAWG call attempted', async () => {
|
||||
|
|
|
|||
|
|
@ -571,14 +571,14 @@ describe('mangaSpec.resolve', () => {
|
|||
http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778' } });
|
||||
});
|
||||
test('no exact match, sole result -> accepted', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({ data: [{ mal_id: 999, title: 'Some Other Title', title_english: '' }] }),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '999' });
|
||||
expect(result).toEqual({ patches: { mal_id: '999' } });
|
||||
});
|
||||
test('ambiguous (multiple results, no exact match) -> null', async () => {
|
||||
const deps = makeDeps({
|
||||
|
|
@ -633,7 +633,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => {
|
|||
},
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778', mangadex_id: 'a1b2c3d4-uuid' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778', mangadex_id: 'a1b2c3d4-uuid' } });
|
||||
});
|
||||
|
||||
test('mangadex search no exact match, sole result -> accepted (unique-exact fallback rule)', async () => {
|
||||
|
|
@ -644,7 +644,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => {
|
|||
},
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778', mangadex_id: 'uuid-solo' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778', mangadex_id: 'uuid-solo' } });
|
||||
});
|
||||
|
||||
test('mangadex search ambiguous (multiple results, no exact match) -> mal_id patched only', async () => {
|
||||
|
|
@ -661,7 +661,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => {
|
|||
},
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778' } });
|
||||
});
|
||||
|
||||
test('mangadex search throws -> log, mal_id patched only (best-effort, no overall failure)', async () => {
|
||||
|
|
@ -672,7 +672,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => {
|
|||
},
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778' } });
|
||||
expect(deps.logCalls.some(m => m.toLowerCase().includes('mangadex'))).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -685,7 +685,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => {
|
|||
},
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man', mangadex_id: 'existing-uuid' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778' } });
|
||||
expect(mangadexCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -817,7 +817,7 @@ describe('mangaSpec.resolve — AniList primary', () => {
|
|||
httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778', anilist_id: '105778' } });
|
||||
});
|
||||
|
||||
test('unique exact match via english title only (case-insensitive) -> accepted', async () => {
|
||||
|
|
@ -826,7 +826,7 @@ describe('mangaSpec.resolve — AniList primary', () => {
|
|||
httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'チェンソーマン', english: 'Chainsaw Man' } }]),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778', anilist_id: '105778' } });
|
||||
});
|
||||
|
||||
test('no exact match, sole AniList result -> accepted (unique-exact fallback rule)', async () => {
|
||||
|
|
@ -835,7 +835,7 @@ describe('mangaSpec.resolve — AniList primary', () => {
|
|||
httpPostJson: async () => anilistPage([{ id: 999, idMal: 888, title: { romaji: 'Some Other Title', english: '' } }]),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '888', anilist_id: '999' });
|
||||
expect(result).toEqual({ patches: { mal_id: '888', anilist_id: '999' } });
|
||||
});
|
||||
|
||||
test('AniList hit, idMal null (no MAL bridge) -> patch has anilist_id only', async () => {
|
||||
|
|
@ -844,7 +844,7 @@ describe('mangaSpec.resolve — AniList primary', () => {
|
|||
httpPostJson: async () => anilistPage([{ id: 105778, idMal: null, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ anilist_id: '105778' });
|
||||
expect(result).toEqual({ patches: { anilist_id: '105778' } });
|
||||
});
|
||||
|
||||
test('AniList ambiguous (multiple, no exact) -> falls back to Jikan search, logs anilist candidates', async () => {
|
||||
|
|
@ -857,17 +857,35 @@ describe('mangaSpec.resolve — AniList primary', () => {
|
|||
]),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778' } });
|
||||
expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('anilist_id=1') && m.includes('Foo') && m.includes('anilist_id=2') && m.includes('Bar'))).toBe(true);
|
||||
});
|
||||
|
||||
test('AniList ambiguous AND Jikan also fails to land a unique match -> AniList candidates returned (top 6, label + full patches)', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({ data: [] }), // jikan: no results either -> resolveMalId returns null
|
||||
httpPostJson: async () =>
|
||||
anilistPage([
|
||||
{ id: 1, idMal: 11, title: { romaji: 'Foo', english: '' }, startDate: { year: 2020 } },
|
||||
{ id: 2, idMal: 22, title: { romaji: 'Bar', english: '' }, startDate: { year: 2021 } },
|
||||
]),
|
||||
});
|
||||
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' } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('AniList miss (empty results) -> falls straight to Jikan, no ambiguous log from AniList side', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }),
|
||||
httpPostJson: async () => anilistPage([]),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778' } });
|
||||
expect(deps.logCalls.some(m => m.includes('anilist_id='))).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -879,7 +897,7 @@ describe('mangaSpec.resolve — AniList primary', () => {
|
|||
},
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778' } });
|
||||
expect(deps.logCalls.some(m => m.toLowerCase().includes('anilist'))).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -904,7 +922,7 @@ describe('mangaSpec.resolve — AniList primary', () => {
|
|||
httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778', anilist_id: '105778', mangadex_id: 'a1b2c3d4-uuid' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778', anilist_id: '105778', mangadex_id: 'a1b2c3d4-uuid' } });
|
||||
});
|
||||
|
||||
test('mangadex_id already present -> mangadex search skipped, even on an AniList hit', async () => {
|
||||
|
|
@ -917,7 +935,7 @@ describe('mangaSpec.resolve — AniList primary', () => {
|
|||
httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man', mangadex_id: 'existing-uuid' }, ''), deps);
|
||||
expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' });
|
||||
expect(result).toEqual({ patches: { mal_id: '116778', anilist_id: '105778' } });
|
||||
expect(mangadexCalled).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,26 @@ function stringifySimpleYaml(value: unknown): string {
|
|||
.concat('\n');
|
||||
}
|
||||
|
||||
class MockModal {
|
||||
app: unknown;
|
||||
titleEl: { setText: (text: string) => void } = { setText: (): void => {} };
|
||||
contentEl: { empty: () => void } = { empty: (): void => {} };
|
||||
|
||||
constructor(app: unknown) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
setTitle(_title: string): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
open(): void {}
|
||||
close(): void {
|
||||
this.onClose();
|
||||
}
|
||||
onClose(): void {}
|
||||
}
|
||||
|
||||
mock.module('obsidian', () => ({
|
||||
AbstractInputSuggest: class {},
|
||||
Component: class {
|
||||
|
|
@ -43,18 +63,12 @@ mock.module('obsidian', () => ({
|
|||
unload(): void {}
|
||||
},
|
||||
DropdownComponent: class {},
|
||||
FuzzySuggestModal: class extends MockModal {
|
||||
setPlaceholder(_text: string): void {}
|
||||
},
|
||||
MarkdownRenderer: { render: async (): Promise<void> => {} },
|
||||
MarkdownView: class {},
|
||||
Modal: class {
|
||||
app: unknown;
|
||||
|
||||
constructor(app: unknown) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
open(): void {}
|
||||
close(): void {}
|
||||
},
|
||||
Modal: MockModal,
|
||||
Notice: class {},
|
||||
normalizePath: (path: string): string => path,
|
||||
moment: Object.assign((value?: unknown): unknown => value, { locale: (): void => {} }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue