fancive_obsidian-parallel-r.../tests/direct-modules.test.js
fancivez b2716da4ca test: add getApiKey, cacheEntryMatches, normalizeCardCount edge case tests
Cover normalizeCardCount boundary values (0, negative, NaN, max 30, string).
Test getApiKey with direct key, env var, precedence, and trimming.
Test cacheEntryMatches for valid match, null entry, wrong hash, wrong schema.

Change-Id: I1113d923885e03f6b28d47721cc303bbbb6d70ed
2026-04-27 14:11:37 +08:00

780 lines
40 KiB
JavaScript

const assert = require('assert');
const esbuild = require('esbuild');
const fs = require('fs');
const os = require('os');
const path = require('path');
const repoRoot = path.join(__dirname, '..');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel-reader-tests-'));
async function requireBundledModule(relativePath) {
const entry = path.join(repoRoot, relativePath);
const outfile = path.join(tempDir, relativePath.replace(/[/.]/g, '_') + '.cjs');
await esbuild.build({
entryPoints: [entry],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
plugins: [
{
name: 'obsidian-stub',
setup(build) {
build.onResolve({ filter: /^obsidian$/ }, () => ({ path: 'obsidian-stub', namespace: 'stub' }));
build.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({
contents: 'module.exports = { requestUrl: async () => { throw new Error("requestUrl not available in direct module tests"); } };',
loader: 'js',
}));
},
},
],
});
return require(outfile);
}
(async () => {
try {
const cache = await requireBundledModule('src/cache.ts');
const cacheManagerModule = await requireBundledModule('src/cache-manager.ts');
const cards = await requireBundledModule('src/cards.ts');
const generation = await requireBundledModule('src/generation.ts');
const i18n = await requireBundledModule('src/i18n.ts');
const providerParsers = await requireBundledModule('src/provider-parsers.ts');
const settings = await requireBundledModule('src/settings.ts');
const streaming = await requireBundledModule('src/streaming.ts');
const vault = await requireBundledModule('src/vault.ts');
const cacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z' };
const touched = cache.touchCacheEntry(cacheEntry, '2024-06-01T00:00:00.000Z');
assert.strictEqual(touched.lastAccessedAt, '2024-06-01T00:00:00.000Z', 'direct cache import touches entries');
assert.strictEqual(cacheEntry.lastAccessedAt, undefined, 'direct cache import keeps cache entries immutable');
assert.strictEqual(JSON.parse(cache.serializeCacheFile({ 'a.md': { cards: [] } })).version, 1);
function createFakeAdapter() {
const files = new Map();
const dirs = new Set();
return {
files,
dirs,
writes: [],
exists: async (filePath) => dirs.has(filePath) || files.has(filePath),
mkdir: async (filePath) => {
dirs.add(filePath);
},
read: async (filePath) => {
if (!files.has(filePath)) throw new Error('not found');
return files.get(filePath);
},
write: async (filePath, content) => {
files.set(filePath, content);
return files.get(filePath);
},
};
}
const adapter = createFakeAdapter();
const manager = new cacheManagerModule.CacheManager(adapter, '.obsidian', 'parallel-reader', () => ({
...settings.DEFAULT_SETTINGS,
maxCacheEntries: 2,
}));
adapter.files.set(
manager.filePath(),
JSON.stringify({
version: 1,
entries: {
'old.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [] },
'fresh.md': { generatedAt: '2024-01-02T00:00:00.000Z', cards: [] },
'touched.md': {
generatedAt: '2024-01-03T00:00:00.000Z',
lastAccessedAt: '2024-02-01T00:00:00.000Z',
cards: [],
},
},
}),
);
await manager.load();
assert.strictEqual(manager.cache['old.md'], undefined, 'CacheManager.load prunes old entries');
assert.ok(adapter.files.get(manager.filePath()).includes('fresh.md'), 'CacheManager.load persists prune results');
const touchedEntry = await manager.touch('fresh.md');
assert.ok(touchedEntry.lastAccessedAt, 'CacheManager.touch updates existing entries');
await manager.flush();
assert.ok(JSON.parse(adapter.files.get(manager.filePath())).entries['fresh.md'].lastAccessedAt);
assert.strictEqual(
await manager.replaceCards('fresh.md', [{ title: 'New', anchor: 'A', gist: 'G', bullets: ['B'], level: 2, startLine: 1 }]),
true,
'CacheManager.replaceCards updates existing entries',
);
assert.strictEqual(JSON.parse(adapter.files.get(manager.filePath())).entries['fresh.md'].cards[0].title, 'New');
await manager.delete('fresh.md');
assert.strictEqual(manager.cache['fresh.md'], undefined, 'CacheManager.delete removes entries');
manager.cache = { 'clear.md': { generatedAt: '2024-01-04T00:00:00.000Z', cards: [] } };
await manager.save();
assert.ok(adapter.files.get(manager.filePath()).includes('clear.md'), 'CacheManager.save persists current cache');
await manager.clear();
assert.deepStrictEqual(manager.cache, {}, 'CacheManager.clear resets cache state');
// Cache pruning interleaved with put: oldest entry gets pruned
const pruneAdapter = createFakeAdapter();
const pruneManager = new cacheManagerModule.CacheManager(pruneAdapter, '.obsidian', 'parallel-reader', () => ({
...settings.DEFAULT_SETTINGS,
maxCacheEntries: 2,
}));
await pruneManager.load();
// Seed with entries that have distinct timestamps so pruning is deterministic
pruneManager.cache = {
'old.md': { schemaVersion: 2, contentHash: 'a', settingsHash: 'a', cards: [], generatedAt: '2024-01-01T00:00:00.000Z', lastAccessedAt: '2024-01-01T00:00:00.000Z' },
'mid.md': { schemaVersion: 2, contentHash: 'b', settingsHash: 'b', cards: [], generatedAt: '2024-06-01T00:00:00.000Z', lastAccessedAt: '2024-06-01T00:00:00.000Z' },
};
// Put a third entry — cache now has 3 entries, save triggers prune to max=2
await pruneManager.put('new.md', 'new content', [{ title: 'N', anchor: 'n', gist: 'g', bullets: [] }], settings.DEFAULT_SETTINGS);
assert.strictEqual(Object.keys(pruneManager.cache).length, 2, 'cache pruned to max entries after put');
assert.ok(pruneManager.cache['new.md'], 'newest entry survives pruning');
assert.ok(pruneManager.cache['mid.md'], 'middle entry survives pruning');
assert.strictEqual(pruneManager.cache['old.md'], undefined, 'oldest entry pruned by timestamp');
// Verify the persisted file reflects the pruned state
const persistedPrune = JSON.parse(pruneAdapter.files.get(pruneManager.filePath()));
assert.ok(persistedPrune.entries['new.md'], 'newest entry persisted after pruning');
assert.strictEqual(persistedPrune.entries['old.md'], undefined, 'oldest entry not in persisted cache');
// --- CacheManager.get() ---
assert.strictEqual(pruneManager.get('new.md').cards[0].title, 'N', 'CacheManager.get returns existing entry');
assert.strictEqual(pruneManager.get('nonexistent.md'), null, 'CacheManager.get returns null for missing entry');
// --- CacheManager.move() ---
const moveAdapter = createFakeAdapter();
const moveManager = new cacheManagerModule.CacheManager(moveAdapter, '.obsidian', 'parallel-reader', () => settings.DEFAULT_SETTINGS);
await moveManager.load();
moveManager.cache = {
'a.md': { schemaVersion: 2, contentHash: 'a', settingsHash: 'a', cards: [{ title: 'A', anchor: 'a', gist: 'g', bullets: [] }], generatedAt: '2024-01-01T00:00:00.000Z' },
'b.md': { schemaVersion: 2, contentHash: 'b', settingsHash: 'b', cards: [], generatedAt: '2024-01-02T00:00:00.000Z' },
};
await moveManager.save();
const moveResult = await moveManager.move('a.md', 'renamed.md');
assert.strictEqual(moveResult, true, 'CacheManager.move returns true on success');
assert.strictEqual(moveManager.get('a.md'), null, 'CacheManager.move removes old path');
assert.strictEqual(moveManager.get('renamed.md').cards[0].title, 'A', 'CacheManager.move preserves entry at new path');
assert.strictEqual(await moveManager.move('renamed.md', 'b.md'), false, 'CacheManager.move rejects when destination exists');
assert.strictEqual(await moveManager.move('b.md', 'b.md'), true, 'CacheManager.move same-path returns true if entry exists');
assert.strictEqual(await moveManager.move('gone.md', 'gone.md'), false, 'CacheManager.move same-path returns false if missing');
assert.strictEqual(await moveManager.move('gone.md', 'new-gone.md'), false, 'CacheManager.move returns false for missing source');
assert.strictEqual(await moveManager.move('', 'dest.md'), false, 'CacheManager.move rejects empty oldPath');
assert.strictEqual(await moveManager.move('src.md', ''), false, 'CacheManager.move rejects empty newPath');
assert.strictEqual(await moveManager.move(' ', 'dest.md'), false, 'CacheManager.move rejects whitespace oldPath');
// --- CacheManager.readFile() with corrupt JSON ---
const corruptAdapter = createFakeAdapter();
const corruptManager = new cacheManagerModule.CacheManager(corruptAdapter, '.obsidian', 'parallel-reader', () => settings.DEFAULT_SETTINGS);
corruptAdapter.files.set(corruptManager.filePath(), '{ invalid json !!!');
const corruptResult = await corruptManager.readFile();
assert.deepStrictEqual(corruptResult, {}, 'CacheManager.readFile returns empty object for corrupt JSON');
// readFile with valid JSON but no entries field
const noEntriesAdapter = createFakeAdapter();
const noEntriesManager = new cacheManagerModule.CacheManager(noEntriesAdapter, '.obsidian', 'parallel-reader', () => settings.DEFAULT_SETTINGS);
noEntriesAdapter.files.set(noEntriesManager.filePath(), JSON.stringify({ version: 1 }));
const noEntriesResult = await noEntriesManager.readFile();
assert.deepStrictEqual(noEntriesResult, {}, 'CacheManager.readFile returns empty object when no entries field');
// --- CacheManager.pruneIfNeeded() ---
const pruneNeededAdapter = createFakeAdapter();
const pruneNeededManager = new cacheManagerModule.CacheManager(pruneNeededAdapter, '.obsidian', 'parallel-reader', () => ({
...settings.DEFAULT_SETTINGS,
maxCacheEntries: 1,
}));
await pruneNeededManager.load();
pruneNeededManager.cache = {
'old.md': { schemaVersion: 2, contentHash: 'a', settingsHash: 'a', cards: [], generatedAt: '2024-01-01T00:00:00.000Z', lastAccessedAt: '2024-01-01T00:00:00.000Z' },
'new.md': { schemaVersion: 2, contentHash: 'b', settingsHash: 'b', cards: [], generatedAt: '2024-06-01T00:00:00.000Z', lastAccessedAt: '2024-06-01T00:00:00.000Z' },
};
const pruneIfResult = await pruneNeededManager.pruneIfNeeded();
assert.strictEqual(pruneIfResult.length, 1, 'CacheManager.pruneIfNeeded returns removed keys');
assert.strictEqual(pruneIfResult[0], 'old.md', 'CacheManager.pruneIfNeeded removes oldest');
assert.strictEqual(Object.keys(pruneNeededManager.cache).length, 1, 'CacheManager.pruneIfNeeded prunes to limit');
// pruneIfNeeded with nothing to prune
const noPruneAdapter = createFakeAdapter();
const noPruneManager = new cacheManagerModule.CacheManager(noPruneAdapter, '.obsidian', 'parallel-reader', () => ({
...settings.DEFAULT_SETTINGS,
maxCacheEntries: 100,
}));
await noPruneManager.load();
noPruneManager.cache = {
'only.md': { schemaVersion: 2, contentHash: 'a', settingsHash: 'a', cards: [], generatedAt: '2024-01-01T00:00:00.000Z' },
};
const noPruneResult = await noPruneManager.pruneIfNeeded();
assert.strictEqual(noPruneResult.length, 0, 'CacheManager.pruneIfNeeded returns empty when nothing to prune');
// --- CacheManager.replaceCards() with missing entry ---
assert.strictEqual(
await pruneNeededManager.replaceCards('nonexistent.md', []),
false,
'CacheManager.replaceCards returns false for missing entry',
);
// --- CacheManager.scheduleSave() + flush() ---
const scheduleAdapter = createFakeAdapter();
const scheduleManager = new cacheManagerModule.CacheManager(scheduleAdapter, '.obsidian', 'parallel-reader', () => settings.DEFAULT_SETTINGS);
await scheduleManager.load();
scheduleManager.cache = { 'sched.md': { schemaVersion: 2, contentHash: 'x', settingsHash: 'x', cards: [], generatedAt: '2024-01-01T00:00:00.000Z' } };
scheduleManager.scheduleSave(50000);
await scheduleManager.flush();
const flushedData = JSON.parse(scheduleAdapter.files.get(scheduleManager.filePath()));
assert.ok(flushedData.entries['sched.md'], 'CacheManager.flush persists scheduled save immediately');
// flush when not dirty should be a no-op
scheduleAdapter.files.delete(scheduleManager.filePath());
await scheduleManager.flush();
assert.strictEqual(scheduleAdapter.files.has(scheduleManager.filePath()), false, 'CacheManager.flush is no-op when not dirty');
assert.strictEqual(
generation.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }),
'cancelRequestedApiInFlight',
'direct generation import exposes API cancellation semantics',
);
assert.strictEqual(
generation.cancellationNoticeKey({ backend: 'codex' }, { phase: 'generating' }),
'cancelRequested',
'direct generation import exposes CLI cancellation semantics',
);
const providerCardsJson = JSON.stringify({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] });
assert.strictEqual(
providerParsers.textFromOpenAiChatResponse({ choices: [{ message: { content: [{ text: providerCardsJson }] } }] }),
providerCardsJson,
'direct provider parser import extracts OpenAI Chat text',
);
assert.strictEqual(
providerParsers.textFromAnthropicMessagesResponse({ content: [{ type: 'text', text: providerCardsJson }] }),
providerCardsJson,
'direct provider parser import extracts Anthropic text',
);
assert.strictEqual(
providerParsers.textFromOpenAiResponsesResponse({
output: [{ content: [{ type: 'output_text', text: providerCardsJson }] }],
}),
providerCardsJson,
'direct provider parser import extracts OpenAI Responses text',
);
assert.strictEqual(
providerParsers.textFromGoogleGenerativeAiResponse({
candidates: [{ content: { parts: [{ text: providerCardsJson }] } }],
}),
providerCardsJson,
'direct provider parser import extracts Gemini text',
);
assert.deepStrictEqual(
providerParsers.cardsFromAnthropicToolUse({
content: [{ type: 'tool_use', name: 'record_parallel_reader_cards', input: JSON.parse(providerCardsJson) }],
}),
[{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }],
'direct provider parser import extracts Anthropic tool-use cards',
);
// --- Provider parser edge cases ---
// textFromProviderContent: string input
assert.strictEqual(providerParsers.textFromProviderContent('hello'), 'hello', 'textFromProviderContent handles string');
// textFromProviderContent: object with output_text
assert.strictEqual(providerParsers.textFromProviderContent({ output_text: 'ot' }), 'ot', 'textFromProviderContent handles output_text object');
// textFromProviderContent: array with mixed items
assert.strictEqual(providerParsers.textFromProviderContent(['a', { text: 'b' }, { output_text: 'c' }, 42]), 'abc', 'textFromProviderContent handles mixed array');
// textFromProviderContent: null/undefined
assert.strictEqual(providerParsers.textFromProviderContent(null), '', 'textFromProviderContent handles null');
assert.strictEqual(providerParsers.textFromProviderContent(undefined), '', 'textFromProviderContent handles undefined');
// textFromProviderContent: object with neither text nor output_text
assert.strictEqual(providerParsers.textFromProviderContent({ foo: 'bar' }), '', 'textFromProviderContent handles object with no text fields');
// textFromOpenAiChatResponse: empty choices
assert.strictEqual(providerParsers.textFromOpenAiChatResponse({}), '', 'OpenAI Chat parser handles empty response');
assert.strictEqual(providerParsers.textFromOpenAiChatResponse({ choices: [] }), '', 'OpenAI Chat parser handles empty choices');
// textFromOpenAiChatResponse: text fallback (old completions format)
assert.strictEqual(providerParsers.textFromOpenAiChatResponse({ choices: [{ text: 'legacy' }] }), 'legacy', 'OpenAI Chat parser handles legacy text field');
// textFromAnthropicMessagesResponse: empty content
assert.strictEqual(providerParsers.textFromAnthropicMessagesResponse({}), '', 'Anthropic parser handles empty response');
assert.strictEqual(providerParsers.textFromAnthropicMessagesResponse({ content: [] }), '', 'Anthropic parser handles empty content');
// textFromAnthropicMessagesResponse: multiple content blocks
assert.strictEqual(
providerParsers.textFromAnthropicMessagesResponse({ content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }),
'ab',
'Anthropic parser concatenates multiple text blocks',
);
// textFromGoogleGenerativeAiResponse: empty candidates
assert.strictEqual(providerParsers.textFromGoogleGenerativeAiResponse({}), '', 'Gemini parser handles empty response');
assert.strictEqual(providerParsers.textFromGoogleGenerativeAiResponse({ candidates: [] }), '', 'Gemini parser handles empty candidates');
assert.strictEqual(
providerParsers.textFromGoogleGenerativeAiResponse({ candidates: [{ content: {} }] }),
'',
'Gemini parser handles candidate with no parts',
);
// textFromOpenAiResponsesResponse: output_text shortcut
assert.strictEqual(providerParsers.textFromOpenAiResponsesResponse({ output_text: 'direct' }), 'direct', 'OpenAI Responses parser handles output_text shortcut');
// textFromOpenAiResponsesResponse: nested content with type output_text
assert.strictEqual(
providerParsers.textFromOpenAiResponsesResponse({ output: [{ content: [{ type: 'output_text', content: 'nested' }] }] }),
'nested',
'OpenAI Responses parser handles nested output_text content',
);
// textFromOpenAiResponsesResponse: empty output
assert.strictEqual(providerParsers.textFromOpenAiResponsesResponse({}), '', 'OpenAI Responses parser handles empty response');
// cardsFromAnthropicToolUse: no tool_use block
assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({ content: [{ type: 'text', text: 'hello' }] }), null, 'Anthropic tool-use returns null when no tool_use block');
// cardsFromAnthropicToolUse: wrong tool name
assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({ content: [{ type: 'tool_use', name: 'other_tool', input: {} }] }), null, 'Anthropic tool-use returns null for wrong tool name');
// cardsFromAnthropicToolUse: no content array
assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({}), null, 'Anthropic tool-use returns null for missing content');
assert.notStrictEqual(
settings.generationFingerprint({ ...settings.DEFAULT_SETTINGS, model: 'a' }),
settings.generationFingerprint({ ...settings.DEFAULT_SETTINGS, model: 'b' }),
'direct settings import exposes generation fingerprinting',
);
// applyApiProviderPreset: model swap when switching providers
const anthropicSettings = { ...settings.DEFAULT_SETTINGS, apiProvider: 'anthropic', model: 'claude-sonnet-4-6' };
const switchedToOpenAi = settings.applyApiProviderPreset(anthropicSettings, 'openai');
assert.strictEqual(switchedToOpenAi.apiProvider, 'openai', 'provider switched to openai');
assert.strictEqual(switchedToOpenAi.apiFormat, 'openai-chat', 'format updated for openai');
assert.strictEqual(switchedToOpenAi.model, 'gpt-5.1', 'model swapped to openai preset model');
// Keep custom model when switching providers
const customModelSettings = { ...settings.DEFAULT_SETTINGS, apiProvider: 'anthropic', model: 'my-custom-model' };
const switchedKeepModel = settings.applyApiProviderPreset(customModelSettings, 'openai');
assert.strictEqual(switchedKeepModel.model, 'my-custom-model', 'custom model preserved when switching providers');
// Swap model when current model matches previous preset's default
const openaiWithPresetModel = { ...settings.DEFAULT_SETTINGS, apiProvider: 'openai', model: 'gpt-5.1' };
const switchedToGoogle = settings.applyApiProviderPreset(openaiWithPresetModel, 'google');
assert.strictEqual(switchedToGoogle.model, 'gemini-3-pro-preview', 'model swapped when it matched previous preset');
assert.strictEqual(switchedToGoogle.apiFormat, 'google-generative-ai', 'format updated for google');
// Empty model triggers swap
const emptyModelSettings = { ...settings.DEFAULT_SETTINGS, apiProvider: 'anthropic', model: '' };
const switchedFromEmpty = settings.applyApiProviderPreset(emptyModelSettings, 'deepseek');
assert.strictEqual(switchedFromEmpty.model, 'deepseek-chat', 'empty model triggers swap to preset default');
// Does not mutate original settings
assert.strictEqual(anthropicSettings.model, 'claude-sonnet-4-6', 'applyApiProviderPreset does not mutate input');
assert.strictEqual(anthropicSettings.apiProvider, 'anthropic', 'original provider unchanged');
// --- cards.ts: resolveCardAnchors ---
const content = 'line0 hello\nline1 world\nline2 anchor here\nline3 end';
const rawCards = [
{ title: 'A', anchor: 'anchor here', gist: 'gA', bullets: ['b1'] },
{ title: 'B', anchor: 'hello', gist: 'gB', bullets: [] },
{ title: 'C', anchor: 'not_found', gist: 'gC', bullets: [] },
];
const resolved = cards.resolveCardAnchors(content, rawCards);
assert.strictEqual(resolved.length, 3, 'resolveCardAnchors returns all cards');
// B (line 0) should come first, then A (line 2), then C (unanchored, pushed to end)
assert.strictEqual(resolved[0].title, 'B', 'resolveCardAnchors: earliest anchor first');
assert.strictEqual(resolved[0].startLine, 0, 'resolveCardAnchors: B at line 0');
assert.strictEqual(resolved[1].title, 'A', 'resolveCardAnchors: second anchor');
assert.strictEqual(resolved[1].startLine, 2, 'resolveCardAnchors: A at line 2');
assert.strictEqual(resolved[2].title, 'C', 'resolveCardAnchors: unanchored pushed to end');
assert.ok(resolved[2].startLine < 0, 'resolveCardAnchors: unanchored has negative startLine');
// Empty input
assert.deepStrictEqual(cards.resolveCardAnchors('', []), [], 'resolveCardAnchors: empty input');
assert.deepStrictEqual(cards.resolveCardAnchors('some content', []), [], 'resolveCardAnchors: no cards');
// Null/undefined cards
assert.deepStrictEqual(cards.resolveCardAnchors('content', null), [], 'resolveCardAnchors: null cards');
assert.deepStrictEqual(cards.resolveCardAnchors('content', undefined), [], 'resolveCardAnchors: undefined cards');
// All unanchored cards maintain relative order (both at -1)
const allUnanchored = cards.resolveCardAnchors('content', [
{ title: 'X', anchor: 'zzz', gist: '', bullets: [] },
{ title: 'Y', anchor: 'qqq', gist: '', bullets: [] },
]);
assert.strictEqual(allUnanchored.length, 2, 'resolveCardAnchors: all unanchored');
assert.ok(allUnanchored[0].startLine < 0, 'resolveCardAnchors: X unanchored');
assert.ok(allUnanchored[1].startLine < 0, 'resolveCardAnchors: Y unanchored');
// Card level defaults to 2
assert.strictEqual(resolved[0].level, 2, 'resolveCardAnchors: level defaults to 2');
// --- settings.ts: getApiKey, cacheEntryMatches, normalizeCardCount ---
// normalizeCardCount edge cases
assert.strictEqual(settings.normalizeCardCount(5, 10), 5, 'normalizeCardCount: normal value');
assert.strictEqual(settings.normalizeCardCount(0, 10), 1, 'normalizeCardCount: zero clamps to 1');
assert.strictEqual(settings.normalizeCardCount(-5, 10), 1, 'normalizeCardCount: negative clamps to 1');
assert.strictEqual(settings.normalizeCardCount(100, 10), 30, 'normalizeCardCount: exceeds max clamps to 30');
assert.strictEqual(settings.normalizeCardCount(NaN, 7), 7, 'normalizeCardCount: NaN uses fallback');
assert.strictEqual(settings.normalizeCardCount(undefined, 7), 7, 'normalizeCardCount: undefined uses fallback');
assert.strictEqual(settings.normalizeCardCount('15', 7), 15, 'normalizeCardCount: string number coerced');
assert.strictEqual(settings.normalizeCardCount(1, 5), 1, 'normalizeCardCount: min boundary');
assert.strictEqual(settings.normalizeCardCount(30, 5), 30, 'normalizeCardCount: max boundary');
// getApiKey
assert.strictEqual(
settings.getApiKey({ ...settings.DEFAULT_SETTINGS, apiKey: 'direct-key' }),
'direct-key',
'getApiKey: direct key takes precedence',
);
assert.strictEqual(
settings.getApiKey({ ...settings.DEFAULT_SETTINGS, apiKey: '', apiKeyEnvVar: '' }),
'',
'getApiKey: no key returns empty string',
);
assert.strictEqual(
settings.getApiKey({ ...settings.DEFAULT_SETTINGS, apiKey: ' trimmed ' }),
'trimmed',
'getApiKey: trims whitespace from direct key',
);
// getApiKey with env var
const envVarName = '__PARALLEL_READER_TEST_KEY__';
process.env[envVarName] = 'env-key-val';
try {
assert.strictEqual(
settings.getApiKey({ ...settings.DEFAULT_SETTINGS, apiKey: '', apiKeyEnvVar: envVarName }),
'env-key-val',
'getApiKey: reads from env var when direct key empty',
);
assert.strictEqual(
settings.getApiKey({ ...settings.DEFAULT_SETTINGS, apiKey: 'direct', apiKeyEnvVar: envVarName }),
'direct',
'getApiKey: direct key wins over env var',
);
} finally {
delete process.env[envVarName];
}
// cacheEntryMatches
const testContent = 'hello world';
const testSettings = { ...settings.DEFAULT_SETTINGS };
const validEntry = {
schemaVersion: settings.CACHE_SCHEMA_VERSION,
contentHash: settings.hashContent(testContent),
settingsHash: settings.generationFingerprint(testSettings),
cards: [],
generatedAt: '2024-01-01T00:00:00.000Z',
};
assert.strictEqual(settings.cacheEntryMatches(validEntry, testContent, testSettings), true, 'cacheEntryMatches: valid entry matches');
assert.strictEqual(settings.cacheEntryMatches(null, testContent, testSettings), false, 'cacheEntryMatches: null entry returns false');
assert.strictEqual(
settings.cacheEntryMatches({ ...validEntry, contentHash: 'wrong' }, testContent, testSettings),
false,
'cacheEntryMatches: wrong content hash returns false',
);
assert.strictEqual(
settings.cacheEntryMatches({ ...validEntry, schemaVersion: 999 }, testContent, testSettings),
false,
'cacheEntryMatches: wrong schema version returns false',
);
assert.strictEqual(
settings.cacheEntryMatches(validEntry, 'different content', testSettings),
false,
'cacheEntryMatches: different content returns false',
);
// --- vault.ts: normalizeVaultPath and folderPathsForTarget ---
assert.strictEqual(vault.normalizeVaultPath('Reading/Articles'), 'Reading/Articles', 'normalizeVaultPath clean path unchanged');
assert.strictEqual(vault.normalizeVaultPath(' Reading / Articles '), 'Reading/Articles', 'normalizeVaultPath trims parts');
assert.strictEqual(vault.normalizeVaultPath('../bad/../path'), 'bad/path', 'normalizeVaultPath strips .. segments');
assert.strictEqual(vault.normalizeVaultPath('./relative/./path'), 'relative/path', 'normalizeVaultPath strips . segments');
assert.strictEqual(vault.normalizeVaultPath(''), '', 'normalizeVaultPath handles empty string');
assert.strictEqual(vault.normalizeVaultPath(null), '', 'normalizeVaultPath handles null');
assert.strictEqual(vault.normalizeVaultPath('single'), 'single', 'normalizeVaultPath handles single segment');
assert.strictEqual(vault.normalizeVaultPath('a//b///c'), 'a/b/c', 'normalizeVaultPath collapses empty segments');
assert.deepStrictEqual(vault.folderPathsForTarget('Reading/Articles'), ['Reading', 'Reading/Articles'], 'folderPathsForTarget returns ancestor chain');
assert.deepStrictEqual(vault.folderPathsForTarget('a/b/c'), ['a', 'a/b', 'a/b/c'], 'folderPathsForTarget 3-deep path');
assert.deepStrictEqual(vault.folderPathsForTarget('single'), ['single'], 'folderPathsForTarget single segment');
assert.deepStrictEqual(vault.folderPathsForTarget(''), [], 'folderPathsForTarget empty returns empty');
assert.deepStrictEqual(vault.folderPathsForTarget('../..'), [], 'folderPathsForTarget invalid path returns empty');
// --- i18n.ts: translate and resolveUiLanguage ---
// translate with explicit language
assert.strictEqual(i18n.translate({ uiLanguage: 'en' }, 'displayName'), 'Parallel Reader', 'translate returns en string');
assert.strictEqual(i18n.translate({ uiLanguage: 'zh' }, 'displayName'), '对照阅读笔记', 'translate returns zh string');
// translate with variable interpolation
assert.strictEqual(
i18n.translate({ uiLanguage: 'en' }, 'cacheClearedFile', { name: 'test.md' }),
'Cleared cache: test.md',
'translate interpolates variables',
);
assert.strictEqual(
i18n.translate({ uiLanguage: 'en' }, 'generationDone', { count: 5, suffix: '' }),
'Generated 5 sections',
'translate interpolates numeric variables',
);
// translate with multiple variables
assert.strictEqual(
i18n.translate({ uiLanguage: 'en' }, 'errorProviderApiStatus', { label: 'OpenAI', status: 429, excerpt: 'rate limited' }),
'OpenAI API returned HTTP 429: rate limited',
'translate interpolates multiple variables',
);
// translate with missing key returns key
assert.strictEqual(i18n.translate({ uiLanguage: 'en' }, 'nonExistentKey123'), 'nonExistentKey123', 'translate returns key for missing translation');
// translate with null settings defaults to some language
assert.ok(i18n.translate(null, 'displayName').length > 0, 'translate with null settings still returns a string');
// translate preserves unmatched placeholders
assert.strictEqual(
i18n.translate({ uiLanguage: 'en' }, 'cacheClearedFile'),
'Cleared cache: {name}',
'translate preserves unmatched placeholders when no vars provided',
);
// resolveUiLanguage
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'en' }), 'en', 'resolveUiLanguage returns en');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'zh' }), 'zh', 'resolveUiLanguage returns zh');
assert.strictEqual(i18n.resolveUiLanguage(null), 'en', 'resolveUiLanguage defaults to en for null');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'en', 'resolveUiLanguage auto falls back to en in Node');
// STRINGS structure validation
assert.ok(i18n.STRINGS.zh, 'STRINGS has zh locale');
assert.ok(i18n.STRINGS.en, 'STRINGS has en locale');
const zhKeys = Object.keys(i18n.STRINGS.zh);
const enKeys = Object.keys(i18n.STRINGS.en);
assert.strictEqual(zhKeys.length, enKeys.length, 'zh and en have same number of keys');
for (const key of zhKeys) {
assert.ok(i18n.STRINGS.en[key], `en missing key: ${key}`);
}
// --- parseSseBuffer unit tests ---
const openAiExtractor = streaming.deltaExtractorForFormat('openai-chat');
const anthropicExtractor = streaming.deltaExtractorForFormat('anthropic-messages');
// deltaExtractorForFormat returns correct extractors
assert.ok(openAiExtractor, 'deltaExtractorForFormat returns extractor for openai-chat');
assert.ok(anthropicExtractor, 'deltaExtractorForFormat returns extractor for anthropic-messages');
assert.strictEqual(streaming.deltaExtractorForFormat('unknown-format'), null, 'deltaExtractorForFormat returns null for unknown');
assert.strictEqual(streaming.deltaExtractorForFormat('google-generative-ai'), null, 'deltaExtractorForFormat returns null for non-streaming format');
// OpenAI delta extractor
assert.strictEqual(openAiExtractor({ choices: [{ delta: { content: 'hello' } }] }), 'hello', 'openai extractor gets content');
assert.strictEqual(openAiExtractor({ choices: [{ delta: {} }] }), '', 'openai extractor handles missing content');
assert.strictEqual(openAiExtractor({ choices: [] }), '', 'openai extractor handles empty choices');
assert.strictEqual(openAiExtractor({}), '', 'openai extractor handles empty json');
// Anthropic delta extractor
assert.strictEqual(anthropicExtractor({ type: 'content_block_delta', delta: { text: 'world' } }), 'world', 'anthropic extractor gets text');
assert.strictEqual(anthropicExtractor({ type: 'content_block_start' }), '', 'anthropic extractor ignores non-delta events');
assert.strictEqual(anthropicExtractor({ type: 'content_block_delta', delta: {} }), '', 'anthropic extractor handles empty delta');
assert.strictEqual(anthropicExtractor({}), '', 'anthropic extractor handles empty json');
// parseSseBuffer: basic single event
const singleEvent = streaming.parseSseBuffer('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', openAiExtractor);
assert.deepStrictEqual(singleEvent.deltas, ['hi'], 'parseSseBuffer extracts single delta');
assert.strictEqual(singleEvent.rest, '', 'parseSseBuffer returns empty rest after complete event');
// parseSseBuffer: multiple events
const multiEvent = streaming.parseSseBuffer(
'data: {"choices":[{"delta":{"content":"a"}}]}\n\ndata: {"choices":[{"delta":{"content":"b"}}]}\n\n',
openAiExtractor,
);
assert.deepStrictEqual(multiEvent.deltas, ['a', 'b'], 'parseSseBuffer extracts multiple deltas');
// parseSseBuffer: incomplete buffer
const incomplete = streaming.parseSseBuffer('data: {"choices":[{"delta":{"content":"partial"}}]}', openAiExtractor);
assert.deepStrictEqual(incomplete.deltas, [], 'parseSseBuffer returns no deltas for incomplete event');
assert.ok(incomplete.rest.length > 0, 'parseSseBuffer returns incomplete data as rest');
// parseSseBuffer: [DONE] sentinel
const withDone = streaming.parseSseBuffer('data: {"choices":[{"delta":{"content":"x"}}]}\n\ndata: [DONE]\n\n', openAiExtractor);
assert.deepStrictEqual(withDone.deltas, ['x'], 'parseSseBuffer ignores [DONE] sentinel');
// parseSseBuffer: non-data lines (comments, event types)
const withComments = streaming.parseSseBuffer(': keep-alive\nevent: message\ndata: {"choices":[{"delta":{"content":"ok"}}]}\n\n', openAiExtractor);
assert.deepStrictEqual(withComments.deltas, ['ok'], 'parseSseBuffer skips comment and event lines');
// parseSseBuffer: CRLF line endings
const crlfEvent = streaming.parseSseBuffer('data: {"choices":[{"delta":{"content":"crlf"}}]}\r\n\r\n', openAiExtractor);
assert.deepStrictEqual(crlfEvent.deltas, ['crlf'], 'parseSseBuffer handles CRLF line endings');
// parseSseBuffer: multi-line data
const multiLine = streaming.parseSseBuffer('data: {"choices":[{"delta":\ndata: {"content":"split"}}]}\n\n', openAiExtractor);
assert.deepStrictEqual(multiLine.deltas, ['split'], 'parseSseBuffer joins multi-line data fields');
// parseSseBuffer: malformed JSON
const malformed = streaming.parseSseBuffer('data: not-json\n\ndata: {"choices":[{"delta":{"content":"ok"}}]}\n\n', openAiExtractor);
assert.deepStrictEqual(malformed.deltas, ['ok'], 'parseSseBuffer skips malformed JSON events');
// parseSseBuffer: Anthropic format
const anthropicEvent = streaming.parseSseBuffer(
'data: {"type":"content_block_delta","delta":{"text":"ant"}}\n\ndata: {"type":"message_stop"}\n\n',
anthropicExtractor,
);
assert.deepStrictEqual(anthropicEvent.deltas, ['ant'], 'parseSseBuffer works with Anthropic format');
// parseSseBuffer: data: with no space after colon
const noSpaceData = streaming.parseSseBuffer('data:{"choices":[{"delta":{"content":"ns"}}]}\n\n', openAiExtractor);
assert.deepStrictEqual(noSpaceData.deltas, ['ns'], 'parseSseBuffer handles data: without trailing space');
// parseSseBuffer: empty buffer
const emptyBuf = streaming.parseSseBuffer('', openAiExtractor);
assert.deepStrictEqual(emptyBuf.deltas, [], 'parseSseBuffer handles empty buffer');
assert.strictEqual(emptyBuf.rest, '', 'parseSseBuffer returns empty rest for empty buffer');
function trackedSignal() {
const controller = new AbortController();
const signal = controller.signal;
let activeListeners = 0;
const addEventListener = signal.addEventListener.bind(signal);
const removeEventListener = signal.removeEventListener.bind(signal);
signal.addEventListener = (type, listener, options) => {
if (type === 'abort') activeListeners++;
return addEventListener(type, listener, options);
};
signal.removeEventListener = (type, listener, options) => {
if (type === 'abort') activeListeners--;
return removeEventListener(type, listener, options);
};
return { controller, signal, activeListeners: () => activeListeners };
}
function streamingBody(text) {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(text));
controller.close();
},
});
}
const originalFetch = globalThis.fetch;
try {
const success = trackedSignal();
globalThis.fetch = async () => ({
ok: true,
status: 200,
body: streamingBody('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n'),
text: async () => '',
});
await streaming.streamingFetch(
'https://example.test',
{},
{},
streaming.deltaExtractorForFormat('openai-chat'),
undefined,
success.signal,
{ streamingTimeoutMs: 1000 },
);
assert.strictEqual(success.activeListeners(), 0, 'streamingFetch removes abort listener after success');
const httpError = trackedSignal();
globalThis.fetch = async () => ({ ok: false, status: 500, body: null, text: async () => 'bad' });
await assert.rejects(
() =>
streaming.streamingFetch(
'https://example.test',
{},
{},
streaming.deltaExtractorForFormat('openai-chat'),
undefined,
httpError.signal,
{ streamingTimeoutMs: 1000 },
),
/HTTP 500|API returned HTTP 500/,
'streamingFetch rejects HTTP errors',
);
assert.strictEqual(httpError.activeListeners(), 0, 'streamingFetch removes abort listener after HTTP error');
const timeout = trackedSignal();
globalThis.fetch = async () => new Promise(() => {});
await assert.rejects(
() =>
streaming.streamingFetch(
'https://example.test',
{},
{},
streaming.deltaExtractorForFormat('openai-chat'),
undefined,
timeout.signal,
{ streamingTimeoutMs: 1 },
),
/Streaming timed out after 1ms/,
'streamingFetch rejects on timeout',
);
assert.strictEqual(timeout.activeListeners(), 0, 'streamingFetch removes abort listener after timeout');
// Abort before fetch starts (pre-aborted signal)
const preAborted = trackedSignal();
preAborted.controller.abort();
globalThis.fetch = async (_url, opts) => {
if (opts?.signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError');
throw new Error('should not reach');
};
await assert.rejects(
() =>
streaming.streamingFetch(
'https://example.test',
{},
{},
streaming.deltaExtractorForFormat('openai-chat'),
undefined,
preAborted.signal,
{ streamingTimeoutMs: 5000 },
),
/abort/i,
'streamingFetch rejects when signal is pre-aborted',
);
assert.strictEqual(preAborted.activeListeners(), 0, 'streamingFetch cleans up listeners on pre-aborted signal');
// Abort during read (signal aborted while reading body)
const abortDuringRead = trackedSignal();
globalThis.fetch = async (_url, opts) => {
const fetchSignal = opts?.signal;
const stream = new ReadableStream({
async pull(ctrl) {
const encoder = new TextEncoder();
// First chunk succeeds
ctrl.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"first"}}]}\n\n'));
// Abort after first chunk
abortDuringRead.controller.abort();
// Wait a tick so the abort propagates
await new Promise((r) => setTimeout(r, 5));
if (fetchSignal?.aborted) {
ctrl.error(new DOMException('The operation was aborted.', 'AbortError'));
return;
}
ctrl.close();
},
});
return { ok: true, status: 200, body: stream, text: async () => '' };
};
await assert.rejects(
() =>
streaming.streamingFetch(
'https://example.test',
{},
{},
streaming.deltaExtractorForFormat('openai-chat'),
undefined,
abortDuringRead.signal,
{ streamingTimeoutMs: 5000 },
),
/abort/i,
'streamingFetch rejects when signal is aborted during read',
);
assert.strictEqual(abortDuringRead.activeListeners(), 0, 'streamingFetch cleans up listeners after mid-read abort');
} finally {
globalThis.fetch = originalFetch;
}
console.log('direct module tests passed');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})().catch((e) => {
console.error(e);
process.exit(1);
});