Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions build/dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ docker build --target test ./build/dashboard
Tests are hermetic — no network, no containers, no real database (an in-memory SQLite is used
via the `state_manager` fixture and the auto-applied DB-isolation fixture in `tests/conftest.py`).

Two coverage ratchets back the suite: a **total** floor (`--cov-fail-under=80`) and a **patch** gate
(`make test-patch-coverage` → `diff-cover` ≥ 90% on changed lines, #286). The money/numeric logic
(earnings, the XvB controller, the donation simulator) also has **property-based tests**
(`hypothesis`, #284) asserting invariants — non-negativity, conservation, monotonicity, clamp
bounds — across a wide input range, the class of bug example tests miss (cf. the #70 overshoot).

**Typing roadmap (deferred):** the app is lightly annotated today, so a static type-checker gate is
premature (and `ty` is still pre-1.0). The on-ramp — turning on ruff's `ANN` ruleset as a
non-blocking annotation ratchet, then adopting `ty`/`pyright` once coverage is meaningful and `ty`
reaches 1.0 — is a post-1.0 follow-up (#284), not a v1.1 blocker.

## Image

The `Dockerfile` is multi-stage:
Expand Down
2 changes: 2 additions & 0 deletions build/dashboard/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ test = [
"pytest-aiohttp>=1.0",
# diff-cover (#286): patch-coverage gate — new/changed lines must be >=90% covered.
"diff-cover>=9",
# hypothesis (#284): property-based tests asserting invariants on the money/numeric logic.
"hypothesis>=6",
]
# Developer tooling (Wave 7, #280). Pinned so local, pre-commit, and CI all run the SAME ruff
# — lint output is version-sensitive, so a floor would let CI and a contributor disagree.
Expand Down
112 changes: 112 additions & 0 deletions build/dashboard/tests/service/test_numeric_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Property-based tests (#284, hypothesis) for the money/numeric service layer.

These assert *invariants* — non-negativity, conservation, monotonicity, clamp bounds — across a
wide input range rather than fixed examples, the class of bug example tests miss (cf. the #70
anti-windup overshoot). Pure functions only; no I/O.
"""

import math
from unittest.mock import MagicMock

from hypothesis import given
from hypothesis import strategies as st

from mining_dashboard.config.config import TIER_DEFAULTS, XVB_TIME_ALGO_MS
from mining_dashboard.service.algo_service import AlgoService
from mining_dashboard.service.earnings import xmr_per_hs_day
from mining_dashboard.service.metrics import _avg_p2pool_over_window, _avg_xvb_over_window

_nonneg = st.floats(min_value=0, max_value=1e18, allow_nan=False, allow_infinity=False)
_pos = st.floats(min_value=1e-6, max_value=1e15, allow_nan=False, allow_infinity=False)


# --------------------------------------------------------------------------- earnings
@given(reward=_nonneg, diff=_nonneg)
def test_xmr_per_hs_day_non_negative(reward, diff):
assert xmr_per_hs_day(reward, diff) >= 0.0


@given(reward=st.floats(max_value=0, allow_nan=False, allow_infinity=False), diff=_nonneg)
def test_xmr_per_hs_day_zero_on_nonpositive_reward(reward, diff):
assert xmr_per_hs_day(reward, diff) == 0.0


@given(reward=_pos, diff=_pos, k=st.floats(min_value=0, max_value=1e6, allow_nan=False))
def test_xmr_per_hs_day_linear_in_reward(reward, diff, k):
# Expected earnings are linear in the block reward (the model's defining property).
base = xmr_per_hs_day(reward, diff)
assert math.isclose(xmr_per_hs_day(k * reward, diff), k * base, rel_tol=1e-9, abs_tol=1e-30)


@given(reward=_pos, diff=_pos, bump=_pos)
def test_xmr_per_hs_day_decreases_with_difficulty(reward, diff, bump):
# Higher network difficulty -> a lower per-H/s rate.
assert xmr_per_hs_day(reward, diff + bump) <= xmr_per_hs_day(reward, diff)


# --------------------------------------------------------------- metrics windowed averages
@st.composite
def _in_window_rows(draw):
"""History rows inside the window, with v == v_p2pool + v_xvb (no legacy fallback)."""
hr = st.floats(min_value=0, max_value=1e9, allow_nan=False, allow_infinity=False)
rows = []
for _ in range(draw(st.integers(min_value=1, max_value=30))):
vp, vx = draw(hr), draw(hr)
rows.append({"timestamp": 10**12, "v_p2pool": vp, "v_xvb": vx, "v": vp + vx})
return rows


@given(rows=_in_window_rows())
def test_window_avgs_non_negative_and_conserve(rows):
window = 10**9 # all rows fall inside
ap = _avg_p2pool_over_window(rows, window)
ax = _avg_xvb_over_window(rows, window)
assert ap >= 0.0 and ax >= 0.0
# Conservation: with v == vp + vx (no legacy fallback), the routed parts sum to the total avg.
avg_v = sum(r["v"] for r in rows) / len(rows)
assert math.isclose(ap + ax, avg_v, rel_tol=1e-9, abs_tol=1e-6)


@given(window=st.floats(min_value=1, max_value=1e9, allow_nan=False))
def test_window_avgs_empty_history_is_zero(window):
assert _avg_p2pool_over_window([], window) == 0.0
assert _avg_xvb_over_window([], window) == 0.0


# --------------------------------------------------------------- algo_service clamp / fraction
def _algo():
sm = MagicMock()
sm.get_tiers.return_value = dict(TIER_DEFAULTS)
return AlgoService(sm, MagicMock(), MagicMock())


@given(frac=st.floats(min_value=0, max_value=1, allow_nan=False))
def test_fraction_to_ms_monotone_and_non_negative(frac):
a = _algo()
assert a._fraction_to_ms(frac) >= 0
# A larger fraction never yields a shorter slice.
assert a._fraction_to_ms(frac) <= a._fraction_to_ms(min(frac + 0.1, 1.0))


@given(frac=st.floats(max_value=0, allow_nan=False, allow_infinity=False))
def test_fraction_to_ms_zero_on_nonpositive(frac):
assert _algo()._fraction_to_ms(frac) == 0


@given(dur=st.floats(min_value=0, max_value=XVB_TIME_ALGO_MS, allow_nan=False))
def test_routed_fraction_in_unit_interval(dur):
assert AlgoService._routed_fraction("XVB", dur) == 1.0
assert AlgoService._routed_fraction("P2POOL", dur) == 0.0
assert 0.0 <= AlgoService._routed_fraction("SPLIT", dur) <= 1.0


@given(
current_hr=st.floats(min_value=1, max_value=1e9, allow_nan=False),
window=st.floats(min_value=1, max_value=1e6, allow_nan=False),
diff=st.floats(min_value=0, max_value=1e15, allow_nan=False),
)
def test_max_donation_fraction_within_reserve_bounds(current_hr, window, diff):
# The VIP/PPLNS reserve clamp must keep the donatable fraction in [0, the hard cap].
a = _algo()
f = a._max_donation_fraction(current_hr, window, {"difficulty": diff})
assert 0.0 <= f <= a.max_donation_fraction
46 changes: 46 additions & 0 deletions build/dashboard/tests/sim/test_donation_model_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Property-based tests (#284, hypothesis) for the XvB donation-controller simulator.

The closed-loop controller (Issue #70) must never *wind up*: whatever the rig hashrate, tier
target, measurement semantics, crediting, or report lag, the donated fraction stays clamped to
[0, 1] every cycle — so p2pool efficiency stays in [0, 1] and the credited rate stays non-negative.
Asserting this over randomized scenarios catches the overshoot class of bug that the fixed-example
tests in test_donation_model.py can't.
"""

from hypothesis import given, settings
from hypothesis import strategies as st

from mining_dashboard.sim.donation_model import CYCLES_PER_DAY, Scenario, run_algo

_hr = st.floats(min_value=1e3, max_value=1e7, allow_nan=False, allow_infinity=False)


@settings(max_examples=50, deadline=None)
@given(
target_hr=_hr,
current_hr=_hr,
warm_avg=st.floats(min_value=0, max_value=1e7, allow_nan=False),
measurement=st.sampled_from(["fixed", "connected"]),
credit_factor=st.floats(min_value=0.5, max_value=2.0, allow_nan=False),
report_lag=st.integers(min_value=0, max_value=6),
)
def test_controller_never_winds_up(
target_hr, current_hr, warm_avg, measurement, credit_factor, report_lag
):
result = run_algo(
Scenario(
name="prop",
target_hr=target_hr,
current_hr=current_hr,
cycles=2 * CYCLES_PER_DAY, # 2 days — reaches steady state, stays fast
warm_avg=warm_avg,
measurement=measurement,
credit_factor=credit_factor,
report_lag_cycles=report_lag,
)
)
# Anti-windup (#70): the donated fraction is clamped to [0, 1] every cycle ...
assert all(0.0 <= f <= 1.0 for f in result.fraction)
# ... so the derived steady-state efficiency and the credited rate stay sane.
assert 0.0 <= result.p2pool_efficiency <= 1.0
assert all(c >= 0.0 for c in result.credited)
23 changes: 23 additions & 0 deletions build/dashboard/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 19 additions & 4 deletions docs/test-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ _Generated by `make test-inventory` ([`tests/inventory.sh`](../tests/inventory.s
edit by hand** — re-run the target to refresh. See [Testing Strategy](testing-strategy.md) for
how the tiers fit together._

**Totals:** 500 dashboard unit tests · 12 contract tests · 31 frontend
**Totals:** 511 dashboard unit tests · 12 contract tests · 31 frontend
tests · 46 `pithead` shell sections · 17 harness self-test sections ·
9 live config scenarios (17 axis values) · 6 mini-stack scenarios.

Expand All @@ -14,7 +14,7 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·

| Tier | Suite | Cases |
|---|---|---|
| 1 — Unit | dashboard pytest | 500 |
| 1 — Unit | dashboard pytest | 511 |
| 1 — Unit | frontend (node --test) | 31 |
| 1 — Unit | `pithead` shell suite | 46 sections |
| 1 — Unit | compose interpolation + hardening (#90) | 1 |
Expand All @@ -27,7 +27,7 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·

## Tier 1 — Unit & component

### Dashboard (pytest) — 500 tests
### Dashboard (pytest) — 511 tests

#### tests/client/test_docker_control.py — 6
- test_tcp_scheme_rewritten_to_http
Expand Down Expand Up @@ -352,6 +352,18 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·
- test_down_clears_only_after_recovery_window
- test_healthy_requires_stable_window_from_unknown

#### tests/service/test_numeric_properties.py — 10
- test_xmr_per_hs_day_non_negative
- test_xmr_per_hs_day_zero_on_nonpositive_reward
- test_xmr_per_hs_day_linear_in_reward
- test_xmr_per_hs_day_decreases_with_difficulty
- test_window_avgs_non_negative_and_conserve
- test_window_avgs_empty_history_is_zero
- test_fraction_to_ms_monotone_and_non_negative
- test_fraction_to_ms_zero_on_nonpositive
- test_routed_fraction_in_unit_interval
- test_max_donation_fraction_within_reserve_bounds

#### tests/service/test_storage_service.py — 30
- test_get_tiers
- test_default_xvb_stats
Expand Down Expand Up @@ -414,6 +426,9 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·
- test_zero_reads_do_not_run_away
- test_recovers_after_worker_drop

#### tests/sim/test_donation_model_properties.py — 1
- test_controller_never_winds_up

#### tests/test_main.py — 1
- test_build_app_returns_wired_application

Expand Down Expand Up @@ -807,5 +822,5 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections ·

---

_Grand total: **621** enumerated cases/sections across the four tiers (plus the live
_Grand total: **632** enumerated cases/sections across the four tiers (plus the live
lifecycle and fault-injection phases, which are exercised on a real server)._