From 769ac58b79497c432ffdd90341bf51981e0d3dbf Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:10:06 +0000 Subject: [PATCH] The shard sentence from the block's facts (7 October 2026, the site's animation said "no shard plan yet" on every planless block): IgneumDag.shardWords(block, nowMs) in scene/live-dag.js (2.0.6) is the one source for the tooltip, the /live inspector and the app's inspector: no shards reads shard plan loading (a chain block under 10 s), excluded, nothing to prove, not yet ordered (pending), off the selected chain, no shards planned, or no shard plan after N s; a planned shard with no prover reads awaiting a prover with the block's age, assigned to when one holds it, proving, verified, paid as before; the app's inspector prints these per shard and as the count line; the copies synced; tools/scene/shard-words.test.mjs on the gate's scene line, known-failed first on 2.0.5 Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/ui/app.js | 5 +-- app/igneum-app/ui/live-dag.js | 29 +++++++++++++++-- scene/live-dag.js | 29 +++++++++++++++-- site/live-dag.js | 29 +++++++++++++++-- tools/ci/pre-push.sh | 2 +- tools/scene/legend.test.mjs | 12 ++++--- tools/scene/shard-words.test.mjs | 54 ++++++++++++++++++++++++++++++++ 7 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 tools/scene/shard-words.test.mjs diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 8e995b7d..d3556155 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -2038,9 +2038,10 @@ if (typeof document !== 'undefined') (function () { setText('i-title', b.locked ? 'Checkpoint locked' : b.proven ? 'Proof complete' : (b.shards || []).some(function (x) { return x.state === 'proving'; }) ? 'Proof in progress' : 'Block observed'); setText('i-hash', b.hash + (b.number ? ' · #' + fmtN(b.number) : '')); var sh = b.shards || [], done = sh.filter(function (x) { return x.state === 'verified' || x.state === 'paid'; }).length; - setText('i-shard-count', sh.length ? done + ' / ' + sh.length + ' complete' : 'no shard plan yet'); + var sw = typeof IgneumDag !== 'undefined' && IgneumDag.shardWords ? IgneumDag.shardWords(b, Date.now()) : { summary: sh.length ? '' : 'no shard plan', items: sh.map(function (x) { return { word: x.state }; }) }; + setText('i-shard-count', sh.length ? done + ' / ' + sh.length + ' complete' : sw.summary); $('i-track').innerHTML = sh.map(function (x) { return ''; }).join(''); - $('i-shards').innerHTML = sh.length ? sh.map(function (x, i) { return '
  • Shard ' + String(i + 1).padStart(2, '0') + '' + esc(x.state.charAt(0).toUpperCase() + x.state.slice(1)) + '
  • '; }).join('') : '
  • ' + (b.chain ? 'No shard plan yet.' : 'Not a chain block: no shards.') + '
  • '; + $('i-shards').innerHTML = sh.length ? sh.map(function (x, i) { return '
  • Shard ' + String(i + 1).padStart(2, '0') + '' + esc((sw.items[i] ? sw.items[i].word : x.state).replace(/^./, function (c) { return c.toUpperCase(); })) + '
  • '; }).join('') : '
  • ' + esc(sw.summary.replace(/^./, function (c) { return c.toUpperCase(); })) + '.
  • '; setText('i-incl', (b.color === 'blue' ? 'Included' : b.color === 'red' ? 'Excluded' : 'Pending') + (b.chain ? ' / selected chain' : '')); setText('i-miner', (b.miner === 'you' ? 'you' : b.miner) + (mineB && b.miner !== 'you' ? ' (you)' : '')); setText('i-blue', fmtN(b.blue_score)); setText('i-daa', fmtN(b.daa)); diff --git a/app/igneum-app/ui/live-dag.js b/app/igneum-app/ui/live-dag.js index 6ef39583..886d1a7b 100644 --- a/app/igneum-app/ui/live-dag.js +++ b/app/igneum-app/ui/live-dag.js @@ -1,5 +1,7 @@ /* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and * app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here. + * 2.0.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the + * empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence. * 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's * Included swatch had been ember while the scene draws included blocks in --included, and "pending" lower-case. * 2.0.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes @@ -303,13 +305,13 @@ function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;} function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);} function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;} - function words(b){ + function words(b,nowMs){ if(!b)return {title:'No block selected',lines:[]}; var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending'; var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final'); return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner, 'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'), - 'Shards: '+(b.shards.length?b.shards.map(function(s){return s.state;}).join(', '):'no shard plan yet')]}; + 'Shards: '+shardWords(b,nowMs).summary]}; } function showTip(b){ if(!tip)return; @@ -379,6 +381,27 @@ getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}}; instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api; } + // the shard sentence, once (7 Oct 2026, the fixed sentence the site's animation showed on every planless block): from the + // block's own facts. No shards: an excluded block has nothing to prove, a pending block is not yet ordered, a blue block off + // the selected chain gets no plan, a chain block under 10 s old is loading its plan, an older one says how long it has waited. + // With shards: a planned shard with no prover is awaiting one (with the block's age), assigned, proving, verified, paid. + function ageWord(sec){sec=Math.max(0,Math.round(sec));return sec<90?sec+' s':sec<5400?Math.round(sec/60)+' min':Math.round(sec/3600)+' h';} + function shardWords(b,nowMs){ + var now=finite(nowMs)?nowMs:Date.now(),age=b&&finite(b.ts)?Math.max(0,(now-b.ts)/1000):0,sh=(b&&b.shards)||[]; + if(!sh.length){ + if(!b)return {summary:'',items:[],state:'none'}; + if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'}; + if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'}; + if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'}; + if(age<10)return {summary:'shard plan loading',items:[],state:'loading'}; + return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'}; + } + var items=sh.map(function(x,i){var st=x.state,w=st==='planned'?(x.prover?'assigned to '+x.prover:'awaiting a prover · '+ageWord(age)):st==='proving'?'proving'+(x.prover?' by '+x.prover:''):st==='verified'?'verified':st==='paid'?'paid'+(finite(x.payout)?' '+x.payout+' IGN':''):'unknown';return {i:i,state:st,word:w};}); + var n={paid:0,verified:0,proving:0,assigned:0,awaiting:0}; + sh.forEach(function(x){if(x.state==='planned'){if(x.prover)n.assigned++;else n.awaiting++;}else if(n[x.state]!==undefined)n[x.state]++;}); + var parts=[];if(n.paid)parts.push(n.paid+' paid');if(n.verified)parts.push(n.verified+' verified');if(n.proving)parts.push(n.proving+' proving');if(n.assigned)parts.push(n.assigned+' assigned');if(n.awaiting)parts.push(n.awaiting+' awaiting a prover · '+ageWord(age)); + return {summary:parts.join(', '),items:items,state:n.awaiting?'awaiting':n.proving?'proving':'done'}; + } // key-22 (7 October 2026, the app's key against /live): THE key, once. Entries in the order both // pages show them, each naming the token the scene draws that state with and the swatch's shape; the app passes // {mine:true} and gets "Your blocks" (ringed, molten) first. tools/scene/legend.test.mjs fails when a page's key or a @@ -399,5 +422,5 @@ entries.forEach(function(e){var sp=doc.createElement('span');sp.setAttribute('data-lg',e.id);var i=doc.createElement('i');i.className='lg '+e.shape+(e.faded?' faded':'');i.style.setProperty('--lg','var('+e.token+')');if(e.fill)i.style.setProperty('--lg-fill','var('+e.fill+')');sp.appendChild(i);sp.appendChild(doc.createTextNode(e.label));el.appendChild(sp);}); keep.forEach(function(n){el.appendChild(n);});return entries; } - root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,version:'2.0.5'}; + root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,shardWords:shardWords,version:'2.0.6'}; })(window); diff --git a/scene/live-dag.js b/scene/live-dag.js index 6ef39583..886d1a7b 100644 --- a/scene/live-dag.js +++ b/scene/live-dag.js @@ -1,5 +1,7 @@ /* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and * app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here. + * 2.0.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the + * empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence. * 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's * Included swatch had been ember while the scene draws included blocks in --included, and "pending" lower-case. * 2.0.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes @@ -303,13 +305,13 @@ function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;} function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);} function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;} - function words(b){ + function words(b,nowMs){ if(!b)return {title:'No block selected',lines:[]}; var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending'; var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final'); return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner, 'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'), - 'Shards: '+(b.shards.length?b.shards.map(function(s){return s.state;}).join(', '):'no shard plan yet')]}; + 'Shards: '+shardWords(b,nowMs).summary]}; } function showTip(b){ if(!tip)return; @@ -379,6 +381,27 @@ getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}}; instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api; } + // the shard sentence, once (7 Oct 2026, the fixed sentence the site's animation showed on every planless block): from the + // block's own facts. No shards: an excluded block has nothing to prove, a pending block is not yet ordered, a blue block off + // the selected chain gets no plan, a chain block under 10 s old is loading its plan, an older one says how long it has waited. + // With shards: a planned shard with no prover is awaiting one (with the block's age), assigned, proving, verified, paid. + function ageWord(sec){sec=Math.max(0,Math.round(sec));return sec<90?sec+' s':sec<5400?Math.round(sec/60)+' min':Math.round(sec/3600)+' h';} + function shardWords(b,nowMs){ + var now=finite(nowMs)?nowMs:Date.now(),age=b&&finite(b.ts)?Math.max(0,(now-b.ts)/1000):0,sh=(b&&b.shards)||[]; + if(!sh.length){ + if(!b)return {summary:'',items:[],state:'none'}; + if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'}; + if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'}; + if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'}; + if(age<10)return {summary:'shard plan loading',items:[],state:'loading'}; + return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'}; + } + var items=sh.map(function(x,i){var st=x.state,w=st==='planned'?(x.prover?'assigned to '+x.prover:'awaiting a prover · '+ageWord(age)):st==='proving'?'proving'+(x.prover?' by '+x.prover:''):st==='verified'?'verified':st==='paid'?'paid'+(finite(x.payout)?' '+x.payout+' IGN':''):'unknown';return {i:i,state:st,word:w};}); + var n={paid:0,verified:0,proving:0,assigned:0,awaiting:0}; + sh.forEach(function(x){if(x.state==='planned'){if(x.prover)n.assigned++;else n.awaiting++;}else if(n[x.state]!==undefined)n[x.state]++;}); + var parts=[];if(n.paid)parts.push(n.paid+' paid');if(n.verified)parts.push(n.verified+' verified');if(n.proving)parts.push(n.proving+' proving');if(n.assigned)parts.push(n.assigned+' assigned');if(n.awaiting)parts.push(n.awaiting+' awaiting a prover · '+ageWord(age)); + return {summary:parts.join(', '),items:items,state:n.awaiting?'awaiting':n.proving?'proving':'done'}; + } // key-22 (7 October 2026, the app's key against /live): THE key, once. Entries in the order both // pages show them, each naming the token the scene draws that state with and the swatch's shape; the app passes // {mine:true} and gets "Your blocks" (ringed, molten) first. tools/scene/legend.test.mjs fails when a page's key or a @@ -399,5 +422,5 @@ entries.forEach(function(e){var sp=doc.createElement('span');sp.setAttribute('data-lg',e.id);var i=doc.createElement('i');i.className='lg '+e.shape+(e.faded?' faded':'');i.style.setProperty('--lg','var('+e.token+')');if(e.fill)i.style.setProperty('--lg-fill','var('+e.fill+')');sp.appendChild(i);sp.appendChild(doc.createTextNode(e.label));el.appendChild(sp);}); keep.forEach(function(n){el.appendChild(n);});return entries; } - root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,version:'2.0.5'}; + root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,shardWords:shardWords,version:'2.0.6'}; })(window); diff --git a/site/live-dag.js b/site/live-dag.js index 6ef39583..886d1a7b 100644 --- a/site/live-dag.js +++ b/site/live-dag.js @@ -1,5 +1,7 @@ /* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and * app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here. + * 2.0.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the + * empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence. * 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's * Included swatch had been ember while the scene draws included blocks in --included, and "pending" lower-case. * 2.0.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes @@ -303,13 +305,13 @@ function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;} function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);} function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;} - function words(b){ + function words(b,nowMs){ if(!b)return {title:'No block selected',lines:[]}; var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending'; var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final'); return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner, 'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'), - 'Shards: '+(b.shards.length?b.shards.map(function(s){return s.state;}).join(', '):'no shard plan yet')]}; + 'Shards: '+shardWords(b,nowMs).summary]}; } function showTip(b){ if(!tip)return; @@ -379,6 +381,27 @@ getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}}; instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api; } + // the shard sentence, once (7 Oct 2026, the fixed sentence the site's animation showed on every planless block): from the + // block's own facts. No shards: an excluded block has nothing to prove, a pending block is not yet ordered, a blue block off + // the selected chain gets no plan, a chain block under 10 s old is loading its plan, an older one says how long it has waited. + // With shards: a planned shard with no prover is awaiting one (with the block's age), assigned, proving, verified, paid. + function ageWord(sec){sec=Math.max(0,Math.round(sec));return sec<90?sec+' s':sec<5400?Math.round(sec/60)+' min':Math.round(sec/3600)+' h';} + function shardWords(b,nowMs){ + var now=finite(nowMs)?nowMs:Date.now(),age=b&&finite(b.ts)?Math.max(0,(now-b.ts)/1000):0,sh=(b&&b.shards)||[]; + if(!sh.length){ + if(!b)return {summary:'',items:[],state:'none'}; + if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'}; + if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'}; + if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'}; + if(age<10)return {summary:'shard plan loading',items:[],state:'loading'}; + return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'}; + } + var items=sh.map(function(x,i){var st=x.state,w=st==='planned'?(x.prover?'assigned to '+x.prover:'awaiting a prover · '+ageWord(age)):st==='proving'?'proving'+(x.prover?' by '+x.prover:''):st==='verified'?'verified':st==='paid'?'paid'+(finite(x.payout)?' '+x.payout+' IGN':''):'unknown';return {i:i,state:st,word:w};}); + var n={paid:0,verified:0,proving:0,assigned:0,awaiting:0}; + sh.forEach(function(x){if(x.state==='planned'){if(x.prover)n.assigned++;else n.awaiting++;}else if(n[x.state]!==undefined)n[x.state]++;}); + var parts=[];if(n.paid)parts.push(n.paid+' paid');if(n.verified)parts.push(n.verified+' verified');if(n.proving)parts.push(n.proving+' proving');if(n.assigned)parts.push(n.assigned+' assigned');if(n.awaiting)parts.push(n.awaiting+' awaiting a prover · '+ageWord(age)); + return {summary:parts.join(', '),items:items,state:n.awaiting?'awaiting':n.proving?'proving':'done'}; + } // key-22 (7 October 2026, the app's key against /live): THE key, once. Entries in the order both // pages show them, each naming the token the scene draws that state with and the swatch's shape; the app passes // {mine:true} and gets "Your blocks" (ringed, molten) first. tools/scene/legend.test.mjs fails when a page's key or a @@ -399,5 +422,5 @@ entries.forEach(function(e){var sp=doc.createElement('span');sp.setAttribute('data-lg',e.id);var i=doc.createElement('i');i.className='lg '+e.shape+(e.faded?' faded':'');i.style.setProperty('--lg','var('+e.token+')');if(e.fill)i.style.setProperty('--lg-fill','var('+e.fill+')');sp.appendChild(i);sp.appendChild(doc.createTextNode(e.label));el.appendChild(sp);}); keep.forEach(function(n){el.appendChild(n);});return entries; } - root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,version:'2.0.5'}; + root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,shardWords:shardWords,version:'2.0.6'}; })(window); diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index 8e24c6e9..54a0c7f7 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -99,7 +99,7 @@ tree_checks() { run "the home hero's loop never idles in view, stops hidden, resumes without a jump" node tools/site-redesign/tests/hero-loop-test.cjs run "chain scene: the site's and the app's copies are scene/ byte for byte, the palette tokens live once (self-test, then the tree)" bash -c 'node tools/scene/sync.mjs --self-test && node tools/scene/sync.mjs --check' run "chain scene: a push paints with the document hidden and no animation frame (the blank /live of 7 Oct 2026; known-failed first)" node tools/scene/paint-test.cjs - run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs tools/scene/legend.test.mjs + run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs tools/scene/legend.test.mjs tools/scene/shard-words.test.mjs run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright)" bash tools/scene/parity-remote.sh run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/lib/money.test.mjs site/api/public-stats.test.mjs run "ship tool self-test" node tools/ship-app.mjs --self-test diff --git a/tools/scene/legend.test.mjs b/tools/scene/legend.test.mjs index f972a50e..a91d4cc8 100644 --- a/tools/scene/legend.test.mjs +++ b/tools/scene/legend.test.mjs @@ -4,7 +4,7 @@ // order or tokens differ from the export, and when a colour the scene draws on blocks has no key entry. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { execSync } from 'node:child_process'; @@ -48,13 +48,15 @@ const SITE_RULED = SITE_ORDER.filter((l) => l !== 'Pending'); // diagnostic on a release branch const branch = (() => { try { return execSync('git rev-parse --abbrev-ref HEAD', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch (e) { return ''; } })(); const siteBinds = process.env.SCENE_SYNC_SCOPE === 'site' || !!process.env.SITE_LIVE_HTML || branch === 'master'; +// the app lives on the release branches (master carries no app/igneum-app/ui/live-dag.js), so the app half binds where that copy exists +const appBinds = existsSync(join(root, 'app/igneum-app/ui/live-dag.js')); const siteEntries = (html) => [...html.matchAll(/<\/i>([^<]+)<\/span>/g)].map((m) => { const tok = /var\((--[a-z0-9-]+)\)/.exec(m[2] || ''); return { label: m[3].trim(), token: tok ? tok[1] : (/\btick\b/.test(m[1]) ? '--bone' : ''), shape: /\btick\b/.test(m[1]) ? 'tick' : /border-radius:50%/.test(m[2] || '') ? 'circle' : 'square' }; }); test('the app key is the export, not a hand-written list; the site key is the export minus the ruled exceptions (Pending, Your blocks), same wording and tokens on every shared entry', (t) => { const app = readFileSync(join(root, 'app/igneum-app/ui/index.html'), 'utf8'), site = readFileSync(process.env.SITE_LIVE_HTML || join(root, 'site/live.html'), 'utf8'); const appLit = literal(app, 'lg'); - assert.ok(/data-legend="mine"/.test(app), 'the app legend renders from IgneumDag.legend({ mine: true })'); - assert.deepEqual(appLit, [], 'hand-written entries in the app key: ' + appLit.join(', ')); + if (appBinds) { assert.ok(/data-legend="mine"/.test(app), 'the app legend renders from IgneumDag.legend({ mine: true })'); assert.deepEqual(appLit, [], 'hand-written entries in the app key: ' + appLit.join(', ')); } + else t.diagnostic('no app scene copy in this tree (master): the app half binds on the release branches'); const got = siteEntries(site), exp = Dag.legend({ mine: false }).filter((e) => e.label !== 'Pending'), problems = []; if (JSON.stringify(got.map((e) => e.label)) !== JSON.stringify(SITE_RULED)) problems.push('the /live key reads ' + got.map((e) => e.label).join(', ') + ', ruled: ' + SITE_RULED.join(', ')); for (const e of exp) { const g = got.find((x) => x.label === e.label); if (!g) continue; if (g.token !== e.token) problems.push(e.label + ' token on /live is ' + g.token + ', the export says ' + e.token); if (g.shape !== e.shape) problems.push(e.label + ' shape on /live is ' + g.shape + ', the export says ' + e.shape); } @@ -62,7 +64,7 @@ test('the app key is the export, not a hand-written list; the site key is the ex if (siteBinds) assert.deepEqual(problems, [], 'the /live key against the export:\n' + problems.join('\n')); else if (problems.length) t.diagnostic('site/live.html on this release branch is the frozen cut (' + problems.join('; ') + '); the check binds on master, where the site deploys from'); // the app's source line stays - assert.match(readFileSync(join(root, 'app/igneum-app/ui/app.js'), 'utf8'), /From Igneum’s observer\. Your blocks are ringed\./); + if (appBinds) assert.match(readFileSync(join(root, 'app/igneum-app/ui/app.js'), 'utf8'), /From Igneum’s observer\. Your blocks are ringed\./); // the copies are the source (the sync gate says the same; this fails first when a legend edit misses the sync) - assert.equal(readFileSync(join(root, 'app/igneum-app/ui/live-dag.js'), 'utf8'), src, 'the app copy of live-dag.js is the scene source'); + if (appBinds) assert.equal(readFileSync(join(root, 'app/igneum-app/ui/live-dag.js'), 'utf8'), src, 'the app copy of live-dag.js is the scene source'); }); diff --git a/tools/scene/shard-words.test.mjs b/tools/scene/shard-words.test.mjs new file mode 100644 index 00000000..e2501b58 --- /dev/null +++ b/tools/scene/shard-words.test.mjs @@ -0,0 +1,54 @@ +// node --test tools/scene/shard-words.test.mjs (7 October 2026: "why does the animation on site say no shard plan yet?") +// IgneumDag.shardWords(block, nowMs) is the one source of the shard sentence the scene's tooltip, the site's /live +// inspector and the app's inspector print. The empty case says what the block's own facts say: a block under 10 s old +// with no shards is loading its plan, an excluded block has nothing to prove, a pending block is not yet ordered, a +// blue block off the selected chain gets no plan; a planned shard with no prover is awaiting one, with the block's age; +// proving, verified and paid as before. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { execSync } from 'node:child_process'; +const here = dirname(fileURLToPath(import.meta.url)), root = join(here, '../..'); +const src = readFileSync(join(root, 'scene/live-dag.js'), 'utf8'); +const win = { document: { hidden: false, addEventListener() {}, createElement: () => ({ style: {} }) }, addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }), getComputedStyle: () => ({ getPropertyValue: () => '' }), devicePixelRatio: 1, innerWidth: 1280 }; +new Function('window', 'document', src)(win, win.document); +const Dag = win.IgneumDag; +const NOW = 1_791_378_800_000; +// the site deploys from master alone and a release-* branch carries site/ frozen as it was cut, so the site inspector's +// check binds on master (or SCENE_SYNC_SCOPE=site) and is a diagnostic on a release branch +const branch = (() => { try { return execSync('git rev-parse --abbrev-ref HEAD', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch (e) { return ''; } })(); +const siteBinds = process.env.SCENE_SYNC_SCOPE === 'site' || branch === 'master'; +const appBinds = existsSync(join(root, 'app/igneum-app/ui/live-dag.js')); // the app lives on the release branches +const block = (over) => ({ hash: 'ab12', ts: NOW - 42_000, color: 'blue', chain: true, miner: 'k1', parents: [], blue_score: 1, daa: 1, shards: [], locked: false, final: false, proven: false, ...over }); + +test('the four empty cases (known-failed on 2.0.5): loading under 10 s, excluded, not yet ordered, off the selected chain; an old chain block with no plan says how long', () => { + assert.ok(Dag && typeof Dag.shardWords === 'function', 'IgneumDag.shardWords exists'); + assert.equal(Dag.shardWords(block({ ts: NOW - 4_000 }), NOW).summary, 'shard plan loading'); + assert.equal(Dag.shardWords(block({ color: 'red', chain: false }), NOW).summary, 'excluded, nothing to prove'); + assert.equal(Dag.shardWords(block({ color: 'pending', chain: false }), NOW).summary, 'not yet ordered'); + assert.equal(Dag.shardWords(block({ chain: false }), NOW).summary, 'off the selected chain, no shards planned'); + assert.equal(Dag.shardWords(block(), NOW).summary, 'no shard plan after 42 s'); + for (const s of [Dag.shardWords(block({ ts: NOW - 4_000 }), NOW), Dag.shardWords(block({ color: 'red' }), NOW)]) assert.deepEqual(s.items, []); + assert.equal(/no shard plan yet/.test(src), false, 'the old words are gone from the scene'); +}); + +test('a planned shard with no prover is awaiting one, with the block age; assigned, proving, verified and paid as before', (t) => { + const sh = [{ i: 0, n: 4, state: 'planned', prover: null }, { i: 1, n: 4, state: 'planned', prover: 'faa34a1a' }, { i: 2, n: 4, state: 'proving', prover: 'b2' }, { i: 3, n: 4, state: 'paid', prover: 'c3', payout: 4.5532 }]; + const w = Dag.shardWords(block({ shards: sh }), NOW); + assert.deepEqual(w.items.map((x) => x.word), ['awaiting a prover · 42 s', 'assigned to faa34a1a', 'proving by b2', 'paid 4.5532 IGN']); + assert.equal(w.summary, '1 paid, 1 proving, 1 assigned, 1 awaiting a prover · 42 s'); + const old = Dag.shardWords(block({ ts: NOW - 400_000, shards: [{ state: 'planned', prover: null }] }), NOW); + assert.equal(old.items[0].word, 'awaiting a prover · 7 min'); assert.equal(old.summary, '1 awaiting a prover · 7 min'); + const done = Dag.shardWords(block({ shards: [{ state: 'verified' }, { state: 'paid', payout: 1 }] }), NOW); + assert.equal(done.summary, '1 paid, 1 verified'); assert.deepEqual(done.items.map((x) => x.word), ['verified', 'paid 1 IGN']); + // the tooltip line uses it (words() is an instance method, so the source line is the check) + assert.match(src, /'Shards: '\+shardWords\(b,nowMs\)\.summary/, 'the tooltip takes the shared sentence'); + // both inspectors use it: no hand-written shard sentence left + const live = readFileSync(join(root, 'site/live.html'), 'utf8'), siteOk = !/no shard plan yet/i.test(live) && /IgneumDag\.shardWords\(/.test(live); + if (siteBinds) assert.ok(siteOk, 'the site inspector still carries its own shard sentence'); + else if (!siteOk) t.diagnostic('site/live.html on this release branch is the frozen cut; the inspector check binds on master, where the site deploys from'); + if (appBinds) { const app = readFileSync(join(root, 'app/igneum-app/ui/app.js'), 'utf8'); assert.equal(/no shard plan yet/i.test(app), false, 'the app inspector'); assert.match(app, /IgneumDag\.shardWords\(/, 'the app inspector takes the shared sentence'); } + else t.diagnostic('no app scene copy in this tree (master): the app half binds on the release branches'); +});