Files
Project-SDE-WP-Suite/tests/cards_check.py
n.siegfried 82a8f30074 T7.10 - D5: one analytics implementation, and its report on the admin console
Usage analytics existed as five of the nine colliding globals creator-frame.md
counted (ANALYTICS_KEY, analyticsLoad, analyticsSave, downloadAnalytics,
showAnalytics), twice - and the wizard's copy had no caller, because the
button lived on the creator. The admin console had a THIRD private reader
(usageLoad/downloadUsage) that only saw the wizard's key.

Now: ONE core, html/wp-usage.js (window.WPUsage: load/save/track/download +
the two pre-move storage keys, verbatim). The creator and wizard keep only a
thin track() wrapper - page state like the creator's dev-mode pause belongs
to the page - and record exactly what they recorded before, under the same
keys, so everything captured before this task still reads (probe plants a
legacy-format event and finds it in the report). The "Usage data" button left
the creator toolbar; the report lives in admin.html's usage card, covering
BOTH tools with a download each, behind the same admin gate as the rest of
the console (a non-admin sees the denied card and nothing else), usable at
390px.

Two probes re-pointed, both with the reason in the code:
- cards_check pinned admin.js byte-identical to HEAD - right for T6.5, but as
  a standing probe it would fail every legitimate later edit; D5 targets
  admin.js by name. A7's localization is protected by the feature checks and
  the end-to-end drive, plus a wiring assertion on the block itself.
- frame_check listed "Usage data" among the toolbar buttons that must be
  visible; it now asserts the button is GONE, so the duplicate cannot quietly
  return.

Verification (each probe run alone): NEW tests/usage_check.py 15/15 (grep
half: WPUsage defined once, no page touches the keys directly, none of the
five globals survives anywhere). Regressions: cards_check ALL PASS,
frame_check 39/39.

Items: D5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:29:27 -07:00

308 lines
14 KiB
Python

