Signing state on every card row and the node line (signing-22, the project lead's signing-bonus ruling, 7 October 2026): the miner signs the finality section by default (Settings.vote true, an old settings file without the field reads true, the one --no-vote flag comes from config::vote_flag and exists only when the user turned voting off; the Rust test vote_defaults_on pins all three); View.signingWords says "signing" with the checkpoint signed and when, or "silent" with the reason (voting off in Settings, no vote key, the clock behind or ahead of the network, no template while the node syncs, or a key that stopped for N min while the chain kept locking), "first checkpoint pending" in the first two minutes, "paused" when the network itself has not locked; the Overview's node line ends in the word and turns bad with the reason as its sub line; each Cards row shows its own keys' state under the state line; two view tests known-failed first

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 17:52:08 +00:00
parent 11aeaf1d68
commit 9e782ee9a3
4 changed files with 97 additions and 6 deletions

View file

@ -180,6 +180,13 @@ fn balanced() -> String {
fn nineteen() -> f64 { fn nineteen() -> f64 {
19.0 19.0
} }
/// signing-22 (7 October 2026, the project lead's signing-bonus ruling): the one place the miner's vote flag comes from. Voting is
/// on by default (`Settings::default`, and an old settings file without the field reads true through `yes`), so the
/// flag exists only when the user turned it off; a silent key then shows on every card row as "silent: voting is off".
pub fn vote_flag(vote: bool) -> Option<&'static str> {
if vote { None } else { Some("--no-vote") }
}
fn yes() -> bool { fn yes() -> bool {
true true
} }
@ -459,6 +466,18 @@ impl Runtime {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn vote_defaults_on() {
// signing-22: the finality section is signed by default; an old file without the field is not a silent key
assert!(super::Settings::default().vote);
let old: super::Settings = serde_json::from_str("{}").unwrap();
assert!(old.vote, "a settings file without `vote` reads true");
let off: super::Settings = serde_json::from_str(r#"{"vote":false}"#).unwrap();
assert!(!off.vote);
assert_eq!(super::vote_flag(true), None);
assert_eq!(super::vote_flag(false), Some("--no-vote"));
}
#[test] #[test]
fn currency_round_trip() { fn currency_round_trip() {
// currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as "" // currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as ""

View file

@ -2274,8 +2274,8 @@ impl Engine {
a.push("--identities".into()); a.push("--identities".into());
a.push(card.identities.to_string()); a.push(card.identities.to_string());
} }
if !s.vote { if let Some(f) = crate::config::vote_flag(s.vote) {
a.push("--no-vote".into()); a.push(f.into());
} }
if !s.dev_fee { if !s.dev_fee {
// the miner software's dev fee (default 1% of templates); the switch in Settings // the miner software's dev fee (default 1% of templates); the switch in Settings

View file

@ -601,12 +601,38 @@ var View = (function () {
// "12 s" / "4 min" / "53 min" / "2 h" for a tip age // "12 s" / "4 min" / "53 min" / "2 h" for a tip age
function tipAge(s) { s = Math.max(0, Math.round(s)); return s < 90 ? s + ' s' : s < 5400 ? Math.round(s / 60) + ' min' : Math.round(s / 3600) + ' h'; } function tipAge(s) { s = Math.max(0, Math.round(s)); return s < 90 ? s + ' s' : s < 5400 ? Math.round(s / 60) + ' min' : Math.round(s / 3600) + ' h'; }
// the one node line on Mine: "Node synced · 6 peers · 109,859 blocks · last block 4 s ago" // the one node line on Mine: "Node synced · 6 peers · 109,859 blocks · last block 4 s ago"
function nodeLine(n, clock, eta) { // ---------- the signing state (signing-22, the project lead's signing-bonus ruling, 7 October 2026) ----------
// The miner signs the finality section by default; this says whether the key is signing now, or why it is silent:
// voting off, a missing vote key, a clock block, no template (the node not synced), or a key that stopped while the
// chain kept locking. With a card it reads that card's own keys and says nothing for a card that is off.
var SIGN_GAP_S = 90;
function agoWords(sec) { sec = Math.max(0, Math.round(sec)); return sec < 90 ? sec + ' s ago' : tipAge(sec) + ' ago'; }
function signingWords(s, now, cd) {
s = s || {}; var st = s.settings || {}, n = s.node || {}, c = s.clock || {}, ld = s.ladder || {}, f = s.finality || {}, m = s.mining || {};
if (cd && !(cd.enabled && !(cd.removed_at > 0) && !cd.problem)) return { state: '', word: '', reason: '', tone: '' };
var keys = cd ? (cd.ids || []).length > 0 : (m.cards || []).some(function (x) { return (x.ids || []).length > 0; });
var silent = function (reason) { return { state: 'silent', word: 'silent', reason: reason, tone: 'bad' }; };
if (st.vote === false) return silent('voting is off in Settings');
if (c.severity === 'block') return silent('the clock is ' + skewWord(c.skew_s) + ' ' + (c.skew_s < 0 ? 'behind' : 'ahead of') + ' the network; signatures would be refused');
if (!n.synced) return silent('no template yet: the node is ' + (n.state || 'starting'));
if (!keys) return silent('no vote key: the miner started without one');
var voteAge = ld.last_vote_at > 0 ? now - ld.last_vote_at : -1, lockAge = f.last_lock_at > 0 ? now - f.last_lock_at : -1;
if (voteAge >= 0 && voteAge <= SIGN_GAP_S) return { state: 'signing', word: 'signing', reason: 'checkpoint ' + withCommas(ld.last_vote_index || 0) + ' signed ' + agoWords(voteAge), tone: 'on' };
if (lockAge < 0 || lockAge > SIGN_GAP_S) return { state: 'paused', word: 'paused', reason: lockAge < 0 ? 'the network has not locked a checkpoint yet' : 'the network has not locked a checkpoint for ' + tipAge(lockAge), tone: '' };
if (voteAge < 0 && !(f.votes > 0) && (s.uptime_s || 0) < 120) return { state: 'waiting', word: 'first checkpoint pending', reason: 'the first signature comes within a checkpoint of the sync', tone: '' };
return silent(voteAge < 0 ? 'no checkpoint signed since start while the chain locked ' + withCommas(f.last_lock || 0) : 'no checkpoint signed for ' + tipAge(voteAge) + ' while the chain locked ' + withCommas(f.last_lock || 0));
}
function nodeLine(n, clock, eta, sign) {
var w = nodeWords(n, clock, eta), parts = ['Node ' + w.word]; var w = nodeWords(n, clock, eta), parts = ['Node ' + w.word];
if (n.peers > 0) parts.push(n.peers + ' peer' + (n.peers === 1 ? '' : 's')); if (n.peers > 0) parts.push(n.peers + ' peer' + (n.peers === 1 ? '' : 's'));
if (n.blocks > 0) parts.push(withCommas(n.blocks) + ' blocks'); if (n.blocks > 0) parts.push(withCommas(n.blocks) + ' blocks');
if (n.tip_age_s >= 0 && (n.state === 'synced' || n.state === 'behind')) parts.push('last block ' + tipAge(n.tip_age_s) + ' ago'); if (n.tip_age_s >= 0 && (n.state === 'synced' || n.state === 'behind')) parts.push('last block ' + tipAge(n.tip_age_s) + ' ago');
return { text: parts.join(' · '), sub: w.line, tone: w.tone }; var out = { text: parts.join(' · '), sub: w.line, tone: w.tone };
if (sign && sign.word) {
out.text += ' · ' + sign.word;
if (sign.state === 'silent' && w.tone !== 'bad') { out.tone = 'bad'; out.sub = 'Silent: ' + sign.reason + '.'; }
}
return out;
} }
function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; } function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; }
function peersLine(n) { return n.peers === 0 ? 'none yet: looking for the seed node' : n.peers === 1 ? 'one other node this one talks to' : 'other nodes this one talks to'; } function peersLine(n) { return n.peers === 0 ? 'none yet: looking for the seed node' : n.peers === 1 ? 'one other node this one talks to' : 'other nodes this one talks to'; }
@ -1276,7 +1302,7 @@ var View = (function () {
} }
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; } function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; }
function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; } function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; }
return { ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords }; return { signingWords: signingWords, SIGN_GAP_S: SIGN_GAP_S, ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
})(); })();
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ---------- /* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
@ -2272,6 +2298,8 @@ if (typeof document !== 'undefined') (function () {
var subs = []; var subs = [];
if (r.sub) subs.push('<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>'); if (r.sub) subs.push('<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>');
if (cells.noReading && r.on) subs.push('<div class="msg dim">' + esc(cells.noReading) + '</div>'); if (cells.noReading && r.on) subs.push('<div class="msg dim">' + esc(cells.noReading) + '</div>');
var sg = View.signingWords(s, s.now, cd);
if (sg.word) subs.push('<div class="msg sign' + (sg.state === 'silent' ? '' : ' dim') + '" title="' + esc(sg.reason) + '">' + esc(sg.word) + (sg.state === 'signing' ? '' : ': ' + esc(sg.reason)) + '</div>');
var dw = View.driverWords(cd, s); var dw = View.driverWords(cd, s);
var drv = dw.kind === 'none' || dw.kind === 'fine' ? '' : '<div class="gl-driver ' + dw.kind + '">' + (dw.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round((dw.progress || 0) * 100) + '%"></b></i>' : '') + '<span class="t">' + esc(dw.text) + '</span>' + var drv = dw.kind === 'none' || dw.kind === 'fine' ? '' : '<div class="gl-driver ' + dw.kind + '">' + (dw.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round((dw.progress || 0) * 100) + '%"></b></i>' : '') + '<span class="t">' + esc(dw.text) + '</span>' +
(dw.button === 'install' ? '<button class="btn small primary" data-driver-install="' + esc(cd.driver_offer.vendor) + '">Install</button>' : dw.button === 'restart' ? '<button class="btn small primary" data-driver-restart="1">Restart now</button>' : '') + (dw.button === 'install' ? '<button class="btn small primary" data-driver-install="' + esc(cd.driver_offer.vendor) + '">Install</button>' : dw.button === 'restart' ? '<button class="btn small primary" data-driver-restart="1">Restart now</button>' : '') +
@ -2408,7 +2436,7 @@ if (typeof document !== 'undefined') (function () {
var switchesSig = ''; var switchesSig = '';
function renderNode(s) { function renderNode(s) {
var n = s.node, f = s.finality, p = s.program, eta = syncEta(n, s.now); var n = s.node, f = s.finality, p = s.program, eta = syncEta(n, s.now);
var nl = View.nodeLine(n, s.clock, eta), fw = View.finalityWords(f); var nl = View.nodeLine(n, s.clock, eta, View.signingWords(s, s.now)), fw = View.finalityWords(f);
setText('node-line-text', nl.text); setText('node-sub', fw.paused && nl.tone !== 'bad' ? fw.note : nl.sub); setText('node-line-text', nl.text); setText('node-sub', fw.paused && nl.tone !== 'bad' ? fw.note : nl.sub);
$('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : ''); $('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : '');
$('node-card').classList.toggle('bad', nl.tone === 'bad'); $('node-card').classList.toggle('bad', nl.tone === 'bad');

View file

@ -1051,3 +1051,47 @@ test('a locale with no currency row falls back to USD with the override prompt v
assert.equal(V.currencyAsk(V.currencyChoice({}, 'en-GB')), ''); assert.equal(V.currencyAsk(V.currencyChoice({}, 'en-GB')), '');
assert.ok(pageHtml.includes('id="s-currency-ask"') && pageHtml.includes('id="region-currency-ask"'), 'the prompt element on Settings and on the first-run step'); assert.ok(pageHtml.includes('id="s-currency-ask"') && pageHtml.includes('id="region-currency-ask"'), 'the prompt element on Settings and on the first-run step');
}); });
// ---------- signing state (signing-22, the project lead's signing-bonus ruling, 7 October 2026) ----------
// The miner signs the finality section by default (settings.vote true, an old settings file without the field reads
// true, the miner starts without --no-vote); the Overview's node line and every card row say "signing" while the key
// signs and "silent" with the reason when it does not (voting off, a missing key, a clock block, no template), so a
// silent key from a bug is visible before any bonus height is set.
test('signing on by default: Settings.vote true in the Rust default and for an old file without the field; the miner flag only when off', () => {
const cfg = rustSrc('config.rs');
assert.match(cfg, /#\[serde\(default = "yes"\)\]\s*\n\s*pub vote: bool/, 'an old settings file without the field reads true');
assert.match(cfg, /fn vote_defaults_on/, 'the Rust test exists');
assert.match(cfg, /pub fn vote_flag\(vote: bool\)/, 'the one place the --no-vote flag comes from');
assert.match(rustSrc('engine.rs'), /crate::config::vote_flag\(s\.vote\)/, 'miner_args uses it');
});
test('"signing" when the key signs; "silent" with the reason: voting off, a missing key, a clock block, no template, a stale key', () => {
const base = stateOf({ uptime_s: 1800, settings: { ...stateOf().settings, vote: true }, node: { synced: true, state: 'synced' }, clock: { severity: 'none', skew_s: 0 }, finality: { last_lock: 8495, last_lock_at: T5 - 12, votes: 240 }, ladder: { ...stateOf().ladder, votes_signed: 240, last_vote_index: 8495, last_vote_at: T5 - 12 } });
const on = V.signingWords(base, T5);
assert.equal(on.state, 'signing'); assert.equal(on.word, 'signing'); assert.equal(on.tone, 'on');
assert.equal(on.reason, 'checkpoint 8,495 signed 12 s ago');
const off = V.signingWords({ ...base, settings: { ...base.settings, vote: false } }, T5);
assert.equal(off.state, 'silent'); assert.equal(off.word, 'silent'); assert.equal(off.tone, 'bad'); assert.equal(off.reason, 'voting is off in Settings');
const nokey = V.signingWords({ ...base, mining: { ...base.mining, cards: [card({ ids: [] })] } }, T5);
assert.equal(nokey.state, 'silent'); assert.equal(nokey.reason, 'no vote key: the miner started without one');
const clock = V.signingWords({ ...base, clock: { severity: 'block', skew_s: -412 } }, T5);
assert.equal(clock.state, 'silent'); assert.equal(clock.reason, 'the clock is 6 min 52 s behind the network; signatures would be refused');
const tpl = V.signingWords({ ...base, node: { synced: false, state: 'syncing' } }, T5);
assert.equal(tpl.state, 'silent'); assert.equal(tpl.reason, 'no template yet: the node is syncing');
const stale = V.signingWords({ ...base, ladder: { ...base.ladder, last_vote_at: T5 - 400 } }, T5);
assert.equal(stale.state, 'silent'); assert.equal(stale.reason, 'no checkpoint signed for 7 min while the chain locked 8,495');
const first = V.signingWords({ ...base, uptime_s: 40, finality: { ...base.finality, votes: 0 }, ladder: { ...base.ladder, votes_signed: 0, last_vote_at: 0 } }, T5);
assert.equal(first.state, 'waiting'); assert.equal(first.word, 'first checkpoint pending'); assert.equal(first.tone, '');
const paused = V.signingWords({ ...base, finality: { ...base.finality, last_lock_at: T5 - 900 }, ladder: { ...base.ladder, last_vote_at: T5 - 400 } }, T5);
assert.equal(paused.state, 'paused'); assert.equal(paused.reason, 'the network has not locked a checkpoint for 15 min');
// one card's row: its own keys decide the missing-key case
const row = V.signingWords(base, T5, card({ ids: [] })); assert.equal(row.state, 'silent');
assert.equal(V.signingWords(base, T5, card({ ids: ['6ad0e117'] })).state, 'signing');
assert.equal(V.signingWords(base, T5, card({ enabled: false, state: 'off', ids: ['6ad0e117'] })).state, '', 'an off card signs nothing and says nothing');
// the node line carries the word
const nl = V.nodeLine({ state: 'synced', synced: true, peers: 4, blocks: 39571, tip_age_s: 3 }, { severity: 'none' }, '', on);
assert.equal(nl.text, 'Node synced · 4 peers · 39,571 blocks · last block 3 s ago · signing');
const nls = V.nodeLine({ state: 'synced', synced: true, peers: 4, blocks: 39571, tip_age_s: 3 }, { severity: 'none' }, '', off);
assert.equal(nls.text, 'Node synced · 4 peers · 39,571 blocks · last block 3 s ago · silent'); assert.equal(nls.tone, 'bad'); assert.equal(nls.sub, 'Silent: voting is off in Settings.');
for (const w of [on, off, nokey, clock, tpl, stale, first, paused]) assert.equal(/—|–/.test(w.reason + w.word), false);
});