Files
Classroom_Desktop/tests/test-classroom-manual.sh
T
ekklesiaandClaude Opus 5 f2942a2946 docs: one manual, the pages a device has and a printable copy of all of them
Documentation becomes part of the release rather than a job afterwards.
docs/manual is now the only place teacher instructions are written, and
tools/build-manual produces both versions from it:

  classroom-manual.html   installed on every machine, showing only the
                          features THAT device has
  classroom-manual.pdf    the universal printed manual, every feature,
                          with a conditional sentence above each
                          optional page so it stays true in a room that
                          does not have them all

They cannot drift apart because neither is written by hand, and
build-release.sh builds and checks them on every release: a page that
declares a feature with no printed-if fails the build, because that is
exactly how the printed manual comes to state an optional feature as
though every room had it. So does a page naming a feature device.conf
does not have, and an optional feature with no page at all.

WHAT REACHES THE CLASSROOM ACCOUNT. Only a list of setting NAMES, and
only ones the package already defines as optional applications.
classroom-desktop-layout — already the one place that decides what a
device offers — writes them to /run/ekklesia-classroom/features.json,
and classroom-manual filters that against FEATURES before using it. No
values from device.conf, no broker password, no addresses. With no
manifest at all every page is shown, which is the printed manual and is
never wrong, only less specific.

Help is a Quick Launch button beside Parent Call, deliberately not in
the row with the applications: putting it there would move buttons
teachers reach for without looking. Every room gets it, because a
teacher who needs the manual is by definition one who does not know
where to look.

The PDF is written directly rather than converted. A manual is
headings, paragraphs, bullets, steps and one table; writing that with
the standard fonts is a couple of hundred lines and no dependency, so a
release can be built on any machine with Python. Pictures are embedded
in the digital manual as data URIs so it stays one file that works
wherever it is copied and with the network unplugged; the printed
manual names them instead, which is the one thing this builder does not
do and is noted where somebody would look for it.

Two defects found building it, both of which hung or corrupted the
output rather than failing: a paragraph opening with a bold word
matched the bullet guard but not the bullet rule, so nothing consumed
it and the build spun; and a list item wrapped across two source lines
ended the list, so a three-step procedure printed as three steps all
numbered 1.

Also adds the 0.2.6 physical checklist to docs/testing.md and the
release validator's checks for the Wi-Fi regression, the conversion
pipeline on a real Debian, the fonts, the manual and the dock order.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 20:21:39 -05:00

264 lines
9.9 KiB
Bash
Executable File

#!/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("<script>") + 8,
html.lastIndexOf("</script>"));
// Enough DOM for it. Sections carry the feature they need, exactly as
// the builder wrote them.
function sectionsOf(text) {
const found = [];
const re = /<section id="([^"]+)"(?: data-feature="([^"]+)")?>/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