File size: 5,673 Bytes
9e8f271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
#!/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())