#!/bin/bash # Which applications a device offers, and the bar that comes out of it. # # Two halves. The first is the decision — one setting per application, and # never the shape of the machine. The second actually RUNS the layout # script the package ships, in node, with Plasma's API stubbed, so the # assertions are about the real file rather than about a copy of its text # that could drift away from it. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' import importlib.util import json import os import shutil import subprocess import sys import tempfile from importlib.machinery import SourceFileLoader from pathlib import Path root = Path(sys.argv[1]) loader = SourceFileLoader("layout", str(root / "scripts/classroom-desktop-layout")) spec = importlib.util.spec_from_loader("layout", loader) module = importlib.util.module_from_spec(spec) loader.exec_module(module) LAYOUT = root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js" 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 BLOCK = """/* CLASSROOM-FEATURES-BEGIN */ var WONDERINK = true; var DVD = true; var PARENT_CALL = true; /* CLASSROOM-FEATURES-END */""" print("1. one line per application, rewritten in place") check("a feature can be switched off", "var WONDERINK = false;" in module.rewrite(BLOCK, "WONDERINK", False)) check("and back on again", "var WONDERINK = true;" in module.rewrite(module.rewrite(BLOCK, "WONDERINK", False), "WONDERINK", True)) check("switching one leaves the others alone", "var DVD = true;" in module.rewrite(BLOCK, "WONDERINK", False) and "var PARENT_CALL = true;" in module.rewrite(BLOCK, "WONDERINK", False)) check("rewriting twice changes nothing the second time", module.rewrite(module.rewrite(BLOCK, "DVD", False), "DVD", False) == module.rewrite(BLOCK, "DVD", False)) print("2. the shape of the machine never decides what it is for") # This is the architecture rule, and it is the one worth a test: a laptop # must be able to run children's church and a tower must be able to be a # Bible study. module.has_optical_drive = lambda: True laptop_with_ink = module.wanted({"DEVICE_TYPE": "laptop"}) check("a laptop still offers Wonder Ink unless told otherwise", laptop_with_ink["WONDERINK"] is True, str(laptop_with_ink)) check("a laptop still offers Parent Call unless told otherwise", laptop_with_ink["PARENT_CALL"] is True) desktop_no_ink = module.wanted( {"DEVICE_TYPE": "desktop", "WONDERINK_ENABLED": "false"}) check("a desktop can have Wonder Ink taken away", desktop_no_ink["WONDERINK"] is False) check("and keeps everything else", desktop_no_ink["DVD"] is True and desktop_no_ink["PARENT_CALL"] is True) unset = module.wanted({}) check("an existing room that has never heard of these keeps all three", unset["WONDERINK"] and unset["DVD"] and unset["PARENT_CALL"], str(unset)) # The one that must NOT follow that rule. Every existing classroom has # Wonder Ink, a drive and Parent Call, so an upgrade has to leave all # three on. No existing classroom has Presentations, so an upgrade has to # leave it off — a new button on a bar nobody asked for is a support call. check("but Presentations is off until a device asks for it", unset["PRESENTATIONS"] is False, str(unset)) print("3. a drive can only ever take the player away") module.has_optical_drive = lambda: False check("no drive means no player, whatever the setting says", module.wanted({"DVD_ENABLED": "true"})["DVD"] is False) module.has_optical_drive = lambda: True check("a drive plus DVD_ENABLED=false is still no player", module.wanted({"DVD_ENABLED": "false"})["DVD"] is False) check("a drive plus DVD_ENABLED=true is the player", module.wanted({"DVD_ENABLED": "true"})["DVD"] is True) print("4. the script rewrites the layout the package actually ships") module.has_optical_drive = lambda: True def run(config): """Run the real script over a copy of the real layout.""" work = Path(tempfile.mkdtemp(prefix="classroom-layout-")) copy = work / "layout.js" shutil.copy(LAYOUT, copy) module.read_device_config = lambda: config module.main.__globals__["sys"].argv = ["classroom-desktop-layout", str(copy)] module.main() return copy.read_text(), work text, work = run({}) check("the shipped layout carries the feature block", module.MARKER in text) check("and an unconfigured machine keeps every application", "var WONDERINK = true;" in text and "var DVD = true;" in text and "var PARENT_CALL = true;" in text) print("5. the bar that comes out, evaluated in node") STUB = r""" const fs = require("fs"); const captured = {widgets: [], config: {}, launchers: []}; let current = null; function makeWidget(type) { const w = { type: type, currentConfigGroup: [], writeConfig: function (k, v) { captured.config[type + "." + k] = String(v); // Quick Launch widgets share a config key, so the last one // written would otherwise be the only one anybody could see. if (k === "launcherUrls") { captured.launchers.push(String(v)); } }, reloadConfig: function () {} }; captured.widgets.push(type); return w; } globalThis.desktops = () => [makeWidget("desktop")]; globalThis.Panel = function () { this.addWidget = makeWidget; return this; }; eval(fs.readFileSync(process.argv[2], "utf8")); console.log(JSON.stringify(captured)); """ def bar(config): text, work = run(config) stub = work / "stub.js" stub.write_text(STUB) result = subprocess.run( ["node", str(stub), str(work / "layout.js")], capture_output=True, text=True, timeout=60, ) if result.returncode != 0: return None, result.stderr return json.loads(result.stdout), "" if shutil.which("node") is None: print(" SKIP node is not installed; the layout was not evaluated") else: LAUNCH = "org.kde.plasma.icontasks.launchers" INK = "applications:ekklesia-wonderink.desktop" WEB = "applications:google-chrome.desktop" DISC = "applications:ekklesia-dvd.desktop" every, why = bar({}) check("a classroom gets Wonder Ink, the browser and the player, in order", every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}", (every or {}).get("config", {}).get(LAUNCH, why)) CALL = "file:///usr/share/applications/ekklesia-parent-call.desktop" HELP = "file:///usr/share/applications/ekklesia-manual.desktop" check("and Parent Call in the corner", every and CALL in every["launchers"], str((every or {}).get("launchers"))) check("and the unfinished Help launcher is hidden", every and HELP not in every["launchers"], str((every or {}).get("launchers"))) no_ink, why = bar({"WONDERINK_ENABLED": "false"}) check("a Bible study desktop has the browser first and no gap", no_ink and no_ink["config"].get(LAUNCH) == f"{WEB},{DISC}", (no_ink or {}).get("config", {}).get(LAUNCH, why)) module.has_optical_drive = lambda: False bible, why = bar({"WONDERINK_ENABLED": "false", "PARENT_CALL_ENABLED": "false", "DEVICE_TYPE": "laptop"}) check("the laptop's bar is the browser alone", bible and bible["config"].get(LAUNCH) == WEB, (bible or {}).get("config", {}).get(LAUNCH, why)) check("with no Parent Call button on it", bible and CALL not in bible["launchers"], str((bible or {}).get("launchers"))) # The source and launcher remain installed, but the unfinished digital # Help entry point is deliberately absent from the teacher-facing bar. check("and unfinished Help stays hidden", bible and HELP not in bible["launchers"], str((bible or {}).get("launchers"))) SLIDES = "applications:ekklesia-presentations.desktop" # THE DOCK ORDER IS THE THING NOT TO BREAK. # # Every existing room has Wonder Ink, the browser and the player in # that order, and teachers have been pressing them in that order for # months. Presentations is appended, so all three keep their place on # every machine that upgrades into this release. module.has_optical_drive = lambda: True slides, why = bar({"PRESENTATIONS_ENABLED": "true"}) check("Presentations goes on the END of the row, after the player", slides and slides["config"].get(LAUNCH) == f"{INK},{WEB},{DISC},{SLIDES}", (slides or {}).get("config", {}).get(LAUNCH, why)) without, why = bar({"PRESENTATIONS_ENABLED": "false"}) check("and is absent entirely when the device does not offer it", without and SLIDES not in without["config"].get(LAUNCH, ""), (without or {}).get("config", {}).get(LAUNCH, why)) check("which leaves the other three exactly where they were", without and without["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}", (without or {}).get("config", {}).get(LAUNCH, why)) # The prayer-room laptop: the browser and Presentations, nothing else. module.has_optical_drive = lambda: False study, why = bar({"PRESENTATIONS_ENABLED": "true", "WONDERINK_ENABLED": "false", "PARENT_CALL_ENABLED": "false", "DEVICE_TYPE": "laptop"}) check("the Bible study laptop gets the browser and Presentations", study and study["config"].get(LAUNCH) == f"{WEB},{SLIDES}", (study or {}).get("config", {}).get(LAUNCH, why)) check("and still no Parent Call on it", study and CALL not in study["launchers"], str((study or {}).get("launchers"))) check("and the status corner is untouched", bible and "org.kde.plasma.volume" in bible["widgets"] and "org.kde.plasma.digitalclock" in bible["widgets"] and "org.kde.plasma.networkmanagement" in bible["widgets"]) module.has_optical_drive = lambda: True ink_laptop, why = bar({"DEVICE_TYPE": "laptop"}) check("a laptop taking children's church gets the same bar as a tower", ink_laptop and ink_laptop["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}", (ink_laptop or {}).get("config", {}).get(LAUNCH, why)) print("6. the order survives an application opening") # 1, "manually", is the ONLY value at which the Icons-Only Task # Manager keeps a running application in its pinned position: # main.qml gates launchInPlace on sortingStrategy === 1, and for an # icons-only bar the launcher is always hidden once its application # starts. 0.2.4 set this to 0 and the browser jumped behind the disc # player the moment it opened. # # This assertion is the mechanism. The behaviour itself is checked # against real windows by tools/lab/order-check, because a # configuration test is exactly what 0.2.4 passed. check("sorting is manual, which is what keeps a launcher in place", every and every["config"].get( "org.kde.plasma.icontasks.sortingStrategy") == "1", str((every or {}).get("config", {}).get( "org.kde.plasma.icontasks.sortingStrategy"))) check("and launchers stay ahead of running windows", every and every["config"].get( "org.kde.plasma.icontasks.separateLaunchers") == "true") print("7. a disabled application does not start either") # Taking the launcher off the bar is half of it. The command itself has # to refuse, so a device that does not offer an application cannot be # talked into running it by a stale desktop entry or a technician's # muscle memory. for script, setting in (("classroom-wonderink", "WONDERINK_ENABLED"), ("classroom-parent-call", "PARENT_CALL_ENABLED"), ("classroom-presentations", "PRESENTATIONS_ENABLED"), ("classroom-presentation-import", "PRESENTATIONS_ENABLED")): text = (root / "scripts" / script).read_text() check(f"{script} refuses when {setting} is false", setting in text and "not starting" in text) check(f"{script} asks the shared helper rather than reading the file", "feature(" in text and "read_device_config()" in text) # And each of them defaults the way its own room needs, which is not the # same answer for all four. Asserting one rule for all of them would pass # for the wrong reason on the one that differs. from classroom_config import feature as _feature # noqa: E402 for setting, default, why in ( ("WONDERINK_ENABLED", True, "every existing classroom has it"), ("DVD_ENABLED", True, "every existing classroom has it"), ("PARENT_CALL_ENABLED", True, "every existing classroom has it"), ("PRESENTATIONS_ENABLED", False, "no existing classroom has it")): check(f"{setting} defaults to {default} — {why}", _feature({}, setting) is default) print() print(f"{passed} passed, {failed} failed") raise SystemExit(1 if failed else 0) PY