Files
Classroom_Desktop/tests/test-classroom-migrate-config.sh
T
ekklesiaandClaude Opus 5 8db5e8dd9f release: the Plasma classroom becomes stable 0.2.0
0.1.50 was taken to the church and the things a container cannot answer
were answered on the hardware: boot, autologin, the panel after a reboot,
panel and browser scaling, Wonder Ink, Parent Call opening in the corner
and reaching the sanctuary with a typed name, readable and silent
notifications, HDMI audio and the volume keys.

The promotion is a fast-forward. The experiment was cut from the
published stable source and never diverged from it, so master reaches
this work without a merge and there is one history.

One thing changed for the release. The channel rules turn around:
classroom-v2 and hyprland are the obsolete channels now and a room on
either comes home to stable, while canary — where the next development
release goes — and any channel somebody named by hand stay where they
were put. A fresh installation has always defaulted to stable.

Nothing else moved. This is the 0.1.50 that was verified, with a version
number and a channel rule.

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

229 lines
7.3 KiB
Bash
Executable File

#!/bin/bash
set -uo pipefail
# What an upgrade does to a room's own settings file.
#
# device.conf is the one file in the classroom that belongs to the church
# rather than to the package: the room's name, and the broker password.
# An upgrade edits it, so an upgrade can also ruin it. These are the cases
# that matter, run against the real code — the migration's own Python is
# lifted out of the script rather than copied here, so a change to one
# cannot pass by disagreeing with the other.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python3 - "$ROOT" <<'PY'
import re
import subprocess
import sys
import tempfile
from pathlib import Path
root = Path(sys.argv[1])
sys.path.insert(0, str(root / "lib"))
from classroom_config import ( # noqa: E402
DEVICE_CONFIG_SECTIONS,
ensure_device_config,
)
script = (root / "scripts/classroom-migrate").read_text()
block = re.search(r'python3 - "\$DEVICE_CONFIG" <<\'PY\'\n(.*?)\nPY\n',
script, re.S)
passed = failed = 0
def check(label, condition):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
failed += 1
check("the migration's device.conf code was found", block is not None)
if block is None:
sys.exit(1)
migration = block.group(1)
def migrate(text, shared=False):
"""Run the real migration over a device.conf and return it.
With shared=True, the shared helper runs afterwards as well, which is
the whole sequence classroom-migrate performs.
"""
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "migrate.py"
source.write_text(migration)
config = Path(directory) / "device.conf"
config.write_text(text)
result = subprocess.run(
[sys.executable, str(source), str(config)],
capture_output=True, text=True, check=False,
)
if shared:
ensure_device_config(config)
return config.read_text(), result.stdout + result.stderr
print("1. a room from the Hyprland experiment")
BEFORE = """# Ekklesia Classroom device configuration
UPDATE_URL=https://updates.riverofglorymn.org
CHANNEL=hyprland
LIVE_ENABLED=false
AUDIO_OUTPUT=auto
UI_SCALE=2.0
ROOM=little-waders
MQTT_ENABLED=false
MQTT_HOST=
MQTT_PORT=1883
MQTT_USERNAME=classroom
MQTT_PASSWORD=hunter2-not-a-real-one
COMPOSITOR=hyprland
HYPR_APPEARANCE=visual
"""
after, output = migrate(BEFORE)
check("the broker is switched on", "MQTT_ENABLED=true" in after)
check("and given the church address", "MQTT_HOST=10.40.0.2" in after)
check("the port is left as it was", "MQTT_PORT=1883" in after)
check("the room keeps its name", "ROOM=little-waders" in after)
check("the username is untouched", "MQTT_USERNAME=classroom" in after)
check("the password is untouched",
"MQTT_PASSWORD=hunter2-not-a-real-one" in after)
check("and is never printed", "hunter2" not in output)
check("the compositor setting is gone", "COMPOSITOR" not in after)
check("and the appearance setting with it", "HYPR_APPEARANCE" not in after)
check("the channel comes home to stable", "CHANNEL=stable" in after)
check("the room's scale is preserved", "UI_SCALE=2.0" in after)
check("and everything else is left alone",
"UPDATE_URL=https://updates.riverofglorymn.org" in after
and "AUDIO_OUTPUT=auto" in after)
print()
print("2. a room already pointed at a broker of its own")
OWN = """CHANNEL=a-channel-somebody-named
MQTT_ENABLED=true
MQTT_HOST=broker.example.invalid
MQTT_PORT=8883
MQTT_PASSWORD=leave-me
"""
after, output = migrate(OWN)
check("a deliberate address is not overwritten",
"MQTT_HOST=broker.example.invalid" in after)
check("nor a deliberate port", "MQTT_PORT=8883" in after)
check("a channel nobody here named is left alone",
"CHANNEL=a-channel-somebody-named" in after)
check("and its password is still there", "MQTT_PASSWORD=leave-me" in after)
print()
print("3. a room old enough to have no broker settings at all")
OLD = """CHANNEL=canary
UI_SCALE=1.0
"""
after, output = migrate(OLD)
check("the settings are added", "MQTT_ENABLED=true" in after
and "MQTT_HOST=10.40.0.2" in after and "MQTT_PORT=1883" in after)
check("no credentials are invented",
"MQTT_USERNAME" not in after and "MQTT_PASSWORD" not in after)
# canary is where the next development release goes. A room somebody put
# there is a room somebody meant to put there.
check("canary is left where it is", "CHANNEL=canary" in after)
after, _ = migrate("CHANNEL=classroom-v2\nUI_SCALE=1.0\n")
check("and the experiment's own channel comes home",
"CHANNEL=stable" in after)
print()
print("4. running it twice changes nothing the second time")
once, _ = migrate(BEFORE)
twice, _ = migrate(once)
check("the second run is a no-op", once == twice)
print()
print("5. a room old enough to be missing settings this release has")
# The migration knows nothing about which settings the classroom has
# gained since a room was installed, and must not: that list lives in
# classroom_config.py. What it has to do is hand the file to the shared
# helper afterwards, so a machine converges whether it arrived through the
# package's postinst or through somebody running the command by hand.
AGED = """# A room set up a long time ago. Keep this comment.
UPDATE_URL=https://updates.riverofglorymn.org
CHANNEL=a-channel-somebody-named
COMPOSITOR=hyprland
MQTT_ENABLED=false
MQTT_HOST=
MQTT_USERNAME=classroom
MQTT_PASSWORD=preserve-me
CUSTOM_SETTING=preserve-me-too
"""
after, output = migrate(AGED, shared=True)
missing = [key for key, _ in DEVICE_CONFIG_SECTIONS]
check("there are settings for this to be about", len(missing) >= 3)
for key in ("LIVE_ENABLED", "AUDIO_OUTPUT", "UI_SCALE"):
check(f"{key} is appended", f"\n{key}=" in "\n" + after)
check(f"{key} exactly once",
len(re.findall(rf"^{key}=", after, re.M)) == 1)
check("every setting this release knows about is present",
all(re.search(rf"^{key}=", after, re.M) for key in missing))
check("the room's comment survives",
"# A room set up a long time ago. Keep this comment." in after)
check("the custom setting survives", "CUSTOM_SETTING=preserve-me-too" in after)
check("the password survives", "MQTT_PASSWORD=preserve-me" in after)
check("and is still never printed", "preserve-me" not in output)
check("the username survives", "MQTT_USERNAME=classroom" in after)
check("the deliberate channel is still deliberate",
"CHANNEL=a-channel-somebody-named" in after)
check("the migration still did its own work",
"COMPOSITOR" not in after
and "MQTT_ENABLED=true" in after
and "MQTT_HOST=10.40.0.2" in after)
twice, _ = migrate(after, shared=True)
check("and running the whole thing again changes nothing", twice == after)
print()
print("6. the defaults live in one place")
check("the migration does not carry a copy of them",
not any(key in migration
for key, _ in DEVICE_CONFIG_SECTIONS
if key not in ("MQTT_ENABLED",)))
check("the script calls the shared helper",
"ensure_device_config" in script)
check("after its own migration, not before",
script.index("ensure_device_config")
> script.index("OBSOLETE_CHANNELS"))
check("with the file it was given",
"ensure_device_config(sys.argv[1])" in script)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY