diff --git a/html/index.html b/html/index.html index eda881f..919f44b 100644 --- a/html/index.html +++ b/html/index.html @@ -129,6 +129,14 @@ is still said, twice — the card's left border turns green and .card-status appears — which is green doing its job as a status rather than as an action colour. */ + /* A7 / T6.5. Every card says its state, in all THREE states — the version + before this said "complete" when it was and nothing when it was not, so + the most common state on a live project was the one with no line at all. + + Each state carries a glyph and a word as well as a colour, and the three + colours are the canonical status tokens: green complete, the suite's amber + for unknown, and secondary text for in-progress. Nothing is declared + here that theme-light.css does not already hold. */ .card-status { display: inline-block; font-size: 12px; @@ -136,6 +144,8 @@ color: var(--cds-support-success); margin-bottom: 0.5rem; } + /* Not finished yet — a real answer, and neither green nor a warning. */ + .card-status.card-status-pending { color: var(--cds-text-secondary); } /* B4: an unreachable server is a third state, and it has to look like neither of the other two. Not green, not silence. */ .card-status.card-status-error { color: var(--wp-status-warning-text); } @@ -187,6 +197,19 @@ } .footer a:hover { text-decoration: underline; } + + .footer p + p { margin-top: 0.4rem; } + /* "Pilot" is the word that changes what the rest of the sentence means, so it + is marked rather than left to be read past. */ + .footer-tag { + display: inline-block; + background: var(--wp-status-warning-bg); + color: var(--wp-status-warning-text); + border: 1px solid var(--cds-support-warning); + padding: 0 6px; + margin-right: 6px; + font-weight: 600; + } /* COMMENTS SECTION */ .comments-section { @@ -542,9 +565,16 @@ - + @@ -883,45 +913,74 @@ // reset (re-render can run multiple times) sopCard.classList.remove('complete'); wpCard && wpCard.classList.remove('disabled'); - const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove(); - const setStatus = (text, cls) => { - const el = document.createElement('div'); + // One status line per card, replaced in place rather than removed and + // re-added — a card that briefly has no status line reads as "no status", + // which is one of the three real answers and must not be shown by accident. + // + // role="status" so the change is announced: the line is written by a fetch + // that lands after the page has settled, which is exactly the case + // aria-live exists for (S10 / T4.5). Polite, because a project's SOP being + // incomplete is information, not an interruption. + const cardStatus = (card, text, cls) => { + if(!card) return null; + let el = card.querySelector('.card-status'); + if(!el){ + el = document.createElement('div'); + el.className = 'card-status'; + el.setAttribute('role', 'status'); + card.insertBefore(el, card.firstChild); + } el.className = 'card-status' + (cls ? ' ' + cls : ''); el.textContent = text; - sopCard.insertBefore(el, sopCard.firstChild); return el; }; + const setStatus = (text, cls) => cardStatus(sopCard, text, cls); + const setWpStatus = (text, cls) => cardStatus(wpCard, text, cls); - // Neutral while in flight: neither "complete" nor "not complete" is known yet, - // and asserting either would be the same lie in a different direction. + // A7 / T6.5. Two things were doing one job badly. + // + // The SOP card said "✓ SOP complete" when it was and NOTHING when it was + // not, so the most common state on a live project was the one with no + // status line at all. And the Work Package card carried its status in its + // BUTTON — "Complete SOP first", "Checking…" — which is a button describing + // a state instead of naming what pressing it does. + // + // Now: every card says its state in its own status line, in all three + // states, and every button says what it does. The buttons no longer change + // text at all. sopBtn.textContent = 'Open tool'; - if(wpBtn) wpBtn.textContent = 'Checking…'; + if(wpBtn) wpBtn.textContent = 'Open creator'; + setStatus('Checking the SOP…', 'card-status-pending'); + setWpStatus('Checking the SOP…', 'card-status-pending'); fetch('/api/projects/' + encodeURIComponent(active.id) + '/summary', { headers: { 'Accept': 'application/json' } }) .then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(sum => { if(ProjectData.getActiveId() !== active.id) return; // switched while in flight - const old = sopCard.querySelector('.card-status'); if(old) old.remove(); if(sum.sop_complete){ sopCard.classList.add('complete'); sopBtn.textContent = 'Review'; - setStatus('✓ SOP complete' + (sum.sop_name ? ' — ' + sum.sop_name : '')); - if(wpBtn) wpBtn.textContent = 'Open creator'; + setStatus('✓ Complete' + (sum.sop_name ? ' — ' + sum.sop_name : '')); + setWpStatus('✓ Ready — the SOP is complete'); } else { if(wpCard) wpCard.classList.add('disabled'); - if(wpBtn) wpBtn.textContent = 'Complete SOP first'; + setStatus('• Not finished — the project baseline is still being set up', + 'card-status-pending'); + setWpStatus('• Not available yet — finish the SOP first', + 'card-status-pending'); } }) .catch(err => { if(ProjectData.getActiveId() !== active.id) return; - const old = sopCard.querySelector('.card-status'); if(old) old.remove(); - // Explicitly unknown. The Creator is left reachable rather than disabled: - // locking someone out of their work because a status request failed is a - // worse outcome than letting the tool tell them itself. - setStatus('⚠ Could not check SOP status — ' + (err && err.message || 'offline'), 'card-status-error'); - if(wpBtn) wpBtn.textContent = 'Open creator'; + // Explicitly unknown, and it looks like neither of the other two. The + // Creator is left reachable rather than disabled: locking somebody out + // of their work because a status request failed is worse than letting + // the tool tell them itself. + const why = '⚠ Unknown — could not reach the server (' + ((err && err.message) || 'offline') + ')'; + setStatus(why, 'card-status-error'); + setWpStatus(why, 'card-status-error'); }); } diff --git a/tests/aggregates_check.py b/tests/aggregates_check.py index d3ad614..70c6c40 100644 --- a/tests/aggregates_check.py +++ b/tests/aggregates_check.py @@ -191,15 +191,23 @@ def main(): return true; })()""") page.goto(base + "/index.html") + # A7/T6.5 gave the card a status line in EVERY state, including while + # the request is in flight ("Checking the SOP..."). So waiting for the + # line to be non-empty is no longer waiting for the answer — wait for + # it to stop saying it is checking. for _ in range(30): s = page.eval("(document.querySelector('#card-sop .card-status')||{}).textContent||''") - if s: + if s and "Checking" not in s: break time.sleep(0.3) status = page.eval( "(document.querySelector('#card-sop .card-status')||{}).textContent||''") + # The wording moved at T6.5 ("SOP complete" -> "Complete - "). + # What this check is about is WHERE the answer came from, not how it is + # phrased, so it asserts the answer and not the sentence. chk("launcher reports the SOP complete despite a cache that says otherwise", - "SOP complete" in status, "card said %r" % status) + "Complete" in status and "Not finished" not in status, + "card said %r" % status.encode("ascii", "replace").decode("ascii")) page.goto(base + "/index.html") time.sleep(0.4) @@ -216,8 +224,12 @@ def main(): time.sleep(1.0) status = page.eval( "(document.querySelector('#card-sop .card-status')||{}).textContent||''") + # Same again: the wording moved at T6.5 ("Could not check SOP status" + # -> "Unknown - could not reach the server"). The assertion is that the + # card states the failure rather than guessing, in whatever words. chk("a failed status request says so on the card", - "Could not check" in status, "card said %r" % status) + "Unknown" in status and "could not reach" in status.lower(), + "card said %r" % status.encode("ascii", "replace").decode("ascii")) finally: page.close() browser.close() diff --git a/tests/cards_check.py b/tests/cards_check.py new file mode 100644 index 0000000..57815bf --- /dev/null +++ b/tests/cards_check.py @@ -0,0 +1,300 @@ +#!/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) + changed = subprocess.run( + ["git", "diff", "--stat", "HEAD", "--", "html/admin.js"], + cwd=ROOT, capture_output=True, text=True).stdout.strip() + chk("...and this task changed nothing in it at all", not changed, changed) + + +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())