/**
* READ HISTORY — the product surface for the one truth lineage owns.
*
* The load-bearing separation this suite defends: a lineage REVISION means the
* published CLAIM changed; the grade strike means the LETTER changed. They must
* never look, read, or be styled like the same event.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..');
const rh = require('../../web/src/lib/readHistory');
const ancestry = require('../../src/services/read/readAncestry');
const COMPONENT = fs.readFileSync(path.join(ROOT, 'web/src/components/vyndr/ReadHistory.tsx'), 'utf8');
const ROUTE = fs.readFileSync(path.join(ROOT, 'src/routes/ancestry.js'), 'utf8');
const LEDGER_PAGE = fs.readFileSync(path.join(ROOT, 'web/src/app/ledger/page.tsx'), 'utf8');
const chain = (over = {}) => ({
ancestry: {
state: 'LINEAGE_AVAILABLE',
revisions: [
{ snapshot_row_id: 1, revision_ordinal: 0, action: 'ORIGIN', change_type: 'INITIAL_PUBLICATION', captured_at: '2026-08-30T01:02:44Z', published_grade: 'C', published_p_win: 0.567, published_line: 4.5 },
{ snapshot_row_id: 2, revision_ordinal: 1, action: 'REVISION', change_type: 'MARKET_REPRICE', captured_at: '2026-08-30T03:01:43Z', published_grade: 'C', published_p_win: 0.567, published_line: 4.5 },
{ snapshot_row_id: 3, revision_ordinal: 2, action: 'REVISION', change_type: 'BELIEF_CHANGE', captured_at: '2026-08-31T03:47:09Z', published_grade: 'C-', published_p_win: 0.515, published_line: 4.5 },
],
recaptures: [],
chronology_complete: true,
unrecorded_publications: 0,
publications_before_recording: 0,
...over,
},
});
describe('the history reads as history, not as a database', () => {
test('ORIGIN, REVISION and change types get plain words', () => {
const v = rh.mapAncestry(chain());
expect(v.available).toBe(true);
expect(v.entries.map((e) => e.label)).toEqual(['First published', 'Updated', 'Updated']);
expect(v.entries[1].what).toBe('The price moved');
expect(v.entries[2].what).toBe('The read changed');
});
test('the newest entry is marked current, and it is worded, not only coloured', () => {
const v = rh.mapAncestry(chain());
expect(v.entries[2].is_current).toBe(true);
expect(v.entries[0].is_current).toBe(false);
expect(COMPONENT).toMatch(/CURRENT/);
});
test('each entry carries what was PUBLISHED then, from the authoritative row', () => {
const v = rh.mapAncestry(chain());
expect(v.entries.map((e) => e.grade)).toEqual(['C', 'C', 'C-']);
// Sourced from model_snapshots via the projection, never from lineage metadata.
const svc = fs.readFileSync(path.join(ROOT, 'src/services/read/readAncestry.js'), 'utf8');
expect(svc).toMatch(/published_grade: row\.grade/);
});
test('no backend vocabulary reaches the user', () => {
// Only the RENDERED strings. The lookup keys are ORIGIN/REVISION/RECAPTURE
// by design — they are how the code addresses an action, not what a person
// reads, and asserting over them tests the wrong thing.
const rendered = [
...Object.values(rh.STATE_COPY).filter(Boolean).flatMap((c) => [c.headline, c.detail]),
...Object.values(rh.ACTION_COPY).map((c) => c.label),
...Object.values(rh.CHANGE_COPY),
rh.summariseRecaptures([{}, {}]).label,
].join(' | ').toLowerCase();
for (const w of ['lineage', 'natural key', 'ordinal', 'digest', 'graph', 'recapture', 'origin', 'chronology']) {
expect(rendered).not.toContain(w);
}
});
test('the feature is not called Lineage anywhere a user can see', () => {
const visible = COMPONENT.split('\n').filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('//') && !l.trim().startsWith('/*'));
expect(visible.join('\n')).not.toMatch(/>[^<]*[Ll]ineage[^<]*);
expect(COMPONENT).toMatch(/READ HISTORY/);
});
});
describe('RECAPTURE is summarised, never destroyed', () => {
test('repeated unchanged observations collapse to one honest line', () => {
const v = rh.mapAncestry(chain({ recaptures: [{ captured_at: 'a' }, { captured_at: 'b' }, { captured_at: 'c' }] }));
expect(v.recaptureSummary.count).toBe(3);
expect(v.recaptureSummary.label).toMatch(/Rechecked 3 times with no change/);
// The chronology itself is unchanged — nothing was removed from the data.
expect(v.entries).toHaveLength(3);
});
test('the API still returns every recapture — the UI summarises, the contract does not', () => {
const act = (o) => ({
id: 1, read_id: 'r1', read_natural_key: 'k', lineage_action: 'ORIGIN', claim_digest: 'd',
revision_ordinal: 0, lineage_state: 's', lineage_version: 'lin@1',
claim_schema_version: 'claim@1', digest_algorithm_version: 'x', publication_id: 'p', ...o,
});
const out = ancestry.classifyAncestry([
act({ id: 1 }),
act({ id: 2, lineage_action: 'RECAPTURE' }),
act({ id: 3, lineage_action: 'RECAPTURE' }),
]);
expect(out.recaptures).toHaveLength(2);
expect(out.recapture_count).toBe(2);
});
});
describe('truth states', () => {
test('legacy says history was not recorded — not that nothing happened', () => {
const v = rh.mapAncestry({ ancestry: { state: 'LEGACY_UNVERIFIED' } });
expect(v.available).toBe(false);
expect(v.headline).toBe('History not recorded');
expect(v.detail).toMatch(/started keeping Read history after/i);
expect(v.entries).toEqual([]);
});
test('unavailable is NOT presented as legacy', () => {
const v = rh.mapAncestry({ ancestry: { state: 'LINEAGE_UNAVAILABLE' } });
expect(v.headline).toBe('History unavailable');
expect(v.headline).not.toBe(rh.STATE_COPY.LEGACY_UNVERIFIED.headline);
expect(v.detail).toMatch(/should have a history/i);
});
test('invalid fails closed into a restrained state and names nothing scary', () => {
const v = rh.mapAncestry({ ancestry: { state: 'INVALID_LINEAGE' } });
expect(v.available).toBe(false);
expect(v.entries).toEqual([]);
// The USER-VISIBLE copy, not the machine `state` field the component keys off.
const shown = `${v.headline} ${v.detail}`.toLowerCase();
expect(shown).not.toMatch(/corrupt|invalid|fork|defect|integrity/);
});
test('an incomplete chronology never claims to be complete, and says WHICH hole', () => {
const before = rh.mapAncestry(chain({ chronology_complete: false, publications_before_recording: 2 }));
expect(before.incomplete).toBe(true);
expect(before.incompleteNote).toMatch(/before VYNDR started recording/i);
const failed = rh.mapAncestry(chain({ chronology_complete: false, unrecorded_publications: 1 }));
expect(failed.incompleteNote).toMatch(/couldn’t be recorded/i);
const both = rh.mapAncestry(chain({ chronology_complete: false, publications_before_recording: 1, unrecorded_publications: 1 }));
expect(both.incompleteNote).toMatch(/Some earlier updates aren’t recorded/i);
expect(rh.mapAncestry(chain()).incomplete).toBe(false);
});
});
describe('THE SEPARATION — claim changed is not letter changed', () => {
test('the history never uses the grade strike styling', () => {
expect(COMPONENT).not.toMatch(/line-through/);
expect(COMPONENT).not.toMatch(/revised_from_grade/);
expect(COMPONENT).not.toMatch(/GradePill|gradeShift|GradeShift/);
});
test('"Updated" never asserts the grade moved', () => {
const v = rh.mapAncestry(chain());
// ordinal 1 is a pure reprice with the SAME letter on both sides.
expect(v.entries[1].what).toBe('The price moved');
expect(v.entries[1].grade).toBe(v.entries[0].grade);
expect(JSON.stringify(rh.ACTION_COPY).toLowerCase()).not.toMatch(/grade|letter/);
});
test('the ledger card still renders the grade strike, untouched', () => {
expect(LEDGER_PAGE).toMatch(/revised_from_grade/);
expect(LEDGER_PAGE).toMatch(/textDecoration: 'line-through'/);
expect(LEDGER_PAGE).toMatch(/