Files
timmy-talking-turd/tests/domain.test.js
timmy 2094a07d9f
Quality gates / quality (pull_request) Successful in 18m50s
feat(portability): version the backup, migrate older files, and keep AI provenance (#35)
A backup is the person's own record and has to keep working as the AI fields evolve.

- Export stamps schema version 2 and carries the confirmed logs plus the training
  contribution receipts, including withdrawn tombstones; both are written through an
  allowlist so unknown keys and credentials never leave the device.
- Import accepts version 1 and 2. A version 1 file is upgraded, the notice names the
  version it came from, and entries that predate provenance stay null instead of being
  given a source they never recorded.
- A file from a newer Timmy is refused with an update instruction, malformed, oversized
  and over-limit files fail closed, and a refused import leaves the ledger unchanged.
- A log saved from a confirmed or corrected AI suggestion records its provenance
  (source, suggested visible fields, model, confirmedAt) and the entry sheet states it.
- Service worker shell cache v5 -> v6 so installed clients actually receive the change.

Evidence: tests/portability.test.js (7 unit cases), tests/portability.acceptance.mjs
(real browser: v1 upgrade, future refusal with unchanged ledger, provenance recorded,
export v2, round trip), release demo scenes with on-camera caption proof, and the
release builder gate portable_backup_restore.

Refs #35
2026-10-22 11:08:55 +00:00

138 lines
5.0 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
bucketForBristolType,
buildTimmySummary,
detectUrgentFlags,
detectUrgentText,
exportLedger,
hasUrgentLedgerContext,
LEDGER_EXPORT_VERSION,
photoQualityMessage,
sanitizeEntry,
} from '../src/domain.js';
test('maps Bristol types to clinically grounded buckets', () => {
assert.equal(bucketForBristolType(1), 'constipation');
assert.equal(bucketForBristolType(2), 'constipation');
assert.equal(bucketForBristolType(3), 'typical');
assert.equal(bucketForBristolType(4), 'typical');
assert.equal(bucketForBristolType(5), 'loose');
assert.equal(bucketForBristolType(7), 'loose');
assert.equal(bucketForBristolType(0), 'unknown');
});
test('escalates reported blood, black stool, severe pain, vomiting, fever, or inability to pass gas', () => {
const result = detectUrgentFlags({
blood: true,
blackOrDarkRed: false,
severePain: true,
vomiting: false,
fever: false,
cannotPassGas: false,
});
assert.equal(result.urgent, true);
assert.deepEqual(result.flags, ['blood', 'severePain']);
assert.match(result.message, /medical care/i);
});
test('does not invent reassurance when no urgent flags are reported', () => {
const result = detectUrgentFlags({});
assert.equal(result.urgent, false);
assert.deepEqual(result.flags, []);
assert.match(result.message, /not a diagnosis/i);
});
test('detects common urgent symptom language without matching unrelated blood wording', () => {
for (const message of [
'I have rectal bleeding',
'There is blood in my stool',
'My stool is black',
'I have severe stomach pain',
'I am throwing up and have a fever',
'I threw up',
'I puked twice',
'I am barfing',
'I barfed',
'I hurled',
'She hurls',
'I am upchucking',
'I spewed',
'She spews',
'I tossed my cookies',
'She tosses her cookies',
'He tossed his cookies',
'Someone is tossing their cookies',
'I lost my lunch',
'She loses her lunch',
'He lost his lunch',
'Someone is losing their lunch',
'I have emesis',
'I am unable to pass gas',
]) assert.equal(detectUrgentText(message).urgent, true, message);
assert.equal(detectUrgentText('My blood pressure was checked').urgent, false);
for (const nonVomiting of [
'She hurled the javelin across the field.',
'He hurls insults when angry.',
'They are hurling rocks at the wall.',
'He spewed hateful rhetoric.',
'The volcano spews ash.',
'The pipe is spewing water.',
]) assert.equal(detectUrgentText(nonVomiting).urgent, false, nonVomiting);
});
test('detects urgent flags or language in confirmed ledger context', () => {
assert.equal(hasUrgentLedgerContext([{ symptoms: { cannotPassGas: true }, note: '' }]), true);
assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'I have rectal bleeding' }]), true);
assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'ordinary entry' }]), false);
});
test('Timmy summary reports patterns without clearing food or diagnosing disease', () => {
const entries = [
{ bristolType: 3, occurredAt: '2026-08-15T08:00:00.000Z' },
{ bristolType: 4, occurredAt: '2026-08-16T08:00:00.000Z' },
{ bristolType: 6, occurredAt: '2026-08-17T08:00:00.000Z' },
];
const summary = buildTimmySummary(entries);
assert.match(summary, /3 logs/);
assert.match(summary, /2 typical/);
assert.doesNotMatch(summary, /safe|diagnos|Taco Bell|clear/i);
});
test('sanitizes a user entry to the MVP data contract', () => {
const entry = sanitizeEntry({
id: 'abc',
occurredAt: '2026-08-17T12:00:00.000Z',
bristolType: 4,
color: 'brown',
urgency: 2,
discomfort: 1,
note: 'After lunch',
photoDataUrl: 'data:image/jpeg;base64,abc',
unexpected: 'drop me',
});
assert.deepEqual(Object.keys(entry).sort(), [
'bristolType', 'color', 'discomfort', 'id', 'note', 'occurredAt',
'photoDataUrl', 'provenance', 'symptoms', 'urgency'
].sort());
assert.equal(entry.unexpected, undefined);
assert.equal(entry.provenance, null, 'a manual entry records no AI provenance rather than an invented one');
});
test('photo quality guidance is deterministic and does not claim visual diagnosis', () => {
assert.match(photoQualityMessage({ width: 300, height: 300, brightness: 0.5 }), /closer/i);
assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.02 }), /light/i);
assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.5 }), /review/i);
assert.doesNotMatch(photoQualityMessage({ width: 1200, height: 900, brightness: 0.5 }), /type [1-7]|disease|diagnos/i);
});
test('export ledger is portable JSON with the current version and entries', () => {
const text = exportLedger([{ id: 'a', bristolType: 4 }], '2026-08-18T00:00:00.000Z');
const parsed = JSON.parse(text);
assert.equal(parsed.schemaVersion, LEDGER_EXPORT_VERSION);
assert.equal(parsed.schemaVersion, 2);
assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z');
assert.equal(parsed.entries.length, 1);
});