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
This commit is contained in:
1 parent
705ec31e0b
commit
c1b2195834
90 files changed
+4969
-456
No files matched your search
@@ -0,0 +1,46 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from test_bridge import bridge
|
||||
source=Path(__file__).resolve().parents[2]/'tools/pi_gpio/device_feedback.py'
|
||||
spec=importlib.util.spec_from_file_location('feedback',source)
|
||||
feedback=importlib.util.module_from_spec(spec);spec.loader.exec_module(feedback)
|
||||
class Pin:
|
||||
def __init__(self,pin,**kwargs): self.value=False;self.closed=False
|
||||
def on(self):self.value=True
|
||||
def off(self):self.value=False
|
||||
def close(self):self.closed=True
|
||||
class FeedbackTests(unittest.TestCase):
|
||||
def config(self,data,pins):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path=Path(folder)/'devices.json';path.write_text(json.dumps(data))
|
||||
return feedback.load_devices(path,pins)
|
||||
def test_pin_ownership(self):
|
||||
with self.assertRaises(ValueError):self.config([dict(id='r',port='J1',kind='rfid')],[17])
|
||||
with self.assertRaises(ValueError):self.config([dict(id='b',port='J17',kind='input',pull_up=True)],[16])
|
||||
with self.assertRaises(ValueError):self.config([dict(id='b',port='J17',kind='input')],[25])
|
||||
with self.assertRaises(ValueError):self.config([dict(id='x',port='J22',kind='output')],[18])
|
||||
result=self.config([dict(id='r',port='J2',kind='rfid'),dict(id='b',port='J17',kind='input',pull_up=True)],[25])
|
||||
self.assertEqual(result[1]['pin'],16)
|
||||
def test_live_values_transitions_age_and_cleanup(self):
|
||||
now=[100]
|
||||
definitions=self.config([dict(id='button',port='J17',kind='input',pull_up=True),dict(id='lamp',port='J22',kind='output')],[25])
|
||||
outputs=bridge.Outputs(Pin,'test',[25],clock=lambda:now[0])
|
||||
monitor=feedback.Feedback(definitions,Pin,outputs,clock=lambda:now[0])
|
||||
monitor.tick();first=monitor.snapshot();monitor.tick()
|
||||
self.assertEqual(len(first['events']),len(monitor.snapshot()['events']))
|
||||
monitor.inputs[0][1].value=True
|
||||
outputs.command(dict(controller='test',pin=25,mode='pulse',duration_ms=200,active_high=True))
|
||||
monitor.tick()
|
||||
self.assertTrue(all(d['value'] for d in monitor.snapshot()['devices']))
|
||||
now[0]+=.3;outputs.tick();monitor.tick()
|
||||
self.assertFalse(monitor.snapshot()['devices'][1]['value'])
|
||||
now[0]+=7
|
||||
self.assertGreater(monitor.snapshot()['devices'][0]['age_ms'],6000)
|
||||
pin=monitor.inputs[0][1];monitor.close();outputs.close();self.assertTrue(pin.closed)
|
||||
def test_rfid_requires_helper(self):
|
||||
outputs=bridge.Outputs(Pin,'test',[25])
|
||||
with self.assertRaises(ValueError):feedback.Feedback([dict(id='r',kind='rfid',port='J1')],Pin,outputs)
|
||||
outputs.close()
|
||||
@@ -0,0 +1,98 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures := 0
|
||||
|
||||
func check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
|
||||
func _initialize() -> void:
|
||||
run.call_deferred()
|
||||
|
||||
func run() -> void:
|
||||
if "--preview" in OS.get_cmdline_user_args():
|
||||
root.gui_embed_subwindows = true
|
||||
root.size = Vector2i(1200, 850)
|
||||
var board := CabinetPCBConfig.new()
|
||||
check(board.ports().size() == 22, "All 22 connectors must be present")
|
||||
check(board.port("J15").bcm == 21 and board.port("J10").bcm == 5, "Three pin order")
|
||||
check(board.port("J22").bcm == 25 and board.port("J17").bcm == 16, "Two pin order")
|
||||
check(board.port("J2").label == "001" and board.port("J8").label == "111", "Reader selector order")
|
||||
var reader := PCBConnection.new()
|
||||
reader.port_id = "J1"
|
||||
reader.device_type = "rfid"
|
||||
reader.device_id = "reader_left"
|
||||
reader.display_name = "Left RFID reader"
|
||||
board.set_connection(reader)
|
||||
var reader2: PCBConnection = reader.duplicate(true)
|
||||
reader2.port_id = "J2"
|
||||
reader2.device_id = "reader_right"
|
||||
board.set_connection(reader2)
|
||||
check(board.issues().is_empty(), "Valid readers")
|
||||
var cabinet := CabinetProfile.new()
|
||||
cabinet.display_name = "PCB editor test"
|
||||
cabinet.pcb_config = board
|
||||
var copy: CabinetProfile = cabinet.duplicate(true)
|
||||
copy.pcb_config.connection("J1").display_name = "Other cabinet"
|
||||
check(board.connection("J1").display_name == "Left RFID reader", "Cabinet deep-copy isolation")
|
||||
var path := "user://pcb_test_roundtrip.tres"
|
||||
check(ResourceSaver.save(cabinet, path) == OK, "Save cabinet")
|
||||
var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) as CabinetProfile
|
||||
check(loaded.pcb_config.connection("J2").device_id == "reader_right", "Save/reload connector data")
|
||||
DirAccess.remove_absolute(path)
|
||||
reader2.device_id = reader.device_id
|
||||
check(not board.issues().is_empty(), "Duplicate IDs reported")
|
||||
reader2.device_id = "reader_right"
|
||||
var hardware := HardwareConfig.new()
|
||||
var action := HardwareAction.new()
|
||||
action.action_id = &"lamp"
|
||||
action.gpio_pin = 17
|
||||
hardware.actions.append(action)
|
||||
check(not board.issues(hardware).is_empty(), "Reserved selector pin reported")
|
||||
var output := PCBConnection.new()
|
||||
output.port_id = "J22"
|
||||
output.device_type = "led"
|
||||
output.device_id = "lamp"
|
||||
output.binding_id = "lamp"
|
||||
board.set_connection(output)
|
||||
action.gpio_pin = 18
|
||||
check(not board.issues(hardware).is_empty(), "Output BCM mismatch reported")
|
||||
action.gpio_pin = 25
|
||||
check(board.issues(hardware).is_empty(), "Matching output action accepted")
|
||||
cabinet.hardware_config = hardware
|
||||
if not Engine.is_editor_hint():
|
||||
print("PCB resource tests: ", "PASS" if failures == 0 else "FAIL")
|
||||
quit(failures)
|
||||
return
|
||||
var property := preload("res://editor/hardware/pcb_editor_property.gd").new()
|
||||
root.add_child(property)
|
||||
property.set_object_and_property(cabinet, "pcb_config")
|
||||
property._update_property()
|
||||
property._open()
|
||||
property._select("J2")
|
||||
check(property.device_id.text == "reader_right", "Editor selects correct reader")
|
||||
property.device_name.text = "Pending edit"
|
||||
property._store()
|
||||
check(board.connection("J2").display_name != "Pending edit", "Draft cannot change source before Apply")
|
||||
property.dialog.hide()
|
||||
property._open()
|
||||
check(property.device_name.text != "Pending edit", "Cancel/reopen discards draft")
|
||||
property._select("J22")
|
||||
property.device_name.text = "Reward lamp"
|
||||
property._store()
|
||||
property.property_changed.connect(func(_key: StringName, value: Variant, _field: StringName, _changing: bool): cabinet.pcb_config = value)
|
||||
property._apply()
|
||||
check(cabinet.pcb_config.connection("J22").display_name == "Reward lamp", "Apply emits updated configuration")
|
||||
if "--preview" in OS.get_cmdline_user_args():
|
||||
root.size = Vector2i(1200, 850)
|
||||
property.dialog.popup_centered(Vector2i(1120, 760))
|
||||
await process_frame
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
root.get_texture().get_image().save_png("user://pcb_editor_preview.png")
|
||||
print("Preview: ", OS.get_user_data_dir(), "/pcb_editor_preview.png")
|
||||
property.queue_free()
|
||||
await process_frame
|
||||
print("PCB tests: ", "PASS" if failures == 0 else "FAIL")
|
||||
quit(failures)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbtyukgnhlhts
|
||||
@@ -0,0 +1,42 @@
|
||||
extends SceneTree
|
||||
var failures := 0
|
||||
func check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
func sample(id: String, uid := "1234ABCD", device := "cab1_rfid1", fresh := true, age := 0) -> Dictionary:
|
||||
return {"fresh": fresh, "events": [{"id": id, "device": device, "kind": "rfid", "value": uid, "error": ""}], "devices": [{"id": device, "value": uid, "age_ms": age, "error": ""}]}
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var game = load("res://games/pacman/pacman.gd").new()
|
||||
root.add_child(game)
|
||||
game.configure({"rfid_credit_device": "cab1_rfid1", "rfid_allowed_uids": ["1234ABCD"]})
|
||||
check(not game.start_game(), "Start must require a credit")
|
||||
game.receive_device_feedback({})
|
||||
game.receive_device_feedback(sample("old"))
|
||||
check(game.credits == 0, "First fresh snapshot must baseline historical scans")
|
||||
game.receive_device_feedback(sample("wrong-reader", "1234ABCD", "other"))
|
||||
game.receive_device_feedback(sample("wrong-card", "99999999"))
|
||||
game.receive_device_feedback(sample("stale", "1234ABCD", "cab1_rfid1", true, 5000))
|
||||
game.receive_device_feedback(sample("offline", "1234ABCD", "cab1_rfid1", false))
|
||||
check(game.credits == 0, "Wrong reader/card, stale and offline samples must not credit")
|
||||
game.receive_device_feedback(sample("scan"))
|
||||
check(game.credits == 1 and game.view.credits == 1, "New accepted scan grants one visible credit")
|
||||
game.receive_device_feedback(sample("scan"))
|
||||
game.receive_device_feedback(sample("scan2"))
|
||||
check(game.credits == 1, "Duplicate and additional scans must not stack credits")
|
||||
check(game.start_game() and game.credits == 0, "Start consumes one credit")
|
||||
game.receive_device_feedback(sample("during-play"))
|
||||
check(game.credits == 0, "Playing scans must not bank credits")
|
||||
game.reset_game()
|
||||
game.receive_device_feedback(sample("scan"))
|
||||
check(game.credits == 0, "Reset must not replay a prior event")
|
||||
game.receive_device_feedback(sample("new-scan"))
|
||||
check(game.credits == 1, "New scan works after reset")
|
||||
game.configure({})
|
||||
check(game.start_game(), "Ordinary non-RFID Pac-Man remains free start")
|
||||
game.queue_free()
|
||||
await process_frame
|
||||
print("RFID credit tests: ", "PASS" if failures == 0 else "FAIL")
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1,64 @@
|
||||
extends SceneTree
|
||||
var failures := 0
|
||||
func check(value: bool, message: String) -> void:
|
||||
if not value:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
func _initialize() -> void:
|
||||
run.call_deferred()
|
||||
func run() -> void:
|
||||
var room := RoomDefinition.new()
|
||||
var wall := RoomWallDefinition.new()
|
||||
wall.wall_id = &"wall"
|
||||
wall.end = Vector2(4, 0)
|
||||
var door := RoomWallDefinition.new()
|
||||
door.wall_id = &"door"
|
||||
door.start = Vector2(1, 0)
|
||||
door.end = Vector2(2, 0)
|
||||
door.is_doorway = true
|
||||
room.walls.assign([wall, door])
|
||||
var path := "user://room_door_test.tres"
|
||||
check(ResourceSaver.save(room, path) == OK, "Initial room save")
|
||||
room.take_over_path(path)
|
||||
var session := RoomEditSession.new()
|
||||
session.begin(room)
|
||||
session.working_room.get_wall(&"door").swing_reversed = true
|
||||
session.mark_dirty()
|
||||
check(session.is_dirty, "Door swing participates in dirty tracking")
|
||||
check(not door.swing_reversed, "Source isolated from edits")
|
||||
check(session.save(), "Save room")
|
||||
var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) as RoomDefinition
|
||||
check(loaded.get_wall(&"door").is_doorway and loaded.get_wall(&"door").swing_reversed, "Door save/reload")
|
||||
session.working_room.get_wall(&"door").is_doorway = false
|
||||
session.mark_dirty()
|
||||
check(session.is_dirty, "Door type participates in dirty tracking")
|
||||
session.revert()
|
||||
check(session.working_room.get_wall(&"door").is_doorway, "Revert restores doorway")
|
||||
DirAccess.remove_absolute(path)
|
||||
room.walls.assign([wall, door])
|
||||
var layout := RoomLayout.new()
|
||||
layout.item_scene = load("res://ui/RoomLayoutItem.tscn")
|
||||
root.add_child(layout)
|
||||
var device := RoomDeviceDefinition.new()
|
||||
device.device_id = &"lamp"
|
||||
device.display_name = "Reward lamp"
|
||||
room.devices.append(device)
|
||||
layout.configure(room, null)
|
||||
var ranges := layout._wall_solid_ranges(wall)
|
||||
check(ranges == [Vector2(0, 0.25), Vector2(0.5, 1)], "Door cuts wall opening")
|
||||
door.start = Vector2(2, 0)
|
||||
door.end = Vector2(1, 0)
|
||||
check(layout._wall_solid_ranges(wall) == ranges, "Reversed doorway endpoints")
|
||||
check(layout._find_wall_at_screen_position(layout.world_to_screen(Vector2(1.5, 0))) == door, "Door selected ahead of underlying wall")
|
||||
layout.set_labels_visible(false)
|
||||
check(not layout.item_nodes[&"lamp"].title_label.visible, "Hide item label")
|
||||
layout.configure(room, null)
|
||||
check(not layout.item_nodes[&"lamp"].title_label.visible, "Label preference survives rebuild")
|
||||
layout.set_labels_visible(true)
|
||||
check(layout.item_nodes[&"lamp"].title_label.visible, "Restore item label")
|
||||
layout.set_dimensions_visible(false)
|
||||
check(not layout.show_dimensions, "Dimension visibility")
|
||||
layout.queue_free()
|
||||
await process_frame
|
||||
print("Room editor tests: ", "PASS" if failures == 0 else "FAIL")
|
||||
quit(failures)
|
||||
@@ -0,0 +1 @@
|
||||
uid://0p3i2u8rm2x4
|
||||
@@ -0,0 +1,440 @@
|
||||
{
|
||||
"cabinets": {
|
||||
"cabinet_01": {
|
||||
"actions": [
|
||||
"coin_release",
|
||||
"start_light"
|
||||
],
|
||||
"capabilities": [
|
||||
"joystick_4way",
|
||||
"button_1",
|
||||
"start_button",
|
||||
"joystick_horizontal",
|
||||
"button_2",
|
||||
"button_3",
|
||||
"button_4",
|
||||
"button_5",
|
||||
"button_6",
|
||||
"select_button",
|
||||
"trackball",
|
||||
"joystick_8way",
|
||||
"joystick_dual"
|
||||
],
|
||||
"name": "Master Cabinet",
|
||||
"position": [
|
||||
-5.94849967956543,
|
||||
0.147500038146973
|
||||
],
|
||||
"devices": [
|
||||
{
|
||||
"id": "cab1_btn_red",
|
||||
"kind": "input",
|
||||
"name": "Red Button",
|
||||
"port": "J17",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_btn_grn",
|
||||
"kind": "input",
|
||||
"name": "Green Button",
|
||||
"port": "J18",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_btn_blue",
|
||||
"kind": "input",
|
||||
"name": "Blue Button",
|
||||
"port": "J19",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_btn_ylw",
|
||||
"kind": "input",
|
||||
"name": "Yellow Btn",
|
||||
"port": "J20",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_rfid2",
|
||||
"kind": "rfid",
|
||||
"name": "RFID Reader 2",
|
||||
"port": "J2",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_rfid1",
|
||||
"kind": "rfid",
|
||||
"name": "RFID Reader 1",
|
||||
"port": "J1",
|
||||
"pull_up": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"cabinet_02": {
|
||||
"actions": [
|
||||
"coin_release",
|
||||
"start_light",
|
||||
"glitch_code"
|
||||
],
|
||||
"capabilities": [
|
||||
"joystick_4way",
|
||||
"button_1",
|
||||
"start_button",
|
||||
"joystick_horizontal"
|
||||
],
|
||||
"name": "Cabinet 02",
|
||||
"position": [
|
||||
-3.51539182662964,
|
||||
0.142745211720467
|
||||
]
|
||||
},
|
||||
"cabinet_03": {
|
||||
"actions": [
|
||||
"coin_release"
|
||||
],
|
||||
"capabilities": [
|
||||
"spinner",
|
||||
"button_1",
|
||||
"start_button",
|
||||
"joystick_4way"
|
||||
],
|
||||
"name": "Cabinet 03",
|
||||
"position": null
|
||||
}
|
||||
},
|
||||
"games": {
|
||||
"frogger": {
|
||||
"alternatives": [],
|
||||
"name": "Frogger",
|
||||
"requirements": [
|
||||
"joystick_4way",
|
||||
"start_button"
|
||||
]
|
||||
},
|
||||
"galaga": {
|
||||
"alternatives": [],
|
||||
"name": "Galaga",
|
||||
"requirements": [
|
||||
"joystick_horizontal",
|
||||
"button_1",
|
||||
"start_button"
|
||||
]
|
||||
},
|
||||
"missile_command": {
|
||||
"alternatives": [],
|
||||
"name": "Missile Command",
|
||||
"requirements": [
|
||||
"trackball",
|
||||
"button_1",
|
||||
"button_2",
|
||||
"button_3",
|
||||
"start_button"
|
||||
]
|
||||
},
|
||||
"pacman": {
|
||||
"alternatives": [],
|
||||
"name": "Pac-Man",
|
||||
"requirements": [
|
||||
"joystick_4way",
|
||||
"start_button"
|
||||
]
|
||||
},
|
||||
"robotron": {
|
||||
"alternatives": [
|
||||
[
|
||||
"dual_joystick_8way"
|
||||
],
|
||||
[
|
||||
"joystick_8way",
|
||||
"button_1",
|
||||
"button_2",
|
||||
"button_3",
|
||||
"button_4"
|
||||
]
|
||||
],
|
||||
"name": "Robotron 2084",
|
||||
"requirements": [
|
||||
"start_button"
|
||||
]
|
||||
},
|
||||
"space_invaders": {
|
||||
"alternatives": [],
|
||||
"name": "Space Invaders",
|
||||
"requirements": [
|
||||
"joystick_horizontal",
|
||||
"button_1",
|
||||
"start_button"
|
||||
]
|
||||
},
|
||||
"tempest": {
|
||||
"alternatives": [
|
||||
[
|
||||
"joystick_horizontal"
|
||||
],
|
||||
[
|
||||
"spinner"
|
||||
],
|
||||
[
|
||||
"trackball"
|
||||
]
|
||||
],
|
||||
"name": "Tempest",
|
||||
"requirements": [
|
||||
"button_1",
|
||||
"button_2",
|
||||
"start_button"
|
||||
]
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"boundary": [
|
||||
[
|
||||
-7.31519985198975,
|
||||
-0.609600007534027
|
||||
],
|
||||
[
|
||||
-2.13359999656677,
|
||||
-0.609600007534027
|
||||
],
|
||||
[
|
||||
-2.13359999656677,
|
||||
7.61999988555908
|
||||
],
|
||||
[
|
||||
-7.31519985198975,
|
||||
7.61999988555908
|
||||
]
|
||||
],
|
||||
"devices": [
|
||||
{
|
||||
"id": "coin_release_a",
|
||||
"name": "Coin Release A",
|
||||
"position": [
|
||||
-6.0149998664856,
|
||||
0.555000007152557
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "coin_release_b",
|
||||
"name": "Coin Release B",
|
||||
"position": [
|
||||
-3.57996416091919,
|
||||
0.568386673927307
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "light_a",
|
||||
"name": "Light A",
|
||||
"position": [
|
||||
-6.46999979019165,
|
||||
0.560000002384186
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "light_b",
|
||||
"name": "Light B",
|
||||
"position": [
|
||||
-4.0322380065918,
|
||||
0.576531648635864
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "glitch_code",
|
||||
"name": "Glitch Code",
|
||||
"position": [
|
||||
-2.75500297546387,
|
||||
1.51234555244446
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
[
|
||||
[
|
||||
-7.01039981842041,
|
||||
-0.304800003767014
|
||||
],
|
||||
[
|
||||
-2.43840003013611,
|
||||
-0.304800003767014
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
-2.43840003013611,
|
||||
-0.304800003767014
|
||||
],
|
||||
[
|
||||
-2.43840003013611,
|
||||
7.31519985198975
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
-2.43840003013611,
|
||||
7.31519985198975
|
||||
],
|
||||
[
|
||||
-7.01039981842041,
|
||||
7.31519985198975
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
-7.01039981842041,
|
||||
7.31519985198975
|
||||
],
|
||||
[
|
||||
-7.01039981842041,
|
||||
-0.304800003767014
|
||||
]
|
||||
]
|
||||
]
|
||||
},
|
||||
"scenarios": {
|
||||
"cabinet_chain_test": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "cabinet_01",
|
||||
"cabinet": "cabinet_02",
|
||||
"game": "galaga",
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
}
|
||||
],
|
||||
"name": "Cabinet 1 -> Cabinet 2"
|
||||
},
|
||||
"galaga_proof_of_concept": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "galaga",
|
||||
"mappings": {
|
||||
"game_won": [
|
||||
"coin_release"
|
||||
]
|
||||
},
|
||||
"variant": "standard"
|
||||
}
|
||||
],
|
||||
"name": "Galaga Proof of Concept"
|
||||
},
|
||||
"proof_of_concept": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"mappings": {
|
||||
"game_won": [
|
||||
"coin_release"
|
||||
]
|
||||
},
|
||||
"variant": "standard"
|
||||
}
|
||||
],
|
||||
"name": "Proof of Concept"
|
||||
},
|
||||
"two_cabinet_test": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"mappings": {
|
||||
"game_started": [
|
||||
"start_light"
|
||||
],
|
||||
"game_won": [
|
||||
"coin_release"
|
||||
]
|
||||
},
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_02",
|
||||
"game": "galaga",
|
||||
"mappings": {
|
||||
"game_activated": [
|
||||
"glitch_code"
|
||||
],
|
||||
"game_started": [
|
||||
"start_light"
|
||||
],
|
||||
"game_won": [
|
||||
"coin_release"
|
||||
]
|
||||
},
|
||||
"variant": "standard"
|
||||
}
|
||||
],
|
||||
"name": "Two Cabinet Test"
|
||||
},
|
||||
"three_cabinet_objective_test": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"game_config": {
|
||||
"boards_to_win": 0,
|
||||
"score_to_win": 0,
|
||||
"test_auto_win_seconds": 5.0
|
||||
},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "cabinet_01",
|
||||
"cabinet": "cabinet_02",
|
||||
"game": "galaga",
|
||||
"game_config": {
|
||||
"score_to_win": 500,
|
||||
"stages_to_win": 0
|
||||
},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "cabinet_02",
|
||||
"cabinet": "cabinet_03",
|
||||
"game": "frogger",
|
||||
"game_config": {
|
||||
"boards_to_win": 0,
|
||||
"homes_to_win": 1
|
||||
},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
}
|
||||
],
|
||||
"name": "3-Cabinet Test: 5 seconds / 500 points / 1 frog"
|
||||
},
|
||||
"pacman_rfid_credit_test": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"game_config": {
|
||||
"boards_to_win": 1,
|
||||
"rfid_allowed_uids": [],
|
||||
"rfid_credit_device": "cab1_rfid1",
|
||||
"score_to_win": 0
|
||||
},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
}
|
||||
],
|
||||
"name": "Cabinet 1: Pac-Man RFID credit"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
extends SceneTree
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var scenario: ScenarioDefinition = load("res://data/scenarios/three_cabinet_objective_test.tres")
|
||||
var edit := ScenarioEditSession.new()
|
||||
assert(edit.begin(scenario))
|
||||
assert(edit.working_scenario.assignments[1].game_config.score_to_win == 500)
|
||||
edit.working_scenario.assignments[1].game_config.score_to_win = 600
|
||||
edit.mark_dirty()
|
||||
assert(edit.is_dirty)
|
||||
assert(scenario.assignments[1].game_config.score_to_win == 500, "Editing must not mutate source")
|
||||
var frogger = load("res://games/frogger/Frogger.tscn").instantiate()
|
||||
root.add_child(frogger)
|
||||
frogger.configure({})
|
||||
frogger.start_game()
|
||||
frogger.homes[2] = true
|
||||
frogger.phase = "home"
|
||||
frogger.phase_time = 0.0
|
||||
frogger._physics_process(.01)
|
||||
assert(frogger.is_running, "Default Frogger must not win on one home")
|
||||
frogger.reset_game()
|
||||
frogger.configure({"homes_to_win": 1, "boards_to_win": 0})
|
||||
frogger.start_game()
|
||||
frogger.homes[2] = true
|
||||
frogger.phase = "home"
|
||||
frogger.phase_time = 0.0
|
||||
frogger._physics_process(.01)
|
||||
assert(not frogger.is_running and frogger.phase == "won")
|
||||
frogger.queue_free()
|
||||
var agent = load("res://core/network/managed_cabinet.gd").new()
|
||||
# Keep this isolated agent outside the tree: no network polling.
|
||||
var game := ArcadeGame.new()
|
||||
agent.add_child(game)
|
||||
agent.game = game
|
||||
game.configure({"test_auto_win_seconds": 5.0})
|
||||
game.start_game()
|
||||
agent.event("started")
|
||||
agent._process(4.0)
|
||||
assert(game.is_running)
|
||||
agent.unload()
|
||||
var replacement := ArcadeGame.new()
|
||||
agent.add_child(replacement)
|
||||
agent.game = replacement
|
||||
replacement.start_game()
|
||||
agent.event("started")
|
||||
agent._process(20.0)
|
||||
assert(replacement.is_running, "Old timer must not affect the next game")
|
||||
agent.free()
|
||||
await process_frame
|
||||
print("PASS: default Frogger, configured home objective, editor copy isolation, and timer cancellation")
|
||||
quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://di2ndju42rh4t
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Three real game clients; no GPIO, no live deployment database."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from http.server import HTTPServer
|
||||
from test_room import ROOT, server
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
fleet=server.Fleet(Path(temp)/'chain.db',json.loads((ROOT/'tools/room_controller/catalog.json').read_text()))
|
||||
service=HTTPServer(('127.0.0.1',0),server.handler_for(fleet,'test-operator-token',{'cabinet_01':'test-token-one-123','cabinet_02':'test-token-two-123','cabinet_03':'test-token-three-123'}))
|
||||
thread=threading.Thread(target=service.serve_forever,daemon=True)
|
||||
thread.start()
|
||||
url='http://127.0.0.1:'+str(service.server_port)
|
||||
processes=[]
|
||||
def post(path,data={}):
|
||||
request=urllib.request.Request(url+path,data=json.dumps(data).encode(),headers={'Authorization':'Bearer test-operator-token'})
|
||||
with urllib.request.urlopen(request,timeout=3) as response: return json.load(response)
|
||||
def wait(predicate):
|
||||
deadline=time.monotonic()+30
|
||||
while time.monotonic()<deadline:
|
||||
state=post('/operator/state')
|
||||
if predicate(state['cabinets']): return state
|
||||
time.sleep(.1)
|
||||
raise AssertionError('Timed out waiting for chain state')
|
||||
try:
|
||||
for cabinet,token in [('cabinet_01','test-token-one-123'),('cabinet_02','test-token-two-123'),('cabinet_03','test-token-three-123')]:
|
||||
processes.append(subprocess.Popen([sys.argv[1],'--headless','--path',str(ROOT),'--log-file',str(Path(temp)/(cabinet+'.log')),'--script','tests/room_network/objective_agent.gd','--','--cabinet='+cabinet,'--test-no-reward'],env=dict(os.environ,ARCADE_ROOM_URL=url,ARCADE_CABINET_TOKEN=token)))
|
||||
wait(lambda c:all(c[k]['online'] for k in ['cabinet_01','cabinet_02','cabinet_03']))
|
||||
post('/operator/command',{'action':'prepare_scenario','scenario':'three_cabinet_objective_test'})
|
||||
wait(lambda c:c['cabinet_01']['phase']=='ready' and c['cabinet_02']['phase']=='waiting' and c['cabinet_03']['phase']=='waiting')
|
||||
post('/operator/command',{'action':'start','cabinet':'cabinet_01'})
|
||||
state=wait(lambda c:all(c[k]['phase']=='won' for k in ['cabinet_01','cabinet_02','cabinet_03']))
|
||||
commands=state['cabinets']['cabinet_02']['commands']
|
||||
assert [c['type'] for c in commands]==['reset','prepare','start']
|
||||
assert all(c['status']=='accepted' for c in commands)
|
||||
assert [c['type'] for c in state['cabinets']['cabinet_03']['commands']]==['reset','prepare','start']
|
||||
for process in processes: assert process.wait(timeout=8)==0
|
||||
print('PASS: actual five-second auto-win -> 499/500 score boundary -> one Frogger home -> all three wins')
|
||||
finally:
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.terminate(); process.wait(timeout=5)
|
||||
service.shutdown(); service.server_close(); thread.join()
|
||||
for room in fleet.rooms.values(): room.db.close()
|
||||
@@ -0,0 +1,36 @@
|
||||
extends SceneTree
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var agent = load("res://core/network/managed_cabinet.gd").new()
|
||||
root.add_child(agent)
|
||||
var injected := false
|
||||
var started_at := -1
|
||||
for i in range(350):
|
||||
await create_timer(.1).timeout
|
||||
if agent.phase == "playing" and started_at < 0: started_at = Time.get_ticks_msec()
|
||||
if agent.phase == "playing" and not injected:
|
||||
if agent.cabinet_id == "cabinet_02":
|
||||
injected = true
|
||||
agent.game._add_score(499)
|
||||
assert(agent.game.is_running and agent.game.state != "victory", "499 must not win")
|
||||
await create_timer(.3).timeout
|
||||
assert(agent.phase == "playing")
|
||||
agent.game._add_score(1)
|
||||
elif agent.cabinet_id == "cabinet_03" and agent.game.phase == "play":
|
||||
injected = true
|
||||
assert(agent.game.homes.count(true) == 0)
|
||||
agent.game.frog = Vector2(112,40)
|
||||
agent.game.hop_left = 0
|
||||
agent.game.step_world(0.0)
|
||||
assert(agent.game.homes.count(true) == 1)
|
||||
if agent.phase == "won":
|
||||
if agent.cabinet_id == "cabinet_01":
|
||||
assert(Time.get_ticks_msec()-started_at >= 4800, "Timer must run for five seconds")
|
||||
elif agent.cabinet_id == "cabinet_02": assert(agent.game.score == 500)
|
||||
elif agent.cabinet_id == "cabinet_03": assert(agent.game.homes.count(true) == 1)
|
||||
await create_timer(1.0).timeout
|
||||
quit()
|
||||
return
|
||||
push_error("Objective integration timed out on " + agent.cabinet_id)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://drmhfkifu2igv
|
||||
@@ -0,0 +1,167 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'tools/deploy'))
|
||||
import deploy
|
||||
import receiver
|
||||
|
||||
|
||||
def site():
|
||||
return dict(schema=1, operator_token='operator_token_12345678', controller_url='http://192.168.77.1:8766',
|
||||
cabinets=[dict(id='cabinet_01', host='192.168.77.11', user='arcade', port=22,
|
||||
token='cabinet1_token_12345678', gpio=False, pins=[], gpio_controller='cabinet_01_gpio')])
|
||||
|
||||
|
||||
def archive(extra=None, missing=False, wrong_hash=False):
|
||||
import hashlib
|
||||
files = {name: b'example' for name in receiver.FILES}
|
||||
manifest = dict(release='test-1', files={name: hashlib.sha256(data).hexdigest() for name, data in files.items()})
|
||||
if wrong_hash:
|
||||
manifest['files']['ArcadeEscape.pck'] = '0' * 64
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode='w:gz') as tar:
|
||||
entries = [('manifest.json', json.dumps(manifest).encode())] + list(files.items())
|
||||
if missing: entries.pop()
|
||||
for name, data in entries:
|
||||
item = tarfile.TarInfo(name)
|
||||
item.size = len(data)
|
||||
tar.addfile(item, io.BytesIO(data))
|
||||
if extra:
|
||||
tar.addfile(extra)
|
||||
stream.seek(0)
|
||||
return stream
|
||||
|
||||
|
||||
class DeployTests(unittest.TestCase):
|
||||
def test_config_separates_credentials_and_keeps_gpio_off(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = Path(temp)
|
||||
settings = site()
|
||||
deploy.generate(folder, settings)
|
||||
cabinet = (folder / 'cabinets/cabinet_01.ini').read_text()
|
||||
self.assertNotIn(settings['operator_token'], cabinet)
|
||||
self.assertIn('gpio = false', cabinet)
|
||||
self.assertIn('~/.local/state/arcade_gpio/requests.sqlite3', cabinet)
|
||||
self.assertIn(settings['operator_token'], (folder / 'controller.ini').read_text())
|
||||
|
||||
def test_duplicate_identity_host_and_tokens_rejected(self):
|
||||
for change in ('id', 'host', 'token'):
|
||||
settings = site()
|
||||
other = dict(settings['cabinets'][0], id='cabinet_02', host='192.168.77.12', token='cabinet2_token_12345678')
|
||||
other[change] = settings['cabinets'][0][change]
|
||||
settings['cabinets'].append(other)
|
||||
with self.subTest(change=change), self.assertRaises(ValueError):
|
||||
deploy.validate(settings)
|
||||
|
||||
def test_unknown_profile_and_command_injection_rejected(self):
|
||||
for key, value in [('id', 'unknown'), ('host', 'pi; reboot'), ('user', '-root'), ('port', True)]:
|
||||
settings = site()
|
||||
settings['cabinets'][0][key] = value
|
||||
with self.subTest(key=key), self.assertRaises(ValueError):
|
||||
deploy.validate(settings)
|
||||
|
||||
def test_bad_url_and_gpio_allowlists_rejected(self):
|
||||
for url in ('http://127.0.0.1:8766', 'http://user:secret@192.168.77.1:8766', 'http://192.168.77.1:8766/path'):
|
||||
settings = site()
|
||||
settings['controller_url'] = url
|
||||
with self.assertRaises(ValueError): deploy.validate(settings)
|
||||
for pins in ([], [17, 17], [28], [True]):
|
||||
with self.assertRaises(ValueError):
|
||||
receiver.render_config(dict(site()['cabinets'][0], url=site()['controller_url'], gpio=True, pins=pins))
|
||||
|
||||
def test_archive_success_and_retry(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
receiver.stage(root, archive())
|
||||
receiver.stage(root, archive())
|
||||
self.assertEqual(receiver.verify_release(root / 'releases/test-1')['release'], 'test-1')
|
||||
self.assertFalse((root / 'active.json').exists())
|
||||
|
||||
def test_archive_traversal_symlinks_duplicates_and_corruption_rejected(self):
|
||||
cases = []
|
||||
cases.append(archive(extra=tarfile.TarInfo('../escape')))
|
||||
link = tarfile.TarInfo('unexpected')
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = '/etc/passwd'
|
||||
cases.append(archive(extra=link))
|
||||
cases.append(archive(extra=tarfile.TarInfo('ArcadeEscape.pck')))
|
||||
cases.append(archive(missing=True))
|
||||
cases.append(archive(wrong_hash=True))
|
||||
for stream in cases:
|
||||
with tempfile.TemporaryDirectory() as temp, self.assertRaises(ValueError):
|
||||
receiver.unpack(stream, Path(temp))
|
||||
|
||||
def test_ssh_is_strict_and_no_token_in_command(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = Path(temp)
|
||||
(folder / 'ssh').mkdir()
|
||||
(folder / 'ssh/arcade_cabinets').touch()
|
||||
command = deploy.ssh_args(folder, site()['cabinets'][0])
|
||||
self.assertIn('StrictHostKeyChecking=yes', command)
|
||||
self.assertIn('BatchMode=yes', command)
|
||||
self.assertNotIn(site()['cabinets'][0]['token'], str(command))
|
||||
|
||||
def test_activation_refuses_running_game_before_writing_config(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
with patch.object(receiver, 'verify_release'), patch.object(receiver, 'render_config', return_value='config'), \
|
||||
patch.object(receiver.Path, 'read_text', return_value=json.dumps({'cabinets': {'cabinet_01': {}}})), \
|
||||
patch.object(receiver.subprocess, 'run') as run:
|
||||
run.return_value.returncode = 0
|
||||
with self.assertRaisesRegex(ValueError, 'running'):
|
||||
receiver.activate(root, 'test-1', site()['cabinets'][0])
|
||||
self.assertFalse((root / 'releases/test-1/tools/launch/launcher.ini').exists())
|
||||
|
||||
def test_setup_rerun_keeps_credentials(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = Path(temp)
|
||||
settings = site()
|
||||
receiver.private_write(folder / 'inventory.json', json.dumps(settings))
|
||||
with patch('builtins.input', side_effect=['', '']):
|
||||
deploy.setup(folder)
|
||||
self.assertEqual(deploy.load(folder), settings)
|
||||
|
||||
def test_activation_failure_restores_previous_service_and_config(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp) / 'deploy'
|
||||
home = Path(temp) / 'home'
|
||||
folder = root / 'releases/test-1'
|
||||
(folder / 'tools/room_controller').mkdir(parents=True)
|
||||
(folder / 'tools/room_controller/catalog.json').write_text(json.dumps({'cabinets': {'cabinet_01': {}}}))
|
||||
config = folder / 'tools/launch/launcher.ini'
|
||||
receiver.private_write(config, 'old configuration')
|
||||
unit = home / '.config/systemd/user/arcade-cabinet.service'
|
||||
receiver.private_write(unit, receiver.MARKER + '\nold unit')
|
||||
settings = dict(site()['cabinets'][0], url=site()['controller_url'])
|
||||
def run(command, **kwargs):
|
||||
import subprocess
|
||||
if command[0] == sys.executable:
|
||||
receiver.private_write(unit, receiver.MARKER + '\nchanged unit')
|
||||
raise subprocess.CalledProcessError(1, command)
|
||||
return subprocess.CompletedProcess(command, 3 if 'is-active' in command else 0)
|
||||
import subprocess
|
||||
with patch.object(receiver, 'verify_release'), patch.object(receiver.Path, 'home', return_value=home), \
|
||||
patch.object(receiver.subprocess, 'run', side_effect=run), self.assertRaises(subprocess.CalledProcessError):
|
||||
receiver.activate(root, 'test-1', settings)
|
||||
self.assertEqual(config.read_text(), 'old configuration')
|
||||
self.assertEqual(unit.read_text(), receiver.MARKER + '\nold unit')
|
||||
self.assertFalse((root / 'active.json').exists())
|
||||
|
||||
def test_master_package_excludes_live_site_data(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
target = Path(temp) / 'master'
|
||||
deploy.master_package(target)
|
||||
names = [str(p.relative_to(target)) for p in target.rglob('*') if p.is_file()]
|
||||
self.assertTrue((target / 'tools/deploy/deploy.py').exists())
|
||||
self.assertFalse(any(n.endswith(('.ini', '.sqlite3', '.cfg')) for n in names))
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
from test_room import server
|
||||
class FeedbackTests(unittest.TestCase):
|
||||
def test_authenticated_poll_feedback_dedup_and_stale(self):
|
||||
now=[100]
|
||||
room=server.Room(':memory:',lambda:now[0])
|
||||
packet=dict(cabinet='cabinet_01',protocol=2,boot='test',gpio='real',reports=[],feedback=dict(fresh=True,devices=[dict(id='reader',kind='rfid',port='J1',value='12345678',age_ms=0)],events=[dict(id='boot:1',device='reader',value='12345678')]))
|
||||
room.poll(packet);room.poll(packet)
|
||||
self.assertTrue(room.snapshot()['feedback']['fresh'])
|
||||
self.assertEqual(sum('Device reader' in x['message'] for x in room.state['history']),1)
|
||||
now[0]+=5
|
||||
self.assertFalse(room.snapshot()['feedback']['fresh'])
|
||||
packet['feedback']['fresh']=False
|
||||
room.poll(packet)
|
||||
self.assertFalse(room.snapshot()['feedback']['fresh'])
|
||||
room.db.close()
|
||||
def test_invalid_feedback_is_bounded(self):
|
||||
room=server.Room(':memory:',lambda:100)
|
||||
room.receive_feedback(dict(devices=[{}]*33,events=[]))
|
||||
self.assertEqual(room.feedback,{})
|
||||
room.receive_feedback(dict(devices=[dict(id='bad',kind='input',value=[],age_ms=0)],fresh=True))
|
||||
self.assertEqual(room.feedback['devices'],[])
|
||||
self.assertFalse(room.feedback['fresh'])
|
||||
room.db.close()
|
||||
@@ -13,7 +13,8 @@ import unittest
|
||||
class FleetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = 100
|
||||
self.catalog = json.loads((ROOT / 'tools/room_controller/catalog.json').read_text())
|
||||
# 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)
|
||||
|
||||
@@ -54,6 +55,9 @@ class FleetTests(unittest.TestCase):
|
||||
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']
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from test_room import ROOT, server
|
||||
|
||||
class HubTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.catalog = json.loads(Path(__file__).with_name('catalog_fixture.json').read_text(encoding='utf-8'))
|
||||
self.path = str(Path(self.temp.name) / 'state.sqlite3')
|
||||
self.fleet = server.Fleet(self.path, self.catalog, lambda: 100)
|
||||
self.room = self.fleet.rooms['cabinet_01']
|
||||
self.room.last_seen = 100
|
||||
self.room.gpio = 'real'
|
||||
self.room.state.update(session='test-session', phase='ready')
|
||||
self.data = dict(cabinet='cabinet_01', expected_session='test-session', action_id='coin_release', request_id='test-request-0001', reason='Test release')
|
||||
def tearDown(self):
|
||||
for r in self.fleet.rooms.values(): r.db.close()
|
||||
self.temp.cleanup()
|
||||
def test_durable_duplicate_does_not_repeat_output(self):
|
||||
self.fleet.operator('trigger_output', self.data)
|
||||
self.fleet.operator('trigger_output', self.data)
|
||||
self.assertEqual(len(self.room.state['commands']), 1)
|
||||
for r in self.fleet.rooms.values(): r.db.close()
|
||||
self.fleet = server.Fleet(self.path, self.catalog, lambda: 100)
|
||||
self.fleet.operator('trigger_output', self.data)
|
||||
self.assertEqual(len(self.fleet.rooms['cabinet_01'].state['commands']), 1)
|
||||
self.assertTrue(any('Test release' in h['message'] for h in self.fleet.rooms['cabinet_01'].state['history']))
|
||||
def test_rejects_unsafe_or_stale_requests(self):
|
||||
for key,value in [('expected_session','old'),('action_id','unknown'),('reason',''),('request_id','short')]:
|
||||
with self.subTest(key=key), self.assertRaises(ValueError):
|
||||
self.fleet.operator('trigger_output', dict(self.data, **{key:value}))
|
||||
self.room.gpio = 'simulation'
|
||||
with self.assertRaises(ValueError): self.fleet.operator('trigger_output', self.data)
|
||||
self.room.gpio = 'real'
|
||||
self.room.last_seen = 0
|
||||
with self.assertRaises(ValueError): self.fleet.operator('trigger_output', self.data)
|
||||
self.assertFalse(self.room.state['commands'])
|
||||
def test_pending_and_unprepared_rejected(self):
|
||||
self.room.state['phase']='idle'
|
||||
with self.assertRaises(ValueError): self.fleet.operator('trigger_output',self.data)
|
||||
self.room.state['phase']='ready'
|
||||
self.fleet.operator('trigger_output',self.data)
|
||||
with self.assertRaises(ValueError): self.fleet.operator('trigger_output',dict(self.data,request_id='test-request-0002'))
|
||||
def test_hub_config(self):
|
||||
p=Path(self.temp.name)/'hub.json'
|
||||
service=dict(id='trivia',name='Trivia',kind='trivia',url='http://trivia.local:8080')
|
||||
p.write_text(json.dumps(dict(services=[service])))
|
||||
self.assertEqual(server.hub_config(p)['room_name'],'Glitch: the Dark Machine')
|
||||
for url in ['javascript:alert(1)','http://user:password@host','http://host/?token=secret','http://host/#secret','http://host:invalid']:
|
||||
p.write_text(json.dumps(dict(services=[dict(service,url=url)])))
|
||||
with self.subTest(url=url),self.assertRaises(ValueError): server.hub_config(p)
|
||||
self.assertEqual(self.fleet.snapshot()['hub']['name'],'Escape Gallery')
|
||||
@@ -0,0 +1,78 @@
|
||||
import unittest
|
||||
import test_fleet
|
||||
|
||||
class ObjectiveTests(unittest.TestCase):
|
||||
setUp = test_fleet.FleetTests.setUp
|
||||
tearDown = test_fleet.FleetTests.tearDown
|
||||
def poll(self, cabinet, reports=None, features=True):
|
||||
return self.fleet.poll(dict(cabinet=cabinet,protocol=2,boot=cabinet,reports=reports or [],features=['assignment_config_v1'] if features else []))
|
||||
|
||||
def test_config_reaches_prepare_and_manual_clears_it(self):
|
||||
self.fleet.operator('prepare_scenario',{'scenario':'three_cabinet_objective_test'})
|
||||
command=self.poll('cabinet_01')['commands'][0]
|
||||
self.assertEqual(command['game_config']['test_auto_win_seconds'],5)
|
||||
self.poll('cabinet_01',[dict(kind='ack',id=command['id'],session=command['session'],ok=True)])
|
||||
self.fleet.operator('reset',{'cabinet':'cabinet_01'})
|
||||
command=self.poll('cabinet_01')['commands'][0]
|
||||
self.poll('cabinet_01',[dict(kind='ack',id=command['id'],session=command['session'],ok=True)])
|
||||
self.fleet.operator('prepare',{'cabinet':'cabinet_01','game':'pacman'})
|
||||
self.assertEqual(self.poll('cabinet_01')['commands'][0]['game_config'],{})
|
||||
|
||||
def test_old_export_rejected_before_dispatch(self):
|
||||
self.poll('cabinet_03', features=False)
|
||||
with self.assertRaisesRegex(ValueError,'Update the game export on cabinet_03'):
|
||||
self.fleet.operator('prepare_scenario',{'scenario':'three_cabinet_objective_test'})
|
||||
self.assertFalse(self.poll('cabinet_01')['commands'])
|
||||
|
||||
def test_invalid_objective_rejected(self):
|
||||
assignment=self.catalog['scenarios']['three_cabinet_objective_test']['assignments'][2]
|
||||
for config in [{'homes_to_win':6},{'homes_to_win':True},{'homes_to_win':.5},{'unknown':1},{'test_auto_win_seconds':float('nan')}]:
|
||||
assignment['game_config']=config
|
||||
with self.assertRaises(ValueError):
|
||||
self.fleet.operator('prepare_scenario',{'scenario':'three_cabinet_objective_test'})
|
||||
self.assertFalse(self.poll('cabinet_01')['commands'])
|
||||
|
||||
def test_never_connected_cabinet_reports_offline_not_old_export(self):
|
||||
room=self.fleet.rooms['cabinet_03']
|
||||
room.last_seen=0
|
||||
room.features=[]
|
||||
with self.assertRaisesRegex(ValueError,'cabinet_03 is offline'):
|
||||
self.fleet.operator('prepare_scenario',{'scenario':'three_cabinet_objective_test'})
|
||||
self.assertFalse(self.poll('cabinet_01')['commands'])
|
||||
|
||||
def test_rfid_credit_validation_and_start_gate(self):
|
||||
room=self.fleet.rooms['cabinet_01']
|
||||
room.features=['assignment_config_v1','rfid_credit_v1']
|
||||
room.gpio='real'
|
||||
self.catalog['cabinets']['cabinet_01']['devices']=[dict(id='reader',kind='rfid')]
|
||||
item=dict(cabinet='cabinet_01',game='pacman',game_config=dict(rfid_credit_device='reader',rfid_allowed_uids=[]))
|
||||
self.fleet.validate_assignment(item)
|
||||
for changes in [dict(rfid_credit_device='missing'),dict(rfid_allowed_uids=['bad']),dict(rfid_allowed_uids=True)]:
|
||||
invalid=dict(item,game_config=dict(item['game_config'],**changes))
|
||||
with self.assertRaises(ValueError): self.fleet.validate_assignment(invalid)
|
||||
room.features=['assignment_config_v1']
|
||||
with self.assertRaisesRegex(ValueError,'2.3'): self.fleet.validate_assignment(item)
|
||||
room.features.append('rfid_credit_v1')
|
||||
room.gpio='simulation'
|
||||
with self.assertRaisesRegex(ValueError,'real GPIO'): self.fleet.validate_assignment(item)
|
||||
room.state.update(phase='ready',game_config=item['game_config'],commands=[])
|
||||
with self.assertRaisesRegex(ValueError,'press Start'): self.fleet.operator('start',dict(cabinet='cabinet_01'))
|
||||
|
||||
def test_shipped_rfid_scenario_prepares_without_outputs(self):
|
||||
# Exercise the actual exported scenario as well as the protocol fixture.
|
||||
self.tearDown()
|
||||
self.catalog=test_fleet.json.loads((test_fleet.ROOT/'tools/room_controller/catalog.json').read_text(encoding='utf-8'))
|
||||
self.fleet=test_fleet.server.Fleet(':memory:',self.catalog,lambda:self.now)
|
||||
self.poll('cabinet_01')
|
||||
room=self.fleet.rooms['cabinet_01']
|
||||
room.features=['assignment_config_v1','rfid_credit_v1']
|
||||
room.gpio='real'
|
||||
self.fleet.operator('prepare_scenario', {'scenario':'pacman_rfid_credit_test'})
|
||||
command=self.poll('cabinet_01')['commands'][0]
|
||||
self.assertEqual(command['game_config']['rfid_credit_device'],'cab1_rfid1')
|
||||
self.assertEqual(command['type'],'prepare')
|
||||
self.poll('cabinet_01',[dict(kind='ack',id=command['id'],session=command['session'],ok=True)])
|
||||
self.assertEqual(room.state['phase'],'ready')
|
||||
self.assertFalse(any(c['type'] in ('output','reward','start') for c in room.state['commands']))
|
||||
self.poll('cabinet_01',[dict(kind='event',id='physical-start',session=command['session'],event='started')])
|
||||
self.assertEqual(room.state['phase'],'playing')
|
||||
@@ -0,0 +1,138 @@
|
||||
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())
|
||||
Reference in new issue
Block a user