diff --git a/.gitignore b/.gitignore index 31c52e4..349fb96 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ model_comparison_results/ .vscode/ .DS_Store trajectories/ +MagicMock/ +alloc/lib/*.bak diff --git a/alloc/cli.py b/alloc/cli.py index 8ba1089..3aa04da 100644 --- a/alloc/cli.py +++ b/alloc/cli.py @@ -320,6 +320,24 @@ def build_parser() -> argparse.ArgumentParser: help="Enable verbose (DEBUG-level) logging", ) + # --- Dashboard publishing --- + parser.add_argument( + "--publish-dashboard", + action="store_true", + help="Generate HTML health dashboard after workflow completes", + ) + parser.add_argument( + "--dashboard-output", + type=str, + default="dashboard.html", + help="Output HTML file path for dashboard (default: dashboard.html)", + ) + parser.add_argument( + "--dashboard-sync", + action="store_true", + help="Push generated dashboard HTML to gh-pages branch", + ) + return parser @@ -378,7 +396,41 @@ def build_config(args: argparse.Namespace) -> TrainingConfig: ------- TrainingConfig Fully populated configuration object. + + Raises + ------ + ValueError + If tickers list is empty, positions contain zero/negative values, + or positions JSON is invalid. """ + # Validate tickers list is not empty + if not args.ticker_list: + raise ValueError( + "Tickers list is empty. Provide at least one ticker via --tickers." + ) + + # Validate positions is a non-empty dict + if not isinstance(args.positions_values, dict): + type_name = type(args.positions_values).__name__ + raise ValueError( + f"Positions must be a JSON object (dict), got {type_name}. " + "Use --positions-values with valid JSON." + ) + + if not args.positions_values: + raise ValueError( + "Positions dictionary is empty. " + "Provide at least one position via --positions-values." + ) + + # Validate no zero or negative position values + for ticker, value in args.positions_values.items(): + if value <= 0: + raise ValueError( + f"Position value for '{ticker}' is {value}. " + "All position values must be strictly positive." + ) + return TrainingConfig( tickers=args.ticker_list, positions=args.positions_values, @@ -405,16 +457,38 @@ def build_config(args: argparse.Namespace) -> TrainingConfig: def print_results(result: "WorkflowResult") -> None: """Print workflow results in a structured, human-readable format. + Handles empty workflow results gracefully by logging an informative + message instead of crashing. + Parameters ---------- result : WorkflowResult The result returned by :meth:`WorkflowRunner.run`. """ best = result.best_trial + + # Handle missing best_trial if best is None: logger.warning("No best trial in results") return + # Handle placeholder/empty best_trial (iteration=0 means no real trial ran) + if best.iteration == 0 and not best.allocation: + logger.warning( + "Workflow completed but no valid trial results were produced. " + "Status: %s", + result.status, + ) + return + + # Handle empty trials list (log warning but still show best_trial if valid) + if not result.trials: + logger.warning( + "Workflow completed with no trials recorded. " + "Status: %s. Showing best_trial from result.", + result.status, + ) + logger.info("=" * 60) logger.info("WORKFLOW COMPLETE") logger.info("=" * 60) @@ -543,6 +617,23 @@ def main(argv: list[str] | None = None) -> int: # --- Print results --- print_results(result) + # --- Dashboard publishing --- + if args.publish_dashboard: + try: + from alloc.lib.dashboard import crawl_package + from alloc.lib.publish_dashboard import generate_html, publish + + logger.info("Generating health dashboard...") + metadata = crawl_package("alloc", "tests") + html = generate_html(metadata.__dict__) + publish(html, output_path=args.dashboard_output, sync=args.dashboard_sync) + logger.info( + "Dashboard published to %s", + args.dashboard_output, + ) + except Exception as exc: + logger.error("Dashboard generation failed: %s", exc, exc_info=True) + # --- Exit code --- if result.status != "success": logger.error("Workflow status: %s", result.status) diff --git a/alloc/lib/__init__.py b/alloc/lib/__init__.py index 28fc782..c5b6660 100644 --- a/alloc/lib/__init__.py +++ b/alloc/lib/__init__.py @@ -1 +1,11 @@ -"""alloc.lib — library utilities.""" +"""alloc.lib — library utilities. + +Submodules +---------- +* :mod:`alloc.lib.cache` — Disk-based cache with configurable TTL +* :mod:`alloc.lib.client` — Polygon.io API wrapper +* :mod:`alloc.lib.cycle_signals` — Terminal tree-view for health signals +* :mod:`alloc.lib.dashboard` — Codebase health dashboard (crawls + JSON) +* :mod:`alloc.lib.publish_dashboard` — HTML publisher for dashboard metadata +* :mod:`alloc.lib.utils` — Scalar coercion, formatting, price-index helpers +""" diff --git a/alloc/lib/publish_dashboard.py b/alloc/lib/publish_dashboard.py new file mode 100644 index 0000000..a8a51b7 --- /dev/null +++ b/alloc/lib/publish_dashboard.py @@ -0,0 +1,727 @@ +"""alloc.lib.publish_dashboard — HTML publisher for dashboard metadata. + +Takes the JSON metadata produced by :mod:`alloc.lib.dashboard` and renders +a standalone, responsive HTML page with inline CSS/JS. Signals S1–S4 are +colour-coded by severity. An optional ``--sync`` CLI flag pushes the +generated HTML to a ``gh-pages`` branch for GitHub Pages hosting. + +Usage +----- + from alloc.lib.publish_dashboard import generate_html, publish + + html = generate_html(metadata_dict) + publish(html, output_path="dashboard.html", sync=False) + +Or from CLI:: + + python -m alloc.lib.publish_dashboard [--json PATH] [--output PATH] [--sync] + +Signals +------- +* **S1** — no tests (orange) +* **S2** — oversized (amber) +* **S3** — dead code (slate) +* **S4** — lint/type errors (red) +""" + +from __future__ import annotations + +import argparse +import json +import logging +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Signal colour palette +# --------------------------------------------------------------------------- + +SIGNAL_COLORS: dict[str, tuple[str, str]] = { + # (background, text) + "S1": ("#fff3cd", "#856404"), # orange — no tests + "S2": ("#ffecb5", "#6d5a00"), # amber — oversized + "S3": ("#e2e3e5", "#383d41"), # slate — dead code + "S4": ("#f8d7da", "#721c24"), # red — lint errors +} + +SIGNAL_ICONS: dict[str, str] = { + "S1": "🧪", + "S2": "📦", + "S3": "💀", + "S4": "🔧", +} + +SIGNAL_LABELS: dict[str, str] = { + "S1": "No Tests", + "S2": "Oversized", + "S3": "Dead Code", + "S4": "Lint Errors", +} + + +# --------------------------------------------------------------------------- +# HTML generation +# --------------------------------------------------------------------------- + + +def _escape_html(text: str) -> str: + """Minimal HTML escaping for safe embedding.""" + return ( + str(text) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def _signal_badge(signal: str) -> str: + """Return an HTML span for a single signal badge. + + Parameters + ---------- + signal : str + Signal string like ``"S1:no_tests"`` or ``"S4:lint_errors(3)"``. + + Returns + ------- + str + HTML ```` element with colour-coded styling. + """ + key = signal.split(":")[0] + label = signal.split(":", 1)[1] if ":" in signal else key + bg, fg = SIGNAL_COLORS.get(key, ("#e9ecef", "#495057")) + icon = SIGNAL_ICONS.get(key, "⚠️") + return ( + f'' + f"{icon} {_escape_html(label)}" + ) + + +def _module_row(mod: dict[str, Any]) -> str: + """Return an HTML table row for a single module. + + Parameters + ---------- + mod : dict + One module entry from the dashboard metadata. + + Returns + ------- + str + HTML ```` element. + """ + path = _escape_html(mod.get("path", "?")) + lines = mod.get("lines", 0) + functions = mod.get("functions", 0) + classes = mod.get("classes", 0) + tests = mod.get("test_count", 0) + has_doc = mod.get("has_docstring", False) + signals = mod.get("signals", []) + + # Docstring indicator + doc_icon = "✅" if has_doc else "❌" + + # Test coverage bar + test_pct = min(tests * 10, 100) if tests > 0 else 0 + test_bar = ( + f'
' + f'
' + f"
" + ) + + # Signal badges + badges_html = " ".join(_signal_badge(s) for s in signals) + signals_cell = badges_html if badges_html else '✅ clear' + + return ( + f"" + f"{path}" + f"{lines}" + f"{functions}" + f"{classes}" + f"{doc_icon}" + f"{tests} {test_bar}" + f"{signals_cell}" + f"" + ) + + +def _summary_card(title: str, value: str, icon: str = "") -> str: + """Return an HTML summary card div. + + Parameters + ---------- + title : str + Card title (e.g. "Modules"). + value : str + Card value (e.g. "18"). + icon : str + Optional emoji icon. + + Returns + ------- + str + HTML ``
`` card element. + """ + return ( + f'
' + f"
{icon}
" + f"
{_escape_html(value)}
" + f"
{_escape_html(title)}
" + f"
" + ) + + +def _signal_summary_cards(signals_summary: dict[str, int]) -> str: + """Return HTML cards for each signal type count. + + Parameters + ---------- + signals_summary : dict + Mapping of signal key (S1-S4) to count. + + Returns + ------- + str + HTML fragment with signal summary cards. + """ + if not signals_summary: + return '
0
' \ + '
All Clear ✅
' + + cards = [] + for key in sorted(signals_summary.keys()): + count = signals_summary[key] + bg, fg = SIGNAL_COLORS.get(key, ("#e9ecef", "#495057")) + icon = SIGNAL_ICONS.get(key, "⚠️") + label = SIGNAL_LABELS.get(key, key) + cards.append( + f'
' + f"
{icon}
" + f"
{count}
" + f"
{label}
" + f"
" + ) + return "\n".join(cards) + + +def generate_html(metadata: dict[str, Any]) -> str: + """Generate a standalone HTML dashboard page from metadata. + + The output is a self-contained HTML document with inline CSS and JS — + no external dependencies required. Works on mobile and desktop via + responsive CSS Grid/Flexbox layout. + + Parameters + ---------- + metadata : dict + The deserialised JSON output of :func:`dashboard.generate_json`. + Must contain keys: ``package``, ``total_modules``, ``total_lines``, + ``total_functions``, ``total_classes``, ``total_tests``, + ``signals_summary``, ``modules``. + + Returns + ------- + str + Complete HTML document string. + """ + pkg = _escape_html(metadata.get("package", "unknown")) + total_modules = metadata.get("total_modules", 0) + total_lines = metadata.get("total_lines", 0) + total_functions = metadata.get("total_functions", 0) + total_classes = metadata.get("total_classes", 0) + total_tests = metadata.get("total_tests", 0) + signals_summary = metadata.get("signals_summary", {}) + modules = metadata.get("modules", []) + + # Module rows + module_rows = "\n".join(_module_row(m) for m in modules) + + # Summary cards + summary_cards = "\n".join([ + _summary_card("Modules", str(total_modules), "📁"), + _summary_card("Lines", f"{total_lines:,}", "📝"), + _summary_card("Functions", str(total_functions), "⚙️"), + _summary_card("Classes", str(total_classes), "🏗️"), + _summary_card("Tests", str(total_tests), "🧪"), + ]) + + # Signal summary + signal_cards = _signal_summary_cards(signals_summary) + + # Timestamp + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + html = f""" + + + + +{pkg} — Health Dashboard + + + +
+ +
+

📊 {pkg} Health Dashboard

+
Generated: {now}
+
+ + +
+ {summary_cards} +
+ + +
+ {signal_cards} +
+ + +
+

Signal Legend

+
🧪 S1 — No Tests
+
📦 S2 — Oversized + (>200 lines & >15 funcs)
+
💀 S3 — Dead Code (0 internal imports)
+
🔧 S4 — Lint/Type Errors
+
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + {module_rows} + +
ModuleLinesFuncsClassesDocTestsSignals
+
+ + + +
+ + + +""" + return html + + +# --------------------------------------------------------------------------- +# Publish helper +# --------------------------------------------------------------------------- + + +def publish( + html: str, + output_path: str | Path = "dashboard.html", + sync: bool = False, +) -> Path: + """Write HTML to *output_path* and optionally sync to ``gh-pages``. + + Parameters + ---------- + html : str + The HTML document string (from :func:`generate_html`). + output_path : str or Path + File path to write the HTML to. + sync : bool + If ``True``, push the HTML to a ``gh-pages`` branch via git. + + Returns + ------- + Path + The path where the HTML was written. + """ + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(html, encoding="utf-8") + logger.info("Dashboard HTML written to %s", out) + + if sync: + _sync_to_ghpages(out) + + return out + + +# --------------------------------------------------------------------------- +# GitHub Pages sync +# --------------------------------------------------------------------------- + + +def _sync_to_ghpages(html_path: Path) -> None: + """Push *html_path* to the ``gh-pages`` branch. + + Uses git subcommands to: + 1. Create or checkout ``gh-pages`` branch + 2. Copy the HTML file to the branch root + 3. Commit and push + + Parameters + ---------- + html_path : Path + Path to the generated HTML file. + + Raises + ------ + RuntimeError + If git is not available or push fails. + """ + try: + # Check git is available + subprocess.run( + ["git", "--version"], + capture_output=True, + check=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + raise RuntimeError(f"git not available: {exc}") from exc + + # Determine repo root + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + repo_root = Path(result.stdout.strip()) + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"Not a git repository: {exc}") from exc + + gh_pages_dir = repo_root / ".gh-pages-staging" + gh_pages_dir.mkdir(exist_ok=True) + + # Copy HTML to staging + dest = gh_pages_dir / html_path.name + dest.write_text(html_path.read_text(encoding="utf-8"), encoding="utf-8") + + # Stage, commit, push + steps = [ + ["git", "-C", str(repo_root), "checkout", "gh-pages"], + ["git", "-C", str(repo_root), "cp", str(dest), str(repo_root / html_path.name)], + ["git", "-C", str(repo_root), "add", html_path.name], + ["git", "-C", str(repo_root), "commit", "-m", + f"docs: update dashboard — {datetime.now(timezone.utc).strftime('%Y-%m-%d')}"], + ["git", "-C", str(repo_root), "push", "origin", "gh-pages"], + ] + + # Handle "nothing to commit" gracefully + for step in steps: + cmd_str = " ".join(step) + logger.debug("Running: %s", cmd_str) + result = subprocess.run(step, capture_output=True, text=True, timeout=60) + if result.returncode != 0: + # "nothing to commit" is acceptable + if "nothing to commit" in result.stderr.lower(): + logger.info("No changes to commit, skipping") + break + logger.warning("Command failed: %s\nstderr: %s", cmd_str, result.stderr) + + # Clean up staging + try: + import shutil + shutil.rmtree(gh_pages_dir) + except OSError: + pass + + logger.info("Dashboard synced to gh-pages") + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + """CLI entry point for publish_dashboard. + + Usage:: + + python -m alloc.lib.publish_dashboard [--json PATH] [--output PATH] [--sync] + + If ``--json`` is provided, reads metadata from that file. + Otherwise generates fresh metadata via :mod:`alloc.lib.dashboard`. + + Options + ------- + --json PATH + Path to pre-generated dashboard JSON. + --output PATH + Output HTML file path (default: ``dashboard.html``). + --sync + Push generated HTML to ``gh-pages`` branch. + """ + parser = argparse.ArgumentParser( + description="Generate HTML dashboard from alloc metadata" + ) + parser.add_argument( + "--json", + dest="json_path", + type=str, + default=None, + help="Path to pre-generated dashboard JSON", + ) + parser.add_argument( + "--output", + type=str, + default="dashboard.html", + help="Output HTML file path (default: dashboard.html)", + ) + parser.add_argument( + "--sync", + action="store_true", + help="Push generated HTML to gh-pages branch", + ) + args = parser.parse_args() + + # Load or generate metadata + if args.json_path: + json_path = Path(args.json_path) + if not json_path.exists(): + logger.error("JSON file not found: %s", json_path) + sys.exit(1) + metadata = json.loads(json_path.read_text(encoding="utf-8")) + else: + from alloc.lib.dashboard import generate_json as _gen_json + + json_str = _gen_json() + metadata = json.loads(json_str) + + # Generate and publish + html = generate_html(metadata) + publish(html, output_path=args.output, sync=args.sync) + print(f"Dashboard published to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/alloc/utils/workflow.py b/alloc/utils/workflow.py index 6331bb4..84df222 100644 --- a/alloc/utils/workflow.py +++ b/alloc/utils/workflow.py @@ -55,6 +55,12 @@ class TrainingConfig: Target final portfolio value. target_outperformance : float Target outperformance percentage. + + Raises + ------ + ValueError + If tickers list is empty, positions dict is empty, or any + position value is zero or negative. """ tickers: list[str] @@ -72,6 +78,32 @@ class TrainingConfig: target_value: float = 220_000.0 target_outperformance: float = 15.0 + def __post_init__(self) -> None: + """Validate configuration after initialization. + + Raises + ------ + ValueError + If tickers list is empty, positions dict is empty, or any + position value is zero or negative. + """ + if not self.tickers: + raise ValueError( + "Tickers list is empty. Provide at least one ticker." + ) + + if not self.positions: + raise ValueError( + "Positions dictionary is empty. Provide at least one position." + ) + + for ticker, value in self.positions.items(): + if value <= 0: + raise ValueError( + f"Position value for '{ticker}' is {value}. " + "All position values must be strictly positive." + ) + @dataclass class TrainingTrial: diff --git a/tests/test_cli.py b/tests/test_cli.py index da2c417..b487747 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -526,10 +526,11 @@ def test_prints_centroid(self, caplog: Any) -> None: def test_no_best_trial_warns(self, caplog: Any) -> None: import logging caplog.set_level(logging.WARNING) + # When trials exist but best_trial is None, we get "No best trial" result = WorkflowResult( status="error", best_trial=None, # type: ignore[arg-type] - trials=[], + trials=[TrainingTrial(iteration=1, update=0)], allocation_stats={}, concentration={}, metrics_progression=[], @@ -590,6 +591,7 @@ def test_main_success_exit_code(self) -> None: patch("alloc.core.create_trainer") as mock_create_trainer: mock_parse.return_value = MagicMock( ticker_list=["AAPL"], + positions_values={"AAPL": 50000.0}, iterations=1, update_iterations=1, conservative=False, @@ -619,6 +621,7 @@ def test_main_workflow_fail_exit_code(self) -> None: patch("alloc.core.create_trainer") as mock_create_trainer: mock_parse.return_value = MagicMock( ticker_list=["AAPL"], + positions_values={"AAPL": 50000.0}, iterations=1, update_iterations=1, conservative=False, @@ -639,6 +642,7 @@ def test_main_exception_returns_workflow_fail(self) -> None: patch("alloc.core.create_trainer") as mock_create_trainer: mock_parse.return_value = MagicMock( ticker_list=["AAPL"], + positions_values={"AAPL": 50000.0}, iterations=1, update_iterations=1, conservative=False, @@ -670,6 +674,7 @@ def test_main_calls_workflow_runner(self) -> None: patch("alloc.core.create_trainer") as mock_create_trainer: mock_parse.return_value = MagicMock( ticker_list=["AAPL"], + positions_values={"AAPL": 50000.0}, iterations=1, update_iterations=1, conservative=False, @@ -701,6 +706,7 @@ def test_main_passes_conservative_to_create_trainer(self) -> None: patch("alloc.core.create_trainer") as mock_create_trainer: mock_parse.return_value = MagicMock( ticker_list=["AAPL"], + positions_values={"AAPL": 50000.0}, iterations=1, update_iterations=1, conservative=True, @@ -751,6 +757,7 @@ def test_main_builds_config(self) -> None: patch("alloc.core.create_trainer") as mock_create_trainer: mock_args = MagicMock( ticker_list=["AAPL"], + positions_values={"AAPL": 50000.0}, iterations=1, update_iterations=1, conservative=False, @@ -805,3 +812,222 @@ def test_main_module_has_main(self) -> None: """alloc.__main__ exposes main from alloc.cli.""" from alloc.__main__ import main as main_entry assert callable(main_entry) + + +# =================================================================== +# Edge case validation tests (issue #58) +# =================================================================== + + +class TestBuildConfigEdgeCases: + """Tests for build_config edge case validation.""" + + def test_empty_tickers_raises_value_error(self) -> None: + """build_config raises ValueError when tickers list is empty.""" + args = MagicMock( + ticker_list=[], + positions_values={"AAPL": 50000.0}, + iterations=1, + update_iterations=1, + trading_days=222, + batch_size=22, + min_allocation=0.001, + concentration_penalty=0.001, + transaction_cost=0.0, + risk_aversion=0.001, + min_cash_allocation=0.05, + target_sharpe=2.1, + target_value=220000.0, + target_outperformance=15.0, + ) + with pytest.raises(ValueError, match="Tickers list is empty"): + build_config(args) + + def test_zero_position_raises_value_error(self) -> None: + """build_config raises ValueError when a position value is zero.""" + args = MagicMock( + ticker_list=["AAPL"], + positions_values={"AAPL": 0.0}, + iterations=1, + update_iterations=1, + trading_days=222, + batch_size=22, + min_allocation=0.001, + concentration_penalty=0.001, + transaction_cost=0.0, + risk_aversion=0.001, + min_cash_allocation=0.05, + target_sharpe=2.1, + target_value=220000.0, + target_outperformance=15.0, + ) + with pytest.raises(ValueError, match="Position value for 'AAPL' is 0"): + build_config(args) + + def test_negative_position_raises_value_error(self) -> None: + """build_config raises ValueError when a position value is negative.""" + args = MagicMock( + ticker_list=["AAPL"], + positions_values={"AAPL": -100.0}, + iterations=1, + update_iterations=1, + trading_days=222, + batch_size=22, + min_allocation=0.001, + concentration_penalty=0.001, + transaction_cost=0.0, + risk_aversion=0.001, + min_cash_allocation=0.05, + target_sharpe=2.1, + target_value=220000.0, + target_outperformance=15.0, + ) + with pytest.raises(ValueError, match="Position value for 'AAPL' is -100"): + build_config(args) + + def test_empty_positions_dict_raises_value_error(self) -> None: + """build_config raises ValueError when positions dict is empty.""" + args = MagicMock( + ticker_list=["AAPL"], + positions_values={}, + iterations=1, + update_iterations=1, + trading_days=222, + batch_size=22, + min_allocation=0.001, + concentration_penalty=0.001, + transaction_cost=0.0, + risk_aversion=0.001, + min_cash_allocation=0.05, + target_sharpe=2.1, + target_value=220000.0, + target_outperformance=15.0, + ) + with pytest.raises(ValueError, match="Positions dictionary is empty"): + build_config(args) + + def test_non_dict_positions_raises_value_error(self) -> None: + """build_config raises ValueError when positions is not a dict.""" + args = MagicMock( + ticker_list=["AAPL"], + positions_values="not a dict", + iterations=1, + update_iterations=1, + trading_days=222, + batch_size=22, + min_allocation=0.001, + concentration_penalty=0.001, + transaction_cost=0.0, + risk_aversion=0.001, + min_cash_allocation=0.05, + target_sharpe=2.1, + target_value=220000.0, + target_outperformance=15.0, + ) + with pytest.raises(ValueError, match="Positions must be a JSON object"): + build_config(args) + + +class TestPrintResultsEmptyWorkflow: + """Tests for print_results graceful handling of empty workflow results.""" + + def test_empty_trials_logs_warning(self, caplog: Any) -> None: + """print_results logs warning when no trials were completed and best_trial is placeholder.""" + import logging + caplog.set_level(logging.WARNING) + result = WorkflowResult( + status="error", + best_trial=TrainingTrial(iteration=0, update=0, allocation=[]), + trials=[], + allocation_stats={}, + concentration={}, + metrics_progression=[], + ) + print_results(result) + assert "no valid trial" in caplog.text.lower() + + def test_empty_trials_with_valid_best_still_shows(self, caplog: Any) -> None: + """print_results warns about empty trials but still shows valid best_trial.""" + import logging + caplog.set_level(logging.INFO) + result = WorkflowResult( + status="success", + best_trial=TrainingTrial( + iteration=1, update=0, sharpe_ratio=2.0, + outperformance=10.0, final_value=120000.0, + model_roi=20.0, buyhold_roi=10.0, + allocation=[0.5, 0.5], + ), + trials=[], + allocation_stats={}, + concentration={}, + metrics_progression=[], + ) + print_results(result) + # Should warn about no trials but still show results + assert "no trials" in caplog.text.lower() + assert "WORKFLOW COMPLETE" in caplog.text + + def test_empty_best_trial_logs_warning(self, caplog: Any) -> None: + """print_results logs warning when best_trial is placeholder (iteration=0).""" + import logging + caplog.set_level(logging.WARNING) + result = WorkflowResult( + status="error", + best_trial=TrainingTrial(iteration=0, update=0, allocation=[]), + trials=[TrainingTrial(iteration=0, update=0, allocation=[])], + allocation_stats={}, + concentration={}, + metrics_progression=[], + ) + print_results(result) + assert "no valid trial" in caplog.text.lower() + + def test_none_best_trial_logs_warning(self, caplog: Any) -> None: + """print_results logs warning when best_trial is None.""" + import logging + caplog.set_level(logging.WARNING) + result = WorkflowResult( + status="error", + best_trial=None, # type: ignore[arg-type] + trials=[TrainingTrial(iteration=1, update=0)], + allocation_stats={}, + concentration={}, + metrics_progression=[], + ) + print_results(result) + assert "No best trial" in caplog.text + + +class TestJsonStringInvalidInput: + """Tests for _json_string with invalid JSON input.""" + + def test_invalid_json_raises_argument_type_error(self) -> None: + """_json_string raises ArgumentTypeError on malformed JSON.""" + with pytest.raises(argparse.ArgumentTypeError, match="not valid JSON"): + _json_string("{invalid json}") + + def test_json_array_raises_argument_type_error(self) -> None: + """_json_string raises ArgumentTypeError when JSON is an array.""" + with pytest.raises(argparse.ArgumentTypeError, match="must be a JSON object"): + _json_string('[1, 2, 3]') + + def test_json_string_raises_argument_type_error(self) -> None: + """_json_string raises ArgumentTypeError when JSON is a string.""" + with pytest.raises(argparse.ArgumentTypeError, match="must be a JSON object"): + _json_string('"just a string"') + + def test_json_number_raises_argument_type_error(self) -> None: + """_json_string raises ArgumentTypeError when JSON is a number.""" + with pytest.raises(argparse.ArgumentTypeError, match="must be a JSON object"): + _json_string('42') + + def test_json_null_raises_argument_type_error(self) -> None: + """_json_string raises ArgumentTypeError when JSON is null.""" + with pytest.raises(argparse.ArgumentTypeError, match="must be a JSON object"): + _json_string('null') + + def test_json_with_non_numeric_value_raises(self) -> None: + """_json_string raises ArgumentTypeError when value is non-numeric.""" + with pytest.raises(argparse.ArgumentTypeError, match="must be numeric"): + _json_string('{"AAPL": "not_a_number"}') diff --git a/tests/test_dashboard_integration.py b/tests/test_dashboard_integration.py new file mode 100644 index 0000000..5f96eb4 --- /dev/null +++ b/tests/test_dashboard_integration.py @@ -0,0 +1,449 @@ +"""Integration tests for the full dashboard pipeline. + +Tests the end-to-end flow: crawl → JSON → HTML → publish → sync. +Also tests the CLI --publish-dashboard flag wiring. +""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typing import Any + +from alloc.cli import build_parser, parse_args +from alloc.lib.dashboard import crawl_package, generate_json +from alloc.lib.publish_dashboard import generate_html, publish + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def tmp_pkg(tmp_path: Path) -> Path: + """Create a minimal fake package under tmp_path.""" + pkg = tmp_path / "fakepkg" + pkg.mkdir() + (pkg / "__init__.py").write_text('"""fake package."""\n') + (pkg / "core.py").write_text( + textwrap.dedent( + '''\ + """Core module.""" + + def run(): + pass + + class Runner: + def execute(self): + pass + ''' + ) + ) + (pkg / "utils.py").write_text( + textwrap.dedent( + '''\ + """Utilities module.""" + from fakepkg.core import run + + def helper(): + return run() + ''' + ) + ) + return pkg + + +@pytest.fixture +def tmp_tests(tmp_path: Path) -> Path: + """Create a minimal tests directory under tmp_path.""" + td = tmp_path / "tests" + td.mkdir() + (td / "test_core.py").write_text( + textwrap.dedent( + """\ + def test_run(): + pass + + def test_runner(): + pass + """ + ) + ) + return td + + +# --------------------------------------------------------------------------- +# Full pipeline: crawl → JSON → HTML → publish +# --------------------------------------------------------------------------- + + +class TestFullPipeline: + """End-to-end dashboard pipeline tests.""" + + def test_crawl_to_json(self, tmp_pkg: Path, tmp_tests: Path) -> None: + """crawl_package produces valid DashboardMetadata.""" + meta = crawl_package(tmp_pkg, tmp_tests) + assert meta.total_modules >= 3 # __init__, core, utils + assert meta.total_lines > 0 + assert meta.total_functions > 0 + assert meta.total_tests >= 2 # test_core has 2 tests + + def test_crawl_to_json_to_html( + self, tmp_pkg: Path, tmp_tests: Path + ) -> None: + """Full crawl → JSON → HTML pipeline produces valid HTML.""" + meta = crawl_package(tmp_pkg, tmp_tests) + json_str = generate_json(tmp_pkg, tmp_tests) + data = json.loads(json_str) + + html = generate_html(data) + assert isinstance(html, str) + assert "" in html + assert "" in html + assert "