timmy-talking-turd/tests/domain.test.js
Timmy dd86d6675d
Some checks failed
Quality gates / quality (pull_request) Failing after 1m28s
fix: close hostile-review blockers in ledger portability
- provenance origin set is own-safe exact membership (Set.has); inherited
  toString/constructor/__proto__ names can never become origins
- import/export symmetry restored with an explicit bounded policy:
  MAX_IMPORT_BYTES raised 2 MiB -> 16 MiB UTF-8 bytes, above any export
  this app can produce (photos capped at 4 MiB binary), so valid exports
  always re-import without silent data loss while hostile files stay bounded
- byte limit is byte-exact now: utf8ByteLength() measures real UTF-8 bytes
  (multibyte boundaries tested), and the browser rejects oversized files
  by File.size BEFORE File.text() reads user data
- collision-safe deterministic mergeLedgers(): existing user-owned rows
  win, incoming rows only ever added for new ids, intra-file duplicates
  collapse deterministically, every collision reported explicitly in the
  import toast (no duplicate/overwrite/shadow of user records)
- base-path Delete Everything is namespace-scoped: root still cleans/
  migrates the legacy store to prevent resurrection, /timmy-staging no
  longer erases another namespace's global legacy ledger (browser
  regression covers deletion with root legacy data present)
- strict current-schema values: Bristol 1-7 / urgency 0-4 / discomfort
  0-4 must be true integers (out-of-range falls back instead of silent
  clamping), photos restricted to JPEG/PNG/WebP base64 raster data URLs
  (SVG/GIF/non-base64 dropped), invalid dates never throw or persist
  Invalid Date values

Verification: npm test 91/91, test:ui/test:photo/test:sleek/test:portability
PASS, staging-deploy 20/20 OK, check:syntax clean, npm audit 0 high,
check_diff clean, adversarial probe battery (exact-byte boundary at cap,
prototype pollution via JSON, lone surrogates, data-URL strictness) green.
2026-08-22 21:53:08 +00:00

