fancive_obsidian-parallel-r.../tests/modules.test.js
wujunchen 70f9a39cd7 refactor: extract provider response parsers
Change-Id: I6884999f34594faec349378deb59e68972e07355
2026-04-26 18:20:49 +08:00

775 lines
33 KiB
JavaScript

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: 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);
assert.deepStrictEqual(batchStats, { total: 3, processed: 0, skipped: 0 }, 'batch stats are immutable');
assert.deepStrictEqual(skippedStats, { total: 3, processed: 1, skipped: 1 }, 'batch skip updates progress');
assert.deepStrictEqual(processedStats, { total: 3, processed: 2, skipped: 1 }, 'batch processed 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');
// ── 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',
);
// 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');
const providerCardsJson = JSON.stringify({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] });
assert.strictEqual(
t.textFromOpenAiChatResponse({ choices: [{ message: { content: [{ text: providerCardsJson }] } }] }),
providerCardsJson,
'OpenAI Chat parser extracts message content text',
);
assert.strictEqual(
t.textFromAnthropicMessagesResponse({ content: [{ type: 'text', text: providerCardsJson }] }),
providerCardsJson,
'Anthropic parser extracts text blocks',
);
assert.strictEqual(
t.textFromOpenAiResponsesResponse({ output: [{ content: [{ type: 'output_text', text: providerCardsJson }] }] }),
providerCardsJson,
'OpenAI Responses parser extracts nested output text',
);
assert.strictEqual(
t.textFromGoogleGenerativeAiResponse({ candidates: [{ content: { parts: [{ text: providerCardsJson }] } }] }),
providerCardsJson,
'Gemini parser extracts candidate part text',
);
assert.deepStrictEqual(
t.cardsFromAnthropicToolUse({
content: [{ type: 'tool_use', name: 'record_parallel_reader_cards', input: JSON.parse(providerCardsJson) }],
}),
[{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }],
'Anthropic tool-use parser extracts structured card payloads',
);
// 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"}}]}\ndata: [DONE]\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"}}]}\ndata: {"choices":[{"delta":{"content":"b"}}]}\ndata: {"choices":[{"delta":{"content":"c"}}]}\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');
// parseSseBuffer: non-data lines are skipped
const sseMixed = t.parseSseBuffer(
': comment\nevent: ping\ndata: {"choices":[{"delta":{"content":"x"}}]}\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\ndata: {"choices":[{"delta":{"content":"y"}}]}\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');
// ── 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 () => {
await testRunCliEdgeCases();
await testCacheManagerMove();
await testSummarizeDocumentAnchorSorting();
console.log('modules tests passed');
})().catch((e) => {
console.error(e);
process.exit(1);
});