"""The Space -> client contract: what clients are promised. Two kinds of test here, and the split is the point. The first kind pins the WORDING. Those sentences are the fallback path for cv_results rows stored before the Space shipped the numbers, and the iOS app parses them with regexes; rewording one is allowed, but it has to be a deliberate act that breaks a test, not a silent degrade to raw text. The second kind pins the AGREEMENT between the sentence and the numbers behind it -- that every metric a fighter dict states in English is exactly what contract.py renders from `_meta.measured`. That is the invariant the whole module exists for: if it holds, there is nothing left to parse. """ import re import pytest import contract import pose_features as pf from test_trackers import two_fighter_poses # --- the sentences ------------------------------------------------------------ RENDERINGS = [ ("pressure", {"label": "pressuring", "net_sw_per_10s": 1.234}, "pressuring (net +1.2 SW/10s)"), ("pressure", {"label": "unclear (camera moves with the fighters)", "net_sw_per_10s": -0.04}, "unclear (camera moves with the fighters) (net -0.0 SW/10s)"), ("guard_by_range", {"close_pct": 62.4, "long_pct": 91.0, "drop_pp": 28.6}, "both hands up 62% in the pocket vs 91% at range"), ("guard_by_hand", {"lead_up_pct": 88.0, "rear_up_pct": 54.4}, "lead hand up 88% / rear hand up 54%"), ("guard_by_hand", {"left_up_pct": 88.0, "right_up_pct": 54.4}, "left hand up 88% / right hand up 54%"), ("pocket_work", {"entries": 23, "median_dwell_s": 1.8, "entries_per_min": 12.0}, "23 trips into range (12/min, median stay 1.8s)"), ("level_changes", {"count": 7, "per_min": 14.0}, "7 level changes (14/min)"), ("level_changes", {"count": 1, "per_min": 2.0}, "1 level change (2/min)"), ("distance_control", {"i_closed_pct": 28.6, "they_closed_pct": 57.1, "mutual_pct": 14.3, "n_entries": 7}, "closed the distance on 29% of entries " "(opponent 57%, mutual 14%; n=7)"), ("vs_opponent", {"hands_up_mine": 74.0, "hands_up_theirs": 89.0, "pocket_guard_mine": 62.0, "pocket_guard_theirs": 80.0}, "hands up 74% vs their 89%; " "both hands up in the pocket 62% vs their 80%"), ("vs_opponent", {"hands_up_mine": 74.0, "hands_up_theirs": 89.0}, "hands up 74% vs their 89%"), ("range_profile", {"close_pct": 30, "mid_pct": 45, "long_pct": 25}, "close 30% / mid 45% / long 25%"), ("stance", {"orthodox_pct": 70.6, "southpaw_pct": 29.4}, "orthodox 71% / southpaw 29%"), ("stance_matchup", {"closed_pct": 60.4, "open_pct": 39.6}, "closed 60% / open 40%"), ("weight_side", {"label": "rear-loaded", "front_pct": 20.0, "centered_pct": 30.0, "rear_pct": 50.0}, "rear-loaded (weight over front foot 20% / centered 30% / rear foot 50%)"), ("weight_side", {"label": "rear-loaded", "front_pct": 20.0, "centered_pct": 30.0, "rear_pct": 50.0, "throwing": contract.weight_throwing("onto_front", 35.0)}, "rear-loaded (weight over front foot 20% / centered 30% / rear foot 50%); " "drives onto the front foot when throwing (35%)"), ("weight_side", {"label": "front-heavy", "front_pct": 55.0, "centered_pct": 30.0, "rear_pct": 15.0, "throwing": contract.weight_throwing("off_front", 40.0)}, "front-heavy (weight over front foot 55% / centered 30% / rear foot 15%); " "stays OFF the front foot when throwing (40%)"), ] @pytest.mark.parametrize("key,values,expected", RENDERINGS) def test_wording_is_pinned(key, values, expected): assert contract.render(key, values) == expected def test_an_unmeasured_read_says_why_instead_of_guessing(): assert (contract.render("stance", {"unknown": "feet rarely visible"}) == "unknown (feet rarely visible)") def test_render_all_skips_what_was_never_measured(): out = contract.render_all({"stance": None, "range_profile": {"close_pct": 10, "mid_pct": 60, "long_pct": 30}}) assert out == {"range_profile": "close 10% / mid 60% / long 30%"} # --- the legacy parse --------------------------------------------------------- # Transcribed from hitanalyze/Models/CVAnalysis.swift. Those regexes read rows # stored before the numbers shipped, so they have to keep matching what the # Space says today -- a reworded sentence would leave old and new rows # rendering differently in the same history list. IOS_REGEXES = { # The advancing/retreating clause is gone from what the Space says today, # but rows stored before that still carry it -- so both halves are # optional and the app keeps reading either. Matches the Swift regex in # CVFighterStats.PressureRead.init(raw:). "pressure": r"^(.+?)\s*\((?:net ([+-][\d.]+) SW/10s)?(?:; )?" r"(?:advancing ([\d.]+)% / retreating ([\d.]+)%)?\)", "range_profile": r"close ([\d.]+)% / mid ([\d.]+)% / long ([\d.]+)%", "stance": r"^([a-z]+) ([\d.]+)% / ([a-z]+) ([\d.]+)%$", "stance_matchup": r"^([a-z]+) ([\d.]+)% / ([a-z]+) ([\d.]+)%$", "weight_side": r"^(.+?) \(weight over front foot ([\d.]+)% / centered ([\d.]+)% " r"/ rear foot ([\d.]+)%\)(?:; (.+))?$", } # Metrics that shipped AFTER the values did. No stored row has ever carried # their sentence, so there is nothing legacy to parse: a client reads # `_meta.measured` and falls back to showing the sentence as plain text. Listed # explicitly so a new metric can't quietly skip the legacy-parse question. VALUES_ONLY = {"guard_by_range", "guard_by_hand", "pocket_work", "distance_control", "vs_opponent", "level_changes"} @pytest.mark.parametrize("key,values,expected", RENDERINGS) def test_the_ios_fallback_regexes_still_read_the_sentence(key, values, expected): if key in VALUES_ONLY: pytest.skip(f"{key} is values-only: no stored row carries its sentence") assert re.search(IOS_REGEXES[key], expected), \ f"{key} no longer matches the app's legacy parse: {expected!r}" def test_every_rendered_metric_is_parseable_or_declared_values_only(): assert set(IOS_REGEXES) | VALUES_ONLY == set(contract.RENDERERS) assert not (set(IOS_REGEXES) & VALUES_ONLY) # --- punches ------------------------------------------------------------------ def parse_like_ios(line): """CVPunchEvent.init(logLine:), transcribed.""" parts = line.split(" ") assert len(parts) >= 3 minutes, seconds = parts[0].split(":") out = {"t": float(minutes) * 60 + float(seconds), "hand": parts[1]} rest = parts[2:] if rest[0].startswith("("): out["role"] = rest[0].strip("()") rest = rest[1:] else: out["role"] = None out["type"] = rest[0] out["conf"] = float(rest[1]) if len(rest) > 1 else None return out @pytest.mark.parametrize("role", ["lead", "rear", ""]) @pytest.mark.parametrize("kind,conf", [("hook", 0.82), ("straight", 0.5), ("unclear", 0.11)]) def test_a_punch_reads_the_same_from_its_values_or_its_line(role, kind, conf): e = contract.punch_event(65.34, "left", role, kind, conf) parsed = parse_like_ios(e["line"]) assert parsed["hand"] == e["hand"] assert parsed["role"] == e["role"] assert parsed["type"] == e["type"] assert parsed["conf"] == e["conf"] assert parsed["t"] == pytest.approx(e["t"], abs=0.05) def test_an_unclear_call_reports_no_confidence(): # There is no confidence to report in a type the classifier or the geometry # veto refused to name. e = contract.punch_event(5.3, "right", "rear", "unclear", 0.4) assert e["conf"] is None assert e["line"] == "0:05.3 right (rear) unclear" def test_the_punch_clock_survives_the_minute_boundary(): assert contract.clock_tenths(59.96) == "1:00.0" assert contract.clock_tenths(0.0) == "0:00.0" # --- errors ------------------------------------------------------------------- def test_every_failure_has_a_code_a_heading_and_a_body(): for code in contract.ERRORS: report = contract.error_report(code, seconds=200, limit=120) heading, _, body = report.partition("\n") assert heading.startswith("### ") and len(heading) > 4 assert body.strip(), f"{code} has no body" def test_the_headings_the_app_falls_back_to_are_unique(): headings = [h for h, _ in contract.ERRORS.values()] assert len(set(headings)) == len(headings) def test_a_failure_carries_its_reason_but_no_fighters(): feats = contract.error_features("seed_failed") assert [k for k in feats if not k.startswith("_")] == [] meta = feats["_meta"] assert meta["error"]["code"] == "seed_failed" assert meta["error"]["message"] == contract.ERRORS["seed_failed"][1] assert meta["contract_version"] == contract.CONTRACT_VERSION def test_clip_too_long_states_both_numbers(): report = contract.error_report("clip_too_long", seconds=200, limit=120) assert "~200s" in report and "120s" in report # --- output slots ------------------------------------------------------------- def test_report_and_features_lead_the_output_slots(): # A client can't look slots up by name until it has read the features # payload the names live in, so those two are positional by definition. assert contract.OUTPUT_NAMES[:2] == ("report", "features") assert len(set(contract.OUTPUT_NAMES)) == len(contract.OUTPUT_NAMES) def test_the_stamp_tells_a_client_what_it_is_reading(): meta = contract.stamp({}) assert meta["contract_version"] == contract.CONTRACT_VERSION assert meta["outputs"] == list(contract.OUTPUT_NAMES) # --- the agreement ------------------------------------------------------------ def test_every_sentence_is_rendered_from_the_numbers_beside_it(): """The invariant the module exists for. Whatever a fighter dict says in English about a measured read, `_meta .measured` carries the values it was rendered from -- so a client reads values, and the two can never disagree. Edit a sentence anywhere but contract.py and this fails.""" poses = two_fighter_poses(250.0, 750.0, n=120) feats, _overlay = pf._features_from_poses(poses, 30.0, ("Fighter A", "Fighter B")) assert feats, "the synthetic clip should analyze" measured = feats["_meta"]["measured"] fighters = [k for k in feats if not k.startswith("_")] assert set(measured) == set(fighters) for name in fighters: for key in contract.RENDERERS: assert key in measured[name], f"{name} states {key} with no values" assert feats[name][key] == contract.render(key, measured[name][key]), \ f"{name}.{key} was written somewhere other than contract.py"