Calibre-Web, RomM and Seerr sat inside "Media Library Apps" carrying a WIP badge and a "not open to family users yet" sentence each, so the household grid mixed doors they can use with doors they cannot. They now render under their own "Work in progress" heading, placed after AI on the user page, and the copy drops the restated "Work in progress:" for the ask-Frank line the section heading no longer needs to repeat. - app.js: SECTION_ORDER.user gains "Work in progress" last. - services.json: the three cards' section, plus one-line copy trim. - homepage_config.bats / homepage_pages.bats: the section contract now includes the new heading and pins the new order (the old test pinned AI last, which this change deliberately overturns). - Homepage.md: the classification list and the SECTION_ORDER/wip contract lines catch up (the user order in the page was already stale, and "down" was an undocumented wip value). The admin page is untouched: its WIP cards (Gluetun, mariadb, valkey, Postgres) are plumbing inside Infrastructure, not applications.
137 lines
6.7 KiB
Bash
137 lines
6.7 KiB
Bash
#!/usr/bin/env bats
|
|
# Seam: shared/homepage/services.json — the service config the checker reads
|
|
# and the pages render. It must be a JSON list; every entry carries name,
|
|
# category, section, and a host:port probe. category is user or admin;
|
|
# section must be one the page's SECTION_ORDER renders. method, when
|
|
# present, is tcp or http. wip, when set, is "open", "closed" or "down".
|
|
|
|
load helpers
|
|
|
|
@test "services.json: valid schema" {
|
|
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
|
|
import json, sys
|
|
path = sys.argv[1]
|
|
try:
|
|
data = json.load(open(path))
|
|
except FileNotFoundError:
|
|
sys.exit("services.json not found")
|
|
assert isinstance(data, list), "top level must be a list"
|
|
assert data, "the list must not be empty"
|
|
for svc in data:
|
|
for field in ("name", "category", "section", "probe"):
|
|
assert field in svc, f"{svc.get('name', '?')} is missing {field}"
|
|
assert svc["category"] in ("user", "admin"), svc["category"]
|
|
if "method" in svc:
|
|
assert svc["method"] in ("tcp", "http"), svc["method"]
|
|
wip = svc.get("wip")
|
|
assert wip is False or wip in ("open", "closed", "down"), f"{svc['name']} wip must be false, 'open', 'closed' or 'down'"
|
|
host, _, port = svc["probe"].rpartition(":")
|
|
assert host and port.isdigit(), f"bad probe {svc['probe']}"
|
|
PY
|
|
}
|
|
|
|
@test "services.json: every section is one the page renders" {
|
|
# A service whose section is not in app.js's SECTION_ORDER for its
|
|
# category is silently dropped from the page. Pin the contract.
|
|
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
|
|
import json, sys
|
|
data = json.load(open(sys.argv[1]))
|
|
allowed = {
|
|
"user": {"AI", "Web Apps", "Media Library Apps", "Work in progress"},
|
|
"admin": {"Media Library Apps", "Observability", "AI", "Infrastructure"},
|
|
}
|
|
for svc in data:
|
|
assert svc.get("section") in allowed[svc["category"]], (
|
|
f"{svc['name']} has unrenderable section {svc.get('section')!r} for {svc['category']}")
|
|
PY
|
|
}
|
|
|
|
@test "services.json: every user service has an Open link, status-only backends do not" {
|
|
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
|
|
import json, sys
|
|
data = json.load(open(sys.argv[1]))
|
|
for svc in data:
|
|
show = svc.get("showLink", True)
|
|
if show:
|
|
assert svc.get("url"), f"{svc['name']} shows a link but has no url"
|
|
else:
|
|
assert not svc.get("url"), f"{svc['name']} is status-only but has a url"
|
|
PY
|
|
}
|
|
|
|
@test "the LLM status cards track the fleet, not retired service names" {
|
|
# This drifted twice: the card probing Dankserver's :7770 was still named
|
|
# frank-small long after frank-medium took that endpoint; the endpoints
|
|
# were renamed to the Protoss set on 2026-10-08. The probe target is what
|
|
# matters; the name has to follow it.
|
|
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
|
|
import json, sys
|
|
services = json.load(open(sys.argv[1]))
|
|
by_probe = {s["probe"]: s["name"] for s in services}
|
|
assert by_probe.get("192.168.0.235:7770") == "frank-archon", by_probe.get("192.168.0.235:7770")
|
|
assert by_probe.get("127.0.0.1:7770") == "frank-dragoon", by_probe.get("127.0.0.1:7770")
|
|
assert by_probe.get("127.0.0.1:7771") == "frank-zealot", by_probe.get("127.0.0.1:7771")
|
|
PY
|
|
}
|
|
|
|
@test "the Model Information component is complete: text, art, voices, warnings" {
|
|
# The fused model panel is the family's only description of the fleet.
|
|
# Every model card must carry: equivalence note, supervision level, the
|
|
# unit art, at least one playable voice line, and (for uncensored models)
|
|
# the legal warning. The compaction recommendation must name the zealot.
|
|
python3 - "$REPO_ROOT/shared/homepage/services.json" "$REPO_ROOT/shared/homepage" <<'PY'
|
|
import json, os, sys
|
|
services = json.load(open(sys.argv[1]))
|
|
root = sys.argv[2]
|
|
fleet = [s for s in services if "model" in s]
|
|
assert {s["name"] for s in fleet} == {"frank-dragoon", "frank-archon", "frank-zealot"}, [s["name"] for s in fleet]
|
|
for s in fleet:
|
|
m = s["model"]
|
|
assert m["equiv"].strip(), f"{s['name']} has no equivalence note"
|
|
assert m["supervision"].strip(), f"{s['name']} has no supervision note"
|
|
assert m["sfx"], f"{s['name']} has no voice line"
|
|
assert os.path.isfile(os.path.join(root, "img", "unit-" + m["unit"] + ".jpg")), f"{s['name']} art missing"
|
|
for f in m["sfx"] + m.get("voice", {}).get("sfx", []):
|
|
assert os.path.isfile(os.path.join(root, "sfx", f)), f"{s['name']} voice {f} missing"
|
|
zealot = next(s for s in fleet if s["name"] == "frank-zealot")
|
|
assert "compaction" in zealot["model"].get("compaction", ""), "zealot must be recommended as compaction model"
|
|
assert zealot["model"].get("compaction", "") != "", "compaction advice must be its own visible field"
|
|
assert "supervision" in zealot["model"]["supervision"].lower()
|
|
for s in fleet:
|
|
m = s["model"]
|
|
uncensored = "ncensored" in m["spec"] or "bliterated" in m["spec"]
|
|
if uncensored:
|
|
assert "legal" in m.get("warn", "").lower(), f"{s['name']} is uncensored but has no legal warning"
|
|
assert [s["name"] for s in fleet] == ["frank-zealot", "frank-dragoon", "frank-archon"], \
|
|
"card render order must be zealot, dragoon, archon: " + str([s["name"] for s in fleet])
|
|
w = {s["name"]: s["model"].get("warn", "") for s in fleet}
|
|
assert "legal risk" in w["frank-dragoon"].lower() and "everybody" in w["frank-dragoon"].lower(), \
|
|
"the abliterated endpoint must carry the common legal warning"
|
|
archon = next(s for s in fleet if s["name"] == "frank-archon")
|
|
assert "permission" in archon["model"], "archon card needs the ask-Frank permission callout"
|
|
PY
|
|
grep -q "renderModelInfo" "$REPO_ROOT/shared/homepage/app.js"
|
|
grep -q ".mi-card" "$REPO_ROOT/shared/homepage/style.css"
|
|
}
|
|
|
|
@test "every app card carries icon art and SearXNG carries the privacy note" {
|
|
# The family sees unit-portrait art on every card, not just the models:
|
|
# brand marks where Simple Icons has them, improvised monograms otherwise.
|
|
# SearXNG's card must warn (hover) that obfuscation is not anonymity.
|
|
python3 - "$REPO_ROOT/shared/homepage/services.json" "$REPO_ROOT/shared/homepage" <<'PY'
|
|
import json, os, sys
|
|
services = json.load(open(sys.argv[1]))
|
|
root = sys.argv[2]
|
|
apps = [s for s in services if "model" not in s]
|
|
for s in apps:
|
|
art = s.get("art", "")
|
|
assert art.startswith("/img/apps/"), f"{s['name']} has no app art"
|
|
assert os.path.isfile(os.path.join(root, art.lstrip("/"))), f"{s['name']} art file missing: {art}"
|
|
sx = next(s for s in services if s["name"] == "SearXNG")
|
|
assert "anonymous" in sx.get("warn", "").lower(), "SearXNG must warn that searches are obfuscated, not anonymous"
|
|
PY
|
|
grep -q "app-art" "$REPO_ROOT/shared/homepage/app.js"
|
|
grep -q ".app-art" "$REPO_ROOT/shared/homepage/style.css"
|
|
grep -q "/whoami" "$REPO_ROOT/dankclaw/Caddyfile"
|
|
}
|