mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
refactor: split main.test.js into focused test files
Reduce main.test.js from 560 to 70 lines by keeping only architecture guards and export smoke tests. Moved provider error tests, Anthropic tool-use parsing, and cancellationNoticeKey tests to providers.test.js. Settings, cache, and other tests were already covered in their dedicated test files. Change-Id: I8f0f3c576dec7fcf5a4c6e0c2b6071601940349d
This commit is contained in:
parent
f5f974fd74
commit
199ab73188
2 changed files with 174 additions and 529 deletions
|
|
@ -1,9 +1,15 @@
|
|||
const crypto = require('crypto');
|
||||
/**
|
||||
* Architecture guard tests — verify source code structure invariants
|
||||
* that cannot be expressed via TypeScript types or lint rules.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { assert, t } = require('./test-setup');
|
||||
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.ts'), 'utf8');
|
||||
const viewSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'view.ts'), 'utf8');
|
||||
|
||||
// View lifecycle guards
|
||||
assert.ok(!/\basync\s+onOpen\s*\(/.test(viewSource), 'ParallelReaderView.onOpen should not be async without await');
|
||||
assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(viewSource), 'empty onClose should not be async');
|
||||
assert.ok(/focusSummaryPane\s*\(\)/.test(viewSource), 'summary pane should expose a focus helper');
|
||||
|
|
@ -12,6 +18,8 @@ assert.ok(
|
|||
'summary pane focus should not scroll the page',
|
||||
);
|
||||
assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(viewSource), 'card navigation should focus the summary pane');
|
||||
|
||||
// Cache debounce guards
|
||||
assert.ok(/scheduleCacheSave\s*\(/.test(mainSource), 'cache touch should use a debounced cache save path');
|
||||
assert.ok(/flushCacheSave\s*\(/.test(mainSource), 'pending cache touches should be flushable');
|
||||
assert.ok(/onunload[\s\S]*flushCacheSave/.test(mainSource), 'plugin unload should flush pending cache touches');
|
||||
|
|
@ -25,537 +33,52 @@ assert.ok(
|
|||
!/handleFileRename[\s\S]*cacheManager\.cache\[/.test(mainSource),
|
||||
'file rename should not mutate cache directly',
|
||||
);
|
||||
|
||||
// Module extraction guards
|
||||
assert.ok(!/function addIconButton/.test(mainSource), 'UI icon helper should live outside main.ts');
|
||||
assert.ok(!/function addTextButton/.test(mainSource), 'UI text-button helper should live outside main.ts');
|
||||
assert.ok(!/function copyToClipboard/.test(mainSource), 'clipboard helper should live outside main.ts');
|
||||
assert.strictEqual(typeof t.cardsToMarkdown, 'function');
|
||||
assert.strictEqual(typeof t.cancellationNoticeKey, 'function');
|
||||
assert.strictEqual(typeof t.summarizeDocument, 'function');
|
||||
assert.strictEqual(typeof t.addIconButton, 'function');
|
||||
assert.strictEqual(typeof t.addTextButton, 'function');
|
||||
assert.strictEqual(typeof t.copyToClipboard, 'function');
|
||||
assert.strictEqual(typeof t.resolveCliPath, 'function');
|
||||
assert.strictEqual(typeof t.runCli, 'function');
|
||||
assert.strictEqual(typeof t.buildPrompts, 'function');
|
||||
assert.strictEqual(typeof t.buildOpenAiChatBody, 'function');
|
||||
assert.strictEqual(typeof t.extractJson, 'function');
|
||||
assert.strictEqual(typeof t.findLineForAnchor, 'function');
|
||||
assert.strictEqual(typeof t.folderPathsForTarget, 'function');
|
||||
assert.strictEqual(typeof t.getApiBaseUrl, 'function');
|
||||
assert.strictEqual(typeof t.generationFingerprint, 'function');
|
||||
assert.strictEqual(typeof t.hasUnsafeBatchFolderSegments, 'function');
|
||||
assert.strictEqual(typeof t.CacheManager, 'function');
|
||||
assert.strictEqual(typeof t.GenerationJobManager, 'function');
|
||||
assert.strictEqual(typeof t.createBatchRunState, 'function');
|
||||
assert.strictEqual(typeof t.modelForApi, 'function');
|
||||
assert.strictEqual(typeof t.activeSectionLine, 'function');
|
||||
assert.strictEqual(typeof t.touchCacheEntry, 'function');
|
||||
assert.strictEqual(typeof t.nextCardIndex, 'function');
|
||||
assert.strictEqual(typeof t.pruneCacheEntries, 'function');
|
||||
assert.strictEqual(typeof t.removeCardAt, 'function');
|
||||
assert.strictEqual(typeof t.activeIndexAfterCardDelete, 'function');
|
||||
assert.strictEqual(typeof t.createRafThrottledHandler, 'function');
|
||||
assert.strictEqual(typeof t.visibleTopProbeY, 'function');
|
||||
assert.strictEqual(typeof t.serializeCacheFile, 'function');
|
||||
assert.strictEqual(typeof t.shouldConfirmRegenerate, 'function');
|
||||
assert.strictEqual(typeof t.translate, 'function');
|
||||
assert.strictEqual(typeof t.updateCardAt, 'function');
|
||||
assert.strictEqual(typeof t.validateBatchFolderInput, 'function');
|
||||
assert.strictEqual(typeof t.normalizeSettings, 'function');
|
||||
assert.strictEqual(typeof t.normalizeStreamingTimeoutMs, 'function');
|
||||
|
||||
const baseSettings = {
|
||||
backend: 'api',
|
||||
apiProvider: 'openai',
|
||||
apiFormat: 'openai-chat',
|
||||
apiBaseUrl: 'https://api.openai.com/v1',
|
||||
apiAuthType: 'bearer',
|
||||
apiKey: 'test-key',
|
||||
apiMaxTokens: 123,
|
||||
model: 'openai/gpt-5.1',
|
||||
};
|
||||
|
||||
assert.notStrictEqual(
|
||||
t.generationFingerprint(baseSettings),
|
||||
t.generationFingerprint({ ...baseSettings, model: 'openai/gpt-5.2' }),
|
||||
'cache fingerprint should change when model changes',
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
t.generationFingerprint(baseSettings),
|
||||
t.generationFingerprint({ ...baseSettings, maxDocChars: 50000 }),
|
||||
'cache fingerprint should change when input truncation limit changes',
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
t.generationFingerprint(baseSettings),
|
||||
t.generationFingerprint({ ...baseSettings, promptLanguage: 'en' }),
|
||||
'cache fingerprint should change when prompt language changes',
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
t.generationFingerprint(baseSettings),
|
||||
t.generationFingerprint({ ...baseSettings, minCards: 3, maxCards: 8 }),
|
||||
'cache fingerprint should change when card count range changes',
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
t.generationFingerprint(baseSettings),
|
||||
t.generationFingerprint({ ...baseSettings, customSystemPrompt: 'custom prompt' }),
|
||||
'cache fingerprint should change when custom prompt changes',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.generationFingerprint(baseSettings),
|
||||
t.generationFingerprint({ ...baseSettings, uiLanguage: 'en' }),
|
||||
'cache fingerprint should not change when UI language changes',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }),
|
||||
'cancelRequestedApiInFlight',
|
||||
'API cancellation should explain that the in-flight network request cannot be aborted',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cancellationNoticeKey({ backend: 'claude-code' }, { phase: 'generating' }),
|
||||
'cancelRequested',
|
||||
'CLI cancellation can use the generic cancellation notice',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'reading' }),
|
||||
'cancelRequested',
|
||||
'API cancellation outside the request phase can use the generic notice',
|
||||
);
|
||||
assert.strictEqual(t.resolveCliPath('codex', ' /tmp/codex '), '/tmp/codex');
|
||||
assert.throws(
|
||||
() => t.modelForApi({ ...baseSettings, model: '', uiLanguage: 'en' }),
|
||||
/Model is not set/,
|
||||
'settings errors should respect English UI mode',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
t.getApiBaseUrl({
|
||||
...baseSettings,
|
||||
apiProvider: 'custom-openai-compatible',
|
||||
apiBaseUrl: '',
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/Custom provider requires an API Base URL/,
|
||||
'custom provider base URL errors should respect English UI mode',
|
||||
);
|
||||
|
||||
const contentHash = crypto.createHash('sha1').update('hello', 'utf8').digest('hex');
|
||||
assert.strictEqual(
|
||||
t.cacheEntryMatches(
|
||||
{
|
||||
schemaVersion: t.CACHE_SCHEMA_VERSION,
|
||||
contentHash,
|
||||
settingsHash: t.generationFingerprint(baseSettings),
|
||||
},
|
||||
'hello',
|
||||
baseSettings,
|
||||
),
|
||||
true,
|
||||
'cache should match content, schema version, and generation fingerprint',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cacheEntryMatches(
|
||||
{
|
||||
schemaVersion: t.CACHE_SCHEMA_VERSION,
|
||||
contentHash,
|
||||
settingsHash: t.generationFingerprint({ ...baseSettings, model: 'openai/gpt-5.2' }),
|
||||
},
|
||||
'hello',
|
||||
baseSettings,
|
||||
),
|
||||
false,
|
||||
'cache should miss when generation settings change',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cacheEntryMatches(
|
||||
{
|
||||
schemaVersion: t.CACHE_SCHEMA_VERSION - 1,
|
||||
contentHash,
|
||||
settingsHash: t.generationFingerprint(baseSettings),
|
||||
},
|
||||
'hello',
|
||||
baseSettings,
|
||||
),
|
||||
false,
|
||||
'cache should miss when schema version changes',
|
||||
);
|
||||
|
||||
const cacheForPrune = {
|
||||
'old.md': { generatedAt: '2024-01-01T00:00:00.000Z' },
|
||||
'new.md': { generatedAt: '2024-01-03T00:00:00.000Z' },
|
||||
'touched.md': {
|
||||
generatedAt: '2024-01-02T00:00:00.000Z',
|
||||
lastAccessedAt: '2024-01-04T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
assert.deepStrictEqual(t.pruneCacheEntries(cacheForPrune, 2), ['old.md']);
|
||||
assert.deepStrictEqual(Object.keys(cacheForPrune).sort(), ['new.md', 'touched.md']);
|
||||
|
||||
assert.strictEqual(
|
||||
t.findLineForAnchor('intro\nAlpha beta\nGamma\tDelta\nlast', 'Alpha beta Gamma Delta'),
|
||||
1,
|
||||
'whitespace-normalized anchor fallback should map back to the original source line',
|
||||
);
|
||||
|
||||
const englishPrompt = t.buildPrompts('Hello world', {
|
||||
...baseSettings,
|
||||
promptLanguage: 'en',
|
||||
minCards: 2,
|
||||
maxCards: 4,
|
||||
});
|
||||
assert.ok(englishPrompt.system.includes('2-4'));
|
||||
assert.ok(englishPrompt.system.includes('Write title, gist, and bullets in English.'));
|
||||
assert.ok(englishPrompt.system.includes('You are a long-form reading summary assistant.'));
|
||||
assert.ok(!englishPrompt.system.includes('你是一个长文阅读摘要助手'));
|
||||
assert.ok(englishPrompt.user.startsWith('Source document:'));
|
||||
|
||||
const customPrompt = t.buildPrompts('Hello world', {
|
||||
...baseSettings,
|
||||
promptLanguage: 'auto',
|
||||
minCards: 1,
|
||||
maxCards: 2,
|
||||
customSystemPrompt: 'Make {minCards}-{maxCards} cards. {languageInstruction}',
|
||||
});
|
||||
assert.ok(customPrompt.system.includes('Make 1-2 cards.'));
|
||||
assert.ok(customPrompt.system.includes('不可覆盖的输出契约'));
|
||||
assert.ok(customPrompt.system.includes('JSON shape'));
|
||||
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdOpenView'), 'Open Parallel Reader pane');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'cmdOpenView'), '打开对照笔记面板');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 3 }), 'Cleared 3 cache entries');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdCardNext'), 'Focus next summary card');
|
||||
|
||||
assert.strictEqual(t.nextCardIndex(-1, 3, 1), 0);
|
||||
assert.strictEqual(t.nextCardIndex(-1, 3, -1), 2);
|
||||
assert.strictEqual(t.nextCardIndex(0, 3, 1), 1);
|
||||
assert.strictEqual(t.nextCardIndex(2, 3, 1), 2);
|
||||
assert.strictEqual(t.nextCardIndex(0, 3, -1), 0);
|
||||
assert.strictEqual(t.nextCardIndex(0, 0, 1), -1);
|
||||
assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 0), 3);
|
||||
assert.strictEqual(t.activeSectionLine([{ startLine: -1 }], 0), -1);
|
||||
assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 1), -1);
|
||||
const scheduledFrames = [];
|
||||
let throttledCalls = 0;
|
||||
const throttled = t.createRafThrottledHandler(
|
||||
() => {
|
||||
throttledCalls += 1;
|
||||
},
|
||||
(cb) => {
|
||||
scheduledFrames.push(cb);
|
||||
return scheduledFrames.length;
|
||||
},
|
||||
);
|
||||
throttled();
|
||||
throttled();
|
||||
throttled();
|
||||
assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be scheduled at most once per frame');
|
||||
assert.strictEqual(throttledCalls, 0, 'throttled handler should not run synchronously');
|
||||
scheduledFrames.shift()(0);
|
||||
assert.strictEqual(throttledCalls, 1);
|
||||
throttled();
|
||||
assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be schedulable after the frame runs');
|
||||
assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 300 }), 130);
|
||||
assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 1200 }), 180);
|
||||
const cardList = [{ title: 'A' }, { title: 'B' }, { title: 'C' }];
|
||||
assert.deepStrictEqual(t.removeCardAt(cardList, 1), [{ title: 'A' }, { title: 'C' }]);
|
||||
assert.deepStrictEqual(t.removeCardAt(cardList, -1), cardList);
|
||||
assert.deepStrictEqual(t.removeCardAt(cardList, 3), cardList);
|
||||
assert.notStrictEqual(t.removeCardAt(cardList, 3), cardList);
|
||||
assert.strictEqual(t.activeIndexAfterCardDelete(1, 3, 1), 1, 'deleting active card should select the next card');
|
||||
assert.strictEqual(
|
||||
t.activeIndexAfterCardDelete(2, 3, 2),
|
||||
1,
|
||||
'deleting the last active card should select the previous card',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.activeIndexAfterCardDelete(1, 3, 2),
|
||||
1,
|
||||
'deleting before active card should shift active index left',
|
||||
);
|
||||
assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 0), 0, 'deleting after active card should keep active index');
|
||||
assert.deepStrictEqual(t.updateCardAt(cardList, 1, { title: 'B2', gist: 'G', bullets: ['x'] }), [
|
||||
{ title: 'A' },
|
||||
{ title: 'B2', gist: 'G', bullets: ['x'] },
|
||||
{ title: 'C' },
|
||||
]);
|
||||
assert.deepStrictEqual(t.updateCardAt(cardList, -1, { title: 'X' }), cardList);
|
||||
assert.deepStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList);
|
||||
assert.notStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList);
|
||||
assert.deepStrictEqual(t.folderPathsForTarget('Reading/Articles'), ['Reading', 'Reading/Articles']);
|
||||
assert.deepStrictEqual(t.folderPathsForTarget('/Reading//Articles/'), ['Reading', 'Reading/Articles']);
|
||||
assert.deepStrictEqual(t.folderPathsForTarget('Reading'), ['Reading']);
|
||||
assert.deepStrictEqual(t.folderPathsForTarget(''), []);
|
||||
const untouchedCacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z' };
|
||||
assert.strictEqual(t.touchCacheEntry(null), null);
|
||||
const touchedEntry = t.touchCacheEntry(untouchedCacheEntry, '2024-01-05T00:00:00.000Z');
|
||||
assert.notStrictEqual(touchedEntry, untouchedCacheEntry, 'touchCacheEntry returns new object');
|
||||
assert.strictEqual(touchedEntry.lastAccessedAt, '2024-01-05T00:00:00.000Z', 'touched entry has updated timestamp');
|
||||
assert.strictEqual(untouchedCacheEntry.lastAccessedAt, undefined, 'original entry not mutated');
|
||||
assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-05T00:00:00.000Z' }, true), true);
|
||||
assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-05T00:00:00.000Z' }, false), false);
|
||||
assert.strictEqual(t.shouldConfirmRegenerate({ generatedAt: '2024-01-01T00:00:00.000Z' }, true), false);
|
||||
assert.strictEqual(t.shouldConfirmRegenerate(null, true), false);
|
||||
const serializedCache = t.serializeCacheFile({
|
||||
'note.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [{ title: 'A' }] },
|
||||
});
|
||||
assert.strictEqual(serializedCache.includes('\n'), false, 'cache.json should be compact');
|
||||
assert.deepStrictEqual(JSON.parse(serializedCache).entries['note.md'].cards, [{ title: 'A' }]);
|
||||
|
||||
const noisyJson =
|
||||
'说明文字 {"cards":[{"title":"A","anchor":"保留 { 花括号 } 字符","gist":"G","bullets":["B"]}]} trailing';
|
||||
const extracted = t.extractJson(noisyJson);
|
||||
assert.deepStrictEqual(JSON.parse(extracted).cards[0].bullets, ['B']);
|
||||
|
||||
const openAiChatBody = t.buildOpenAiChatBody('system JSON', 'user', baseSettings);
|
||||
assert.strictEqual(openAiChatBody.max_completion_tokens, 123);
|
||||
assert.strictEqual(openAiChatBody.max_tokens, undefined);
|
||||
assert.strictEqual(openAiChatBody.response_format.type, 'json_schema');
|
||||
|
||||
const compatChatBody = t.buildOpenAiChatBody('system JSON', 'user', {
|
||||
...baseSettings,
|
||||
apiProvider: 'openrouter',
|
||||
model: 'openrouter/anthropic/claude-sonnet-4-5',
|
||||
});
|
||||
assert.strictEqual(compatChatBody.max_tokens, 123);
|
||||
assert.strictEqual(compatChatBody.max_completion_tokens, undefined);
|
||||
assert.strictEqual(compatChatBody.response_format.type, 'json_schema');
|
||||
|
||||
const responsesBody = t.buildOpenAiResponsesBody('system JSON', 'user', baseSettings);
|
||||
assert.strictEqual(responsesBody.text.format.type, 'json_schema');
|
||||
|
||||
const geminiBody = t.buildGeminiBody('system JSON', 'user', {
|
||||
...baseSettings,
|
||||
apiProvider: 'google',
|
||||
apiFormat: 'google-generative-ai',
|
||||
});
|
||||
assert.strictEqual(geminiBody.generationConfig.responseMimeType, 'application/json');
|
||||
assert.strictEqual(geminiBody.generationConfig.responseJsonSchema.type, 'object');
|
||||
|
||||
const anthropicBody = t.buildAnthropicMessagesBody('system JSON', 'user', {
|
||||
...baseSettings,
|
||||
apiProvider: 'anthropic',
|
||||
apiFormat: 'anthropic-messages',
|
||||
model: 'anthropic/claude-sonnet-4-6',
|
||||
});
|
||||
assert.strictEqual(anthropicBody.tools[0].name, 'record_parallel_reader_cards');
|
||||
assert.strictEqual(anthropicBody.tool_choice.name, 'record_parallel_reader_cards');
|
||||
assert.strictEqual(
|
||||
t.buildAnthropicMessagesBody('system JSON', 'user', baseSettings, { structured: false }).tools,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const markdown = t.cardsToMarkdown('Example', [
|
||||
{
|
||||
title: '第一段',
|
||||
anchor: '原文引用',
|
||||
gist: '核心摘要',
|
||||
bullets: ['要点 A', '要点 B'],
|
||||
},
|
||||
]);
|
||||
assert.ok(markdown.includes('# Example'));
|
||||
assert.ok(markdown.includes('## 第一段'));
|
||||
assert.ok(markdown.includes('- 要点 A'));
|
||||
|
||||
async function testOpenAiStructuredFallback() {
|
||||
const calls = [];
|
||||
const requestUrlImpl = async (req) => {
|
||||
const body = JSON.parse(req.body);
|
||||
calls.push(body);
|
||||
if (calls.length === 1) {
|
||||
return { status: 400, text: 'unsupported response_format json_schema' };
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '{"cards":[{"title":"T","anchor":"A","gist":"G","bullets":["B"]}]}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const originalWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
let cards;
|
||||
try {
|
||||
cards = await t.summarizeViaApi(requestUrlImpl, 'system JSON', 'user', baseSettings);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
assert.strictEqual(calls.length, 2);
|
||||
assert.strictEqual(calls[0].response_format.type, 'json_schema');
|
||||
assert.strictEqual(calls[1].response_format, undefined);
|
||||
assert.deepStrictEqual(cards, [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }]);
|
||||
// Export surface smoke test
|
||||
const expectedExports = [
|
||||
'cardsToMarkdown',
|
||||
'cancellationNoticeKey',
|
||||
'summarizeDocument',
|
||||
'addIconButton',
|
||||
'addTextButton',
|
||||
'copyToClipboard',
|
||||
'resolveCliPath',
|
||||
'runCli',
|
||||
'buildPrompts',
|
||||
'buildOpenAiChatBody',
|
||||
'extractJson',
|
||||
'findLineForAnchor',
|
||||
'folderPathsForTarget',
|
||||
'getApiBaseUrl',
|
||||
'generationFingerprint',
|
||||
'hasUnsafeBatchFolderSegments',
|
||||
'CacheManager',
|
||||
'GenerationJobManager',
|
||||
'createBatchRunState',
|
||||
'modelForApi',
|
||||
'activeSectionLine',
|
||||
'touchCacheEntry',
|
||||
'nextCardIndex',
|
||||
'pruneCacheEntries',
|
||||
'removeCardAt',
|
||||
'activeIndexAfterCardDelete',
|
||||
'createRafThrottledHandler',
|
||||
'visibleTopProbeY',
|
||||
'serializeCacheFile',
|
||||
'shouldConfirmRegenerate',
|
||||
'translate',
|
||||
'updateCardAt',
|
||||
'validateBatchFolderInput',
|
||||
'normalizeSettings',
|
||||
'normalizeStreamingTimeoutMs',
|
||||
];
|
||||
for (const name of expectedExports) {
|
||||
assert.strictEqual(typeof t[name], 'function', `test-exports should include ${name}`);
|
||||
}
|
||||
|
||||
async function testProviderMissingApiKeyUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(
|
||||
async () => {
|
||||
throw new Error('request should not be sent');
|
||||
},
|
||||
'system JSON',
|
||||
'user',
|
||||
{
|
||||
...baseSettings,
|
||||
apiKey: '',
|
||||
apiKeyEnvVar: '',
|
||||
uiLanguage: 'en',
|
||||
},
|
||||
),
|
||||
/API key is not set/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderHeaderJsonErrorUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(
|
||||
async () => {
|
||||
throw new Error('request should not be sent');
|
||||
},
|
||||
'system JSON',
|
||||
'user',
|
||||
{
|
||||
...baseSettings,
|
||||
apiHeaders: '{"x":',
|
||||
uiLanguage: 'en',
|
||||
},
|
||||
),
|
||||
/Custom headers JSON parse failed/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderResponseNonJsonUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(
|
||||
async () => ({
|
||||
status: 200,
|
||||
text: '<html>not json</html>',
|
||||
}),
|
||||
'system JSON',
|
||||
'user',
|
||||
{
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
},
|
||||
),
|
||||
/OpenAI-compatible Chat returned non-JSON/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderRequestFailureUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(
|
||||
async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
'system JSON',
|
||||
'user',
|
||||
{
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
},
|
||||
),
|
||||
/OpenAI-compatible Chat request failed: network down/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderApiStatusErrorUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(
|
||||
async () => ({
|
||||
status: 500,
|
||||
text: 'upstream exploded',
|
||||
}),
|
||||
'system JSON',
|
||||
'user',
|
||||
{
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
},
|
||||
),
|
||||
/OpenAI-compatible Chat API returned HTTP 500: upstream exploded/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testSchemaNonJsonErrorUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(
|
||||
async () => ({
|
||||
status: 200,
|
||||
json: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: 'not json',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'system JSON',
|
||||
'user',
|
||||
{
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
},
|
||||
),
|
||||
/LLM returned non-JSON/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testAnthropicToolUseParsing() {
|
||||
const requestUrlImpl = async (req) => {
|
||||
const body = JSON.parse(req.body);
|
||||
assert.strictEqual(body.tools[0].name, 'record_parallel_reader_cards');
|
||||
assert.strictEqual(body.tool_choice.name, 'record_parallel_reader_cards');
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'record_parallel_reader_cards',
|
||||
input: {
|
||||
cards: [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const cards = await t.summarizeViaApi(requestUrlImpl, 'system JSON', 'user', {
|
||||
...baseSettings,
|
||||
apiProvider: 'anthropic',
|
||||
apiFormat: 'anthropic-messages',
|
||||
apiBaseUrl: 'https://api.anthropic.com/v1',
|
||||
apiAuthType: 'x-api-key',
|
||||
model: 'anthropic/claude-sonnet-4-6',
|
||||
});
|
||||
assert.deepStrictEqual(cards, [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }]);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await testOpenAiStructuredFallback();
|
||||
await testProviderMissingApiKeyUsesEnglishUi();
|
||||
await testProviderHeaderJsonErrorUsesEnglishUi();
|
||||
await testProviderResponseNonJsonUsesEnglishUi();
|
||||
await testProviderRequestFailureUsesEnglishUi();
|
||||
await testProviderApiStatusErrorUsesEnglishUi();
|
||||
await testSchemaNonJsonErrorUsesEnglishUi();
|
||||
await testAnthropicToolUseParsing();
|
||||
console.log('tests passed');
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
console.log('tests passed');
|
||||
|
|
|
|||
|
|
@ -221,10 +221,132 @@ async function testStructuredOutputNonRetryableError() {
|
|||
}
|
||||
}
|
||||
|
||||
async function testProviderMissingApiKey() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(
|
||||
async () => {
|
||||
throw new Error('request should not be sent');
|
||||
},
|
||||
'system JSON',
|
||||
'user',
|
||||
{ ...baseSettings, apiKey: '', apiKeyEnvVar: '', uiLanguage: 'en' },
|
||||
),
|
||||
/API key is not set/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderHeaderJsonError() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(
|
||||
async () => {
|
||||
throw new Error('request should not be sent');
|
||||
},
|
||||
'system JSON',
|
||||
'user',
|
||||
{ ...baseSettings, apiHeaders: '{"x":', uiLanguage: 'en' },
|
||||
),
|
||||
/Custom headers JSON parse failed/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderResponseNonJson() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(async () => ({ status: 200, text: '<html>not json</html>' }), 'system JSON', 'user', {
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/OpenAI-compatible Chat returned non-JSON/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderRequestFailure() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(
|
||||
async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
'system JSON',
|
||||
'user',
|
||||
{ ...baseSettings, uiLanguage: 'en' },
|
||||
),
|
||||
/OpenAI-compatible Chat request failed: network down/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderApiStatusError() {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.summarizeViaApi(async () => ({ status: 500, text: 'upstream exploded' }), 'system JSON', 'user', {
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/OpenAI-compatible Chat API returned HTTP 500: upstream exploded/,
|
||||
);
|
||||
}
|
||||
|
||||
async function testAnthropicToolUseParsing() {
|
||||
const cards = await t.summarizeViaApi(
|
||||
async (req) => {
|
||||
const body = JSON.parse(req.body);
|
||||
assert.strictEqual(body.tools[0].name, 'record_parallel_reader_cards');
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'record_parallel_reader_cards',
|
||||
input: { cards: [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
'system JSON',
|
||||
'user',
|
||||
{
|
||||
...baseSettings,
|
||||
apiProvider: 'anthropic',
|
||||
apiFormat: 'anthropic-messages',
|
||||
apiBaseUrl: 'https://api.anthropic.com/v1',
|
||||
apiAuthType: 'x-api-key',
|
||||
model: 'anthropic/claude-sonnet-4-6',
|
||||
},
|
||||
);
|
||||
assert.deepStrictEqual(cards, [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }]);
|
||||
}
|
||||
|
||||
// cancellationNoticeKey
|
||||
assert.strictEqual(
|
||||
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }),
|
||||
'cancelRequestedApiInFlight',
|
||||
'API cancellation should explain that the in-flight network request cannot be aborted',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cancellationNoticeKey({ backend: 'claude-code' }, { phase: 'generating' }),
|
||||
'cancelRequested',
|
||||
'CLI cancellation can use the generic cancellation notice',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'reading' }),
|
||||
'cancelRequested',
|
||||
'API cancellation outside the request phase can use the generic notice',
|
||||
);
|
||||
|
||||
(async () => {
|
||||
await testSummarizeDocumentAnchorSorting();
|
||||
await testStructuredOutputFallback();
|
||||
await testStructuredOutputNonRetryableError();
|
||||
await testProviderMissingApiKey();
|
||||
await testProviderHeaderJsonError();
|
||||
await testProviderResponseNonJson();
|
||||
await testProviderRequestFailure();
|
||||
await testProviderApiStatusError();
|
||||
await testAnthropicToolUseParsing();
|
||||
console.log('providers tests passed');
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
|
|
|
|||
Loading…
Reference in a new issue