git mv all tracked files from the nested padelnomics/ workspace directory to the git repo root. Merged .gitignore files. No code changes — pure path rename. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
251 lines
9.7 KiB
Python
251 lines
9.7 KiB
Python
"""
|
||
Tests for augment_d() chart output.
|
||
|
||
Regression for: charts not rendering because augment_d() was producing raw
|
||
data dicts instead of full Chart.js 4.x config objects. Each chart must have
|
||
the shape {type, data: {labels, datasets: [{data, ...}]}, options} so that
|
||
initCharts() can pass it directly to new Chart(canvas, config).
|
||
"""
|
||
import json
|
||
|
||
import pytest
|
||
from padelnomics.planner.calculator import calc, validate_state
|
||
from padelnomics.planner.routes import augment_d
|
||
|
||
|
||
def make_result(lang="en", **state_overrides):
|
||
"""Return (d, s) with augment_d applied."""
|
||
s = validate_state(state_overrides)
|
||
d = calc(s)
|
||
augment_d(d, s, lang)
|
||
return d, s
|
||
|
||
|
||
INDOOR_CHART_KEYS = ["capex_chart", "ramp_chart", "pl_chart", "cf_chart", "cum_chart", "dscr_chart"]
|
||
ALL_CHART_KEYS = INDOOR_CHART_KEYS + ["season_chart"]
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Structure: every chart must be a valid Chart.js config
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
class TestChartStructure:
|
||
"""Each chart must have the shape Chart.js 4.x expects: {type, data, options}."""
|
||
|
||
def test_all_indoor_charts_present(self):
|
||
d, _ = make_result()
|
||
for key in INDOOR_CHART_KEYS:
|
||
assert key in d, f"Missing chart key: {key}"
|
||
|
||
def test_season_chart_present_for_outdoor(self):
|
||
d, _ = make_result(venue="outdoor")
|
||
assert "season_chart" in d
|
||
|
||
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
|
||
def test_chart_has_type_string(self, key):
|
||
d, _ = make_result()
|
||
assert "type" in d[key], f"{key} missing 'type'"
|
||
assert isinstance(d[key]["type"], str)
|
||
|
||
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
|
||
def test_chart_has_data_with_datasets_list(self, key):
|
||
d, _ = make_result()
|
||
chart = d[key]
|
||
assert "data" in chart, f"{key} missing 'data'"
|
||
assert "datasets" in chart["data"], f"{key} missing 'data.datasets'"
|
||
assert isinstance(chart["data"]["datasets"], list)
|
||
assert len(chart["data"]["datasets"]) >= 1
|
||
|
||
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
|
||
def test_chart_has_responsive_options(self, key):
|
||
d, _ = make_result()
|
||
opts = d[key].get("options", {})
|
||
assert opts.get("responsive") is True, f"{key} options.responsive must be True"
|
||
assert opts.get("maintainAspectRatio") is False, f"{key} options.maintainAspectRatio must be False"
|
||
|
||
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
|
||
def test_chart_is_json_serializable(self, key):
|
||
"""Charts are embedded as JSON in templates — they must serialise cleanly."""
|
||
d, _ = make_result()
|
||
json.dumps(d[key]) # must not raise
|
||
|
||
@pytest.mark.parametrize("key", INDOOR_CHART_KEYS)
|
||
def test_datasets_each_have_data_array(self, key):
|
||
d, _ = make_result()
|
||
for ds in d[key]["data"]["datasets"]:
|
||
assert "data" in ds, f"{key} dataset missing 'data'"
|
||
assert isinstance(ds["data"], list)
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Per-chart specifics
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
class TestCapexChart:
|
||
def test_type_is_doughnut(self):
|
||
d, _ = make_result()
|
||
assert d["capex_chart"]["type"] == "doughnut"
|
||
|
||
def test_labels_data_colors_same_length(self):
|
||
d, _ = make_result()
|
||
chart = d["capex_chart"]
|
||
ds = chart["data"]["datasets"][0]
|
||
assert len(chart["data"]["labels"]) == len(ds["data"]) == len(ds["backgroundColor"])
|
||
|
||
def test_only_nonzero_items_included(self):
|
||
"""Zero-amount CAPEX items must be excluded from the chart."""
|
||
d, _ = make_result()
|
||
for val in d["capex_chart"]["data"]["datasets"][0]["data"]:
|
||
assert val > 0, "Chart must only contain items with amount > 0"
|
||
|
||
def test_cutout_set(self):
|
||
d, _ = make_result()
|
||
assert d["capex_chart"]["options"].get("cutout") == "60%"
|
||
|
||
def test_legend_position_right(self):
|
||
d, _ = make_result()
|
||
legend = d["capex_chart"]["options"]["plugins"]["legend"]
|
||
assert legend["position"] == "right"
|
||
|
||
|
||
class TestRampChart:
|
||
def test_type_is_line(self):
|
||
d, _ = make_result()
|
||
assert d["ramp_chart"]["type"] == "line"
|
||
|
||
def test_two_datasets(self):
|
||
d, _ = make_result()
|
||
assert len(d["ramp_chart"]["data"]["datasets"]) == 2
|
||
|
||
def test_24_data_points(self):
|
||
d, _ = make_result()
|
||
for ds in d["ramp_chart"]["data"]["datasets"]:
|
||
assert len(ds["data"]) == 24
|
||
|
||
def test_labels_translated(self):
|
||
d_en, _ = make_result(lang="en")
|
||
d_de, _ = make_result(lang="de")
|
||
en_label = d_en["ramp_chart"]["data"]["datasets"][0]["label"]
|
||
de_label = d_de["ramp_chart"]["data"]["datasets"][0]["label"]
|
||
assert en_label != de_label, "Ramp chart labels must be translated"
|
||
|
||
|
||
class TestPLChart:
|
||
def test_type_is_bar(self):
|
||
d, _ = make_result()
|
||
assert d["pl_chart"]["type"] == "bar"
|
||
|
||
def test_horizontal_axis(self):
|
||
"""P&L chart must be horizontal (indexAxis='y')."""
|
||
d, _ = make_result()
|
||
assert d["pl_chart"]["options"].get("indexAxis") == "y"
|
||
|
||
def test_five_labels_and_values(self):
|
||
d, _ = make_result()
|
||
assert len(d["pl_chart"]["data"]["labels"]) == 5
|
||
assert len(d["pl_chart"]["data"]["datasets"][0]["data"]) == 5
|
||
|
||
def test_colors_reflect_sign(self):
|
||
"""Positive values → green, negative → red."""
|
||
d, _ = make_result()
|
||
ds = d["pl_chart"]["data"]["datasets"][0]
|
||
for val, color in zip(ds["data"], ds["backgroundColor"]):
|
||
if val >= 0:
|
||
assert "22,163,74" in color, f"Expected green for positive {val}"
|
||
else:
|
||
assert "239,68,68" in color, f"Expected red for negative {val}"
|
||
|
||
|
||
class TestCFChart:
|
||
def test_type_is_bar(self):
|
||
d, _ = make_result()
|
||
assert d["cf_chart"]["type"] == "bar"
|
||
|
||
def test_60_data_points(self):
|
||
d, _ = make_result()
|
||
assert len(d["cf_chart"]["data"]["datasets"][0]["data"]) == 60
|
||
|
||
def test_colors_reflect_sign(self):
|
||
d, _ = make_result()
|
||
ds = d["cf_chart"]["data"]["datasets"][0]
|
||
for val, color in zip(ds["data"], ds["backgroundColor"]):
|
||
if val >= 0:
|
||
assert "22,163,74" in color
|
||
else:
|
||
assert "239,68,68" in color
|
||
|
||
|
||
class TestCumChart:
|
||
def test_type_is_line(self):
|
||
d, _ = make_result()
|
||
assert d["cum_chart"]["type"] == "line"
|
||
|
||
def test_60_data_points(self):
|
||
d, _ = make_result()
|
||
assert len(d["cum_chart"]["data"]["datasets"][0]["data"]) == 60
|
||
|
||
def test_fill_enabled(self):
|
||
d, _ = make_result()
|
||
assert d["cum_chart"]["data"]["datasets"][0].get("fill") is True
|
||
|
||
|
||
class TestDSCRChart:
|
||
def test_type_is_bar(self):
|
||
d, _ = make_result()
|
||
assert d["dscr_chart"]["type"] == "bar"
|
||
|
||
def test_five_entries(self):
|
||
d, _ = make_result()
|
||
assert len(d["dscr_chart"]["data"]["datasets"][0]["data"]) == 5
|
||
|
||
def test_colors_reflect_1_2_threshold(self):
|
||
d, _ = make_result()
|
||
ds = d["dscr_chart"]["data"]["datasets"][0]
|
||
for val, color in zip(ds["data"], ds["backgroundColor"]):
|
||
if val >= 1.2:
|
||
assert "22,163,74" in color, f"Expected green for DSCR {val} >= 1.2"
|
||
else:
|
||
assert "239,68,68" in color, f"Expected red for DSCR {val} < 1.2"
|
||
|
||
def test_values_capped_at_10(self):
|
||
"""DSCR values above 10 (no-debt scenarios) must be capped."""
|
||
d, _ = make_result(loanPct=0)
|
||
for val in d["dscr_chart"]["data"]["datasets"][0]["data"]:
|
||
assert val <= 10
|
||
|
||
|
||
class TestSeasonChart:
|
||
def test_type_is_bar(self):
|
||
d, _ = make_result(venue="outdoor")
|
||
assert d["season_chart"]["type"] == "bar"
|
||
|
||
def test_12_points(self):
|
||
d, _ = make_result(venue="outdoor")
|
||
assert len(d["season_chart"]["data"]["datasets"][0]["data"]) == 12
|
||
assert len(d["season_chart"]["data"]["labels"]) == 12
|
||
|
||
def test_values_are_percentages(self):
|
||
"""Season values are fractions (0–1.5) multiplied by 100."""
|
||
d, s = make_result(venue="outdoor")
|
||
chart_vals = d["season_chart"]["data"]["datasets"][0]["data"]
|
||
expected = [v * 100 for v in s["season"]]
|
||
assert chart_vals == expected
|
||
|
||
def test_labels_translated(self):
|
||
d_en, _ = make_result(lang="en", venue="outdoor")
|
||
d_de, _ = make_result(lang="de", venue="outdoor")
|
||
assert d_en["season_chart"]["data"]["labels"] != d_de["season_chart"]["data"]["labels"]
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# All combos: charts always valid
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
@pytest.mark.parametrize("venue,own", [
|
||
("indoor", "rent"), ("indoor", "buy"), ("outdoor", "rent"), ("outdoor", "buy")
|
||
])
|
||
def test_all_charts_json_serializable_for_all_combos(venue, own):
|
||
d, _ = make_result(venue=venue, own=own)
|
||
for key in INDOOR_CHART_KEYS:
|
||
json.dumps(d[key])
|