Files
room_glitch/tests/room_network/test_remote_start.py
T
Jeff Luke c1b2195834 Expand Glitch operations hub, cabinet authoring, deployment, and RFID support
Operator hub
- Introduce Escape Gallery branding and Glitch: the Dark Machine room views
- Add cabinet illustrations, Pac-Man marquee/gameplay artwork, and waiting states
- Add device feedback panels with RFID readings, input/output states, and stale status
- Add logged operator hardware interventions with request deduplication

Room and cabinet authoring
- Add a visual custom-PCB connection editor with per-cabinet device inventory
- Support device IDs, bindings, wiring notes, input polarity, and validation
- Add physical-room label/dimension toggles and editable doorway annotations
- Preserve doorway changes through undo, save, and revert
- Expand cabinet profiles, room layout, and Glitch scenario resources

Scenarios and gameplay
- Add per-assignment game configuration and runtime feature validation
- Add timed test wins and Frogger individual-home objectives
- Add the three-cabinet timed/score/Frogger test scenario
- Add Cabinet 1 Pac-Man RFID credits, credit sound, and CREDIT 0/1 display
- Require physical Start after an accepted scan and prevent duplicate credits

Pi hardware and deployment
- Add GPIO feedback sampling and a separate multiplexed RFID helper
- Validate reader pin ownership and input/output conflicts
- Add SSH cabinet startup and Pi desktop-session service support
- Add master packaging, site setup, SSH enrollment, and staged cabinet releases
- Verify deployment manifests and support explicit activation/status/log commands

Documentation and verification
- Consolidate the root README around the current architecture and setup workflow
- Add the complete master/Pi/RFID export and deployment walkthrough
- Document network isolation, credentials, card identification, and rollback
- Add PCB, room-editor, feedback, credit, deployment, and controller tests
- Isolate protocol tests from designer-edited cabinet configurations

Known limitations
- RFID helper compilation and physical Pi commissioning remain unverified
- Fleet provisioning does not yet configure RFID-only installations completely
- Broader Pac-Man tests retain a ghost-capture assertion failure
- Staged exports must be rebuilt and checked before deploying runtime 2.3
2026-10-17 14:53:54 -07:00

139 lines
7.7 KiB
Python

