diff --git a/kaievolve/viewer/server.py b/kaievolve/viewer/server.py index 17e6ca0..79e1c47 100644 --- a/kaievolve/viewer/server.py +++ b/kaievolve/viewer/server.py @@ -11,23 +11,29 @@ ----- * ``/`` overview - per-task setup tables, ranked by score * ``/setup/{label}`` one setup: its runs, feature toggles -* ``/setup/{label}/run/{idx}`` single-page run dashboard: trajectory chart - (new-best markers + ±1σ band), step list, and the selected step's notes / - metrics / diff in one view. ``?step={n}`` selects a step (defaults to best). -* ``/setup/{label}/run/{idx}/step/{n}`` redirects to the dashboard at ``?step={n}`` +* ``/setup/{label}/run/{idx}`` run dashboard: trajectory chart + a summary + panel beside it, then a full-width clickable step table. Clicking a step opens + a right-side **drawer** (its notes / diff / solution viz) without leaving the page. +* ``/setup/{label}/run/{idx}/step/{n}/detail`` the drawer fragment for one step + (notes + measurements + diff + a viz iframe); fetched by the drawer JS. +* ``/setup/{label}/run/{idx}/step/{n}/viz`` one step's solution viz as a + standalone page, loaded inside the drawer's inner iframe (height-synced). +* ``/setup/{label}/run/{idx}/step/{n}`` legacy URL → 307 to the run page at + ``#step={n}`` (the drawer auto-opens that step). * ``/setup/{label}/run/{idx}/programs`` full-width sortable table of every - program in the run, with task-metric columns. ``?sort=&dir=`` re-sorts. + program; rows open the same detail drawer. ``?sort=&dir=`` re-sorts. * ``/setup/{label}/run/{idx}/approaches`` offline synthesis of the strategies the AI tried: the new-best lineage + recurring technique phrases from its notes. -* ``/setup/{label}/run/{idx}/solution`` interactive view of the best program's - actual output, via the task's optional ``visualize.py`` (lazy, sandboxed, - cached). See :mod:`kaievolve.viewer.solution` and ``skills/visualization``. +* ``/setup/{label}/run/{idx}/solution`` the *best* program's solution viz, full + page, via the task's optional ``visualize.py``. See :mod:`kaievolve.viewer.solution`. * ``/compare`` setups side by side, per task (shared scale) * ``/glossary`` plain-language term explanations -Pure disk reader. No database, no JS, no external requests - every chart is an -inline ```` from :mod:`kaievolve.viewer.svg`. Templates are inlined via a -Jinja ``DictLoader`` so there is no separate template directory to package. +Mostly a disk reader; charts are inline ```` from :mod:`kaievolve.viewer.svg`. +A small amount of dependency-free JS drives the detail drawer, and the solution +viz runs the evolved program in a sandboxed subprocess (see +:mod:`kaievolve.viewer.solution`). Templates are inlined via a Jinja +``DictLoader`` so there is no separate template directory to package. """ from __future__ import annotations @@ -40,7 +46,7 @@ # ─── templates (inlined; see module docstring) ─────────────────────────────── -_BASE = """ +_BASE = r""" {% block title %}kai{% endblock %} @@ -140,13 +146,88 @@ .detail { min-width:0; } .chip { display:inline-block; font-size:0.74rem; padding:1px 7px; border-radius:9px; background:#eef4fb; color:var(--accent); margin-left:6px; } + /* back button (top of every drilled-in page) */ + .backbtn { display:inline-flex; align-items:center; gap:5px; font-size:0.82rem; + color:var(--muted); margin-bottom:4px; } + .backbtn:hover { color:var(--accent); text-decoration:none; } + /* run header: chart (left) + summary panel (right), no wasted width */ + .runhead { display:grid; grid-template-columns:1fr minmax(190px,260px); gap:22px; align-items:start; } + @media (max-width:760px){ .runhead { grid-template-columns:1fr; } } + .summary { border:1px solid var(--faint); border-radius:8px; padding:12px 14px; background:#fff; } + .summary .row { display:flex; justify-content:space-between; gap:10px; font-size:0.9rem; + padding:3px 0; border-bottom:1px solid #f0f0ef; } + .summary .row:last-child { border-bottom:none; } + .summary .k { color:var(--muted); } .summary .v { font-variant-numeric:tabular-nums; font-weight:600; } + .summary .models { font-size:0.82rem; color:var(--muted); margin-top:8px; line-height:1.5; } + /* a clickable table whose rows open the detail drawer */ + table.rows tr.r { cursor:pointer; } + table.rows tr.r:hover td { background:#f4f7fb; } + table.rows tr.imp td:first-child { box-shadow:inset 3px 0 0 var(--accent); } + .open-x { color:var(--accent); font-size:0.8rem; white-space:nowrap; } + /* right side drawer (step detail: notes + diff + viz) */ + .scrim { position:fixed; inset:0; background:#0003; opacity:0; pointer-events:none; + transition:opacity .16s; z-index:40; } + .scrim.on { opacity:1; pointer-events:auto; } + .drawer { position:fixed; top:0; right:0; height:100vh; width:min(620px,94vw); background:var(--bg); + box-shadow:-3px 0 24px #00000018; transform:translateX(100%); transition:transform .2s ease; + z-index:41; display:flex; flex-direction:column; } + .drawer.on { transform:translateX(0); } + .drawer-head { display:flex; align-items:center; justify-content:space-between; gap:10px; + padding:12px 18px; border-bottom:1px solid var(--faint); } + .drawer-head h3 { margin:0; font-size:1.04rem; } + .drawer-x { cursor:pointer; border:none; background:none; font-size:1.2rem; color:var(--muted); line-height:1; } + .drawer-x:hover { color:var(--ink); } + .drawer-body { overflow-y:auto; padding:16px 18px 40px; } + .drawer-body .vizframe { width:100%; border:none; border-top:1px solid var(--faint); margin-top:6px; } + .drawer-body .loading { color:var(--muted); font-size:0.9rem; padding:8px 0; }
-{% if crumbs %}
+{% if crumbs %} +{% set ns = namespace(back=None) %} +{% for label, href in crumbs %}{% if href %}{% set ns.back = href %}{% endif %}{% endfor %} +{% if ns.back %}◀ back{% endif %} +
{% for label, href in crumbs %}{% if href %}{{ label }}{% else %}{{ label }}{% endif %}{% if not loop.last %} › {% endif %}{% endfor %}
{% endif %} {% block content %}{% endblock %} -
+
+
+ + + """ _OVERVIEW = """{% extends "base" %}{% block title %}kai - overview{% endblock %} @@ -213,61 +294,85 @@ _DASHBOARD = """{% extends "base" %}{% block title %}{{ short }} run {{ seed }} - kai{% endblock %} {% block content %}

{{ short }} · run {{ seed }}

-

{{ task }} · {{ steps_n }} steps · best score {{ best }} · {{ cost }} · {{ calls }} AI calls

+

{{ task }}{% if blurb %} — {{ blurb }}{% endif %}

-{{ chart | safe }} -

Line = best score so far · hollow dots = a new best · faint dots = -each attempt{% if has_std %} · whiskers = ±1σ score noise{% endif %}. Models: -{% for m in models %}{{ m.name }} {{ m.count }}{% if not loop.last %} · {% endif %}{% endfor %}

+
+
+ {{ chart | safe }} +

Line = best score so far · hollow dots = a new best · faint dots = + each attempt{% if has_std %} · whiskers = ±1σ score noise{% endif %}.

+
+
+
best score{{ best }}
+
steps{{ steps_n }}
+
cost{{ cost }}
+
AI calls{{ calls }}
+
models: {% for m in models %}{{ m.name }} {{ m.count }}{% if not loop.last %} · {% endif %}{% endfor %}
+
+ {% if has_solution %}see the solution →{% endif %} +
+
+
- {% if has_solution %}see the solution →{% endif %} approaches: strategies tried → - browse all {{ steps_n }} programs → + all {{ steps_n }} programs as a table →
-
-
- - - {% for s in step_rows %} - - - - - - - {% endfor %} -
stepmodelscoreΔ
{{ s.iter }}{{ s.model }}{{ s.score }}{{ s.delta }}
-
+

Steps

+

Each row is one program the AI tried. Click to see its notes, the code diff, and the solution it produced.

+ + +{% for s in step_rows %} + + + + + + + +{% endfor %} +
stepmodelscoreΔ
{{ s.iter }}{{ s.model }}{{ s.score }}{{ s.delta }}open ›
+{% if not step_rows %}

No steps recorded yet.

{% endif %} +{% endblock %} +""" -
- {% if sel %} -

