import html_charts import json import re def test_load_chartjs_inline_returns_bundle(): src = html_charts.load_chartjs_inline() assert isinstance(src, str) assert len(src) > 100000 # minified UMD is ~200 KB assert "Chart" in src # Chart.js UMD defines Chart def test_load_chartjs_inline_missing_file_raises(tmp_path, monkeypatch): import pytest missing = tmp_path / "nope.js" monkeypatch.setattr(html_charts, "CHARTJS_PATH", missing) with pytest.raises(FileNotFoundError, match=r"(?i)(assets|chart)"): html_charts.load_chartjs_inline() import pandas as pd def _evolution_df(): idx = pd.to_datetime(["2024-01-01", "2024-02-01", "2024-03-01"]) return pd.DataFrame( {"cost": [1000.0, 1000.0, 1000.0], "realized_pl": [0.0, 10.0, 20.0], "total_value": [1000.0, 1050.0, 1080.0]}, index=idx, ) def test_evolution_chart_config_empty_returns_none(): assert html_charts.evolution_chart_config(pd.DataFrame(), "EUR") is None assert html_charts.evolution_chart_config(None, "EUR") is None def test_evolution_chart_config_builds_line_chart(): cfg = html_charts.evolution_chart_config(_evolution_df(), "EUR") assert cfg["type"] == "line" assert cfg["data"]["labels"] == ["2024-01-01", "2024-02-01", "2024-03-01"] ds = cfg["data"]["datasets"] assert len(ds) == 3 assert ds[0]["label"] == "Cost (invested)" assert ds[0]["borderColor"] == "#6b7280" assert ds[1]["label"] == "Value (realized + unrealized)" assert ds[1]["borderColor"] == "#2c5282" assert ds[2]["label"] == "Cumulative realized P/L" assert ds[2]["borderColor"] == "#f39c12" assert ds[2]["borderDash"] == [6, 4] assert cfg["options"]["responsive"] is True assert cfg["options"]["maintainAspectRatio"] is False def _holdings_df(): return pd.DataFrame( {"ticker": ["A", "B", "C"], "name": ["Alpha", "Beta", "Gamma"], "market_value": [1200.0, 0.0, 800.0]} ) def _flows(): return {"deposits": 1000.0, "withdrawals": 50.0, "interest": 0.0, "dividends": 4.0, "dividend_tax": 0.0, "conversion_fees": 0.0, "invested": 1500.0, "proceeds": 0.0, "fees": 1.0} def _empty_flows(): return {"deposits": 0.0, "withdrawals": 0.0, "interest": 0.0, "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, "invested": 0.0, "proceeds": 0.0, "fees": 0.0} def test_review_holdings_doughnut_filters_zero(): cfg = html_charts.review_charts_config( _holdings_df(), _flows(), pd.Series(dtype=float), "EUR") h = cfg["holdings"] assert h["type"] == "doughnut" assert h["data"]["labels"] == ["A", "C"] # B has market_value 0, dropped assert h["data"]["datasets"][0]["data"] == [1200.0, 800.0] assert len(h["data"]["datasets"][0]["backgroundColor"]) == len(h["data"]["datasets"][0]["data"]) def test_review_cashflows_signed_and_filtered(): cfg = html_charts.review_charts_config( _holdings_df(), _flows(), pd.Series(dtype=float), "EUR") cf = cfg["cashflows"] assert cf["type"] == "bar" items = dict(zip(cf["data"]["labels"], cf["data"]["datasets"][0]["data"])) assert items["Withdrawals"] == -50.0 # negated assert items["Invested"] == -1500.0 # negated assert items["Fees"] == -1.0 # negated assert items["Dividends"] == 4.0 # near-zero items (interest 0, div.tax 0, fx fees 0, proceeds 0) dropped assert "Interest" not in items assert "Proceeds" not in items colors = dict(zip(cf["data"]["labels"], cf["data"]["datasets"][0]["backgroundColor"])) assert colors["Invested"] == "#e74c3c" # negative -> red assert colors["Deposits"] == "#2ecc71" # positive -> green def test_review_income_bar_mirrors_series(): income = pd.Series([1.5, 2.5], index=["2024-01", "2024-02"]) cfg = html_charts.review_charts_config( _holdings_df(), _empty_flows(), income, "EUR") inc = cfg["income"] assert inc["type"] == "bar" assert inc["data"]["labels"] == ["2024-01", "2024-02"] assert inc["data"]["datasets"][0]["data"] == [1.5, 2.5] assert inc["data"]["datasets"][0]["backgroundColor"] == "#3498db" def test_review_empty_holdings_and_flows_are_none(): empty_holdings = pd.DataFrame(columns=["ticker", "market_value"]) cfg = html_charts.review_charts_config( empty_holdings, _empty_flows(), pd.Series(dtype=float), "EUR") assert cfg["holdings"] is None assert cfg["cashflows"] is None assert cfg["income"] is None def test_review_holdings_doughnut_cycles_colors_past_palette(): many = pd.DataFrame({ "ticker": [f"T{i}" for i in range(12)], "market_value": [100.0] * 12, }) cfg = html_charts.review_charts_config( many, _flows(), pd.Series(dtype=float), "EUR") h = cfg["holdings"] assert len(h["data"]["labels"]) == 12 bg = h["data"]["datasets"][0]["backgroundColor"] assert len(bg) == 12 # not truncated to 10 assert bg[0] == bg[10] # cycles: index 10 wraps to index 0 def test_review_cashflows_near_zero_boundary(): flows = {"deposits": 0.0, "withdrawals": 0.0, "interest": 2e-9, "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, "invested": 0.0, "proceeds": 0.0, "fees": 0.0} cfg = html_charts.review_charts_config( _holdings_df(), flows, pd.Series(dtype=float), "EUR") # interest 2e-9 > 1e-9 stays; everything else is <= 1e-9 and dropped assert cfg["cashflows"]["data"]["labels"] == ["Interest"] def test_review_holdings_all_zero_returns_none(): all_zero = pd.DataFrame({"ticker": ["A", "B"], "market_value": [0.0, 0.0]}) cfg = html_charts.review_charts_config( all_zero, _flows(), pd.Series(dtype=float), "EUR") assert cfg["holdings"] is None def _full_review_cfg(): return html_charts.review_charts_config( _holdings_df(), _flows(), pd.Series([1.0], index=["2024-01"]), "EUR") def _full_evolution_cfg(): return html_charts.evolution_chart_config(_evolution_df(), "EUR") def test_render_charts_block_empty_when_nothing_to_show(): empty_review = html_charts.review_charts_config( pd.DataFrame(columns=["ticker", "market_value"]), _empty_flows(), pd.Series(dtype=float), "EUR") block = html_charts.render_charts_block(None, empty_review, "EUR") assert block == "" def test_render_charts_block_contains_canvases_scripts_and_data(): block = html_charts.render_charts_block( _full_evolution_cfg(), _full_review_cfg(), "EUR") assert "" in block assert "" in block assert "" in block assert "" in block assert "id='chart-data'" in block # inlined Chart.js bundle present assert html_charts.load_chartjs_inline()[:200] in block # render script with gain/loss plugin present assert "gainLoss" in block assert "beforeDatasetsDraw" in block assert "new Chart(" in block # no PNG embedding assert "data:image/png;base64" not in block def test_render_charts_block_omits_evolution_when_none(): block = html_charts.render_charts_block(None, _full_review_cfg(), "EUR") assert "" not in block assert "" in block def test_render_charts_block_json_payload_parses(): block = html_charts.render_charts_block( _full_evolution_cfg(), _full_review_cfg(), "EUR") m = re.search( r"", block, re.S) assert m, "chart-data JSON block not found" payload = json.loads(m.group(1)) assert set(payload) == {"currency", "evolution", "holdings", "cashflows", "income"} assert payload["currency"] == "EUR" assert payload["evolution"]["type"] == "line" assert payload["holdings"]["type"] == "doughnut" def test_render_charts_block_empty_state_fallbacks(): # holdings None, cashflows None, income None — but evolution present so the # Charts card renders with muted fallbacks for holdings/cashflows. review = html_charts.review_charts_config( pd.DataFrame(columns=["ticker", "market_value"]), _empty_flows(), pd.Series(dtype=float), "EUR") block = html_charts.render_charts_block(_full_evolution_cfg(), review, "EUR") assert "No open positions." in block assert "No cash flows." in block assert "" not in block