Supersedes 4249952's half-fix: try now wraps pickCandidate too; restores the 19 controller tests that commit deleted; replaces its source-grep pseudo-tests with a behavioral two-entry isolation test.
607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import type { MediaTypeSpec } from 'packages/obsidian/src/library/types';
|
|
import { bookSpec } from 'packages/obsidian/src/library/book';
|
|
import { gameSpec } from 'packages/obsidian/src/library/game';
|
|
import { LibraryController } from 'packages/obsidian/src/library/LibraryController';
|
|
import type { LibraryEngineDeps } from 'packages/obsidian/src/library/LibraryEngine';
|
|
import { mangaSpec } from 'packages/obsidian/src/library/manga';
|
|
|
|
function fakePlugin(
|
|
overrides: Partial<{
|
|
mangaEnabled: boolean;
|
|
bookEnabled: boolean;
|
|
gameEnabled: boolean;
|
|
comicEnabled: boolean;
|
|
last: number;
|
|
hours: number;
|
|
rawgKeyId: string;
|
|
comicvineKeyId: string;
|
|
}> = {},
|
|
) {
|
|
const secrets: Record<string, string> = {};
|
|
if (overrides.rawgKeyId) secrets[overrides.rawgKeyId] = 'rawg-secret';
|
|
if (overrides.comicvineKeyId) secrets[overrides.comicvineKeyId] = 'cv-secret';
|
|
return {
|
|
settings: {
|
|
libraryMangaEnabled: overrides.mangaEnabled ?? true,
|
|
libraryMangaFolder: 'Mangas',
|
|
libraryBookEnabled: overrides.bookEnabled ?? true,
|
|
libraryBookFolder: 'Books',
|
|
libraryGameEnabled: overrides.gameEnabled ?? true,
|
|
libraryGameFolder: 'Games',
|
|
libraryComicEnabled: overrides.comicEnabled ?? true,
|
|
libraryComicFolder: 'Comics',
|
|
libraryLastSync: overrides.last ?? 0,
|
|
watchlistSyncIntervalHours: overrides.hours ?? 24,
|
|
RAWGAPIKeyId: overrides.rawgKeyId ?? '',
|
|
ComicVineKeyId: overrides.comicvineKeyId ?? '',
|
|
},
|
|
saveSettings: async () => {},
|
|
app: {
|
|
secretStorage: { getSecret: (id: string) => secrets[id] ?? null },
|
|
vault: {},
|
|
},
|
|
} as any;
|
|
}
|
|
|
|
describe('maybeCatchUp', () => {
|
|
test('overdue → syncs', async () => {
|
|
const c = new LibraryController(fakePlugin({ last: 0 }));
|
|
let called = false;
|
|
(c as any).syncAll = async () => {
|
|
called = true;
|
|
};
|
|
await c.maybeCatchUp();
|
|
expect(called).toBe(true);
|
|
});
|
|
|
|
test('recent sync → no call', async () => {
|
|
const c = new LibraryController(fakePlugin({ last: Date.now() }));
|
|
let called = false;
|
|
(c as any).syncAll = async () => {
|
|
called = true;
|
|
};
|
|
await c.maybeCatchUp();
|
|
expect(called).toBe(false);
|
|
});
|
|
|
|
test('all types disabled → no call', async () => {
|
|
const c = new LibraryController(fakePlugin({ mangaEnabled: false, bookEnabled: false, gameEnabled: false, comicEnabled: false, last: 0 }));
|
|
let called = false;
|
|
(c as any).syncAll = async () => {
|
|
called = true;
|
|
};
|
|
await c.maybeCatchUp();
|
|
expect(called).toBe(false);
|
|
});
|
|
});
|
|
|
|
function fakeSpecDeps() {
|
|
return { http: async () => ({}), httpText: async () => '', httpPostJson: async () => ({}), getKey: () => '', log: () => {}, notify: () => {} };
|
|
}
|
|
|
|
function deferredDeps(): { deps: LibraryEngineDeps; listNotesCalls: () => number; release: () => void } {
|
|
let listNotesCalls = 0;
|
|
let release!: () => void;
|
|
const gate = new Promise<void>(resolve => {
|
|
release = resolve;
|
|
});
|
|
const deps: LibraryEngineDeps = {
|
|
listNotes: async () => {
|
|
listNotesCalls++;
|
|
await gate;
|
|
return [];
|
|
},
|
|
readNote: async () => '',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: fakeSpecDeps(),
|
|
};
|
|
return { deps, listNotesCalls: () => listNotesCalls, release };
|
|
}
|
|
|
|
describe('concurrency guard', () => {
|
|
test('overlapping syncType calls: second short-circuits while first is in flight', async () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const { deps, listNotesCalls, release } = deferredDeps();
|
|
(c as any).makeDeps = () => deps;
|
|
|
|
const first = c.syncType(mangaSpec, false);
|
|
const second = await c.syncType(mangaSpec, false);
|
|
|
|
expect(listNotesCalls()).toBe(1);
|
|
expect(second.scanned).toBe(0);
|
|
expect(second.written).toBe(0);
|
|
|
|
release();
|
|
const firstResult = await first;
|
|
expect(firstResult.scanned).toBe(0);
|
|
});
|
|
|
|
test('syncType in flight blocks resolveType (shared guard)', async () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const { deps, release } = deferredDeps();
|
|
(c as any).makeDeps = () => deps;
|
|
|
|
const first = c.syncType(mangaSpec, false);
|
|
const resolveResult = await c.resolveType(bookSpec);
|
|
|
|
expect(resolveResult.resolved.length).toBe(0);
|
|
expect(resolveResult.ambiguous.length).toBe(0);
|
|
|
|
release();
|
|
await first;
|
|
});
|
|
|
|
test('syncType in flight blocks syncAll (shared guard)', async () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const { deps, release } = deferredDeps();
|
|
(c as any).makeDeps = () => deps;
|
|
|
|
const first = c.syncType(mangaSpec, false);
|
|
await c.syncAll(false, true); // should short-circuit, not throw
|
|
|
|
release();
|
|
await first;
|
|
});
|
|
|
|
test('flag resets after completion → next call runs normally', async () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
let listNotesCalls = 0;
|
|
(c as any).makeDeps = () => ({
|
|
listNotes: async () => {
|
|
listNotesCalls++;
|
|
return [];
|
|
},
|
|
readNote: async () => '',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: fakeSpecDeps(),
|
|
});
|
|
|
|
await c.syncType(mangaSpec, false);
|
|
await c.syncType(mangaSpec, false);
|
|
|
|
expect(listNotesCalls).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('missing API key handling', () => {
|
|
test('missing RAWG + Comic Vine keys + quiet sync → zero notices, logged only', async () => {
|
|
const c = new LibraryController(fakePlugin()); // no rawgKeyId/comicvineKeyId configured
|
|
const notices: string[] = [];
|
|
const logs: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
const calledTypes: string[] = [];
|
|
(c as any).makeDeps = (spec: { typeName: string }) => {
|
|
calledTypes.push(spec.typeName);
|
|
return {
|
|
listNotes: async () => [],
|
|
readNote: async () => '',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: fakeSpecDeps(),
|
|
};
|
|
};
|
|
|
|
const origLog = console.log;
|
|
console.log = (msg: string) => {
|
|
logs.push(msg);
|
|
origLog(msg);
|
|
};
|
|
|
|
try {
|
|
await c.syncAll(false, true);
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
|
|
expect(calledTypes.sort()).toEqual(['book', 'comic', 'game', 'manga']);
|
|
const keyNotices = notices.filter(m => m.includes('API key not configured'));
|
|
expect(keyNotices.length).toBe(0);
|
|
const keyLogs = logs.filter(m => m.includes('API key not configured'));
|
|
expect(keyLogs.length).toBe(2);
|
|
expect(keyLogs.some(m => m.includes('RAWG') && m.includes('game'))).toBe(true);
|
|
expect(keyLogs.some(m => m.includes('Comic Vine') && m.includes('comic'))).toBe(true);
|
|
});
|
|
|
|
test('missing RAWG + Comic Vine keys + manual (non-quiet) sync → one notice each for game/comic', async () => {
|
|
const c = new LibraryController(fakePlugin()); // no rawgKeyId/comicvineKeyId configured
|
|
const notices: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
const calledTypes: string[] = [];
|
|
(c as any).makeDeps = (spec: { typeName: string }) => {
|
|
calledTypes.push(spec.typeName);
|
|
return {
|
|
listNotes: async () => [],
|
|
readNote: async () => '',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: fakeSpecDeps(),
|
|
};
|
|
};
|
|
|
|
await c.syncAll(false, false);
|
|
|
|
expect(calledTypes.sort()).toEqual(['book', 'comic', 'game', 'manga']);
|
|
const keyNotices = notices.filter(m => m.includes('API key not configured'));
|
|
expect(keyNotices.length).toBe(2);
|
|
expect(keyNotices.some(m => m.includes('RAWG') && m.includes('game'))).toBe(true);
|
|
expect(keyNotices.some(m => m.includes('Comic Vine') && m.includes('comic'))).toBe(true);
|
|
});
|
|
|
|
test('keys present → no missing-key notices', async () => {
|
|
const c = new LibraryController(fakePlugin({ rawgKeyId: 'rid', comicvineKeyId: 'cvid' }));
|
|
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.syncAll(false, true);
|
|
|
|
expect(notices.filter(m => m.includes('API key not configured')).length).toBe(0);
|
|
});
|
|
|
|
test('single-type sync (game) with missing RAWG key still notifies once and runs', async () => {
|
|
const c = new LibraryController(fakePlugin({ rawgKeyId: '' }));
|
|
const notices: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
let ran = false;
|
|
(c as any).makeDeps = () => {
|
|
ran = true;
|
|
return {
|
|
listNotes: async () => [],
|
|
readNote: async () => '',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: fakeSpecDeps(),
|
|
};
|
|
};
|
|
|
|
await c.syncType(gameSpec, false);
|
|
|
|
expect(ran).toBe(true);
|
|
expect(notices.filter(m => m.includes('API key not configured')).length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('syncAll error isolation (I5)', () => {
|
|
test('first spec throws (e.g. missing vault folder) -> remaining specs still synced, lastSync updated', async () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const notices: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
const calledTypes: string[] = [];
|
|
(c as any).makeDeps = (spec: { typeName: string }) => {
|
|
calledTypes.push(spec.typeName);
|
|
if (spec.typeName === 'manga') {
|
|
return {
|
|
listNotes: async () => {
|
|
throw new Error('Library folder not found: Mangas');
|
|
},
|
|
readNote: async () => '',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: fakeSpecDeps(),
|
|
};
|
|
}
|
|
return {
|
|
listNotes: async () => [],
|
|
readNote: async () => '',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: fakeSpecDeps(),
|
|
};
|
|
};
|
|
|
|
const before = (c as any).plugin.settings.libraryLastSync;
|
|
await c.syncAll(false, false);
|
|
|
|
// manga (first in LIBRARY_TYPES) threw, but book/game/comic still got their turn
|
|
expect(calledTypes.sort()).toEqual(['book', 'comic', 'game', 'manga']);
|
|
expect(notices.some(m => m.includes('Library sync failed for manga'))).toBe(true);
|
|
expect((c as any).plugin.settings.libraryLastSync).toBeGreaterThan(before);
|
|
});
|
|
|
|
test('quiet sync: per-type failure logged only, no Notice', async () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const notices: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
const logs: string[] = [];
|
|
const origLog = console.log;
|
|
console.log = (msg: string) => {
|
|
logs.push(msg);
|
|
};
|
|
(c as any).makeDeps = (spec: { typeName: string }) => ({
|
|
listNotes: async () => {
|
|
if (spec.typeName === 'book') throw new Error('boom');
|
|
return [];
|
|
},
|
|
readNote: async () => '',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: fakeSpecDeps(),
|
|
});
|
|
|
|
try {
|
|
await c.syncAll(false, true);
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
|
|
expect(notices.some(m => m.includes('Library sync failed for book'))).toBe(false);
|
|
expect(logs.some(m => m.includes('Library sync failed for book'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('dry-run notify suppression (minor)', () => {
|
|
test('makeSpecDeps(dryRun=true) -> notify logs instead of raising a Notice', () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const notices: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
const logs: string[] = [];
|
|
const origLog = console.log;
|
|
console.log = (msg: string) => {
|
|
logs.push(msg);
|
|
};
|
|
try {
|
|
const specDeps = (c as any).makeSpecDeps(true);
|
|
specDeps.notify('«Test Manga» ch. 5 out');
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
expect(notices).toEqual([]);
|
|
expect(logs.some(m => m.includes('«Test Manga» ch. 5 out'))).toBe(true);
|
|
});
|
|
|
|
test('makeSpecDeps(dryRun=false) -> notify raises a Notice normally', () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const notices: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
const specDeps = (c as any).makeSpecDeps(false);
|
|
specDeps.notify('«Test Manga» ch. 5 out');
|
|
expect(notices).toEqual(['«Test Manga» ch. 5 out']);
|
|
});
|
|
|
|
test('dry-run manga flip scenario end-to-end: syncType(dryRun=true) -> no Notice, log entry present', async () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const notices: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
const logs: string[] = [];
|
|
const origLog = console.log;
|
|
console.log = (msg: string) => {
|
|
logs.push(msg);
|
|
};
|
|
|
|
const flipSpec = {
|
|
typeName: 'manga' as const,
|
|
itemType: 'manga_item',
|
|
folderSettingKey: 'libraryMangaFolder',
|
|
enabledSettingKey: 'libraryMangaEnabled',
|
|
throttleMs: 0,
|
|
hasId: () => true,
|
|
isActive: () => true,
|
|
resolve: async () => null,
|
|
sync: async (_ctx: unknown, deps: { notify: (msg: string) => void }) => {
|
|
deps.notify('«Test Manga» ch. 5 out');
|
|
return { content: 'updated content', flipped: true };
|
|
},
|
|
};
|
|
|
|
(c as any).makeDeps = (spec: unknown, dryRun: boolean) => ({
|
|
listNotes: async () => [{ path: 'Mangas/Test Manga.md' }],
|
|
readNote: async () => 'orig content',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: (c as any).makeSpecDeps(dryRun),
|
|
});
|
|
|
|
try {
|
|
await c.syncType(flipSpec as any, false, true); // dryRun = true
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
|
|
// the run-summary Notice (dry-run or not) is unrelated existing behavior and still fires;
|
|
// what must NOT happen is the spec's flip-notify reaching a real Notice
|
|
expect(notices.some(m => m.includes('«Test Manga» ch. 5 out'))).toBe(false);
|
|
expect(logs.some(m => m.includes('«Test Manga» ch. 5 out'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('sync summary transparency', () => {
|
|
test('scanned + no-data counts surface in summary notice, not hidden behind a silent-looking 0/0', async () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const notices: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
(c as any).makeDeps = () => ({
|
|
listNotes: async () => [{ path: 'Mangas/Test.md' }],
|
|
readNote: async () => 'content',
|
|
writeNote: async () => {},
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
specDeps: fakeSpecDeps(),
|
|
});
|
|
|
|
// simulates e.g. a Comic Vine error-envelope: fetch "succeeds" (no throw) but spec.sync
|
|
// has no usable data -> counted as skippedNoData rather than a written/errors change
|
|
const noDataSpec: MediaTypeSpec = {
|
|
typeName: 'manga',
|
|
itemType: 'manga_item',
|
|
folderSettingKey: 'libraryMangaFolder',
|
|
enabledSettingKey: 'libraryMangaEnabled',
|
|
throttleMs: 0,
|
|
hasId: () => true,
|
|
isActive: () => true,
|
|
resolve: async () => null,
|
|
sync: async () => null,
|
|
};
|
|
|
|
const report = await c.syncType(noDataSpec, false);
|
|
|
|
expect(report.scanned).toBe(1);
|
|
expect(report.skippedNoData).toBe(1);
|
|
expect(notices.length).toBe(1);
|
|
expect(notices[0]).toContain('1 scanned');
|
|
expect(notices[0]).toContain('0 ok');
|
|
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('per-entry failure isolated: first entry readNote throws in picker phase, second still picked', async () => {
|
|
const c = new LibraryController(fakePlugin());
|
|
const notices: string[] = [];
|
|
(c as any).notify = (msg: string) => notices.push(msg);
|
|
const logs: string[] = [];
|
|
const writes: { path: string; content: string }[] = [];
|
|
const readCounts: Record<string, number> = {};
|
|
(c as any).makeDeps = () => ({
|
|
listNotes: async () => [{ path: 'Mangas/Bad.md' }, { path: 'Mangas/Good.md' }],
|
|
readNote: async (path: string) => {
|
|
readCounts[path] = (readCounts[path] ?? 0) + 1;
|
|
// engine resolve pass reads once; picker phase read is the second call
|
|
if (path === 'Mangas/Bad.md' && readCounts[path] > 1) throw new Error('note vanished');
|
|
return '---\ntype: manga_item\ntitle: X\n---\n\nbody\n';
|
|
},
|
|
writeNote: async (path: string, content: string) => {
|
|
writes.push({ path, content });
|
|
},
|
|
sleep: async () => {},
|
|
log: (m: string) => logs.push(m),
|
|
specDeps: fakeSpecDeps(),
|
|
});
|
|
(c as any).pickCandidate = async () => 0;
|
|
|
|
const report = await c.resolveType(candidateSpec);
|
|
|
|
expect(writes.length).toBe(1);
|
|
expect(writes[0].path).toBe('Mangas/Good.md');
|
|
expect(report.resolved).toEqual(['Mangas/Good.md']);
|
|
expect(logs.some(l => l.includes('picker failed for Bad.md') && l.includes('note vanished'))).toBe(true);
|
|
expect(notices[0]).toContain('1 picked, 1 skipped');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|