318 lines
13 KiB
Bash
Executable File
318 lines
13 KiB
Bash
Executable File
#!/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
|