The one long page becomes a rail with six sections, a thin top bar and the status strip under it; the setup screens and the key sheet stay. Mine: one big start/stop, the numbers, one row per GPU with its switch, hash rate, temperature and power. Prove: the switch with plain words, the counts, the verifier, the pinned ids. Rewards: the address with one Copy, the key backup card, another address. Node: the plain lines, the consensus digest, the next switch. Updates: the version, the jobs. Settings: one switch or slider per setting with one line of help. Every function that existed is placed, none removed. Engine (three small additions, each with a unit test): node.consensus_digest from the node's own line, node.consensus_switches from the override file, proving.program_id and aggregator_id from the host's --mode id. Hosts: the window minimum is 900 x 600 on both. UI tests: view.test.mjs (10) joins notices and update-card in CI. Proof: docs/plans/miner-ui-2.md and the 19 screenshots under docs/plans/miner-ui-2/, taken from a build of this tree running on its own port against the devnet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
151 lines
9.6 KiB
JavaScript
151 lines
9.6 KiB
JavaScript
// node --test app/igneum-app/ui/view.test.mjs (no dependencies; CI runs it in the site job)
|
||
// Loads the View block of app.js (plain browser JS: the file is run with `module` defined and no `document`, so only
|
||
// the pure blocks execute) and checks the words each page shows for a given state (miner-ui-2).
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
|
||
const mod = { exports: {} };
|
||
new Function('module', src)(mod);
|
||
const V = mod.exports.View;
|
||
|
||
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
||
|
||
test('the six sections and their order', () => {
|
||
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'prove', 'rewards', 'node', 'updates', 'settings']);
|
||
assert.equal(V.page('node').title, 'Node');
|
||
assert.equal(V.page('nonsense').id, 'mine');
|
||
});
|
||
|
||
test('a GPU row: name, kind, the numbers where known, the switch', () => {
|
||
const r = V.cardRow(card());
|
||
assert.equal(r.name, 'NVIDIA GeForce RTX 5090');
|
||
assert.equal(r.kindWord, 'Discrete');
|
||
assert.equal(r.integrated, false);
|
||
assert.equal(r.on, true);
|
||
assert.equal(r.word, 'mining');
|
||
assert.equal(r.tone, 'on');
|
||
assert.equal(r.hash, '124');
|
||
assert.equal(r.temp, '61 °C');
|
||
assert.equal(r.tempTone, '');
|
||
assert.equal(r.power, '410 W');
|
||
assert.equal(r.cap, 460);
|
||
assert.equal(r.meta, '32 GB · 3 blocks · next program ready');
|
||
assert.equal(r.canToggle, true);
|
||
});
|
||
|
||
test('a GPU row: temperatures turn amber then red; unknown numbers are empty', () => {
|
||
assert.equal(V.cardRow(card({ temp_mem: 92 })).tempTone, 'warm');
|
||
assert.equal(V.cardRow(card({ temp_mem: 96 })).tempTone, 'hot');
|
||
assert.equal(V.cardRow(card({ temp_gpu: 93 })).tempTone, 'hot');
|
||
const apple = V.cardRow(card({ key: 'apple::Apple M5 Max', name: 'Apple M5 Max', vendor: 'apple', kind: 'apple', worker: 'Metal', vram_mb: 131072, power_w: 0, power_default_w: 0, temp_gpu: 0, temp_mem: 0, telemetry_at: 0, hash_now: 7.4, accepted: 0, prepared: false }));
|
||
assert.equal(apple.kindWord, 'Apple silicon');
|
||
assert.equal(apple.hash, '7.4');
|
||
assert.equal(apple.temp, '');
|
||
assert.equal(apple.power, '');
|
||
assert.equal(apple.cap, 0);
|
||
assert.equal(apple.meta, '128 GB unified');
|
||
});
|
||
|
||
test('an integrated GPU is shown as integrated and off, with its reason', () => {
|
||
const r = V.cardRow(card({ key: 'intel:1:UHD', name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated', enabled: false, state: 'off', hash_now: 0, reason: 'integrated GPU: slow and shares the machine memory', power_w: 0, temp_gpu: 0 }));
|
||
assert.equal(r.integrated, true);
|
||
assert.equal(r.kindWord, 'Integrated');
|
||
assert.equal(r.on, false);
|
||
assert.equal(r.word, 'off');
|
||
assert.equal(r.tone, 'off');
|
||
assert.equal(r.hash, '');
|
||
assert.equal(r.sub, 'integrated GPU: slow and shares the machine memory');
|
||
assert.equal(V.cardRow(card({ kind: 'unknown' })).canToggle, false);
|
||
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 7 })).word, 'restart in 7 s');
|
||
assert.equal(V.cardRow(card({ state: 'faulted' })).tone, 'bad');
|
||
assert.equal(V.cardRow(card({ state: 'waiting' })).word, 'waiting for the node');
|
||
});
|
||
|
||
test('the big button: start when paused, stop when mining, disabled with no card on', () => {
|
||
const m = (over) => ({ state: 'mining', paused: false, cards: [card()], ...over });
|
||
assert.deepEqual(V.toggle(m(), { synced: true }, {}), { label: 'Stop mining', sub: 'mining on 1 of 1 card', cls: 'stop', disabled: false, act: 'pause' });
|
||
assert.deepEqual(V.toggle(m({ state: 'paused', paused: true }), { synced: true }, {}), { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' });
|
||
const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {});
|
||
assert.equal(none.disabled, true);
|
||
assert.equal(none.act, '');
|
||
assert.equal(none.sub, 'switch a GPU on below first');
|
||
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive');
|
||
assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync');
|
||
assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true);
|
||
});
|
||
|
||
test('the node words: synced, syncing with a percentage, failed, a blocked clock', () => {
|
||
const n = (over) => ({ state: 'synced', blocks: 135200, headers: 135200, peers: 3, daa: 140000, last_reading_age_s: 4, message: '', restart_in_s: 0, ...over });
|
||
const ok = V.nodeWords(n(), { severity: 'none' }, '');
|
||
assert.equal(ok.word, 'synced');
|
||
assert.equal(ok.tone, 'ok');
|
||
assert.match(ok.line, /every block/);
|
||
const sy = V.nodeWords(n({ state: 'syncing', blocks: 50000, headers: 100000 }), { severity: 'none' }, 'about 3 min left at 300 blocks/s');
|
||
assert.equal(sy.word, 'syncing');
|
||
assert.equal(sy.line, 'about 3 min left at 300 blocks/s · 50,000 of 100,000 (50%)');
|
||
assert.equal(V.nodeWords(n({ state: 'failed', message: 'igneumd could not start' }), { severity: 'none' }, '').tone, 'bad');
|
||
const blocked = V.nodeWords(n(), { severity: 'block', skew_s: -75 }, '');
|
||
assert.equal(blocked.tone, 'bad');
|
||
assert.match(blocked.line, /clock is off by 75 s/);
|
||
assert.equal(V.peersLine(n({ peers: 0 })), 'none yet: looking for the seed node');
|
||
assert.equal(V.peersLine(n({ peers: 1 })), 'one other node this one talks to');
|
||
assert.equal(V.heightLine(n({ state: 'syncing', blocks: 10, headers: 500 })), 'of 500 headers seen');
|
||
assert.equal(V.heightLine(n()), 'blocks this node holds');
|
||
});
|
||
|
||
test('the next consensus switch is the first height above the DAA score, in plain words', () => {
|
||
const sw = [
|
||
{ key: 'fees_v1_activation_daa', name: 'Fees v1', daa: 210000 },
|
||
{ key: 'difficulty_v2_activation_daa', name: 'Difficulty v2', daa: 33000 },
|
||
{ key: 'finality_v3_activation_daa', name: 'Finality v3', daa: 135200 }
|
||
];
|
||
const next = V.nextSwitch(sw, 140000);
|
||
assert.equal(next.name, 'Fees v1');
|
||
assert.equal(next.away, 70000);
|
||
assert.equal(V.switchLine(next, 140000), 'Fees v1 at DAA 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
|
||
assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2');
|
||
assert.equal(V.nextSwitch(sw, 300000), null);
|
||
assert.match(V.switchLine(null, 300000), /Every planned switch is behind this node/);
|
||
assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/);
|
||
assert.equal(V.nextSwitch([], 5), null);
|
||
});
|
||
|
||
test('the prove words follow the switch, the setup, the node and the status', () => {
|
||
const pv = (over) => ({ enabled: true, available: true, setup_hint: '', backend: 'cuda', status: 'idle', message: '', current: '', verifier_mode: 'command', pool_entries: 4, pool_verified: 4, pool_failed: 0, ...over });
|
||
assert.equal(V.proveWords(pv(), false, true).word, 'off');
|
||
assert.equal(V.proveWords(pv({ status: 'setup', available: false, setup_hint: 'proving needs the WSL2 setup' }), true, true).word, 'needs setup');
|
||
assert.equal(V.proveWords(pv(), true, false).word, 'waiting');
|
||
assert.equal(V.proveWords(pv({ status: 'proving', current: 'block 59199 shard 0' }), true, true).sub, 'block 59199 shard 0');
|
||
assert.equal(V.proveWords(pv({ status: 'submitted' }), true, true).tone, 'on');
|
||
assert.equal(V.proveWords(pv(), true, true).word, 'idle');
|
||
assert.equal(V.verifierWords(pv()).word, 'verifying · pool 4/4');
|
||
assert.equal(V.verifierWords(pv()).tone, 'ok');
|
||
assert.equal(V.verifierWords(pv({ verifier_mode: 'off', pool_entries: 0 })).tone, 'warn');
|
||
assert.equal(V.verifierWords(pv({ verifier_mode: 'off', verifier_reason: 'the WSL2 prover is not installed' })).needsSetup, true);
|
||
assert.equal(V.verifierWords({}).word, 'not read yet');
|
||
});
|
||
|
||
test('the dev-fee lines name the share and where Settings turns it off', () => {
|
||
const s = (on, fee) => ({ settings: { dev_fee: on }, mining: { fee_total: fee }, dev_fee: { on, percent: on ? 1 : 0, address: '0x1234567890abcdef1234567890abcdef12345678', line: '' } });
|
||
assert.equal(V.devFeeLine(s(true, 12)), 'One block in 100 pays the miner software’s dev fee (12 so far). Settings turns it off.');
|
||
assert.equal(V.devFeeLine(s(false, 0)), 'The dev fee is off. Every block pays this address.');
|
||
assert.equal(V.devFeeText(s(true, 0)), 'Dev fee 1% (1 block in 100) to 0x123456…5678');
|
||
assert.match(V.devFeeText(s(false, 0)), /^Dev fee off/);
|
||
});
|
||
|
||
test('the remote-jobs line and the helpers', () => {
|
||
const title = (j) => j.title || j.kind;
|
||
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until Settings allows remote jobs.');
|
||
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.');
|
||
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).');
|
||
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
|
||
assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a');
|
||
assert.equal(V.shortHex('0xabc'), '0xabc');
|
||
assert.equal(V.withCommas(1234567), '1,234,567');
|
||
assert.equal(V.compact(2500000), '2.50M');
|
||
assert.equal(V.rel(90), '1 min ago');
|
||
});
|