import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import io
from contextlib import redirect_stdout
import unittest
from unittest.mock import patch
from test_room import ROOT
def module(name):
spec=importlib.util.spec_from_file_location(name,ROOT/'tools/launch'/f'{name}.py')
loaded=importlib.util.module_from_spec(spec)
spec.loader.exec_module(loaded)
return loaded
remote=module('remote_cabinets')
service=module('pi_service')
class RemoteTests(unittest.TestCase):
def config(self,folder,items,enabled=True):
path=Path(folder)/'launcher.ini'
path.write_text('[remote]\nenabled = '+str(enabled)+'\ncabinets = '+json.dumps(items))
return path
def test_opt_in_and_cabinet_mode_do_not_start_remotes(self):
self.assertEqual(remote.load_targets('missing.ini','cabinet'),[])
with tempfile.TemporaryDirectory() as folder:
path=self.config(folder,[],False)
self.assertEqual(remote.load_targets(path,'controller'),[])
def test_safe_idempotent_command(self):
with tempfile.TemporaryDirectory() as folder,patch.object(remote.shutil,'which',return_value='ssh'):
path=self.config(folder,[{'id':'cabinet_01','host':'10.0.0.12','user':'arcade'}])
target=remote.load_targets(path,'controller')[0]
self.assertEqual(target['command'][-5:],['arcade@10.0.0.12','systemctl','--user','start','arcade-cabinet.service'])
self.assertIn('StrictHostKeyChecking=yes',target['command'])
self.assertIn('BatchMode=yes',target['command'])
self.assertNotIn('restart',target['command'])
def test_invalid_hosts_and_duplicates_rejected(self):
with tempfile.TemporaryDirectory() as folder,patch.object(remote.shutil,'which',return_value='ssh'):
for host in ['-oProxyCommand=bad','host;reboot','user@host','host name']:
with self.assertRaises(ValueError):
remote.load_targets(self.config(folder,[{'id':'cabinet_01','host':host,'user':'arcade'}]),'controller')
item={'id':'cabinet_01','host':'pi-one','user':'arcade'}
with self.assertRaisesRegex(ValueError,'Duplicate'):
remote.load_targets(self.config(folder,[item,item]),'controller')
def test_ssh_failure_is_reported_not_retried(self):
target={'id':'cabinet_01','command':['ssh','example']}
with patch.object(remote.subprocess,'run',return_value=subprocess.CompletedProcess([],255,'','Permission denied')) as run:
cabinet,ok,message=remote.start_one(target)
self.assertFalse(ok)
self.assertIn('Permission denied',message)
run.assert_called_once()
with patch.object(remote.subprocess,'run',side_effect=subprocess.TimeoutExpired('ssh',15)):
self.assertFalse(remote.start_one(target)[1])
def test_service_has_desktop_environment_and_no_restart_loop(self):
unit=service.render_unit('/usr/bin/python3','/home/arcade/room/tools/launch/launch.py','/home/arcade/room/tools/launch/pi.ini')
self.assertIn('EnvironmentFile=%t/arcade-cabinet-display.env',unit)
self.assertIn('Restart=no',unit)
self.assertIn('KillMode=mixed',unit)
self.assertIn('UnsetEnvironment=ARCADE_ROOM_URL',unit)
self.assertNotIn('[Install]',unit)
self.assertIn('%%',service.quoted('/folder/100%/file'))
self.assertIn('refresh-session',service.render_desktop('/usr/bin/python3','/home/arcade/pi_service.py'))
def test_working_directory_is_not_shell_quoted(self):
launcher = Path('/home/arcade/room with spaces/100%/tools/launch/launch.py')
unit = service.render_unit('/usr/bin/python3', launcher, '/tmp/pi.ini')
line = next(line for line in unit.splitlines() if line.startswith('WorkingDirectory='))
expected = str(launcher.resolve().parents[2]).replace('%', '%%')
self.assertEqual(line, 'WorkingDirectory=' + expected)
self.assertEqual(service.working_directory('/home/arcade/room with spaces'),
'/home/arcade/room with spaces')
with self.assertRaises(ValueError):
service.working_directory('/home/arcade/room\nRestart=always')
def test_installer_checks_systemd_load_state(self):
for state in ('bad-setting', 'error', 'not-found'):
with self.subTest(state=state), patch.object(service.subprocess, 'run',
return_value=subprocess.CompletedProcess([], 0, state + '\n', '')):
with self.assertRaisesRegex(ValueError, 'systemd rejected'):
service.reload_service()
with patch.object(service.subprocess, 'run',
return_value=subprocess.CompletedProcess([], 0, 'loaded\n', '')) as run:
service.reload_service()
self.assertEqual(run.call_count, 2)
self.assertIn('--property=LoadState', run.call_args.args[0])
def test_remote_install_waits_for_desktop_without_guessing_display(self):
import sys
with tempfile.TemporaryDirectory() as folder:
from types import SimpleNamespace
with patch.object(service.sys, 'argv', ['pi_service.py', 'install', '--remote']), \
patch.object(service.sys, 'platform', 'linux'), \
patch.object(service.os, 'getuid', return_value=1000, create=True), \
patch.dict(service.os.environ, {}, clear=True), \
patch.dict(sys.modules, {'launch': SimpleNamespace(plan=lambda *args: None)}), \
patch.object(service.Path, 'home', return_value=Path(folder)), \
patch.object(service, 'reload_service'), patch.object(service, 'refresh_session') as refresh:
self.assertEqual(service.main(), 0)
refresh.assert_not_called()
self.assertTrue((Path(folder) / '.config/systemd/user/arcade-cabinet.service').exists())
self.assertTrue((Path(folder) / '.config/autostart/arcade-cabinet-display.desktop').exists())
def test_installer_refuses_unrelated_files(self):
with tempfile.TemporaryDirectory() as folder:
target=Path(folder)/'service'
target.write_text('User-owned settings')
with self.assertRaisesRegex(ValueError,'unrelated'):
service.write_managed(target,'replacement')
self.assertEqual(target.read_text(),'User-owned settings')
def test_online_confirmation_is_separate_from_ssh_success(self):
target={'id':'cabinet_01','host':'pi-one','command':['ssh']}
controller={'ready':'http://127.0.0.1:8766/','env':{'ARCADE_OPERATOR_TOKEN':'test-token'}}
response=io.BytesIO(json.dumps({'cabinets':{'cabinet_01':{'online':True}}}).encode())
output=io.StringIO()
with patch.object(remote,'start_one',return_value=('cabinet_01',True,'accepted')),patch.object(remote.urllib.request,'urlopen',return_value=response),redirect_stdout(output):
remote.start_and_check([target],controller)
self.assertIn('SSH OK',output.getvalue())
self.assertIn('ONLINE in room controller',output.getvalue())
self.assertNotIn('test-token',output.getvalue())
def test_offline_target_does_not_raise_or_stop_controller(self):
target={'id':'cabinet_01','host':'pi-one','command':['ssh']}
controller={'ready':'http://127.0.0.1:8766/','env':{'ARCADE_OPERATOR_TOKEN':'test-token'}}
output=io.StringIO()
with patch.object(remote,'start_one',return_value=('cabinet_01',False,'unreachable')),patch.object(remote.time,'monotonic',side_effect=[0,16]),redirect_stdout(output):
remote.start_and_check([target],controller)
self.assertIn('Controller remains running',output.getvalue())