A walk is any trace — it starts and ends at our node (a Trace path route directly, a Trace target boomerang just the same) — so every walk home can be measured and scored. services/records.py owns that scoring: measure a walk once (distance, reach, node counts, weakest link, enclosed area) and offer it to every one of six disciplines it places in — Longest distance, Farthest node, Most nodes, No revisits, Weakest link, Biggest loop. walk_from_trace turns a reply into its re-walkable spec, canonical route, and stats in one call. Records persist per (category, hash width) in the discovered_paths table (schema v11) — the 64-byte path field makes each width its own game — kept top-5 with spec-dedupe and worst-first pruning, stamped with the app version. Disciplines carry plain descriptive names; their ids stay stable database keys so stored records survive the naming. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
198 lines
8.4 KiB
Python
198 lines
8.4 KiB
Python
"""Unit tests for the trophy-case records: walk scoring, derivation, and persistence.
|
|
|
|
The scoring layer is pure measurement (the UI owns the radio), so everything here runs
|
|
headless: hand-built trace results, synthetic positions, and a temp repository for the
|
|
leaderboard invariants.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from meshterm.core.models import Hop, TraceResult
|
|
from meshterm.persistence.repository import Repository
|
|
from meshterm.services.records import (
|
|
CATEGORY_BY_ID,
|
|
compute_walk_stats,
|
|
max_hops,
|
|
walk_from_trace,
|
|
walk_scores,
|
|
)
|
|
|
|
#: Montréal-ish positions ~1.1 km apart, so distance scores are easy to sanity-check.
|
|
SELF_POS = (45.500, -73.600)
|
|
HUB_ID, FAR_ID, LEAF_ID = "3d63c6429436", "f2c24f54551e", "27d4396a2967"
|
|
POSITIONS = {
|
|
HUB_ID: (45.510, -73.600), # ~1.11 km north of us
|
|
FAR_ID: (45.510, -73.585), # north-east of us
|
|
LEAF_ID: (45.490, -73.600), # ~1.11 km south of us
|
|
}
|
|
|
|
|
|
def _result(*snrs: float, rtt: float = 250.0, nodes=None) -> TraceResult: # noqa: ANN001
|
|
"""A successful walk reply: one hop per SNR, the last one hash-less (us)."""
|
|
hops = []
|
|
for i, snr in enumerate(snrs):
|
|
node = None if i == len(snrs) - 1 else (nodes[i] if nodes else f"{i:02x}")
|
|
hops.append(Hop(index=i, node=node, snr=snr))
|
|
return TraceResult(target="walk", success=True, hops=hops, round_trip_ms=rtt)
|
|
|
|
|
|
# --- scoring ---------------------------------------------------------------------
|
|
|
|
|
|
def test_walk_stats_measure_distance_reach_and_area() -> None:
|
|
"""The circuit us→Hub→Far→us is positioned end to end: exact km, far, and area."""
|
|
stats = compute_walk_stats(
|
|
(HUB_ID, FAR_ID), _result(8.0, 6.0, 7.0).hops, rtt_ms=250.0,
|
|
positions=POSITIONS, self_pos=SELF_POS,
|
|
)
|
|
assert stats.km_complete
|
|
assert stats.km_travelled > 2.0 # up ~1.1 km, across, and back home
|
|
assert stats.far_km is not None and 1.0 < stats.far_km < 2.5
|
|
assert stats.area_km2 is not None and stats.area_km2 > 0.3
|
|
assert stats.distinct_nodes == 2 and not stats.repeats
|
|
assert stats.min_snr == 6.0
|
|
|
|
|
|
def test_walk_stats_unpositioned_segments_score_a_lower_bound() -> None:
|
|
"""A hop with no position contributes 0 km and clears the complete flag."""
|
|
positions = {HUB_ID: POSITIONS[HUB_ID]} # Far is off the map
|
|
stats = compute_walk_stats(
|
|
(HUB_ID, FAR_ID), _result(8.0, 6.0, 7.0).hops, rtt_ms=None,
|
|
positions=positions, self_pos=SELF_POS,
|
|
)
|
|
assert not stats.km_complete
|
|
assert 1.0 < stats.km_travelled < 1.3 # only the positioned us→Hub leg counts
|
|
|
|
|
|
def test_cross_category_scores_score_every_game_at_once() -> None:
|
|
"""One walk competes everywhere it can — the every-board-at-once rule."""
|
|
stats = compute_walk_stats(
|
|
(HUB_ID, FAR_ID, HUB_ID), _result(8.0, -11.0, 7.0, 9.0).hops, rtt_ms=100.0,
|
|
positions=POSITIONS, self_pos=SELF_POS,
|
|
)
|
|
scores = walk_scores(stats)
|
|
assert scores["grand_tour"] == 2.0 # two distinct nodes, revisit allowed
|
|
assert "clean_trail" not in scores # Hub repeats: disqualified, not zeroed
|
|
assert scores["thin_thread"] == -11.0 # the weakest surviving link
|
|
assert scores["long_haul"] > 3.0
|
|
assert scores["far_point"] > 1.0
|
|
|
|
|
|
def test_category_titles_are_plain_and_ids_are_stable() -> None:
|
|
"""Ids are database keys (unchanged); titles read plainly for the trophy case."""
|
|
assert CATEGORY_BY_ID["grand_tour"].title == "Most nodes"
|
|
assert CATEGORY_BY_ID["thin_thread"].title == "Weakest link"
|
|
assert CATEGORY_BY_ID["long_haul"].title == "Longest distance"
|
|
# Every id still resolves — the persisted keys never changed under the rename.
|
|
assert set(CATEGORY_BY_ID) == {
|
|
"long_haul", "far_point", "grand_tour", "clean_trail", "thin_thread", "big_loop"
|
|
}
|
|
|
|
|
|
def test_max_hops_follows_the_64_byte_path_field() -> None:
|
|
"""The width bounds the walk: 64 hops at 1 byte, 16 at 4."""
|
|
assert max_hops(1) == 64
|
|
assert max_hops(2) == 32
|
|
assert max_hops(4) == 16
|
|
|
|
|
|
# --- deriving a walk from a trace ---------------------------------------------------
|
|
|
|
|
|
def test_walk_from_trace_derives_spec_route_and_stats() -> None:
|
|
"""A successful reply yields its re-walkable spec, canonical route, and stats."""
|
|
result = _result(8.0, 6.0, 7.0, nodes=["3d", "f2"]) # two relays, then us
|
|
canon = {"3d": HUB_ID, "f2": FAR_ID}
|
|
derived = walk_from_trace(
|
|
result, canonical=lambda h: canon.get(h),
|
|
positions=POSITIONS, self_pos=SELF_POS,
|
|
)
|
|
assert derived is not None
|
|
spec, route, stats = derived
|
|
assert spec == "3d,f2" # the reply hops, joined — re-walkable verbatim
|
|
assert route == (HUB_ID, FAR_ID) # canonicalized for stable display + geometry
|
|
assert stats.km_complete and stats.far_km is not None
|
|
|
|
|
|
def test_walk_from_trace_ignores_a_failure_or_a_reply_with_no_relays() -> None:
|
|
"""A failed trace, or one that only came back off us, scores nothing."""
|
|
failed = TraceResult(target="walk", success=False, hops=[])
|
|
assert walk_from_trace(failed, canonical=lambda h: h, positions={}, self_pos=None) is None
|
|
# A direct-to-us reply carries only the hash-less homecoming hop: no relay to score.
|
|
direct = TraceResult(target="walk", success=True, hops=[Hop(index=0, node=None, snr=9.0)])
|
|
assert walk_from_trace(direct, canonical=lambda h: h, positions={}, self_pos=None) is None
|
|
|
|
|
|
# --- the leaderboards ----------------------------------------------------------------
|
|
|
|
|
|
def test_leaderboard_keeps_five_dedupes_and_prunes_the_worst(tmp_path) -> None: # noqa: ANN001
|
|
"""Placement, spec dedupe, and pruning hold the top-5 invariant."""
|
|
repo = Repository(tmp_path / "t.db")
|
|
try:
|
|
for i in range(6):
|
|
repo.record_discovery(
|
|
"grand_tour", 1, f"a{i},b{i}", (f"a{i}", f"b{i}"),
|
|
score=float(i), stats={}, app_version="0.1.0",
|
|
)
|
|
rows = repo.discoveries("grand_tour", width_bytes=1)
|
|
assert len(rows) == 5
|
|
assert min(r.score for r in rows) == 1.0 # the 0-score walk fell off
|
|
# Re-walking a stored spec only ever *improves* its row.
|
|
assert repo.record_discovery(
|
|
"grand_tour", 1, "a5,b5", ("a5", "b5"),
|
|
score=2.0, stats={}, app_version="0.1.0",
|
|
) is None
|
|
improved = repo.record_discovery(
|
|
"grand_tour", 1, "a5,b5", ("a5", "b5"),
|
|
score=9.0, stats={"hop_count": 2}, app_version="0.1.1",
|
|
)
|
|
assert improved is not None
|
|
rows = repo.discoveries("grand_tour", width_bytes=1)
|
|
assert len(rows) == 5
|
|
top = max(rows, key=lambda r: r.score)
|
|
assert top.score == 9.0 and top.app_version == "0.1.1"
|
|
finally:
|
|
repo.close()
|
|
|
|
|
|
def test_leaderboard_ascends_for_thin_thread(tmp_path) -> None: # noqa: ANN001
|
|
"""Weakest link keeps the *lowest* scores: a strong link never places."""
|
|
repo = Repository(tmp_path / "t.db")
|
|
try:
|
|
for i, snr in enumerate((-2.0, -8.0, -5.0, -11.0, -3.0)):
|
|
repo.record_discovery(
|
|
"thin_thread", 1, f"c{i}", (f"c{i}",),
|
|
score=snr, stats={}, app_version="0.1.0", ascending=True,
|
|
)
|
|
strong = repo.record_discovery(
|
|
"thin_thread", 1, "c9", ("c9",),
|
|
score=7.0, stats={}, app_version="0.1.0", ascending=True,
|
|
)
|
|
assert strong is None # +7 dB is a great link and a terrible record
|
|
rows = repo.discoveries("thin_thread", width_bytes=1)
|
|
assert len(rows) == 5 and max(r.score for r in rows) == -2.0
|
|
finally:
|
|
repo.close()
|
|
|
|
|
|
def test_record_deletion_by_row_category_and_wholesale(tmp_path) -> None: # noqa: ANN001
|
|
"""The three deletion grains: one record, one category, everything."""
|
|
repo = Repository(tmp_path / "t.db")
|
|
try:
|
|
for category in ("grand_tour", "long_haul"):
|
|
for width in (1, 2):
|
|
repo.record_discovery(
|
|
category, width, f"{category[:2]},{width}", ("x",),
|
|
score=1.0, stats={}, app_version="0.1.0",
|
|
)
|
|
first = repo.discoveries("grand_tour", width_bytes=1)[0]
|
|
assert repo.delete_discovery(first.id)
|
|
assert not repo.delete_discovery(first.id) # already gone
|
|
assert repo.delete_discoveries("grand_tour") == 1 # the width-2 row
|
|
assert repo.discoveries("grand_tour") == []
|
|
assert repo.delete_discoveries() == 2 # long_haul at both widths
|
|
assert repo.discoveries() == []
|
|
finally:
|
|
repo.close()
|