fancive_obsidian-parallel-r.../tests/direct-prompt.test.js
wujunchen 5f36b1218b feat: provider/lifecycle robustness, onboarding nudge, CI coverage gate
Assessment-driven batch of 10 verified, low-risk improvements. All gates green
(typecheck, biome, obsidian lint + strict review, 28 test files, branch
coverage 100%, e2e gate).

Correctness:
- provider-request: decide structured-output fallback on HTTP status via a new
  ProviderApiError (status+body) instead of pattern-matching the i18n-translated
  message — previously only en/zh matched, so fr/de/es/ja/ko users hit silent
  permanent failures when a provider rejected json_schema.
- streaming: surface in-stream provider error payloads ({type:'error'} /
  {error:{}}) by throwing, instead of swallowing them and later misreporting a
  transient overload/quota error as "non-JSON LLM output". Note: detection runs
  outside the JSON.parse try/catch so the throw is not swallowed.
- main: throwIfCancelled before cacheManager.put so a cancelled job cannot
  poison the cache.
- generation-job-manager: add cancelAll(); onunload now cancels in-flight jobs
  (aborting streaming HTTP + SIGKILL-ing CLI children), not just queued waiters.

Onboarding / UX:
- settings: DEFAULT_SETTINGS.promptLanguage 'zh' -> 'auto' so new non-Chinese
  users get source-language summaries by default (existing users unaffected).
- view/main/types: first-run "Set up AI provider" CTA in the empty state when no
  credential is configured (PluginHost.openSettings + isCredentialConfigured).
- error-ui/generation-job-manager/types: new 'network' ErrorKind with an
  actionable notice + Retry for offline/connection failures.
- i18n-strings: 4 new keys across all 7 locales (parity test enforced).

CI / docs:
- .c8rc.json + package.json: branch-coverage gate (check-coverage, branches=100,
  degenerate metrics disabled) and preserve c8's exit code in the coverage script.
- ci.yml: run coverage gate and strict obsidian review in CI.
- README: fix Obsidian version badge 1.4.0 -> 1.8.7 (matches manifest).
- e2e product-shell DOM shim: add createSpan (was missing; real Obsidian has it).

Tests: cover the status-based fallback (incl. non-English locale), in-stream
error throwing, cancelAll, and network classification.

Change-Id: Ic619098aa7cdf3dc1c444be4bb8a445550eadf55
2026-06-16 09:37:59 +08:00

138 lines
8.1 KiB
JavaScript

