feat(library): local canonical conversion for id-less notes

This commit is contained in:
afiqzudinhadi 2026-08-05 20:55:42 +08:00
parent 75db364a1d
commit 8fb16b3220
13 changed files with 630 additions and 7 deletions

View file

@ -15,12 +15,14 @@ interface FakeSpecOptions {
isActive?(fm: Record<string, string>): boolean;
sync?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType<MediaTypeSpec['sync']>;
resolve?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType<MediaTypeSpec['resolve']>;
convertLocal?(ctx: LibraryNoteCtx): string;
throttleMs?: number;
}
function makeFakeSpec(opts: FakeSpecOptions = {}) {
const syncCalls: LibraryNoteCtx[] = [];
const resolveCalls: LibraryNoteCtx[] = [];
const convertLocalCalls: LibraryNoteCtx[] = [];
const spec: MediaTypeSpec = {
typeName: 'manga',
itemType: FAKE_ITEM_TYPE,
@ -38,8 +40,12 @@ function makeFakeSpec(opts: FakeSpecOptions = {}) {
if (opts.sync) return await opts.sync(ctx, deps);
return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false };
},
convertLocal: ctx => {
convertLocalCalls.push(ctx);
return opts.convertLocal ? opts.convertLocal(ctx) : renderFake(ctx.frontmatter, extractMyNotes(ctx.body));
},
};
return { spec, syncCalls, resolveCalls };
return { spec, syncCalls, resolveCalls, convertLocalCalls };
}
const ACTIVE_NOTE = `---
@ -111,6 +117,68 @@ describe('libraryFolderSync: tiering', () => {
});
});
describe('libraryFolderSync: local conversion (no-id notes)', () => {
test('unflagged no-id note: convertLocal differs from content -> written once, convertedLocal++, skippedNoId still counted, sync() never called', async () => {
const { spec, syncCalls } = makeFakeSpec({ convertLocal: () => 'CONVERTED' });
const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
const report = await libraryFolderSync(spec, deps, {});
expect(report.convertedLocal).toBe(1);
expect(report.skippedNoId).toBe(1);
expect(writes.length).toBe(1);
expect(writes[0].content).toBe('CONVERTED');
expect(syncCalls.length).toBe(0);
});
test('idempotence: second pass over already-converted content writes nothing, convertedLocal stays 0', async () => {
const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED' });
const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
const first = await libraryFolderSync(spec, deps, {});
expect(first.convertedLocal).toBe(1);
expect(writes.length).toBe(1);
const second = makeDeps([{ path: 'A.md', content: writes[0].content }]);
const report2 = await libraryFolderSync(spec, second.deps, {});
expect(second.writes.length).toBe(0);
expect(report2.convertedLocal).toBe(0);
});
test('convertLocal output identical to existing content -> no write, convertedLocal stays 0', async () => {
const { spec } = makeFakeSpec({ convertLocal: () => NO_ID_NOTE });
const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
const report = await libraryFolderSync(spec, deps, {});
expect(report.convertedLocal).toBe(0);
expect(writes.length).toBe(0);
});
test('no_resolve-flagged no-id note: still converted+written, but excluded from skippedNoId (no_resolve log line stays)', async () => {
const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED' });
const flagged = NO_ID_NOTE.replace('fake_id: ', 'fake_id: \nno_resolve: true');
const logs: string[] = [];
const { deps, writes } = makeDeps([{ path: 'A.md', content: flagged }]);
deps.log = (m: string) => logs.push(m);
const report = await libraryFolderSync(spec, deps, {});
expect(report.convertedLocal).toBe(1);
expect(report.skippedNoId).toBe(0);
expect(writes.length).toBe(1);
expect(logs.some(l => l.includes('no_resolve flag set, skipping sync: A.md'))).toBe(true);
});
test('dryRun: convertedLocal counted but no writeNote call', async () => {
const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED' });
const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
const report = await libraryFolderSync(spec, deps, { dryRun: true });
expect(report.convertedLocal).toBe(1);
expect(writes.length).toBe(0);
});
test('no-id note does not sleep spec.throttleMs (no network involved)', async () => {
const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED', throttleMs: 777 });
const { deps, slept } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]);
await libraryFolderSync(spec, deps, {});
expect(slept).not.toContain(777);
});
});
describe('libraryFolderSync: diff-on-write + dryRun', () => {
test('diff-on-write: second pass on rendered output writes nothing', async () => {
const { spec } = makeFakeSpec();