Files
Classroom_Desktop/tests/test-classroom-desktop-layout.sh
T
ekklesiaandClaude Opus 5 a31fa75d55 fix: the bar keeps its order when an application opens
On the laptop the browser jumped behind the disc player the moment it was
opened. 0.2.4 caused that, and the cause is two lines of the stock applet:

    main.qml:153  hideActivatedLaunchers: tasks.iconsOnly || ...
    main.qml:155  launchInPlace: tasks.iconsOnly && sortingStrategy === 1

For an icons-only bar the first is always true — a launcher is hidden the
instant its application starts — and the second is what puts the running
window back where the launcher was. It only does that at sortingStrategy
1. 0.2.4 set it to 0 to stop teachers dragging the icons about, so the
launcher vanished and the window was appended after the others.

Reproduced in the lab with a real Chrome window before changing anything,
and the bar read off the screen: Wonder Ink, the player, then the
browser. With 1 it is Wonder Ink, the browser, the player, through
opening, a second window, another application opening beside it, and
every close.

The launcher identity was suspected and cleared: only one browser button
appears, because google-chrome.desktop already carries Chrome's own file
name and StartupWMClass for exactly this reason.

THE COST, AND IT IS REAL. Manual sorting is also the only mode in which
MouseHandler.qml accepts a reordering drop, so a teacher who knows to drag
can rearrange the bar again. There is no value that gives both and no
Kiosk key that separates them. classroom-desktop-reset already rebuilds
the bar from the package at every login, so a drag never outlives the
session. docs/testing.md states the trade rather than burying it, and the
configuration escape is written down there too as still open.

tools/lab/order-check is the test this needed and did not have. 0.2.4
passed a full set of configuration assertions while the bar did something
else, so this one opens real application windows and reads the panel
strip as pixels — there is no image library in the lab, so it captures
PPM and names each icon by colour. Six checks, all of them behaviour.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 19:34:15 -05:00

241 lines
9.2 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)
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), ""
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))
check("and Parent Call in the corner",
every and "org.kde.plasma.quicklaunch" in every["widgets"])
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 "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"])
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")):
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} defaults to starting, so existing rooms are unaffected",
'default=True' in text or 'feature(' in text)
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY