Verify bounded ordinary-input approach to the prison gate

This commit is contained in:
2026-09-19 19:21:21 +02:00
parent b7fba094d7
commit ec1071595d
90 changed files with 102832 additions and 35 deletions
+270
View File
@@ -479,4 +479,274 @@ class PointerTests(unittest.TestCase):
with self.assertRaises(proof.Refused):mouse.move(dx,dy,guard)
class ApproachTests(unittest.TestCase):
def test_explicit_mode_requires_look_and_matching_native_gate_nonce(self):
argv=['--enable-private-keyboard-proof','--prefix','/tmp/pfx','--game-executable','/tmp/game',
'--profile','/tmp/profile','--report','/tmp/report','--host-display',':0','--nonce','N']
with self.assertRaises(SystemExit),patch('sys.stderr'):
proof.arguments(argv+['--enable-private-approach-proof'])
self.assertFalse(proof.arguments(argv).enable_private_approach_proof)
args=proof.arguments(argv+['--enable-private-look-proof','--enable-private-approach-proof'])
native={'args':['-errOrdinaryInputObserve','N','-errOrdinaryLookObserve','N','-errOrdinaryGateObserve','N',
'-errOrdinaryScenario','core','-profile','Z:/tmp/profile']}
owner={'pid':123,'process':{'pid':123}}
with patch.object(proof.display,'process',return_value=native),patch.object(proof.display,'identity',return_value=owner['process']):
proof.game_arguments(owner,args)
native['args'][5]='foreign'
with self.assertRaisesRegex(proof.Refused,'gate observer nonce'):proof.game_arguments(owner,args)
def test_variant_runs_look_then_route_without_default_w_tab_cycle(self):
clock=Clock();observer=FakeObserver();observer.enable_approach=True;observer.ready_at=0
guard=FakeGuard();keyboard=FakeKeyboard(observer);report={};order=[];notifications=[]
def look(*args):order.append('look');report['look_proof']={'completed':True};clock.sleep(.2)
def route(g,k,o,m,r,c,s):
self.assertEqual(order,['look']);order.append('approach')
k.send('W',True);k.send('W',False)
r['approach_proof']={'completed':True,'paired_release_errors':[],'unconfirmed_held_key':None}
with patch.object(proof,'perform_look',side_effect=look),patch.object(proof,'run_approach',side_effect=route),patch.object(proof,'notify',side_effect=notifications.append):
proof.sequence(guard,keyboard,observer,report,clock,clock.sleep,mouse=object(),approach=True)
self.assertEqual(order,['look','approach']);self.assertEqual(keyboard.events,[('W',True),('W',False)])
self.assertTrue(report['sequence_completed']);self.assertTrue(report['all_attempted_presses_released'])
self.assertEqual(notifications[0],{'kind':'approach-pending','deadline_monotonic':35})
self.assertEqual(notifications[-1],{'kind':'approach-finished','successful':True})
def test_failed_look_never_calls_route_and_keeps_uncertain_route_release(self):
clock=Clock();o=FakeObserver();o.enable_approach=True;o.ready_at=0;k=FakeKeyboard(o);r={}
with patch.object(proof,'perform_look',side_effect=proof.Refused('look refusal')),patch.object(proof,'run_approach') as route:
with self.assertRaisesRegex(proof.Refused,'look refusal'):
proof.sequence(FakeGuard(),k,o,r,clock,clock.sleep,mouse=object(),approach=True)
route.assert_not_called();self.assertEqual(k.events,[])
def failed(*args):
r['approach_proof']={'completed':False,'unconfirmed_held_key':'W','paired_release_errors':['ownership lost']}
raise proof.Refused('route refusal')
with patch.object(proof,'perform_look'),patch.object(proof,'run_approach',side_effect=failed):
with self.assertRaisesRegex(proof.Refused,'route refusal'):
proof.sequence(FakeGuard(),k,o,r,clock,clock.sleep,mouse=object(),approach=True)
self.assertEqual(r['unconfirmed_held_key'],'W');self.assertFalse(r['all_attempted_presses_released'])
self.assertFalse(r['sequence_completed']);self.assertEqual(r['paired_release_errors'],['ownership lost'])
def test_original_ready_deadline_not_renewed_after_calibration(self):
clock=Clock();clock.now=34;o=FakeObserver();o.enable_approach=True;o.ready_at=0;k=FakeKeyboard(o);r={}
def route(*args):clock.sleep(2)
with patch.object(proof,'perform_look'),patch.object(proof,'run_approach',side_effect=route):
with self.assertRaisesRegex(proof.Refused,'Original approach deadline'):
proof.sequence(FakeGuard(),k,o,r,clock,clock.sleep,mouse=object(),approach=True)
self.assertFalse(r['sequence_completed']);self.assertEqual(k.events,[])
def test_w_only_adapter_rejects_other_keys_and_expiry_but_allows_release(self):
clock=Clock();o=FakeObserver();k=FakeKeyboard(o);r={'events':[]}
adapter=proof.ApproachKeyboard(k,r,clock,35)
for key,down in [('F',True),('Tab',True),('W',1)]:
with self.assertRaises(proof.Refused):adapter.send(key,down)
adapter.send('W',True);clock.now=35
with self.assertRaisesRegex(proof.Refused,'deadline'):adapter.send('W',True)
adapter.send('W',False)
self.assertEqual(k.events,[('W',True),('W',False)]);self.assertTrue(r['input_sent'])
def test_relative_approach_requires_explicit_capability_and_checks_pointer_and_deadline(self):
class Fn:
def __init__(self,fn):self.fn=fn
def __call__(self,*args):return self.fn(*args)
events=[];xt=SimpleNamespace(XTestFakeRelativeMotionEvent=Fn(lambda *a:events.append(a) or 1))
x=SimpleNamespace(XQueryPointer=Fn(lambda *a:1),XFlush=lambda d:None)
keyboard=SimpleNamespace(xt=xt,require_idle=lambda:None);query=SimpleNamespace(x=x,d=123)
mouse=proof.RelativeMouse(query,keyboard,{'window':100,'pid':9});clock=Clock();checks=[]
mouse.validate_pointer=lambda *a:checks.append(a);guard=SimpleNamespace(check=lambda:{'verified':True})
with self.assertRaises(proof.Refused):mouse.approach_move(1,0,guard,deadline=35,clock=clock)
mouse.enable_approach=True
for dx,dy in [(1,0),(-128,0),(128,0)]:mouse.approach_move(dx,dy,guard,deadline=35,clock=clock)
self.assertEqual(len(events),3);self.assertEqual(len(checks),6)
for dx,dy in [(0,0),(129,0),(-129,0),(True,0),(1,1),(1,False)]:
with self.assertRaises(proof.Refused):mouse.approach_move(dx,dy,guard,deadline=35,clock=clock)
def delayed(*args):clock.now=35
mouse.validate_pointer=delayed
with self.assertRaisesRegex(proof.Refused,'deadline'):mouse.approach_move(1,0,guard,deadline=35,clock=clock)
self.assertEqual(len(events),3)
def test_raw_join_retains_partial_pair_and_every_intermediate_snapshot(self):
from test_ordinary_gate_evidence import fixture,NONCE
lines=[s for s in fixture().splitlines() if 'CANCELLED' not in s]
with tempfile.TemporaryDirectory() as folder:
path=Path(folder)/'console.log';path.write_text('')
clock=Clock();clock.now=10
observer=proof.Observer(path,NONCE,clock=clock,enable_look=True,enable_approach=True)
try:
# Split after SAMPLE but before GEOMETRY: pair29 is newest complete.
with path.open('a') as out:out.write('\n'.join(lines[:-1])+'\n')
first=observer.approach_snapshots();self.assertEqual([s['sample'] for s in first],[29])
self.assertEqual(first[0]['geometry']['gate'],(6090.,211.344,7154.))
clock.sleep(.1)
with path.open('a') as out:out.write(lines[-1]+'\n')
following=observer.approach_snapshots();self.assertEqual([s['sample'] for s in following],[30])
received=following[0]['received_monotonic'];clock.sleep(.1)
repeated=observer.approach_snapshots();self.assertEqual(repeated,following)
self.assertEqual(repeated[0]['received_monotonic'],received)
clock.sleep(.5)
with self.assertRaisesRegex(proof.Refused,'stale'):observer.approach_snapshots()
finally:observer.close()
with tempfile.TemporaryDirectory() as folder:
path=Path(folder)/'console.log';path.write_text('');clock=Clock()
observer=proof.Observer(path,NONCE,clock=clock,enable_look=True,enable_approach=True)
try:
pivot=next(i for i,s in enumerate(lines) if 'sample=27 ' in s)
with path.open('a') as out:out.write('\n'.join(lines[:pivot])+'\n')
self.assertEqual(observer.approach_snapshots()[-1]['sample'],26)
clock.sleep(.1)
with path.open('a') as out:out.write('\n'.join(lines[pivot:])+'\n')
self.assertEqual([s['sample'] for s in observer.approach_snapshots()],[27,28,29,30])
finally:observer.close()
def test_raw_join_refuses_gate_cancel_foreign_nonce_and_changed_geometry(self):
from test_ordinary_gate_evidence import fixture,NONCE
clean='\n'.join(s for s in fixture().splitlines() if 'CANCELLED' not in s)+'\n'
for change in ('cancel','foreign','geometry','clock','action'):
with self.subTest(change=change),tempfile.TemporaryDirectory() as folder:
path=Path(folder)/'console.log';path.write_text('')
observer=proof.Observer(path,NONCE,clock=lambda:10,enable_look=True,enable_approach=True)
try:
raw=clean
if change=='cancel':raw+='ERR GATE OBSERVE: run='+NONCE+' sample=31 real=4410 CANCELLED native-player-or-prison-context-lost\n'
if change=='foreign':raw=raw.replace('ERR GATE OBSERVE: run='+NONCE,'ERR GATE OBSERVE: run=foreign')
if change=='geometry':raw=raw.replace('sample=30 real=4300 GEOMETRY body=', 'sample=30 real=4300 GEOMETRY body=').replace('sample=30 real=4300 GEOMETRY body=<6088.5,211.389,7158.5> context=<6092.77', 'sample=30 real=4300 GEOMETRY body=<6088.5,211.389,7158.5> context=<6093.77')
if change=='action':raw=raw.replace('duration=4 pressed=0','duration=4 pressed=1')
if change=='clock':raw=raw.replace('sample=30 real=4300 GEOMETRY','sample=30 real=4301 GEOMETRY')
with path.open('a') as out:out.write(raw)
with self.assertRaises(ValueError):observer.approach_snapshots()
finally:observer.close()
def test_adapter_requires_independent_look_acceptance_before_module(self):
observer=SimpleNamespace(approach_data=lambda:('raw',{}),nonce='N');report={'look_proof':{}}
with patch('ordinary_look_evidence.validate_look_evidence',return_value={'verified':False,'errors':['native mismatch']}),patch('prison_approach_proof.perform_approach') as run:
with self.assertRaisesRegex(proof.Refused,'calibration refused'):
proof.run_approach(None,None,observer,None,report,lambda:0,lambda s:None)
run.assert_not_called()
def test_real_calibration_adapter_preserves_clocks_caps_and_completion_owner(self):
import math
from test_ordinary_gate_evidence import fixture,NONCE
raw='\n'.join(s for s in fixture().splitlines() if 'CANCELLED' not in s)+'\n'
angle=math.radians(2)
raw='\n'.join(line.replace('direction=<0,0,1>',f'direction=<{math.sin(angle)},0,{math.cos(angle)}>') if 'ERR LOOK OBSERVE:' in line and 'sample=22 ' in line else line for line in raw.splitlines())+'\n'
with tempfile.TemporaryDirectory() as folder:
path=Path(folder)/'console.log';path.write_text('');clock=Clock();clock.now=10
observer=proof.Observer(path,NONCE,clock=clock,enable_look=True,enable_approach=True)
try:
with path.open('a') as out:out.write(raw)
observer.fresh();clock.sleep(.4)
look={'requested':True,'completed':True,'started_monotonic':10.,'finished_monotonic':10.4,
'baseline_samples':[20,21],'response_sample':22,'return_sample':23,
'motions':[{'dx':24,'dy':0,'monotonic':10.1,'after_sample':21,'after_real_ms':3310},
{'dx':-24,'dy':0,'monotonic':10.3,'after_sample':22,'after_real_ms':3420}]}
report={'look_proof':look,'events':[]};keyboard=FakeKeyboard(observer);notifications=[];motions=[]
mouse=SimpleNamespace(approach_move=lambda *a,**k:motions.append((a,k)))
def module(guard,k,m,observe,geometry,calibration,r,**kwargs):
self.assertEqual(calibration['ready_monotonic'],10)
self.assertEqual(calibration['ready_real_ms'],3091)
self.assertEqual(calibration['observer_start_real_ms'],1000)
self.assertEqual(kwargs['key_state_busy'],proof.KeyStateBusy)
self.assertEqual(observe()[-1]['geometry'],geometry)
self.assertEqual(geometry['nonce'],NONCE)
with self.assertRaises(proof.Refused):k.send('F',True)
k.send('W',True);k.send('W',False);m.move(12,0,guard)
r['approach_proof']={'completed':True}
kwargs['notify']({'kind':'approach-pending','deadline_monotonic':45})
kwargs['notify']({'kind':'approach-finished','successful':True})
with patch('prison_approach_proof.perform_approach',side_effect=module),patch.object(proof,'notify',side_effect=notifications.append):
proof.run_approach(FakeGuard(),keyboard,observer,mouse,report,clock,clock.sleep)
self.assertEqual([e['key'] for e in report['events']],['W','W'])
self.assertEqual(motions[0][1]['deadline'],45)
self.assertEqual(notifications,[{'kind':'approach-pending','deadline_monotonic':45}])
self.assertTrue(report['input_sent'])
finally:observer.close()
def test_actual_main_metadata_allows_exactly_one_module_attempt(self):
import copy
from test_prison_approach_proof import Scene
notices=[]
with tempfile.TemporaryDirectory() as folder:
args=SimpleNamespace(enable_private_look_proof=True,enable_private_approach_proof=True,nonce='test',
report=Path(folder)/'proof.json',console=Path(folder)/'console.log',prefix=Path(folder)/'pfx',
profile=Path(folder),game_executable=Path(folder)/'game',host_display=':0')
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,'Observer',side_effect=OSError('metadata-only fixture stop')),patch.object(proof.signal,'signal'),patch.object(proof,'notify',side_effect=lambda event:notices.append(copy.deepcopy(event))):
self.assertEqual(proof.main([]),1)
metadata=next(event['metadata'] for event in notices if event['kind']=='report')
self.assertTrue(metadata['approach_requested']);self.assertNotIn('approach_proof',metadata)
self.assertEqual(set(metadata['approach_module_sha256']),set(proof.APPROACH_MODULES))
scene=Scene();self.assertTrue(scene.run(report=metadata)['completed'])
with self.assertRaisesRegex(ValueError,'already exists'):scene.run(report=metadata)
def test_final_serialization_compacts_success_and_failure_without_mutating_live_samples(self):
import copy
sample={'sample':42,'real_ms':5200,'received_monotonic':10.2,'origin':[1,2,3],'geometry':{'large':'pose'}}
for completed in (True,False):
report={'approach_requested':True,'approach_proof':{'completed':completed,'samples':[copy.deepcopy(sample)],'error':'kept'}}
encoded=proof.report_for_write(report)
self.assertEqual(encoded['approach_proof']['samples'],[{'sample':42,'real_ms':5200,'received_monotonic':10.2}])
self.assertEqual(report['approach_proof']['samples'],[sample])
self.assertEqual(encoded['approach_proof']['error'],'kept')
ordinary={'approach_requested':False,'events':[]}
self.assertIs(proof.report_for_write(ordinary),ordinary)
def test_exact_module_hashes_are_filename_bound_and_missing_module_refuses(self):
import hashlib
with tempfile.TemporaryDirectory() as folder:
root=Path(folder)
for name in proof.APPROACH_MODULES:(root/name).write_text(name)
with patch.object(proof,'__file__',str(root/'headless_input_proof.py')):
values=proof.approach_module_hashes()
self.assertEqual(set(values),set(proof.APPROACH_MODULES))
self.assertEqual(values['prison_approach_proof.py'],hashlib.sha256(b'prison_approach_proof.py').hexdigest())
(root/'prison_approach_proof.py').unlink()
with self.assertRaises(OSError):proof.approach_module_hashes()
def test_max_native_record_budget_compact_report_remains_below_one_mebibyte(self):
import copy,json
from test_ordinary_gate_evidence import fixture,NONCE
template=fixture().splitlines()
initial=[line for line in template if 'sample=0 ' in line]
basis=[line for line in template if 'sample=1 ' in line and 'BASIS' in line]
ordinary=[line for line in template if 'sample=2 ' in line]
lines=list(initial)
for index in range(1,451):
t=1000+index*90;world=10000+index*90
for line in ordinary:
line=line.replace('sample=2 real=1220','sample='+str(index)+' real='+str(t)).replace('world=10220','world='+str(world))
if index==1:
for ack in basis:
if ('ERR LOOK OBSERVE:' in line and 'ERR LOOK OBSERVE:' in ack) or ('SAMPLE phase=' in line and 'ERR GATE OBSERVE:' in ack):
lines.append(ack.replace('real=1110','real='+str(t)))
lines.append(line)
if index==23:lines.append(f'ERR INPUT OBSERVE: run={NONCE} sample={index} real={t+1} world={world} READY')
with tempfile.TemporaryDirectory() as folder:
path=Path(folder)/'console.log';path.write_text('')
observer=proof.Observer(path,NONCE,clock=lambda:10.,enable_look=True,enable_approach=True)
try:
with path.open('a') as out:out.write('\n'.join(lines)+'\n')
while observer.offset<path.stat().st_size:observer.poll()
sample=observer.approach_snapshots()[-1]
full=[{**copy.deepcopy(sample),'sample':i} for i in range(1,451)]
report={'approach_requested':True,'approach_proof':{'samples':full,'motions':[{'dx':128,'dy':0,'monotonic':10,'after_sample':i,'response_sample':i+1} for i in range(16)],'events':[{'key':'W','down':bool(i%2),'monotonic':10} for i in range(24)]},
'look_observation':observer.look_records,'gate_observation':observer.gate_records,
'observer':{'records':observer.raw,'events':observer.events},'provenance_budget':'x'*65536}
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)
self.assertEqual(len(report['approach_proof']['samples']),450)
self.assertIn('geometry',report['approach_proof']['samples'][0])
finally:observer.close()
def test_approach_watchdog_cannot_be_renewed_by_module_notification(self):
events=[{'kind':'report','metadata':{}},{'kind':'child','identity':{}},
{'kind':'approach-pending','deadline_monotonic':10},
{'kind':'approach-pending','deadline_monotonic':30}]
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+=1;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.assertLessEqual(clock.now,11)
stop.assert_called_once();self.assertIsNone(failed.call_args.args[1])
if __name__ == '__main__': unittest.main()
+3 -2
View File
@@ -26,7 +26,7 @@ using string=std::string;using UUID=string;
struct vector {float v[3]={0,0,0}; float& operator[](int i){return v[i];}float Length(){return std::sqrt(v[0]*v[0]+v[1]*v[1]+v[2]*v[2]);}vector operator/(float n){return {{v[0]/n,v[1]/n,v[2]/n}};}};
vector Vector(float a,float b,float c){return {{a,b,c}};}
struct BaseWorld {};
struct IEntity {BaseWorld* world=nullptr;bool living=true; vector matrix[4]={Vector(1,0,0),Vector(0,1,0),Vector(0,0,1),Vector(100,2,100)};BaseWorld* GetWorld(){return world;}void GetTransform(vector out[4]){for(int i=0;i<4;i++)out[i]=matrix[i];}};
struct IEntity {BaseWorld* world=nullptr;bool living=true;vector origin=Vector(100,2,100);vector GetOrigin(){return origin;} vector matrix[4]={Vector(1,0,0),Vector(0,1,0),Vector(0,0,1),Vector(100,2,100)};BaseWorld* GetWorld(){return world;}void GetTransform(vector out[4]){for(int i=0;i<4;i++)out[i]=matrix[i];}};
struct CameraManager;
struct PlayerCamera:IEntity {CameraManager* replaceDuringRead=nullptr;void GetWorldCameraTransform(vector out[4]);};
struct CameraManager {PlayerCamera* current=nullptr;PlayerCamera* CurrentCamera(){return current;}};
@@ -62,13 +62,14 @@ struct Fixture {BaseWorld world,foreignWorld;ERR_GameMode mode;IEntity actor,oth
};
int main(){
{Fixture f;f.sample();assert(headers==1&&samples==1&&!cancels);assert(lastDirection[0]==1&&lastDirection[2]==0);assert(lastFacing[2]==1);assert(lastCamera[1]!=lastBody[1]);assert(lastTurn[0]==.25f&&lastTurn[1]==-.1f);f.sample(2,1200);assert(headers==1&&samples==2);}
{Fixture f;f.actor.origin=Vector(100.04,1.999,99.97);f.actor.matrix[3]=Vector(100,2,100);f.actor.matrix[2]=Vector(0,0,-1);f.sample();assert(samples==1&&!cancels);assert(lastBody[0]==f.actor.origin[0]&&lastBody[1]==f.actor.origin[1]&&lastBody[2]==f.actor.origin[2]);assert(lastBody[0]!=f.actor.matrix[3][0]);assert(lastFacing[2]==-1);assert(lastCamera[1]==f.camera.matrix[3][1]&&lastDirection[0]==1);}
for(int fault=0;fault<7;fault++){Fixture f;if(fault==0)System::cli.erase("errOrdinaryLookObserve");if(fault==1)System::cli["errOrdinaryLookObserve"]="foreign";if(fault==2)System::cli["errOrdinaryInputObserve"]="foreign";if(fault==3)System::console=true;if(fault==4)Replication::server=false;if(fault==5)RplSession::mode=RplMode::Listen;if(fault==6)System::cli["errOrdinaryScenario"]="other";f.sample();assert(!samples&&!headers&&!cancels&&!ERR_OrdinaryLookObserver::s_Attempted);}
{Fixture f;System::cli["errOrdinaryScenario"]="chat";f.sample();assert(samples==1);System::cli.erase("errOrdinaryLookObserve");f.sample(2,1200);assert(cancels==1);System::cli["errOrdinaryLookObserve"]="Run42";f.sample(3,1300);assert(samples==1&&cancels==1);}
for(int fault=0;fault<16;fault++){Fixture f;f.sample();if(fault==0)f.cameras.current=&f.otherCamera;if(fault==1)f.controller.camera=&f.otherCamera;if(fault==2)game.controller=&f.otherController;if(fault==3)f.controller.actor=&f.otherActor;if(fault==4)f.players.actor=&f.otherActor;if(fault==5)f.players.controller=&f.otherController;if(fault==6)f.life.AccountId="replacement";if(fault==7)f.roster.life=&f.otherLife;if(fault==8)f.actor.world=&f.foreignWorld;if(fault==9)f.camera.world=&f.foreignWorld;if(fault==10)f.mode.active=false;if(fault==11)f.mode.phase=ERR_Phase::EXTRACTION;if(fault==12)f.actor.living=false;if(fault==13)game.cameras=nullptr;if(fault==14)activeGame=nullptr;if(fault==15)f.menus.open=true;f.sample(2,1200);assert(samples==1&&cancels==1);if(fault==15)assert(reason=="menu-opened");f.sample(3,1300);assert(cancels==1);}
for(int fault=0;fault<5;fault++){Fixture f;f.sample();int index=2,now=1200;if(fault==0)index=1;if(fault==1)index=3;if(fault==2)index=451;if(fault==3)now=1099;if(fault==4)now=46000;f.sample(index,now);assert(samples==1&&cancels==1&&reason=="sample-or-clock-bound");}
{Fixture f;f.sample();for(int i=2;i<=450;i++)f.sample(i,1000+i*99);assert(samples==450&&!cancels);f.sample(451,45649);assert(cancels==1);}
{Fixture f;f.camera.replaceDuringRead=&f.cameras;f.sample();assert(!headers&&!samples&&cancels==1);}
for(int fault=0;fault<4;fault++){Fixture f;if(fault==0)f.camera.matrix[2]=Vector(0,0,0);if(fault==1)f.camera.matrix[3][0]=std::numeric_limits<float>::infinity();if(fault==2)f.input.up=std::numeric_limits<float>::quiet_NaN();if(fault==3)f.actor.matrix[2]=Vector(3,0,0);f.sample();assert(!samples&&cancels==1&&reason=="invalid-native-pose");}
for(int fault=0;fault<5;fault++){Fixture f;if(fault==0)f.camera.matrix[2]=Vector(0,0,0);if(fault==1)f.camera.matrix[3][0]=std::numeric_limits<float>::infinity();if(fault==2)f.input.up=std::numeric_limits<float>::quiet_NaN();if(fault==3)f.actor.matrix[2]=Vector(3,0,0);if(fault==4)f.actor.origin[0]=std::numeric_limits<float>::quiet_NaN();f.sample();assert(!samples&&cancels==1&&reason=="invalid-native-pose");}
{Fixture f;f.sample();ERR_OrdinaryLookObserver::Sample(&f.mode,&f.foreignWorld,&f.controller,&f.actor,"Run42",1000,2,1200);assert(cancels==1&&reason=="original-binding-changed");}
}
''')
+82
View File
@@ -0,0 +1,82 @@
"""Independent native route policy regression; no input/display operations."""
import copy
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
import prison_approach_evidence as v
def fixture():
samples={}
for i in range(21,36):
moved=min(6,max(0,i-24))*.15
p=[6092.77,211.44,7157.1-moved]
active=25<=i<=29
samples[i]={'sample':i,'real_ms':1000+i*100,
'input':{'origin':p,'forward':1. if active else 0.,'input':[0,0,1 if active else 0],'velocity':[0,0,-1.5 if active else 0]},
'look':{'direction':[0,0,-1]},
'action':{'pressed':0.,'performing':False,'progress':0.},
'geometry':{'body':p,'prison':[6090,211.44,7160],'gate':[6090,211.34,7154],'context':[6092.77,213.22,7154],'gate_forward':[0,0,1],'door_state':0.,'door_control':0.}}
data={'snapshots':samples,'ready':{'sample':20,'real_ms':3000}}
look={'return_sample':20,'finished_monotonic':9.}
proof={'final_sample':35,'requested':True,'completed':True,'nonce':'N','paired_release_errors':[],'unconfirmed_held_key':None,'started_monotonic':10.,'finished_monotonic':12.,'deadline_monotonic':35.,'events':[{'key':'W','down':True,'monotonic':10.5},{'key':'W','down':False,'monotonic':10.8}],'stops':[{'before_sample':24,'after_sample':33}],'motions':[],'samples':[{'sample':i,'real_ms':1000+i*100,'received_monotonic':10+(i-21)*.1} for i in range(21,36)]}
return data,look,proof
class RouteEvidence(unittest.TestCase):
def run_case(self, mutate=None, geometry=True, calibration=True):
d,l,p=fixture()
if mutate:mutate(d,l,p)
with patch.object(v,'join_gate_snapshots',return_value=d),patch.object(v,'validate_gate_geometry',return_value={'verified':geometry,'errors':[]}),patch.object(v,'validate_look_evidence',return_value={'verified':calibration,'errors':[],'response_yaw_degrees':2.}) as validator:
r=v.validate_approach_evidence('raw','N',l,p)
if r['verified']:self.assertEqual(validator.call_args.args[-1],p['events'][:2])
return r
def reject(self, mutation):
r=self.run_case(mutation);self.assertFalse(r['verified'],r);self.assertTrue(r['errors'])
def test_stopped_interior_route_is_limited_not_escape(self):
r=self.run_case();self.assertTrue(r['verified'],r);self.assertAlmostEqual(r['path_metres'],.9);self.assertAlmostEqual(r['final_distance_metres'],.7);self.assertFalse(r['escape_verified']);self.assertFalse(r['held_action_verified'])
def test_no_helper_completion_or_release_shortcut(self):
for field,value in [('completed',False),('nonce','foreign'),('paired_release_errors',['failed']),('unconfirmed_held_key','W')]:
self.reject(lambda d,l,p:p.update({field:value}))
def test_requires_both_independent_raw_gate_and_calibration_verification(self):
self.assertFalse(self.run_case(geometry=False)['verified']);self.assertFalse(self.run_case(calibration=False)['verified'])
def test_actual_held_time_and_key_shape_not_requested_sleep(self):
self.reject(lambda d,l,p:p['events'][1].update(monotonic=11.))
self.reject(lambda d,l,p:p['events'][0].update(key='F'))
self.reject(lambda d,l,p:p['events'][0].update(down=1))
self.reject(lambda d,l,p:p['events'][1].update(monotonic=10.4))
def test_final_coasting_or_wrong_waypoint_not_success(self):
self.reject(lambda d,l,p:d['snapshots'][35]['input'].update(velocity=[0,0,.1]))
self.reject(lambda d,l,p:d['snapshots'][34]['input'].update(velocity=[0,0,.1]))
self.reject(lambda d,l,p:d['snapshots'][35]['input'].update(origin=[6092.77,211.44,7156.4]))
def test_native_forward_input_required(self):
def mutation(d,l,p):
for s in d['snapshots'].values():s['input']['forward']=0
self.reject(mutation)
def test_gate_operation_or_teleport_refused(self):
self.reject(lambda d,l,p:d['snapshots'][27]['geometry'].update(door_state=.1))
self.reject(lambda d,l,p:d['snapshots'][27]['action'].update(pressed=1))
self.reject(lambda d,l,p:d['snapshots'][27]['input'].update(origin=[6092.77,211.44,7153.5]))
self.reject(lambda d,l,p:d['snapshots'][27].update(real_ms=5000))
def test_final_reference_and_unexpected_input_rejected(self):
self.reject(lambda d,l,p:p.update(final_sample=34))
self.reject(lambda d,l,p:d['snapshots'][27]['input'].update(input=[.2,0,1]))
def no_z(d,l,p):
for item in d['snapshots'].values():item['input']['input']=[0,0,0]
self.reject(no_z)
def test_native_clock_and_sample_references_bound(self):
self.reject(lambda d,l,p:d['ready'].update(real_ms=-40000))
self.reject(lambda d,l,p:p['stops'][0].update(before_sample=21))
self.reject(lambda d,l,p:p['samples'].append({'sample':35}))
self.reject(lambda d,l,p:p.update(deadline_monotonic=100.))
def test_helper_native_clocks_and_receipts_cannot_be_rewritten(self):
self.reject(lambda d,l,p:p['samples'][3].update(real_ms=99999))
self.reject(lambda d,l,p:p['samples'][3].update(received_monotonic=11.9))
self.reject(lambda d,l,p:p['events'][0].update(monotonic=10.1))
def test_unacknowledged_or_oversized_mouse_proposals_refused(self):
self.reject(lambda d,l,p:p['motions'].append({'dx':129,'dy':0,'monotonic':10.2,'after_sample':21,'response_sample':23}))
self.reject(lambda d,l,p:p['motions'].append({'dx':24,'dy':0,'monotonic':10.2,'after_sample':21,'response_sample':23}))
if __name__=='__main__':unittest.main()
+228
View File
@@ -0,0 +1,228 @@
"""Simulated native motion/observation adapters only; never open a display."""
import copy
import math
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
import prison_approach_proof as policy
class Busy(ValueError): pass
class Scene:
def __init__(self):
self.now=2.5; self.next_sample=2.6
self.geometry={'nonce':'test', 'basis':'registered-prison-lock-native-interaction',
'prison':[100.,0.,100.], 'gate':[100.,0.,94.], 'gate_forward':[0.,0.,1.],
'context':[102.7,1.2,94.], 'radius':2.}
self.position=[98.5,0.,98.5];self.angle=0.;self.speed=0.;self.held=False
self.key_events=[];self.turns=[];self.notifications=[];self.focus=True
self.gain=.085;self.acceleration=8.;self.braking=8.;self.move_scale=1.
self.guard_cost=.18;self.ignore_turn=False;self.wrong_turn=False
self.turn_delay=0.;self.pending_turns=[]
self.stale=False;self.no_movement=False;self.changed_geometry=False
self.context_lost=False;self.fail_down=False;self.coast_events=[]
self.sample=self.snapshot();self.history=[copy.deepcopy(self.sample)];self.last_read=None
def clock(self):return self.now
def snapshot(self):
r=math.radians(self.angle)
geometry=copy.deepcopy(self.geometry)
if self.changed_geometry:geometry['context'][0]+=.1
return {'sample':int(round((2000+self.now*1000)/100)), 'real_ms':int(round(2000+self.now*1000)),
'received_monotonic':self.now-1 if self.stale else self.now,
'origin':list(self.position), 'direction':[math.sin(r),0.,math.cos(r)],
'body_forward':[math.sin(r),0.,math.cos(r)], 'input':[0.,0.,1. if self.held else 0.],
'forward':1. if self.held else 0.,'velocity':[self.speed*math.sin(r),0.,self.speed*math.cos(r)],
'inventory':False,'phase':2 if self.context_lost else 1,'door_state':0.,'door_control':0.,'geometry':geometry}
def sleep(self,seconds):
end=self.now+seconds
while self.now < end-1e-9:
step=min(.005,end-self.now)
target=3. if self.held and not self.no_movement else 0.
rate=self.acceleration if target>self.speed else self.braking
self.speed=max(target,self.speed-rate*step) if target<self.speed else min(target,self.speed+rate*step)
r=math.radians(self.angle)
self.position[0]+=self.move_scale*self.speed*math.sin(r)*step
self.position[2]+=self.move_scale*self.speed*math.cos(r)*step
self.now+=step
while self.pending_turns and self.pending_turns[0][0] <= self.now:
self.angle+=self.pending_turns.pop(0)[1]
if self.now >= self.next_sample-1e-8:
self.sample=self.snapshot();self.history.append(copy.deepcopy(self.sample));self.next_sample+=.1
def check(self):
if not self.focus:raise policy.Refused('focus lost')
self.sleep(self.guard_cost)
def require_idle(self):
if self.held:raise Busy('key held')
def send(self,key,down):
if key != 'W':raise AssertionError('Only W permitted')
if down and self.speed>.05:self.coast_events.append(self.speed)
self.key_events.append((key,down,self.now))
self.held=down
if down and self.fail_down:raise OSError('uncertain native press')
def move(self,dx,dy,guard):
if dy != 0 or not 1<=abs(dx)<=128:raise AssertionError('motion cap')
guard.check();guard.check()
self.turns.append((dx,dy,self.now))
if not self.ignore_turn:
change=dx*self.gain*(-1 if self.wrong_turn else 1)
if self.turn_delay:self.pending_turns.append((self.now+self.turn_delay,change))
else:self.angle+=change
def observe(self):
if self.last_read is None:
batch=[self.sample]
else:
batch=[s for s in self.history if s['sample'] > self.last_read] or [self.sample]
self.last_read=batch[-1]['sample']
return copy.deepcopy(batch)
def run(self,report=None,calibration=None):
self.report={} if report is None else report
angle=math.radians(24*.085)
calibration=calibration or {'nonce':'test','completed':True,'pixels':24,
'baseline_direction':[0.,0.,1.], 'response_direction':[math.sin(angle),0.,math.cos(angle)],
'ready_real_ms':2000,'ready_monotonic':0.,'observer_start_real_ms':0}
return policy.perform_approach(self,self,self,self.observe,self.geometry,calibration,self.report,
clock=self.clock,sleep=self.sleep,notify=self.notifications.append,key_state_busy=Busy)
class ApproachTests(unittest.TestCase):
def test_measured_heading_and_coasting_route_stops_inside_without_interaction(self):
scene=Scene();result=scene.run()
self.assertTrue(result['completed'])
target,_=policy.target_for(scene.geometry)
self.assertLessEqual(policy.horizontal(scene.position,target),.75)
self.assertLessEqual(scene.speed,.05);self.assertFalse(scene.held)
self.assertFalse(scene.coast_events)
self.assertLess(scene.now,35);self.assertLessEqual(len(scene.turns),16)
self.assertLessEqual(len(scene.key_events)//2,12)
self.assertTrue(all(k=='W' for k,_,_ in scene.key_events))
self.assertLessEqual(result['cumulative_horizontal_m'],6)
self.assertTrue(all(policy.inside(s['origin'],scene.geometry['prison']) for s in result['samples']))
def test_no_turn_or_opposite_turn_never_authorizes_w(self):
for attribute in ('ignore_turn','wrong_turn'):
scene=Scene();setattr(scene,attribute,True)
with self.subTest(attribute=attribute),self.assertRaisesRegex(policy.Refused,'heading failed|opposite|settling'):scene.run()
self.assertEqual(scene.key_events,[])
def test_excessive_observed_turn_refuses_despite_bounded_pixels(self):
scene=Scene();scene.gain=.5
with self.assertRaisesRegex(policy.Refused,'angle bound'):scene.run()
self.assertEqual(scene.key_events,[])
def test_no_forward_movement_does_not_treat_neutral_samples_as_success(self):
scene=Scene();scene.no_movement=True
with self.assertRaisesRegex(policy.Refused,'full-stop'):scene.run()
self.assertEqual([v[1] for v in scene.key_events],[True,False])
self.assertFalse(scene.held)
def test_long_coast_never_starts_second_press(self):
scene=Scene();scene.braking=.1
with self.assertRaises(policy.Refused):scene.run()
self.assertEqual([v[1] for v in scene.key_events],[True,False]);self.assertFalse(scene.coast_events)
def test_large_forward_displacement_fails_without_a_second_pulse(self):
scene=Scene();scene.move_scale=5
with self.assertRaises(policy.Refused):scene.run()
self.assertEqual([v[1] for v in scene.key_events],[True,False])
def test_stale_geometry_context_or_input_refuses_before_events(self):
for attribute in ('stale','changed_geometry','context_lost'):
scene=Scene();setattr(scene,attribute,True);scene.sleep(.11)
with self.subTest(attribute=attribute),self.assertRaises(policy.Refused):scene.run()
self.assertEqual(scene.key_events,[]);self.assertEqual(scene.turns,[])
def test_uncertain_press_is_paired_only_on_owned_focus(self):
scene=Scene();scene.fail_down=True
with self.assertRaisesRegex(OSError,'uncertain'):scene.run()
self.assertEqual([v[1] for v in scene.key_events],[True,False]);self.assertFalse(scene.held)
scene=Scene();original=scene.send
def lose(key,down):
original(key,down)
if down:scene.focus=False
scene.send=lose
with self.assertRaisesRegex(policy.Refused,'focus lost'):scene.run()
self.assertEqual([v[1] for v in scene.key_events],[True])
self.assertEqual(scene.report['approach_proof']['unconfirmed_held_key'],'W')
self.assertTrue(scene.report['approach_proof']['paired_release_errors'])
def test_expired_ready_has_no_extended_budget_or_inputs(self):
scene=Scene();scene.now=35
with self.assertRaisesRegex(policy.Refused,'Insufficient'):scene.run()
self.assertEqual(scene.key_events,[]);self.assertEqual(scene.turns,[])
def test_slow_guard_actual_hold_cap_aborts_after_release(self):
scene=Scene();original=scene.send
def slow(key,down):
original(key,down)
if down:scene.guard_cost=.4
scene.send=slow
with self.assertRaisesRegex(policy.Refused,'400ms'):scene.run()
self.assertFalse(scene.held);self.assertEqual([v[1] for v in scene.key_events],[True,False])
def test_geometry_is_native_relative_and_closed_scene_specific(self):
geometry=copy.deepcopy(Scene().geometry);target,_=policy.target_for(geometry)
self.assertEqual(target,(102.7,0.,95.5))
for k in ('prison','gate','context'):
geometry[k][0]+=500;geometry[k][2]-=300
translated,_=policy.target_for(geometry)
self.assertEqual(translated,(602.7,0.,-204.5))
geometry['gate'][0]+=1
with self.assertRaisesRegex(policy.Refused,'placement'):policy.target_for(geometry)
def test_delayed_native_rotation_is_not_confused_with_unchanged_settling(self):
scene=Scene();scene.turn_delay=.4
result=scene.run()
self.assertTrue(result['completed'])
self.assertTrue(all(m['response_sample'] > m['after_sample'] for m in result['motions']))
self.assertLess(scene.now,35)
def test_adapter_must_preserve_intermediate_samples_during_slow_guards(self):
scene=Scene();original=scene.observe
scene.observe=lambda:original()[-1:]
with self.assertRaisesRegex(policy.Refused,'sampling gap'):scene.run()
self.assertEqual(scene.key_events,[])
def test_sample_tampering_or_gap_is_refused(self):
scene=Scene();original=scene.observe;calls=[0]
def skip():
records=original();calls[0]+=1
if calls[0]>1 and len(records)>1:return records[1:]
return records
scene.observe=skip
with self.assertRaisesRegex(policy.Refused,'sampling gap'):scene.run()
self.assertEqual(scene.key_events,[])
def test_body_motion_during_heading_refuses_without_w(self):
scene=Scene();original=scene.move
def turn(dx,dy,guard):
original(dx,dy,guard);scene.position[0]+=.1
scene.move=turn
with self.assertRaisesRegex(policy.Refused,'translated'):scene.run()
self.assertEqual(scene.key_events,[])
def test_transient_overshoot_in_batch_is_not_hidden_by_later_good_angle(self):
scene=Scene()
def turn(dx,dy,guard):
guard.check();guard.check()
scene.angle+=20;scene.sleep(.2);scene.angle-=10
scene.move=turn
with self.assertRaisesRegex(policy.Refused,'angle bound'):scene.run()
self.assertEqual(scene.key_events,[])
def test_missed_sampled_forward_is_missing_evidence_not_success(self):
scene=Scene();original=scene.observe
def hide():
records=original()
for s in records:s['forward']=0.;s['input']=[0.,0.,0.]
return records
scene.observe=hide
with self.assertRaisesRegex(policy.Refused,'W acknowledgement'):scene.run()
self.assertEqual([v[1] for v in scene.key_events],[True,False])
self.assertFalse(scene.held)
def test_second_attempt_cannot_reuse_the_same_report(self):
scene=Scene();report={'approach_proof':{'completed':False}}
with self.assertRaisesRegex(policy.Refused,'already exists'):scene.run(report=report)
self.assertEqual(scene.key_events,[]);self.assertEqual(scene.turns,[])
def test_calibration_must_be_same_run_and_nonzero_native_yaw(self):
scene=Scene()
for c in ({'nonce':'foreign','completed':True,'pixels':24}, {'nonce':'test','completed':False,'pixels':24}):
with self.assertRaises(policy.Refused):scene.run(calibration=c)
self.assertEqual(scene.turns,[])
if __name__=='__main__':unittest.main()
+55 -1
View File
@@ -43,7 +43,7 @@ class LookRunner(unittest.TestCase):
def test_helper_optin_requires_exact_matching_native_nonce_and_keeps_private_boundary(self):
with tempfile.TemporaryDirectory() as folder:
root=Path(folder);(root/'tools').mkdir();profile=root/'profile';profile.mkdir()
for name in ('headless_input_proof.py','headless_display_probe.py'):(root/'tools'/name).write_text('# fake')
for name in ('headless_input_proof.py','headless_display_probe.py','prison_approach_proof.py','ordinary_gate_evidence.py','ordinary_look_evidence.py','prison_approach_evidence.py'):(root/'tools'/name).write_text('# fake')
base=['gamescope','--backend','headless','--','/proton','run','/game','-errOrdinaryInputObserve',NONCE]
for native,flag,valid in [(base,False,True),(base,True,False),(base+['-errOrdinaryLookObserve',NONCE],True,True),(base+['-errOrdinaryLookObserve','foreign'],True,False),(base+['-errOrdinaryLookObserve',NONCE],False,False),(base+['-errOrdinaryLookObserve'],True,False),(base+['-errOrdinaryLookObserve',NONCE,'-errOrdinaryLookObserve',NONCE],True,False)]:
wrapped,display=r.display_probe_command(native,root,Path('/compat'),profile,Path('/game'),NONCE,':0')
@@ -80,4 +80,58 @@ class LookRunner(unittest.TestCase):
result,proof=r.input_exercise_result('game-started',context,session,observation,{'verified':True})
self.assertFalse(proof['verified']);self.assertEqual(result,'input-exercise-incomplete')
class ApproachRunner(unittest.TestCase):
def test_wider_viewport_only_changes_owned_headless_command(self):
with self.assertRaises(ValueError):r.validate_input_exercise(SimpleNamespace(approach_wide_display=True))
args=(r.ROOT,Path('/steam'),Path('/compat'),Path('/profile'),'chat')
normal=r.command_for(*args);wide=r.command_for(*args,private_width=2560)
self.assertEqual(wide[:3],['gamescope','--backend','headless'])
self.assertEqual(wide[wide.index('-W')+1],'2560');self.assertEqual(wide[wide.index('-w')+1],'2560')
replaced=wide.copy();replaced[wide.index('-W')+1]='1280';replaced[wide.index('-w')+1]='1280';self.assertEqual(replaced,normal)
for bad in (True,1920,0,4096):
with self.assertRaises(ValueError):r.command_for(*args,private_width=bad)
def test_native_gate_look_and_route_optins_are_all_required(self):
valid=dict(exercise_prison_approach=True,exercise_relative_look=True,observe_prison_gate=True,exercise_player_input=True,observe_player_input=True,inspect_headless_display=True,project='chat',settle=50)
r.validate_input_exercise(SimpleNamespace(**valid))
for missing in ('exercise_relative_look','observe_prison_gate','exercise_player_input','observe_player_input','inspect_headless_display'):
with self.assertRaises(ValueError):r.validate_input_exercise(SimpleNamespace(**{**valid,missing:False}))
with tempfile.TemporaryDirectory() as folder:
root=Path(folder);(root/'tools').mkdir();profile=root/'profile';profile.mkdir()
for name in ('headless_input_proof.py','headless_display_probe.py','prison_approach_proof.py','ordinary_gate_evidence.py','ordinary_look_evidence.py','prison_approach_evidence.py'):(root/'tools'/name).write_text('# fake')
native=['gamescope','--backend','headless','--','/proton','run','/game','-errOrdinaryInputObserve',NONCE,'-errOrdinaryLookObserve',NONCE,'-errOrdinaryGateObserve',NONCE]
wrapped,display=r.display_probe_command(native,root,Path('/compat'),profile,Path('/game'),NONCE,':0')
command,context=r.input_exercise_command(wrapped,root,Path('/compat'),profile,Path('/game'),NONCE,':0',display,True,True)
self.assertIn('--enable-private-approach-proof',command);self.assertTrue(context['approach_requested'])
for bad in (native[:-2],native[:-1]+['foreign']):
wrapped,display=r.display_probe_command(bad,root,Path('/compat'),profile,Path('/game'),NONCE,':0')
with self.assertRaises(ValueError):r.input_exercise_command(wrapped,root,Path('/compat'),profile,Path('/game'),NONCE,':0',display,True,True)
def test_route_uses_independent_raw_proof_and_preserves_prior_failure(self):
for verified,prior in [(True,'game-started'),(False,'game-started'),(True,'game-failed')]:
with tempfile.TemporaryDirectory() as folder:
session,context,report,observation=fixtures.ExerciseTests.make_proof(self,Path(folder))
context.update(look_requested=True,approach_requested=True,approach_module_sha256=r.approach_module_hashes(r.ROOT));report['approach_module_sha256']=context['approach_module_sha256'];report.update(look_requested=True,approach_requested=True,look_proof={'completed':True})
report['events']=report['events'][:2];report['observer']['events']=['READY'];report['approach_proof']={'events':copy.deepcopy(report['events'])}
observation['inventory_open_observed']=False;observation['inventory_close_observed']=False
observation['movement']={'action_observed':False,'physical_motion_observed':False,'released_input_observed':False}
# The legacy summary measures movement only before INVENTORY_OPEN. The
# separate approach validator must own raw movement evidence in this mode.
(session/'console.log').write_text('raw native');Path(context['report']).write_text(json.dumps(report))
module=ModuleType('prison_approach_evidence');calls=[]
def validate(*args):calls.append(args);return {'verified':verified,'errors':[]}
module.validate_approach_evidence=validate
with patch.dict(sys.modules,{'prison_approach_evidence':module}):
result,proof=r.input_exercise_result(prior,context,session,observation,{'verified':True})
self.assertEqual(calls,[('raw native',NONCE,report['look_proof'],report['approach_proof'])]);self.assertEqual(proof['verified'],verified)
self.assertEqual(result,'input-exercise-incomplete' if not verified and prior=='game-started' else prior)
def test_unrequested_or_mismatched_approach_does_not_qualify(self):
for payload in ({'approach_requested':True},{'approach_proof':{}},{'approach_requested':1}):
with tempfile.TemporaryDirectory() as folder:
session,context,report,observation=fixtures.ExerciseTests.make_proof(self,Path(folder));report.update(payload)
Path(context['report']).write_text(json.dumps(report))
result,proof=r.input_exercise_result('game-started',context,session,observation,{'verified':True})
self.assertFalse(proof['verified']);self.assertEqual(result,'input-exercise-incomplete')
if __name__=='__main__':unittest.main()