From 20a568a45fa81b198d3e2cfc3adb0ece11af8588 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 11:33:32 -0500 Subject: [PATCH] =?UTF-8?q?test(dashboard):=20close=20the=20#808=20review?= =?UTF-8?q?=20gaps=20=E2=80=94=20fixture=20drift,=20window=20wiring,=20cou?= =?UTF-8?q?nt=20trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-v1.16.0 repo review riders: - Regenerate the frontend fixture (state.json) — it predated #808's earnings_summary key, so ExpectedVsActualCard's null-guard silently skipped the card in EVERY frontend render test; add four node tests covering its real branches (both views, empty-yield, stale-payload survival, percent + partial asterisk + footnote, view-key hints, two-sig-fig Tari blocks, XvB win row and its gating). - Standing drift guard: a pytest pins the fixture's top-level key set to the live build_state() contract, so a new payload key without a fixture regen fails loudly instead of silently disabling coverage. - Assert build_metrics wires p2pool_7d/p2pool_30d with their OWN windows (a 3-day-old sample is in, a 40-day-old one is out) — a wrong window constant previously passed every test. - Trim the unconsumed n_24h/n_yesterday/n_7d payload keys — n_30d (the Tari block count) is the only consumer; the sibling assertion counting confirmed sub-panels is scoped to the panel class, not tooltip prose. - testing-strategy scenario F gains the #808 catalog row; two doc wording fixes (hashrate_drop_threshold phrasing, last-win recency). Co-Authored-By: Claude Fable 5 --- .../mining_dashboard/service/earnings.py | 17 ++--- .../tests/frontend/components.test.mjs | 70 ++++++++++++++++++- .../tests/frontend/fixtures/state.json | 24 +++++++ .../dashboard/tests/service/test_earnings.py | 10 +-- build/dashboard/tests/service/test_metrics.py | 16 +++++ build/dashboard/tests/web/test_views.py | 22 ++++-- docs/configuration.md | 2 +- docs/dashboard.md | 2 +- docs/dev/testing-strategy.md | 1 + 9 files changed, 135 insertions(+), 29 deletions(-) diff --git a/build/dashboard/mining_dashboard/service/earnings.py b/build/dashboard/mining_dashboard/service/earnings.py index e09151c8..cfdc4c6a 100644 --- a/build/dashboard/mining_dashboard/service/earnings.py +++ b/build/dashboard/mining_dashboard/service/earnings.py @@ -120,10 +120,10 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x confirmed yet" (shows 0.000000). Windows: ``24h``/``7d``/``30d`` are trailing spans from ``now``, ``yesterday`` is the previous - full local day (:func:`previous_local_day`), ``all`` is everything stored. Each window also - carries its payout **count** (``n_24h`` … ``n_30d``, #808): for solo-merge-mined Tari a payout - IS a found block, so the count is the honest actual to hold against the expected block count — - the all-time count stays ``count``. + full local day (:func:`previous_local_day`), ``all`` is everything stored. The 30d window also + carries its payout **count** (``n_30d``, #808): for solo-merge-mined Tari a payout IS a found + block, so that count is the honest actual to hold against the expected block count. Only the + consumed window gets a count — the all-time count stays ``count``. ``partial`` marks each running window whose span begins before the oldest payout on record (``since_ts``) — the sum then covers only part of the window it is labelled with, so the UI @@ -138,25 +138,22 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x day, week, month = now - 86_400, now - 7 * 86_400, now - 30 * 86_400 y_start, y_end = previous_local_day(now) atomic = dict.fromkeys(("24h", "yesterday", "7d", "30d", "all"), 0) - counts = dict.fromkeys(("24h", "yesterday", "7d", "30d"), 0) + n_30d = 0 for p in payouts: amt = p.get("amount_atomic", 0) or 0 ts = p.get("ts", 0) or 0 atomic["all"] += amt if ts >= month: atomic["30d"] += amt - counts["30d"] += 1 + n_30d += 1 if ts >= week: atomic["7d"] += amt - counts["7d"] += 1 if ts >= day: atomic["24h"] += amt - counts["24h"] += 1 # Half-open [start, end) so a payout landing exactly at midnight belongs to the day it # starts, never to both days. if y_start <= ts < y_end: atomic["yesterday"] += amt - counts["yesterday"] += 1 # Only positive stamps: a row with a missing/zero ts can't date the history, and letting it # win the min would mark every window complete on the strength of a broken row. stamps = [t for t in (p.get("ts", 0) or 0 for p in payouts) if t > 0] @@ -172,5 +169,5 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x "partial": {w: since_ts <= 0 or since_ts > starts[w] for w in RUNNING_WINDOWS}, } summary.update({f"{unit}_{w}": v / divisor for w, v in atomic.items()}) - summary.update({f"n_{w}": v for w, v in counts.items()}) + summary["n_30d"] = n_30d return summary diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs index 28742923..4caf471e 100644 --- a/build/dashboard/tests/frontend/components.test.mjs +++ b/build/dashboard/tests/frontend/components.test.mjs @@ -491,8 +491,9 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe partial: { yesterday: false, '7d': false, '30d': false }, }; let html = renderApp({ state: s }); - // One confirmed block per tab, populated from the summary keys through the coin formatters. - assert.equal(html.split('Confirmed on-chain').length - 1, 2); + // One confirmed block per tab (counted by the sub-panel's own class — the phrase also + // appears in the #808 card's tooltip prose), populated through the coin formatters. + assert.equal(html.split('confirmed-subhead').length - 1, 2); assert.match(html, /0\.250000 XMR/); // xmr_24h assert.match(html, /1\.7500 XMR/); // xmr_all assert.match(html, /4552\.1500 XTM/); // xtm_all @@ -513,7 +514,8 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe s.earnings.confirmed = { enabled: false }; s.earnings.tari_confirmed = { enabled: false }; html = renderApp({ state: s }); - assert.doesNotMatch(html, /Confirmed on-chain/); + // Scoped to the sub-panel class — the #808 card's tooltip prose reuses the phrase. + assert.doesNotMatch(html, /confirmed-subhead/); }); test('EarningsCard marks running windows the payout history does not fully cover (#787)', () => { @@ -986,3 +988,65 @@ test('StatsTable renders nothing when a rig reports no metrics (#507)', () => { assert.equal(render(StatsTable, { stats: [] }), ''); assert.equal(render(StatsTable, { stats: undefined }), ''); }); + +// --- ExpectedVsActualCard (#808) ------------------------------------------------------- + +test('ExpectedVsActualCard renders in BOTH views and yields to nothing when empty (#808)', () => { + // The base fixture has XvB on (one recorded win) → the card renders, Simple and Advanced. + assert.match(renderApp({ ui: { ...UI, view: 'simple' } }), /card-expected-vs-actual/); + assert.match(renderApp({ ui: { ...UI, view: 'advanced' } }), /card-expected-vs-actual/); + // Nothing to compare on any stream → the card yields entirely (no empty shell). + const s = clone(); + s.earnings_summary = { + xmr: { available: false, enabled: false }, + tari: { available: false, enabled: false }, + xvb: { enabled: false }, + }; + assert.doesNotMatch(renderApp({ state: s }), /card-expected-vs-actual/); + // A stale payload without the key (mid-upgrade poll) must not take the App down. + delete s.earnings_summary; + assert.match(renderApp({ state: s }), /Overview/); +}); + +test('ExpectedVsActualCard compares Monero with a percent and marks partial windows (#808)', () => { + const s = clone(); + s.earnings_summary.xmr = { + available: true, expected_7d: 0.0123, enabled: true, + actual_7d: 0.0101, partial: true, pct: 82, + }; + const out = renderApp({ state: s }); + assert.match(out, /Monero \(7d\)/); + assert.match(out, /0\.012300 XMR/); // expected, formatXmr precision + assert.match(out, /0\.010100 XMR \(82%\) \*/); // actual + pct + the partial asterisk + assert.match(out, /covers only the payout history on record/); // the footnote appears +}); + +test('ExpectedVsActualCard shows the config key when confirmation is off, never a zero (#808)', () => { + const s = clone(); + s.earnings_summary.xmr = { available: true, expected_7d: 0.0123, enabled: false, + actual_7d: null, partial: false, pct: null }; + s.earnings_summary.tari = { available: true, expected_blocks_30d: 0.0052, enabled: false, + blocks_30d: null, xtm_30d: null, partial: false }; + const out = renderApp({ state: s }); + assert.match(out, /set monero\.view_key/); + assert.match(out, /set tari\.view_key/); + assert.doesNotMatch(out, /0\.000000 XMR \(/); // no zero-actual masquerading as a figure + // Tari expectation keeps two significant digits — a fraction of a block must never read 0.0. + assert.match(out, /≈ 0\.0052 blocks/); +}); + +test('ExpectedVsActualCard counts Tari blocks and windows XvB wins (#808)', () => { + const s = clone(); + s.earnings_summary.tari = { available: true, expected_blocks_30d: 0.41, enabled: true, + blocks_30d: 1, xtm_30d: 12345.0, partial: false }; + s.earnings_summary.xvb = { enabled: true, wins_30d: 2, last_win_ts: 1735689000, + published_day: 0.06 }; + const out = renderApp({ state: s }); + assert.match(out, /≈ 0\.41 blocks/); + assert.match(out, /1 block · 12345\.0000 XTM/); // singular block, XTM alongside + assert.match(out, /2 wins · last /); + assert.match(out, /0\.060000 XMR\/day \(XvB est\.\)/); + // XvB off → the row disappears (no invented raffle framing on a non-XvB box). + s.earnings_summary.xvb = { enabled: false, wins_30d: 0, last_win_ts: 0, published_day: null }; + assert.doesNotMatch(renderApp({ state: s }), /XvB wins \(30d\)/); +}); diff --git a/build/dashboard/tests/frontend/fixtures/state.json b/build/dashboard/tests/frontend/fixtures/state.json index 9a04db01..f4e86c36 100644 --- a/build/dashboard/tests/frontend/fixtures/state.json +++ b/build/dashboard/tests/frontend/fixtures/state.json @@ -99,6 +99,30 @@ "tari_reward": 0, "xvb_day": null }, + "earnings_summary": { + "tari": { + "available": false, + "blocks_30d": null, + "enabled": false, + "expected_blocks_30d": 0.0, + "partial": false, + "xtm_30d": null + }, + "xmr": { + "actual_7d": null, + "available": false, + "enabled": false, + "expected_7d": 0.0, + "partial": false, + "pct": null + }, + "xvb": { + "enabled": true, + "last_win_ts": 1735689000, + "published_day": null, + "wins_30d": 1 + } + }, "egress": { "components": [ { diff --git a/build/dashboard/tests/service/test_earnings.py b/build/dashboard/tests/service/test_earnings.py index b301e721..5622c951 100644 --- a/build/dashboard/tests/service/test_earnings.py +++ b/build/dashboard/tests/service/test_earnings.py @@ -168,9 +168,6 @@ def test_empty_list_is_on_with_zeros(self): "xmr_7d": 0.0, "xmr_30d": 0.0, "xmr_all": 0.0, - "n_24h": 0, - "n_yesterday": 0, - "n_7d": 0, "n_30d": 0, "last_ts": 0, "since_ts": 0, @@ -191,10 +188,8 @@ def test_trailing_windows_bucket_by_age(self): assert s["xmr_7d"] == 3.0 assert s["xmr_30d"] == 7.0 assert s["xmr_all"] == 15.0 - # Per-window counts mirror the sums (#808) — for Tari a payout IS a found block, so the - # count is the actual the expected-vs-actual card holds against the expected block count. - assert s["n_24h"] == 1 - assert s["n_7d"] == 2 + # The 30d count rides along (#808) — for Tari a payout IS a found block, so this is the + # actual the expected-vs-actual card holds against the expected block count. assert s["n_30d"] == 3 assert s["last_ts"] == self.NOW - 3_600 assert s["since_ts"] == self.NOW - 40 * 86_400 @@ -211,7 +206,6 @@ def test_yesterday_is_the_previous_calendar_day(self): ] s = confirmed_payouts_summary(payouts, now=self.NOW) assert s["xmr_yesterday"] == 6.0 - assert s["n_yesterday"] == 2 # The trailing 24h is a different span and deliberately disagrees: it reaches back into # yesterday noon and includes today's midnight payout. assert s["xmr_24h"] == 12.0 diff --git a/build/dashboard/tests/service/test_metrics.py b/build/dashboard/tests/service/test_metrics.py index 19f8580a..71d7dd1b 100644 --- a/build/dashboard/tests/service/test_metrics.py +++ b/build/dashboard/tests/service/test_metrics.py @@ -133,6 +133,22 @@ def test_p2pool_averages_from_history(self): assert m.p2pool_1h == 900.0 assert m.p2pool_24h == 900.0 + def test_p2pool_window_averages_use_their_own_windows(self): + # The 7d/30d averages (#808) must reach past the 24h window — a sample 3 days old is + # invisible to 1h/24h but counted by both long windows; one 40 days old by neither. + # A wrong window constant (e.g. 7*3600) fails this: the 3-day sample would drop out. + now = time.time() + history = [ + {"timestamp": now - 30, "v": 900, "v_p2pool": 900, "v_xvb": 0}, + {"timestamp": now - 3 * 86_400, "v": 300, "v_p2pool": 300, "v_xvb": 0}, + {"timestamp": now - 40 * 86_400, "v": 999, "v_p2pool": 999, "v_xvb": 0}, + ] + m = build_metrics(_data(), _mgr(history=history)) + assert m.p2pool_1h == 900.0 + assert m.p2pool_24h == 900.0 + assert m.p2pool_7d == 600.0 # (900 + 300) / 2 — the 3-day sample is IN + assert m.p2pool_30d == 600.0 # the 40-day sample stays OUT + def test_xvb_credited_averages_from_stats(self): # Credited (XvB API avg_1h/24h) is kept independent — controller input + Advanced card (#156). m = build_metrics(_data(), _mgr(xvb={"avg_1h": 2100, "avg_24h": 2300})) diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py index 31d099c0..1e541d1f 100644 --- a/build/dashboard/tests/web/test_views.py +++ b/build/dashboard/tests/web/test_views.py @@ -9,6 +9,7 @@ import json import time from dataclasses import replace +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -1803,6 +1804,21 @@ def test_rides_build_state_end_to_end(self, monkeypatch): assert set(st["earnings_summary"]) == {"xmr", "tari", "xvb"} assert st["earnings_summary"]["xmr"]["enabled"] is False + def test_frontend_fixture_carries_every_payload_key(self): + # Drift guard (#808 post-mortem): the frontend render tests run against + # tests/frontend/fixtures/state.json, "a real build_state() payload". When a new top-level + # key ships without regenerating the fixture, every component gated on that key silently + # renders nothing in the whole frontend suite — exactly how the #808 card briefly had zero + # render coverage. This pins the fixture's key set to the live contract. + fixture = Path(__file__).parent.parent / "frontend" / "fixtures" / "state.json" + fixture_keys = set(json.loads(fixture.read_text())) + live_keys = set(build_state(_data(), _state_mgr(), "all")) + missing = live_keys - fixture_keys + assert not missing, ( + f"frontend fixture is stale — regenerate with tests/frontend/fixtures/_gen_state.py " + f"(missing keys: {sorted(missing)})" + ) + # --- Host address beside the hostname (Issue #119) ------------------------------------ @@ -1940,9 +1956,6 @@ def test_confirmed_enabled_but_empty(self): "xmr_7d": 0.0, "xmr_30d": 0.0, "xmr_all": 0.0, - "n_24h": 0, - "n_yesterday": 0, - "n_7d": 0, "n_30d": 0, "last_ts": 0, "since_ts": 0, @@ -1964,9 +1977,6 @@ def test_tari_confirmed_enabled_but_empty(self): "xtm_7d": 0.0, "xtm_30d": 0.0, "xtm_all": 0.0, - "n_24h": 0, - "n_yesterday": 0, - "n_7d": 0, "n_30d": 0, "last_ts": 0, "since_ts": 0, diff --git a/docs/configuration.md b/docs/configuration.md index 364aa097..b2ff37d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -146,7 +146,7 @@ control channel will commit, are unaffected either way. | `dashboard.timezone` | `auto` | Timezone for the dashboard's timestamps and charts. `auto` = the host machine's timezone (auto-detected, falling back to `Etc/UTC`); set an IANA name (e.g. `America/Chicago`) to override. | | `dashboard.data_dir` | `auto` | Where the dashboard's database lives. `auto` = `./data/dashboard`, unless the four other `*.data_dir` all point under one parent directory — then the dashboard joins them at `/dashboard`, and the first `upgrade`/`apply` moves data from the old default there automatically (see [Data directories](#data-directories)). | | `dashboard.check_for_updates` | `true` _(on)_ | The dashboard periodically asks GitHub whether a newer Pithead release exists and, if so, shows a header badge linking to it (e.g. "New release v1.4.0 available"). Notify-only: it never updates anything; you upgrade with `./pithead upgrade` on your own terms. On by default because the check is routed over Tor (the same bridge SOCKS as the XvB fetch, `socks5h` so the DNS lookup goes through Tor too), so GitHub sees a Tor exit, not your IP. It's cached (hourly) and fails silently offline. The same flag also covers the per-worker [RigForge new-release badge](workers.md#rigforge-new-release-badge) — one more hourly, Tor-routed fetch of the latest RigForge release, compared against every rig's reported version. Set to `false` to opt out of both. See [Privacy › Runtime egress](privacy.md#runtime-egress). | -| `dashboard.hashrate_drop_threshold` | `50` | Percent below the recent normal that counts as a hashrate drop for the `hashrate_loss` alert and its chart marker. `50` = fire when total fleet hashrate falls to half its baseline. Raise it to catch smaller dips, lower it to only flag near-total outages. | +| `dashboard.hashrate_drop_threshold` | `50` | Percent of the recent baseline the total fleet hashrate must fall below to count as a drop, for the `hashrate_loss` alert and its chart marker. `50` = fire when hashrate falls to half its baseline. Raise it to catch smaller dips, lower it to only flag near-total outages. | | `dashboard.hashrate_drop_minutes` | `10` | How many minutes the hashrate must stay below the threshold before the drop is reported — the debounce that keeps a brief blip from pinging you. | | `dashboard.tari_required` | `true` | How much a Tari problem holds up the rest of the stack. Monero is required to mine, so its behavior isn't configurable: a monerod outage always rejects workers (stops `xmrig-proxy` so miners fail over to their backup pools), and the miner is always held until monerod finishes syncing. Tari is only needed for merge-mining, so this one flag decides how much it blocks. `true` (default): a Tari outage also rejects workers, the miner waits for Tari's initial sync too, and a Tari-only (re)sync shows the full-screen Sync view. `false` (non-blocking): keep mining Monero through a Tari outage, start mining as soon as Monero is synced (Tari finishes in the background), and keep the normal dashboard, with a `Tari syncing` indicator, instead of the takeover screen. | | `dashboard.fail_closed` | `false` _(off)_ | Whether an **unrecoverable** dashboard health failure holds the miner, not just alerts (#490). The dashboard is an observability layer, not the mining datapath (`xmrig-proxy` → `p2pool` → `monerod` runs independently of it), so the default (`false`) is alert-only: a loud Telegram/Healthchecks alert plus a dashboard badge, and mining keeps running. `true` reuses the same hold the sync gate uses (stops `p2pool` and `xmrig-proxy`) the moment one of two narrow conditions hits, and starts them again once it clears — no restart needed: the dashboard's SQLite database failed to self-heal after its own auto-recovery attempt (disk full, permissions — see `db_reset` in [Dashboard › Node status & failover](dashboard.md#node-status--failover)), or the `dashboard` container itself is crash-looping. A transient write blip, a slow query, or a single failed external fetch never trips it — only these two genuinely non-transient failures do. Set it `true` only if a stale dashboard is a bigger risk to you than a held miner; a false positive here idles the fleet. | diff --git a/docs/dashboard.md b/docs/dashboard.md index a6684d32..59241edb 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -269,7 +269,7 @@ stream, each over the window that suits how that stream pays: |---|---|---| | **Monero (7d)** | The linear estimate over the trailing 7 days, at your **7-day average** routed P2Pool hashrate — the hashrate that actually ran the window, so a fleet that grew or shrank mid-week is judged against what really ran. | Confirmed on-chain payouts over the same 7 days ([payout confirmation](#payout-confirmation)), with a percent-of-expected. | | **Tari (30d)** | Expected **blocks** over the trailing 30 days (hashrate × window ÷ Tari difficulty). Tari is merge-mined solo, so blocks are the honest unit — at fractions of a block per month, zero found is the normal case, not a fault. | Blocks found (each confirmed Tari payout is one solo-found block) and the XTM they paid. | -| **XvB wins (30d)** | XvB's published per-day estimate for your current tier — XvB's own raffle-wide expectation, not a promise. | Raffle wins recorded in the window, and how long ago the last one landed. | +| **XvB wins (30d)** | XvB's published per-day estimate for your current tier — XvB's own raffle-wide expectation, not a promise. | Raffle wins recorded in the window, and how long ago the most recent win on record landed (which can predate the window). | Rows degrade honestly rather than guess: a stream with [payout confirmation](#payout-confirmation) off shows the config key to set instead of a zero that would read as "earned nothing"; the XvB row diff --git a/docs/dev/testing-strategy.md b/docs/dev/testing-strategy.md index 1423b99b..defe975a 100644 --- a/docs/dev/testing-strategy.md +++ b/docs/dev/testing-strategy.md @@ -105,6 +105,7 @@ The deploy-time axes — each changes a real runtime path. Full table and assert | `/metrics` Prometheus exposition (#379), through Caddy + basic_auth | scrape | 1 ✅ (format) · 4 ▶ (`--check`) | | `share_stats` series populated on a mining box (#116) | polls land | 1 ✅ (shape) · 4 ▶ (`--check`) | | Confirmed running earnings (#787): yesterday as a **calendar** day vs the trailing 24h/7d/30d spans, the DST-length day boundary, partial marking when a window outruns the recorded payout history, and one roll-up feeding both the dashboard card and `/earnings` | stored payouts | 1 ✅ (`test_earnings.py`, `test_telegram_commands.py`, `components.test.mjs`) | +| Expected-vs-actual earnings summary (#808): window-matched 7d/30d hashrate basis, per-stream gates and honest degradation, Tari block counts, XvB win windowing, and the card's render branches in both views | metrics + stored payouts | 1 ✅ (`test_views.py` `TestEarningsVsActual`, `test_metrics.py`, `components.test.mjs`) | | Dashboard reads correct live state on a real stack | real daemons | 4 ▶ | ### G. CLI lifecycle (`pithead`)