Step {{ sel.iter }} {{ sel.model }} - score {{ sel.score }} - {% if sel.delta %}{{ sel.delta }}{% endif %}

-
- {% if sel.prev is not none %}← prev{% endif %} - {% if sel.next is not none %}next →{% endif %} -
-

What the AI was thinking

-
- {% for h in sel.hmrd %}
{{ h.label }}

{{ h.text }}

{% endfor %} - {% if not sel.hmrd %}

The AI didn't leave notes for this step.

{% endif %} -
-

Measurements

- - {% for k, v in sel.metrics %}{% endfor %} -
{{ k }}{{ v }}
-

{{ sel.change_title }}

- {% if sel.diff %}
{{ sel.diff | safe }}
- {% else %}
{{ sel.code }}
{% endif %} - {% else %} -

No steps recorded yet.

- {% endif %} -
+# Drawer fragment: notes + measurements + diff + (optional) the step's solution +# viz in an inner iframe. Injected into the drawer via innerHTML, so it relies on +# the host page's base CSS (.hmrd/.diff/...) and keeps any script inside the iframe. +_DETAIL = """
+

{{ model }} · score {{ score }}{% if delta %} · {{ delta }}{% endif %}

+

What the AI was thinking

+
+{% for h in hmrd %}
{{ h.label }}

