#!/usr/bin/env python3 """Smoke test for Calculus Animator. Verifies the install-and-launch path end-to-end without launching the WebView. Three checks, in order: 1. ``ai_tutor.main.create_app`` constructs a FastAPI instance without raising. 2. ``api.bridge.CalculusAPI.capacity_test_slide`` returns a valid JSON envelope (the bridge instantiates and the capacity probe responds). 3. Parser + solver + slide pipeline renders one fixed curriculum slide end-to-end via the persistent subprocess worker. Exits 0 on success, non-zero on first failure. Prints a one-line PASS or FAIL summary per check plus a final aggregated verdict. Designed to be wired into CI as a single command. The script keeps imports inside each check so a missing optional dependency surfaces as a localized failure (e.g., missing chromadb does not poison the parser/solver checks). """ from __future__ import annotations import json import subprocess import sys import time import traceback from pathlib import Path from typing import Callable # Allow running ``python scripts/smoke_test.py`` from any working directory: # the project root must be on sys.path so ``import api.bridge`` resolves. _PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(_PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(_PROJECT_ROOT)) _PASS = "PASS" _FAIL = "FAIL" def _run(name: str, fn: Callable[[], None]) -> bool: start = time.monotonic() try: fn() except Exception: elapsed = (time.monotonic() - start) * 1000 print(f"[{_FAIL}] {name} ({elapsed:.0f} ms)") traceback.print_exc() return False elapsed = (time.monotonic() - start) * 1000 print(f"[{_PASS}] {name} ({elapsed:.0f} ms)") return True def check_backend_app_constructs() -> None: """``ai_tutor.main.create_app`` returns a FastAPI instance.""" from fastapi import FastAPI from ai_tutor.main import create_app app = create_app() if not isinstance(app, FastAPI): raise AssertionError( f"create_app() returned {type(app).__name__}, expected FastAPI" ) def check_bridge_capacity_probe() -> None: """``CalculusAPI.capacity_test_slide`` returns a valid JSON envelope. Uses ``__new__`` to avoid running the full constructor (which spawns the persistent render worker and writes a capacity report to disk). The capacity probe itself is a method-level shape check. """ from api.bridge import CalculusAPI api = CalculusAPI.__new__(CalculusAPI) raw = api.capacity_test_slide(text="smoke", with_image=False, page_index=0) payload = json.loads(raw) if "success" not in payload: raise AssertionError( f"capacity probe envelope missing 'success' key: {payload!r}" ) # Reason / error string is informational; we just assert the envelope # serializes cleanly and is structurally valid. json.dumps(payload) def _teardown_render_worker(api) -> None: """Best-effort teardown of the persistent render worker subprocess. Sends SIGTERM, waits up to 5 s, then SIGKILL on timeout. All errors are warnings — a leaked daemon child is preferable to masking a real failure in the calling check. """ worker = getattr(api, "_render_worker", None) if worker is None: return try: worker.terminate() try: worker.wait(timeout=5) except subprocess.TimeoutExpired: worker.kill() try: worker.wait(timeout=5) except subprocess.TimeoutExpired as exc: print( f"[warn] render worker did not exit after kill: {exc}", file=sys.stderr, ) except (OSError, ValueError, subprocess.SubprocessError) as exc: print(f"[warn] render worker teardown: {exc}", file=sys.stderr) def check_render_pipeline() -> None: """Render one fixed curriculum slide end-to-end without WebView.""" from api.bridge import CalculusAPI api = CalculusAPI() try: curriculum = json.loads(api.get_curriculum()) pathways = curriculum.get("pathways") or [] if not pathways: raise AssertionError("curriculum has no pathways") chapters = pathways[0].get("chapters") or [] if not chapters: raise AssertionError("first pathway has no chapters") raw = api.render_learning_slide( pathways[0]["id"], chapters[0]["id"], 0 ) payload = json.loads(raw) if not payload.get("success"): raise AssertionError( f"render_learning_slide failed: {payload.get('error')!r}" ) data_url = payload.get("data_url", "") if not isinstance(data_url, str) or not data_url.startswith( "data:image/png;base64," ): raise AssertionError( f"unexpected data_url shape: {data_url[:60]!r}" ) finally: _teardown_render_worker(api) def main() -> int: checks = [ ("backend_app_constructs", check_backend_app_constructs), ("bridge_capacity_probe", check_bridge_capacity_probe), ("render_pipeline", check_render_pipeline), ] print("Calculus Animator — smoke test") print("=" * 60) failures = 0 for name, fn in checks: if not _run(name, fn): failures += 1 print("=" * 60) if failures: print(f"FAIL ({failures} of {len(checks)} checks failed)") return 1 print(f"PASS (all {len(checks)} checks)") return 0 if __name__ == "__main__": sys.exit(main())