mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
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
This commit is contained in:
parent
62f99718f6
commit
7cb68658f5
15 changed files with 945 additions and 984 deletions
|
|
@ -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",
|
||||
|
|
|
|||
22
tests/anchor.test.js
Normal file
22
tests/anchor.test.js
Normal file
|
|
@ -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');
|
||||
80
tests/cache.test.js
Normal file
80
tests/cache.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
44
tests/cards-nav.test.js
Normal file
44
tests/cards-nav.test.js
Normal file
|
|
@ -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');
|
||||
99
tests/cli.test.js
Normal file
99
tests/cli.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
33
tests/i18n.test.js
Normal file
33
tests/i18n.test.js
Normal file
|
|
@ -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');
|
||||
20
tests/markdown.test.js
Normal file
20
tests/markdown.test.js
Normal file
|
|
@ -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');
|
||||
|
|
@ -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);
|
||||
});
|
||||
225
tests/providers.test.js
Normal file
225
tests/providers.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
91
tests/schema.test.js
Normal file
91
tests/schema.test.js
Normal file
|
|
@ -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');
|
||||
17
tests/scroll.test.js
Normal file
17
tests/scroll.test.js
Normal file
|
|
@ -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');
|
||||
67
tests/settings.test.js
Normal file
67
tests/settings.test.js
Normal file
|
|
@ -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');
|
||||
98
tests/streaming.test.js
Normal file
98
tests/streaming.test.js
Normal file
|
|
@ -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');
|
||||
61
tests/test-setup.js
Normal file
61
tests/test-setup.js
Normal file
|
|
@ -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; },
|
||||
};
|
||||
87
tests/vault-batch.test.js
Normal file
87
tests/vault-batch.test.js
Normal file
|
|
@ -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');
|
||||
Loading…
Reference in a new issue