{{ h.text }}

{% endfor %} +{% if not hmrd %}

The AI didn't leave notes for this step.

{% endif %} +
+

Measurements

+{% for k, v in metrics %}{% endfor %}
{{ k }}{{ v }}
+

{{ change_title }}

+{% if diff %}
{{ diff | safe }}
{% else %}
{{ code }}
{% endif %} +{% if has_viz %}

Solution

+ +{% endif %}
-{% endblock %} +""" + +# Standalone page for a single step's solution viz, loaded inside the drawer's +# inner iframe. Carries a minimal CSS reset and posts its height to the parent +# so the iframe can size itself. +_VIZ_PAGE = """ + + +{% if fragment %}{{ fragment | safe }} +{% elif error %}

Couldn't render this step's solution:

{{ error }}
+{% else %}

No visualizer for this task.

{% endif %} + + """ _PROGRAMS = """{% extends "base" %}{% block title %}{{ short }} run {{ seed }} programs - kai{% endblock %} @@ -278,17 +383,18 @@ ← back to dashboard
{% if rows %} - -{% for c in columns %}{% endfor %} +
{{ c.label }}{% if c.arrow %} {{ c.arrow }}{% endif %}
+{% for c in columns %}{% endfor %} {% for r in rows %} - -{% for cell in r.cells %}{% endfor %} + +{% for cell in r.cells %}{% endfor %} + {% endfor %}
{{ c.label }}{% if c.arrow %} {{ c.arrow }}{% endif %}
{% if cell.href %}{{ cell.text }}{% else %}{{ cell.text }}{% endif %}
{{ cell.text }}open ›
-

Each row links to that program in the dashboard. The tinted row is -the run's best; a bar on the left marks a step that set a new best. Click a column -header to re-sort.

+

Click any row to see that program's notes, code diff, and the +solution it produced. The tinted row is the run's best; a bar on the left marks a +step that set a new best. Click a column header to re-sort.

{% else %}

No programs recorded for this run yet.

