refactor: flatten padelnomics/padelnomics/ → repo root
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>
This commit is contained in:
250
web/tests/test_planner_charts.py
Normal file
250
web/tests/test_planner_charts.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
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])
|
||||
Reference in New Issue
Block a user