Change the unit of session_cache.staleness from minutes to seconds so it can express finer-grained (sub-minute) staleness windows. This is a straight rename, not an additive/compat knob — no deprecated alias, per the project's convention of updating every consumer in the same change. New bounds: floor 5 seconds (was 1 minute), ceiling 7200 seconds (was 120 minutes). Default: 1200 seconds (was 20 minutes). The validator's reasoning is unit-independent and carries over: the floor is deliberately > 0 because 0 would make session_cache.get() miss every turn while put() still writes and the classifier-failure cascade's stale_read ignores staleness; the ceiling reasoning (unbounded window = never-expiring cache, 7200s still >> 840s real max run) also carries over in seconds. Every consumer updated in the same commit: - src/config.py: STALENESS_MINUTES_MIN/MAX -> STALENESS_SECONDS_MIN/MAX = 5/7200, staleness_minutes -> staleness_seconds: 1200, validator updated - src/dispatcher.py: drop the * 60 conversion (field is native seconds) - src/admin.py: _INT_KNOBS key/path/constants, _CONFIG_ALLOWLIST, _CONFIG_GET_ORDER, _runtime_state, error message template - admin/frontend/controls.html: note keys, tooltip, NUMBER_BOUNDS - config/config.yaml: staleness_seconds: 1200 - tests: test_admin_runtime/config/frontend/knob_coverage, plus stale comment in test_chat_completions - docs: admin-portal.md, evaluation.md, README.md config.local.yaml is gitignored and will be migrated separately.
565 lines
24 KiB
Python
565 lines
24 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_threshold_field_is_percent_with_conversion(admin_client):
|
|
"""The encoder confidence_threshold field takes 0-100 (a human types "80"
|
|
meaning 80%) and converts to the 0.0-1.0 probability classify_zero_shot
|
|
actually returns -- typing the intuitive percent value used to be stored
|
|
literally, 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_threshold * 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 Profiles nav link markup."""
|
|
for path in ["/admin/", "/admin/models", "/admin/profiles", "/admin/decisions", "/admin/controls"]:
|
|
resp = admin_client.get(path)
|
|
assert resp.status_code == 200
|
|
assert ">Profiles<" in resp.text, f"Profiles nav link 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()
|
|
assert 'href="/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"
|