diff --git a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js index 660fb39..364deab 100644 --- a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js +++ b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js @@ -10,6 +10,23 @@ // applications, the right is the small amount of status a teacher needs. // Nothing else is on the screen. +// WHICH APPLICATIONS THIS DEVICE OFFERS. +// +// classroom-desktop-layout rewrites the three lines between the markers +// to match device.conf and this machine's hardware, and nothing else in +// this file. Each application is its own line because each is its own +// setting: a laptop taking children's church needs Wonder Ink and a tower +// used for Bible study does not, so the shape of the machine is never +// allowed to decide what it is for. +// +// All true here is the shipped default, which is what an existing +// classroom must come up as. +/* CLASSROOM-FEATURES-BEGIN */ +var WONDERINK = true; +var DVD = true; +var PARENT_CALL = true; +/* CLASSROOM-FEATURES-END */ + var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"; // Matches the 76px strip the classroom has always used, so muscle memory @@ -52,18 +69,20 @@ panel.opacity = "adaptive"; // lesson's tasks. // // Quick Launch reads the entry live and shows nothing but the button. -var parentCall = panel.addWidget("org.kde.plasma.quicklaunch"); -parentCall.currentConfigGroup = ["General"]; -// A file URL, not applications:. Quick Launch resolves the entry itself -// and does not copy it, so the path is a live reference to what the -// package installs. -parentCall.writeConfig( - "launcherUrls", - "file:///usr/share/applications/ekklesia-parent-call.desktop" -); -parentCall.writeConfig("showLauncherNames", false); -parentCall.writeConfig("enablePopup", false); -parentCall.writeConfig("maxSectionCount", 1); +if (PARENT_CALL) { + var parentCall = panel.addWidget("org.kde.plasma.quicklaunch"); + parentCall.currentConfigGroup = ["General"]; + // A file URL, not applications:. Quick Launch resolves the entry + // itself and does not copy it, so the path is a live reference to + // what the package installs. + parentCall.writeConfig( + "launcherUrls", + "file:///usr/share/applications/ekklesia-parent-call.desktop" + ); + parentCall.writeConfig("showLauncherNames", false); + parentCall.writeConfig("enablePopup", false); + parentCall.writeConfig("maxSectionCount", 1); +} var leftGap = panel.addWidget("org.kde.plasma.panelspacer"); leftGap.currentConfigGroup = ["General"]; @@ -73,21 +92,49 @@ leftGap.writeConfig("expanding", true); // story. This applet is the reason the classroom stops writing a dock. var tasks = panel.addWidget("org.kde.plasma.icontasks"); tasks.currentConfigGroup = ["General"]; -// Two of these three lines are rewritten by classroom-desktop-layout to -// match the machine, and each is marked so the edit is a line replacement -// and not a parse. +// THE LAUNCHERS, IN ONE FIXED ORDER. // -// The disc player follows the hardware: a room with no optical drive has -// it removed, so no teacher is offered a player that cannot exist. +// The order is written here and nowhere else, so the same set of enabled +// applications produces the same bar on every machine in the church. +// Anything switched off is left out of the list rather than blanked, so +// the remaining launchers close up and there is never a gap where a +// disabled application used to be. // -// Wonder Ink follows WONDERINK_ENABLED in device.conf: a device that is -// not a classroom — the laptop that goes out with a volunteer — has no -// lesson application, and the ordinary browser is unaffected either way. -tasks.writeConfig("launchers", [ - /* CLASSROOM-WONDERINK-LAUNCHER */ "applications:ekklesia-wonderink.desktop", - "applications:google-chrome.desktop", - /* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop" -].join(",")); +// The browser is always present. Wonder Ink and the disc player are +// answered above. +var launchers = []; + +if (WONDERINK) { + launchers.push("applications:ekklesia-wonderink.desktop"); +} + +launchers.push("applications:google-chrome.desktop"); + +if (DVD) { + launchers.push("applications:ekklesia-dvd.desktop"); +} + +tasks.writeConfig("launchers", launchers.join(",")); + +// THE ORDER STAYS PUT. +// +// 0 is "do not sort", and it is what stops a teacher dragging the +// launchers into a different order. Plasma's default is 1, "manually", +// and manual sorting is the only mode in which the task manager accepts a +// reordering drop at all — see MouseHandler.qml, which tests +// tasksModel.sortMode === SortManual before it will move anything. The +// classroom shipped without this line, so it had Plasma's default and the +// bar could be rearranged by anybody who dragged an icon. +// +// This is the supported lever rather than a patched task manager: the +// drop is refused by the applet's own code, not caught afterwards by +// something of ours putting the bar back. +tasks.writeConfig("sortingStrategy", 0); +// Launchers stay together at the start of the row and running windows +// appear after them, so opening or closing an application cannot move a +// pinned icon. This is Plasma's default; it is written down because the +// classroom depends on it. +tasks.writeConfig("separateLaunchers", true); // Every window gets its own button. Teachers should never have to open a // group to find out that a second window exists. tasks.writeConfig("groupingStrategy", 0); diff --git a/docs/screenshots/panel-0.2.4-features.png b/docs/screenshots/panel-0.2.4-features.png new file mode 100644 index 0000000..d248c2b Binary files /dev/null and b/docs/screenshots/panel-0.2.4-features.png differ diff --git a/lib/classroom_config.py b/lib/classroom_config.py index bb2b578..ec16190 100644 --- a/lib/classroom_config.py +++ b/lib/classroom_config.py @@ -73,6 +73,56 @@ MQTT_PASSWORD= # false takes the launcher off the panel and stops the command starting. # The ordinary browser, Parent Call and everything else are untouched. WONDERINK_ENABLED=true + +# What kind of machine this is. +# +# desktop a classroom tower. This is every existing room. +# laptop a machine with a lid and an internal panel. +# +# This says what the hardware IS. It decides nothing about which +# applications the device offers: a laptop running children's church needs +# Wonder Ink, and a desktop used for Bible study does not. Every +# application below is its own setting, on purpose, so that moving a +# machine between rooms is a matter of saying what it is for rather than +# what shape it is. +DEVICE_TYPE=desktop + +# Whether this device offers the disc player. +# +# The machine is asked as well: a room with no optical drive never shows +# the player whatever this says, because a button that cannot work is +# worse than no button. This setting is how a device that HAS a drive is +# told not to offer it anyway. +DVD_ENABLED=true + +# Whether this device offers Parent Call. +# +# A room full of children needs it. A Bible study does not, and the button +# in the corner is then one more thing to explain. +PARENT_CALL_ENABLED=true + +# Which network this device is meant to be on. +# +# wired an Ethernet cable is the intended connection. +# wifi this device is meant to be on Wi-Fi. Normal and expected; the +# room is not warned about a missing cable. +# +# This changes what the network indicator offers, never what +# NetworkManager is allowed to connect to. Wi-Fi hardware is not disabled +# on a wired device, and a wired device is not stopped from falling back. +NETWORK_PREFERENCE=wired + +# Which displays this device drives. +# +# all every connected display, which is what a tower with one +# television wants and what every existing room gets. +# external-only the external display only, with an internal laptop +# panel switched off. For a laptop installed as a fixed +# appliance with its lid shut. +# +# external-only is a setting rather than something inferred from having a +# lid, because another laptop may want its own screen or both. +DISPLAY_MODE=all """ @@ -127,6 +177,71 @@ UI_SCALE=1.0 # The ordinary browser, Parent Call and everything else are untouched. WONDERINK_ENABLED=true ''', + ), + ( + "DEVICE_TYPE", + """# What kind of machine this is. +# +# desktop a classroom tower. This is every existing room. +# laptop a machine with a lid and an internal panel. +# +# This says what the hardware IS. It decides nothing about which +# applications the device offers: a laptop running children's church needs +# Wonder Ink, and a desktop used for Bible study does not. Every +# application below is its own setting, on purpose, so that moving a +# machine between rooms is a matter of saying what it is for rather than +# what shape it is. +DEVICE_TYPE=desktop +""", + ), + ( + "DVD_ENABLED", + """# Whether this device offers the disc player. +# +# The machine is asked as well: a room with no optical drive never shows +# the player whatever this says, because a button that cannot work is +# worse than no button. This setting is how a device that HAS a drive is +# told not to offer it anyway. +DVD_ENABLED=true +""", + ), + ( + "PARENT_CALL_ENABLED", + """# Whether this device offers Parent Call. +# +# A room full of children needs it. A Bible study does not, and the button +# in the corner is then one more thing to explain. +PARENT_CALL_ENABLED=true +""", + ), + ( + "NETWORK_PREFERENCE", + """# Which network this device is meant to be on. +# +# wired an Ethernet cable is the intended connection. +# wifi this device is meant to be on Wi-Fi. Normal and expected; the +# room is not warned about a missing cable. +# +# This changes what the network indicator offers, never what +# NetworkManager is allowed to connect to. Wi-Fi hardware is not disabled +# on a wired device, and a wired device is not stopped from falling back. +NETWORK_PREFERENCE=wired +""", + ), + ( + "DISPLAY_MODE", + """# Which displays this device drives. +# +# all every connected display, which is what a tower with one +# television wants and what every existing room gets. +# external-only the external display only, with an internal laptop +# panel switched off. For a laptop installed as a fixed +# appliance with its lid shut. +# +# external-only is a setting rather than something inferred from having a +# lid, because another laptop may want its own screen or both. +DISPLAY_MODE=all +""", ), ( "MQTT_ENABLED", @@ -198,6 +313,52 @@ def ensure_device_config(path=None): path.write_text(text + separator + "\n\n".join(additions) + "\n") +DEVICE_TYPES = ("desktop", "laptop") +NETWORK_PREFERENCES = ("wired", "wifi") +DISPLAY_MODES = ("all", "external-only") + +# Every optional application, and what a device that has never heard of +# the setting should do. All true: an existing classroom upgrading to this +# release must come up exactly as it was, and every existing classroom has +# all of these. +FEATURES = { + "WONDERINK_ENABLED": True, + "DVD_ENABLED": True, + "PARENT_CALL_ENABLED": True, +} + + +def feature(config, name): + """Whether an optional application is offered on this device. + + Deliberately reads one key per application and never consults + DEVICE_TYPE. A laptop taking children's church needs Wonder Ink and a + tower used for Bible study does not, so shape cannot be allowed to + decide purpose — that rule is the whole reason these are separate + settings rather than a profile. + """ + return enabled(config, name, default=FEATURES.get(name, False)) + + +def _choice(config, key, allowed, default): + value = (config.get(key) or "").strip().lower() + + return value if value in allowed else default + + +def device_type(config): + """What the hardware is. Never what it is for.""" + return _choice(config, "DEVICE_TYPE", DEVICE_TYPES, "desktop") + + +def network_preference(config): + return _choice(config, "NETWORK_PREFERENCE", NETWORK_PREFERENCES, "wired") + + +def display_mode(config): + return _choice(config, "DISPLAY_MODE", DISPLAY_MODES, "all") + + def ui_scale(config): """UI_SCALE, clamped to something a classroom can actually use.""" raw = config.get("UI_SCALE", "") diff --git a/scripts/classroom-desktop-layout b/scripts/classroom-desktop-layout index 9251642..8a2afae 100755 --- a/scripts/classroom-desktop-layout +++ b/scripts/classroom-desktop-layout @@ -2,29 +2,33 @@ """Match the classroom's panel to the machine it is on. -Two of the bar's launchers depend on the machine rather than on the -release, and this edits the marked line for each of them in the installed -layout, and nothing else. +Which applications a device offers is decided here, once, and written into +the three feature lines of the installed layout. The panel is then built +from that layout at the next login. - the disc player a room with no optical drive should not be offered - one. The machine is asked; nothing is remembered. +Each application is its own question, and the shape of the machine is +never one of them: - Wonder Ink WONDERINK_ENABLED in device.conf. One future device - is a laptop that will not have Wonder Ink at all, - and a device that is not a classroom should not - carry a lesson application it cannot use. + Wonder Ink WONDERINK_ENABLED. A laptop taking children's church + needs it; a tower used for the women's Bible study does + not. DEVICE_TYPE is not consulted and must not be. -The two are deliberately answered differently. A drive is something a -machine can be asked about, and asking beats remembering to edit a file -when a computer moves between rooms. A licence is not, so it is a -setting. + Parent Call PARENT_CALL_ENABLED. Same rule. + + the disc DVD_ENABLED *and* a drive being present. The setting can + player take the player away from a machine that has a drive; + nothing can put it on a machine that has none, because a + button that cannot work is worse than no button. + +That last one is the only place hardware gets a vote, and it can only ever +remove a launcher. Everything else is configuration, so that moving a +machine between rooms is a matter of saying what it is for. Run before the display manager by ekklesia-classroom-layout.service, and again by udev when a drive is plugged in or pulled out. That ordering is the whole point: the panel is built from this layout the moment a session starts, and a room that is told about its hardware afterwards keeps the -wrong bar until the next login. It was being left to the provisioning -service, which runs at boot alongside the session rather than before it. +wrong bar until the next login. It writes the *layout*, not the running panel. Editing plasma-org.kde.plasma.desktop-appletsrc underneath a live plasmashell was @@ -32,7 +36,6 @@ tried and it destroyed the panel: the shell owns that file and rewrites it, so an edit races with it. The layout is a file the package owns and the shell only reads, and classroom-desktop-reset makes the shell read it again at the next login. - """ import glob @@ -46,18 +49,21 @@ sys.path.insert( os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), ) -from classroom_config import enabled, read_device_config # noqa: E402 +from classroom_config import feature, read_device_config # noqa: E402 LAYOUT = ( "/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop" "/contents/layouts/org.kde.plasma.desktop-layout.js" ) -DVD_MARKER = "/* CLASSROOM-DVD-LAUNCHER */" -DVD_ENTRY = "ekklesia-dvd.desktop" +# The variable in the layout, and the setting that answers it. +FEATURE_LINES = ( + ("WONDERINK", "WONDERINK_ENABLED"), + ("DVD", "DVD_ENABLED"), + ("PARENT_CALL", "PARENT_CALL_ENABLED"), +) -WONDERINK_MARKER = "/* CLASSROOM-WONDERINK-LAUNCHER */" -WONDERINK_ENTRY = "ekklesia-wonderink.desktop" +MARKER = "/* CLASSROOM-FEATURES-BEGIN */" # The kernel names optical drives sr0, sr1, ... regardless of bus. Both # views are checked: /dev is what anything opening the drive will use, and @@ -67,50 +73,52 @@ DRIVE_GLOB = "/dev/sr[0-9]*" SYSFS_GLOB = "/sys/block/sr[0-9]*" -def drives(): - return sorted(glob.glob(DRIVE_GLOB)), sorted(glob.glob(SYSFS_GLOB)) +def say(message): + print(f"classroom-desktop-layout: {message}") def has_optical_drive(): - nodes, blocks = drives() + nodes = sorted(glob.glob(DRIVE_GLOB)) + blocks = sorted(glob.glob(SYSFS_GLOB)) # Said out loud every time. When a room shows a disc player it has no # drive for, or hides one it has, this line in the journal is the # difference between an answer and an afternoon. - print("classroom-desktop-layout: /dev " - f"[{', '.join(nodes) or 'none'}], /sys/block " - f"[{', '.join(blocks) or 'none'}]") + say(f"/dev [{', '.join(nodes) or 'none'}], " + f"/sys/block [{', '.join(blocks) or 'none'}]") return bool(nodes and blocks) -def rewrite(text, marker, entry, wanted): - """Comment one marked launcher in or out, idempotently.""" +def wanted(config): + """What each feature line in the layout should say.""" + answers = {} + + for variable, setting in FEATURE_LINES: + answer = feature(config, setting) + say(f"{setting} [{config.get(setting, 'unset')}] -> {answer}") + answers[variable] = answer + + # A drive is the one thing the machine itself gets a vote on, and it + # is a veto: it can only ever take the player away. + if answers["DVD"] and not has_optical_drive(): + say("DVD_ENABLED is true but this machine has no drive") + answers["DVD"] = False + + return answers + + +def rewrite(text, variable, answer): + """Set one `var NAME = true;` line, idempotently.""" line = re.compile( - r"^(\s*)(//\s*)?(" + re.escape(marker) - + r'\s*"applications:' + re.escape(entry) + r'")', + r"^(\s*var\s+" + re.escape(variable) + r"\s*=\s*)(true|false)(\s*;)", re.M, ) - def swap(match): - indent, _, body = match.groups() - return f"{indent}{'' if wanted else '// '}{body}" - - return line.sub(swap, text) - - -def wonderink_wanted(): - """WONDERINK_ENABLED, defaulting to true. - - A classroom that has never heard of the setting is a classroom, so the - absent case is the one every existing room is in and it has to mean - yes. Said out loud for the same reason the drive is. - """ - config = read_device_config() - wanted = enabled(config, "WONDERINK_ENABLED", default=True) - print(f"classroom-desktop-layout: WONDERINK_ENABLED " - f"[{config.get('WONDERINK_ENABLED', 'unset')}]") - return wanted + return line.sub( + lambda m: f"{m.group(1)}{'true' if answer else 'false'}{m.group(3)}", + text, + ) def main(): @@ -123,24 +131,18 @@ def main(): print(f"classroom-desktop-layout: {error}", file=sys.stderr) return 0 - if DVD_MARKER not in before: - print("classroom-desktop-layout: no DVD marker in the layout", + if MARKER not in before: + print("classroom-desktop-layout: no feature block in the layout", file=sys.stderr) return 0 - after = rewrite(before, DVD_MARKER, DVD_ENTRY, has_optical_drive()) + after = before - if WONDERINK_MARKER in before: - after = rewrite( - after, WONDERINK_MARKER, WONDERINK_ENTRY, wonderink_wanted() - ) - else: - print("classroom-desktop-layout: no Wonder Ink marker in the layout", - file=sys.stderr) + for variable, answer in wanted(read_device_config()).items(): + after = rewrite(after, variable, answer) if after == before: - print("classroom-desktop-layout: the layout already matches " - "this machine") + say("the layout already matches this machine") return 0 # Replace the file rather than edit in place, so a full disk or a @@ -155,7 +157,7 @@ def main(): print(f"classroom-desktop-layout: {error}", file=sys.stderr) return 1 - print("classroom-desktop-layout: the layout now matches this machine") + say("the layout now matches this machine") return 0 diff --git a/scripts/classroom-desktop-reset b/scripts/classroom-desktop-reset index 5e9a2ec..308c00f 100755 --- a/scripts/classroom-desktop-reset +++ b/scripts/classroom-desktop-reset @@ -166,13 +166,17 @@ fi # THE DISC PLAYER, AND WHETHER THIS MACHINE HAS ONE. # -# The panel about to be built comes from the packaged layout, and the one -# line in it that depends on hardware is written by -# ekklesia-classroom-layout.service before the display manager starts. -# This cannot write /usr and does not try. It checks, because a room -# showing a disc player it has no drive for is a teacher clicking a button -# that can only disappoint, and the journal should say so at the moment -# the panel is built rather than after somebody notices. +# The panel about to be built comes from the packaged layout, and the +# feature lines in it are written by ekklesia-classroom-layout.service +# before the display manager starts. This cannot write /usr and does not +# try. It checks one direction only: a layout that offers a player on a +# machine with no drive is a teacher clicking a button that can only +# disappoint, and the journal should say so at the moment the panel is +# built rather than after somebody notices. +# +# The other direction is not a fault. A machine with a drive and +# DVD_ENABLED=false is a device that has been told not to offer the +# player, which is a supported thing to want. LAYOUT=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js if [[ -r "$LAYOUT" ]]; then @@ -182,15 +186,15 @@ if [[ -r "$LAYOUT" ]]; then drive=no fi - if grep -q '^\s*/\* CLASSROOM-DVD-LAUNCHER \*/' "$LAYOUT"; then + if grep -qE '^\s*var DVD = true;' "$LAYOUT"; then offered=yes else offered=no fi - if [[ "$drive" != "$offered" ]]; then - say "WARNING: this machine has drive=$drive but the layout offers" - say " the disc player=$offered — run classroom-desktop-layout" + if [[ "$offered" == yes && "$drive" == no ]]; then + say "WARNING: the layout offers the disc player but this machine" + say " has no drive — run classroom-desktop-layout" fi fi diff --git a/tests/test-classroom-config.sh b/tests/test-classroom-config.sh index e911042..0ddb441 100755 --- a/tests/test-classroom-config.sh +++ b/tests/test-classroom-config.sh @@ -27,8 +27,19 @@ def check(label, condition, detail=""): print("1. the room no longer keeps a list of dock buttons") check("dock_items is gone with the dock it described", not hasattr(__import__("classroom_config"), "dock_items")) -check("and DVD_ENABLED with it, because the machine can be asked", - "DVD_ENABLED" not in __import__("classroom_config").DEVICE_CONFIG_TEMPLATE) +# DVD_ENABLED is back, and it is not the setting that was removed with the +# dock. That one was the whole answer; this one is a veto an administrator +# holds over a machine that HAS a drive, and the hardware is still asked. +# A Bible study on a tower with a drive in it does not want a disc player. +check("DVD_ENABLED is a setting again, alongside asking the machine", + "DVD_ENABLED" in __import__("classroom_config").DEVICE_CONFIG_TEMPLATE) +check("and every optional application has one of its own", + all(key in __import__("classroom_config").DEVICE_CONFIG_TEMPLATE + for key in ("WONDERINK_ENABLED", "DVD_ENABLED", + "PARENT_CALL_ENABLED"))) +check("while DEVICE_TYPE decides none of them", + __import__("classroom_config").feature( + {"DEVICE_TYPE": "laptop"}, "WONDERINK_ENABLED") is True) print("2. fresh device configuration") with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test-classroom-desktop-layout.sh b/tests/test-classroom-desktop-layout.sh index 702e592..c9c3599 100755 --- a/tests/test-classroom-desktop-layout.sh +++ b/tests/test-classroom-desktop-layout.sh @@ -1,10 +1,21 @@ #!/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 @@ -16,6 +27,8 @@ 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 @@ -31,115 +44,172 @@ def check(label, condition, detail=""): failed += 1 -WITH = '''tasks.writeConfig("launchers", [ - /* CLASSROOM-WONDERINK-LAUNCHER */ "applications:ekklesia-wonderink.desktop", - "applications:google-chrome.desktop", - /* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop" -].join(","));''' +BLOCK = """/* CLASSROOM-FEATURES-BEGIN */ +var WONDERINK = true; +var DVD = true; +var PARENT_CALL = true; +/* CLASSROOM-FEATURES-END */""" -def dvd(text, wanted): - return module.rewrite(text, module.DVD_MARKER, module.DVD_ENTRY, wanted) +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)) -def ink(text, wanted): - return module.rewrite( - text, module.WONDERINK_MARKER, module.WONDERINK_ENTRY, wanted +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) + +check("an existing room that has never heard of these keeps them all", + all(module.wanted({}).values()), str(module.wanted({}))) + + +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: {}}; +let current = null; +function makeWidget(type) { + const w = { + type: type, + currentConfigGroup: [], + writeConfig: function (k, v) { captured.config[type + "." + k] = 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), "" -WITHOUT = WITH.replace(' /* CLASSROOM-DVD-LAUNCHER */', - ' // /* CLASSROOM-DVD-LAUNCHER */') -NO_INK = WITH.replace(' /* CLASSROOM-WONDERINK-LAUNCHER */', - ' // /* CLASSROOM-WONDERINK-LAUNCHER */') +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" -print("1. the DVD launcher follows the hardware") -check("a machine with a drive keeps it", dvd(WITH, True) == WITH) -check("a machine without a drive loses it", - dvd(WITH, False) == WITHOUT, dvd(WITH, False)) -check("and gets it back when a drive appears", dvd(WITHOUT, True) == WITH) -check("removing twice changes nothing the second time", - dvd(dvd(WITH, False), False) == WITHOUT) -check("adding twice changes nothing the second time", - dvd(dvd(WITHOUT, True), True) == WITH) + 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)) + check("and Parent Call in the corner", + every and "org.kde.plasma.quicklaunch" in every["widgets"]) -print() -print("2. Wonder Ink follows the device setting") -check("a device with Wonder Ink keeps it", ink(WITH, True) == WITH) -check("a device without it loses the launcher", - ink(WITH, False) == NO_INK, ink(WITH, False)) -check("and gets it back when the setting changes", - ink(NO_INK, True) == WITH) -check("turning it off twice changes nothing the second time", - ink(ink(WITH, False), False) == NO_INK) -# The line carries a trailing comma, so a comment that stopped short of it -# would leave the array with a hole in it. -check("the comma goes with the line", - '// /* CLASSROOM-WONDERINK-LAUNCHER */ "applications:' - 'ekklesia-wonderink.desktop",' in ink(WITH, False)) -check("the browser is untouched either way", - "applications:google-chrome.desktop" in ink(WITH, False)) -check("and so is the disc player", - 'CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"' - in ink(WITH, False)) + 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)) -print() -print("3. the two settings are independent") -neither = ink(dvd(WITH, False), False) -check("a laptop with no drive and no Wonder Ink keeps only the browser", - neither.count("// /* CLASSROOM") == 2 - and "applications:google-chrome.desktop" in neither) -check("and comes all the way back", - ink(dvd(neither, True), True) == WITH) + 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 "org.kde.plasma.quicklaunch" not in bible["widgets"], + str((bible or {}).get("widgets"))) + 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"]) -print() -print("4. nothing else in the layout is touched") -other = WITH + "\npanel.addWidget(\"org.kde.plasma.digitalclock\");\n" -check("a line without the marker is left alone", - dvd(other, False).endswith('digitalclock");\n')) -check("the other launchers survive", - "ekklesia-wonderink.desktop" in dvd(WITH, False) - and "google-chrome.desktop" in dvd(WITH, False)) + 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() -print("5. the real layout carries both markers") -layout = (root / "config/plasma/lookandfeel/contents/layouts" - / "org.kde.plasma.desktop-layout.js").read_text() -check("exactly one DVD marker on a launcher line", - layout.count(module.DVD_MARKER + ' "applications:') == 1) -check("exactly one Wonder Ink marker on a launcher line", - layout.count(module.WONDERINK_MARKER + ' "applications:') == 1) -check("the shipped layout is one a drive-less machine can be given", - dvd(layout, False) != layout) -check("and one a Wonder-Ink-less device can be given", - ink(layout, False) != layout) - -print() -print("6. the setting defaults to yes") -check("an unset WONDERINK_ENABLED means the room has Wonder Ink", - module.enabled({}, "WONDERINK_ENABLED", default=True)) -for value in ("true", "1", "yes", "on"): - check(f"{value} is on", - module.enabled({"WONDERINK_ENABLED": value}, - "WONDERINK_ENABLED", default=True)) -for value in ("false", "0", "no", "off", "banana"): - check(f"{value} is off", - not module.enabled({"WONDERINK_ENABLED": value}, - "WONDERINK_ENABLED", default=True)) - -print() -print("7. a missing or unmarked layout is not an error") -with tempfile.TemporaryDirectory() as work: - empty = Path(work) / "none.js" - check("a layout that does not exist exits cleanly", - module.main.__code__ is not None) - unmarked = Path(work) / "plain.js" - unmarked.write_text("panel.addWidget('x');\n") - sys.argv = ["classroom-desktop-layout", str(unmarked)] - check("an unmarked layout is left alone", module.main() == 0) - check("and unchanged", unmarked.read_text() == "panel.addWidget('x');\n") - sys.argv = ["classroom-desktop-layout", str(Path(work) / "missing.js")] - check("a missing layout is not a failure", module.main() == 0) + print("6. the bar cannot be rearranged by dragging") + check("sorting is off, which is what refuses a reordering drop", + every and every["config"].get( + "org.kde.plasma.icontasks.sortingStrategy") == "0", + 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() print(f"{passed} passed, {failed} failed") diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index ccbd098..5612f30 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -343,8 +343,13 @@ check("started at every boot", "WantedBy=graphical.target" in layout_unit) check("enabled by the package", "enable ekklesia-classroom-layout.service" in postinst) check("a drive plugged in later is noticed", 'ENV{ID_CDROM}=="1"' in udev) check("and the rules are reloaded on install", "udevadm control --reload-rules" in postinst) -check("the login says so when the layout and the hardware disagree", - "the disc player=" in reset) +# One direction only. A layout offering a player on a machine with no +# drive is a button that cannot work; a machine with a drive and +# DVD_ENABLED=false is an administrator's choice and not a fault. +check("the login says so when a player is offered with no drive", + "has no drive" in reset) +check("and reads the layout's own feature line to decide", + "var DVD = true" in reset) print() print("7c. the room's own popups")