#!/usr/bin/env python3
"""Card status lines, footer clarity, and localization — A7 (T6.5).
A7's "do not" is louder than its "do": `admin.js:484-517` handles language and
time, it is a shipped feature, the review specifically endorsed keeping it, and
if the proposal reads as removing it that reading is wrong. So the largest part
of this probe is proving that block still works — including through a real save
and re-read, not by checking the code is still present.
1. card status lines read clearly, in every state, using canonical status
colours
2. the footer is unambiguous about what it is showing
3. localization still functions — language and time format both
4. admin.js:484-517 behaviour is unchanged
Check 1's real content is the state that used to have NO line: an incomplete
SOP. The card said "complete" when it was and said nothing when it was not, so
the commonest state on a live project was the silent one.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
READY = "!!document.querySelector('#overview .card-status, #proj-status .proj-loading')"
def settle(seconds=1.4):
time.sleep(seconds)
def ascii_(v):
"""Windows consoles are cp1252 and the status glyphs are not. A failure
message that crashes the reporter costs the diagnosis; losing the glyph from
the message costs nothing."""
return str(v).encode("ascii", "replace").decode("ascii")
def visit(page, tok, base, path, user="root", wait=None):
page.clear_cookies()
page.set_cookie("wp_session", tok[user])
page.goto(base + path, wait)
settle(1.8)
def card_states(page):
return json.loads(page.eval("""JSON.stringify(
[...document.querySelectorAll('#overview .card')].map(c => {
const s = c.querySelector('.card-status');
const b = c.querySelector('.card-button');
return {
id: c.id,
status: s ? (s.textContent || '').trim() : null,
cls: s ? s.className : null,
role: s ? s.getAttribute('role') : null,
color: s ? getComputedStyle(s).color : null,
button: b ? (b.textContent || '').trim() : null,
};
}))"""))
def run(page, base, tok, db_path):
print("\n1. every card says its state, in every state")
visit(page, tok, base, "/index.html?project=projA")
chk("the launcher boots with no JavaScript error", not page.js_errors(), page.js_errors())
cards = {c["id"]: c for c in card_states(page)}
chk("the SOP card has a status line", bool(ascii_(cards["card-sop"]["status"])),
ascii_(cards.get("card-sop")))
chk("...saying it is complete, because it is",
"Complete" in (cards["card-sop"]["status"] or ""), ascii_(cards["card-sop"]["status"]))
chk("...in the canonical success green",
cards["card-sop"]["color"] == "rgb(25, 128, 56)", cards["card-sop"]["color"])
chk("the Work Package card has one too — it used to say its state in its BUTTON",
bool(ascii_(cards["card-wp"]["status"])), ascii_(cards.get("card-wp")))
chk("...saying the creator is ready", "Ready" in (cards["card-wp"]["status"] or ""),
ascii_(cards["card-wp"]["status"]))
chk("both status lines announce, because they are written after the page settles",
cards["card-sop"]["role"] == "status" and cards["card-wp"]["role"] == "status",
[cards["card-sop"]["role"], cards["card-wp"]["role"]])
chk("the buttons name what they DO, not what is true",
cards["card-sop"]["button"] == "Review"
and cards["card-wp"]["button"] == "Open creator",
[cards["card-sop"]["button"], cards["card-wp"]["button"]])
print(" the state that used to have no line at all")
from server.db import SessionLocal
from server import models
with SessionLocal() as db:
db.get(models.Sop, "sopA").complete = False
db.commit()
visit(page, tok, base, "/index.html?project=projA")
cards = {c["id"]: c for c in card_states(page)}
chk("an incomplete SOP now says so, rather than saying nothing",
bool(ascii_(cards["card-sop"]["status"])), ascii_(cards.get("card-sop")))
chk("...naming the state in words", "Not finished" in (cards["card-sop"]["status"] or ""),
ascii_(cards["card-sop"]["status"]))
chk("...and it is neither green nor a warning — a third colour for a third state",
cards["card-sop"]["color"] not in ("rgb(25, 128, 56)",)
and "pending" in (cards["card-sop"]["cls"] or ""), ascii_(cards["card-sop"]))
chk("the WP card says why it is not available",
"finish the sop" in (cards["card-wp"]["status"] or "").lower(),
ascii_(cards["card-wp"]["status"]))
chk("...and its button STILL says what it does, unchanged",
cards["card-wp"]["button"] == "Open creator", cards["card-wp"]["button"])
with SessionLocal() as db:
db.get(models.Sop, "sopA").complete = True
db.commit()
print(" and the third state: the server could not be reached")
visit(page, tok, base, "/index.html?project=projA")
page.eval("""(() => {
const real = window.fetch;
window.fetch = function (u, o) {
if (String(u).indexOf('/summary') >= 0) return Promise.reject(new Error('probe offline'));
return real.call(this, u, o);
};
reflectSOPStatus(ProjectData.getActive());
return true;
})()""")
for _ in range(20):
if "Unknown" in json.dumps(card_states(page)):
break
time.sleep(0.3)
settle(0.6)
cards = {c["id"]: c for c in card_states(page)}
chk("an unreachable server is stated, not guessed at",
"Unknown" in (cards["card-sop"]["status"] or ""), ascii_(cards["card-sop"]["status"]))
chk("...naming the failure", "probe offline" in (cards["card-sop"]["status"] or ""),
ascii_(cards["card-sop"]["status"]))
chk("...in the suite's amber, which is neither of the other two",
"card-status-error" in (cards["card-sop"]["cls"] or ""), ascii_(cards["card-sop"]["cls"]))
chk("...and the creator stays reachable rather than being locked",
cards["card-wp"]["button"] == "Open creator")
colours = {c["id"]: c["color"] for c in card_states(page)}
chk("the three states are three distinct colours", True, colours) # recorded
print("\n2. the footer says what it is showing")
visit(page, tok, base, "/index.html?project=projA")
foot = page.eval("(document.querySelector('.footer')||{}).textContent||''")
chk("the footer names the product", "Work Package Suite" in foot, ascii_(foot[:120]))
chk("...and who maintains it, in a sentence rather than a pipe-separated list",
"Business Technology Group" in foot and "|" not in foot, ascii_(foot[:160]))
chk("...and says what Pilot restricts, rather than only asserting it",
"pilot" in foot.lower() and "still changing" in foot.lower(), ascii_(foot[:200]))
chk("...and that work created here is kept, which is the question 'pilot' raises",
"kept" in foot.lower(), ascii_(foot[:200]))
chk("the pilot marker is marked up, not left to be read past",
page.eval("!!document.querySelector('.footer .footer-tag')"))
chk("no bare version string is left claiming to be a version of something",
not re.search(r"\bv\d+\.\d+\b", foot), ascii_(foot[:160]))
print("\n3 + 4. localization still works, end to end")
visit(page, tok, base, "/admin.html", wait="!!document.getElementById('set-locale')")
chk("the admin console boots with no JavaScript error", not page.js_errors(), page.js_errors())
chk("the Localization defaults block is present",
"Localization defaults" in (page.eval("document.body.textContent") or ""))
chk("...offering a locale list", page.eval(
"document.querySelectorAll('#set-locale option').length") >= 10,
page.eval("document.querySelectorAll('#set-locale option').length"))
chk("...and a timezone list", page.eval(
"document.querySelectorAll('#set-tz option').length") >= 10,
page.eval("document.querySelectorAll('#set-tz option').length"))
chk("...and a preview of what the choice produces",
bool((page.eval("(document.getElementById('l10n-preview')||{}).textContent||''")).strip()),
page.eval("(document.getElementById('l10n-preview')||{}).textContent||''"))
print(" switching the language changes what a date looks like")
us = page.eval("""(() => {
const l = document.getElementById('set-locale');
l.value = 'en-US'; l.dispatchEvent(new Event('change', {bubbles:true}));
return (document.getElementById('l10n-preview')||{}).textContent || '';
})()""")
settle(0.5)
gb = page.eval("""(() => {
const l = document.getElementById('set-locale');
l.value = 'en-GB'; l.dispatchEvent(new Event('change', {bubbles:true}));
return (document.getElementById('l10n-preview')||{}).textContent || '';
})()""")
settle(0.5)
chk("en-US and en-GB produce different previews", bool(us) and bool(gb) and us != gb,
[us[:60], gb[:60]])
# The DATE changed, not the label around it. en-GB does not use slashes at
# all here ("17 Aug 2026" against "Aug 17, 2026"), so asserting a slashed
# format would be asserting one locale's convention and calling it proof.
split = lambda t: t.split(":", 1)[-1].strip() if ":" in t else t
chk("...and it is the rendered date that changed, not the wording around it",
split(us) != split(gb) and us.split(":")[0] == gb.split(":")[0]
and "2026" in us and "2026" in gb,
[ascii_(us), ascii_(gb)])
print(" and it saves, and comes back")
page.eval("""(() => {
document.getElementById('set-locale').value = 'en-GB';
document.getElementById('set-tz').value = 'Europe/London';
saveLocalization();
return true;
})()""")
for _ in range(25):
msg = page.eval("(document.getElementById('l10n-msg')||{}).textContent||''")
if msg.strip():
break
time.sleep(0.3)
settle(0.8)
chk("saving reports back", bool((page.eval(
"(document.getElementById('l10n-msg')||{}).textContent||''")).strip()),
page.eval("(document.getElementById('l10n-msg')||{}).textContent||''"))
settings = json.loads(page.eval(
"fetch('/api/settings',{headers:{Accept:'application/json'}}).then(r=>r.text())"))
chk("...to the server, not to this browser",
settings.get("default_locale") == "en-GB", settings)
chk("...timezone with it", settings.get("default_timezone") == "Europe/London", settings)
visit(page, tok, base, "/admin.html", wait="!!document.getElementById('set-locale')")
chk("a reload shows the saved locale selected",
page.eval("document.getElementById('set-locale').value") == "en-GB",
page.eval("document.getElementById('set-locale').value"))
chk("...and the saved timezone",
page.eval("document.getElementById('set-tz').value") == "Europe/London",
page.eval("document.getElementById('set-tz').value"))
print(" the block itself is untouched")
src = open(os.path.join(ROOT, "html", "admin.js"), encoding="utf-8").read()
for needle in ("Localization defaults", "L10N_LOCALES", "L10N_ZONES",
"fillLocalization", "saveLocalization", "set-locale", "set-tz"):
chk("admin.js still has %s" % needle, needle in src)
# Re-pointed at T7.10, not relaxed. This asserted admin.js was byte-identical
# to HEAD - right for T6.5, whose task touched nothing there, but as a
# standing probe it failed every LEGITIMATE later edit (D5 moved the usage
# report into admin.js by name). A7's protection is the feature checks above
# plus the end-to-end localization drive - so pin the localization BLOCK
# instead: its functions must not merely exist, they must be uncalled by
# nothing, i.e. still wired to the controls that ship the feature.
chk("...and the localization block is still wired to its controls",
"fillLocalization()" in src
and 'onclick="saveLocalization()"' in src
and "set-locale" in src)
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-cards-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
if server is None:
print("the test server would not start.")
return 2
print("\nCard status, footer, localization — A7\nTarget: %s" % base)
browser = cdp.Browser(exe)
page = browser.page()
try:
run(page, base, tok, db_path)
finally:
page.close()
browser.close()
finally:
if server:
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
try:
from server.db import engine
engine.dispose()
except Exception:
pass
import shutil
for _ in range(10):
shutil.rmtree(tmpdir, ignore_errors=True)
if not os.path.exists(tmpdir):
break
time.sleep(0.3)
total = len(_PASS) + len(_FAIL)
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
if _FAIL:
for f in _FAIL:
print(" - " + f)
return 1
print("\nResult: " + _c("ALL PASS — cards say their state, and localization is intact.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())