const { assert, requireBundledModule, cleanup } = require('./direct-test-setup');
(async () => {
try {
const prompt = await requireBundledModule('src/prompt.ts');
const settings = await requireBundledModule('src/settings.ts');
const base = { ...settings.DEFAULT_SETTINGS, promptLanguage: 'en', minCards: 3, maxCards: 7 };
// ── promptLanguageInstruction ──
assert.strictEqual(prompt.promptLanguageInstruction('en'), 'Write title, gist, and bullets in English.');
assert.strictEqual(prompt.promptLanguageInstruction('zh'), '用中文输出 title、gist 和 bullets。');
assert.strictEqual(
prompt.promptLanguageInstruction('auto'),
'Write title, gist, and bullets in the main language of the source document.',
);
assert.strictEqual(prompt.promptLanguageInstruction('ja'), 'Write title, gist, and bullets in Japanese.');
assert.strictEqual(prompt.promptLanguageInstruction('ko'), 'Write title, gist, and bullets in Korean.');
assert.strictEqual(prompt.promptLanguageInstruction('fr'), 'Write title, gist, and bullets in French.');
assert.strictEqual(prompt.promptLanguageInstruction('de'), 'Write title, gist, and bullets in German.');
assert.strictEqual(prompt.promptLanguageInstruction('es'), 'Write title, gist, and bullets in Spanish.');
assert.strictEqual(
prompt.promptLanguageInstruction('xx'),
'用中文输出 title、gist 和 bullets。',
'unknown language falls to zh',
);
// ── promptSchemaExample ──
const enExample = prompt.promptSchemaExample('en');
assert.ok(enExample.includes('U-shaped gains'), 'en example has expected title');
assert.ok(enExample.includes('"cards"'), 'en example is JSON-like');
const zhExample = prompt.promptSchemaExample('zh');
assert.ok(zhExample.includes('U 型收益曲线'), 'zh example has expected title');
assert.ok(zhExample.includes('"cards"'), 'zh example is JSON-like');
assert.ok(prompt.promptSchemaExample('auto').includes('U 型收益曲线'), 'auto falls to zh example');
assert.ok(prompt.promptSchemaExample('ja').includes('U字型の利益'), 'ja example has expected title');
assert.ok(prompt.promptSchemaExample('ko').includes('U자형 이득'), 'ko example has expected title');
assert.ok(prompt.promptSchemaExample('fr').includes('Gains en U'), 'fr example has expected title');
assert.ok(prompt.promptSchemaExample('de').includes('U-förmige Gewinne'), 'de example has expected title');
assert.ok(prompt.promptSchemaExample('es').includes('Ganancias en forma de U'), 'es example has expected title');
// ── renderPromptTemplate ──
assert.strictEqual(prompt.renderPromptTemplate('Hello {name}!', { name: 'World' }), 'Hello World!');
assert.strictEqual(prompt.renderPromptTemplate('{a} and {b}', { a: 1, b: 2 }), '1 and 2', 'numeric vars');
assert.strictEqual(prompt.renderPromptTemplate('{missing}', {}), '{missing}', 'unmatched placeholder preserved');
assert.strictEqual(prompt.renderPromptTemplate('no vars', {}), 'no vars', 'no placeholders');
assert.strictEqual(prompt.renderPromptTemplate('', { a: 1 }), '', 'empty template');
assert.strictEqual(prompt.renderPromptTemplate(null, { a: 1 }), '', 'null template returns empty string');
// ── buildPrompts: basic en ──
const enResult = prompt.buildPrompts('Hello world', base);
assert.ok(enResult.system.includes('3-7'), 'en card range in system');
assert.ok(enResult.system.includes('English'), 'en language instruction');
assert.ok(enResult.user.includes('Source document:'), 'en user prefix');
assert.ok(enResult.user.includes('Hello world'), 'en content');
// ── buildPrompts: basic zh ──
const zhResult = prompt.buildPrompts('你好', { ...base, promptLanguage: 'zh' });
assert.ok(zhResult.system.includes('3-7'), 'zh card range');
assert.ok(zhResult.system.includes('中文'), 'zh language instruction');
assert.ok(zhResult.user.includes('以下是需要处理的文档全文'), 'zh user prefix');
// ── buildPrompts: auto language ──
const autoResult = prompt.buildPrompts('content', { ...base, promptLanguage: 'auto' });
assert.ok(autoResult.system.includes('main language'), 'auto language instruction');
// ── buildPrompts: expanded output languages ──
for (const [language, expected] of [
['ja', 'Japanese'],
['ko', 'Korean'],
['fr', 'French'],
['de', 'German'],
['es', 'Spanish'],
]) {
const result = prompt.buildPrompts('content', { ...base, promptLanguage: language });
assert.ok(result.system.includes(expected), `${language} language instruction`);
assert.ok(result.user.includes('Source document:'), `${language} uses provider-facing source prefix`);
}
// ── buildPrompts: truncation ──
const longDoc = 'x'.repeat(25000);
const truncResult = prompt.buildPrompts(longDoc, { ...base, maxDocChars: 20000 });
assert.ok(truncResult.user.includes('[Document truncated]'), 'en truncation marker');
assert.ok(truncResult.user.length < 25000, 'content truncated');
const zhTrunc = prompt.buildPrompts(longDoc, { ...base, promptLanguage: 'zh', maxDocChars: 20000 });
assert.ok(zhTrunc.user.includes('[文档过长,已截断]'), 'zh truncation marker');
// ── buildPrompts: custom system prompt ──
const custom = prompt.buildPrompts('doc', {
...base,
customSystemPrompt: 'Custom: {minCards}-{maxCards} cards. {languageInstruction}',
});
assert.ok(custom.system.includes('Custom: 3-7 cards'), 'custom template vars substituted');
assert.ok(custom.system.includes('Non-overridable output contract'), 'contract appended');
assert.ok(!custom.system.includes('long-form reading'), 'default prompt NOT included when custom set');
// ── buildPrompts: empty custom prompt uses default ──
const emptyCustom = prompt.buildPrompts('doc', { ...base, customSystemPrompt: '' });
assert.ok(emptyCustom.system.includes('long-form reading'), 'default prompt used when custom is empty');
// ── buildPrompts: whitespace-only custom prompt uses default ──
const wsCustom = prompt.buildPrompts('doc', { ...base, customSystemPrompt: ' ' });
assert.ok(wsCustom.system.includes('long-form reading'), 'default prompt used when custom is whitespace-only');
// ── buildPrompts: invalid promptLanguage falls back to the default ('auto') ──
const badLang = prompt.buildPrompts('doc', { ...base, promptLanguage: 'xyz' });
const autoFallback = prompt.buildPrompts('doc', { ...base, promptLanguage: 'auto' });
assert.strictEqual(badLang.system, autoFallback.system, 'invalid promptLanguage falls back to the default (auto)');
assert.ok(badLang.system.includes('main language'), 'fallback uses the auto language instruction');
// ── buildPrompts: card count normalization ──
// 0 is falsy so || picks DEFAULT (5), then Math.max(1, 5) = 5
const zeroCards = prompt.buildPrompts('doc', { ...base, minCards: 0, maxCards: 0 });
assert.ok(zeroCards.system.includes('1-1'), 'zero minCards/maxCards clamps to 1 (normalizeCardCount floor)');
const bigCards = prompt.buildPrompts('doc', { ...base, minCards: 5, maxCards: 3 });
assert.ok(bigCards.system.includes('5-5'), 'maxCards raised to match minCards');
// ── buildPrompts: maxDocChars normalization ──
const defaultMax = prompt.buildPrompts('doc', { ...base, maxDocChars: undefined });
assert.ok(defaultMax.user.includes('doc'), 'undefined maxDocChars uses default without truncation');
// ── buildPrompts: card count is normalized (capped at 30) — keeps prompt ↔ fingerprint in sync ──
const oversized = prompt.buildPrompts('doc', { ...base, minCards: 100, maxCards: 200 });
assert.ok(oversized.system.includes('30-30'), 'over-cap card count is clamped to 30 in prompt');
assert.ok(!oversized.system.includes('100'), 'raw 100 must not appear in prompt');
assert.ok(!oversized.system.includes('200'), 'raw 200 must not appear in prompt');
console.log('direct prompt tests passed');
} finally {
cleanup();
}
})().catch((e) => {
console.error(e);
process.exit(1);
});