Retain aircraft through native landing and qualify bounded input diagnostics
This commit is contained in:
@@ -6,6 +6,8 @@ from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import copy
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -720,6 +722,10 @@ class ApproachTests(unittest.TestCase):
|
||||
'observer':{'records':observer.raw,'events':observer.events},'provenance_budget':'x'*65536}
|
||||
report['alignment_requested']=True
|
||||
report['alignment_proof']={'samples':full[-80:],'motions':[{'dx':128,'dy':0,'monotonic':10,'after_sample':i,'response_sample':i+1} for i in range(6)]}
|
||||
report['selection_requested']=True
|
||||
report['facing_proof']={'samples':full[-80:],'motions':report['alignment_proof']['motions']}
|
||||
report['action_selection_proof']={'samples':full[-20:],'wheel':{'button':5,'released':True}}
|
||||
report['wheel_events']=[{'button':5,'down':True,'monotonic':10.,'sent':True},{'button':5,'down':False,'monotonic':10.1,'sent':True}]
|
||||
payload=json.dumps(proof.report_for_write(report),indent=2).encode()
|
||||
self.assertEqual(len(observer.look_records),451);self.assertEqual(len(observer.gate_records),901)
|
||||
self.assertLess(len(payload),1024*1024)
|
||||
@@ -941,4 +947,230 @@ class GateChoicesArguments(unittest.TestCase):
|
||||
with self.assertRaises(proof.Refused):proof.wrapper_command(args)
|
||||
with self.assertRaises(proof.Refused):proof.game_arguments(owner,args)
|
||||
|
||||
|
||||
class WheelRig:
|
||||
class Fn:
|
||||
def __init__(self,fn):self.fn=fn
|
||||
def __call__(self,*args):return self.fn(*args)
|
||||
def __init__(self,clock=None):
|
||||
self.clock=clock or Clock();self.clock.now=max(self.clock.now,1.)
|
||||
self.mask=0;self.calls=[];self.fail_down=False;self.fail_up=False;self.sticky=False;self.lost=False
|
||||
self.report={};self.guards=0
|
||||
def event(d,button,down,delay):
|
||||
self.calls.append((button,down));self.clock.sleep(.001)
|
||||
if down:self.mask=1<<12
|
||||
elif not self.sticky:self.mask=0
|
||||
return 0 if (self.fail_down and down) or (self.fail_up and not down) else 1
|
||||
self.xt=SimpleNamespace(XTestFakeButtonEvent=self.Fn(event))
|
||||
self.query=SimpleNamespace(d=object(),x=SimpleNamespace(XFlush=lambda d:None))
|
||||
self.keyboard=SimpleNamespace(require_idle=lambda:None)
|
||||
self.mouse=SimpleNamespace(xt=self.xt,query=self.query,keyboard=self.keyboard,validate_pointer=self.position)
|
||||
def position(self,target,dx,dy,allowed_mask=0):
|
||||
assert dx==dy==0
|
||||
if self.mask & ~allowed_mask:raise proof.Refused('foreign button')
|
||||
return {'mask':self.mask}
|
||||
def check(self):
|
||||
self.guards+=1;self.clock.sleep(.03)
|
||||
if self.lost:raise proof.Refused('ownership lost')
|
||||
return {}
|
||||
def wheel(self,deadline=3):return proof.SelectionWheel(self.mouse,self.report,self.clock,self.clock.sleep,deadline,enabled=True)
|
||||
|
||||
class SelectionIntegration(unittest.TestCase):
|
||||
def test_selection_requires_all_optins_and_exact_native_choices(self):
|
||||
argv=['--enable-private-keyboard-proof','--prefix','/tmp/pfx','--game-executable','/tmp/game','--profile','/tmp/profile','--report','/tmp/report','--host-display',':0','--nonce','N']
|
||||
flags=['--enable-private-selection-proof','--enable-private-alignment-proof','--enable-private-approach-proof','--enable-private-look-proof']
|
||||
self.assertFalse(proof.arguments(argv).enable_private_selection_proof)
|
||||
for missing in flags[1:]:
|
||||
with self.assertRaises(SystemExit),patch('sys.stderr'):proof.arguments(argv+[f for f in flags if f!=missing])
|
||||
args=proof.arguments(argv+flags)
|
||||
for native in (['-errOrdinaryGateObserve','N'],['-errOrdinaryGateObserve','N','-errOrdinaryGateChoices','other'],['-errOrdinaryGateObserve','N','-errOrdinaryGateChoices','N','-errOrdinaryGateChoices','N']):
|
||||
with self.assertRaises(proof.Refused):proof.gate_options(native,args)
|
||||
proof.gate_options(['-errOrdinaryGateObserve','N','-errOrdinaryGateChoices','N'],args)
|
||||
|
||||
def test_selection_sequence_uses_facing_selection_branch_without_default_alignment(self):
|
||||
clock=Clock();observer=FakeObserver();observer.enable_approach=True;observer.enable_selection=True;observer.ready_at=0
|
||||
keyboard=FakeKeyboard(observer);report={};calls=[]
|
||||
def approach(*a):report['approach_proof']={'completed':True};calls.append('approach')
|
||||
def select(*a):report.update(facing_proof={'completed':True},action_selection_proof={'completed':True});calls.append('selection')
|
||||
with patch.object(proof,'perform_look',side_effect=lambda *a:calls.append('look')),patch.object(proof,'run_approach',side_effect=approach),patch.object(proof,'run_selection',side_effect=select),patch.object(proof,'run_alignment',side_effect=AssertionError('strict alignment must stay separate')):
|
||||
proof.sequence(FakeGuard(),keyboard,observer,report,clock,clock.sleep,mouse=object(),approach=True,alignment=True,selection=True)
|
||||
self.assertEqual(calls,['look','approach','selection']);self.assertTrue(report['sequence_completed']);self.assertEqual(report['events'],[])
|
||||
with self.assertRaises(proof.Refused):proof.sequence(FakeGuard(),keyboard,observer,{},clock,clock.sleep,mouse=object(),approach=True,selection=True)
|
||||
|
||||
def test_actual_main_wires_selection_only_to_observer_and_sequence(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root=Path(folder);args=SimpleNamespace(nonce='N',prefix=root/'pfx',profile=root/'profile',game_executable=root/'game',host_display=':0',report=root/'report.json',console=root/'console.log',command=[],enable_private_look_proof=True,enable_private_approach_proof=True,enable_private_alignment_proof=True,enable_private_selection_proof=True)
|
||||
owner={'window':1,'pid':2,'server_pid':2,'process':{'pid':2}}
|
||||
query=SimpleNamespace(windows=lambda:[{}],focus=lambda:(1,0),close=lambda:None)
|
||||
observer=SimpleNamespace(identity=(1,2),start_offset=0,offset=0,raw=[],events=[],look_records=[],gate_records=[],close=lambda:None)
|
||||
polls=[]
|
||||
def startup_poll():
|
||||
polls.append(True)
|
||||
if len(polls)>=2:observer.events=['READY']
|
||||
observer.poll=startup_poll
|
||||
def ready_windows():
|
||||
self.assertGreaterEqual(len(polls),2,'Never bind a transient pre-READY window')
|
||||
self.assertEqual(observer.events,['READY'])
|
||||
return [{}]
|
||||
query.windows=ready_windows
|
||||
def sequence(guard,keyboard,o,report,**kw):
|
||||
self.assertIs(o,observer);self.assertTrue(kw['selection']);self.assertTrue(kw['alignment']);self.assertTrue(kw['approach'])
|
||||
self.assertNotIn('facing_proof',report);self.assertNotIn('action_selection_proof',report)
|
||||
self.assertEqual(set(report['selection_module_sha256']),set(proof.SELECTION_MODULES))
|
||||
report.update(sequence_completed=True,events=[],paired_release_errors=[])
|
||||
with patch.object(proof,'arguments',return_value=args),patch.object(proof,'wrapper_command',return_value=[]),patch.object(proof,'console_path'),patch.object(proof.display,'validate_paths'),patch.object(proof.display,'binding',return_value={'display':':2'}),patch.object(proof.display,'X11',return_value=query),patch.object(proof.display,'owned_window',return_value=owner),patch.object(proof.display,'input_target',return_value={'verified':True}),patch.object(proof,'Observer',autospec=True,return_value=observer) as ob,patch.object(proof,'Guard',autospec=True),patch.object(proof,'Keyboard',autospec=True),patch.object(proof,'RelativeMouse',autospec=True) as mouse,patch.object(proof,'sequence',side_effect=sequence),patch.object(proof.signal,'signal'):
|
||||
self.assertEqual(proof.main([]),0)
|
||||
self.assertTrue(ob.call_args.kwargs['enable_selection']);self.assertNotIn('selection',mouse.call_args.kwargs)
|
||||
archived=json.loads(args.report.read_text());self.assertTrue(archived['selection_requested']);self.assertEqual(archived['wheel_events'],[])
|
||||
|
||||
def test_one_native_wheel_pair_and_mask_roundtrip_no_retries(self):
|
||||
r=WheelRig();w=r.wheel();notices=[]
|
||||
with patch.object(proof,'notify',side_effect=notices.append):result=w.pulse(5,r,deadline=3)
|
||||
self.assertEqual(r.calls,[(5,1),(5,0)]);self.assertTrue(result['released'])
|
||||
self.assertLess(result['down_monotonic'],result['up_monotonic']);self.assertEqual(r.report['unconfirmed_held_button'],None)
|
||||
self.assertEqual([x['sent'] for x in r.report['wheel_events']],[True,True]);self.assertEqual(r.report['wheel_release_errors'],[])
|
||||
self.assertEqual([e['kind'] for e in notices],['button-pending','button-release-confirmed'])
|
||||
self.assertEqual(w.xt.XTestFakeButtonEvent.argtypes,[proof.C.c_void_p,proof.C.c_uint,proof.C.c_int,proof.C.c_ulong])
|
||||
for button in (5,4,True):
|
||||
with self.assertRaises(proof.Refused):w.pulse(button,r,deadline=3)
|
||||
self.assertEqual(len(r.calls),2)
|
||||
|
||||
def test_wheel_capability_deadline_foreign_state_and_cleanup(self):
|
||||
for fault in ('disabled','wrong','expired_during_guard','foreign','down_error','up_error','sticky'):
|
||||
r=WheelRig();deadline=3
|
||||
if fault=='foreign':r.mask=1<<8
|
||||
if fault=='down_error':r.fail_down=True
|
||||
if fault=='up_error':r.fail_up=True
|
||||
if fault=='sticky':r.sticky=True
|
||||
if fault=='expired_during_guard':deadline=1.05
|
||||
with self.subTest(fault=fault),self.assertRaises(proof.Refused):
|
||||
w=proof.SelectionWheel(r.mouse,r.report,r.clock,r.clock.sleep,deadline,enabled=fault!='disabled')
|
||||
w.pulse(4 if fault=='wrong' else 5,r,deadline=deadline)
|
||||
if fault in ('disabled','wrong','expired_during_guard','foreign'):self.assertEqual(r.calls,[])
|
||||
else:
|
||||
self.assertEqual(r.calls,[(5,1),(5,0)])
|
||||
self.assertTrue(r.report['wheel_release_errors'])
|
||||
if fault=='down_error':
|
||||
self.assertTrue(r.report['wheel_events'][-1]['finally']);self.assertIsNone(r.report['unconfirmed_held_button'])
|
||||
else:self.assertEqual(r.report['unconfirmed_held_button'],5)
|
||||
|
||||
def test_captured_pointer_prevents_cleanup_writes(self):
|
||||
r=WheelRig();original=r.xt.XTestFakeButtonEvent.fn
|
||||
def send(*a):result=original(*a);r.lost=True;return result
|
||||
r.xt.XTestFakeButtonEvent.fn=send
|
||||
with self.assertRaises(proof.Refused):r.wheel().pulse(5,r,deadline=3)
|
||||
self.assertEqual(r.calls,[(5,1)]);self.assertEqual(r.report['unconfirmed_held_button'],5);self.assertTrue(r.report['wheel_release_errors'])
|
||||
|
||||
def test_actual_pointer_validator_allows_only_own_button_mask_on_release(self):
|
||||
m=proof.RelativeMouse.__new__(proof.RelativeMouse);m.owner={'pid':7};m.query=SimpleNamespace(server_pid=lambda c:7)
|
||||
state={'root':1,'mask':1<<12,'root_x':5,'root_y':5,'child':0};m.pointer=lambda:state
|
||||
target={'geometry':{'root':1},'focused_clipped_bounds':[0,0,10,10]}
|
||||
with self.assertRaises(proof.Refused):m.validate_pointer(target,0,0)
|
||||
self.assertEqual(m.validate_pointer(target,0,0,allowed_mask=1<<12),state)
|
||||
state['mask']|=1<<8
|
||||
with self.assertRaises(proof.Refused):m.validate_pointer(target,0,0,allowed_mask=1<<12)
|
||||
state['mask']=0;state['root_x']=10
|
||||
with self.assertRaises(proof.Refused):m.validate_pointer(target,0,0,allowed_mask=1<<12)
|
||||
|
||||
def test_choice_join_waits_for_exact_complete_line_and_retains_receipts(self):
|
||||
from test_ordinary_gate_evidence import fixture,NONCE
|
||||
from test_ordinary_gate_choices import line as choice_line
|
||||
from ordinary_gate_evidence import parse_gate_record
|
||||
lines=[]
|
||||
for line in fixture().splitlines():
|
||||
if 'CANCELLED' in line:continue
|
||||
lines.append(line)
|
||||
record=parse_gate_record(line,NONCE)
|
||||
if record and record['kind']=='geometry':
|
||||
lines.append(choice_line(record['sample'],record['real_ms'],selected='none:-1',context=0,ordered='').replace('actions=2 permissions=2 shown=2','actions=0 permissions=0 shown=0'))
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path=Path(folder)/'console.log';path.write_text('');clock=Clock();clock.now=10
|
||||
o=proof.Observer(path,NONCE,clock=clock,enable_look=True,enable_approach=True,enable_selection=True)
|
||||
try:
|
||||
with path.open('a') as f:f.write('\n'.join(lines[:-1])+'\n')
|
||||
first=o.approach_snapshots();self.assertEqual(first[-1]['sample'],29)
|
||||
self.assertNotIn(30,o.gate_received);clock.sleep(.1)
|
||||
with path.open('a') as f:f.write(lines[-1]+'\n')
|
||||
after=o.approach_snapshots();self.assertEqual([x['sample'] for x in after],[30]);receipt=after[0]['received_monotonic']
|
||||
self.assertEqual(set(after[0]['choices']),{'nonce','sample','real_ms','selected_class','selected_id','actions'})
|
||||
o.selection_previous=28;clock.sleep(.1)
|
||||
batch=o.selection_snapshots();self.assertEqual([x['sample'] for x in batch],[29,30]);self.assertEqual(batch[-1]['received_monotonic'],receipt)
|
||||
self.assertEqual(o.choice_previous,30);self.assertFalse(hasattr(o,'choice_records'))
|
||||
with path.open('a') as f:f.write(lines[-1]+'\n')
|
||||
with self.assertRaisesRegex(proof.Refused,'repeated'):o.poll()
|
||||
finally:o.close()
|
||||
|
||||
def test_facing_selection_real_modules_through_one_wheel_adapter(self):
|
||||
from test_prison_action_selection_proof import ChoiceScene
|
||||
scene=ChoiceScene();report=scene.report;prior=copy.deepcopy(report['approach_proof'])
|
||||
report['look_proof']={'baseline_samples':[20,21],'response_sample':22};report['events']=[]
|
||||
o=SimpleNamespace(ready_at=2.,ready_real=32000,queued_real=30000,nonce='N',approach_data=lambda:('raw',{}),
|
||||
look_records=[{'kind':'sample','sample':21,'direction':scene.calibration['baseline_direction']},{'kind':'sample','sample':22,'direction':scene.calibration['response_direction']}],alignment_snapshots=scene.observe,alignment_previous=None)
|
||||
def observe_selection():
|
||||
if o.selection_previous is not None:scene.last_read=o.selection_previous;o.selection_previous=None
|
||||
return scene.observe()
|
||||
o.selection_snapshots=observe_selection
|
||||
r=WheelRig();r.clock=scene.clock;mask=[0];calls=[]
|
||||
def event(d,button,down,delay):
|
||||
calls.append((button,down));scene.sleep(.001);mask[0]=(1<<12) if down else 0
|
||||
if down:scene.selected_at=scene.now+.15
|
||||
return 1
|
||||
def position(target,dx,dy,allowed_mask=0):
|
||||
if mask[0]&~allowed_mask:raise proof.Refused('foreign mask')
|
||||
return {'mask':mask[0]}
|
||||
r.xt.XTestFakeButtonEvent.fn=event
|
||||
mouse=SimpleNamespace(xt=r.xt,query=r.query,keyboard=SimpleNamespace(require_idle=scene.idle),validate_pointer=position,
|
||||
alignment_move=lambda dx,dy,guard,deadline,clock:scene.move(dx,dy,guard,deadline=deadline))
|
||||
notices=[]
|
||||
def facing_evidence(*args):return {'verified':True,'last_sample':report['facing_proof']['final_sample']}
|
||||
with patch('prison_approach_evidence.validate_approach_evidence',return_value=scene.accepted),patch('prison_alignment_evidence.validate_facing_evidence',side_effect=facing_evidence),patch.object(proof,'notify',side_effect=notices.append):
|
||||
proof.run_selection(scene,SimpleNamespace(require_idle=scene.idle),o,mouse,report,scene.clock,scene.sleep)
|
||||
self.assertEqual(calls,[(5,1),(5,0)]);self.assertTrue(report['facing_proof']['completed']);self.assertTrue(report['action_selection_proof']['completed'])
|
||||
self.assertNotIn('alignment_proof',report);self.assertEqual(report['approach_proof'],prior);self.assertEqual(report['events'],[])
|
||||
self.assertEqual(report['action_selection_proof']['deadline_monotonic'],report['facing_proof']['deadline_monotonic'])
|
||||
finished=[n for n in notices if n['kind']=='alignment-finished'];self.assertEqual(finished,[{'kind':'alignment-finished','successful':True}])
|
||||
self.assertLess(scene.now,39.)
|
||||
|
||||
def test_refused_facing_validator_never_allocates_wheel_or_finishes_lease(self):
|
||||
from test_prison_action_selection_proof import ChoiceScene
|
||||
scene=ChoiceScene();report=scene.report
|
||||
report['look_proof']={'baseline_samples':[20,21],'response_sample':22}
|
||||
o=SimpleNamespace(ready_at=2.,ready_real=32000,queued_real=30000,nonce='N',approach_data=lambda:('raw',{}),look_records=[{'kind':'sample','sample':21,'direction':scene.calibration['baseline_direction']},{'kind':'sample','sample':22,'direction':scene.calibration['response_direction']}],alignment_snapshots=scene.observe)
|
||||
mouse=SimpleNamespace(alignment_move=lambda dx,dy,guard,deadline,clock:scene.move(dx,dy,guard,deadline=deadline));notices=[]
|
||||
with patch('prison_approach_evidence.validate_approach_evidence',return_value=scene.accepted),patch('prison_alignment_evidence.validate_facing_evidence',return_value={'verified':False}),patch.object(proof,'SelectionWheel',side_effect=AssertionError('no wheel before validation')),patch.object(proof,'notify',side_effect=notices.append):
|
||||
with self.assertRaisesRegex(proof.Refused,'facing prerequisite'):proof.run_selection(scene,SimpleNamespace(require_idle=scene.idle),o,mouse,report,scene.clock,scene.sleep)
|
||||
self.assertTrue(report['facing_proof']['completed']);self.assertNotIn('action_selection_proof',report)
|
||||
self.assertEqual([n for n in notices if n['kind']=='alignment-finished'],[{'kind':'alignment-finished','successful':False}])
|
||||
|
||||
def test_selection_compaction_and_hashes_leave_live_proof_intact(self):
|
||||
full={'sample':1,'real_ms':1,'received_monotonic':1.,'origin':[1,2,3],'choices':{'actions':[]}}
|
||||
report={'approach_requested':True,'alignment_requested':True,'selection_requested':True,'approach_proof':{'samples':[full]},'facing_proof':{'samples':[full]},'action_selection_proof':{'samples':[full]}}
|
||||
encoded=proof.report_for_write(report)
|
||||
for name in ('approach_proof','facing_proof','action_selection_proof'):
|
||||
self.assertEqual(set(encoded[name]['samples'][0]),{'sample','real_ms','received_monotonic'});self.assertIn('origin',report[name]['samples'][0])
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root=Path(folder)
|
||||
for name in proof.SELECTION_MODULES:(root/name).write_text(name)
|
||||
with patch.object(proof,'__file__',str(root/'headless_input_proof.py')):self.assertEqual(set(proof.module_hashes(proof.SELECTION_MODULES)),{'ordinary_gate_choices.py','prison_action_selection_proof.py','prison_action_selection_evidence.py'})
|
||||
|
||||
def test_button_supervisor_lease_is_independent_of_key_release(self):
|
||||
events=[{'kind':'report','metadata':{}},{'kind':'child','identity':{}},{'kind':'button-pending','button':5},{'kind':'release-confirmed','key':'W'}]
|
||||
class Pipe:
|
||||
def poll(self,*a):return bool(events)
|
||||
def recv(self):return events.pop(0)
|
||||
def close(self):pass
|
||||
class Process:
|
||||
pid=123;exitcode=0;alive=True;terminated=0
|
||||
def start(self):pass
|
||||
def is_alive(self):return self.alive
|
||||
def terminate(self):self.terminated+=1
|
||||
def join(self,*a):self.alive=False
|
||||
def kill(self):self.alive=False
|
||||
child=Process();pipe=Pipe();clock=Clock()
|
||||
def ticking():clock.now+=.25;return clock.now
|
||||
context=SimpleNamespace(Pipe=lambda **k:(pipe,pipe),Process=lambda **k:child)
|
||||
with patch.object(proof.multiprocessing,'get_context',return_value=context),patch.object(proof.time,'monotonic',side_effect=ticking),patch.object(proof.signal,'signal'),patch.object(proof,'stop_exact_child') as stop,patch.object(proof,'supervision_failure') as failed:
|
||||
self.assertEqual(proof.supervise([]),1);self.assertEqual(child.terminated,1)
|
||||
self.assertEqual(failed.call_args.kwargs['button'],5);self.assertIsNone(failed.call_args.args[1]);stop.assert_called_once()
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user