Files
room_glitch/tests/room_network/test_fleet.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

150 lines
7.7 KiB
Python

import json
import tempfile
import sqlite3
from pathlib import Path
import threading
import urllib.request
import urllib.error
from http.server import HTTPServer
from test_room import ROOT, server
import unittest
class FleetTests(unittest.TestCase):
def setUp(self):
self.now = 100
# Fixed historical test room: designer edits must not alter protocol assertions.
self.catalog = json.loads(Path(__file__).with_name('catalog_fixture.json').read_text(encoding='utf-8'))
self.fleet = server.Fleet(':memory:', self.catalog, lambda: self.now)
for cabinet in self.fleet.rooms: self.poll(cabinet)
def tearDown(self):
for room in self.fleet.rooms.values(): room.db.close()
def poll(self, cabinet, reports=None):
return self.fleet.poll(dict(cabinet=cabinet, protocol=2, boot=cabinet, reports=reports or []))
def ack(self, cabinet):
commands = self.poll(cabinet)['commands']
self.poll(cabinet, [dict(kind='ack', id=c['id'], session=c['session'], ok=True) for c in commands])
def test_scenario_routes_games_and_events_to_correct_cabinets(self):
self.fleet.operator('prepare_scenario', {'scenario': 'two_cabinet_test'})
self.assertEqual(self.poll('cabinet_01')['commands'][0]['game'], 'pacman')
self.assertEqual(self.poll('cabinet_02')['commands'][0]['game'], 'galaga')
self.assertFalse(self.poll('cabinet_03')['commands'])
self.ack('cabinet_01')
self.assertEqual(self.fleet.rooms['cabinet_02'].state['phase'], 'preparing')
self.ack('cabinet_02')
self.assertEqual(self.poll('cabinet_02')['commands'][0]['action_id'], 'glitch_code')
self.assertFalse(self.poll('cabinet_01')['commands'])
def test_offline_assignment_prevents_any_scenario_dispatch(self):
self.now += 5
self.poll('cabinet_01')
with self.assertRaisesRegex(ValueError, 'offline'):
self.fleet.operator('prepare_scenario', {'scenario': 'two_cabinet_test'})
self.assertFalse(self.fleet.rooms['cabinet_01'].state['commands'])
def test_manual_assignment_does_not_inherit_reward(self):
self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'galaga'})
self.ack('cabinet_01')
room = self.fleet.rooms['cabinet_01']
self.poll('cabinet_01', [dict(kind='event', id=event, event=event, session=room.state['session']) for event in ['started', 'won']])
self.assertEqual(room.state['phase'], 'won')
self.assertFalse(self.poll('cabinet_01')['commands'])
def test_compatibility_rejects_missing_controls_and_accepts_alternatives(self):
# Explicit fixture: actual cabinet hardware can evolve independently.
self.catalog['cabinets']['cabinet_01']['capabilities'] = ['joystick_4way', 'button_1', 'start_button']
self.catalog['cabinets']['cabinet_03']['capabilities'] = ['spinner', 'button_1', 'start_button']
with self.assertRaisesRegex(ValueError, 'hardware'):
self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'missile_command'})
cabinet = self.catalog['cabinets']['cabinet_03']
self.assertFalse(self.fleet.compatible('cabinet_03', 'tempest'))
cabinet['capabilities'].append('button_2')
self.assertTrue(self.fleet.compatible('cabinet_03', 'tempest'))
cabinet['capabilities'].remove('spinner')
self.assertFalse(self.fleet.compatible('cabinet_03', 'tempest'))
cabinet['capabilities'].append('trackball')
self.assertTrue(self.fleet.compatible('cabinet_03', 'tempest'))
def test_old_export_cannot_silently_load_wrong_game(self):
with self.assertRaisesRegex(ValueError, 'protocol 2'):
self.fleet.poll(dict(cabinet='cabinet_01', protocol=1, boot='old'))
def test_invalid_mapping_does_not_dispatch(self):
self.catalog['scenarios']['two_cabinet_test']['assignments'][1]['mappings']['unknown_event'] = ['coin_release']
with self.assertRaisesRegex(ValueError, 'not implemented'):
self.fleet.operator('prepare_scenario', {'scenario': 'two_cabinet_test'})
self.assertFalse(self.poll('cabinet_01')['commands'])
def test_output_timeout_does_not_report_a_reward(self):
room = self.fleet.rooms['cabinet_01']
room.command('output')
self.now += 16
room.expire()
self.assertEqual(room.state['reward'], 'not requested')
self.assertIn('unknown', room.state['last_output'])
def test_one_accepted_output_cannot_hide_another_failed_reward(self):
room = self.fleet.rooms['cabinet_01']
first, second = room.command('reward'), room.command('reward')
room.ack(dict(id=first['id'], ok=False))
room.ack(dict(id=second['id'], ok=True))
self.assertIn('failed', room.state['reward'])
def test_migration_and_independent_persistence(self):
with tempfile.TemporaryDirectory() as temp:
path = str(Path(temp) / 'migration.db')
legacy = sqlite3.connect(path)
legacy.execute('CREATE TABLE room (id INTEGER PRIMARY KEY, value TEXT NOT NULL)')
old = dict(self.fleet.rooms['cabinet_01'].state, session='old-session', phase='won')
legacy.execute('INSERT INTO room VALUES (1,?)', (json.dumps(old),))
legacy.commit()
legacy.close()
fleet = server.Fleet(path, self.catalog)
try:
self.assertEqual(fleet.rooms['cabinet_01'].state['session'], 'old-session')
self.assertEqual(fleet.rooms['cabinet_02'].state['session'], '')
fleet.rooms['cabinet_02'].state['game'] = 'galaga'
fleet.rooms['cabinet_02'].save()
finally:
for room in fleet.rooms.values(): room.db.close()
restored = server.Fleet(path, self.catalog)
try:
self.assertEqual(restored.rooms['cabinet_01'].state['phase'], 'won')
self.assertEqual(restored.rooms['cabinet_02'].state['game'], 'galaga')
self.assertFalse(restored.rooms['cabinet_01'].online())
finally:
for room in restored.rooms.values(): room.db.close()
def test_http_browser_and_identity_bound_credentials(self):
service = HTTPServer(('127.0.0.1', 0), server.handler_for(self.fleet, 'operator-secret', {'cabinet_01': 'cabinet-one', 'cabinet_02': 'cabinet-two'}))
thread = threading.Thread(target=service.serve_forever, daemon=True)
thread.start()
base = 'http://127.0.0.1:' + str(service.server_port)
try:
with urllib.request.urlopen(base) as response:
self.assertIn(b'Room control', response.read())
def request(path, token, data, origin=base):
req = urllib.request.Request(base + path, data=json.dumps(data).encode(), headers={'Authorization': 'Bearer '+token, 'Origin': origin})
return urllib.request.urlopen(req)
with request('/operator/state', 'operator-secret', {}) as response:
self.assertEqual(len(json.load(response)['cabinets']), 3)
for path, token, data, code, origin in [
('/operator/state', 'cabinet-one', {}, 401, base),
('/cabinet/poll', 'cabinet-one', {'cabinet':'cabinet_02','protocol':2,'boot':'x'}, 401, base),
('/operator/state', 'operator-secret', {}, 400, 'http://unrelated.example'),
]:
with self.assertRaises(urllib.error.HTTPError) as caught:
request(path, token, data, origin)
self.assertEqual(caught.exception.code, code)
finally:
service.shutdown()
service.server_close()
thread.join()
if __name__ == '__main__': unittest.main()