{% endif %} @@ -418,6 +524,8 @@ "overview": _OVERVIEW, "setup": _SETUP, "dashboard": _DASHBOARD, + "detail": _DETAIL, + "viz_page": _VIZ_PAGE, "programs": _PROGRAMS, "approaches": _APPROACHES, "solution": _SOLUTION, @@ -696,9 +804,9 @@ def setup_page(label: str): runs=runs, ) - def _selected_detail(steps, sel_pos, rd): - """Build the detail-panel dict for the selected step (HMRD/metrics/diff).""" - s = steps[sel_pos] + def _detail_ctx(step, rd, label, idx, has_viz): + """Build the drawer-detail context for one step (HMRD/metrics/diff/viz).""" + s = step d, dcls = "", "" if s.delta is not None and abs(s.delta) > 1e-9: d = f"{s.delta:+.4f} better" if s.delta > 0 else f"{s.delta:+.4f} worse" @@ -713,28 +821,32 @@ def _selected_detail(steps, sel_pos, rd): ] diff = explore.diff_against_parent(s, rd) change_title = ( - f"What changed (improved from {s.parent_id[:8]}…)" + f"What changed (from {s.parent_id[:8]}…)" if diff.strip() and s.parent_id else "The code" ) return { "iter": s.iteration, + "label": label, + "idx": idx, "model": explore.short_model(s.model), "score": _fmt_score(s.score), "delta": d, "delta_cls": dcls, - "prev": steps[sel_pos - 1].iteration if sel_pos > 0 else None, - "next": steps[sel_pos + 1].iteration if sel_pos + 1 < len(steps) else None, "hmrd": hmrd, "metrics": metrics, "diff": svg.diff_html(diff) if diff.strip() else "", "code": svg.code_html(s.code), "change_title": change_title, + "has_viz": has_viz, } - # ── single-page run dashboard (chart + program list + selected detail) ────── + def _find_step(rd, iteration): + return next((s for s in explore.steps_for_run(rd) if s.iteration == iteration), None) + + # ── run dashboard: chart + summary header, then a clickable step table ────── @app.get("/setup/{label}/run/{idx}", response_class=HTMLResponse) - def run_page(label: str, idx: int, step: Optional[int] = None): + def run_page(label: str, idx: int): arm = arm_or_404(label) if idx < 0 or idx >= len(arm.run_dirs): raise HTTPException(404, f"run {idx} out of range for {label}") @@ -751,8 +863,7 @@ def run_page(label: str, idx: int, step: Optional[int] = None): b = max(b, s.score) best.append((s.iteration, b)) raw.append((s.iteration, s.score)) - sd = s.metrics.get("combined_score_std") if s.metrics else None - raw_std.append(sd) + raw_std.append(s.metrics.get("combined_score_std") if s.metrics else None) has_std = any(isinstance(x, (int, float)) and x > 0 for x in raw_std) chart = ( svg.trajectory(best, raw, raw_std=raw_std) @@ -764,18 +875,8 @@ def run_page(label: str, idx: int, step: Optional[int] = None): for m, c in sorted(rs.by_model.items(), key=lambda kv: -kv[1]) ] - # selected step: explicit ?step=, else the best-scoring step - sel_pos = None - if step is not None: - sel_pos = next((i for i, s in enumerate(steps) if s.iteration == step), None) - if sel_pos is None and steps: - sel_pos = max( - range(len(steps)), - key=lambda i: (steps[i].score is not None, steps[i].score or -1), - ) - step_rows = [] - for i, s in enumerate(steps): + for s in steps: d, dcls = "", "" if s.delta is not None and abs(s.delta) > 1e-9: d = f"{s.delta:+.4f}" @@ -791,12 +892,10 @@ def run_page(label: str, idx: int, step: Optional[int] = None): "score": sc, "delta": d, "delta_cls": dcls, - "selected": i == sel_pos, "improved": s.iteration in improved, } ) - sel = _selected_detail(steps, sel_pos, rd) if sel_pos is not None else None return render( "dashboard", crumbs=[ @@ -808,6 +907,7 @@ def run_page(label: str, idx: int, step: Optional[int] = None): idx=idx, short=_short(label), task=_task_of(label), + blurb=solution.task_blurb(rd.parent.name, roots), seed=rs.seed, steps_n=len(steps), best=_fmt_score(rs.best_score), @@ -818,13 +918,41 @@ def run_page(label: str, idx: int, step: Optional[int] = None): has_solution=solution.find_visualizer(rd, roots) is not None, models=models, step_rows=step_rows, - sel=sel, ) - # Deep links to the old per-step page now select the step in the dashboard. + # ── drawer fragment for one step (notes + diff + optional viz iframe) ─────── + @app.get("/setup/{label}/run/{idx}/step/{iteration}/detail", response_class=HTMLResponse) + def step_detail(label: str, idx: int, iteration: int): + arm = arm_or_404(label) + if idx < 0 or idx >= len(arm.run_dirs): + raise HTTPException(404, f"run {idx} out of range for {label}") + rd = arm.run_dirs[idx] + s = _find_step(rd, iteration) + if s is None: + raise HTTPException(404, f"no step {iteration}") + has_viz = solution.find_visualizer(rd, roots) is not None + return render("detail", **_detail_ctx(s, rd, label, idx, has_viz)) + + # ── one step's solution viz, standalone (loaded in the drawer's inner iframe) + @app.get("/setup/{label}/run/{idx}/step/{iteration}/viz", response_class=HTMLResponse) + def step_viz(label: str, idx: int, iteration: int): + arm = arm_or_404(label) + if idx < 0 or idx >= len(arm.run_dirs): + raise HTTPException(404, f"run {idx} out of range for {label}") + rd = arm.run_dirs[idx] + viz = solution.find_visualizer(rd, roots) + fragment, error = (None, None) + if viz is not None: + s = _find_step(rd, iteration) + if s is None: + raise HTTPException(404, f"no step {iteration}") + fragment, error = solution.render_step(rd, iteration, s.code, viz) + return render("viz_page", fragment=fragment, error=error) + + # Old per-step URL → run page with the step's drawer auto-opened via #hash. @app.get("/setup/{label}/run/{idx}/step/{iteration}") def step_page(label: str, idx: int, iteration: int): - return RedirectResponse(f"/setup/{label}/run/{idx}?step={iteration}", status_code=307) + return RedirectResponse(f"/setup/{label}/run/{idx}#step={iteration}", status_code=307) # ── programs browser: full-width, sortable table of every program in a run ── @app.get("/setup/{label}/run/{idx}/programs", response_class=HTMLResponse) @@ -889,7 +1017,6 @@ def _href(key): for key, lbl, num in col_defs ] - base = f"/setup/{label}/run/{idx}" rows = [] for s in steps_sorted: sc = _fmt_score(s.score) @@ -899,21 +1026,19 @@ def _href(key): d, dcls = "", "" if s.delta is not None and abs(s.delta) > 1e-9: d, dcls = f"{s.delta:+.4f}", ("pos" if s.delta > 0 else "neg") - href = f"{base}?step={s.iteration}" cells = [ - {"text": s.iteration, "num": True, "cls": "", "href": href}, - {"text": sc, "num": True, "cls": "", "href": ""}, - {"text": d, "num": True, "cls": dcls, "href": ""}, + {"text": s.iteration, "num": True, "cls": ""}, + {"text": sc, "num": True, "cls": ""}, + {"text": d, "num": True, "cls": dcls}, ] cells += [ - {"text": _fmt_metric((s.metrics or {}).get(k)), "num": True, "cls": "", "href": ""} + {"text": _fmt_metric((s.metrics or {}).get(k)), "num": True, "cls": ""} for k in metric_keys ] - cells.append( - {"text": explore.short_model(s.model), "num": False, "cls": "", "href": href} - ) + cells.append({"text": explore.short_model(s.model), "num": False, "cls": ""}) rows.append( { + "iter": s.iteration, "cells": cells, "best": s.iteration == best_iter, "improved": s.iteration in improved, diff --git a/kaievolve/viewer/solution.py b/kaievolve/viewer/solution.py index b3f1010..846423d 100644 --- a/kaievolve/viewer/solution.py +++ b/kaievolve/viewer/solution.py @@ -53,6 +53,29 @@ def _best_program(run_dir: Path) -> Optional[Path]: return p if p.is_file() else None +def _render_subprocess( + visualizer: Path, program_path: Path, timeout: int +) -> Tuple[Optional[str], Optional[str]]: + """Run ``visualize.render(program_path)`` in a child process. Never raises.""" + try: + proc = subprocess.run( + [sys.executable, "-m", "kaievolve.viewer.solution", str(visualizer), str(program_path)], + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return None, f"visualizer timed out after {timeout}s" + except Exception as e: # pragma: no cover - defensive + return None, f"{type(e).__name__}: {e}" + if proc.returncode != 0: + return None, (proc.stderr or "visualizer exited non-zero").strip()[-1000:] + frag = proc.stdout + if not frag.strip(): + return None, "visualizer produced no output" + return frag, None + + def render_solution( run_dir: Path, visualizer: Path, @@ -60,11 +83,10 @@ def render_solution( timeout: int = DEFAULT_TIMEOUT, force: bool = False, ) -> Tuple[Optional[str], Optional[str]]: - """Return ``(html_fragment, error)``; exactly one is non-None. + """Render the run's *best* program. ``(html_fragment, error)``; one is None. Serves a cached fragment when fresh; otherwise runs the visualizer in a - subprocess and caches the result. Never raises - failures come back as the - ``error`` string so the viewer can show a card instead of a 500. + subprocess and caches to ``best/solution_viz.html``. """ prog = _best_program(run_dir) if prog is None: @@ -77,28 +99,94 @@ def render_solution( except OSError: pass # fall through and re-render + frag, err = _render_subprocess(visualizer, prog, timeout) + if frag is not None: + try: + cache.write_text(frag) + except OSError: + pass + return frag, err + + +def render_step( + run_dir: Path, + iteration: int, + code: str, + visualizer: Path, + *, + timeout: int = DEFAULT_TIMEOUT, + force: bool = False, +) -> Tuple[Optional[str], Optional[str]]: + """Render an *arbitrary* step's program (its stored ``code``). Caches under + ``.viz_cache/step_{n}.html`` so re-opening a step's drawer is instant. + + The step's code is materialized to a temp file and fed to the same + subprocess path as :func:`render_solution`. + """ + if not code or not code.strip(): + return None, "this step has no stored program code" + cache_dir = run_dir / ".viz_cache" + cache = cache_dir / f"step_{iteration}.html" + if cache.is_file() and not force: + try: + return cache.read_text(), None + except OSError: + pass + import tempfile + + tmp = None try: - proc = subprocess.run( - [sys.executable, "-m", "kaievolve.viewer.solution", str(visualizer), str(prog)], - capture_output=True, - text=True, - timeout=timeout, - ) - except subprocess.TimeoutExpired: - return None, f"visualizer timed out after {timeout}s" - except Exception as e: # pragma: no cover - defensive + cache_dir.mkdir(exist_ok=True) + with tempfile.NamedTemporaryFile("w", suffix=".py", dir=str(cache_dir), delete=False) as fh: + fh.write(code) + tmp = Path(fh.name) + frag, err = _render_subprocess(visualizer, tmp, timeout) + except OSError as e: return None, f"{type(e).__name__}: {e}" + finally: + if tmp is not None: + try: + tmp.unlink() + except OSError: + pass + if frag is not None: + try: + cache.write_text(frag) + except OSError: + pass + return frag, err - if proc.returncode != 0: - return None, (proc.stderr or "visualizer exited non-zero").strip()[-1000:] - frag = proc.stdout - if not frag.strip(): - return None, "visualizer produced no output" - try: - cache.write_text(frag) - except OSError: - pass - return frag, None + +def task_blurb(task_name: str, task_roots: List[Path]) -> str: + """A one-line description for a task, from its initial_program.py module + docstring (first sentence) — falls back to '' if none is found. Lets the + viewer show *what the task is*, not just its directory name.""" + import ast + + for root in task_roots or []: + root = Path(root) + if not root.is_dir(): + continue + for ip in [ + root / task_name / "initial_program.py", + *root.rglob(f"{task_name}/initial_program.py"), + ]: + if not ip.is_file(): + continue + try: + doc = ast.get_docstring(ast.parse(ip.read_text())) + except (OSError, SyntaxError, ValueError): + doc = None + if doc: + first = doc.strip().split("\n\n")[0].replace("\n", " ").strip() + # trim to the first sentence-ish, capped + for stop in (". ", " — ", " - "): + if stop in first: + first = first.split(stop)[0] + break + return first[:160].strip() + return "" + return "" def _run_cli(argv: List[str]) -> int: diff --git a/tests/test_solution.py b/tests/test_solution.py index 536a871..e5381d9 100644 --- a/tests/test_solution.py +++ b/tests/test_solution.py @@ -107,6 +107,39 @@ def test_missing_program(self): self.assertIsNone(frag) self.assertIn("no best_program", err) + def test_render_step_caches_per_step(self): + with TemporaryDirectory() as d: + root = Path(d) + rd = _make_run(root) + viz = rd.parent / "visualize.py" + viz.write_text(_VIZ_OK) + frag, err = solution.render_step(rd, 7, _PROG, viz) + self.assertIsNone(err) + self.assertIn("hello viz", frag) + self.assertTrue((rd / ".viz_cache" / "step_7.html").is_file()) + # empty code -> error, not a crash + f2, e2 = solution.render_step(rd, 8, "", viz) + self.assertIsNone(f2) + self.assertIn("no stored program code", e2) + + +class TestTaskBlurb(unittest.TestCase): + def test_blurb_from_initial_program_docstring(self): + with TemporaryDirectory() as d: + root = Path(d) + (root / "mytask").mkdir(parents=True) + (root / "mytask" / "initial_program.py").write_text( + '"""Pack n circles in the unit square. More detail here."""\n' + ) + self.assertEqual( + solution.task_blurb("mytask", [root]), + "Pack n circles in the unit square", + ) + + def test_blurb_absent_is_empty(self): + with TemporaryDirectory() as d: + self.assertEqual(solution.task_blurb("nope", [Path(d)]), "") + def _has_numpy() -> bool: try: diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 98d1eb2..2bd125d 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -216,25 +216,34 @@ def test_static_pages(self): def test_drill_down(self): self.assertEqual(self.client.get("/setup/auto_full").status_code, 200) - self.assertEqual(self.client.get("/setup/auto_full/run/0").status_code, 200) - r = self.client.get("/setup/auto_full/run/0/step/1") - self.assertEqual(r.status_code, 200) - # plain-language HMRD labels surface, not raw HMRD keys - self.assertIn("Idea", r.text) - self.assertIn("Takeaway", r.text) + run = self.client.get("/setup/auto_full/run/0") + self.assertEqual(run.status_code, 200) + # the run page is a chart+summary header, a back button, and a clickable + # step table whose rows open the detail drawer + self.assertIn("backbtn", run.text) + self.assertIn('class="summary"', run.text) + self.assertIn('id="drawer"', run.text) + self.assertIn("/step/1/detail", run.text) + # the per-step detail fragment carries the plain-language HMRD labels + d = self.client.get("/setup/auto_full/run/0/step/1/detail") + self.assertEqual(d.status_code, 200) + self.assertIn("Idea", d.text) + self.assertIn("Takeaway", d.text) def test_not_found(self): self.assertEqual(self.client.get("/setup/nope").status_code, 404) self.assertEqual(self.client.get("/setup/auto_full/run/99").status_code, 404) - # The old per-step URL now redirects into the single-page dashboard, - # which gracefully falls back to the best step for an unknown ?step. - self.assertEqual(self.client.get("/setup/auto_full/run/0/step/999").status_code, 200) - self.assertEqual(self.client.get("/setup/auto_full/run/0?step=999").status_code, 200) - - def test_code_is_escaped_in_step(self): + # the legacy per-step URL now 307-redirects into the run page (#step hash) + r = self.client.get("/setup/auto_full/run/0/step/1", follow_redirects=False) + self.assertEqual(r.status_code, 307) + self.assertIn("#step=1", r.headers["location"]) + # a detail fragment for a nonexistent step is a 404 + self.assertEqual(self.client.get("/setup/auto_full/run/0/step/999/detail").status_code, 404) + + def test_code_is_escaped_in_detail(self): # the program code contains "1 < 2"; it must be escaped, not raw markup - r = self.client.get("/setup/auto_full/run/0/step/1") - self.assertIn("1 < 2", r.text) + d = self.client.get("/setup/auto_full/run/0/step/1/detail") + self.assertIn("1 < 2", d.text) def test_programs_table(self): r = self.client.get("/setup/auto_full/run/0/programs") @@ -244,11 +253,19 @@ def test_programs_table(self): self.assertIn("raw_C", r.text) self.assertNotIn("runs_successfully", r.text) self.assertNotIn(">stage<", r.text) # 'stage' is excluded as a column header - # rows link back into the dashboard's ?step= selector - self.assertIn("?step=1", r.text) + # rows open the detail drawer for that step + self.assertIn("/step/1/detail", r.text) # the dashboard offers a link into this table self.assertIn("/programs", self.client.get("/setup/auto_full/run/0").text) + def test_step_viz_graceful_without_visualizer(self): + # no visualize.py in the synthetic tree -> the per-step viz page says so + r = self.client.get("/setup/auto_full/run/0/step/1/viz") + self.assertEqual(r.status_code, 200) + self.assertIn("No visualizer", r.text) + # and the detail fragment omits the viz iframe + self.assertNotIn("vizframe", self.client.get("/setup/auto_full/run/0/step/1/detail").text) + def test_solution_no_visualizer(self): # default synthetic tree has no visualize.py -> graceful, button hidden r = self.client.get("/setup/auto_full/run/0/solution") @@ -266,6 +283,9 @@ def test_solution_with_visualizer(self): self.assertEqual(r.status_code, 200) self.assertIn("PACKED", r.text) self.assertIn("see the solution", self.client.get("/setup/auto_full/run/0").text) + # the per-step viz page renders the fragment, and the detail couples it in + self.assertIn("PACKED", self.client.get("/setup/auto_full/run/0/step/1/viz").text) + self.assertIn("vizframe", self.client.get("/setup/auto_full/run/0/step/1/detail").text) def test_approaches_page(self): r = self.client.get("/setup/auto_full/run/0/approaches")