Read History: the graph becomes something a person can read
The ancestry contract existed and nothing rendered it. This turns it into a product surface, and stops there. WHERE IT GOES. `LedgerCard` is the terminal surface — there is no Ledger detail view — so the history expands in place inside the card, matching the board's existing "ALL N READS" affordance rather than adding a page, a modal or a navigation category. It loads on first open, not on render. WHAT IT IS CALLED. "Read History". Lineage stays engineering vocabulary; a test asserts no rendered string contains lineage, natural key, ordinal, digest, graph, origin or recapture. ORIGIN reads "First published", REVISION reads "Updated", and the persisted `change_type` supplies "The price moved" / "The read changed" / "The read and the price changed". `change_type` is stored and trustworthy, so naming it is reporting; no field-level diff is persisted, so none is invented. THE SEPARATION, WHICH IS THE LOAD-BEARING PART. The grade strike means the LETTER changed. A history entry means the published CLAIM changed — often the price, sometimes the read, frequently with no letter change at all. The history uses no strike-through, shares no styling, and a tooth fails if it ever does. The ledger card's own strike is untouched. RECAPTURES ARE SUMMARISED, NEVER DESTROYED. A republishing board can produce hundreds of "unchanged" entries that bury the two that matter, so the UI collapses them to a count. The API still returns every one. WHAT EACH ENTRY SHOWS came from the acceptance run: without the published grade the history can say a Read changed but never what it changed to, which answers none of the questions someone opens a history to ask. `published_grade` / `published_p_win` / `published_line` are read off the SAME retained row the lineage action sits on — the authoritative record of the published claim, with lineage only the pointer to it. A tooth fails if they are ever synthesised from lineage metadata. TWO DEFECTS THE PRODUCTION ACCEPTANCE FOUND, NEITHER OF WHICH A TEST HAD. `ledger_entries.id` is a UUID and the route parsed it with Number.parseInt, so every real row would have 400'd. The unauthenticated probe that "proved the route was live" returns 401 from requireAuth before the handler runs, so it could never have seen this. And `chase burns / hits_allowed / under / 4.5` has SIX published captures and four lineage actions — two were published while the writer was off. The response said `chronology_complete: true` while showing four of six. Completeness now counts published-but-never-recorded states as well as attempted-and-failed ones, kept as separate numbers because the causes differ and the copy says which. Suite 399/5,544/0 · tsc 0 · web build 0 · teeth 15/15. Lint is not runnable in this repository (`next lint` removed in Next 16, no eslint.config.*) — pre-existing, untouched here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
@@ -309,6 +309,37 @@ describe('ancestry response states, end to end', () => {
|
||||
expect(ancestry.classifyAncestry([]).state).toBe(A.NOT_FOUND);
|
||||
});
|
||||
|
||||
test('a published capture the writer never recorded also breaks completeness', () => {
|
||||
// The real `chase burns / hits_allowed / under / 4.5` shape: published
|
||||
// captures from a window when the writer was off. Not a failed attempt —
|
||||
// nothing tried — but still a published state the reader cannot see.
|
||||
const out = ancestry.classifyAncestry([
|
||||
act({ id: 1 }),
|
||||
{ id: 2, published: true, publication_id: null },
|
||||
]);
|
||||
expect(out.state).toBe(A.LINEAGE_AVAILABLE);
|
||||
expect(out.chronology_complete).toBe(false);
|
||||
expect(out.publications_before_recording).toBe(1);
|
||||
expect(out.unrecorded_publications).toBe(0); // distinct cause, distinct count
|
||||
});
|
||||
|
||||
test('the two kinds of hole are counted separately', () => {
|
||||
const out = ancestry.classifyAncestry([
|
||||
act({ id: 1 }),
|
||||
{ id: 2, published: true, publication_id: 'p2' }, // attempted, failed
|
||||
{ id: 3, published: true, publication_id: null }, // never attempted
|
||||
]);
|
||||
expect(out.unrecorded_publications).toBe(1);
|
||||
expect(out.publications_before_recording).toBe(1);
|
||||
expect(out.chronology_complete).toBe(false);
|
||||
});
|
||||
|
||||
test('a genuinely whole history still reports complete', () => {
|
||||
const out = ancestry.classifyAncestry([act({ id: 1, published: true })]);
|
||||
expect(out.chronology_complete).toBe(true);
|
||||
expect(out.publications_before_recording).toBe(0);
|
||||
});
|
||||
|
||||
test('other sports are unaffected — nothing here is sport-specific product logic', () => {
|
||||
const out = ancestry.classifyAncestry([act({ id: 1 })]);
|
||||
expect(out.state).toBe(A.LINEAGE_AVAILABLE);
|
||||
|
||||
@@ -367,7 +367,16 @@ describe('NON-AUTHORITY — lineage cannot serve the product', () => {
|
||||
for (const c of cols) if (src.includes(c)) hits.push(`${path.relative(ROOT, f)} -> ${c}`);
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
// ONE product-side consumer of the ancestry CONTRACT is permitted, by name.
|
||||
// It reads two fields off an API RESPONSE — never from the store — which is
|
||||
// the whole point of the additive contract, and the assertion below keeps
|
||||
// that distinction honest: if this file ever learns to query, it fails.
|
||||
const ANCESTRY_CONSUMER = 'web/src/lib/readHistory.js';
|
||||
const consumer = fs.readFileSync(path.join(ROOT, ANCESTRY_CONSUMER), 'utf8');
|
||||
for (const q of ['supabase', 'from(', 'select(', 'createClient']) {
|
||||
expect(consumer).not.toContain(q);
|
||||
}
|
||||
expect(hits.filter((h) => !h.startsWith(ANCESTRY_CONSUMER))).toEqual([]);
|
||||
});
|
||||
|
||||
test('the served envelope comes from the cache, not from model_snapshots', () => {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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(/<ReadHistory ledgerId=\{row\.id\} \/>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('client, transport and accessibility', () => {
|
||||
test('the component uses the established API client, not ad hoc fetch', () => {
|
||||
expect(COMPONENT).toMatch(/import \{ getReadAncestry \} from '@\/lib\/api'/);
|
||||
const body = COMPONENT.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
expect(body).not.toMatch(/\bfetch\(/);
|
||||
expect(fs.readFileSync(path.join(ROOT, 'web/src/lib/api.ts'), 'utf8')).toMatch(/export async function getReadAncestry/);
|
||||
});
|
||||
|
||||
test('transport failures are never rendered as history states', () => {
|
||||
for (const code of ['401', '404', '429']) expect(COMPONENT).toContain(code);
|
||||
expect(COMPONENT).toMatch(/transportError/);
|
||||
// The unavailable-state copy is chosen by mapAncestry; transport copy is separate.
|
||||
expect(COMPONENT).toMatch(/History could not be loaded right now/);
|
||||
expect(JSON.stringify(rh.STATE_COPY)).not.toMatch(/could not be loaded/);
|
||||
});
|
||||
|
||||
test('the disclosure is keyboard and screen-reader usable', () => {
|
||||
expect(COMPONENT).toMatch(/type="button"/);
|
||||
expect(COMPONENT).toMatch(/aria-expanded=\{open\}/);
|
||||
expect(COMPONENT).toMatch(/aria-controls=\{panelId\}/);
|
||||
expect(COMPONENT).toMatch(/aria-hidden="true"/); // the caret is decorative
|
||||
expect(COMPONENT).toMatch(/role="status"/);
|
||||
expect(COMPONENT).toMatch(/<ol/); // chronology is a list
|
||||
expect(COMPONENT).toMatch(/<time dateTime=/);
|
||||
expect(COMPONENT).toMatch(/minHeight: 40/); // touch target
|
||||
});
|
||||
|
||||
test('the route is keyed on the ledger UUID and is authenticated', () => {
|
||||
expect(ROUTE).toMatch(/requireAuth/);
|
||||
expect(ROUTE).toMatch(/UUID_RE/);
|
||||
expect(ROUTE).not.toMatch(/read_natural_key/);
|
||||
});
|
||||
});
|
||||
@@ -428,7 +428,16 @@ describe('SHADOW AUTHORITY', () => {
|
||||
for (const c of cols) if (src.includes(c)) hits.push(`${path.relative(ROOT, f)} -> ${c}`);
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
// ONE product-side consumer of the ancestry CONTRACT is permitted, by name.
|
||||
// It reads two fields off an API RESPONSE — never from the store — which is
|
||||
// the whole point of the additive contract, and the assertion below keeps
|
||||
// that distinction honest: if this file ever learns to query, it fails.
|
||||
const ANCESTRY_CONSUMER = 'web/src/lib/readHistory.js';
|
||||
const consumer = fs.readFileSync(path.join(ROOT, ANCESTRY_CONSUMER), 'utf8');
|
||||
for (const q of ['supabase', 'from(', 'select(', 'createClient']) {
|
||||
expect(consumer).not.toContain(q);
|
||||
}
|
||||
expect(hits.filter((h) => !h.startsWith(ANCESTRY_CONSUMER))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user