timmy-talking-turd/tests/analysis.test.js
Timmy b8532f587d
All checks were successful
Quality gates / quality (pull_request) Successful in 1m42s
feat: version ledger migrations and hardened JSON portability
Implements #35.

- importLedger migrates prior schema versions (v0 bare-array legacy
  exports and the v1 envelope) and fails safely on future versions,
  malformed JSON, wrong-product envelopes, and oversized files with a
  new 2 MiB MAX_IMPORT_BYTES guard applied before parsing.
- exportLedger normalizes entries through sanitizeEntry so confirmed
  values and bounded provenance round-trip while smuggled secrets and
  unknown fields never enter the portable file.
- Entries may carry a whitelisted provenance origin ('user' or
  'ai-suggestion'); mergeVisualSuggestion records 'ai-suggestion' only
  when a suggestion is actually applied, keeping nonvisual fields
  user-owned.
- App import now merges into the existing ledger instead of replacing
  it, so a failed or partial import can never silently drop
  user-owned records.
- Service-worker shell cache bumped to v6 (per base-path namespace)
  so installed PWAs receive the migration code; old v5 caches are
  purged on activation.
- New tests/ledger-portability.acceptance.mjs browser gate covers
  export round trip, merge import, safe-failure surfacing, root vs
  /timmy-staging storage isolation, and Delete Everything for both
  namespaces; wired into package.json test:portability and CI quality.yml.

Deterministic medical safety unchanged: urgent-flag detection, red-flag
copy, and chat escalation paths are untouched; all fixtures synthetic.
2026-08-22 20:32:57 +00:00

91 lines
4.2 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildVisionRequest,
mergeVisualSuggestion,
parseVisionResponse,
validatePhotoPayload,
} from '../src/analysis.js';
test('validates a confident visual suggestion without inventing nonvisual fields', () => {
const result = parseVisionResponse({
isStool: true,
bristolType: 4,
color: 'brown',
confidence: 0.82,
imageQuality: 'good',
observations: 'Smooth, formed appearance.',
urgency: 4,
discomfort: 3,
symptoms: { blood: true },
diagnosis: 'anything',
});
assert.deepEqual(result, {
status: 'suggestion',
isStool: true,
bristolType: 4,
color: 'brown',
confidence: 0.82,
imageQuality: 'good',
observations: 'Smooth, formed appearance.',
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
});
assert.equal(result.urgency, undefined);
assert.equal(result.symptoms, undefined);
assert.equal(result.diagnosis, undefined);
});
test('fails closed on a non-stool or low-confidence image', () => {
assert.deepEqual(parseVisionResponse({ isStool: false, confidence: 0.9 }), {
status: 'needs_user_input',
isStool: false,
reason: 'The image does not clearly show stool.',
});
assert.deepEqual(parseVisionResponse({ isStool: true, bristolType: 4, color: 'brown', confidence: 0.31 }), {
status: 'needs_user_input',
isStool: true,
reason: 'The image is too uncertain to prefill safely.',
});
});
test('rejects malformed model output instead of guessing defaults', () => {
assert.throws(() => parseVisionResponse({ isStool: true, bristolType: 9, color: 'purple', confidence: 0.8 }), /invalid/i);
assert.throws(() => parseVisionResponse('not json'), /invalid/i);
});
test('merges only visual fields and preserves user-reported context', () => {
const form = { bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } };
const merged = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.8 });
assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true }, provenance: { origin: 'ai-suggestion' } });
});
test('records ai-suggestion provenance only when a suggestion is actually applied', () => {
const form = { bristolType: 2, color: 'green', urgency: 1, discomfort: 0, note: '', symptoms: {} };
const abstained = mergeVisualSuggestion(form, { status: 'needs_user_input', reason: 'too uncertain' });
assert.deepEqual(abstained, form);
assert.equal(abstained.provenance, undefined);
const suggested = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 5, color: 'yellow', confidence: 0.9 });
assert.deepEqual(suggested.provenance, { origin: 'ai-suggestion' });
assert.equal(suggested.urgency, form.urgency, 'nonvisual fields stay user-owned');
});
test('accepts bounded JPEG/PNG/WebP data URLs and rejects oversized or unsupported input', () => {
const payload = validatePhotoPayload({ imageDataUrl: 'data:image/jpeg;base64,' + 'YQ==', consent: true });
assert.equal(payload.mime, 'image/jpeg');
assert.equal(payload.bytes, 1);
assert.throws(() => validatePhotoPayload({ imageDataUrl: 'data:image/svg+xml;base64,PHN2Zz4=', consent: true }), /JPEG, PNG, or WebP/i);
assert.throws(() => validatePhotoPayload({ imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: false }), /consent/i);
assert.throws(() => validatePhotoPayload({ imageDataUrl: 'data:image/jpeg;base64,' + 'A'.repeat(6_000_000), consent: true }), /too large/i);
});
test('builds a structured multimodal request that forbids diagnosis and nonvisual inference', () => {
const body = buildVisionRequest({ imageDataUrl: 'data:image/jpeg;base64,YQ==', model: 'vision-model' });
assert.equal(body.model, 'vision-model');
assert.equal(body.response_format.type, 'json_schema');
const prompt = body.messages[0].content.find(part => part.type === 'text').text;
assert.match(prompt, /do not infer urgency/i);
assert.match(prompt, /not a diagnosis/i);
assert.equal(body.messages[0].content.find(part => part.type === 'image_url').image_url.url, 'data:image/jpeg;base64,YQ==');
});