#!/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