"""Smoke tests for `openrun.plots`. We deliberately don't visual-regress matplotlib output — see ROADMAP test conventions. Each test asserts only structural properties: bar count, axis labels, "no data" sentinel behavior. """ from __future__ import annotations import matplotlib matplotlib.use("Agg") # must be set before pyplot import import matplotlib.pyplot as plt import numpy as np import pandas as pd from openrun.plots import plot_fit_decoupling def _synthetic_records(duration_s: int = 1800, decel_at: int = 900) -> pd.DataFrame: """A FIT-records-like frame: constant HR, but speed drops halfway through. With 2 segments, the second-half efficiency must be lower → positive decoupling. """ elapsed = np.arange(duration_s, dtype=float) speed = np.where(elapsed < decel_at, 3.0, 2.5) hr = np.full(duration_s, 150.0) return pd.DataFrame({"elapsed_s": elapsed, "speed_mps": speed, "heart_rate": hr}) def test_plot_fit_decoupling_returns_axes_with_n_bars() -> None: records = _synthetic_records() ax = plot_fit_decoupling(records, segments=4) bars = [p for p in ax.patches if hasattr(p, "get_height")] assert len(bars) == 4 assert ax.get_ylabel() == "decoupling (%)" plt.close(ax.figure) def test_plot_fit_decoupling_segments_default_is_two() -> None: records = _synthetic_records() ax = plot_fit_decoupling(records) bars = [p for p in ax.patches if hasattr(p, "get_height")] assert len(bars) == 2 plt.close(ax.figure) def test_plot_fit_decoupling_handles_empty_records() -> None: """Empty records frame → 'no usable records' sentinel, not a crash.""" empty = pd.DataFrame(columns=["elapsed_s", "speed_mps", "heart_rate"]) ax = plot_fit_decoupling(empty) texts = [t.get_text() for t in ax.texts] assert any("no usable records" in t for t in texts) plt.close(ax.figure) def test_plot_fit_decoupling_respects_caller_axes() -> None: """When the caller passes ax=, no new figure is created.""" fig, ax = plt.subplots() out = plot_fit_decoupling(_synthetic_records(), ax=ax) assert out is ax plt.close(fig)