#!/bin/bash # The teacher's manual: one source, two versions, and no drift. # # Two halves, because the manual has two failure modes and they are not # alike. The source can be wrong — a page for an optional feature with # no conditional sentence makes the PRINTED manual state it # unconditionally, which is wrong in every room that does not have it. # And the built page can be wrong — it can show a teacher a feature # their computer does not have, or hide one it does, or carry something # out of device.conf that the classroom account has no business seeing. # # The second half runs the manual's own viewer script in node against a # stub DOM, the same way test-classroom-network.sh evaluates the polkit # rule: it is the actual shipped code, not a description of it. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" echo "A. the source, and the release gate" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' import subprocess import sys from pathlib import Path root = Path(sys.argv[1]) sys.path.insert(0, str(root / "lib")) from classroom_config import FEATURES passed = failed = 0 def check(label, condition, detail=""): global passed, failed if condition: print(f" ok {label}") passed += 1 else: print(f" FAIL {label}") if detail: print(f" {detail}") failed += 1 source = root / "docs" / "manual" build = source / "build" pages = sorted(p for p in source.glob("*.md") if p.name != "README.md") check("there is a manual source directory", source.is_dir()) check("with pages in it", len(pages) >= 5, str(len(pages))) headers = {} for path in pages: text = path.read_text(encoding="utf-8") header = {} if text.startswith("---\n"): end = text.index("\n---\n", 3) for line in text[4:end].splitlines(): if ":" in line: key, value = line.split(":", 1) header[key.strip()] = value.strip() headers[path.name] = header for name, header in headers.items(): check(f"{name} has a title", bool(header.get("title"))) # THE RULE THAT KEEPS THE PRINTED MANUAL TRUE. for name, header in headers.items(): if header.get("feature"): check(f"{name} says when it applies, for the printed manual", bool(header.get("printed-if")), "without it the printed manual states an optional feature " "as though every room had it") check(f"{name} names a real device.conf feature", header["feature"] in FEATURES, f"{header.get('feature')} is not one of " f"{', '.join(sorted(FEATURES))}") documented = {h.get("feature") for h in headers.values() if h.get("feature")} for name in FEATURES: check(f"{name} has a manual page", name in documented, "a room would get a button the manual never mentions") orders = [h.get("order") for h in headers.values()] check("no two pages claim the same place", len(orders) == len(set(orders)), str(sorted(orders))) print(" -- the builder itself") builder = root / "tools" / "build-manual" check("it exists and is runnable", builder.exists() and builder.stat().st_mode & 0o111) result = subprocess.run([str(builder)], capture_output=True, text=True, timeout=120) check("it builds without complaint", result.returncode == 0, result.stderr.strip()) check("it produced the digital manual", (build / "classroom-manual.html").exists()) check("and the printable one", (build / "classroom-manual.pdf").exists()) pdf = (build / "classroom-manual.pdf").read_bytes() check("the PDF is a real PDF", pdf.startswith(b"%PDF-") and pdf.rstrip().endswith(b"%%EOF"), str(pdf[:8])) check("with more than one page", pdf.count(b"/Type /Page ") > 1, str(pdf.count(b"/Type /Page "))) check("and a revision date on it, so an old printout can be spotted", b"Revision" in pdf) # Every conditional sentence has to actually reach the printed manual. for name, header in headers.items(): if header.get("printed-if"): words = header["printed-if"].encode("latin-1", "replace") check(f"{name}'s conditional sentence is in the PDF", words in pdf, header["printed-if"]) check("the check mode passes on what was just built", subprocess.run([str(builder), "--check"], capture_output=True, timeout=120).returncode == 0) html = (build / "classroom-manual.html").read_text(encoding="utf-8") for name, header in headers.items(): if header.get("feature"): check(f"{name} is marked in the HTML with its feature", f'data-feature="{header["feature"]}"' in html) check("the digital manual needs nothing from the network", "http://" not in html and "https://" not in html, "it has to work in a building with no internet") print() print(f"{passed} passed, {failed} failed") raise SystemExit(1 if failed else 0) PY status=$? [[ $status -ne 0 ]] && exit $status echo echo "B. what a device actually shows" if ! command -v node >/dev/null 2>&1; then echo " SKIP node is not installed; the viewer script was not evaluated" exit 0 fi node - "$ROOT" <<'JS' const fs = require("fs"); const path = require("path"); const root = process.argv[2]; let passed = 0, failed = 0; function check(label, condition, detail) { if (condition) { console.log(" ok " + label); passed++; } else { console.log(" FAIL " + label); if (detail) console.log(" " + detail); failed++; } } const html = fs.readFileSync( path.join(root, "docs/manual/build/classroom-manual.html"), "utf8"); // The shipped viewer, pulled out of the built page and run for real. const script = html.slice(html.lastIndexOf("")); // Enough DOM for it. Sections carry the feature they need, exactly as // the builder wrote them. function sectionsOf(text) { const found = []; const re = /
/g; let m; while ((m = re.exec(text)) !== null) { found.push({id: m[1], feature: m[2] || null}); } return found; } function show(features) { const sections = sectionsOf(html).map(s => ({ id: s.id, removed: false, getAttribute: function (name) { return name === "data-feature" ? s.feature : null; }, remove: function () { this.removed = true; }, querySelector: function () { return {textContent: s.id}; }, })); const contents = {children: [], appendChild: function (c) { this.children.push(c); }}; const window = {CLASSROOM_FEATURES: features}; const document = { querySelectorAll: () => sections, getElementById: () => contents, createElement: () => ({}), }; new Function("window", "document", script)(window, document); return sections.filter(s => !s.removed).map(s => s.id); } const all = sectionsOf(html).map(s => s.id); const optional = sectionsOf(html).filter(s => s.feature); check("the built manual carries every page", all.length >= 5, all.join(", ")); check("and some of them are optional", optional.length >= 3, optional.map(s => s.feature).join(", ")); console.log(" -- the Bible study laptop: Presentations, and nothing else optional"); const laptop = show(["PRESENTATIONS_ENABLED"]); check("the Presentations page is shown", laptop.some(id => id.includes("presentations")), laptop.join(", ")); check("the Browser page is shown, because every room has one", laptop.some(id => id.includes("browser"))); check("sound and volume are shown", laptop.some(id => id.includes("sound"))); check("Wonder Ink is NOT shown", !laptop.some(id => id.includes("wonderink")), laptop.join(", ")); check("Parent Call is NOT shown", !laptop.some(id => id.includes("parent-call")), laptop.join(", ")); check("the DVD page is NOT shown", !laptop.some(id => id.includes("dvd")), laptop.join(", ")); console.log(" -- a children's classroom: everything but Presentations"); const classroom = show(["WONDERINK_ENABLED", "PARENT_CALL_ENABLED", "DVD_ENABLED"]); check("Wonder Ink is shown", classroom.some(id => id.includes("wonderink"))); check("Parent Call is shown", classroom.some(id => id.includes("parent-call"))); check("the DVD page is shown", classroom.some(id => id.includes("dvd"))); check("Presentations is NOT shown", !classroom.some(id => id.includes("presentations")), classroom.join(", ")); console.log(" -- a device that could not say what it has"); // No manifest: show everything, which is the printed manual and is never // wrong, only less specific. const unknown = show(null); check("every page is shown rather than none", unknown.length === all.length, unknown.length + " of " + all.length); console.log(" -- nothing optional at all"); const bare = show([]); check("only the common pages remain", bare.length === all.length - optional.length, bare.join(", ")); console.log(" -- what the page is allowed to know about the device"); const manual = fs.readFileSync(path.join(root, "scripts/classroom-manual"), "utf8"); check("the manual is handed a list of setting NAMES, not device.conf", /window\.CLASSROOM_FEATURES/.test(manual) && /json\.dumps\(sorted\(features\)\)/.test(manual)); check("and the names are filtered against the features the package knows", /name for name in listed if name in FEATURES/.test(manual), "a stale manifest must not be able to name anything else"); const layout = fs.readFileSync(path.join(root, "scripts/classroom-desktop-layout"), "utf8"); check("the manifest is written from the enabled features alone", /setting for variable, setting in FEATURE_LINES if answers\.get\(variable\)/.test(layout)); check("no value from device.conf is written into it", !/read_device_config\(\)\[/.test(layout)); check("it lives in the runtime directory, so it cannot go stale", /\/run\/ekklesia-classroom\/features\.json/.test(layout)); console.log(); console.log(passed + " passed, " + failed + " failed"); process.exit(failed ? 1 : 0); JS