calculus-animator / scripts /smoke_test.py
Rsan0948
feat(packaging): canonical launcher, smoke test, pinned deps, release tooling
9e8f271
Raw
History Blame Contribute Delete
5.67 kB
#!/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())