From 7cb68658f5ac3f0483ed061f03bde6852ccf8d63 Mon Sep 17 00:00:00 2001 From: wujunchen Date: Mon, 27 Apr 2026 15:21:03 +0800 Subject: [PATCH] refactor: split modules.test.js into 12 per-module test files Replaces the monolithic modules.test.js (983 lines) with focused per-module test files sharing a common test-setup.js. Each file is under 225 lines. All existing test assertions preserved. New files: test-setup.js, anchor.test.js, schema.test.js, cache.test.js, cards-nav.test.js, markdown.test.js, vault-batch.test.js, i18n.test.js, scroll.test.js, settings.test.js, providers.test.js, streaming.test.js, cli.test.js Change-Id: I37a3342a1588807277e679e6535cde361e650565 --- package.json | 2 +- tests/anchor.test.js | 22 + tests/cache.test.js | 80 ++++ tests/cards-nav.test.js | 44 ++ tests/cli.test.js | 99 ++++ tests/i18n.test.js | 33 ++ tests/markdown.test.js | 20 + tests/modules.test.js | 983 -------------------------------------- tests/providers.test.js | 225 +++++++++ tests/schema.test.js | 91 ++++ tests/scroll.test.js | 17 + tests/settings.test.js | 67 +++ tests/streaming.test.js | 98 ++++ tests/test-setup.js | 61 +++ tests/vault-batch.test.js | 87 ++++ 15 files changed, 945 insertions(+), 984 deletions(-) create mode 100644 tests/anchor.test.js create mode 100644 tests/cache.test.js create mode 100644 tests/cards-nav.test.js create mode 100644 tests/cli.test.js create mode 100644 tests/i18n.test.js create mode 100644 tests/markdown.test.js delete mode 100644 tests/modules.test.js create mode 100644 tests/providers.test.js create mode 100644 tests/schema.test.js create mode 100644 tests/scroll.test.js create mode 100644 tests/settings.test.js create mode 100644 tests/streaming.test.js create mode 100644 tests/test-setup.js create mode 100644 tests/vault-batch.test.js diff --git a/package.json b/package.json index 2d98d1b..2d57797 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit", "lint": "biome check main.ts src/", "lint:fix": "biome check --write main.ts src/", - "test": "npm run build && npm run typecheck && node tests/main.test.js && node tests/generation-job-manager.test.js && node tests/modules.test.js && node tests/direct-modules.test.js" + "test": "npm run build && npm run typecheck && node tests/main.test.js && node tests/generation-job-manager.test.js && node tests/anchor.test.js && node tests/schema.test.js && node tests/cache.test.js && node tests/cards-nav.test.js && node tests/markdown.test.js && node tests/vault-batch.test.js && node tests/i18n.test.js && node tests/scroll.test.js && node tests/settings.test.js && node tests/providers.test.js && node tests/streaming.test.js && node tests/cli.test.js && node tests/direct-modules.test.js" }, "devDependencies": { "@biomejs/biome": "^2.4.13", diff --git a/tests/anchor.test.js b/tests/anchor.test.js new file mode 100644 index 0000000..25f1201 --- /dev/null +++ b/tests/anchor.test.js @@ -0,0 +1,22 @@ +const { assert, t } = require('./test-setup'); + +assert.strictEqual(t.findLineForAnchor('', 'hello'), -1, 'empty content returns -1'); +assert.strictEqual(t.findLineForAnchor('hello world', ''), -1, 'empty anchor returns -1'); +assert.strictEqual(t.findLineForAnchor('hello world', 'hello world'), 0, 'exact full match on line 0'); +assert.strictEqual(t.findLineForAnchor('line0\nline1\nhello', 'hello'), 2, 'exact match on line 2'); +assert.strictEqual(t.findLineForAnchor('line0\nline1\nhello world', ' hello world '), 2, 'trimmed match'); +assert.strictEqual( + t.findLineForAnchor('intro\nThe quick brown fox jumps over the lazy dog\nend', 'The quick brown fox jumps'), + 1, 'prefix match at 60-char threshold' +); +assert.strictEqual( + t.findLineForAnchor('a\nb\nc\nd\ne\nAlpha beta\nGamma\tDelta\nlast', 'Alpha beta Gamma Delta'), + 5, 'normalized whitespace match returns correct line' +); +assert.strictEqual(t.findLineForAnchor('hello\nworld', 'zzz_not_found'), -1, 'unmatched anchor returns -1'); +assert.strictEqual( + t.findLineForAnchor('first line\nsecond with 日本語 text\nthird', '日本語'), + 1, 'unicode anchor match' +); + +console.log('anchor tests passed'); diff --git a/tests/cache.test.js b/tests/cache.test.js new file mode 100644 index 0000000..ed97a9d --- /dev/null +++ b/tests/cache.test.js @@ -0,0 +1,80 @@ +const { assert, t } = require('./test-setup'); + +// ── touchCacheEntry / serializeCacheFile / shouldConfirmRegenerate ── + +assert.strictEqual(t.touchCacheEntry(null), null, 'touchCacheEntry on null returns null'); +const entry = { generatedAt: '2024-01-01T00:00:00.000Z' }; +const touched = t.touchCacheEntry(entry, '2024-06-01T00:00:00.000Z'); +assert.strictEqual(touched.lastAccessedAt, '2024-06-01T00:00:00.000Z', 'touchCacheEntry sets lastAccessedAt on returned entry'); +assert.strictEqual(entry.lastAccessedAt, undefined, 'touchCacheEntry does not mutate original entry'); + +const serialized = t.serializeCacheFile({ 'a.md': { cards: [] } }); +const parsed = JSON.parse(serialized); +assert.strictEqual(parsed.version, 1, 'cache file has version 1'); +assert.ok(parsed.entries['a.md'], 'cache file has entries'); +assert.strictEqual(serialized.includes('\n'), false, 'cache file is single line'); + +assert.strictEqual(t.shouldConfirmRegenerate(null, true), false, 'null entry never confirms'); +assert.strictEqual(t.shouldConfirmRegenerate(null, false), false); +assert.strictEqual(t.shouldConfirmRegenerate({ generatedAt: '2024-01-01' }, true), false, 'no updatedAt => no confirm'); +assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-01' }, false), false, 'force=false => no confirm'); +assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-01' }, true), true, 'edited + force => confirm'); +assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: ' ' }, true), false, 'whitespace updatedAt => no confirm'); + +// ── CacheManager.move ── + +async function testCacheManagerMove() { + const writes = []; + const adapter = { + exists: async () => true, + mkdir: async () => {}, + read: async () => '{}', + write: async (filePath, content) => writes.push({ filePath, content }), + }; + const manager = new t.CacheManager(adapter, '.obsidian', 'parallel-reader', () => ({ + maxCacheEntries: 100, + })); + const cacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z', cards: [{ title: 'Card' }] }; + manager.cache = { + 'old.md': cacheEntry, + 'other.md': { generatedAt: '2024-01-02T00:00:00.000Z', cards: [] }, + }; + + assert.strictEqual(await manager.move('missing.md', 'new.md'), false, 'missing cache move returns false'); + assert.strictEqual(await manager.move('missing.md', 'missing.md'), false, 'missing same-path move returns false'); + assert.strictEqual(await manager.move(' ', 'new.md'), false, 'blank source path is rejected'); + assert.strictEqual(await manager.move('old.md', ' '), false, 'blank target path is rejected'); + assert.strictEqual(await manager.move('old.md', 'old.md'), true, 'same-path move is a no-op success'); + assert.strictEqual(await manager.move('old.md', 'other.md'), false, 'move does not overwrite an existing target path'); + assert.strictEqual(writes.length, 0, 'no-op and rejected cache moves are not persisted'); + assert.strictEqual(await manager.move('old.md', 'new.md'), true, 'existing cache move returns true'); + assert.strictEqual(manager.cache['old.md'], undefined, 'old cache path is removed'); + assert.deepStrictEqual(manager.cache['new.md'], cacheEntry, 'cache entry is moved to new path'); + assert.ok(manager.cache['other.md'], 'unrelated cache entries remain'); + assert.strictEqual(writes.length, 1, 'successful cache move is persisted once'); + assert.ok(JSON.parse(writes[0].content).entries['new.md'], 'persisted cache uses moved path'); +} + +// ── pruneCacheEntries ── + +const cache = { + 'a.md': { generatedAt: '2024-01-01' }, + 'b.md': { generatedAt: '2024-01-03' }, + 'c.md': { generatedAt: '2024-01-02', lastAccessedAt: '2024-01-05' }, +}; +const removed = t.pruneCacheEntries(cache, 2); +assert.deepStrictEqual(removed, ['a.md'], 'removes oldest by lastAccessedAt/generatedAt'); +assert.strictEqual(Object.keys(cache).length, 2, 'cache pruned to max'); +assert.ok(!cache['a.md'], 'oldest removed'); +assert.ok(cache['b.md'] && cache['c.md'], 'newest kept'); + +const smallCache = { 'x.md': { generatedAt: '2024-01-01' } }; +assert.deepStrictEqual(t.pruneCacheEntries(smallCache, 10), [], 'nothing pruned when under limit'); + +(async () => { + await testCacheManagerMove(); + console.log('cache tests passed'); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/cards-nav.test.js b/tests/cards-nav.test.js new file mode 100644 index 0000000..0f6471e --- /dev/null +++ b/tests/cards-nav.test.js @@ -0,0 +1,44 @@ +const { assert, t } = require('./test-setup'); + +// ── cards.ts ── + +const cards = [{ title: 'A' }, { title: 'B' }, { title: 'C' }]; +assert.deepStrictEqual(t.removeCardAt(cards, 0), [{ title: 'B' }, { title: 'C' }], 'remove first'); +assert.deepStrictEqual(t.removeCardAt(cards, 2), [{ title: 'A' }, { title: 'B' }], 'remove last'); +assert.strictEqual(t.removeCardAt(cards, 0).length, 2, 'returns new array'); +assert.strictEqual(cards.length, 3, 'original not mutated'); +assert.deepStrictEqual(t.removeCardAt([], 0), [], 'empty array'); +assert.deepStrictEqual(t.removeCardAt(cards, -1).length, 3, 'negative index no-op'); +assert.deepStrictEqual(t.removeCardAt(cards, 5).length, 3, 'out of range no-op'); + +assert.strictEqual(t.activeIndexAfterCardDelete(0, 3, 0), 0, 'delete first active -> stays 0'); +assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 2), 1, 'delete last active -> previous'); +assert.strictEqual(t.activeIndexAfterCardDelete(0, 1, 0), -1, 'single card delete -> -1'); +assert.strictEqual(t.activeIndexAfterCardDelete(1, 5, 3), 2, 'delete before active -> shift left'); +assert.strictEqual(t.activeIndexAfterCardDelete(3, 5, 1), 1, 'delete after active -> no change'); + +const updated = t.updateCardAt(cards, 1, { title: 'B2', gist: 'new gist' }); +assert.strictEqual(updated[1].title, 'B2'); +assert.strictEqual(updated[1].gist, 'new gist'); +assert.strictEqual(updated[0].title, 'A', 'other cards unchanged'); +assert.strictEqual(cards[1].title, 'B', 'original not mutated'); + +// ── navigation.ts ── + +assert.strictEqual(t.nextCardIndex(-1, 5, 1), 0, 'from none forward -> first'); +assert.strictEqual(t.nextCardIndex(-1, 5, -1), 4, 'from none backward -> last'); +assert.strictEqual(t.nextCardIndex(0, 5, 1), 1, 'step forward'); +assert.strictEqual(t.nextCardIndex(4, 5, 1), 4, 'clamp at end'); +assert.strictEqual(t.nextCardIndex(0, 5, -1), 0, 'clamp at start'); +assert.strictEqual(t.nextCardIndex(2, 5, -1), 1, 'step backward'); +assert.strictEqual(t.nextCardIndex(0, 0, 1), -1, 'empty list'); +assert.strictEqual(t.nextCardIndex(0, -1, 1), -1, 'negative count'); + +assert.strictEqual(t.activeSectionLine([{ startLine: 10 }], 0), 10); +assert.strictEqual(t.activeSectionLine([{ startLine: -1 }], 0), -1, 'negative startLine'); +assert.strictEqual(t.activeSectionLine([], 0), -1, 'empty sections'); +assert.strictEqual(t.activeSectionLine([{ startLine: 5 }], -1), -1, 'negative index'); +assert.strictEqual(t.activeSectionLine([{ startLine: 5 }], 1), -1, 'out of range index'); +assert.strictEqual(t.activeSectionLine(null, 0), -1, 'null sections'); + +console.log('cards-nav tests passed'); diff --git a/tests/cli.test.js b/tests/cli.test.js new file mode 100644 index 0000000..0dd3fba --- /dev/null +++ b/tests/cli.test.js @@ -0,0 +1,99 @@ +const { assert, t, EventEmitter } = require('./test-setup'); + +// ── resolveCliPath ── + +assert.strictEqual(t.resolveCliPath('claude', ' /usr/bin/claude '), '/usr/bin/claude', 'trims override path'); +assert.strictEqual(t.resolveCliPath('claude', '/custom/path'), '/custom/path', 'returns custom path unchanged'); + +const fsModule = require('fs'); +const origExistsSync = fsModule.existsSync; + +const mockPath = require('path').join(require('os').homedir(), '.local', 'bin', 'claude'); +fsModule.existsSync = (p) => p === mockPath; +try { + const resolved = t.resolveCliPath('claude', ''); + assert.strictEqual(resolved, mockPath, 'resolveCliPath finds binary in mocked ~/.local/bin'); +} finally { + fsModule.existsSync = origExistsSync; +} + +fsModule.existsSync = () => false; +try { + const fallback = t.resolveCliPath('claude', ''); + assert.strictEqual(fallback, 'claude', 'resolveCliPath falls back to bare name when not found'); +} finally { + fsModule.existsSync = origExistsSync; +} + +// ── runCli edge cases ── + +function createFakeChild() { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { + written: '', + ended: false, + write(value) { this.written += value; }, + end() { this.ended = true; }, + }; + child.killedWith = null; + child.kill = (signal) => { child.killedWith = signal; return true; }; + return child; +} + +async function testRunCliEdgeCases() { + const timeoutChild = createFakeChild(); + await assert.rejects( + () => t.runCli('fake', [], '', 5, undefined, () => timeoutChild), + /CLI timed out \(5ms\)/, + 'runCli rejects on timeout', + ); + assert.strictEqual(timeoutChild.killedWith, 'SIGKILL', 'runCli kills timed out processes'); + + const cancelChild = createFakeChild(); + let cancelHandler = null; + const cancelPromise = t.runCli( + 'fake', + [], + '', + 1000, + { key: 'cancel.md', onCancel: (handler) => { cancelHandler = handler; } }, + () => cancelChild, + ); + cancelHandler(); + await assert.rejects( + () => cancelPromise, + (err) => err && err.code === 'cancelled' && err.key === 'cancel.md', + 'runCli rejects with GenerationJobCancelledError on cancellation', + ); + assert.strictEqual(cancelChild.killedWith, 'SIGKILL', 'runCli kills cancelled processes'); + + await assert.rejects( + () => t.runCli('fake', [], '', 100, undefined, () => ({ stdout: null, stderr: null, stdin: null })), + /CLI process streams are unavailable/, + 'runCli reports unavailable stdio streams', + ); +} + +// ── classifyGenerationError ── + +assert.strictEqual(t.classifyGenerationError(new Error('API key 未设置')), 'auth'); +assert.strictEqual(t.classifyGenerationError(new Error('API key is not set')), 'auth'); +assert.strictEqual(t.classifyGenerationError(new Error('CLI timed out (120000ms)')), 'timeout'); +assert.strictEqual(t.classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout'); +assert.strictEqual(t.classifyGenerationError(new Error('API returned HTTP 429')), 'rate-limit'); +assert.strictEqual(t.classifyGenerationError(new Error('json_schema validation failed')), 'schema'); +assert.strictEqual(t.classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema'); +assert.strictEqual(t.classifyGenerationError(new Error('bad config value')), 'config'); +assert.strictEqual(t.classifyGenerationError(new Error('random error')), 'unknown'); +assert.strictEqual(t.classifyGenerationError(null), 'unknown', 'null error'); +assert.strictEqual(t.classifyGenerationError('string error'), 'unknown', 'string error'); + +(async () => { + await testRunCliEdgeCases(); + console.log('cli tests passed'); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/i18n.test.js b/tests/i18n.test.js new file mode 100644 index 0000000..a466d7d --- /dev/null +++ b/tests/i18n.test.js @@ -0,0 +1,33 @@ +const { assert, t } = require('./test-setup'); + +assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'appTitle'), '对照阅读笔记', 'zh translation'); +assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'appTitle'), 'Parallel Reader', 'en translation'); +assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'nonexistent_key'), 'nonexistent_key', 'missing key returns key'); +assert.strictEqual( + t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 5 }), + 'Cleared 5 cache entries', + 'variable interpolation' +); +assert.strictEqual( + t.translate({ uiLanguage: 'en' }, 'exported', { path: 'foo/bar.md' }), + 'Exported → foo/bar.md', + 'path variable' +); +assert.strictEqual( + t.translate(null, 'appTitle'), + 'Parallel Reader', + 'null settings defaults to en in Node.js (no navigator)' +); +assert.strictEqual( + t.translate({ uiLanguage: 'en' }, 'generationDone', { count: 3, suffix: '' }), + 'Generated 3 sections', + 'variable interpolation with multiple vars', +); +assert.strictEqual( + t.translate({ uiLanguage: 'zh' }, 'appTitle'), + '对照阅读笔记', + 'zh translation for appTitle', +); +assert.strictEqual(t.translate({ uiLanguage: 'en' }, '__no_such_key__'), '__no_such_key__', 'missing key returns key'); + +console.log('i18n tests passed'); diff --git a/tests/markdown.test.js b/tests/markdown.test.js new file mode 100644 index 0000000..a148554 --- /dev/null +++ b/tests/markdown.test.js @@ -0,0 +1,20 @@ +const { assert, t } = require('./test-setup'); + +const md = t.cardsToMarkdown('Test Title', [ + { title: 'Section 1', anchor: 'original quote', gist: 'Summary', bullets: ['point a', 'point b'] }, + { title: 'Section 2', gist: 'Gist only', bullets: [] }, +]); +assert.ok(md.includes('# Test Title'), 'top-level heading'); +assert.ok(md.includes('## Section 1'), 'card heading'); +assert.ok(md.includes('> original quote'), 'anchor blockquote'); +assert.ok(md.includes('Summary'), 'gist included'); +assert.ok(md.includes('- point a'), 'bullet list'); +assert.ok(md.includes('## Section 2'), 'second card'); +assert.ok(!md.includes('> \n'), 'no empty blockquote for card without anchor'); + +assert.strictEqual(t.cardsToMarkdown('', []), '# 对照笔记', 'empty title uses default'); + +const plain = t.cardsToMarkdown('X', []); +assert.ok(plain.includes('# X')); + +console.log('markdown tests passed'); diff --git a/tests/modules.test.js b/tests/modules.test.js deleted file mode 100644 index 1fcb34e..0000000 --- a/tests/modules.test.js +++ /dev/null @@ -1,983 +0,0 @@ -const assert = require('assert'); -const { EventEmitter } = require('events'); -const Module = require('module'); - -const originalLoad = Module._load; -let requestUrlMock = async () => ({ status: 200, json: {}, text: '{}' }); -Module._load = function load(request, parent, isMain) { - if (request === 'obsidian') { - class Plugin {} - class ItemView { constructor(leaf) { this.leaf = leaf; this.containerEl = { children: [{}, {}] }; } } - class PluginSettingTab {} - class Setting {} - class Notice {} - class MarkdownView {} - class TFile {} - class Menu {} - class Modal {} - return { - Plugin, ItemView, PluginSettingTab, Setting, Notice, MarkdownView, TFile, Menu, Modal, - MarkdownRenderer: { render: async () => {} }, - requestUrl: (params) => requestUrlMock(params), - setIcon: () => {}, - }; - } - return originalLoad.call(this, request, parent, isMain); -}; - -const t = require('../main.js').__test; - -function openAiCardsResponse(cards) { - const json = { - choices: [{ - message: { - content: JSON.stringify({ cards }), - }, - }], - }; - return { status: 200, json, text: JSON.stringify(json) }; -} - -// ── anchor.ts ── - -assert.strictEqual(t.findLineForAnchor('', 'hello'), -1, 'empty content returns -1'); -assert.strictEqual(t.findLineForAnchor('hello world', ''), -1, 'empty anchor returns -1'); -assert.strictEqual(t.findLineForAnchor('hello world', 'hello world'), 0, 'exact full match on line 0'); -assert.strictEqual(t.findLineForAnchor('line0\nline1\nhello', 'hello'), 2, 'exact match on line 2'); -assert.strictEqual(t.findLineForAnchor('line0\nline1\nhello world', ' hello world '), 2, 'trimmed match'); -assert.strictEqual( - t.findLineForAnchor('intro\nThe quick brown fox jumps over the lazy dog\nend', 'The quick brown fox jumps'), - 1, 'prefix match at 60-char threshold' -); -assert.strictEqual( - t.findLineForAnchor('a\nb\nc\nd\ne\nAlpha beta\nGamma\tDelta\nlast', 'Alpha beta Gamma Delta'), - 5, 'normalized whitespace match returns correct line' -); -assert.strictEqual(t.findLineForAnchor('hello\nworld', 'zzz_not_found'), -1, 'unmatched anchor returns -1'); -assert.strictEqual( - t.findLineForAnchor('first line\nsecond with 日本語 text\nthird', '日本語'), - 1, 'unicode anchor match' -); - -// ── schema.ts: collectJsonObjectCandidates & extractJson ── - -const { extractJson, normalizeCardsPayload } = t; - -assert.strictEqual(extractJson(''), '', 'empty text returns empty'); -assert.strictEqual(extractJson('{"a":1}'), '{"a":1}', 'already-valid JSON returned as-is'); -assert.strictEqual( - JSON.parse(extractJson('prefix {"cards":[]} suffix')).cards.length, - 0, 'extracts JSON from surrounding text' -); -assert.strictEqual( - JSON.parse(extractJson('```json\n{"cards":[]}\n```')).cards.length, - 0, 'extracts JSON from fenced code block' -); -assert.strictEqual( - JSON.parse(extractJson('text {"a":1} and {"cards":[{"title":"T"}]} end')).cards[0].title, - 'T', 'picks the largest valid JSON object' -); -assert.strictEqual(extractJson('not json at all'), 'not json at all', 'non-JSON text returned as-is'); - -// extractJson with nested braces in strings -const nestedBraces = '{"cards":[{"title":"test {with} braces","anchor":"a","gist":"g","bullets":[]}]}'; -assert.deepStrictEqual(JSON.parse(extractJson(nestedBraces)).cards[0].title, 'test {with} braces'); - -// ── schema.ts: repairTruncatedCardsJson ── - -const { repairTruncatedCardsJson } = t; - -assert.strictEqual(repairTruncatedCardsJson('not json'), null, 'non-cards text returns null'); -assert.strictEqual(repairTruncatedCardsJson(''), null, 'empty string returns null'); -assert.strictEqual(repairTruncatedCardsJson('{"cards":['), null, 'no complete cards returns null'); - -const truncJson = '{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"x","gist":"trunc'; -const repaired = repairTruncatedCardsJson(truncJson); -assert.ok(repaired, 'truncated JSON is repaired'); -const repairedParsed = JSON.parse(repaired); -assert.strictEqual(repairedParsed.cards.length, 1, 'only complete card is kept'); -assert.strictEqual(repairedParsed.cards[0].title, 'A', 'salvaged card has correct title'); - -const twoComplete = '{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"a2","gist":"g2","bullets":["c"]},{"title":"C","anch'; -const repairedTwo = JSON.parse(repairTruncatedCardsJson(twoComplete)); -assert.strictEqual(repairedTwo.cards.length, 2, 'two complete cards salvaged from three'); - -// repairTruncatedCardsJson + normalizeCardsPayload integration -const salvagedCards = normalizeCardsPayload(JSON.parse(repairTruncatedCardsJson(truncJson))); -assert.strictEqual(salvagedCards.length, 1, 'repair + normalize produces valid cards'); -assert.strictEqual(salvagedCards[0].title, 'A', 'salvaged card has correct fields'); - -// repairTruncatedCardsJson edge cases -// Whitespace variations in the "cards":[ pattern -const spaced = '{ "cards" : [ {"title":"A","anchor":"a","gist":"g","bullets":[]},{"title":"B","anch'; -const repairedSpaced = repairTruncatedCardsJson(spaced); -assert.ok(repairedSpaced, 'repair handles whitespace in cards pattern'); -assert.strictEqual(JSON.parse(repairedSpaced).cards.length, 1, 'repair with whitespace keeps complete card'); - -// Cards with escaped quotes in string values -const escapedQuotes = '{"cards":[{"title":"A \\"quoted\\"","anchor":"a","gist":"g","bullets":["line \\"1\\""]},{"title":"B","anch'; -const repairedEscaped = repairTruncatedCardsJson(escapedQuotes); -assert.ok(repairedEscaped, 'repair handles escaped quotes in values'); -assert.strictEqual(JSON.parse(repairedEscaped).cards[0].title, 'A "quoted"', 'escaped quotes preserved'); - -// Cards with braces in string values -const bracesInStrings = '{"cards":[{"title":"func() { return }","anchor":"a","gist":"{obj}","bullets":[]},{"incomp'; -const repairedBraces = repairTruncatedCardsJson(bracesInStrings); -assert.ok(repairedBraces, 'repair handles braces inside strings'); -assert.strictEqual(JSON.parse(repairedBraces).cards[0].title, 'func() { return }', 'braces in string preserved'); - -// All cards truncated (only partial objects) -assert.strictEqual(repairTruncatedCardsJson('{"cards":[{"title":"incomp'), null, 'all truncated cards returns null'); - -// Empty cards array with truncation -assert.strictEqual(repairTruncatedCardsJson('{"cards":[]'), null, 'empty truncated array returns null'); - -// collectJsonObjectCandidates edge cases -const { collectJsonObjectCandidates } = t; -assert.deepStrictEqual(collectJsonObjectCandidates(''), [], 'empty string gives no candidates'); -assert.deepStrictEqual(collectJsonObjectCandidates('no braces'), [], 'no braces gives no candidates'); -assert.deepStrictEqual(collectJsonObjectCandidates('{"a":1}'), ['{"a":1}'], 'single object extracted'); -assert.deepStrictEqual(collectJsonObjectCandidates('{"a":1} {"b":2}'), ['{"a":1}', '{"b":2}'], 'multiple objects extracted'); -assert.deepStrictEqual(collectJsonObjectCandidates('{"a":{"b":1}}'), ['{"a":{"b":1}}'], 'nested objects extracted as one'); -assert.deepStrictEqual(collectJsonObjectCandidates('{"a":"{}"}'), ['{"a":"{}"}'], 'braces in strings handled correctly'); -assert.deepStrictEqual(collectJsonObjectCandidates('{unclosed'), [], 'unclosed brace gives no candidates'); - -// ── schema.ts: normalizeCardsPayload ── - -assert.deepStrictEqual(normalizeCardsPayload(null), [], 'null payload returns empty array'); -assert.deepStrictEqual(normalizeCardsPayload({}), [], 'empty object returns empty array'); -assert.deepStrictEqual(normalizeCardsPayload({ cards: [null, undefined, 42, 'str'] }), [], 'non-object items filtered'); -assert.deepStrictEqual( - normalizeCardsPayload({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] }), - [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }], - 'valid card passes through' -); -assert.deepStrictEqual( - normalizeCardsPayload({ cards: [{ title: 123, gist: null, bullets: [1, 'valid', null] }] }), - [{ title: '(无标题)', anchor: '', gist: '', bullets: ['valid'] }], - 'non-string fields get defaults, non-string bullets filtered' -); - -// ── cache.ts ── - -assert.strictEqual(t.touchCacheEntry(null), null, 'touchCacheEntry on null returns null'); -const entry = { generatedAt: '2024-01-01T00:00:00.000Z' }; -const touched = t.touchCacheEntry(entry, '2024-06-01T00:00:00.000Z'); -assert.strictEqual(touched.lastAccessedAt, '2024-06-01T00:00:00.000Z', 'touchCacheEntry sets lastAccessedAt on returned entry'); -assert.strictEqual(entry.lastAccessedAt, undefined, 'touchCacheEntry does not mutate original entry'); - -const serialized = t.serializeCacheFile({ 'a.md': { cards: [] } }); -const parsed = JSON.parse(serialized); -assert.strictEqual(parsed.version, 1, 'cache file has version 1'); -assert.ok(parsed.entries['a.md'], 'cache file has entries'); -assert.strictEqual(serialized.includes('\n'), false, 'cache file is single line'); - -async function testCacheManagerMove() { - const writes = []; - const adapter = { - exists: async () => true, - mkdir: async () => {}, - read: async () => '{}', - write: async (filePath, content) => writes.push({ filePath, content }), - }; - const manager = new t.CacheManager(adapter, '.obsidian', 'parallel-reader', () => ({ - maxCacheEntries: 100, - })); - const cacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z', cards: [{ title: 'Card' }] }; - manager.cache = { - 'old.md': cacheEntry, - 'other.md': { generatedAt: '2024-01-02T00:00:00.000Z', cards: [] }, - }; - - assert.strictEqual(await manager.move('missing.md', 'new.md'), false, 'missing cache move returns false'); - assert.strictEqual(await manager.move('missing.md', 'missing.md'), false, 'missing same-path move returns false'); - assert.strictEqual(await manager.move(' ', 'new.md'), false, 'blank source path is rejected'); - assert.strictEqual(await manager.move('old.md', ' '), false, 'blank target path is rejected'); - assert.strictEqual(await manager.move('old.md', 'old.md'), true, 'same-path move is a no-op success'); - assert.strictEqual(await manager.move('old.md', 'other.md'), false, 'move does not overwrite an existing target path'); - assert.strictEqual(writes.length, 0, 'no-op and rejected cache moves are not persisted'); - assert.strictEqual(await manager.move('old.md', 'new.md'), true, 'existing cache move returns true'); - assert.strictEqual(manager.cache['old.md'], undefined, 'old cache path is removed'); - assert.deepStrictEqual(manager.cache['new.md'], cacheEntry, 'cache entry is moved to new path'); - assert.ok(manager.cache['other.md'], 'unrelated cache entries remain'); - assert.strictEqual(writes.length, 1, 'successful cache move is persisted once'); - assert.ok(JSON.parse(writes[0].content).entries['new.md'], 'persisted cache uses moved path'); -} - -assert.strictEqual(t.shouldConfirmRegenerate(null, true), false, 'null entry never confirms'); -assert.strictEqual(t.shouldConfirmRegenerate(null, false), false); -assert.strictEqual(t.shouldConfirmRegenerate({ generatedAt: '2024-01-01' }, true), false, 'no updatedAt => no confirm'); -assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-01' }, false), false, 'force=false => no confirm'); -assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-01' }, true), true, 'edited + force => confirm'); -assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: ' ' }, true), false, 'whitespace updatedAt => no confirm'); - -// ── cards.ts ── - -const cards = [{ title: 'A' }, { title: 'B' }, { title: 'C' }]; -assert.deepStrictEqual(t.removeCardAt(cards, 0), [{ title: 'B' }, { title: 'C' }], 'remove first'); -assert.deepStrictEqual(t.removeCardAt(cards, 2), [{ title: 'A' }, { title: 'B' }], 'remove last'); -assert.strictEqual(t.removeCardAt(cards, 0).length, 2, 'returns new array'); -assert.strictEqual(cards.length, 3, 'original not mutated'); -assert.deepStrictEqual(t.removeCardAt([], 0), [], 'empty array'); -assert.deepStrictEqual(t.removeCardAt(cards, -1).length, 3, 'negative index no-op'); -assert.deepStrictEqual(t.removeCardAt(cards, 5).length, 3, 'out of range no-op'); - -assert.strictEqual(t.activeIndexAfterCardDelete(0, 3, 0), 0, 'delete first active -> stays 0'); -assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 2), 1, 'delete last active -> previous'); -assert.strictEqual(t.activeIndexAfterCardDelete(0, 1, 0), -1, 'single card delete -> -1'); -assert.strictEqual(t.activeIndexAfterCardDelete(1, 5, 3), 2, 'delete before active -> shift left'); -assert.strictEqual(t.activeIndexAfterCardDelete(3, 5, 1), 1, 'delete after active -> no change'); - -const updated = t.updateCardAt(cards, 1, { title: 'B2', gist: 'new gist' }); -assert.strictEqual(updated[1].title, 'B2'); -assert.strictEqual(updated[1].gist, 'new gist'); -assert.strictEqual(updated[0].title, 'A', 'other cards unchanged'); -assert.strictEqual(cards[1].title, 'B', 'original not mutated'); - -// ── navigation.ts ── - -assert.strictEqual(t.nextCardIndex(-1, 5, 1), 0, 'from none forward -> first'); -assert.strictEqual(t.nextCardIndex(-1, 5, -1), 4, 'from none backward -> last'); -assert.strictEqual(t.nextCardIndex(0, 5, 1), 1, 'step forward'); -assert.strictEqual(t.nextCardIndex(4, 5, 1), 4, 'clamp at end'); -assert.strictEqual(t.nextCardIndex(0, 5, -1), 0, 'clamp at start'); -assert.strictEqual(t.nextCardIndex(2, 5, -1), 1, 'step backward'); -assert.strictEqual(t.nextCardIndex(0, 0, 1), -1, 'empty list'); -assert.strictEqual(t.nextCardIndex(0, -1, 1), -1, 'negative count'); - -assert.strictEqual(t.activeSectionLine([{ startLine: 10 }], 0), 10); -assert.strictEqual(t.activeSectionLine([{ startLine: -1 }], 0), -1, 'negative startLine'); -assert.strictEqual(t.activeSectionLine([], 0), -1, 'empty sections'); -assert.strictEqual(t.activeSectionLine([{ startLine: 5 }], -1), -1, 'negative index'); -assert.strictEqual(t.activeSectionLine([{ startLine: 5 }], 1), -1, 'out of range index'); -assert.strictEqual(t.activeSectionLine(null, 0), -1, 'null sections'); - -// ── markdown.ts ── - -const md = t.cardsToMarkdown('Test Title', [ - { title: 'Section 1', anchor: 'original quote', gist: 'Summary', bullets: ['point a', 'point b'] }, - { title: 'Section 2', gist: 'Gist only', bullets: [] }, -]); -assert.ok(md.includes('# Test Title'), 'top-level heading'); -assert.ok(md.includes('## Section 1'), 'card heading'); -assert.ok(md.includes('> original quote'), 'anchor blockquote'); -assert.ok(md.includes('Summary'), 'gist included'); -assert.ok(md.includes('- point a'), 'bullet list'); -assert.ok(md.includes('## Section 2'), 'second card'); -assert.ok(!md.includes('> \n'), 'no empty blockquote for card without anchor'); - -assert.strictEqual(t.cardsToMarkdown('', []), '# 对照笔记', 'empty title uses default'); - -// cardToPlain -const plain = t.cardsToMarkdown('X', []); -assert.ok(plain.includes('# X')); - -// ── vault.ts ── - -assert.deepStrictEqual(t.folderPathsForTarget(''), [], 'empty path'); -assert.deepStrictEqual(t.folderPathsForTarget('A/B/C'), ['A', 'A/B', 'A/B/C']); -assert.deepStrictEqual(t.folderPathsForTarget('/A//B/'), ['A', 'A/B'], 'normalizes slashes'); -assert.deepStrictEqual(t.folderPathsForTarget('single'), ['single']); -assert.deepStrictEqual(t.folderPathsForTarget('../../etc/passwd'), ['etc', 'etc/passwd'], 'strips .. path traversal'); -assert.deepStrictEqual(t.folderPathsForTarget('./a/../b'), ['a', 'a/b'], 'strips . and .. segments'); -assert.deepStrictEqual(t.folderPathsForTarget('a/../../b'), ['a', 'a/b'], 'strips mid-path ..'); - -// ── batch.ts ── - -assert.strictEqual(t.normalizeBatchFolderInput('/Reading//Articles/'), 'Reading/Articles', 'normalizes folder input'); -assert.strictEqual(t.normalizeBatchFolderInput('../Inbox/./Daily'), 'Inbox/Daily', 'strips unsafe folder segments'); -assert.strictEqual(t.hasUnsafeBatchFolderSegments('../Inbox/./Daily'), true, 'unsafe folder segments are detected'); -assert.strictEqual(t.hasUnsafeBatchFolderSegments('/Reading//Articles/'), false, 'normal folder paths are safe'); -assert.deepStrictEqual( - t.validateBatchFolderInput('', () => false), - { valid: true, reason: 'ok', folderPath: '' }, - 'empty batch folder targets vault root', -); -assert.deepStrictEqual( - t.validateBatchFolderInput('Reading', (path) => path === 'Reading'), - { valid: true, reason: 'ok', folderPath: 'Reading' }, - 'existing folder input is valid', -); -assert.deepStrictEqual( - t.validateBatchFolderInput('../Reading', () => true), - { valid: false, reason: 'unsafe', folderPath: 'Reading' }, - 'unsafe folder input is invalid even if normalized target exists', -); -assert.deepStrictEqual( - t.validateBatchFolderInput('Missing', () => false), - { valid: false, reason: 'missing', folderPath: 'Missing' }, - 'missing folder input is invalid', -); -const batchFiles = [ - { path: 'root.md', parent: null }, - { path: 'Reading/note.md', parent: { path: 'Reading' } }, - { path: 'Reading/Deep/nested.md', parent: { path: 'Reading/Deep' } }, -]; -assert.deepStrictEqual(t.selectBatchFiles(batchFiles, '').map((file) => file.path), ['root.md'], 'root folder only'); -assert.deepStrictEqual( - t.selectBatchFiles(batchFiles, '/Reading/').map((file) => file.path), - ['Reading/note.md'], - 'selected folder is non-recursive', -); -assert.deepStrictEqual(t.batchProgressVars(1, 3), { current: 2, total: 3 }, 'progress vars are one-based'); -const batchStats = t.createBatchStats(3); -const skippedStats = t.recordBatchSkip(batchStats); -const processedStats = t.recordBatchProcessed(skippedStats); -const errorStats = t.recordBatchError(processedStats); -assert.deepStrictEqual(batchStats, { total: 3, processed: 0, skipped: 0, errors: 0 }, 'batch stats are immutable'); -assert.deepStrictEqual(skippedStats, { total: 3, processed: 1, skipped: 1, errors: 0 }, 'batch skip updates progress'); -assert.deepStrictEqual(processedStats, { total: 3, processed: 2, skipped: 1, errors: 0 }, 'batch processed count accumulates'); -assert.deepStrictEqual(errorStats, { total: 3, processed: 3, skipped: 1, errors: 1 }, 'batch error count accumulates'); - -const batchState = t.createBatchRunState(); -assert.deepStrictEqual(batchState, { cancelled: false, currentPath: '' }, 'batch state starts idle'); -assert.strictEqual(t.markBatchFileRunning(batchState, 'Reading/a.md'), batchState, 'batch state updates in place'); -assert.strictEqual(batchState.currentPath, 'Reading/a.md', 'batch state records current file'); -assert.strictEqual(t.requestBatchCancel(batchState), true, 'first batch cancellation request succeeds'); -assert.strictEqual(batchState.cancelled, true, 'batch cancellation flag is set'); -assert.strictEqual(t.requestBatchCancel(batchState), false, 'duplicate batch cancellation request is ignored'); -assert.strictEqual(t.requestBatchCancel(null), false, 'missing batch state cannot be cancelled'); - -// Batch state isolation: two concurrent batches don't interfere -const batchA = t.createBatchRunState(); -const batchB = t.createBatchRunState(); -t.markBatchFileRunning(batchA, 'folderA/note.md'); -t.markBatchFileRunning(batchB, 'folderB/note.md'); -assert.strictEqual(batchA.currentPath, 'folderA/note.md', 'batch A tracks its own file'); -assert.strictEqual(batchB.currentPath, 'folderB/note.md', 'batch B tracks its own file'); -t.requestBatchCancel(batchA); -assert.strictEqual(batchA.cancelled, true, 'batch A cancelled'); -assert.strictEqual(batchB.cancelled, false, 'batch B unaffected by A cancellation'); - -// Batch cancellation mid-iteration: stats accumulate correctly -const midCancelStats = t.createBatchStats(5); -const s1 = t.recordBatchProcessed(midCancelStats); -const s2 = t.recordBatchSkip(s1); -// "cancelled" at this point — no more processing, stats frozen -assert.strictEqual(s2.processed, 2, 'two files processed before cancel'); -assert.strictEqual(s2.skipped, 1, 'one skipped before cancel'); -assert.strictEqual(s2.errors, 0, 'no errors before cancel'); -assert.strictEqual(s2.total, 5, 'total unchanged by partial processing'); - -// ── i18n.ts ── - -assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'appTitle'), '对照阅读笔记', 'zh translation'); -assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'appTitle'), 'Parallel Reader', 'en translation'); -assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'nonexistent_key'), 'nonexistent_key', 'missing key returns key'); -assert.strictEqual( - t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 5 }), - 'Cleared 5 cache entries', - 'variable interpolation' -); -assert.strictEqual( - t.translate({ uiLanguage: 'en' }, 'exported', { path: 'foo/bar.md' }), - 'Exported → foo/bar.md', - 'path variable' -); -// null settings -> resolveUiLanguage falls back to navigator.language; in Node.js there's no navigator, so defaults to 'en' -assert.strictEqual( - t.translate(null, 'appTitle'), - 'Parallel Reader', - 'null settings defaults to en in Node.js (no navigator)' -); - -// ── scroll.ts ── - -assert.strictEqual(t.visibleTopProbeY({ top: 0, height: 0 }), 0, 'zero rect'); -assert.strictEqual(t.visibleTopProbeY(null), 0, 'null rect'); -assert.strictEqual(t.visibleTopProbeY({ top: 50, height: 500 }), 100, 'standard rect: min(80, 500*0.1)=50, 50+50=100'); -assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 300 }), 130, 'small rect offset 10%'); -assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 1200 }), 180, 'large rect capped at 80'); - -// createRafThrottledHandler already tested in main.test.js, test cancel behavior -const frames = []; -const throttled = t.createRafThrottledHandler(() => {}, cb => { frames.push(cb); return frames.length; }); -throttled(); -assert.strictEqual(frames.length, 1); -throttled.cancel(); -throttled(); -assert.strictEqual(frames.length, 2, 'new frame after cancel'); - -// ── settings.ts ── - -const baseSettings = { - backend: 'api', - apiProvider: 'openai', - apiFormat: 'openai-chat', - apiBaseUrl: 'https://api.openai.com/v1', - apiAuthType: 'bearer', - apiKey: 'test-key', - apiMaxTokens: 4096, - model: 'openai/gpt-5.1', -}; - -// modelForApi strips provider prefix -assert.strictEqual(t.modelForApi({ ...baseSettings, model: 'openai/gpt-5.1' }), 'gpt-5.1', 'strip openai prefix'); -assert.strictEqual(t.modelForApi({ ...baseSettings, model: 'gpt-5.1' }), 'gpt-5.1', 'no prefix pass-through'); -assert.throws(() => t.modelForApi({ ...baseSettings, model: '' }), /Model/, 'empty model throws'); - -// getApiBaseUrl -assert.strictEqual( - t.getApiBaseUrl({ ...baseSettings, apiBaseUrl: 'https://custom.ai/v1/' }), - 'https://custom.ai/v1', - 'strips trailing slash' -); -assert.throws( - () => t.getApiBaseUrl({ ...baseSettings, apiProvider: 'custom-openai', apiBaseUrl: '' }), - /Custom provider/, - 'custom provider without url throws' -); - -// generationFingerprint stability -const fp1 = t.generationFingerprint(baseSettings); -const fp2 = t.generationFingerprint({ ...baseSettings }); -assert.strictEqual(fp1, fp2, 'same settings = same fingerprint'); -assert.notStrictEqual(fp1, t.generationFingerprint({ ...baseSettings, model: 'other' }), 'different model = different fp'); -assert.notStrictEqual(fp1, t.generationFingerprint({ ...baseSettings, apiMaxTokens: 8192 }), 'different tokens = different fp'); -assert.strictEqual(t.normalizeStreamingTimeoutMs('30000'), 30000, 'streaming timeout accepts numeric strings'); -assert.strictEqual(t.normalizeStreamingTimeoutMs(999), 120000, 'streaming timeout below minimum falls back'); -assert.strictEqual(t.normalizeStreamingTimeoutMs('bad'), 120000, 'streaming timeout rejects non-numeric values'); -assert.strictEqual( - t.normalizeSettings({ ...baseSettings, streamingTimeoutMs: 500 }).streamingTimeoutMs, - 120000, - 'normalizeSettings protects invalid streaming timeout values', -); - -// normalizeSettings does not mutate its input -const settingsInput = { ...baseSettings, streamingTimeoutMs: 500, minCards: -1, maxCards: 999 }; -const settingsInputCopy = JSON.parse(JSON.stringify(settingsInput)); -const normalized = t.normalizeSettings(settingsInput); -assert.deepStrictEqual(settingsInput, settingsInputCopy, 'normalizeSettings does not mutate input object'); -assert.notStrictEqual(normalized, settingsInput, 'normalizeSettings returns a new object'); -assert.strictEqual(normalized.minCards, 1, 'normalizeSettings clamps minCards'); -assert.strictEqual(normalized.maxCards, 30, 'normalizeSettings clamps maxCards to max 30'); - -// cacheEntryMatches -const crypto = require('crypto'); -const hash = crypto.createHash('sha1').update('test', 'utf8').digest('hex'); -assert.strictEqual( - t.cacheEntryMatches({ - schemaVersion: t.CACHE_SCHEMA_VERSION, - contentHash: hash, - settingsHash: t.generationFingerprint(baseSettings), - }, 'test', baseSettings), - true, 'matching entry returns true' -); -assert.strictEqual(t.cacheEntryMatches(null, 'test', baseSettings), false, 'null entry returns false'); -assert.strictEqual( - t.shouldSkipBatchFile({ - schemaVersion: t.CACHE_SCHEMA_VERSION, - contentHash: hash, - settingsHash: t.generationFingerprint(baseSettings), - }, 'test', baseSettings), - true, - 'fresh cache entry is skipped during batch', -); -assert.strictEqual(t.shouldSkipBatchFile(null, 'test', baseSettings), false, 'missing cache entry is not skipped'); - -async function testSummarizeDocumentAnchorSorting() { - const previousRequestUrlMock = requestUrlMock; - requestUrlMock = async () => - openAiCardsResponse([ - { title: 'Second', anchor: 'second anchor', gist: 'G2', bullets: ['B2'] }, - { title: 'First', anchor: 'first anchor', gist: 'G1', bullets: ['B1'] }, - ]); - - let sections; - try { - sections = await t.summarizeDocument( - 'intro\nfirst anchor\nmiddle\nsecond anchor\nend', - { - ...baseSettings, - streaming: false, - promptLanguage: 'en', - minCards: 1, - maxCards: 3, - maxDocChars: 10000, - }, - { phase: 'queued', onCancel: () => {}, throwIfCancelled: () => {}, setPhase: () => {} }, - ); - } finally { - requestUrlMock = previousRequestUrlMock; - } - - assert.deepStrictEqual( - sections.map((section) => section.title), - ['First', 'Second'], - 'summarizeDocument sorts API cards by resolved anchor line', - ); - assert.deepStrictEqual( - sections.map((section) => section.startLine), - [1, 3], - 'summarizeDocument resolves anchor lines from source content', - ); -} - -// pruneCacheEntries -const cache = { - 'a.md': { generatedAt: '2024-01-01' }, - 'b.md': { generatedAt: '2024-01-03' }, - 'c.md': { generatedAt: '2024-01-02', lastAccessedAt: '2024-01-05' }, -}; -const removed = t.pruneCacheEntries(cache, 2); -assert.deepStrictEqual(removed, ['a.md'], 'removes oldest by lastAccessedAt/generatedAt'); -assert.strictEqual(Object.keys(cache).length, 2, 'cache pruned to max'); -assert.ok(!cache['a.md'], 'oldest removed'); -assert.ok(cache['b.md'] && cache['c.md'], 'newest kept'); - -// pruneCacheEntries with no pruning needed -const smallCache = { 'x.md': { generatedAt: '2024-01-01' } }; -assert.deepStrictEqual(t.pruneCacheEntries(smallCache, 10), [], 'nothing pruned when under limit'); - -// ── prompt.ts ── - -const enPrompt = t.buildPrompts('Hello world', { - ...baseSettings, - promptLanguage: 'en', - minCards: 3, - maxCards: 7, -}); -assert.ok(enPrompt.system.includes('3-7'), 'card range in system prompt'); -assert.ok(enPrompt.system.includes('English'), 'language instruction in english prompt'); -assert.ok(enPrompt.user.includes('Source document:'), 'english user prompt prefix'); -assert.ok(enPrompt.user.includes('Hello world'), 'content in user prompt'); - -const zhPrompt = t.buildPrompts('你好世界', { - ...baseSettings, - promptLanguage: 'zh', - minCards: 2, - maxCards: 5, -}); -assert.ok(zhPrompt.system.includes('2-5'), 'zh card range'); -assert.ok(zhPrompt.system.includes('中文'), 'chinese language instruction'); -assert.ok(zhPrompt.user.includes('你好世界'), 'zh content'); - -// custom prompt with variables -const customPrompt = t.buildPrompts('doc', { - ...baseSettings, - promptLanguage: 'en', - minCards: 1, - maxCards: 3, - customSystemPrompt: 'Generate {minCards} to {maxCards} cards. {languageInstruction}', -}); -assert.ok(customPrompt.system.includes('Generate 1 to 3 cards'), 'custom prompt variable substitution'); -assert.ok(customPrompt.system.includes('Non-overridable output contract'), 'contract appended to custom prompt'); - -// truncation -const longDoc = 'x'.repeat(25000); -const truncated = t.buildPrompts(longDoc, { - ...baseSettings, - promptLanguage: 'en', - minCards: 1, - maxCards: 3, - maxDocChars: 20000, -}); -assert.ok(truncated.user.includes('[Document truncated]'), 'truncation marker'); -assert.ok(truncated.user.length < 25000, 'content actually truncated'); - -// ── providers.ts body builders ── - -// OpenAI Chat body -const chatBody = t.buildOpenAiChatBody('sys', 'usr', { ...baseSettings, apiMaxTokens: 200 }); -assert.strictEqual(chatBody.model, 'gpt-5.1', 'model stripped'); -assert.strictEqual(chatBody.messages.length, 2, 'system + user messages'); -assert.ok(chatBody.response_format, 'has structured output'); - -const chatUnstructured = t.buildOpenAiChatBody('sys', 'usr', baseSettings, { structured: false }); -assert.strictEqual(chatUnstructured.response_format, undefined, 'no structured output when disabled'); - -// Anthropic Messages body -const anthBody = t.buildAnthropicMessagesBody('sys', 'usr', { - ...baseSettings, - apiProvider: 'anthropic', - apiFormat: 'anthropic-messages', - model: 'claude-sonnet-4-6', -}); -assert.ok(anthBody.tools, 'has tools for structured output'); -assert.strictEqual(anthBody.tools[0].name, 'record_parallel_reader_cards'); -assert.strictEqual(anthBody.system, 'sys', 'system prompt set'); - -// OpenAI Responses body -const respBody = t.buildOpenAiResponsesBody('sys', 'usr', baseSettings); -assert.strictEqual(respBody.instructions, 'sys', 'instructions set'); -assert.strictEqual(respBody.input, 'usr', 'input set'); -assert.ok(respBody.text, 'has text format'); - -// Gemini body -const gemBody = t.buildGeminiBody('sys', 'usr', { - ...baseSettings, - apiProvider: 'google', - apiFormat: 'google-generative-ai', - model: 'gemini-3-pro-preview', -}); -assert.strictEqual(gemBody.systemInstruction.parts[0].text, 'sys'); -assert.strictEqual(gemBody.contents[0].parts[0].text, 'usr'); -assert.strictEqual(gemBody.generationConfig.responseMimeType, 'application/json'); - -// tokenLimitFieldForOpenAiChat -assert.strictEqual( - t.tokenLimitFieldForOpenAiChat({ ...baseSettings, apiProvider: 'openai' }), - 'max_completion_tokens', - 'OpenAI uses max_completion_tokens' -); -assert.strictEqual( - t.tokenLimitFieldForOpenAiChat({ ...baseSettings, apiProvider: 'openrouter' }), - 'max_tokens', - 'OpenRouter uses max_tokens' -); - -// ── generation-job-manager.ts: classifyGenerationError ── - -assert.strictEqual(t.classifyGenerationError(new Error('API key 未设置')), 'auth'); -assert.strictEqual(t.classifyGenerationError(new Error('API key is not set')), 'auth'); -assert.strictEqual(t.classifyGenerationError(new Error('CLI timed out (120000ms)')), 'timeout'); -assert.strictEqual(t.classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout'); -assert.strictEqual(t.classifyGenerationError(new Error('API returned HTTP 429')), 'rate-limit'); -assert.strictEqual(t.classifyGenerationError(new Error('json_schema validation failed')), 'schema'); -assert.strictEqual(t.classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema'); -assert.strictEqual(t.classifyGenerationError(new Error('bad config value')), 'config'); -assert.strictEqual(t.classifyGenerationError(new Error('random error')), 'unknown'); -assert.strictEqual(t.classifyGenerationError(null), 'unknown', 'null error'); -assert.strictEqual(t.classifyGenerationError('string error'), 'unknown', 'string error'); - -// ── streaming.ts ── - -// supportsStreaming -assert.strictEqual( - t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'openai-chat' }), - true, 'OpenAI Chat supports streaming' -); -assert.strictEqual( - t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'anthropic-messages', apiProvider: 'anthropic' }), - true, 'Anthropic Messages supports streaming' -); -assert.strictEqual( - t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'google-generative-ai', apiProvider: 'google' }), - false, 'Gemini does not support streaming' -); -assert.strictEqual( - t.supportsStreaming({ ...baseSettings, streaming: false, apiFormat: 'openai-chat' }), - false, 'streaming disabled returns false' -); - -// deltaExtractorForFormat -const openaiExtract = t.deltaExtractorForFormat('openai-chat'); -assert.ok(openaiExtract, 'openai-chat extractor exists'); -assert.strictEqual( - openaiExtract({ choices: [{ delta: { content: 'hello' } }] }), - 'hello', 'extracts OpenAI delta' -); -assert.strictEqual(openaiExtract({ choices: [{ delta: {} }] }), '', 'empty delta'); -assert.strictEqual(openaiExtract({}), '', 'missing choices'); - -const anthropicExtract = t.deltaExtractorForFormat('anthropic-messages'); -assert.ok(anthropicExtract, 'anthropic extractor exists'); -assert.strictEqual( - anthropicExtract({ type: 'content_block_delta', delta: { text: 'world' } }), - 'world', 'extracts Anthropic delta' -); -assert.strictEqual( - anthropicExtract({ type: 'message_start' }), - '', 'non-delta event returns empty' -); - -assert.strictEqual(t.deltaExtractorForFormat('google-generative-ai'), null, 'no extractor for gemini'); - -// parseSseBuffer -const sseResult = t.parseSseBuffer( - 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: {"choices":[{"delta":{"content":" there"}}]}\n\ndata: [DONE]\n\npartial', - openaiExtract, -); -assert.deepStrictEqual(sseResult.deltas, ['hi', ' there'], 'SSE parser extracts deltas'); -assert.strictEqual(sseResult.rest, 'partial', 'SSE parser keeps partial line'); - -const sseEmpty = t.parseSseBuffer('', openaiExtract); -assert.deepStrictEqual(sseEmpty.deltas, []); -assert.strictEqual(sseEmpty.rest, ''); - -// Anthropic SSE format -const anthSse = t.parseSseBuffer( - 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"token"}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n', - anthropicExtract, -); -assert.deepStrictEqual(anthSse.deltas, ['token'], 'Anthropic SSE extracts text delta'); - -// parseSseBuffer: partial line (no trailing newline → stays in rest) -const ssePartial = t.parseSseBuffer('data: {"choices":[{"delta":{"content":"ok"}}]}', openaiExtract); -assert.deepStrictEqual(ssePartial.deltas, [], 'incomplete line yields no deltas'); -assert.ok(ssePartial.rest.includes('"ok"'), 'partial line kept in rest'); - -// parseSseBuffer: multi-event in one chunk -const sseMulti = t.parseSseBuffer( - 'data: {"choices":[{"delta":{"content":"a"}}]}\n\ndata: {"choices":[{"delta":{"content":"b"}}]}\n\ndata: {"choices":[{"delta":{"content":"c"}}]}\n\n', - openaiExtract, -); -assert.deepStrictEqual(sseMulti.deltas, ['a', 'b', 'c'], 'three events in one chunk'); -assert.strictEqual(sseMulti.rest, '', 'nothing left in rest when chunk ends with newline'); - -const sseMultilineData = t.parseSseBuffer( - 'data: {"choices":[\ndata: {"delta":{"content":"joined"}}\ndata: ]}\n\n', - openaiExtract, -); -assert.deepStrictEqual(sseMultilineData.deltas, ['joined'], 'consecutive data lines are merged into one event'); - -const ssePartialEvent = t.parseSseBuffer( - 'data: {"choices":[{"delta":{"content":"wait"}}]}\n', - openaiExtract, -); -assert.deepStrictEqual(ssePartialEvent.deltas, [], 'event without blank-line terminator is retained'); -assert.strictEqual(ssePartialEvent.rest.includes('"wait"'), true, 'partial event remains in rest'); - -// parseSseBuffer: non-data lines are skipped -const sseMixed = t.parseSseBuffer( - ': comment\nevent: ping\ndata: {"choices":[{"delta":{"content":"x"}}]}\n\n', - openaiExtract, -); -assert.deepStrictEqual(sseMixed.deltas, ['x'], 'comment and event lines ignored'); - -// parseSseBuffer: malformed JSON is silently skipped -const sseBad = t.parseSseBuffer('data: not_json\n\ndata: {"choices":[{"delta":{"content":"y"}}]}\n\n', openaiExtract); -assert.deepStrictEqual(sseBad.deltas, ['y'], 'bad JSON line skipped, good line extracted'); - -// i18n: additional edge cases -assert.strictEqual( - t.translate({ uiLanguage: 'en' }, 'generationDone', { count: 3, suffix: '' }), - 'Generated 3 sections', - 'variable interpolation with multiple vars', -); -assert.strictEqual( - t.translate({ uiLanguage: 'zh' }, 'appTitle'), - '对照阅读笔记', - 'zh translation for appTitle', -); -// missing key fallback chain: should return the key itself -assert.strictEqual(t.translate({ uiLanguage: 'en' }, '__no_such_key__'), '__no_such_key__', 'missing key returns key'); - -// ── parseApiHeaders ── - -// JSON object input -assert.deepStrictEqual( - t.parseApiHeaders('{"X-Custom": "value", "Authorization": "Bearer tok"}'), - { 'X-Custom': 'value', 'Authorization': 'Bearer tok' }, - 'parseApiHeaders: JSON object input', -); - -// Empty string -assert.deepStrictEqual(t.parseApiHeaders(''), {}, 'parseApiHeaders: empty string returns empty object'); -assert.deepStrictEqual(t.parseApiHeaders(' '), {}, 'parseApiHeaders: whitespace-only returns empty object'); - -// Line-based input -assert.deepStrictEqual( - t.parseApiHeaders('X-Custom: value\nAuthorization: Bearer tok'), - { 'X-Custom': 'value', 'Authorization': 'Bearer tok' }, - 'parseApiHeaders: line-based input', -); - -// Line-based with comments and blank lines -assert.deepStrictEqual( - t.parseApiHeaders('# comment\nX-Key: val\n\n# another\nY-Key: val2'), - { 'X-Key': 'val', 'Y-Key': 'val2' }, - 'parseApiHeaders: comments and blank lines skipped', -); - -// Malformed JSON throws -assert.throws( - () => t.parseApiHeaders('{ invalid json }', baseSettings), - /parse failed|JSON/i, - 'parseApiHeaders: malformed JSON throws', -); - -// JSON array falls to line-based parsing and throws format error -assert.throws( - () => t.parseApiHeaders('[1, 2, 3]', baseSettings), - /Header.*value|format/i, - 'parseApiHeaders: JSON array input throws line format error', -); - -// Line-based missing colon throws -assert.throws( - () => t.parseApiHeaders('InvalidHeader', baseSettings), - /Header.*value|format/i, - 'parseApiHeaders: missing colon throws', -); - -// JSON with non-string values filters them out -assert.deepStrictEqual( - t.parseApiHeaders('{"valid": "yes", "number": 42, "null": null}'), - { valid: 'yes' }, - 'parseApiHeaders: non-string values filtered from JSON', -); - -// Line-based with value containing colons -assert.deepStrictEqual( - t.parseApiHeaders('Authorization: Bearer abc:def:ghi'), - { 'Authorization': 'Bearer abc:def:ghi' }, - 'parseApiHeaders: value with colons preserved', -); - -// ── cli.ts: resolveCliPath ── - -// Override path: should return the trimmed override immediately -assert.strictEqual(t.resolveCliPath('claude', ' /usr/bin/claude '), '/usr/bin/claude', 'trims override path'); -assert.strictEqual(t.resolveCliPath('claude', '/custom/path'), '/custom/path', 'returns custom path unchanged'); - -// Empty override: falls back to filesystem search; mock fs.existsSync to control behavior -const fsModule = require('fs'); -const origExistsSync = fsModule.existsSync; - -// Mock existsSync to pretend a specific path exists -const mockPath = require('path').join(require('os').homedir(), '.local', 'bin', 'claude'); -fsModule.existsSync = (p) => p === mockPath; -try { - const resolved = t.resolveCliPath('claude', ''); - assert.strictEqual(resolved, mockPath, 'resolveCliPath finds binary in mocked ~/.local/bin'); -} finally { - fsModule.existsSync = origExistsSync; -} - -// No match: falls back to bare name -fsModule.existsSync = () => false; -try { - const fallback = t.resolveCliPath('claude', ''); - assert.strictEqual(fallback, 'claude', 'resolveCliPath falls back to bare name when not found'); -} finally { - fsModule.existsSync = origExistsSync; -} - -function createFakeChild() { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.stdin = { - written: '', - ended: false, - write(value) { - this.written += value; - }, - end() { - this.ended = true; - }, - }; - child.killedWith = null; - child.kill = (signal) => { - child.killedWith = signal; - return true; - }; - return child; -} - -async function testRunCliEdgeCases() { - const timeoutChild = createFakeChild(); - await assert.rejects( - () => t.runCli('fake', [], '', 5, undefined, () => timeoutChild), - /CLI timed out \(5ms\)/, - 'runCli rejects on timeout', - ); - assert.strictEqual(timeoutChild.killedWith, 'SIGKILL', 'runCli kills timed out processes'); - - const cancelChild = createFakeChild(); - let cancelHandler = null; - const cancelPromise = t.runCli( - 'fake', - [], - '', - 1000, - { key: 'cancel.md', onCancel: (handler) => { cancelHandler = handler; } }, - () => cancelChild, - ); - cancelHandler(); - await assert.rejects( - () => cancelPromise, - (err) => err && err.code === 'cancelled' && err.key === 'cancel.md', - 'runCli rejects with GenerationJobCancelledError on cancellation', - ); - assert.strictEqual(cancelChild.killedWith, 'SIGKILL', 'runCli kills cancelled processes'); - - await assert.rejects( - () => t.runCli('fake', [], '', 100, undefined, () => ({ stdout: null, stderr: null, stdin: null })), - /CLI process streams are unavailable/, - 'runCli reports unavailable stdio streams', - ); -} - -// Wrap the tail in an async runner so module-level tests can include async cases. -async function testStructuredOutputFallback() { - const previousRequestUrlMock = requestUrlMock; - let callCount = 0; - requestUrlMock = async (params) => { - callCount++; - if (callCount === 1) { - // First call: structured output rejected with 400 - return { - status: 400, - json: { error: 'response_format is not supported' }, - text: JSON.stringify({ error: 'response_format is not supported' }), - }; - } - // Second call: fallback succeeds - return openAiCardsResponse([ - { title: 'Fallback', anchor: 'fallback anchor', gist: 'G', bullets: ['B'] }, - ]); - }; - - try { - const sections = await t.summarizeDocument( - 'intro\nfallback anchor\nend', - { - ...baseSettings, - streaming: false, - promptLanguage: 'en', - minCards: 1, - maxCards: 3, - maxDocChars: 10000, - }, - { phase: 'queued', onCancel: () => {}, throwIfCancelled: () => {}, setPhase: () => {} }, - ); - assert.strictEqual(callCount, 2, 'structured output fallback made two requests'); - assert.strictEqual(sections[0].title, 'Fallback', 'fallback response parsed correctly'); - } finally { - requestUrlMock = previousRequestUrlMock; - } -} - -async function testStructuredOutputNonRetryableError() { - const previousRequestUrlMock = requestUrlMock; - requestUrlMock = async () => ({ - status: 500, - json: { error: 'Internal server error' }, - text: JSON.stringify({ error: 'Internal server error' }), - }); - - try { - await assert.rejects( - () => t.summarizeDocument( - 'test content', - { - ...baseSettings, - streaming: false, - promptLanguage: 'en', - minCards: 1, - maxCards: 3, - maxDocChars: 10000, - }, - { phase: 'queued', onCancel: () => {}, throwIfCancelled: () => {}, setPhase: () => {} }, - ), - /500/, - 'non-retryable error (500) is not caught by structured fallback', - ); - } finally { - requestUrlMock = previousRequestUrlMock; - } -} - -(async () => { - await testRunCliEdgeCases(); - await testCacheManagerMove(); - await testSummarizeDocumentAnchorSorting(); - await testStructuredOutputFallback(); - await testStructuredOutputNonRetryableError(); - console.log('modules tests passed'); -})().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/tests/providers.test.js b/tests/providers.test.js new file mode 100644 index 0000000..3e84f4c --- /dev/null +++ b/tests/providers.test.js @@ -0,0 +1,225 @@ +const { assert, t, baseSettings, openAiCardsResponse, setRequestUrlMock } = require('./test-setup'); + +// ── prompt.ts ── + +const enPrompt = t.buildPrompts('Hello world', { + ...baseSettings, + promptLanguage: 'en', + minCards: 3, + maxCards: 7, +}); +assert.ok(enPrompt.system.includes('3-7'), 'card range in system prompt'); +assert.ok(enPrompt.system.includes('English'), 'language instruction in english prompt'); +assert.ok(enPrompt.user.includes('Source document:'), 'english user prompt prefix'); +assert.ok(enPrompt.user.includes('Hello world'), 'content in user prompt'); + +const zhPrompt = t.buildPrompts('你好世界', { + ...baseSettings, + promptLanguage: 'zh', + minCards: 2, + maxCards: 5, +}); +assert.ok(zhPrompt.system.includes('2-5'), 'zh card range'); +assert.ok(zhPrompt.system.includes('中文'), 'chinese language instruction'); +assert.ok(zhPrompt.user.includes('你好世界'), 'zh content'); + +const customPrompt = t.buildPrompts('doc', { + ...baseSettings, + promptLanguage: 'en', + minCards: 1, + maxCards: 3, + customSystemPrompt: 'Generate {minCards} to {maxCards} cards. {languageInstruction}', +}); +assert.ok(customPrompt.system.includes('Generate 1 to 3 cards'), 'custom prompt variable substitution'); +assert.ok(customPrompt.system.includes('Non-overridable output contract'), 'contract appended to custom prompt'); + +const longDoc = 'x'.repeat(25000); +const truncated = t.buildPrompts(longDoc, { + ...baseSettings, + promptLanguage: 'en', + minCards: 1, + maxCards: 3, + maxDocChars: 20000, +}); +assert.ok(truncated.user.includes('[Document truncated]'), 'truncation marker'); +assert.ok(truncated.user.length < 25000, 'content actually truncated'); + +// ── provider body builders ── + +const chatBody = t.buildOpenAiChatBody('sys', 'usr', { ...baseSettings, apiMaxTokens: 200 }); +assert.strictEqual(chatBody.model, 'gpt-5.1', 'model stripped'); +assert.strictEqual(chatBody.messages.length, 2, 'system + user messages'); +assert.ok(chatBody.response_format, 'has structured output'); + +const chatUnstructured = t.buildOpenAiChatBody('sys', 'usr', baseSettings, { structured: false }); +assert.strictEqual(chatUnstructured.response_format, undefined, 'no structured output when disabled'); + +const anthBody = t.buildAnthropicMessagesBody('sys', 'usr', { + ...baseSettings, + apiProvider: 'anthropic', + apiFormat: 'anthropic-messages', + model: 'claude-sonnet-4-6', +}); +assert.ok(anthBody.tools, 'has tools for structured output'); +assert.strictEqual(anthBody.tools[0].name, 'record_parallel_reader_cards'); +assert.strictEqual(anthBody.system, 'sys', 'system prompt set'); + +const respBody = t.buildOpenAiResponsesBody('sys', 'usr', baseSettings); +assert.strictEqual(respBody.instructions, 'sys', 'instructions set'); +assert.strictEqual(respBody.input, 'usr', 'input set'); +assert.ok(respBody.text, 'has text format'); + +const gemBody = t.buildGeminiBody('sys', 'usr', { + ...baseSettings, + apiProvider: 'google', + apiFormat: 'google-generative-ai', + model: 'gemini-3-pro-preview', +}); +assert.strictEqual(gemBody.systemInstruction.parts[0].text, 'sys'); +assert.strictEqual(gemBody.contents[0].parts[0].text, 'usr'); +assert.strictEqual(gemBody.generationConfig.responseMimeType, 'application/json'); + +assert.strictEqual( + t.tokenLimitFieldForOpenAiChat({ ...baseSettings, apiProvider: 'openai' }), + 'max_completion_tokens', + 'OpenAI uses max_completion_tokens' +); +assert.strictEqual( + t.tokenLimitFieldForOpenAiChat({ ...baseSettings, apiProvider: 'openrouter' }), + 'max_tokens', + 'OpenRouter uses max_tokens' +); + +// ── parseApiHeaders ── + +assert.deepStrictEqual( + t.parseApiHeaders('{"X-Custom": "value", "Authorization": "Bearer tok"}'), + { 'X-Custom': 'value', 'Authorization': 'Bearer tok' }, + 'parseApiHeaders: JSON object input', +); +assert.deepStrictEqual(t.parseApiHeaders(''), {}, 'parseApiHeaders: empty string returns empty object'); +assert.deepStrictEqual(t.parseApiHeaders(' '), {}, 'parseApiHeaders: whitespace-only returns empty object'); +assert.deepStrictEqual( + t.parseApiHeaders('X-Custom: value\nAuthorization: Bearer tok'), + { 'X-Custom': 'value', 'Authorization': 'Bearer tok' }, + 'parseApiHeaders: line-based input', +); +assert.deepStrictEqual( + t.parseApiHeaders('# comment\nX-Key: val\n\n# another\nY-Key: val2'), + { 'X-Key': 'val', 'Y-Key': 'val2' }, + 'parseApiHeaders: comments and blank lines skipped', +); +assert.throws( + () => t.parseApiHeaders('{ invalid json }', baseSettings), + /parse failed|JSON/i, + 'parseApiHeaders: malformed JSON throws', +); +assert.throws( + () => t.parseApiHeaders('[1, 2, 3]', baseSettings), + /Header.*value|format/i, + 'parseApiHeaders: JSON array input throws line format error', +); +assert.throws( + () => t.parseApiHeaders('InvalidHeader', baseSettings), + /Header.*value|format/i, + 'parseApiHeaders: missing colon throws', +); +assert.deepStrictEqual( + t.parseApiHeaders('{"valid": "yes", "number": 42, "null": null}'), + { valid: 'yes' }, + 'parseApiHeaders: non-string values filtered from JSON', +); +assert.deepStrictEqual( + t.parseApiHeaders('Authorization: Bearer abc:def:ghi'), + { 'Authorization': 'Bearer abc:def:ghi' }, + 'parseApiHeaders: value with colons preserved', +); + +// ── summarizeDocument ── + +async function testSummarizeDocumentAnchorSorting() { + const prev = require('./test-setup').getRequestUrlMock(); + setRequestUrlMock(async () => + openAiCardsResponse([ + { title: 'Second', anchor: 'second anchor', gist: 'G2', bullets: ['B2'] }, + { title: 'First', anchor: 'first anchor', gist: 'G1', bullets: ['B1'] }, + ]), + ); + + let sections; + try { + sections = await t.summarizeDocument( + 'intro\nfirst anchor\nmiddle\nsecond anchor\nend', + { ...baseSettings, streaming: false, promptLanguage: 'en', minCards: 1, maxCards: 3, maxDocChars: 10000 }, + { phase: 'queued', onCancel: () => {}, throwIfCancelled: () => {}, setPhase: () => {} }, + ); + } finally { + setRequestUrlMock(prev); + } + + assert.deepStrictEqual(sections.map((s) => s.title), ['First', 'Second'], 'sorts by anchor line'); + assert.deepStrictEqual(sections.map((s) => s.startLine), [1, 3], 'resolves anchor lines'); +} + +async function testStructuredOutputFallback() { + const prev = require('./test-setup').getRequestUrlMock(); + let callCount = 0; + setRequestUrlMock(async () => { + callCount++; + if (callCount === 1) { + return { + status: 400, + json: { error: 'response_format is not supported' }, + text: JSON.stringify({ error: 'response_format is not supported' }), + }; + } + return openAiCardsResponse([ + { title: 'Fallback', anchor: 'fallback anchor', gist: 'G', bullets: ['B'] }, + ]); + }); + + try { + const sections = await t.summarizeDocument( + 'intro\nfallback anchor\nend', + { ...baseSettings, streaming: false, promptLanguage: 'en', minCards: 1, maxCards: 3, maxDocChars: 10000 }, + { phase: 'queued', onCancel: () => {}, throwIfCancelled: () => {}, setPhase: () => {} }, + ); + assert.strictEqual(callCount, 2, 'structured output fallback made two requests'); + assert.strictEqual(sections[0].title, 'Fallback', 'fallback response parsed correctly'); + } finally { + setRequestUrlMock(prev); + } +} + +async function testStructuredOutputNonRetryableError() { + const prev = require('./test-setup').getRequestUrlMock(); + setRequestUrlMock(async () => ({ + status: 500, + json: { error: 'Internal server error' }, + text: JSON.stringify({ error: 'Internal server error' }), + })); + + try { + await assert.rejects( + () => t.summarizeDocument( + 'test content', + { ...baseSettings, streaming: false, promptLanguage: 'en', minCards: 1, maxCards: 3, maxDocChars: 10000 }, + { phase: 'queued', onCancel: () => {}, throwIfCancelled: () => {}, setPhase: () => {} }, + ), + /500/, + 'non-retryable error (500) is not caught by structured fallback', + ); + } finally { + setRequestUrlMock(prev); + } +} + +(async () => { + await testSummarizeDocumentAnchorSorting(); + await testStructuredOutputFallback(); + await testStructuredOutputNonRetryableError(); + console.log('providers tests passed'); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/schema.test.js b/tests/schema.test.js new file mode 100644 index 0000000..cb77875 --- /dev/null +++ b/tests/schema.test.js @@ -0,0 +1,91 @@ +const { assert, t } = require('./test-setup'); + +const { extractJson, normalizeCardsPayload, repairTruncatedCardsJson, collectJsonObjectCandidates } = t; + +// ── extractJson ── + +assert.strictEqual(extractJson(''), '', 'empty text returns empty'); +assert.strictEqual(extractJson('{"a":1}'), '{"a":1}', 'already-valid JSON returned as-is'); +assert.strictEqual( + JSON.parse(extractJson('prefix {"cards":[]} suffix')).cards.length, + 0, 'extracts JSON from surrounding text' +); +assert.strictEqual( + JSON.parse(extractJson('```json\n{"cards":[]}\n```')).cards.length, + 0, 'extracts JSON from fenced code block' +); +assert.strictEqual( + JSON.parse(extractJson('text {"a":1} and {"cards":[{"title":"T"}]} end')).cards[0].title, + 'T', 'picks the largest valid JSON object' +); +assert.strictEqual(extractJson('not json at all'), 'not json at all', 'non-JSON text returned as-is'); + +const nestedBraces = '{"cards":[{"title":"test {with} braces","anchor":"a","gist":"g","bullets":[]}]}'; +assert.deepStrictEqual(JSON.parse(extractJson(nestedBraces)).cards[0].title, 'test {with} braces'); + +// ── repairTruncatedCardsJson ── + +assert.strictEqual(repairTruncatedCardsJson('not json'), null, 'non-cards text returns null'); +assert.strictEqual(repairTruncatedCardsJson(''), null, 'empty string returns null'); +assert.strictEqual(repairTruncatedCardsJson('{"cards":['), null, 'no complete cards returns null'); + +const truncJson = '{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"x","gist":"trunc'; +const repaired = repairTruncatedCardsJson(truncJson); +assert.ok(repaired, 'truncated JSON is repaired'); +const repairedParsed = JSON.parse(repaired); +assert.strictEqual(repairedParsed.cards.length, 1, 'only complete card is kept'); +assert.strictEqual(repairedParsed.cards[0].title, 'A', 'salvaged card has correct title'); + +const twoComplete = '{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"a2","gist":"g2","bullets":["c"]},{"title":"C","anch'; +const repairedTwo = JSON.parse(repairTruncatedCardsJson(twoComplete)); +assert.strictEqual(repairedTwo.cards.length, 2, 'two complete cards salvaged from three'); + +const salvagedCards = normalizeCardsPayload(JSON.parse(repairTruncatedCardsJson(truncJson))); +assert.strictEqual(salvagedCards.length, 1, 'repair + normalize produces valid cards'); +assert.strictEqual(salvagedCards[0].title, 'A', 'salvaged card has correct fields'); + +const spaced = '{ "cards" : [ {"title":"A","anchor":"a","gist":"g","bullets":[]},{"title":"B","anch'; +const repairedSpaced = repairTruncatedCardsJson(spaced); +assert.ok(repairedSpaced, 'repair handles whitespace in cards pattern'); +assert.strictEqual(JSON.parse(repairedSpaced).cards.length, 1, 'repair with whitespace keeps complete card'); + +const escapedQuotes = '{"cards":[{"title":"A \\"quoted\\"","anchor":"a","gist":"g","bullets":["line \\"1\\""]},{"title":"B","anch'; +const repairedEscaped = repairTruncatedCardsJson(escapedQuotes); +assert.ok(repairedEscaped, 'repair handles escaped quotes in values'); +assert.strictEqual(JSON.parse(repairedEscaped).cards[0].title, 'A "quoted"', 'escaped quotes preserved'); + +const bracesInStrings = '{"cards":[{"title":"func() { return }","anchor":"a","gist":"{obj}","bullets":[]},{"incomp'; +const repairedBraces = repairTruncatedCardsJson(bracesInStrings); +assert.ok(repairedBraces, 'repair handles braces inside strings'); +assert.strictEqual(JSON.parse(repairedBraces).cards[0].title, 'func() { return }', 'braces in string preserved'); + +assert.strictEqual(repairTruncatedCardsJson('{"cards":[{"title":"incomp'), null, 'all truncated cards returns null'); +assert.strictEqual(repairTruncatedCardsJson('{"cards":[]'), null, 'empty truncated array returns null'); + +// ── collectJsonObjectCandidates ── + +assert.deepStrictEqual(collectJsonObjectCandidates(''), [], 'empty string gives no candidates'); +assert.deepStrictEqual(collectJsonObjectCandidates('no braces'), [], 'no braces gives no candidates'); +assert.deepStrictEqual(collectJsonObjectCandidates('{"a":1}'), ['{"a":1}'], 'single object extracted'); +assert.deepStrictEqual(collectJsonObjectCandidates('{"a":1} {"b":2}'), ['{"a":1}', '{"b":2}'], 'multiple objects extracted'); +assert.deepStrictEqual(collectJsonObjectCandidates('{"a":{"b":1}}'), ['{"a":{"b":1}}'], 'nested objects extracted as one'); +assert.deepStrictEqual(collectJsonObjectCandidates('{"a":"{}"}'), ['{"a":"{}"}'], 'braces in strings handled correctly'); +assert.deepStrictEqual(collectJsonObjectCandidates('{unclosed'), [], 'unclosed brace gives no candidates'); + +// ── normalizeCardsPayload ── + +assert.deepStrictEqual(normalizeCardsPayload(null), [], 'null payload returns empty array'); +assert.deepStrictEqual(normalizeCardsPayload({}), [], 'empty object returns empty array'); +assert.deepStrictEqual(normalizeCardsPayload({ cards: [null, undefined, 42, 'str'] }), [], 'non-object items filtered'); +assert.deepStrictEqual( + normalizeCardsPayload({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] }), + [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }], + 'valid card passes through' +); +assert.deepStrictEqual( + normalizeCardsPayload({ cards: [{ title: 123, gist: null, bullets: [1, 'valid', null] }] }), + [{ title: '(无标题)', anchor: '', gist: '', bullets: ['valid'] }], + 'non-string fields get defaults, non-string bullets filtered' +); + +console.log('schema tests passed'); diff --git a/tests/scroll.test.js b/tests/scroll.test.js new file mode 100644 index 0000000..f69a327 --- /dev/null +++ b/tests/scroll.test.js @@ -0,0 +1,17 @@ +const { assert, t } = require('./test-setup'); + +assert.strictEqual(t.visibleTopProbeY({ top: 0, height: 0 }), 0, 'zero rect'); +assert.strictEqual(t.visibleTopProbeY(null), 0, 'null rect'); +assert.strictEqual(t.visibleTopProbeY({ top: 50, height: 500 }), 100, 'standard rect: min(80, 500*0.1)=50, 50+50=100'); +assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 300 }), 130, 'small rect offset 10%'); +assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 1200 }), 180, 'large rect capped at 80'); + +const frames = []; +const throttled = t.createRafThrottledHandler(() => {}, cb => { frames.push(cb); return frames.length; }); +throttled(); +assert.strictEqual(frames.length, 1); +throttled.cancel(); +throttled(); +assert.strictEqual(frames.length, 2, 'new frame after cancel'); + +console.log('scroll tests passed'); diff --git a/tests/settings.test.js b/tests/settings.test.js new file mode 100644 index 0000000..4c94d72 --- /dev/null +++ b/tests/settings.test.js @@ -0,0 +1,67 @@ +const { assert, t, baseSettings } = require('./test-setup'); + +// modelForApi strips provider prefix +assert.strictEqual(t.modelForApi({ ...baseSettings, model: 'openai/gpt-5.1' }), 'gpt-5.1', 'strip openai prefix'); +assert.strictEqual(t.modelForApi({ ...baseSettings, model: 'gpt-5.1' }), 'gpt-5.1', 'no prefix pass-through'); +assert.throws(() => t.modelForApi({ ...baseSettings, model: '' }), /Model/, 'empty model throws'); + +// getApiBaseUrl +assert.strictEqual( + t.getApiBaseUrl({ ...baseSettings, apiBaseUrl: 'https://custom.ai/v1/' }), + 'https://custom.ai/v1', + 'strips trailing slash' +); +assert.throws( + () => t.getApiBaseUrl({ ...baseSettings, apiProvider: 'custom-openai', apiBaseUrl: '' }), + /Custom provider/, + 'custom provider without url throws' +); + +// generationFingerprint stability +const fp1 = t.generationFingerprint(baseSettings); +const fp2 = t.generationFingerprint({ ...baseSettings }); +assert.strictEqual(fp1, fp2, 'same settings = same fingerprint'); +assert.notStrictEqual(fp1, t.generationFingerprint({ ...baseSettings, model: 'other' }), 'different model = different fp'); +assert.notStrictEqual(fp1, t.generationFingerprint({ ...baseSettings, apiMaxTokens: 8192 }), 'different tokens = different fp'); +assert.strictEqual(t.normalizeStreamingTimeoutMs('30000'), 30000, 'streaming timeout accepts numeric strings'); +assert.strictEqual(t.normalizeStreamingTimeoutMs(999), 120000, 'streaming timeout below minimum falls back'); +assert.strictEqual(t.normalizeStreamingTimeoutMs('bad'), 120000, 'streaming timeout rejects non-numeric values'); +assert.strictEqual( + t.normalizeSettings({ ...baseSettings, streamingTimeoutMs: 500 }).streamingTimeoutMs, + 120000, + 'normalizeSettings protects invalid streaming timeout values', +); + +// normalizeSettings does not mutate its input +const settingsInput = { ...baseSettings, streamingTimeoutMs: 500, minCards: -1, maxCards: 999 }; +const settingsInputCopy = JSON.parse(JSON.stringify(settingsInput)); +const normalized = t.normalizeSettings(settingsInput); +assert.deepStrictEqual(settingsInput, settingsInputCopy, 'normalizeSettings does not mutate input object'); +assert.notStrictEqual(normalized, settingsInput, 'normalizeSettings returns a new object'); +assert.strictEqual(normalized.minCards, 1, 'normalizeSettings clamps minCards'); +assert.strictEqual(normalized.maxCards, 30, 'normalizeSettings clamps maxCards to max 30'); + +// cacheEntryMatches +const crypto = require('crypto'); +const hash = crypto.createHash('sha1').update('test', 'utf8').digest('hex'); +assert.strictEqual( + t.cacheEntryMatches({ + schemaVersion: t.CACHE_SCHEMA_VERSION, + contentHash: hash, + settingsHash: t.generationFingerprint(baseSettings), + }, 'test', baseSettings), + true, 'matching entry returns true' +); +assert.strictEqual(t.cacheEntryMatches(null, 'test', baseSettings), false, 'null entry returns false'); +assert.strictEqual( + t.shouldSkipBatchFile({ + schemaVersion: t.CACHE_SCHEMA_VERSION, + contentHash: hash, + settingsHash: t.generationFingerprint(baseSettings), + }, 'test', baseSettings), + true, + 'fresh cache entry is skipped during batch', +); +assert.strictEqual(t.shouldSkipBatchFile(null, 'test', baseSettings), false, 'missing cache entry is not skipped'); + +console.log('settings tests passed'); diff --git a/tests/streaming.test.js b/tests/streaming.test.js new file mode 100644 index 0000000..9f45d17 --- /dev/null +++ b/tests/streaming.test.js @@ -0,0 +1,98 @@ +const { assert, t, baseSettings } = require('./test-setup'); + +// ── supportsStreaming ── + +assert.strictEqual( + t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'openai-chat' }), + true, 'OpenAI Chat supports streaming' +); +assert.strictEqual( + t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'anthropic-messages', apiProvider: 'anthropic' }), + true, 'Anthropic Messages supports streaming' +); +assert.strictEqual( + t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'google-generative-ai', apiProvider: 'google' }), + false, 'Gemini does not support streaming' +); +assert.strictEqual( + t.supportsStreaming({ ...baseSettings, streaming: false, apiFormat: 'openai-chat' }), + false, 'streaming disabled returns false' +); + +// ── deltaExtractorForFormat ── + +const openaiExtract = t.deltaExtractorForFormat('openai-chat'); +assert.ok(openaiExtract, 'openai-chat extractor exists'); +assert.strictEqual( + openaiExtract({ choices: [{ delta: { content: 'hello' } }] }), + 'hello', 'extracts OpenAI delta' +); +assert.strictEqual(openaiExtract({ choices: [{ delta: {} }] }), '', 'empty delta'); +assert.strictEqual(openaiExtract({}), '', 'missing choices'); + +const anthropicExtract = t.deltaExtractorForFormat('anthropic-messages'); +assert.ok(anthropicExtract, 'anthropic extractor exists'); +assert.strictEqual( + anthropicExtract({ type: 'content_block_delta', delta: { text: 'world' } }), + 'world', 'extracts Anthropic delta' +); +assert.strictEqual( + anthropicExtract({ type: 'message_start' }), + '', 'non-delta event returns empty' +); + +assert.strictEqual(t.deltaExtractorForFormat('google-generative-ai'), null, 'no extractor for gemini'); + +// ── parseSseBuffer ── + +const sseResult = t.parseSseBuffer( + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: {"choices":[{"delta":{"content":" there"}}]}\n\ndata: [DONE]\n\npartial', + openaiExtract, +); +assert.deepStrictEqual(sseResult.deltas, ['hi', ' there'], 'SSE parser extracts deltas'); +assert.strictEqual(sseResult.rest, 'partial', 'SSE parser keeps partial line'); + +const sseEmpty = t.parseSseBuffer('', openaiExtract); +assert.deepStrictEqual(sseEmpty.deltas, []); +assert.strictEqual(sseEmpty.rest, ''); + +const anthSse = t.parseSseBuffer( + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"token"}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n', + anthropicExtract, +); +assert.deepStrictEqual(anthSse.deltas, ['token'], 'Anthropic SSE extracts text delta'); + +const ssePartial = t.parseSseBuffer('data: {"choices":[{"delta":{"content":"ok"}}]}', openaiExtract); +assert.deepStrictEqual(ssePartial.deltas, [], 'incomplete line yields no deltas'); +assert.ok(ssePartial.rest.includes('"ok"'), 'partial line kept in rest'); + +const sseMulti = t.parseSseBuffer( + 'data: {"choices":[{"delta":{"content":"a"}}]}\n\ndata: {"choices":[{"delta":{"content":"b"}}]}\n\ndata: {"choices":[{"delta":{"content":"c"}}]}\n\n', + openaiExtract, +); +assert.deepStrictEqual(sseMulti.deltas, ['a', 'b', 'c'], 'three events in one chunk'); +assert.strictEqual(sseMulti.rest, '', 'nothing left in rest when chunk ends with newline'); + +const sseMultilineData = t.parseSseBuffer( + 'data: {"choices":[\ndata: {"delta":{"content":"joined"}}\ndata: ]}\n\n', + openaiExtract, +); +assert.deepStrictEqual(sseMultilineData.deltas, ['joined'], 'consecutive data lines are merged into one event'); + +const ssePartialEvent = t.parseSseBuffer( + 'data: {"choices":[{"delta":{"content":"wait"}}]}\n', + openaiExtract, +); +assert.deepStrictEqual(ssePartialEvent.deltas, [], 'event without blank-line terminator is retained'); +assert.strictEqual(ssePartialEvent.rest.includes('"wait"'), true, 'partial event remains in rest'); + +const sseMixed = t.parseSseBuffer( + ': comment\nevent: ping\ndata: {"choices":[{"delta":{"content":"x"}}]}\n\n', + openaiExtract, +); +assert.deepStrictEqual(sseMixed.deltas, ['x'], 'comment and event lines ignored'); + +const sseBad = t.parseSseBuffer('data: not_json\n\ndata: {"choices":[{"delta":{"content":"y"}}]}\n\n', openaiExtract); +assert.deepStrictEqual(sseBad.deltas, ['y'], 'bad JSON line skipped, good line extracted'); + +console.log('streaming tests passed'); diff --git a/tests/test-setup.js b/tests/test-setup.js new file mode 100644 index 0000000..4e02a3b --- /dev/null +++ b/tests/test-setup.js @@ -0,0 +1,61 @@ +const assert = require('assert'); +const { EventEmitter } = require('events'); +const Module = require('module'); + +const originalLoad = Module._load; +let requestUrlMock = async () => ({ status: 200, json: {}, text: '{}' }); + +Module._load = function load(request, parent, isMain) { + if (request === 'obsidian') { + class Plugin {} + class ItemView { constructor(leaf) { this.leaf = leaf; this.containerEl = { children: [{}, {}] }; } } + class PluginSettingTab {} + class Setting {} + class Notice {} + class MarkdownView {} + class TFile {} + class Menu {} + class Modal {} + return { + Plugin, ItemView, PluginSettingTab, Setting, Notice, MarkdownView, TFile, Menu, Modal, + MarkdownRenderer: { render: async () => {} }, + requestUrl: (params) => requestUrlMock(params), + setIcon: () => {}, + }; + } + return originalLoad.call(this, request, parent, isMain); +}; + +const t = require('../main.js').__test; + +function openAiCardsResponse(cards) { + const json = { + choices: [{ + message: { + content: JSON.stringify({ cards }), + }, + }], + }; + return { status: 200, json, text: JSON.stringify(json) }; +} + +const baseSettings = { + backend: 'api', + apiProvider: 'openai', + apiFormat: 'openai-chat', + apiBaseUrl: 'https://api.openai.com/v1', + apiAuthType: 'bearer', + apiKey: 'test-key', + apiMaxTokens: 4096, + model: 'openai/gpt-5.1', +}; + +module.exports = { + assert, + EventEmitter, + t, + baseSettings, + openAiCardsResponse, + getRequestUrlMock() { return requestUrlMock; }, + setRequestUrlMock(fn) { requestUrlMock = fn; }, +}; diff --git a/tests/vault-batch.test.js b/tests/vault-batch.test.js new file mode 100644 index 0000000..3b925a9 --- /dev/null +++ b/tests/vault-batch.test.js @@ -0,0 +1,87 @@ +const { assert, t } = require('./test-setup'); + +// ── vault.ts ── + +assert.deepStrictEqual(t.folderPathsForTarget(''), [], 'empty path'); +assert.deepStrictEqual(t.folderPathsForTarget('A/B/C'), ['A', 'A/B', 'A/B/C']); +assert.deepStrictEqual(t.folderPathsForTarget('/A//B/'), ['A', 'A/B'], 'normalizes slashes'); +assert.deepStrictEqual(t.folderPathsForTarget('single'), ['single']); +assert.deepStrictEqual(t.folderPathsForTarget('../../etc/passwd'), ['etc', 'etc/passwd'], 'strips .. path traversal'); +assert.deepStrictEqual(t.folderPathsForTarget('./a/../b'), ['a', 'a/b'], 'strips . and .. segments'); +assert.deepStrictEqual(t.folderPathsForTarget('a/../../b'), ['a', 'a/b'], 'strips mid-path ..'); + +// ── batch.ts ── + +assert.strictEqual(t.normalizeBatchFolderInput('/Reading//Articles/'), 'Reading/Articles', 'normalizes folder input'); +assert.strictEqual(t.normalizeBatchFolderInput('../Inbox/./Daily'), 'Inbox/Daily', 'strips unsafe folder segments'); +assert.strictEqual(t.hasUnsafeBatchFolderSegments('../Inbox/./Daily'), true, 'unsafe folder segments are detected'); +assert.strictEqual(t.hasUnsafeBatchFolderSegments('/Reading//Articles/'), false, 'normal folder paths are safe'); +assert.deepStrictEqual( + t.validateBatchFolderInput('', () => false), + { valid: true, reason: 'ok', folderPath: '' }, + 'empty batch folder targets vault root', +); +assert.deepStrictEqual( + t.validateBatchFolderInput('Reading', (path) => path === 'Reading'), + { valid: true, reason: 'ok', folderPath: 'Reading' }, + 'existing folder input is valid', +); +assert.deepStrictEqual( + t.validateBatchFolderInput('../Reading', () => true), + { valid: false, reason: 'unsafe', folderPath: 'Reading' }, + 'unsafe folder input is invalid even if normalized target exists', +); +assert.deepStrictEqual( + t.validateBatchFolderInput('Missing', () => false), + { valid: false, reason: 'missing', folderPath: 'Missing' }, + 'missing folder input is invalid', +); +const batchFiles = [ + { path: 'root.md', parent: null }, + { path: 'Reading/note.md', parent: { path: 'Reading' } }, + { path: 'Reading/Deep/nested.md', parent: { path: 'Reading/Deep' } }, +]; +assert.deepStrictEqual(t.selectBatchFiles(batchFiles, '').map((file) => file.path), ['root.md'], 'root folder only'); +assert.deepStrictEqual( + t.selectBatchFiles(batchFiles, '/Reading/').map((file) => file.path), + ['Reading/note.md'], + 'selected folder is non-recursive', +); +assert.deepStrictEqual(t.batchProgressVars(1, 3), { current: 2, total: 3 }, 'progress vars are one-based'); +const batchStats = t.createBatchStats(3); +const skippedStats = t.recordBatchSkip(batchStats); +const processedStats = t.recordBatchProcessed(skippedStats); +const errorStats = t.recordBatchError(processedStats); +assert.deepStrictEqual(batchStats, { total: 3, processed: 0, skipped: 0, errors: 0 }, 'batch stats are immutable'); +assert.deepStrictEqual(skippedStats, { total: 3, processed: 1, skipped: 1, errors: 0 }, 'batch skip updates progress'); +assert.deepStrictEqual(processedStats, { total: 3, processed: 2, skipped: 1, errors: 0 }, 'batch processed count accumulates'); +assert.deepStrictEqual(errorStats, { total: 3, processed: 3, skipped: 1, errors: 1 }, 'batch error count accumulates'); + +const batchState = t.createBatchRunState(); +assert.deepStrictEqual(batchState, { cancelled: false, currentPath: '' }, 'batch state starts idle'); +assert.strictEqual(t.markBatchFileRunning(batchState, 'Reading/a.md'), batchState, 'batch state updates in place'); +assert.strictEqual(batchState.currentPath, 'Reading/a.md', 'batch state records current file'); +assert.strictEqual(t.requestBatchCancel(batchState), true, 'first batch cancellation request succeeds'); +assert.strictEqual(batchState.cancelled, true, 'batch cancellation flag is set'); +assert.strictEqual(t.requestBatchCancel(batchState), false, 'duplicate batch cancellation request is ignored'); +assert.strictEqual(t.requestBatchCancel(null), false, 'missing batch state cannot be cancelled'); + +const batchA = t.createBatchRunState(); +const batchB = t.createBatchRunState(); +t.markBatchFileRunning(batchA, 'folderA/note.md'); +t.markBatchFileRunning(batchB, 'folderB/note.md'); +assert.strictEqual(batchA.currentPath, 'folderA/note.md', 'batch A tracks its own file'); +assert.strictEqual(batchB.currentPath, 'folderB/note.md', 'batch B tracks its own file'); +t.requestBatchCancel(batchA); +assert.strictEqual(batchA.cancelled, true, 'batch A cancelled'); +assert.strictEqual(batchB.cancelled, false, 'batch B unaffected by A cancellation'); + +const midCancelStats = t.createBatchStats(5); +const s1 = t.recordBatchProcessed(midCancelStats); +const s2 = t.recordBatchSkip(s1); +assert.strictEqual(s2.processed, 2, 'two files processed before cancel'); +assert.strictEqual(s2.skipped, 1, 'one skipped before cancel'); +assert.strictEqual(s2.errors, 0, 'no errors before cancel'); +assert.strictEqual(s2.total, 5, 'total unchanged by partial processing'); + +console.log('vault-batch tests passed');