746 lines
32 KiB
Python
746 lines
32 KiB
Python
"""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 <ul id="nav-links"> the script fills
|
|
rather than the Profiles anchor itself.
|
|
"""
|
|
for path in ["/admin/", "/admin/models", "/admin/profiles", "/admin/decisions", "/admin/controls"]:
|
|
resp = admin_client.get(path)
|
|
assert resp.status_code == 200
|
|
assert 'id="nav-links"' in resp.text, f"nav-links mount missing on {path}"
|
|
|
|
|
|
def test_quota_page_returns_html_with_quota_marker(admin_client):
|
|
"""GET /admin/quota returns 200, text/html, and contains the Quota page marker."""
|
|
resp = admin_client.get("/admin/quota")
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"].startswith("text/html")
|
|
assert "Quota" in resp.text
|
|
assert "chart.js" in resp.text
|
|
assert "renderQuotaChip" not in resp.text
|
|
|
|
|
|
def test_quota_api_returns_accounts_shape(admin_client):
|
|
"""GET /admin/api/quota returns the quota_accounts shape with period, accounts, spend, alarm."""
|
|
resp = admin_client.get("/admin/api/quota")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "period" in data
|
|
assert "accounts" in data
|
|
assert "spend" in data
|
|
assert data["spend"]["total_usd"] >= 0
|
|
|
|
|
|
def test_the_home_page_carries_no_quota_chip():
|
|
"""The navbar chip is gone, deliberately.
|
|
|
|
It restated one number the Quota tile already shows, on every page, in
|
|
the one strip of the layout that has to stay legible at any width. The
|
|
tile links to the same place. Asserted as an absence so the chip cannot
|
|
quietly return with the next navbar edit.
|
|
"""
|
|
html = (ROOT / "admin" / "frontend" / "index.html").read_text()
|
|
# The href attribute is built client-side now: the nav renders from
|
|
# navbar.js and the board tiles from tile('/admin/quota', ...). The
|
|
# static trace of both is the pathname itself.
|
|
assert "/admin/quota" in html, "the Quota tile still has to link there"
|
|
assert "renderQuotaChip" not in html
|
|
assert "quota-chip-link" not in html
|
|
|
|
|
|
def test_quota_chip_has_no_modal_references():
|
|
"""The chip no longer opens a modal — no data-bs-toggle, quota-modal or renderQuotaModal."""
|
|
index_path = ROOT / "admin" / "frontend" / "index.html"
|
|
html = index_path.read_text()
|
|
assert "quota-modal" not in html
|
|
assert "renderQuotaModal" not in html
|
|
assert "formatProviderBalance" not in html
|
|
assert "formatBalanceStaleness" not in html
|
|
|
|
|
|
def test_builtin_profile_cards_offer_duplicate(admin_client):
|
|
"""Built-ins had no action buttons at all, so the page was five locked cards.
|
|
|
|
Duplicate is the path AROUND the read-only rule (a new config profile
|
|
pre-filled from the built-in's definition), not through it — the edit and
|
|
delete branch must stay behind ``!isBuiltin``.
|
|
"""
|
|
text = admin_client.get("/admin/profiles").text
|
|
assert 'data-action="duplicate"' in text
|
|
assert "openDuplicateModal" in text
|
|
# Still no edit/delete offered for a built-in. The true branch of the
|
|
# ternary is one template literal, so it runs to the next backtick.
|
|
branch = re.search(r"const actions = isBuiltin\s*\?\s*`([^`]*)`", text)
|
|
assert branch is not None, "the isBuiltin actions ternary was restructured"
|
|
builtin_branch = branch.group(1)
|
|
assert 'data-action="duplicate"' in builtin_branch
|
|
assert 'data-action="delete"' not in builtin_branch
|
|
assert 'data-action="edit"' not in builtin_branch
|
|
|
|
|
|
def test_profiles_page_renders_category_coverage(admin_client):
|
|
"""The bare "admits 0 models" number is no longer the whole story."""
|
|
text = admin_client.get("/admin/profiles").text
|
|
assert "category_coverage" in text
|
|
assert "for an unspecified category" in text
|
|
assert "Category-gated" in text
|
|
|
|
|
|
def test_controls_page_explains_gaming_modes_cloud_requirement(admin_client):
|
|
"""The refusal is the feature, so the page has to say what it requires."""
|
|
text = admin_client.get("/admin/controls").text
|
|
assert "local-compute-toggle" in text
|
|
assert "classifier.cloud_fallback" in text
|
|
# And why the free cascade steps still run ahead of the paid one.
|
|
assert "route_decisions" in text
|
|
|
|
|
|
def test_controls_page_warns_next_to_the_prefix_probe_toggle(admin_client):
|
|
"""pinch.prefix_probe is the one knob whose only effect is on measurement.
|
|
|
|
A bare switch in a column of switches says nothing about that, and turning
|
|
it off costs the evidence the context-pruning rewrite is waiting on. The
|
|
note must be reachable from BOTH panels -- the runtime knob name and the
|
|
persisted-config key -- because the knob appears in both.
|
|
"""
|
|
text = admin_client.get("/admin/controls").text
|
|
assert "off stops the cache-loss measurement" in text
|
|
assert "pinch_prefix_probe:" in text
|
|
assert "'pinch.prefix_probe':" in text
|
|
# Rendered into the meta track of both lists, not as a new visual pattern.
|
|
assert "noteBadge(key)" in text
|
|
|
|
|
|
def test_controls_page_pairs_the_incumbent_gate_with_its_dial(admin_client):
|
|
"""The two Wave 2 knobs are two rows that only mean anything together.
|
|
|
|
A bare switch beside a bare number field says nothing about that, and the
|
|
house style forbids answering it with a paragraph above the list. So each
|
|
row carries one short note, reachable from BOTH panels -- the runtime knob
|
|
name and the persisted-config key.
|
|
"""
|
|
text = admin_client.get("/admin/controls").text
|
|
assert "enables the challenger dial" in text
|
|
assert "blank = neutral, 0 = full penalty" in text
|
|
for key in (
|
|
"incumbent_cache_pricing:",
|
|
"'objective.incumbent_cache_pricing':",
|
|
"incumbent_challenger_cache_rate:",
|
|
"'objective.incumbent_challenger_cache_rate':",
|
|
):
|
|
assert key in text
|
|
|
|
|
|
def test_controls_page_dial_is_a_bounded_number_field_that_blanks_to_null(
|
|
admin_client,
|
|
):
|
|
"""Blank must post null, not 0 -- they are opposite ends of this dial.
|
|
|
|
``Number('')`` is 0 in JavaScript, so the obvious one-liner turns "clear
|
|
the field to switch this off" into "set the maximum penalty". The guard is
|
|
the explicit empty-string test in the onchange handler, and the 0-1 bounds
|
|
are what keep a percentage (the confidence_threshold incident's raw 80)
|
|
off the field in the first place.
|
|
"""
|
|
text = admin_client.get("/admin/controls").text
|
|
assert "NUMBER_BOUNDS" in text
|
|
assert "incumbent_challenger_cache_rate: { min: 0, max: 1" in text
|
|
assert "placeholder: 'neutral'" in text
|
|
assert "this.value.trim() === '' ? null : Number(this.value)" in text
|
|
# And the number branch is reached at all: renderRuntime used to fall
|
|
# through to a read-only muted span for anything that was not a bool,
|
|
# an enum or a string.
|
|
assert "typeof persisted === 'number'" in text
|
|
|
|
|
|
def test_controls_page_pairs_the_session_cache_switch_with_its_window(admin_client):
|
|
"""The switch and the window it gates are one decision in two rows.
|
|
|
|
A bare number field beside a bare switch says nothing about that, and the
|
|
house style forbids answering it with a paragraph above the list. So each
|
|
row carries one short note, keyed from BOTH panels — the runtime knob name
|
|
and the persisted-config key.
|
|
"""
|
|
text = admin_client.get("/admin/controls").text
|
|
assert "enables the reuse window" in text
|
|
assert "how long one label steers routing" in text
|
|
for key in (
|
|
"session_cache_enabled:",
|
|
"'session_cache.enabled':",
|
|
"session_cache_staleness_seconds:",
|
|
"'session_cache.staleness_seconds':",
|
|
):
|
|
assert key in text
|
|
|
|
|
|
def test_controls_page_staleness_window_is_bounded_and_has_no_blank(admin_client):
|
|
"""5 to 7200, and no placeholder — blank means nothing on this field.
|
|
|
|
The dial above uses `placeholder` to say what an empty field means, so its
|
|
absence here is the signal that emptiness is not a setting. The floor is 5
|
|
rather than 0 because 0 reads as "off" and is not: the cache still writes
|
|
and the classifier-failure cascade still replays the entry.
|
|
"""
|
|
text = admin_client.get("/admin/controls").text
|
|
assert "session_cache_staleness_seconds: { min: 5, max: 7200, step: 1 }" in text
|
|
assert "this.value.trim() === '' ? null : Number(this.value)" in text
|
|
|
|
|
|
def test_controls_panels_state_when_each_mechanism_takes_effect(admin_client):
|
|
"""Runtime is immediate, persisted needs the bounce. One clause each."""
|
|
text = admin_client.get("/admin/controls").text
|
|
assert "Live on the next request" in text
|
|
assert "Applied on restart" in text
|
|
|
|
|
|
def test_admin_controls_persisted_config_handles_wrapped_value_shape(admin_client):
|
|
"""GET /admin/controls contains the updated Persisted Config hint and the
|
|
frontend unwrapping logic for the {value, source} response shape."""
|
|
resp = admin_client.get("/admin/controls")
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"].startswith("text/html")
|
|
text = resp.text
|
|
assert "config/config.local.yaml" in text
|
|
assert "entry.value" in text
|
|
assert "source === 'overlay'" in text
|
|
assert "data-orig" in text
|
|
|
|
def test_admin_controls_has_cloud_fallback_card_ids(tmp_path, admin_client):
|
|
"""classifier.cloud_fallback gets its own card in admin/controls.
|
|
The card has id="cloud-fallback-state", cf-base-url input,
|
|
cf-model input, and a saveCloudFallback() button."""
|
|
resp = admin_client.get("/admin/controls")
|
|
text = resp.text
|
|
# Card id
|
|
assert 'id="cloud-fallback-state"' in text
|
|
# Input ids
|
|
assert 'id="cf-base-url"' in text
|
|
assert 'id="cf-model"' in text
|
|
# Save button onclick
|
|
assert 'onclick="saveCloudFallback()"' in text
|
|
# Card title
|
|
assert "Cloud Fallback" in text
|
|
# Error container
|
|
assert 'id="cloud-fallback-error"' in text
|
|
|
|
|
|
def test_frontend_version_returns_a_digest(admin_client):
|
|
"""GET /admin/api/frontend-version is what an open page polls to notice the
|
|
frontend under it changed."""
|
|
resp = admin_client.get("/admin/api/frontend-version")
|
|
assert resp.status_code == 200
|
|
digest = resp.json()["digest"]
|
|
assert isinstance(digest, str) and digest
|
|
# Nothing moved between the two calls, so neither did the digest.
|
|
assert admin_client.get("/admin/api/frontend-version").json()["digest"] == digest
|
|
|
|
|
|
def test_frontend_digest_is_stable_and_moves_with_mtime_or_size(tmp_path):
|
|
"""Stable when nothing changes; different when mtime or size does.
|
|
|
|
Both halves matter. A digest that drifts on its own cries wolf on every
|
|
poll until the operator learns to ignore the notice, and a digest that
|
|
misses a same-size edit is exactly the hand-edit-during-iteration case this
|
|
exists for -- which is why it carries st_mtime_ns and not just st_size.
|
|
"""
|
|
page = tmp_path / "index.html"
|
|
page.write_text("one")
|
|
paths = (page,)
|
|
|
|
first = admin.frontend_digest(paths)
|
|
assert admin.frontend_digest(paths) == first
|
|
|
|
# Same length, different content: only the mtime distinguishes them.
|
|
stat = page.stat()
|
|
page.write_text("two")
|
|
os.utime(page, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
|
|
mtime_changed = admin.frontend_digest(paths)
|
|
assert mtime_changed != first
|
|
|
|
# Same mtime, different size.
|
|
page.write_text("three")
|
|
os.utime(page, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
|
|
assert admin.frontend_digest(paths) != mtime_changed
|
|
|
|
|
|
def test_frontend_digest_survives_a_missing_file(tmp_path):
|
|
"""A missing file must not raise -- the notice is a convenience, and a
|
|
partial checkout is not a reason to 500 an admin page."""
|
|
present = tmp_path / "index.html"
|
|
present.write_text("x")
|
|
absent = tmp_path / "gone.html"
|
|
|
|
missing = admin.frontend_digest((present, absent))
|
|
assert isinstance(missing, str) and missing
|
|
# And when the file comes back, the digest says so.
|
|
absent.write_text("y")
|
|
assert admin.frontend_digest((present, absent)) != missing
|
|
|
|
|
|
def test_frontend_digest_covers_every_served_frontend_file():
|
|
"""The tripwire: a new admin page must join the digest set.
|
|
|
|
If it does not, the one file that changed is invisible to the poll and a
|
|
browser sitting on that page keeps running its old copy with nothing on
|
|
screen saying so -- the precise failure the notice was built for, reopened
|
|
by adding a page.
|
|
"""
|
|
directory = ROOT / "admin" / "frontend"
|
|
on_disk = {
|
|
p.name
|
|
for p in directory.iterdir()
|
|
if p.is_file() and p.name != "__init__.py"
|
|
}
|
|
covered = {p.name for p in admin.frontend_paths(ROOT)}
|
|
assert covered == on_disk, (
|
|
"admin._FRONTEND_FILES and admin/frontend/ disagree; "
|
|
f"missing from the digest: {sorted(on_disk - covered)}, "
|
|
f"listed but absent: {sorted(covered - on_disk)}"
|
|
)
|
|
|
|
|
|
def test_navbar_offers_a_reload_and_never_takes_one():
|
|
"""The notice is one short line with a reload the operator chooses.
|
|
|
|
Auto-reloading would discard whatever is half-typed in a profile modal or a
|
|
dirty config row, which is a worse outcome than the stale page. The reload
|
|
call therefore has to sit inside a click handler and nowhere else.
|
|
"""
|
|
js = (ROOT / "admin" / "frontend" / "navbar.js").read_text()
|
|
assert "frontend-version" in js
|
|
assert "This page is out of date" in js
|
|
reloads = re.findall(r"window\.location\.reload\(\)", js)
|
|
assert len(reloads) == 1, "exactly one reload, and it belongs to the button"
|
|
handler = re.search(
|
|
r"nav-stale-reload'\)\.addEventListener\('click', function \(\) \{\s*"
|
|
r"window\.location\.reload\(\);",
|
|
js,
|
|
)
|
|
assert handler, "the only reload must be the Reload button's own handler"
|
|
|
|
|
|
def test_navbar_stale_notice_cannot_shift_the_page():
|
|
"""Fixed, and hidden until it fires.
|
|
|
|
In the navbar cluster it would shove the status dot and the profile
|
|
switcher sideways on appearing, and it would scroll out of view with the
|
|
header while the reason to reload stayed true.
|
|
"""
|
|
css = (ROOT / "admin" / "frontend" / "navbar.css").read_text()
|
|
block = re.search(r"^\.nav-stale\{(.*?)\}", css, re.S | re.M)
|
|
assert block, ".nav-stale rule not found"
|
|
assert "position:fixed" in block.group(1)
|
|
assert "display:none" in block.group(1)
|
|
assert ".nav-stale.show{display:flex}" in css
|
|
|
|
|
|
def test_the_home_page_no_longer_renders_a_verdict_bar():
|
|
"""The verdict-mix card left the home page with the Board rebuild.
|
|
|
|
Keeping the reasoning, because it cost a browser session to find and the
|
|
next verdict chart will hit it again: `unverifiable` is not a finding. It
|
|
is the default outcome for prose and for any turn ending in a tool call,
|
|
so it is structurally the largest bucket by a wide margin -- 13,347
|
|
against 870 for the next-largest over 7 days of real traffic. Any bar
|
|
scaled against it renders every meaningful verdict at the 2px minimum.
|
|
Exclude it from the SCALE, never from the denominator: the share is what
|
|
tells you most traffic is unverifiable.
|
|
|
|
The home page now states the same fact as one ratio in the Live rail's
|
|
Signal quality card, which needs no scale at all.
|
|
"""
|
|
html = (ROOT / "admin" / "frontend" / "index.html").read_text()
|
|
assert "VERDICT_OFF_SCALE" not in html
|
|
assert "renderVerdict" not in html
|
|
assert "failing, last 7 days" in html, "the fact itself still has to be on the page"
|
|
|
|
|
|
def _classifier_mode_select_html() -> str:
|
|
"""The classifier card's mode <select> block, from the static markup."""
|
|
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
|
|
select = re.search(r"<select [^>]*id=\"classifier-mode-select\".*?</select>", html, re.DOTALL)
|
|
assert select, "classifier-mode-select block not found in controls.html"
|
|
return select.group(0)
|
|
|
|
|
|
def test_classifier_mode_select_starts_disabled():
|
|
"""The mode select ships disabled and waits for the config fetch.
|
|
|
|
A live select during load would let a change fire onClassifierModeChange
|
|
against an empty _classifierConfigData; loadClassifierConfig is the only
|
|
thing allowed to unlock the card.
|
|
"""
|
|
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
|
|
tag = re.search(r"<select [^>]*id=\"classifier-mode-select\"[^>]*>", html)
|
|
assert tag, "classifier-mode-select open tag not found in controls.html"
|
|
assert "disabled" in tag.group(0)
|
|
|
|
|
|
def test_classifier_mode_select_leads_with_a_loading_placeholder():
|
|
"""The first option is an unconfirmed placeholder, not a real mode.
|
|
|
|
With a real mode preselected, a disabled select would still display a
|
|
claim about the config before it has been fetched; 'loading...' shows
|
|
nothing has been read yet.
|
|
"""
|
|
block = _classifier_mode_select_html()
|
|
first = re.search(r"<option[^>]*>", block)
|
|
assert first, "the select has no options"
|
|
assert 'value=""' in first.group(0)
|
|
assert "selected" in first.group(0)
|
|
assert ">loading...<" in block
|
|
|
|
|
|
def test_classifier_mode_select_preselects_no_real_mode():
|
|
"""selected belongs to the placeholder alone in the static markup.
|
|
|
|
A static selected on local_llm/cloud_llm/local_encoder would flash that
|
|
mode before the config resolves and would survive a failed fetch as a
|
|
false reading.
|
|
"""
|
|
block = _classifier_mode_select_html()
|
|
for mode in ("local_llm", "cloud_llm", "local_encoder"):
|
|
option = re.search(rf"<option value=\"{mode}\"[^>]*>", block)
|
|
assert option, f"option for {mode} missing from the static markup"
|
|
assert "selected" not in option.group(0), f"{mode} carries selected"
|
|
assert block.count("selected") == 1, "selected must appear only on the placeholder"
|
|
|
|
|
|
def test_classifier_save_button_starts_disabled():
|
|
"""Save unlocks together with the select, never before.
|
|
|
|
It reads collectClassifierConfigBody(), which reads the select's value --
|
|
against the placeholder that is an empty mode payload, so a clickable
|
|
Save during load would post garbage.
|
|
"""
|
|
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
|
|
tag = re.search(r"<button [^>]*id=\"classifier-save-btn\"[^>]*>", html)
|
|
assert tag, "classifier-save-btn open tag not found in controls.html"
|
|
assert "disabled" in tag.group(0)
|
|
assert "onclick=\"saveClassifierConfig()\"" in tag.group(0)
|
|
|
|
|
|
def test_classifier_config_load_guards_the_placeholder_removal():
|
|
"""The success path must survive running more than once.
|
|
|
|
saveClassifierConfig re-awaits loadClassifierConfig after every
|
|
successful save, so the second run arrives with the placeholder already
|
|
gone: the removal is optional-chained. The failure branch keeps both
|
|
controls disabled, flips only the placeholder label, and reports through
|
|
the card's existing error helper.
|
|
"""
|
|
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
|
|
match = re.search(r"^async function loadClassifierConfig\(.*?^\}", html, re.DOTALL | re.MULTILINE)
|
|
assert match, "loadClassifierConfig() not found in controls.html"
|
|
body = match.group(0)
|
|
assert "option[value=\"\"]')?.remove()" in body
|
|
assert body.index("?.remove()") < body.index(".value = data.mode")
|
|
assert "document.getElementById('classifier-mode-select').disabled = false" in body
|
|
assert "document.getElementById('classifier-save-btn').disabled = false" in body
|
|
assert "placeholder.textContent = 'unavailable'" in body
|
|
assert (
|
|
"showClassifierConfigError('classifier config unavailable; controls disabled')"
|
|
in body
|
|
)
|
|
|
|
|
|
def test_decisions_filters_read_and_write_the_url():
|
|
"""E3: the decisions filter state lives in the URL.
|
|
|
|
The page reads kind/category/profile/tier/q/size from the query string
|
|
before the first render and writes changes back with replaceState -
|
|
never pushState, the filter log is not navigation history.
|
|
"""
|
|
html = (ROOT / "admin" / "frontend" / "decisions.html").read_text()
|
|
assert "new URLSearchParams(location.search)" in html
|
|
assert "history.replaceState(" in html
|
|
assert "pushState" not in html
|
|
init_match = re.search(r"^function init\(\)\s*\{.*?^\}", html, re.DOTALL | re.MULTILINE)
|
|
assert init_match, "init() not found in decisions.html"
|
|
body = init_match.group(0)
|
|
assert body.index("applyUrlFilters()") < body.index("loadDecisions()")
|
|
writer_match = re.search(r"^function writeFiltersToUrl\(\)\s*\{.*?^\}", html, re.DOTALL | re.MULTILINE)
|
|
assert writer_match, "writeFiltersToUrl() not found in decisions.html"
|
|
writer = writer_match.group(0)
|
|
assert writer.index("history.replaceState(") > writer.index("new URLSearchParams()")
|
|
assert "size !== '100'" in writer
|
|
|
|
|
|
def test_render_board_reads_decision_outcomes_and_render_live_keeps_verdict_mix():
|
|
"""R2: the Decisions tile reads decision_outcomes; the Signal card does not.
|
|
|
|
Scope is per function, never whole-file: renderLive legitimately keeps
|
|
its ``_snap.verdict_mix`` read, so a whole-file "no verdict_mix" assertion
|
|
would be wrong. The board must consume decision_outcomes through the
|
|
guarded local (a stale tab can receive a snapshot without the field, and
|
|
an unguarded property read throws and blanks the whole board), and the
|
|
old "verified in 7 days" diagnostic framing must be gone.
|
|
"""
|
|
html = (ROOT / "admin" / "frontend" / "index.html").read_text()
|
|
board = _function_body(html, "renderBoard")
|
|
assert "decision_outcomes" in board
|
|
assert "verdict_mix" not in board
|
|
assert "const dout = _snap.decision_outcomes" in board
|
|
assert "verified in 7 days" not in board
|
|
# The share travels inside the template: leading space, no trailing
|
|
# space. The old " ${share}: " pattern rendered a double space before
|
|
# the colon ("reports (6.6%) :") and a stranded space when omitted.
|
|
assert " ${share}: " not in board
|
|
live = _function_body(html, "renderLive")
|
|
assert "verdict_mix" in live
|
|
|
|
|
|
def test_controls_html_has_local_decision_fields():
|
|
"""controls.html contains all 7 local_decision field data-key attributes.
|
|
|
|
The classifierModeFieldsHtml function must render fields for base_url,
|
|
model, num_ctx, timeout_s, confidence_min, coverage_min, and tier_enabled
|
|
when mode is local_decision.
|
|
"""
|
|
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
|
|
assert 'data-key="decision.base_url"' in html
|
|
assert 'data-key="decision.model"' in html
|
|
assert 'data-key="decision.num_ctx"' in html
|
|
assert 'data-key="decision.timeout_s"' in html
|
|
assert 'data-key="decision.confidence_min"' in html
|
|
assert 'data-key="decision.coverage_min"' in html
|
|
assert 'data-key="decision.tier_enabled"' in html
|
|
|
|
|
|
def test_controls_html_local_decision_field_rendering():
|
|
"""classifierModeFieldsHtml has a local_decision case rendering 7 fields.
|
|
|
|
The local_decision block must exist in the function and must reference
|
|
data.decision for field values.
|
|
"""
|
|
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
|
|
fields = _function_body(html, "classifierModeFieldsHtml")
|
|
assert "mode === 'local_decision'" in fields
|
|
assert "data && data.decision" in fields
|
|
assert "dec.base_url" in fields
|
|
assert "dec.model" in fields
|
|
assert "dec.num_ctx" in fields
|
|
assert "dec.timeout_s" in fields
|
|
assert "dec.confidence_min" in fields
|
|
assert "dec.coverage_min" in fields
|
|
assert "dec.tier_enabled" in fields
|