64 lines
2.1 KiB
Python
64 lines
2.1 KiB
Python
|
|
"""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)
|