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
139 lines
7.7 KiB
Python
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())
|