435 lines
21 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
bucketForBristolType,
buildTimmySummary,
detectUrgentFlags,
detectUrgentText,
exportLedger,
hasUrgentLedgerContext,
importLedger,
MAX_IMPORT_BYTES,
mergeLedgers,
utf8ByteLength,
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', 'symptoms', 'urgency'
].sort());
assert.equal(entry.unexpected, undefined);
});
test('keeps only a bounded provenance origin and strips smuggled secrets', () => {
const entry = sanitizeEntry({
id: 'prov-1',
bristolType: 4,
provenance: { origin: 'ai-suggestion', suggestedBristolType: 4, apiToken: 'sk-secret-value', sessionId: 'hermes-session-x' },
});
assert.deepEqual(entry.provenance, { origin: 'ai-suggestion' });
assert.doesNotMatch(JSON.stringify(entry), /secret|session/i);
});
test('omits provenance entirely when none was recorded', () => {
const entry = sanitizeEntry({ id: 'plain-1', bristolType: 3 });
assert.equal(entry.provenance, undefined);
});
test('rejects provenance origins outside the recorded vocabulary', () => {
for (const bogus of ['clinician', 'self-diagnosis', '']) {
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: bogus } });
assert.equal(entry.provenance, undefined, bogus);
}
});
test('provenance membership is own-property safe: inherited Object names are not origins', () => {
for (const poisoned of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf', 'isPrototypeOf']) {
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: poisoned } });
assert.equal(entry.provenance, undefined, poisoned);
assert.doesNotMatch(JSON.stringify(entry), new RegExp(poisoned), poisoned);
}
// Even a null-prototype provenance carrying a real origin stays acceptable.
const nullProto = sanitizeEntry({ id: 'y', bristolType: 4, provenance: Object.assign(Object.create(null), { origin: 'user' }) });
assert.deepEqual(nullProto.provenance, { origin: 'user' });
});
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 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, 1);
assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z');
assert.equal(parsed.entries.length, 1);
});
test('import migrates the legacy bare-array ledger to the current versioned envelope', () => {
const legacy = JSON.stringify([
{ id: 'legacy-1', occurredAt: '2026-08-17T12:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'older export' },
{ id: 'legacy-2', bristolType: 7 },
]);
const entries = importLedger(legacy);
assert.equal(entries.length, 2);
assert.equal(entries[0].id, 'legacy-1');
assert.equal(entries[0].bristolType, 2);
assert.equal(entries[0].note, 'older export');
});
test('import accepts every prior schema version and migrates entries forward', () => {
for (const version of [0, 1]) {
const payload = version === 0
? [{ id: `v${version}`, bristolType: 3 }]
: { product: 'Timmy the Talking Turd', schemaVersion: version, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: `v${version}`, bristolType: 3 }] };
const entries = importLedger(JSON.stringify(payload));
assert.equal(entries.length, 1, `schemaVersion ${version}`);
assert.equal(entries[0].bristolType, 3, `schemaVersion ${version}`);
}
});
test('import fails safely on a newer schema version instead of guessing', () => {
for (const schemaVersion of [2, 99]) {
assert.throws(
() => importLedger(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion, entries: [{ id: 'x' }] })),
error => error instanceof RangeError && /newer Timmy app/i.test(error.message),
`schemaVersion ${schemaVersion}`,
);
}
});
test('import fails safely on malformed or wrong-shaped payloads', () => {
for (const payload of [
'not json',
'{"schemaVersion":1,"entries":{}}',
'{"entries":[]}',
'{"product":"Other App","schemaVersion":1,"entries":[]}',
null,
42,
]) {
assert.throws(() => importLedger(payload), /not a supported Timmy export/, JSON.stringify(String(payload)).slice(0, 40));
}
});
test('import rejects oversized ledgers before parsing user data', () => {
const huge = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: 'x', note: 'n'.repeat(MAX_IMPORT_BYTES + 1024) }] });
assert.ok(huge.length > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(huge), RangeError);
});
test('utf8ByteLength measures UTF-8 bytes, not UTF-16 code units', () => {
assert.equal(utf8ByteLength(''), 0);
assert.equal(utf8ByteLength('abc'), 3);
// é is 1 UTF-16 unit but 2 UTF-8 bytes; 💩 is 2 UTF-16 units but 4 UTF-8 bytes.
assert.equal(utf8ByteLength('é'), 2);
assert.equal(utf8ByteLength('💩'), 4);
assert.equal(utf8ByteLength('aé💩b'), 1 + 2 + 4 + 1);
const emojiBlob = '💩'.repeat(1000);
assert.equal(emojiBlob.length, 2000, 'sanity: two code units each');
assert.equal(utf8ByteLength(emojiBlob), 4000);
});
test('import rejects oversized payloads by UTF-8 bytes regardless of composition', () => {
const asciiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES) + '"}]}';
assert.ok(utf8ByteLength(asciiOver) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(asciiOver), RangeError);
// 4 bytes per glyph: byte size crosses the cap at half the code-unit count.
const emojiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + '💩'.repeat(Math.ceil(MAX_IMPORT_BYTES / 2)) + '"}]}';
assert.ok(emojiOver.length <= MAX_IMPORT_BYTES * 1.01, 'code-unit count must not be what trips this');
assert.ok(utf8ByteLength(emojiOver) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(emojiOver), RangeError);
});
test('import accepts a dense multibyte payload just under the byte cap', () => {
// Many small multibyte entries packed deterministically to just under the byte
// cap without tripping per-field bounds (note <= 500 chars).
const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[';
const makeEntry = i => ({ id: `m${i}`, occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕', photoDataUrl: '', symptoms: {} });
const perEntryBytes = utf8ByteLength(JSON.stringify(makeEntry(0))) + 1; // + comma
const budget = Math.floor((MAX_IMPORT_BYTES - utf8ByteLength(head) - 2) * 0.97);
const count = Math.max(1, Math.floor(budget / perEntryBytes));
const payload = `${head}${Array.from({ length: count }, (_, i) => JSON.stringify(makeEntry(i))).join(',')}]}`;
assert.ok(utf8ByteLength(payload) <= MAX_IMPORT_BYTES);
assert.ok(utf8ByteLength(payload) > MAX_IMPORT_BYTES * 0.95, 'payload must sit close to the boundary');
const imported = importLedger(payload);
assert.equal(imported.length, count, 'every packed entry survives');
assert.equal(imported[imported.length - 1].note, 'café ☕');
});
test('a maximal app-produced export with a 4 MiB photo round trips byte-symmetrically', () => {
// 4 MiB binary is the app-wide photo ceiling (analysis.js MAX_IMAGE_BYTES).
const photoBytes = 4 * 1024 * 1024;
let b64 = Buffer.from('a'.repeat(photoBytes)).toString('base64');
const entry = sanitizeEntry({
id: 'big-photo',
bristolType: 4,
photoDataUrl: `data:image/jpeg;base64,${b64}`,
note: 'boundary photo',
});
const exported = exportLedger([entry], '2026-08-22T00:00:00.000Z');
assert.ok(utf8ByteLength(exported) <= MAX_IMPORT_BYTES, 'largest producible export must stay inside the import cap');
const roundTripped = importLedger(exported);
assert.equal(roundTripped.length, 1);
assert.equal(roundTripped[0].photoDataUrl, entry.photoDataUrl, 'photo survives the round trip without silent loss');
});
test('import rejects payloads past the explicit portability ceiling before parsing', () => {
const huge = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES + 1024) + '"}]}';
assert.ok(utf8ByteLength(huge) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(huge), RangeError);
});
test('merge keeps every distinct record and never duplicates or overwrites user-owned entries', () => {
const local = [
{ id: 'a', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, note: 'local version of shared id' },
{ id: 'b', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'local b', photoDataUrl: '', symptoms: {} },
];
const incoming = importLedger(JSON.stringify([
{ id: 'b', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, color: 'black', urgency: 4, note: 'hostile rewrite of existing id' },
{ id: 'c', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 6, note: 'new from file' },
]));
const result = mergeLedgers(local, incoming);
assert.equal(result.merged.length, 3, 'one row per unique id');
assert.equal(result.merged.filter(entry => entry.id === 'b').length, 1, 'no duplicate ids');
assert.equal(result.merged.find(entry => entry.id === 'b').note, 'local b', 'existing user-owned entry is never overwritten');
assert.deepEqual(result.added.map(entry => entry.id), ['c'], 'only genuinely new records are added');
assert.deepEqual(result.skippedIds, ['b'], 'collisions are reported explicitly');
// Order stays deterministic: local rows first in their stored order, then additions in incoming order.
assert.deepEqual(result.merged.map(entry => entry.id), ['a', 'b', 'c']);
});
test('re-importing the same file twice changes nothing (idempotent)', () => {
const base = [{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }];
const file = importLedger(JSON.stringify([{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }, { id: 'extra', occurredAt: '2026-08-22T08:30:00.000Z', bristolType: 3 }]));
const first = mergeLedgers(base, file);
assert.deepEqual(first.added.map(entry => entry.id), ['extra']);
const second = mergeLedgers(first.merged, file);
assert.equal(second.merged.length, first.merged.length, 'second import adds nothing');
assert.deepEqual(second.added, [], 'second import reports no additions');
assert.deepEqual(second.skippedIds.sort(), ['extra', 'seed'], 'both already-present ids are reported as skipped');
});
test('merge sanitizes incoming entries so imports cannot smuggle hostile fields into storage', () => {
const incoming = [{ id: 'proto-entry', bristolType: 4, __proto__: { poisoned: true }, extra: 'strip me' }, { id: 'ctor-entry', bristolType: 4, sessionCookie: 'SID=x' }];
const result = mergeLedgers([], incoming);
assert.equal(result.merged.length, 2, 'both records still import as data');
for (const entry of result.merged) {
assert.equal(Object.getPrototypeOf(entry), Object.prototype, `plain-object entry ${entry.id}`);
assert.equal(entry.poisoned, undefined, 'prototype payload must not leak');
assert.equal(entry.extra, undefined, 'unknown fields stay out of storage');
assert.equal(entry.sessionCookie, undefined, 'smuggled secrets stay out of storage');
}
const serialized = JSON.stringify(result.merged);
assert.doesNotMatch(serialized, /poisoned|extra|sessionCookie|SID=/);
});
test('current-schema numeric fields are strict integers within clinical bounds', () => {
for (const bogus of [4.5, '3', 0, 8, NaN, null, true, [3]]) {
const entry = sanitizeEntry({ id: 'n', bristolType: bogus });
assert.equal(entry.bristolType, 4, `bristolType ${JSON.stringify(String(bogus))} falls back to the neutral default`);
}
assert.equal(sanitizeEntry({ id: 'ok1', bristolType: 1 }).bristolType, 1);
assert.equal(sanitizeEntry({ id: 'ok2', bristolType: 7 }).bristolType, 7);
const mixed = sanitizeEntry({ id: 'm', urgency: 2.5, discomfort: -1 });
assert.equal(mixed.urgency, 0);
assert.equal(mixed.discomfort, 0);
const strings = sanitizeEntry({ id: 's', urgency: '3', discomfort: 11 });
assert.equal(strings.urgency, 0, 'numeric strings are not schema integers');
assert.equal(strings.discomfort, 0);
const valid = sanitizeEntry({ id: 'v', urgency: 3, discomfort: 4 });
assert.equal(valid.urgency, 3);
assert.equal(valid.discomfort, 4);
});
test('photo fields accept only approved raster JPEG/PNG/WebP base64 data URLs', () => {
const okJpeg = `data:image/jpeg;base64,${Buffer.from('ok').toString('base64')}`;
const okPng = 'data:image/png;base64,iVBORw0KGgo=';
const okWebp = 'data:image/webp;base64,UklGRg==';
assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg);
assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng);
assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp);
for (const bad of [
'data:image/svg+xml;base64,PHN2Zy8+',
'data:image/svg+xml,<svg onload="alert(1)">',
'data:image/gif;base64,R0lGODlh',
'data:image/jpeg;base64,!!!not-base64!!!',
'data:image/jpeg,percent%2Dencoded',
'data:text/html;base64,PGh0bWw+',
'http://example.com/photo.jpg',
42,
]) {
assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${String(bad).slice(0, 40)}`);
}
});
test('invalid or missing dates never throw and never persist Invalid Date values', () => {
for (const bad of ['not-a-date', '2026-13-45T99:99:99Z', {}, ['2026-01-01'], true]) {
let entry;
assert.doesNotThrow(() => { entry = sanitizeEntry({ id: 'd', occurredAt: bad }); }, String(bad));
assert.match(entry.occurredAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, `safe ISO for ${String(bad)}`);
}
const blank = sanitizeEntry({ id: 'd2', occurredAt: '' });
assert.equal(Number.isNaN(new Date(blank.occurredAt).getTime()), false);
const kept = sanitizeEntry({ id: 'd3', occurredAt: '2026-08-01T10:00:00.000Z' });
assert.equal(kept.occurredAt, '2026-08-01T10:00:00.000Z', 'valid dates pass through unchanged');
});
test('imports containing invalid dates migrate forward instead of crashing the whole ledger', () => {
const payload = JSON.stringify([
{ id: 'bad-date', occurredAt: 'garbage-date-value', bristolType: 3 },
{ id: 'good-date', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 },
]);
const imported = importLedger(payload);
assert.equal(imported.length, 2, 'one bad field cannot destroy the batch');
assert.equal(Number.isNaN(new Date(imported[0].occurredAt).getTime()), false, 'bad date becomes a safe ISO timestamp');
assert.equal(imported[1].occurredAt, '2026-08-20T09:00:00.000Z');
});
test('round trip preserves confirmed values and provenance without leaking secrets', () => {
const saved = [
sanitizeEntry({
id: 'r1',
occurredAt: '2026-08-19T08:30:00.000Z',
bristolType: 2,
color: 'green',
urgency: 3,
discomfort: 2,
note: 'rough morning',
provenance: { origin: 'ai-suggestion', apiToken: 'sk-leaked-token' },
}),
{
id: 'raw-2', bristolType: 9, color: 'chartreuse', urgency: 11, discomfort: -4,
note: 'odd shape', symptoms: { blood: true }, sessionCookie: 'SID=hijack',
},
];
const exported = exportLedger(saved, '2026-08-20T00:00:00.000Z');
assert.doesNotMatch(exported, /sk-leaked-token|SID=hijack|chartreuse/);
const roundTripped = importLedger(exported);
assert.equal(roundTripped.length, 2);
assert.deepEqual(
{ id: roundTripped[0].id, occurredAt: roundTripped[0].occurredAt, bristolType: roundTripped[0].bristolType, color: roundTripped[0].color, urgency: roundTripped[0].urgency, discomfort: roundTripped[0].discomfort, note: roundTripped[0].note },
{ id: 'r1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'rough morning' },
);
assert.equal(roundTripped[0].symptoms.blood, false);
assert.deepEqual(roundTripped[0].provenance, { origin: 'ai-suggestion' });
assert.equal(roundTripped[1].bristolType, 4, 'out-of-range Bristol type falls back to the neutral default under strict schema');
assert.equal(roundTripped[1].color, 'brown');
assert.equal(roundTripped[1].urgency, 0, 'out-of-range urgency falls back to the neutral default');
assert.equal(roundTripped[1].discomfort, 0);
assert.deepEqual(roundTripped[1].provenance, undefined);
});
test('re-exporting an imported ledger converges to the same portable document', () => {
const entries = [{ id: 'c1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 6, color: 'yellow', urgency: 2, discomfort: 1, note: 'loose', provenance: { origin: 'user' } }];
const first = JSON.parse(exportLedger(entries, '2026-08-20T00:00:00.000Z'));
const second = JSON.parse(exportLedger(importLedger(exportLedger(entries, '2026-08-20T00:00:00.000Z')), '2026-08-20T00:00:00.000Z'));
assert.deepEqual(second, first);
});