"""Tests for the admin frontend serving route GET /admin/. The frontend is served as a single FileResponse (no StaticFiles mount). Its path is derived from ``Path(__file__).resolve().parent`` inside ``admin.py`` — not from ``base_dir`` — so it resolves to the real repo file even when the router is built without a base_dir, mirroring the daashbrd H3 portability fix. """ from __future__ import annotations import os import re import sqlite3 import subprocess import sys from pathlib import Path import pytest from starlette.testclient import TestClient import admin import dispatcher from config import load_config ROOT = Path(__file__).resolve().parent.parent CFG = load_config(str(ROOT / "config" / "config.yaml")) def _function_body(html: str, name: str) -> str: """One JS function's source, so an assertion cannot match another card. Several dashboard cards share the same bar markup, so a bare substring check against the whole file silently passes on the wrong one. """ match = re.search(rf"^function {re.escape(name)}\(.*?^\}}", html, re.S | re.M) assert match, f"{name}() not found in index.html" return match.group(0) @pytest.fixture def admin_client(tmp_path, monkeypatch): """The real dispatcher app, which mounts admin at the /admin prefix.""" db_path = str(tmp_path / "admin.db") conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row conn.executescript((ROOT / "config" / "schema.sql").read_text()) conn.close() monkeypatch.setattr(dispatcher.cfg.database, "path", db_path) monkeypatch.setattr(dispatcher.cfg.verification, "local_llm_enabled", False) monkeypatch.setattr(dispatcher.cfg.routing, "require_vision", False) monkeypatch.setenv("NEURALWATT_API_KEY", "test-key") with TestClient(dispatcher.app) as client: yield client def test_admin_index_returns_html_with_chartjs(admin_client): """GET /admin/ returns 200, text/html, and contains the Chart.js tag.""" resp = admin_client.get("/admin/") assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/html") assert "chart.js" in resp.text def test_admin_index_file_exists_at_module_derived_path(): """The served file lives at Path(__file__).parent/admin/frontend/index.html.""" expected = ( Path(admin.__file__).resolve().parent.parent / "admin" / "frontend" / "index.html" ) assert expected.is_file(), f"frontend file missing at {expected}" def test_admin_frontend_path_does_not_depend_on_base_dir(): """build_router WITHOUT base_dir already serves '/' on the raw router. The route must derive its file path from admin.py's own location, not from the optional base_dir argument, so it stays portable and testable. """ probe = ( "import sqlite3\n" "import admin\n" "from fastapi import FastAPI\n" "cfg = admin.load_config('config/config.yaml')\n" "def db():\n" " c = sqlite3.connect(':memory:')\n" " c.row_factory = sqlite3.Row\n" " return c\n" "app = FastAPI()\n" "app.include_router(admin.build_router(cfg, db))\n" "from starlette.testclient import TestClient\n" "with TestClient(app) as client:\n" " resp = client.get('/')\n" " assert resp.status_code == 200, resp.status_code\n" " assert resp.headers['content-type'].startswith('text/html')\n" ) subprocess.run( [sys.executable, "-c", probe], check=True, cwd=str(ROOT), capture_output=True, env={**os.environ, "PYTHONPATH": str(ROOT / "src")}, ) def test_admin_unknown_api_returns_json_404_not_html(admin_client): """GET /admin/api/nonexistent is a 404 JSON body, not the HTML page.""" resp = admin_client.get("/admin/api/nonexistent") assert resp.status_code == 404 assert resp.headers["content-type"].startswith("application/json") assert "chart.js" not in resp.text def test_admin_controls_returns_html_with_controls_marker(admin_client): """GET /admin/controls returns 200, text/html, and contains the controls section marker (Operational Triggers).""" resp = admin_client.get("/admin/controls") assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/html") assert "Operational Triggers" in resp.text # The save button's label is rendered from the dirty-row count, so assert # on the element it hangs off rather than on its text. assert "Persisted Config" in resp.text assert 'id="config-save-btn"' in resp.text def test_admin_controls_has_a_dedicated_classifier_card(admin_client): """classifier.mode gets its own card, not a row in the generic Runtime Knobs list -- its cross-field structure (cloud_llm needs a primary, local_encoder needs a model) can't be represented as one scalar.""" resp = admin_client.get("/admin/controls") text = resp.text assert 'id="classifier-mode-select"' in text assert 'id="classifier-config-error"' in text assert "onClassifierCloudAutoToggle" in text # The bug this pins: toggling auto must NOT call the full-regenerate # handler, which discarded the checkbox state it had just set. assert 'onchange="onClassifierCloudAutoToggle()"' in text # The second bug this pins: a blank base_url/model must be sent as # null, not {"base_url": "", "model": ""} -- an object with keys is # truthy, so the backend's own "cloud_primary is required" check would # not have caught it (this was live-verified in a browser: it silently # saved before this line existed). assert "(baseUrl && model) ? { base_url: baseUrl, model } : null" in text def test_controls_page_carries_the_candidate_labels_readout(admin_client): """The classifier card shows what it may return, and what it may not. candidate_categories is a structural list, so the scalar-knob tripwire never reaches it; this readout is the visible answer to "why does nothing classify as X any more" -- and the excluded half names the categories that stop accumulating outcomes. Rendered with textContent throughout: config strings are data, not markup. """ text = admin_client.get("/admin/controls").text assert 'id="classifier-candidate-categories"' in text assert "renderClassifierCandidateCategories(data)" in text assert "labels the classifier may return: " in text assert "excluded from the scoring axis, so they stop accumulating outcomes: " in text def test_admin_controls_confidence_min_field_is_percent_with_conversion(admin_client): """The encoder confidence_min field takes 0-100 (a human types "80" meaning 80%) and converts to the 0.0-1.0 raw similarity score classify_zero_shot actually returns -- typing the intuitive percent value used to be stored literally under the old field name ``confidence_threshold``, so no real confidence score could ever clear the threshold and every classification silently failed (live 2026-09-06).""" resp = admin_client.get("/admin/controls") text = resp.text assert 'id="classifier-encoder-threshold"' in text assert 'max="100"' in text assert "parseFloat(thresholdPct) / 100" in text # Loading back a stored 0.0-1.0 value must display it as a percent. assert "Math.round(enc.confidence_min * 100)" in text def test_admin_models_returns_html_with_availability_marker(admin_client): """GET /admin/models returns 200, text/html, and contains the Model Availability card title.""" resp = admin_client.get("/admin/models") assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/html") assert "Model Availability" in resp.text def test_admin_models_hides_deprecated_by_default_but_can_show_them(admin_client): """The models page defaults to hiding deprecated/stale rows (e.g. after the OpenRouter allowlist prunes 425 down to 30) but can reveal them. Asserted as a property rather than as a specific widget. This previously pinned `id="show-deprecated-toggle"`, and when that all-or-nothing switch became an availability select -- which can also isolate *only* the stale rows, which is what auditing an override needs -- the test failed for a change that preserved everything it was protecting. See tests/test_models_page_structure.py for the filter surface itself. """ text = admin_client.get("/admin/models").text # Default view excludes the two non-routable states... assert "'deprecated'" in text and "'stale'" in text assert 'value="routable"' in text # ...and there is a control that puts them back. assert 'id="mf-availability"' in text assert ">All statuses<" in text def test_admin_profiles_returns_html_with_profiles_marker(admin_client): """GET /admin/profiles returns 200, text/html, and contains the Chart.js tag and the Profiles page marker.""" resp = admin_client.get("/admin/profiles") assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/html") assert "chart.js" in resp.text assert "Profiles" in resp.text def test_admin_pages_include_profiles_nav_link(admin_client): """Every served admin page body contains the mount the shared nav renders into. The nav links are built client-side from navbar.js's NAV_LINKS (the link list's completeness is owned by tests/test_admin_nav_is_complete.py), so served HTML ships the empty