Retain aircraft through native landing and qualify bounded input diagnostics
This commit is contained in:
@@ -24,13 +24,14 @@ assert source.count('physics.SetActive(ActiveState.ACTIVE)')==1
|
||||
for prohibited in ('SetWorldTransform(', 'SetOrigin(', 'SetVelocity(', '.StartEngine(', '.StopEngine(', 'm_Mode.Own('):assert prohibited not in source
|
||||
assert 'ERR_AircraftRouteEnvelope.Survey(candidate, destination)' in body(P,'protected bool FindPad(')
|
||||
tick=body(P,'int Tick(')
|
||||
assert 'ERR_AircraftRouteEnvelope.QualifyRoute(' in tick and 'm_Flight.StartRoute(' in tick
|
||||
assert tick.index('ERR_AircraftRouteEnvelope.QualifyRoute(')<tick.index('m_Flight.StartRoute(')
|
||||
assert 'ERR_AircraftRouteEnvelope.QualifyRoute(' in tick and 'StartRetainedRoute()' in tick
|
||||
assert tick.index('ERR_AircraftRouteEnvelope.QualifyRoute(')<tick.index('StartRetainedRoute()')
|
||||
assert 'm_Flight.Arrived() && vector.DistanceXZ(transform[3], m_Destination) <= 0.75' in tick
|
||||
assert 'm_Flight.ERR_NativeControlDeadline() != m_ControlDeadline' in tick
|
||||
assert tick.index('m_Flight.Tick();')<tick.index('if (!m_Flight.Finished())')
|
||||
assert tick.index('m_Service.Tick();')<tick.index('if (!m_Flight.Finished())')
|
||||
assert 'm_Flight.Tick(' not in source and 'm_Flight.StartRoute(' not in source and 'm_Flight.Cancel(' not in source
|
||||
stop=body(P,'void Stop(')
|
||||
assert stop.index('s_Current = null')<stop.index('m_Flight.Cancel(')<stop.index('m_Flight = null')<stop.index('ERR_Actors.DeleteSpawned(')
|
||||
assert stop.index('s_Current = null')<stop.index('m_Service.Stop()')<stop.index('ERR_Actors.DeleteSpawned(')<stop.index('m_Service.Tick()')<stop.index('m_Flight = null')<stop.index('m_Service = null')
|
||||
assert 'ERR_NativeAircraftRouteRules.CanDispose(' in stop
|
||||
assert source.index('probe.PrepareActions(')<source.index('super.OnPrepareControls(owner, am, dt, player);')
|
||||
start=source.index('protected override event void OnPrepareControls()')
|
||||
@@ -110,3 +111,142 @@ assert diagnostic.index('now < m_NextLog')<diagnostic.index('m_NextLog = now + 2
|
||||
for forbidden in ('SetActionValue(', 'SetVelocity(', 'SetWorldTransform(', '.Cancel(', '.Tick(', '.StartRoute(', '.SetAutohoverEnabled('):assert forbidden not in diagnostic
|
||||
for field in ('position=%2','velocity=%3','along=%4','cross=%5','targetError=%6','computedDemand=%7','projectedForward=%8','projectedAside=%9','stageRemaining=%5','overallRemaining=%6'):assert field in diagnostic
|
||||
assert body(P,'float ERR_NativeRouteStageDeadline(').strip()=='float ERR_NativeRouteStageDeadline() { return m_StageDeadline; }'
|
||||
|
||||
|
||||
# Service adaptation executes the actual fixture entry and retained-identity
|
||||
# methods. Only native service outcomes are substituted; no physical proof.
|
||||
def retained_cpp(text):
|
||||
return text.replace('protected ','').replace('m_Service.', 'm_Service->').replace('new ERR_AircraftService(', 'new Service(')
|
||||
run(r'''#include <cassert>
|
||||
#include <string>
|
||||
struct Flight{};
|
||||
bool registration=true,startAccepted=true;int constructions=0,registerCalls=0,startCalls=0;
|
||||
struct Service {
|
||||
Flight flight;bool pending=false;int token=0,count=0,attempts=0;float deadline=0;int aircraft=0,group=0,pilot=0,seat=0;
|
||||
Service(int){constructions++;}
|
||||
bool Register(int a,int g,int p,int s){registerCalls++;if(!registration)return false;aircraft=a;group=g;pilot=p;seat=s;count=1;return true;}
|
||||
bool Ready(int){return registration;}int RegisteredCount(){return count;}int AttemptedOrders(){return attempts;}
|
||||
int OrderToken(int){return token;}float OrderDeadline(int){return deadline;}bool Pending(int){return pending;}
|
||||
bool StartRoute(int a,int,int t,float d){assert(a==aircraft);startCalls++;attempts++;token=t;deadline=d;pending=startAccepted;return startAccepted;}
|
||||
Flight* ERR_NativeOriginalFlight(int){return attempts?&flight:nullptr;}
|
||||
};
|
||||
struct Probe {
|
||||
Service* m_Service=nullptr;Flight* m_Flight=nullptr;
|
||||
int m_Mode=1,m_Aircraft=2,m_Group=3,m_Pilot=4,m_Seat=5,m_Envelope=6,m_ControllerStarts=0,failed=0;
|
||||
const int ORDER_TOKEN=771001;float m_ControlDeadline=240000;
|
||||
bool Check(bool okay,const char*){if(!okay)failed++;return okay;}
|
||||
''' + retained_cpp(body(P,'protected bool RetainedIdentity(')) + retained_cpp(body(P,'protected bool StartRetainedRoute(')) + r'''
|
||||
};
|
||||
int main(){
|
||||
Probe p;assert(!p.RetainedIdentity());assert(p.StartRetainedRoute());assert(p.RetainedIdentity()&&p.m_ControllerStarts==1&&constructions==1&®isterCalls==1&&startCalls==1);
|
||||
assert(p.m_Service->aircraft==2&&p.m_Service->group==3&&p.m_Service->pilot==4&&p.m_Service->seat==5&&p.m_Service->Pending(2));
|
||||
assert(!p.StartRetainedRoute()&&constructions==1&&startCalls==1); // no replacement or duplicate controller
|
||||
Flight foreign;auto original=p.m_Flight;p.m_Flight=&foreign;assert(!p.RetainedIdentity());p.m_Flight=original;
|
||||
p.m_Service->token++;assert(!p.RetainedIdentity());p.m_Service->token--;
|
||||
p.m_Service->deadline++;assert(!p.RetainedIdentity());p.m_Service->deadline--;
|
||||
p.m_Service->count=2;assert(!p.RetainedIdentity());p.m_Service->count=1;
|
||||
p.m_Service->attempts=2;assert(!p.RetainedIdentity());p.m_Service->attempts=1;assert(p.RetainedIdentity());
|
||||
registration=false;Probe unready;assert(!unready.StartRetainedRoute()&&unready.m_ControllerStarts==0&&startCalls==1);registration=true;
|
||||
startAccepted=false;Probe refused;assert(!refused.StartRetainedRoute()&&refused.m_ControllerStarts==1&&startCalls==2&&refused.m_Flight);
|
||||
}
|
||||
''')
|
||||
# The sole fixture getter observes the service's original retained controller.
|
||||
getter=body(P,'ERR_AircraftRouteFlight ERR_NativeOriginalFlight(')
|
||||
run(r'''#include <cassert>
|
||||
struct Flight{};struct Entry {Flight* FlightPtr=nullptr;};
|
||||
struct Observe {Entry* entry=nullptr;Entry* Find(int){return entry;}
|
||||
''' + getter.replace('ERR_AircraftRouteFlight ', 'Flight* ').replace('Vehicle aircraft','int aircraft').replace('ERR_AircraftServiceEntry entry','Entry* entry').replace('entry.Flight','entry->FlightPtr').replace('return null;','return nullptr;') + r'''
|
||||
};
|
||||
int main(){Observe p;assert(!p.ERR_NativeOriginalFlight(1));Flight flight;Entry e;e.FlightPtr=&flight;p.entry=&e;assert(p.ERR_NativeOriginalFlight(1)==&flight);}
|
||||
''')
|
||||
assert 'm_Service.Drained() && !m_Service.Pending(m_Aircraft) && !m_Service.Retired(m_Aircraft)' in tick
|
||||
assert 'if (!RetainedIdentity())' in tick
|
||||
assert source.count('new ERR_AircraftService(')==1 and 'new ERR_AircraftRouteFlight(' not in source
|
||||
for sig in ('void PrepareActions(', 'void PrepareHelicopter('):
|
||||
assert 'm_Flight.Prepare' not in body(P,sig), 'Production bridge remains the only native control dispatcher'
|
||||
print('PASS: actual retained-service fixture entry, immutable controller/token/deadline/count bindings, observer getter and cleanup ownership ordering; native route validation pending')
|
||||
|
||||
# Cancellation is a separate explicit variant; no arrival credit or second stop.
|
||||
flight=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftFlight.c'
|
||||
cancel_rules='\n'.join(body(P,k) for k in ('static bool CanStop(', 'static bool CancelProof(')).replace('ERR_AircraftFlightState.','State::').replace('ERR_AircraftFlightRules.Hover','Hover')
|
||||
run('''#include <cassert>
|
||||
#include <cmath>
|
||||
namespace State {enum {IDLE,HOOKS,STARTING,POSITIVE,NEGATIVE,CLIMB,LANDING,SHUTDOWN,COMPLETE,CANCELLED,FAILED,RELINQUISHED,TRANSLATING};}
|
||||
namespace Math {float AbsFloat(float x){return std::abs(x);}}
|
||||
'''+body(flight,'static bool Hover(').replace('Math.','Math::')+cancel_rules+'''
|
||||
int main(){
|
||||
assert(CanStop(State::TRANSLATING,false,8,.4,0,1,3));
|
||||
assert(!CanStop(State::TRANSLATING,false,8,.4,0,1,2.999f));
|
||||
assert(!CanStop(State::TRANSLATING,true,8,.4,0,1,4));
|
||||
assert(!CanStop(State::TRANSLATING,false,6,.4,0,1,4));
|
||||
assert(!CanStop(State::TRANSLATING,false,8,2,0,1,4));
|
||||
assert(!CanStop(State::TRANSLATING,false,8,.4,.6,1,4));
|
||||
assert(!CanStop(State::TRANSLATING,false,8,.4,0,.9,4));
|
||||
for(int i=State::IDLE;i<=State::RELINQUISHED;i++)assert(!CanStop(i,false,8,.4,0,1,4));
|
||||
assert(CancelProof(true,true,true,true,true,true,.75f,false));
|
||||
for(int i=0;i<6;i++)assert(!CancelProof(i!=0,i!=1,i!=2,i!=3,i!=4,i!=5,.1f,false));
|
||||
assert(!CancelProof(true,true,true,true,true,true,.751f,false));
|
||||
assert(!CancelProof(true,true,true,true,true,true,-1,false));
|
||||
assert(!CancelProof(true,true,true,true,true,true,0,true));
|
||||
}
|
||||
''')
|
||||
|
||||
def stop_cpp(text):
|
||||
return retained_cpp(text).replace('m_Flight.','m_Flight->').replace('m_Envelope.','m_Envelope->').replace('ERR_AircraftFlightState.','State::')
|
||||
run(r'''#include <cassert>
|
||||
namespace State {enum {TRANSLATING=12,LANDING=6,CANCELLED=9};}
|
||||
template<class... Args>void PrintFormat(const char*,Args...){}
|
||||
struct Flight {
|
||||
int state=State::TRANSLATING,actions=50,helicopters=60,token=771001,point=0;float deadline=240000;bool finished=false;
|
||||
int State(){return state;}bool Finished(){return finished;}int OrderToken(){return token;}
|
||||
int LandingPoint(){return point;}float ERR_NativeControlDeadline(){return deadline;}
|
||||
int ERR_NativeRouteActionUpdates(){return actions;}int ERR_NativeRouteHelicopterUpdates(){return helicopters;}
|
||||
};
|
||||
struct Service {
|
||||
Flight flight;bool stopping=false,pending=true,retain=true;int stops=0,count=1,attempts=1,token=771001;float deadline=240000;
|
||||
Flight* ERR_NativeOriginalFlight(int){return &flight;}bool Stopping(){return stopping;}bool Pending(int){return pending;}
|
||||
bool Drained(){return !pending;}bool Ready(int){return !stopping;}
|
||||
int RegisteredCount(){return count;}int AttemptedOrders(){return attempts;}int OrderToken(int){return token;}float OrderDeadline(int){return deadline;}
|
||||
void Stop(){stops++;stopping=true;pending=retain;flight.state=State::LANDING;flight.point=22;}
|
||||
};
|
||||
struct Envelope{bool inside=true;bool Contains(int p,float){return inside&&p==22;}};
|
||||
struct Probe {
|
||||
Service service;Envelope envelope;Service* m_Service=&service;Flight* m_Flight=&service.flight;Envelope* m_Envelope=&envelope;
|
||||
int m_Aircraft=1,ORDER_TOKEN=771001,m_CancelCount=0,m_CancelActionUpdates=0,m_CancelHelicopterUpdates=0,m_CancelLandingPoint=0;
|
||||
float m_ControlDeadline=240000,m_CancelAt=-1;int failed=0;
|
||||
bool Check(bool okay,const char*){if(!okay)failed++;return okay;}
|
||||
'''+stop_cpp(body(P,'protected bool RetainedIdentity('))+stop_cpp(body(P,'protected bool StopRetainedRoute('))+r'''
|
||||
};
|
||||
int main(){
|
||||
Probe p;assert(p.StopRetainedRoute(12000));assert(p.m_CancelCount==1&&p.m_CancelAt==12000&&p.service.stops==1);
|
||||
assert(p.m_CancelActionUpdates==50&&p.m_CancelHelicopterUpdates==60&&p.m_CancelLandingPoint==22);
|
||||
assert(p.service.Pending(1)&&!p.service.Drained()&&!p.service.Ready(1)&&p.service.flight.state==State::LANDING);
|
||||
assert(p.RetainedIdentity());assert(!p.StopRetainedRoute(20000)&&p.service.stops==1&&p.m_CancelAt==12000&&p.m_ControlDeadline==240000);
|
||||
Probe already;already.service.stopping=true;assert(!already.StopRetainedRoute(1)&&already.service.stops==0);
|
||||
Probe rebound;rebound.service.token++;assert(!rebound.StopRetainedRoute(1)&&rebound.service.stops==0);
|
||||
Probe lost;lost.service.retain=false;assert(!lost.StopRetainedRoute(1)&&lost.service.stops==1); // premature drain is a failure
|
||||
Probe foreign;foreign.envelope.inside=false;assert(!foreign.StopRetainedRoute(1));
|
||||
Probe renewed;renewed.service.flight.deadline++;assert(!renewed.StopRetainedRoute(1));
|
||||
}
|
||||
''')
|
||||
readback=body(P,'bool ERR_NativeRouteFreshReadback(')
|
||||
run('''#include <cassert>
|
||||
struct Observe {bool m_Readback=true;float m_LastControl=1000;
|
||||
'''+readback+'''
|
||||
};
|
||||
int main(){Observe p;assert(p.ERR_NativeRouteFreshReadback(1000));assert(p.ERR_NativeRouteFreshReadback(2000));assert(!p.ERR_NativeRouteFreshReadback(2001));assert(!p.ERR_NativeRouteFreshReadback(999));p.m_LastControl=-1;assert(!p.ERR_NativeRouteFreshReadback(0));p.m_LastControl=1000;p.m_Readback=false;assert(!p.ERR_NativeRouteFreshReadback(1000));}
|
||||
''')
|
||||
assert 'm_CancelFixture = System.GetCLIParam("errSmokeRouteServiceCancel", cancellation) && cancellation == "1";' in source
|
||||
assert 'if (m_CancelFixture && m_CancelCount == 0)' in tick
|
||||
assert 'now - m_CancelStable >= 500 && !StopRetainedRoute(now)' in tick
|
||||
assert tick.index('StopRetainedRoute(now)')<tick.index('m_Service.Tick();')
|
||||
assert 'else if (m_TranslationFixture)' in tick, 'cancel does not claim destination hold'
|
||||
assert 'm_Envelope.Contains(m_CancelLandingPoint, 1)' in tick and 'm_Flight.LandingPoint() != m_CancelLandingPoint' in tick
|
||||
assert 'm_Flight.ERR_NativeRouteActionUpdates() > m_CancelActionUpdates + 10' in tick
|
||||
assert 'm_Flight.ERR_NativeRouteHelicopterUpdates() > m_CancelHelicopterUpdates + 10' in tick
|
||||
assert 'm_Flight.ERR_NativeRouteFreshReadback(now)' in tick
|
||||
assert 'now - m_LandingStable >= 2000' in tick
|
||||
assert 'if (m_Service && !m_Service.Stopping()) m_Service.Stop();' in stop
|
||||
assert source.count('m_Service.Stop();')==2 # exclusive trigger vs guarded final cleanup
|
||||
assert 'now + 300000' not in tick and 'now + 240000' in tick
|
||||
print('PASS: actual measured cancellation gate, one-shot retained service Stop, no premature drain, immutable lease, fresh real controller readback and separate physical cancellation result policy')
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Actual finite service lifecycle methods; controller/physical APIs are fakes."""
|
||||
from policy_harness import ROOT, body, run
|
||||
import re
|
||||
P=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftService.c'
|
||||
|
||||
def cpp(s):
|
||||
s=s.replace('protected ','').replace('return null;', 'return nullptr;')
|
||||
for a,b in [('ERR_AircraftServiceEntry entry','Entry* entry'),('ERR_AircraftServiceEntry known','Entry* known'),('new ERR_AircraftServiceEntry()','new Entry()'),('ERR_AircraftRouteEnvelope qualification','Qualification* qualification'),('new ERR_AircraftRouteFlight()','new Flight()'),('ERR_GameMode mode','Mode* mode'),('ERR_GameMode.Get()','Mode::Get()'),('ERR_AircraftFlightRules.','Rules::'),('ERR_AircraftFlightState.','State::'),('Replication.IsServer()','server'),('string reason','std::string reason'),('vector destination = "0 0 0"','Vec destination')]:s=s.replace(a,b)
|
||||
s=re.sub(r'foreach \(Entry\* (entry|known) : m_Entries\)',r'for (Entry* \1 : m_Entries.values)',s)
|
||||
for name in ('entry','known','qualification','GetGame()','m_Mode','m_World','s_Retainers','entry.Flight'):
|
||||
s=s.replace(name+'.',name+'->')
|
||||
s=s.replace('entry->Flight.','entry->Flight->').replace('entry->Reason.IsEmpty()','entry->Reason.empty()')
|
||||
return s.replace('ERR_AircraftServiceEntry Find(', 'Entry* Find(').replace('ERR_AircraftServiceEntry entry)', 'Entry* entry)').replace('vector Destination(', 'Vec Destination(').replace('string Reason(', 'std::string Reason(')
|
||||
|
||||
keys=['protected bool Context(', 'protected ERR_AircraftServiceEntry Find(', 'bool Register(', 'bool Ready(', 'bool StartRoute(', 'protected void Finish(', 'bool Cancel(', 'void Tick(', 'void Stop(', 'protected void ReleaseRetainers(', 'bool Drained(', 'bool Stopping(', 'int RegisteredCount(', 'int AttemptedOrders(', 'bool Pending(', 'bool Retired(', 'int OrderToken(', 'float OrderDeadline(', 'int FlightState(', 'string Reason(', 'vector Destination(']
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
bool server=true;
|
||||
namespace State {enum {IDLE,HOOKS,STARTING,POSITIVE,NEGATIVE,CLIMB,LANDING,SHUTDOWN,COMPLETE,CANCELLED,FAILED,RELINQUISHED,TRANSLATING};}
|
||||
struct Vec {float x=0,y=0,z=0;};
|
||||
struct World {float now=1000;float GetWorldTime(){return now;}}world;
|
||||
struct Game {World* current=&world;World* GetWorld(){return current;}}game;Game* gamePointer=&game;Game* GetGame(){return gamePointer;}
|
||||
struct Mode {bool active=true;World* world=&::world;static Mode* current;World* GetWorld(){return world;}bool Active(){return active;}static Mode* Get(){return current;}};Mode* Mode::current=nullptr;
|
||||
struct Rules {static bool Deadline(float now,float deadline){return now>=0&&deadline>now&&deadline-now<=240000;}};
|
||||
struct Entity {bool original=true,ready=true;int startMode=0,next=State::HOOKS;};
|
||||
using Vehicle=Entity*;using SCR_AIGroup=Entity*;using IEntity=Entity*;using PilotCompartmentSlot=Entity*;
|
||||
struct Qualification {bool claimed=false;Vec target={20,0,0};Vec Destination(){return target;}};
|
||||
int starts=0,claims=0,ticks=0,cancels=0;
|
||||
struct Flight {
|
||||
Entity* aircraft=nullptr;int token=0,state=State::IDLE;float deadline=0;bool cancelled=false;std::string reason;
|
||||
bool Finished(){return state>=State::COMPLETE&&state<=State::RELINQUISHED;}
|
||||
bool StartRoute(Mode*,Vehicle a,SCR_AIGroup,IEntity,PilotCompartmentSlot,Qualification* q,int t,float d){
|
||||
starts++;aircraft=a;
|
||||
if(a->startMode==1||q->claimed)return false;
|
||||
q->claimed=true;claims++;token=t;deadline=d;state=State::HOOKS;
|
||||
if(a->startMode==2){state=State::FAILED;return false;}
|
||||
if(a->startMode==3)return false;
|
||||
return true;
|
||||
}
|
||||
void Cancel(std::string r){if(Finished()||cancelled)return;cancelled=true;cancels++;reason=r;state=State::LANDING;}
|
||||
void Tick(){ticks++;if(!aircraft->original||!Mode::Get()||!Mode::Get()->active){state=State::RELINQUISHED;return;}if(world.now>=deadline){state=State::FAILED;return;}state=aircraft->next;}
|
||||
int State(){return state;}int OrderToken(){return token;}std::string Reason(){return reason;}
|
||||
};
|
||||
struct Entry {Vehicle Aircraft=nullptr;SCR_AIGroup Group=nullptr;IEntity Pilot=nullptr;PilotCompartmentSlot Seat=nullptr;struct Flight* Flight=nullptr;int Token=0;float Deadline=0;Vec Destination;bool Pending=false,CancelRequested=false,Retired=false;std::string Reason;};
|
||||
struct Entries {std::vector<Entry*> values;int Count(){return values.size();}void Insert(Entry* e){values.push_back(e);}};
|
||||
struct Tokens {std::set<int> values;int Count(){return values.size();}bool Contains(int n){return values.count(n);}void Insert(int n){values.insert(n);}};
|
||||
struct Service;
|
||||
struct Registry {std::map<Entity*,Service*> values;Service* Get(Entity* a){auto i=values.find(a);return i==values.end()?nullptr:i->second;}void Set(Entity* a,Service* s){values[a]=s;}void Remove(Entity* a){values.erase(a);}}registry;
|
||||
struct Service {
|
||||
static const int MAX_AIRCRAFT=4,MAX_ORDERS=128;static Registry* s_Retainers;
|
||||
Mode* m_Mode;World* m_World=&world;Entries m_Entries;Tokens m_UsedTokens;bool m_Stopping=false;
|
||||
Service(Mode* m):m_Mode(m){}
|
||||
bool Original(Entry* e){return Context()&&e&&e->Aircraft&&e->Aircraft->original;}
|
||||
bool PhysicalReady(Entry* e){return Original(e)&&e->Aircraft->ready;}
|
||||
''' + '\n'.join(cpp(body(P,k)) for k in keys) + r'''
|
||||
};Registry* Service::s_Retainers=®istry;
|
||||
struct Setup {
|
||||
Mode mode;Entity a[5],g[5],p[5],seat[5];Service service;Qualification q;
|
||||
Setup():service(&mode){registry.values.clear();server=true;gamePointer=&game;game.current=&world;world.now=1000;Mode::current=&mode;starts=claims=ticks=cancels=0;}
|
||||
bool Register(int i=0){return service.Register(&a[i],&g[i],&p[i],&seat[i]);}
|
||||
bool Start(int token=1){return service.StartRoute(&a[0],&q,token,200000);}
|
||||
};
|
||||
int main(){
|
||||
{Setup x;for(int i=0;i<4;i++)assert(x.Register(i));assert(!x.Register(4)&&x.service.RegisteredCount()==4&&starts==0&&claims==0);}
|
||||
{Setup x;assert(x.Register());assert(!x.Register());Service other(&x.mode);assert(!other.Register(&x.a[0],&x.g[0],&x.p[0],&x.seat[0]));assert(!x.service.Register(&x.a[1],&x.g[1],&x.p[0],&x.seat[1]));assert(!x.service.Register(&x.a[1],&x.g[0],&x.p[1],&x.seat[1]));assert(!x.service.Register(&x.a[1],&x.g[1],&x.p[1],&x.seat[0]));}
|
||||
{Setup x;x.a[0].ready=false;assert(!x.Register()&®istry.values.empty());x.a[0].ready=true;x.mode.active=false;assert(!x.Register());}
|
||||
{Setup x;assert(x.Register());Mode other;Mode::current=&other;assert(!x.Start()&&starts==0);Mode::current=&x.mode;World otherWorld;game.current=&otherWorld;assert(!x.Start()&&starts==0);game.current=&world;server=false;assert(!x.Start()&&starts==0);}
|
||||
{Setup x;assert(x.Register());assert(!x.service.StartRoute(&x.a[0],nullptr,1,200000));assert(!x.Start(0));assert(!x.service.StartRoute(&x.a[0],&x.q,1,world.now));assert(!x.service.StartRoute(&x.a[0],&x.q,1,world.now+240001));assert(!x.service.StartRoute(&x.a[0],&x.q,1,NAN));assert(starts==0&&x.service.AttemptedOrders()==0);}
|
||||
{Setup x;assert(x.Register());assert(x.Start());assert(starts==1&&claims==1&&x.service.Pending(&x.a[0]));assert(!x.Start()&&!x.Start(2)&&starts==1&&claims==1);assert(x.service.OrderToken(&x.a[0])==1&&x.service.OrderDeadline(&x.a[0])==200000);x.q.target.x=999;assert(x.service.Destination(&x.a[0]).x==20);}
|
||||
// Cancellation retains the same controller/token/deadline and continues Tick.
|
||||
{Setup x;assert(x.Register()&&x.Start());Entry* entry=x.service.Find(&x.a[0]);Flight* flight=entry->Flight;x.a[0].next=State::TRANSLATING;x.service.Tick();assert(!x.service.Cancel(&x.a[0],2,"foreign"));assert(x.service.Cancel(&x.a[0],1,"report lost"));assert(!x.service.Cancel(&x.a[0],1,"duplicate"));assert(cancels==1&&entry->Flight==flight&&x.service.Pending(&x.a[0])&&!x.service.Ready(&x.a[0]));x.a[0].next=State::LANDING;x.service.Tick();assert(ticks==2&&entry->Flight==flight&&x.service.OrderDeadline(&x.a[0])==200000);x.a[0].next=State::CANCELLED;x.service.Tick();assert(x.service.Drained()&&!x.service.Retired(&x.a[0])&&x.service.Ready(&x.a[0]));assert(!x.Start());Qualification q;assert(x.service.StartRoute(&x.a[0],&q,2,210000)&&starts==2&&claims==2);}
|
||||
// Stop is admission/cancellation, not fabricated completion or disposal.
|
||||
{Setup x;assert(x.Register()&&x.Start());x.service.Stop();x.service.Stop();assert(x.service.Stopping()&&!x.service.Drained()&&cancels==1&®istry.Get(&x.a[0])==&x.service);assert(!x.Register(1)&&!x.Start(2));x.a[0].next=State::LANDING;x.service.Tick();assert(!x.service.Drained()&®istry.Get(&x.a[0])==&x.service);x.a[0].next=State::CANCELLED;x.service.Tick();assert(x.service.Drained()&®istry.values.empty()&&!x.service.Ready(&x.a[0]));}
|
||||
// Completion must not overrule current seat/health/contact/fuel readiness.
|
||||
{Setup x;assert(x.Register()&&x.Start());x.a[0].next=State::COMPLETE;x.a[0].ready=false;x.service.Tick();assert(x.service.Drained()&&!x.service.Ready(&x.a[0]));x.a[0].ready=true;assert(x.service.Ready(&x.a[0]));x.a[0].original=false;x.service.Tick();assert(x.service.Retired(&x.a[0])&&!x.service.Ready(&x.a[0]));x.a[0].original=true;assert(!x.service.Ready(&x.a[0]));}
|
||||
// Native failure/capture and mode teardown never claim a usable returned asset.
|
||||
for(int state:{State::FAILED,State::RELINQUISHED}){Setup x;assert(x.Register()&&x.Start());x.a[0].next=state;x.service.Tick();assert(x.service.Drained()&&x.service.Retired(&x.a[0])&&!x.service.Ready(&x.a[0]));}
|
||||
{Setup x;assert(x.Register()&&x.Start());x.mode.active=false;x.service.Tick();assert(x.service.Stopping()&&x.service.Drained()&&x.service.Retired(&x.a[0])&®istry.values.empty());}
|
||||
// Expiry is owned by the original controller deadline, never renewed by Tick.
|
||||
{Setup x;assert(x.Register()&&x.Start());world.now=200000;x.service.Tick();assert(x.service.FlightState(&x.a[0])==State::FAILED&&x.service.Retired(&x.a[0])&&x.service.Drained()&&x.service.OrderDeadline(&x.a[0])==200000&&!x.service.Ready(&x.a[0]));}
|
||||
// Unclaimed/claimed/ambiguous Start failures burn the attempted token; a
|
||||
// nonterminal partial lease remains retained and canceled until actual result.
|
||||
for(int fail:{1,2,3}){Setup x;assert(x.Register());x.a[0].startMode=fail;assert(!x.Start());assert(starts==1&&x.service.AttemptedOrders()==1&&x.service.Retired(&x.a[0]));assert(!x.Start(2)&&starts==1);if(fail==3){assert(x.service.Pending(&x.a[0])&&cancels==1);assert(!x.service.Cancel(&x.a[0],1,"duplicate"));x.a[0].next=State::CANCELLED;x.service.Tick();assert(x.service.Drained());}else assert(x.service.Drained());}
|
||||
{Setup x;assert(x.Register());x.q.claimed=true;assert(!x.Start()&&starts==1&&claims==0&&x.service.AttemptedOrders()==1);}
|
||||
{Setup x;assert(x.Register());for(int i=1;i<=Service::MAX_ORDERS;i++){Qualification q;assert(x.service.StartRoute(&x.a[0],&q,i,200000));x.a[0].next=State::COMPLETE;x.service.Tick();}assert(x.service.AttemptedOrders()==128);Qualification q;assert(!x.service.StartRoute(&x.a[0],&q,129,200000)&&starts==128);}
|
||||
}
|
||||
''')
|
||||
source=P.read_text()
|
||||
# Native-boundary inventory: readiness invokes original occupancy/player/world
|
||||
# checks plus actual fuel, rotor, engine and settled ground predicates.
|
||||
original=body(P,'protected bool Original(');ready=body(P,'protected bool PhysicalReady(')
|
||||
for required in ('entry.Seat.GetOccupant() != entry.Pilot','controller.GetPilotCompartmentSlot() != entry.Seat','CompartmentAccessComponent.GetVehicleIn(entry.Pilot) != entry.Aircraft','entry.Group.GetPlayerCount(true) > 0','GetPlayerControlledEntity(id)','entry.Aircraft.GetWorld() != m_World','entry.Pilot.GetWorld() != m_World'):
|
||||
assert required in original
|
||||
for required in ('!Original(entry)','!ERR_Actors.CanFight(entry.Pilot)','!fuel.HasFuel()','!damage.GetEngineFunctional()','simulation.RotorGetState(rotor) == RotorState.DESTROYED','!simulation.HasAnyGroundContact()','!physics.IsActive()','controller.IsEngineOn()'):
|
||||
assert required in ready
|
||||
assert source.count('new ERR_AircraftRouteFlight()')==1
|
||||
assert '.Claim(' not in source and 'QualifyRoute(' not in source
|
||||
for prohibited in ('Spawn(', 'Delete(', 'DeleteSpawned(', 'SetOrigin(', 'SetWorldTransform(', 'SetVelocity(', 'SetActive(', 'SetFuel(', 'SetHealth(', 'PrepareActions(', 'PrepareHelicopter(', 'CallLater(', 'GetPlayerCount() == 0'):
|
||||
assert prohibited not in source
|
||||
print('PASS: actual finite registration, unique claims/tokens, retention/cancel/stop, readiness gating, capture/expiry retirement and bounded order history; native boundaries faked')
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Explicit service cancellation is scoped before mutation; never launches native."""
|
||||
import argparse
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNER = ROOT/'tools/headless_workbench.py'
|
||||
SOURCE = RUNNER.read_text()
|
||||
TREE = ast.parse(SOURCE)
|
||||
|
||||
class ServiceCancelRunner(unittest.TestCase):
|
||||
def refused(self, smoke, project='chat', rounds='1', timeout='420', extra=(), message='--route-service-cancel requires --smoke aircraft-route'):
|
||||
with tempfile.TemporaryDirectory(prefix='erri-service-cancel-parse-') as directory:
|
||||
temporary = Path(directory)
|
||||
result = subprocess.run([sys.executable, str(RUNNER), '--compat', str(temporary/'absent-prefix'), '--profile', str(temporary/'absent-profile'), '--smoke', smoke, '--project', project, '--rounds', rounds, '--timeout', timeout, '--route-service-cancel', *extra], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 2, result.stderr)
|
||||
self.assertIn(message, result.stderr)
|
||||
self.assertEqual(list(temporary.iterdir()), [], 'Invalid opt-in cannot create a prefix lock or profile')
|
||||
|
||||
def test_unrelated_outcomes_refused_before_prefix_or_audio(self):
|
||||
for smoke in ('victory','aircraft-control','aircraft-cancel','aircraft-translate','powered-flight'):
|
||||
with self.subTest(smoke=smoke): self.refused(smoke)
|
||||
guard = SOURCE.index("if a.route_service_cancel and a.smoke != 'aircraft-route'")
|
||||
self.assertLess(guard, SOURCE.index('prefix_lock ='))
|
||||
self.assertLess(guard, SOURCE.index('profile.mkdir('))
|
||||
self.assertLess(guard, SOURCE.index("silent_sink ="))
|
||||
|
||||
def test_original_route_requirements_unchanged(self):
|
||||
for project, rounds, timeout in [('core','1','420'),('chat','2','420'),('chat','1','419')]:
|
||||
with self.subTest(project=project,rounds=rounds,timeout=timeout):
|
||||
self.refused('aircraft-route', project, rounds, timeout, message='requires Chat, one round, and at least 420 seconds')
|
||||
|
||||
def test_valid_opt_in_still_requires_disposable_prefix(self):
|
||||
self.refused('aircraft-route', message='must be a disposable cloned prefix')
|
||||
|
||||
def test_no_second_flight_input_mode(self):
|
||||
for extra, message in [(['--flight-action-inputs'],'flight-action-inputs requires the Core powered-flight fixture'), (['--flight-autohover'],'flight-autohover requires Core powered-flight with flight-action-inputs')]:
|
||||
with self.subTest(extra=extra): self.refused('aircraft-route', extra=extra, message=message)
|
||||
|
||||
def test_actual_argparse_default_and_exact_command(self):
|
||||
# Execute only actual argparse declarations and command assembly AST nodes.
|
||||
# No prefix, process, environment, profile or audio code is evaluated.
|
||||
parser_nodes = []
|
||||
for node in TREE.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t,ast.Name) and t.id=='p' for t in node.targets): parser_nodes.append(node)
|
||||
if isinstance(node, ast.Expr) and isinstance(node.value,ast.Call) and isinstance(node.value.func,ast.Attribute) and isinstance(node.value.func.value,ast.Name) and node.value.func.value.id=='p' and node.value.func.attr=='add_argument': parser_nodes.append(node)
|
||||
scope={'argparse':argparse,'Path':Path,'__doc__':'test'}
|
||||
exec(compile(ast.Module(body=parser_nodes,type_ignores=[]),str(RUNNER),'exec'),scope)
|
||||
for requested in (False,True):
|
||||
args=scope['p'].parse_args(['--compat','/tmp/absent','--profile','/tmp/absent-profile','--smoke','aircraft-route',*(['--route-service-cancel'] if requested else [])])
|
||||
self.assertIs(args.route_service_cancel,requested)
|
||||
nodes=[]
|
||||
for node in TREE.body:
|
||||
if isinstance(node,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='command' for t in node.targets):nodes.append(node)
|
||||
if isinstance(node,ast.If) and isinstance(node.test,ast.Attribute) and isinstance(node.test.value,ast.Name) and node.test.value.id=='a' and node.test.attr in {'route_service_cancel','station_teardown_diagnostics','flight_input_diagnostics','flight_action_inputs','flight_autohover'}:nodes.append(node)
|
||||
context={'a':args,'ROOT':ROOT,'proton':Path('/tmp/nonexistent-proton'),'wb':Path('/tmp/nonexistent-workbench'),'profile_arg':'Z:/tmp/absent-profile'}
|
||||
exec(compile(ast.Module(body=nodes,type_ignores=[]),str(RUNNER),'exec'),context)
|
||||
command=context['command']
|
||||
self.assertEqual(command.count('-errSmokeRouteServiceCancel'),int(requested))
|
||||
if requested:
|
||||
self.assertEqual(command[-2:],['-errSmokeRouteServiceCancel','1'])
|
||||
self.assertEqual(command[:-2],normal)
|
||||
else:normal=command
|
||||
self.assertEqual(command[command.index('-errSmokeOutcome')+1],'aircraft-route')
|
||||
self.assertIn('--backend',command)
|
||||
self.assertEqual(command[command.index('--backend')+1],'headless')
|
||||
self.assertNotIn('-errFlightActionInputs',command)
|
||||
self.assertNotIn('-errFlightAutohover',command)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Execute actual Core tick/stop/default maintenance methods with fake engine APIs.
|
||||
|
||||
Checks scheduling/authority boundaries, not native aircraft, network or timing.
|
||||
"""
|
||||
from policy_harness import ROOT,body,run
|
||||
MODE=ROOT/'addons/core/Scripts/Game/ERR/ERR_GameMode.c'
|
||||
ACTORS=ROOT/'addons/core/Scripts/Game/ERR/ERR_Actors.c'
|
||||
|
||||
def cpp(s):
|
||||
s=s.replace('protected ','').replace('Replication.IsServer()','server').replace('Replication.BumpMe()','Bump()')
|
||||
s=s.replace('ERR_Phase.DEFEAT','5').replace('ERR_Phase.PRISON','1').replace('ERR_MissionRules.Terminal(m_Phase)','(m_Phase>=5)')
|
||||
s=s.replace('array<int> connected = {};','std::vector<int> connected;').replace('foreach (int id : connected)','for (int id : connected)')
|
||||
s=s.replace('SCR_PlayerController controller = SCR_PlayerController.Cast(', 'Controller* controller = Controller::Cast(')
|
||||
s=s.replace('foreach (IEntity entity : m_OwnedEntities)','for (Entity* entity : m_OwnedEntities)')
|
||||
s=s.replace('SCR_AIGroup.Cast(entity)','(entity && entity->group)').replace('ERR_Actors.DeleteSpawned(entity)','Delete(entity)')
|
||||
s=s.replace('ERR_ObserverReason.NONE','0')
|
||||
for var in ('m_Extraction','m_RadioDefence','m_Recovery','m_Command','m_Roster','controller','GetWorld()','GetGame()','GetPlayerManager()','GetCallqueue()'):
|
||||
s=s.replace(var+'.',var+'->')
|
||||
for name in ('StartScenario','ServerTick','BindLocalObserver'):s=s.replace('Remove('+name+')','Remove("'+name+'")')
|
||||
return s
|
||||
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
bool server=true;int bumps=0;void Bump(){bumps++;}
|
||||
std::vector<std::string> events;
|
||||
struct World {float now=1234;float GetWorldTime(){return now;}}world;
|
||||
struct Controller {int updates=0,resets=0;static Controller* Cast(Controller* p){return p;}void ERR_UpdateObserverServer(){updates++;events.push_back("observer");}void ERR_SetObserver(int){resets++;}};
|
||||
struct Players {int count=0;Controller controller;int GetPlayerCount(){return count;}void GetPlayers(std::vector<int>& out){if(count)out.push_back(1);}Controller* GetPlayerController(int){return &controller;}}players;
|
||||
struct Queue {std::vector<std::string> removed;void Remove(std::string s){removed.push_back(s);}}queue;
|
||||
struct Game {Players* GetPlayerManager(){return &players;}Queue* GetCallqueue(){return &queue;}}game;Game* GetGame(){return &game;}
|
||||
struct Hook {int calls=0;std::function<void()> callback;std::string name;void Invoke(float=0){calls++;events.push_back(name);if(callback)callback();}};
|
||||
struct Work {int calls=0;std::string name;std::function<void()> callback;void Tick(float=0){calls++;events.push_back(name);if(callback)callback();}};
|
||||
struct Roster {bool survivor=true;bool HasSurvivor(){return survivor;}}roster;
|
||||
struct Entity {bool group=false;};int deleted=0;void Delete(Entity*){deleted++;}
|
||||
struct Command {
|
||||
virtual '''+body(ACTORS,'void Maintain(')+r'''
|
||||
virtual void Tick(float){events.push_back("tactics");}
|
||||
virtual void Stop(){events.push_back("stop");}
|
||||
virtual ~Command()=default;
|
||||
};
|
||||
struct Retained : Command {
|
||||
int maintenance=0,tactics=0,stops=0;float time=-1;bool pending=true;
|
||||
std::function<void()> callback;
|
||||
void Maintain(float now) override {maintenance++;time=now;events.push_back("maintain");if(callback)callback();}
|
||||
void Tick(float) override {tactics++;events.push_back("tactics");}
|
||||
void Stop() override {stops++;pending=false;events.push_back("stop");}
|
||||
};
|
||||
struct Mode {
|
||||
bool m_Stopped=false,m_NativeEndHandled=false,m_StartedPlayers=false;int m_Phase=1;
|
||||
float m_NoSurvivorsSince=-1;std::string m_EndReason;
|
||||
Work extraction{0,"extraction",{}},radio{0,"radio",{}},recovery{0,"recovery",{}};
|
||||
Work* m_Extraction=&extraction;Work* m_RadioDefence=&radio;Work* m_Recovery=&recovery;
|
||||
Command* m_Command=nullptr;Roster* m_Roster=&roster;
|
||||
Hook m_OnTick{0,{},"event"},m_OnStop{0,{},"onstop"};std::vector<Entity*> m_OwnedEntities;
|
||||
World* GetWorld(){return &world;}bool RefreshRadioFeedback(){return false;}
|
||||
void Transition(int phase){m_Phase=phase;StopServices();}
|
||||
'''+cpp(body(MODE,'bool Active('))+cpp(body(MODE,'protected void ServerTick('))+cpp(body(MODE,'protected void StopServices('))+r'''
|
||||
};
|
||||
void reset(){server=true;events.clear();world.now=1234;players.count=0;players.controller=Controller();queue.removed.clear();roster.survivor=true;deleted=0;}
|
||||
int main(){
|
||||
// Existing defaults remain inert; the no-player tactical pause is preserved.
|
||||
{reset();Mode m;Command c;m.m_Command=&c;m.ServerTick();assert((events==std::vector<std::string>{"extraction","radio","recovery"}));assert(m.m_OnTick.calls==0);}
|
||||
// Retained work receives exactly one maintenance call on an empty server,
|
||||
// including later ticks, without tactical decisions, observer updates or OnTick.
|
||||
{reset();Mode m;Retained c;m.m_Command=&c;m.m_NoSurvivorsSince=100;
|
||||
m.ServerTick();world.now=2234;m.ServerTick();assert(c.maintenance==2&&c.time==2234&&c.tactics==0&&c.pending);
|
||||
assert(players.controller.updates==0&&m.m_OnTick.calls==0&&m.m_NoSurvivorsSince==-1);}
|
||||
// Populated ticks have exactly one maintenance call before ordinary decisions.
|
||||
{reset();players.count=1;Mode m;Retained c;m.m_Command=&c;m.ServerTick();assert(c.maintenance==1&&c.tactics==1);
|
||||
assert((events==std::vector<std::string>{"extraction","radio","recovery","maintain","observer","tactics","event"}));}
|
||||
// No command service is valid both with and without connected players.
|
||||
{reset();Mode m;m.ServerTick();players.count=1;m.ServerTick();assert(m.m_OnTick.calls==1);}
|
||||
// Authority, preparation, terminal phase, native end and stopped service all
|
||||
// refuse maintenance as well as tactics. No stale clock/actor admission exists.
|
||||
for(int fault=0;fault<5;fault++){reset();Mode m;Retained c;m.m_Command=&c;
|
||||
if(fault==0){server=false;} if(fault==1){m.m_Phase=0;} if(fault==2){m.m_Phase=5;}
|
||||
if(fault==3){m.m_NativeEndHandled=true;} if(fault==4){m.m_Stopped=true;}
|
||||
m.ServerTick();assert(c.maintenance==0&&c.tactics==0&&events.empty());}
|
||||
// A preceding service can terminalize the round: later maintenance must stop.
|
||||
for(int earlier=0;earlier<2;earlier++){reset();Mode m;Retained c;m.m_Command=&c;
|
||||
auto end=[&](){m.Transition(5);};if(earlier==0)m.extraction.callback=end;else m.recovery.callback=end;
|
||||
m.ServerTick();assert(c.maintenance==0&&c.tactics==0&&c.stops==1&&!m.Active());}
|
||||
// Maintenance may itself end the round. Its return cannot resurrect tactics.
|
||||
{reset();players.count=1;Mode m;Retained c;m.m_Command=&c;c.callback=[&](){m.Transition(5);};m.ServerTick();
|
||||
assert(c.maintenance==1&&c.tactics==0&&c.stops==1&&m.m_OnTick.calls==0&&players.controller.updates==0);}
|
||||
// Existing defeat path still runs after maintenance with players present.
|
||||
{reset();players.count=1;roster.survivor=false;Mode m;Retained c;m.m_Command=&c;m.m_StartedPlayers=true;m.m_NoSurvivorsSince=0;world.now=30000;
|
||||
m.ServerTick();assert(c.maintenance==1&&c.stops==1&&c.tactics==0&&m.m_Phase==5&&!m.Active());}
|
||||
// Stop removes the existing callback before the command receives terminal Stop;
|
||||
// no maintenance drain loop or new scheduling has been introduced in Core.
|
||||
{reset();Mode m;Retained c;m.m_Command=&c;Entity group{true},other{false};m.m_OwnedEntities={&group,&other};
|
||||
m.StopServices();m.StopServices();m.ServerTick();assert(c.stops==1&&c.maintenance==0&&!c.pending&&deleted==1);
|
||||
assert((queue.removed==std::vector<std::string>{"StartScenario","ServerTick","BindLocalObserver"}));
|
||||
assert((events==std::vector<std::string>{"stop","onstop"}));}
|
||||
}
|
||||
''')
|
||||
print('PASS: actual Core maintenance/default/tick/stop methods; empty/populated authority, terminal reentry, no tactical resume or new scheduler; native boundaries substituted')
|
||||
@@ -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()
|
||||
|
||||
@@ -114,7 +114,9 @@ class NativePackageTests(unittest.TestCase):
|
||||
(folder/'external.pak').symlink_to(self.source/'addons/core/addon.gproj')
|
||||
with self.assertRaises(ValueError): n.package_inventory(folder)
|
||||
|
||||
def native_double(self, authored, timeout=30, mutate=False, failure=None, native_code=0, write_status=True, launcher_code=0):
|
||||
def native_double(self, authored, timeout=30, mutate=False, failure=None, native_code=0, write_status=True, launcher_code=0, silent_workbench=False, exit_after_init=False, cleanup_proof=None, prefix_remains_active=False):
|
||||
if cleanup_proof is None:
|
||||
cleanup_proof = {'cleanup_confirmed': True, 'idle_verified': True, 'errors': [], 'orphan_cleanup': {'attempted': False}}
|
||||
child = Mock(); child.poll.return_value = launcher_code
|
||||
def spawn(*args, **kwargs):
|
||||
self.assertNotIn('WINEPREFIX', kwargs['env'])
|
||||
@@ -124,7 +126,7 @@ class NativePackageTests(unittest.TestCase):
|
||||
(folder/'console.log').write_text('SYNTHETIC NATIVE PROCESS DOUBLE\n'+(failure or ''))
|
||||
(self.session/'packages/core/data.pak').write_bytes(b'SYNTHETIC TEST ONLY')
|
||||
if write_status:
|
||||
native_command = n.command_for(self.steam, self.session, 'core')
|
||||
native_command = n.command_for(self.steam, self.session, 'core', silent_workbench, exit_after_init)
|
||||
native_command = native_command[native_command.index('--')+1:]
|
||||
(self.session/'evidence/core/native-exit.json').write_text(json.dumps({'command': native_command, 'exit_code': native_code}))
|
||||
if mutate:
|
||||
@@ -132,11 +134,17 @@ class NativePackageTests(unittest.TestCase):
|
||||
return child
|
||||
def check_output(args, **kwargs):
|
||||
return '[]' if 'sink-inputs' in args else '987'
|
||||
with patch.object(n, 'assert_no_game'), patch.object(n, 'assert_prefix_idle'), \
|
||||
patch.object(n, 'cleanup') as cleanup, patch.object(n.subprocess, 'Popen', side_effect=spawn), \
|
||||
probes = 0
|
||||
def idle(*args):
|
||||
nonlocal probes
|
||||
probes += 1
|
||||
if prefix_remains_active and probes > 2:
|
||||
raise ValueError('synthetic adopted helper remains after native exit')
|
||||
with patch.object(n, 'assert_no_game'), patch.object(n, 'assert_prefix_idle', side_effect=idle), \
|
||||
patch.object(n, 'cleanup_owned_run', return_value=cleanup_proof) as cleanup, patch.object(n.subprocess, 'Popen', side_effect=spawn), \
|
||||
patch.object(n.subprocess, 'check_output', side_effect=check_output), patch.object(n.subprocess, 'run') as process:
|
||||
result = n.run_project(self.steam, self.compat, self.session, 'core', timeout, authored)
|
||||
cleanup.assert_called_once()
|
||||
result = n.run_project(self.steam, self.compat, self.session, 'core', timeout, authored, silent_workbench, exit_after_init)
|
||||
cleanup.assert_called_once_with(child, Path(n.command_for(self.steam, self.session, 'core')[n.command_for(self.steam, self.session, 'core').index('--')+1]), unittest.mock.ANY, self.compat)
|
||||
self.assertTrue(any(call.args[0] == ['pactl', 'unload-module', '987'] for call in process.call_args_list))
|
||||
return result
|
||||
|
||||
@@ -179,7 +187,7 @@ class NativePackageTests(unittest.TestCase):
|
||||
probes += 1
|
||||
if probes > 2: raise ValueError('synthetic owned prefix child remains')
|
||||
with patch.object(n, 'assert_no_game'), patch.object(n, 'assert_prefix_idle', side_effect=idle), \
|
||||
patch.object(n, 'cleanup') as cleanup, patch.object(n.subprocess, 'Popen', return_value=child), \
|
||||
patch.object(n, 'cleanup_owned_run', return_value={'cleanup_confirmed': False, 'idle_verified': False, 'errors': ['synthetic owned prefix child remains']}) as cleanup, patch.object(n.subprocess, 'Popen', return_value=child), \
|
||||
patch.object(n.subprocess, 'check_output', side_effect=lambda args, **kw: '[]' if 'sink-inputs' in args else '987'), \
|
||||
patch.object(n.subprocess, 'run'), patch.object(n.time, 'monotonic', side_effect=itertools.count(0, 6)), patch.object(n.time, 'sleep'):
|
||||
report = n.run_project(self.steam, self.compat, self.session, 'core', 100, authored)
|
||||
@@ -225,14 +233,14 @@ class NativePackageTests(unittest.TestCase):
|
||||
copy.assert_not_called()
|
||||
self.assertFalse(fresh.exists())
|
||||
|
||||
def orchestration_double(self, reports, continuation=True, idle=None):
|
||||
def orchestration_double(self, reports, continuation=True, idle=None, exit_after_init=False):
|
||||
import contextlib
|
||||
import io
|
||||
import uuid
|
||||
fresh = Path('/tmp')/('erri-package-orchestration-'+uuid.uuid4().hex)
|
||||
self.addCleanup(lambda: shutil.rmtree(fresh, ignore_errors=True))
|
||||
args = argparse.Namespace(steam=self.steam, compat=self.compat, session=fresh,
|
||||
project='chat', timeout=30, continue_on_failure=continuation)
|
||||
project='chat', timeout=30, continue_on_failure=continuation, exit_after_init=exit_after_init)
|
||||
with patch.object(n, 'assert_no_game'), patch.object(n, 'assert_prefix_idle', side_effect=idle), \
|
||||
patch.object(n.shutil, 'which', return_value='/mock'), \
|
||||
patch.object(n, 'run_project', side_effect=reports) as run, \
|
||||
@@ -387,6 +395,141 @@ class NativePackageTests(unittest.TestCase):
|
||||
self.assertIn('-packAddon', silent)
|
||||
self.assertIn('-noBackend', silent)
|
||||
|
||||
def test_exit_after_init_changes_only_documented_lifecycle_flag(self):
|
||||
ordinary = n.command_for(self.steam, self.session, 'core')
|
||||
experiment = n.command_for(self.steam, self.session, 'core', exit_after_init=True)
|
||||
self.assertEqual(experiment, ordinary + ['-exitAfterInit'])
|
||||
self.assertNotIn('-wbSilent', experiment)
|
||||
self.assertNotIn('-run', experiment)
|
||||
with self.assertRaisesRegex(ValueError, 'only one'):
|
||||
n.command_for(self.steam, self.session, 'core', True, True)
|
||||
with patch.object(n, 'run', return_value=1) as run:
|
||||
base = ['--session', '/tmp/not-launched', '--compat', str(self.compat)]
|
||||
n.main(base)
|
||||
self.assertFalse(run.call_args.args[0].silent_workbench)
|
||||
self.assertFalse(run.call_args.args[0].exit_after_init)
|
||||
n.main(base + ['--silent-workbench'])
|
||||
self.assertTrue(run.call_args.args[0].silent_workbench)
|
||||
self.assertFalse(run.call_args.args[0].exit_after_init)
|
||||
n.main(base + ['--exit-after-init'])
|
||||
self.assertTrue(run.call_args.args[0].exit_after_init)
|
||||
self.assertFalse(run.call_args.args[0].silent_workbench)
|
||||
run.reset_mock()
|
||||
with self.assertRaises(SystemExit) as refused:
|
||||
n.main(base + ['--silent-workbench', '--exit-after-init'])
|
||||
self.assertEqual(refused.exception.code, 2)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_exit_after_init_forwards_across_dependency_closure(self):
|
||||
code, summary, calls = self.orchestration_double(
|
||||
[self.completed_report(True) for _ in range(5)], exit_after_init=True)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(len(calls), 5)
|
||||
self.assertTrue(all(call.args[-2:] == (False, True) for call in calls))
|
||||
self.assertFalse(summary['release_qualified'])
|
||||
|
||||
def test_exit_after_init_records_exact_command_and_retains_shutdown_errors(self):
|
||||
authored = n.copy_payload(self.source, self.session)
|
||||
report = self.native_double(authored, launcher_code=None, exit_after_init=True,
|
||||
failure='RESOURCES (E): synthetic shutdown UI leak')
|
||||
launch = json.loads((self.session/'evidence/core/launch.json').read_text())
|
||||
self.assertEqual(launch['native_command'][-1], '-exitAfterInit')
|
||||
self.assertTrue(launch['exit_after_init_experiment'])
|
||||
self.assertTrue(report['exit_after_init_experiment'])
|
||||
self.assertFalse(report['silent_workbench_experiment'])
|
||||
status = json.loads((self.session/'evidence/core/native-exit.json').read_text())
|
||||
self.assertEqual(status['command'], launch['native_command'])
|
||||
self.assertEqual(report['native_exit_code'], 0)
|
||||
self.assertEqual(report['completion_observed'], 'native-child-exited')
|
||||
self.assertFalse(report['candidate_created'])
|
||||
self.assertTrue(report['errors'])
|
||||
self.assertFalse(report['release_qualified'])
|
||||
console = self.session/'profiles/core/logs/logs_test/console.log'
|
||||
self.assertIn('synthetic shutdown UI leak', console.read_text())
|
||||
self.assertEqual(report['logs'][str(console)]['sha256'], n.digest(console))
|
||||
|
||||
def test_exit_after_init_without_recorded_native_exit_still_refuses(self):
|
||||
authored = n.copy_payload(self.source, self.session)
|
||||
report = self.native_double(authored, exit_after_init=True, write_status=False)
|
||||
self.assertFalse(report['candidate_created'])
|
||||
self.assertIsNone(report['native_exit_code'])
|
||||
|
||||
def test_exit_after_init_timeout_keeps_error_and_cleanup_rules(self):
|
||||
authored = n.copy_payload(self.source, self.session)
|
||||
report = self.native_double(authored, exit_after_init=True, timeout=0)
|
||||
self.assertEqual(report['completion_observed'], 'timeout')
|
||||
self.assertFalse(report['candidate_created'])
|
||||
self.assertTrue(report['cleanup_confirmed'])
|
||||
|
||||
def test_shared_cleanup_report_preserves_partial_refusal_and_native_errors(self):
|
||||
authored = n.copy_payload(self.source, self.session)
|
||||
proof = {'cleanup_confirmed': False, 'idle_verified': False,
|
||||
'errors': ['private helper cleanup: ambiguous live process'],
|
||||
'orphan_cleanup': {'attempted': True, 'report': {'cleared': False, 'term_sent': [123]}}}
|
||||
report = self.native_double(authored, launcher_code=None,
|
||||
failure='RESOURCES (E): retained UI leak', cleanup_proof=proof)
|
||||
self.assertEqual(report['cleanup_report'], proof)
|
||||
self.assertFalse(report['cleanup_confirmed'])
|
||||
self.assertFalse(report['candidate_created'])
|
||||
self.assertIn('ambiguous live process', report['runner_error'])
|
||||
self.assertTrue(any('retained UI leak' in error for error in report['errors']))
|
||||
self.assertEqual(report['native_exit_code'], 0)
|
||||
self.assertEqual(report['completion_observed'], 'native-child-exited')
|
||||
|
||||
def test_successful_adopted_cleanup_cannot_upgrade_timeout(self):
|
||||
authored = n.copy_payload(self.source, self.session)
|
||||
proof = {'cleanup_confirmed': True, 'idle_verified': True, 'errors': [],
|
||||
'orphan_cleanup': {'attempted': True, 'report': {'cleared': True, 'term_sent': [123], 'kill_sent': []}}}
|
||||
report = self.native_double(authored, timeout=0, cleanup_proof=proof)
|
||||
self.assertEqual(report['cleanup_report'], proof)
|
||||
self.assertTrue(report['cleanup_confirmed'])
|
||||
self.assertEqual(report['completion_observed'], 'timeout')
|
||||
self.assertFalse(report['candidate_created'])
|
||||
|
||||
def test_post_run_cleanup_does_not_reclassify_prefix_active_native_exit(self):
|
||||
import itertools
|
||||
authored = n.copy_payload(self.source, self.session)
|
||||
proof = {'cleanup_confirmed': True, 'idle_verified': True, 'errors': [],
|
||||
'orphan_cleanup': {'attempted': True, 'report': {'cleared': True, 'term_sent': [123]}}}
|
||||
with patch.object(n.time, 'monotonic', side_effect=itertools.count(0, 2)), patch.object(n.time, 'sleep'):
|
||||
report = self.native_double(authored, timeout=100, launcher_code=None,
|
||||
cleanup_proof=proof, prefix_remains_active=True,
|
||||
failure='RESOURCES (E): synthetic shutdown leak')
|
||||
self.assertEqual(report['native_exit_code'], 0)
|
||||
self.assertEqual(report['completion_observed'], 'native-or-launcher-exited-prefix-active')
|
||||
self.assertTrue(report['cleanup_confirmed'])
|
||||
self.assertFalse(report['candidate_created'])
|
||||
self.assertTrue(report['errors'])
|
||||
self.assertEqual(report['cleanup_report'], proof)
|
||||
|
||||
def test_packaging_uses_exact_shared_cleanup_adapter_and_scoping(self):
|
||||
import headless_game as game
|
||||
import private_prefix_cleanup as private
|
||||
self.assertIs(n.cleanup_owned_run, game.cleanup_owned_run)
|
||||
for compat, expected in ((private.COMPAT, ['normal', 'adopted', 'idle']),
|
||||
(self.compat, ['normal', 'idle'])):
|
||||
events = []
|
||||
with patch.object(game, 'cleanup', side_effect=lambda *args: events.append('normal')), \
|
||||
patch.object(private, 'cleanup_private_prefix', side_effect=lambda *args: events.append('adopted') or {'cleared': True}), \
|
||||
patch.object(game, 'assert_prefix_idle', side_effect=lambda *args: events.append('idle')):
|
||||
report = n.cleanup_owned_run(Mock(), private.INSTALLED_PROTON, {}, compat)
|
||||
self.assertEqual(events, expected)
|
||||
self.assertTrue(report['cleanup_confirmed'])
|
||||
self.assertEqual(report['orphan_cleanup']['attempted'], compat == private.COMPAT)
|
||||
|
||||
def test_failed_native_launch_does_not_call_post_run_cleanup(self):
|
||||
authored = n.copy_payload(self.source, self.session)
|
||||
with patch.object(n, 'assert_no_game'), patch.object(n, 'assert_prefix_idle'), \
|
||||
patch.object(n.subprocess, 'check_output', return_value='987'), \
|
||||
patch.object(n.subprocess, 'run'), \
|
||||
patch.object(n.subprocess, 'Popen', side_effect=OSError('synthetic launch refusal')), \
|
||||
patch.object(n, 'cleanup_owned_run') as cleanup:
|
||||
report = n.run_project(self.steam, self.compat, self.session, 'core', 30, authored)
|
||||
cleanup.assert_not_called()
|
||||
self.assertIsNone(report['cleanup_report'])
|
||||
self.assertEqual(report['completion_observed'], 'runner-failed')
|
||||
self.assertFalse(report['candidate_created'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Actual policies -> complete synthetic native RAW -> independent validators.
|
||||
|
||||
No raw parser, prerequisite validator, display, or native API is mocked.
|
||||
"""
|
||||
import copy
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
sys.path[:0]=[str(Path(__file__).resolve().parents[1]/'tools'),str(Path(__file__).resolve().parent)]
|
||||
from test_prison_alignment_evidence import build as alignment_fixture, raw as gate_raw
|
||||
from test_prison_action_selection_proof import ChoiceScene
|
||||
import prison_alignment_evidence as facing_validator
|
||||
import prison_action_selection_evidence as validator
|
||||
|
||||
|
||||
def build():
|
||||
frames,look,route,unused=alignment_fixture()
|
||||
frames={i:s for i,s in frames.items() if i<=route['final_sample']}
|
||||
s=ChoiceScene();end=route['samples'][-1]
|
||||
s.now=route['finished_monotonic'];s.next_sample=(end['real_ms']-2000)/1000+.1
|
||||
s.geometry=copy.deepcopy(route['geometry']);s.origin=list(end['origin'])
|
||||
s.camera=[s.origin[0],s.origin[1]+1.7,s.origin[2]-.1]
|
||||
s.angle=math.degrees(math.atan2(end['direction'][0],end['direction'][2]));s.guard_cost=.18
|
||||
make=s.make
|
||||
def snapshot():
|
||||
v=make();v['sample']=int(round((2000+s.now*1000)/100));v['real_ms']=2000+int(round(s.now*1000))
|
||||
v['choices'].update(nonce='test',sample=v['sample'],real_ms=v['real_ms'])
|
||||
return v
|
||||
s.make=snapshot;v=snapshot();v.update(sample=end['sample'],real_ms=end['real_ms'],received_monotonic=end['received_monotonic'])
|
||||
v['choices'].update(sample=v['sample'],real_ms=v['real_ms'])
|
||||
s.snapshot=v;s.history=[copy.deepcopy(v)];s.last_read=v['sample']-1;s.pending=[]
|
||||
s.calibration.update(nonce='test',ready_real_ms=2000,ready_monotonic=0.,observer_start_real_ms=0,
|
||||
observer_deadline_monotonic=42.5,alignment_deadline_monotonic=s.now+8)
|
||||
s.report={'approach_proof':route};s.accepted={'verified':True,'last_sample':route['final_sample']}
|
||||
facing=s.facing();proof=s.select_action()
|
||||
for frame in s.history:frames[frame['sample']]=copy.deepcopy(frame)
|
||||
return frames,look,route,facing,proof
|
||||
|
||||
|
||||
def raw(frames):
|
||||
content=gate_raw(frames)
|
||||
lines=[]
|
||||
for i,s in sorted(frames.items()):
|
||||
c=s.get('choices',{'selected_id':-1,'selected_class':'none','actions':[]})
|
||||
a=c['actions'];entries=','.join(f'{n}={e["class"]}:{e["id"]}:{int(e["permitted"])}' for n,e in enumerate(a))
|
||||
lines.append(f'ERR GATE CHOICE: run=test sample={i} real={s["real_ms"]} selected={c["selected_class"]}:{c["selected_id"]} context={int(s["context_selected"])} actions={len(a)} permissions={len(a)} shown={len(a)} truncated=0 ordered=[{entries}]')
|
||||
return content+'\n'.join(lines)+'\n'
|
||||
|
||||
|
||||
class SelectionEvidenceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):cls.template=build()
|
||||
def result(self,mutation=None):
|
||||
frames,look,route,facing,proof=copy.deepcopy(self.template)
|
||||
if mutation:mutation(frames,look,route,facing,proof)
|
||||
return validator.validate_selection_evidence(raw(frames),'test',look,route,facing,proof)
|
||||
def reject(self,mutation):
|
||||
r=self.result(mutation);self.assertFalse(r['verified'],r);self.assertTrue(r['errors'])
|
||||
def test_actual_policy_chain_passes_complete_unmocked_native_parsers(self):
|
||||
f,l,a,p,s=copy.deepcopy(self.template)
|
||||
r=facing_validator.validate_facing_evidence(raw(f),'test',l,a,p)
|
||||
self.assertTrue(r['verified'],r);self.assertFalse(r['lock_selection_verified'])
|
||||
r=self.result();self.assertTrue(r['verified'],r)
|
||||
self.assertEqual(r['selected_action_id'],0);self.assertFalse(r['held_action_verified']);self.assertFalse(r['escape_verified'])
|
||||
def test_prior_look_approach_or_facing_cannot_be_asserted(self):
|
||||
for which in (1,2,3):
|
||||
self.reject(lambda *args:args[which].update(completed=False))
|
||||
def test_extra_or_wrong_wheel_and_unconfirmed_release_refuse(self):
|
||||
self.reject(lambda f,l,a,p,s:s['wheel'].update(button=4))
|
||||
self.reject(lambda f,l,a,p,s:s['wheel'].update(released=False))
|
||||
self.reject(lambda f,l,a,p,s:s['wheel'].update(confirmed=False))
|
||||
self.reject(lambda f,l,a,p,s:s.update(events=[{'key':'F','down':True}]))
|
||||
self.reject(lambda f,l,a,p,s:s.update(motions=[{'dx':1,'dy':0}]))
|
||||
self.reject(lambda f,l,a,p,s:s['wheel'].update(events=[4,5]))
|
||||
self.reject(lambda f,l,a,p,s:s.update(wheel_events=[{'button':4}]))
|
||||
def test_full_3d_body_drift_and_capture_context_refuse(self):
|
||||
def drift(f,l,a,p,s):
|
||||
i=s['initial_sample']+1;f[i]['origin'][0]+=.04;f[i]['origin'][1]+=.04
|
||||
self.reject(drift)
|
||||
self.reject(lambda f,l,a,p,s:f[s['initial_sample']+1].update(context_selected=False))
|
||||
self.reject(lambda f,l,a,p,s:f[s['initial_sample']+1].update(phase=2))
|
||||
def test_original_choice_ids_order_and_permission_cannot_change(self):
|
||||
self.reject(lambda f,l,a,p,s:f[s['final_sample']]['choices']['actions'][1].update(id=8))
|
||||
self.reject(lambda f,l,a,p,s:f[s['final_sample']]['choices']['actions'].reverse())
|
||||
self.reject(lambda f,l,a,p,s:f[s['final_sample']]['choices']['actions'][0].update(permitted=True))
|
||||
self.reject(lambda f,l,a,p,s:f[s['final_sample']]['choices'].update(selected_id=1))
|
||||
self.reject(lambda f,l,a,p,s:f[s['final_sample']]['choices'].update(selected_class='ERR_PrisonDoorAction'))
|
||||
def test_facing_pin_cannot_change_before_final_pair(self):
|
||||
def change(f,l,a,p,s):
|
||||
for i in range(p['initial_sample'],p['final_sample']):
|
||||
if f[i]['context_selected']:
|
||||
f[i]['choices']['actions'][1]['id']=8;return
|
||||
raise AssertionError('simulation must expose early native context')
|
||||
self.reject(change)
|
||||
def test_facing_is_not_lock_selection_and_requires_context(self):
|
||||
self.reject(lambda f,l,a,p,s:p.update(selection_samples=p['facing_samples']))
|
||||
self.reject(lambda f,l,a,p,s:f[p['final_sample']].update(context_selected=False))
|
||||
self.reject(lambda f,l,a,p,s:f[p['final_sample']].update(progress_bound=True))
|
||||
def test_deadlines_hold_cap_and_ack_bound_are_not_renewable(self):
|
||||
self.reject(lambda f,l,a,p,s:s.update(deadline_monotonic=s['deadline_monotonic']+.1))
|
||||
self.reject(lambda f,l,a,p,s:s.update(finished_monotonic=s['deadline_monotonic']+.1))
|
||||
self.reject(lambda f,l,a,p,s:s['wheel'].update(up_monotonic=s['wheel']['attempted_monotonic']+1.6))
|
||||
self.reject(lambda f,l,a,p,s:s['wheel'].update(down_monotonic=s['started_monotonic']-.1))
|
||||
self.reject(lambda f,l,a,p,s:s.update(finished_monotonic=s['wheel']['up_monotonic']+1.6))
|
||||
def test_pre_dispatch_insufficient_remaining_time_refuses(self):
|
||||
def exhausted(f,l,a,p,s):
|
||||
# Jointly shortening a valid upstream lease must not bypass the
|
||||
# selection policy's >=1.5s admission requirement.
|
||||
d=s['wheel']['attempted_monotonic']+1.49
|
||||
p['deadline_monotonic']=d;s['deadline_monotonic']=d
|
||||
self.reject(exhausted)
|
||||
def test_sample_receipts_stale_missing_or_nonfinite_refuse(self):
|
||||
self.reject(lambda f,l,a,p,s:s['samples'][-1].update(received_monotonic=float('nan')))
|
||||
self.reject(lambda f,l,a,p,s:s['samples'].pop(1))
|
||||
self.reject(lambda f,l,a,p,s:s.update(finished_monotonic=s['samples'][-1]['received_monotonic']+.6))
|
||||
self.reject(lambda f,l,a,p,s:s['wheel'].update(attempted_monotonic=s['started_monotonic']-.2))
|
||||
def test_selected_lock_needs_consecutive_bound_neutral_samples(self):
|
||||
self.reject(lambda f,l,a,p,s:f[s['selection_samples'][0]].update(progress_bound=False))
|
||||
self.reject(lambda f,l,a,p,s:f[s['selection_samples'][0]].update(pressed=1))
|
||||
self.reject(lambda f,l,a,p,s:s.update(selection_samples=[s['final_sample'],s['final_sample']]))
|
||||
self.reject(lambda f,l,a,p,s:f[s['selection_samples'][0]].update(input=[0,0,1]))
|
||||
def test_native_lock_selected_before_wheel_is_not_acknowledgement(self):
|
||||
def early(f,l,a,p,s):
|
||||
i=s['wheel']['after_sample'];f[i].update(selected=True,progress_bound=True)
|
||||
f[i]['choices'].update(selected_id=0,selected_class='ERR_UnlockPrisonAction')
|
||||
self.reject(early)
|
||||
def test_selected_then_dropped_then_reacquired_cannot_hide_in_batch(self):
|
||||
def dropped(f,l,a,p,s):
|
||||
first=s['wheel']['after_sample']+1
|
||||
f[first].update(selected=True,progress_bound=True);f[first]['choices'].update(selected_id=0,selected_class='ERR_UnlockPrisonAction')
|
||||
f[first+1].update(selected=False,progress_bound=False);f[first+1]['choices'].update(selected_id=1,selected_class='ERR_PrisonDoorAction')
|
||||
self.reject(dropped)
|
||||
def test_final_bearing_and_stability_are_native(self):
|
||||
def angle(f,l,a,p,s):
|
||||
i=s['selection_samples'][0];v=f[i]['direction'];r=math.atan2(v[0],v[2])+math.radians(.3)
|
||||
f[i]['direction']=[math.sin(r),0,math.cos(r)]
|
||||
self.reject(angle)
|
||||
self.reject(lambda f,l,a,p,s:f[s['final_sample']].update(direction=[0,0,1]))
|
||||
def test_report_geometry_cannot_replace_native_measurement(self):
|
||||
def forged(f,l,a,p,s):
|
||||
p['geometry']['context'][0]+=.2;s['geometry']['context'][0]+=.2
|
||||
self.reject(forged)
|
||||
def test_missing_choice_record_or_reversed_choice_clock_refuses(self):
|
||||
f,l,a,p,s=copy.deepcopy(self.template);content=raw(f)
|
||||
line=next(x for x in content.splitlines() if x.startswith(f'ERR GATE CHOICE: run=test sample={s["final_sample"]} '))
|
||||
self.assertFalse(validator.validate_selection_evidence(content.replace(line+'\n',''),'test',l,a,p,s)['verified'])
|
||||
self.assertFalse(validator.validate_selection_evidence(content.replace(line,line.replace('run=test','run=other')),'test',l,a,p,s)['verified'])
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Synthetic joined native snapshots; no display, engine, or input backend."""
|
||||
import copy
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
|
||||
from test_prison_alignment_proof import Scene
|
||||
import prison_alignment_proof as alignment
|
||||
import prison_action_selection_proof as selection
|
||||
|
||||
|
||||
class ChoiceScene(Scene):
|
||||
def __init__(self):
|
||||
self.selected_at = None; self.choices_fault = None; self.wheels = []
|
||||
self.wheel_delay = .15; self.wheel_ignored = False
|
||||
super().__init__()
|
||||
def make(self):
|
||||
s = super().make()
|
||||
target = math.degrees(math.atan2(self.geometry['context'][0]-self.camera[0],self.geometry['context'][2]-self.camera[2]))
|
||||
visible = abs(alignment.delta(target,self.angle)) <= 3
|
||||
chosen = self.selected_at is not None and self.now >= self.selected_at
|
||||
s.update(selected=chosen,context_selected=visible,cached_performable=visible,progress_bound=chosen)
|
||||
s['choices']={'nonce':'N','sample':s['sample'],'real_ms':s['real_ms'],
|
||||
'selected_id':0 if chosen else 1,'selected_class':'ERR_UnlockPrisonAction' if chosen else 'ERR_PrisonDoorAction',
|
||||
'actions':[{'class':'ERR_PrisonDoorAction','id':1,'permitted':False},
|
||||
{'class':'ERR_UnlockPrisonAction','id':0,'permitted':True}]}
|
||||
if self.choices_fault: self.choices_fault(s)
|
||||
return s
|
||||
def facing(self):
|
||||
return alignment.perform_facing(self,self.idle,self,self.observe,self.geometry,self.calibration,self.accepted,self.report,
|
||||
clock=self.clock,sleep=self.sleep,notify=self.notifications.append)
|
||||
def pulse(self,button,guard,*,deadline):
|
||||
if button != 5: raise AssertionError('Only one explicit logical5 capability')
|
||||
guard.check();down=self.clock()
|
||||
self.sleep(.01);guard.check();up=self.clock()
|
||||
if up >= deadline: raise selection.Refused('owned adapter deadline')
|
||||
self.wheels.append((button,down,up,deadline))
|
||||
if not self.wheel_ignored:self.selected_at=down+self.wheel_delay
|
||||
return {'button':button,'down_monotonic':down,'up_monotonic':up,'released':True}
|
||||
def select_action(self):
|
||||
facing=self.report['facing_proof']
|
||||
self.last_read=facing['final_sample']-1
|
||||
return selection.perform_selection(self,self.idle,self,self.observe,self.geometry,self.calibration,
|
||||
{'verified':True,'last_sample':facing['final_sample']},self.report,
|
||||
clock=self.clock,sleep=self.sleep,notify=self.notifications.append)
|
||||
|
||||
|
||||
class SelectionTests(unittest.TestCase):
|
||||
def test_actual_pose_facing_then_one_wheel_selects_with_same_lease(self):
|
||||
s=ChoiceScene();f=s.facing();r=s.select_action()
|
||||
self.assertTrue(f['completed']);self.assertNotIn('alignment_proof',s.report)
|
||||
self.assertNotIn('selection_samples',f);self.assertEqual(len(f['facing_samples']),2)
|
||||
self.assertFalse(f['samples'][-1]['selected']);self.assertFalse(f['samples'][-1]['progress_bound'])
|
||||
self.assertTrue(r['completed']);self.assertEqual(len(s.wheels),1)
|
||||
self.assertEqual(r['deadline_monotonic'],f['deadline_monotonic'])
|
||||
self.assertEqual([x['class'] for x in r['actions']],['ERR_PrisonDoorAction','ERR_UnlockPrisonAction'])
|
||||
a,b=r['selection_samples'];raw={x['sample']:x for x in r['samples']}
|
||||
self.assertEqual(b,a+1);self.assertGreaterEqual(raw[b]['real_ms']-raw[a]['real_ms'],100)
|
||||
self.assertTrue(raw[a]['progress_bound'] and raw[b]['progress_bound'])
|
||||
self.assertLess(s.now,39);self.assertEqual(s.origin,[6092.42,211.297,7155.81])
|
||||
def test_facing_stops_without_waiting_for_lock_selection(self):
|
||||
s=ChoiceScene();f=s.facing()
|
||||
self.assertTrue(f['completed']);self.assertLess(s.now,38)
|
||||
self.assertEqual(s.wheels,[]);self.assertFalse(f['samples'][-1]['selected'])
|
||||
def test_wrong_direction_or_ignored_wheel_never_retries(self):
|
||||
s=ChoiceScene();s.facing();s.wheel_ignored=True
|
||||
with self.assertRaisesRegex(selection.Refused,'Single wheel|deadline|acknowledgement'):s.select_action()
|
||||
self.assertEqual(len(s.wheels),1)
|
||||
self.assertFalse(s.report['action_selection_proof']['completed'])
|
||||
def test_actual_delayed_selection_waits_for_two_fresh_bound_samples(self):
|
||||
s=ChoiceScene();s.facing();s.wheel_delay=.65
|
||||
r=s.select_action();self.assertTrue(r['completed'])
|
||||
raw={x['sample']:x for x in r['samples']}
|
||||
self.assertGreaterEqual(raw[r['selection_samples'][0]]['received_monotonic'],s.wheels[0][1]+.65)
|
||||
def test_insufficient_shared_budget_refuses_before_wheel(self):
|
||||
s=ChoiceScene();s.facing();s.sleep(39-s.now-1.4)
|
||||
with self.assertRaisesRegex(selection.Refused,'Insufficient'):s.select_action()
|
||||
self.assertEqual(s.wheels,[])
|
||||
def test_explicit_handoff_cannot_renew_facing_or_original_deadline(self):
|
||||
s=ChoiceScene();s.facing();s.report['facing_proof']['deadline_monotonic']=50
|
||||
with self.assertRaisesRegex(selection.Refused,'renewed'):s.select_action()
|
||||
self.assertEqual(s.wheels,[])
|
||||
def test_filtered_list_and_join_faults_refuse_facing_without_wheel(self):
|
||||
def third(x):x['choices']['actions'].append({'class':'Other','id':12,'permitted':True})
|
||||
def order(x):x['choices']['actions'].reverse()
|
||||
def identity(x):x['choices']['actions'][1]['id']=1
|
||||
def nonce(x):x['choices']['nonce']='other'
|
||||
def timestamp(x):x['choices']['real_ms']-=1
|
||||
def permission(x):x['choices']['actions'][0]['permitted']=True
|
||||
def selected_class(x):x['choices']['selected_class']='Other'
|
||||
def missing(x):x.pop('choices')
|
||||
for fault in (third,order,identity,nonce,timestamp,permission,selected_class,missing):
|
||||
s=ChoiceScene();s.choices_fault=fault
|
||||
with self.subTest(fault=fault.__name__),self.assertRaises(ValueError):s.facing()
|
||||
self.assertEqual(s.wheels,[])
|
||||
def test_ids_cannot_be_readopted_between_facing_and_selection(self):
|
||||
s=ChoiceScene();s.facing()
|
||||
def replaced(x):x['choices']['actions'][1]['id']=15
|
||||
s.choices_fault=replaced;s.sleep(.2)
|
||||
with self.assertRaisesRegex(selection.Refused,'identities'):s.select_action()
|
||||
self.assertEqual(s.wheels,[])
|
||||
def test_transient_intermediate_movement_or_action_cannot_be_hidden(self):
|
||||
for field,value in [('origin',[6092.46,211.337,7155.81]),('pressed',1.),('performing',True),('input',[0,0,1]),('phase',2)]:
|
||||
s=ChoiceScene();s.facing();s.sleep(.3)
|
||||
i=next(i for i,x in enumerate(s.history) if x['sample']>s.report['facing_proof']['final_sample'])
|
||||
s.history[i][field]=value
|
||||
with self.subTest(field=field),self.assertRaises(ValueError):s.select_action()
|
||||
self.assertEqual(s.wheels,[])
|
||||
def test_focus_loss_or_unconfirmed_pair_is_not_success(self):
|
||||
s=ChoiceScene();s.facing();s.focus=False
|
||||
with self.assertRaisesRegex(ValueError,'focus'):s.select_action()
|
||||
self.assertEqual(s.wheels,[])
|
||||
s=ChoiceScene();s.facing();original=s.pulse
|
||||
def bad(*a,**kw):r=original(*a,**kw);r['released']=False;return r
|
||||
s.pulse=bad
|
||||
with self.assertRaisesRegex(selection.Refused,'release'):s.select_action()
|
||||
self.assertEqual(len(s.wheels),1);self.assertFalse(s.report['action_selection_proof']['wheel']['confirmed'])
|
||||
def test_already_selected_is_not_wheel_proof(self):
|
||||
s=ChoiceScene();s.facing();s.selected_at=s.now;s.sleep(.2)
|
||||
with self.assertRaisesRegex(selection.Refused,'before wheel'):s.select_action()
|
||||
self.assertEqual(s.wheels,[])
|
||||
def test_one_selected_sample_followed_by_drop_cannot_qualify(self):
|
||||
s=ChoiceScene();s.facing();original=s.pulse
|
||||
def pulse(*a,**kw):
|
||||
r=original(*a,**kw)
|
||||
def drop(x):
|
||||
if x['received_monotonic'] > s.selected_at+.11:
|
||||
x.update(selected=False,progress_bound=False)
|
||||
x['choices'].update(selected_id=1,selected_class='ERR_PrisonDoorAction')
|
||||
s.choices_fault=drop
|
||||
return r
|
||||
s.pulse=pulse
|
||||
with self.assertRaises(selection.Refused):s.select_action()
|
||||
self.assertEqual(len(s.wheels),1)
|
||||
def test_unbound_selected_lock_refuses(self):
|
||||
s=ChoiceScene();s.facing();original=s.pulse
|
||||
def pulse(*a,**kw):
|
||||
r=original(*a,**kw)
|
||||
s.choices_fault=lambda x:x.update(progress_bound=False)
|
||||
return r
|
||||
s.pulse=pulse
|
||||
with self.assertRaisesRegex(selection.Refused,'binding'):s.select_action()
|
||||
def test_guarded_wheel_cannot_extend_its_uncertain_button_lease(self):
|
||||
s=ChoiceScene();s.facing()
|
||||
def slow(button,guard,*,deadline):
|
||||
down=s.now;s.sleep(1.6)
|
||||
return {'button':5,'down_monotonic':down,'up_monotonic':s.now,'released':True}
|
||||
s.pulse=slow
|
||||
with self.assertRaisesRegex(selection.Refused,'lease'):s.select_action()
|
||||
self.assertFalse(s.report['action_selection_proof']['completed'])
|
||||
def test_no_extra_mouse_requests_after_facing_handoff(self):
|
||||
s=ChoiceScene();s.facing();count=len(s.motions)
|
||||
s.select_action()
|
||||
self.assertEqual(len(s.motions),count)
|
||||
self.assertTrue(all(event['kind'].startswith(('facing','action-selection')) for event in s.notifications))
|
||||
def test_facing_ids_pin_on_first_visible_context_not_just_final_pair(self):
|
||||
s=ChoiceScene()
|
||||
first=[None]
|
||||
def swap(x):
|
||||
if x['context_selected']:
|
||||
if first[0] is None:first[0]=x['sample']
|
||||
elif x['sample'] > first[0]:x['choices']['actions'][1]['id']=7
|
||||
s.choices_fault=swap
|
||||
with self.assertRaisesRegex(ValueError,'identities'):s.facing()
|
||||
self.assertEqual(s.wheels,[])
|
||||
|
||||
def test_selected_but_oscillating_view_is_not_stable_pair(self):
|
||||
s=ChoiceScene();s.facing();base=s.angle;original=s.pulse
|
||||
def pulse(*a,**kw):
|
||||
r=original(*a,**kw)
|
||||
def oscillate(x):
|
||||
radians=math.radians(base+(.18 if x['sample']%2 else -.18))
|
||||
x['direction']=[math.sin(radians),0.,math.cos(radians)]
|
||||
s.choices_fault=oscillate
|
||||
return r
|
||||
s.pulse=pulse
|
||||
with self.assertRaises(selection.Refused):s.select_action()
|
||||
self.assertFalse(s.report['action_selection_proof']['completed'])
|
||||
|
||||
def test_attempt_report_is_single_use(self):
|
||||
s=ChoiceScene();s.facing();s.select_action()
|
||||
with self.assertRaisesRegex(selection.Refused,'already exists'):s.select_action()
|
||||
self.assertEqual(len(s.wheels),1)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -21,7 +21,7 @@ class Scene:
|
||||
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.guard_cost=.22;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=[]
|
||||
@@ -149,7 +149,7 @@ class ApproachTests(unittest.TestCase):
|
||||
scene=Scene();original=scene.send
|
||||
def slow(key,down):
|
||||
original(key,down)
|
||||
if down:scene.guard_cost=.4
|
||||
if down:scene.guard_cost=.41
|
||||
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])
|
||||
@@ -163,7 +163,11 @@ class ApproachTests(unittest.TestCase):
|
||||
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
|
||||
scene=Scene();scene.turn_delay=.4;scene.angle=110.
|
||||
scene.sample=scene.snapshot();scene.history=[copy.deepcopy(scene.sample)]
|
||||
# A shorter initial heading isolates delayed native turn acknowledgement.
|
||||
scene.position=[100.,0.,97.5]
|
||||
scene.sample=scene.snapshot();scene.history=[copy.deepcopy(scene.sample)]
|
||||
result=scene.run()
|
||||
self.assertTrue(result['completed'])
|
||||
self.assertTrue(all(m['response_sample'] > m['after_sample'] for m in result['motions']))
|
||||
@@ -224,5 +228,102 @@ class ApproachTests(unittest.TestCase):
|
||||
with self.assertRaises(policy.Refused):scene.run(calibration=c)
|
||||
self.assertEqual(scene.turns,[])
|
||||
|
||||
def first_pulse_with_guard_cost(self, cost):
|
||||
scene=Scene();scene.no_movement=True;original=scene.send
|
||||
def send(key,down):
|
||||
original(key,down)
|
||||
if down:scene.guard_cost=cost
|
||||
scene.send=send
|
||||
with self.assertRaises(policy.Refused):scene.run()
|
||||
self.assertFalse(scene.held)
|
||||
self.assertEqual([event[1] for event in scene.key_events],[True,False])
|
||||
return scene
|
||||
|
||||
def test_slow_release_guard_uses_initial_hold_without_extra_sleep(self):
|
||||
scene=self.first_pulse_with_guard_cost(.20)
|
||||
down,up=scene.key_events
|
||||
self.assertAlmostEqual(up[2]-down[2],.20,places=6)
|
||||
timing=scene.report['approach_proof']['release_timings'][0]
|
||||
self.assertFalse(timing['cleanup']);self.assertEqual(len(timing['guards']),1)
|
||||
self.assertAlmostEqual(timing['guards'][0]['entered_monotonic'],down[2])
|
||||
self.assertAlmostEqual(timing['guards'][0]['returned_monotonic'],up[2])
|
||||
self.assertAlmostEqual(timing['up_attempted_monotonic'],up[2])
|
||||
self.assertAlmostEqual(timing['up_returned_monotonic'],up[2])
|
||||
|
||||
def test_fast_guard_waits_only_remainder_then_full_fresh_guard(self):
|
||||
scene=self.first_pulse_with_guard_cost(.01)
|
||||
down,up=scene.key_events
|
||||
self.assertAlmostEqual(up[2]-down[2],.06,places=6)
|
||||
timing=scene.report['approach_proof']['release_timings'][0]
|
||||
first,second=timing['guards']
|
||||
self.assertAlmostEqual(first['entered_monotonic'],down[2])
|
||||
self.assertAlmostEqual(first['returned_monotonic']-down[2],.01,places=6)
|
||||
self.assertAlmostEqual(second['entered_monotonic']-down[2],.05,places=6)
|
||||
self.assertAlmostEqual(second['returned_monotonic'],timing['up_attempted_monotonic'])
|
||||
|
||||
def test_guard_refusal_records_attempt_then_finally_rechecks_before_release(self):
|
||||
scene=Scene();original=scene.check;held_checks=0
|
||||
def check():
|
||||
nonlocal held_checks
|
||||
if scene.held:
|
||||
held_checks+=1
|
||||
if held_checks==1:
|
||||
scene.sleep(.02)
|
||||
raise policy.Refused('first owned release guard refused')
|
||||
original()
|
||||
scene.check=check
|
||||
with self.assertRaisesRegex(policy.Refused,'release guard refused'):scene.run()
|
||||
proof=scene.report['approach_proof'];first,cleanup=proof['release_timings']
|
||||
self.assertIn('refused',first['guards'][0]['error'])
|
||||
self.assertIsNone(first['up_attempted_monotonic'])
|
||||
self.assertTrue(cleanup['cleanup']);self.assertEqual(len(cleanup['guards']),1)
|
||||
self.assertIsNotNone(cleanup['up_returned_monotonic'])
|
||||
self.assertFalse(scene.held);self.assertIsNone(proof['unconfirmed_held_key'])
|
||||
self.assertEqual([event[1] for event in scene.key_events],[True,False])
|
||||
|
||||
def test_fast_guard_cannot_authorize_release_after_wait_and_focus_loss(self):
|
||||
scene=Scene();original_send=scene.send;original_check=scene.check;checks=0
|
||||
def send(key,down):
|
||||
original_send(key,down)
|
||||
if down:scene.guard_cost=.01
|
||||
def check():
|
||||
nonlocal checks
|
||||
if scene.held:
|
||||
checks+=1
|
||||
if checks>=2:scene.focus=False
|
||||
original_check()
|
||||
scene.send=send;scene.check=check
|
||||
with self.assertRaisesRegex(policy.Refused,'focus lost'):scene.run()
|
||||
proof=scene.report['approach_proof'];first,cleanup=proof['release_timings']
|
||||
self.assertEqual(len(first['guards']),2)
|
||||
self.assertIn('focus lost',first['guards'][1]['error'])
|
||||
self.assertIsNone(first['up_attempted_monotonic'])
|
||||
self.assertIsNone(cleanup['up_attempted_monotonic'])
|
||||
self.assertEqual([event[1] for event in scene.key_events],[True])
|
||||
self.assertEqual(proof['unconfirmed_held_key'],'W')
|
||||
self.assertTrue(proof['paired_release_errors'])
|
||||
|
||||
def test_shorter_pulses_do_not_expand_original_pulse_budget(self):
|
||||
scene=Scene();scene.guard_cost=.18 # Original synthetic scene now takes smaller steps.
|
||||
with self.assertRaisesRegex(policy.Refused,'pulse budget'):scene.run()
|
||||
proof=scene.report['approach_proof']
|
||||
self.assertEqual(len(scene.key_events),24)
|
||||
self.assertEqual(len(proof['release_timings']),12)
|
||||
self.assertTrue(all(len(timing['guards'])==1 for timing in proof['release_timings']))
|
||||
self.assertFalse(proof['completed']);self.assertFalse(scene.held)
|
||||
self.assertEqual(proof['deadline_monotonic'],35)
|
||||
|
||||
def test_measured_stopping_deadzone_is_still_refused(self):
|
||||
scene=Scene();scene.position=[102.7,0.,97.3];scene.angle=180.;scene.guard_cost=.33
|
||||
scene.sample=scene.snapshot();scene.history=[copy.deepcopy(scene.sample)]
|
||||
with self.assertRaisesRegex(policy.Refused,'measured stopping reserve'):scene.run()
|
||||
proof=scene.report['approach_proof'];self.assertEqual(len(proof['stops']),1)
|
||||
target,_=policy.target_for(scene.geometry)
|
||||
remaining=policy.horizontal(scene.position,target)
|
||||
self.assertGreater(remaining,.75)
|
||||
self.assertLessEqual(remaining,proof['stops'][0]['distance']+.25)
|
||||
self.assertEqual([event[1] for event in scene.key_events],[True,False])
|
||||
self.assertFalse(proof['completed']);self.assertFalse(scene.held)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
||||
@@ -230,4 +230,69 @@ class GateChoicesRunner(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):wrap(native[:-2]+['-errOrdinaryGateChoices',NONCE])
|
||||
with self.assertRaises(ValueError):wrap(native+['-errOrdinaryGateChoices',NONCE],False)
|
||||
|
||||
class SelectionRunner(unittest.TestCase):
|
||||
def make_selection(self,folder):
|
||||
session,context,report,observation=fixtures.ExerciseTests.make_proof(self,folder)
|
||||
context.update(look_requested=True,approach_requested=True,alignment_requested=True,selection_requested=True,approach_module_sha256=r.approach_module_hashes(r.ROOT),alignment_module_sha256=r.alignment_module_hashes(r.ROOT),selection_module_sha256=r.selection_module_hashes(r.ROOT))
|
||||
report.update({k:context[k] for k in ('look_requested','approach_requested','alignment_requested','selection_requested','approach_module_sha256','alignment_module_sha256','selection_module_sha256')})
|
||||
report.update(look_proof={},facing_proof={},action_selection_proof={'wheel':{'down_monotonic':31.,'up_monotonic':31.2}},wheel_events=[{'button':5,'down':True,'sent':True,'monotonic':31.},{'button':5,'down':False,'sent':True,'monotonic':31.2}])
|
||||
report['events']=report['events'][:2];report['observer']['events']=['READY'];report['approach_proof']={'events':copy.deepcopy(report['events'])}
|
||||
(session/'console.log').write_text('native selection')
|
||||
return session,context,report,observation
|
||||
|
||||
def test_selection_requires_alignment_and_native_choices(self):
|
||||
with self.assertRaises(ValueError):r.validate_input_exercise(SimpleNamespace(exercise_prison_selection=True))
|
||||
with self.assertRaises(ValueError):r.validate_input_exercise(SimpleNamespace(exercise_prison_selection=True,exercise_prison_alignment=True))
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root=Path(folder);(root/'tools').mkdir();profile=root/'profile';profile.mkdir()
|
||||
names=('headless_input_proof.py','headless_display_probe.py','prison_approach_proof.py','ordinary_gate_evidence.py','ordinary_look_evidence.py','prison_approach_evidence.py','prison_alignment_proof.py','prison_alignment_evidence.py','ordinary_gate_choices.py','prison_action_selection_proof.py','prison_action_selection_evidence.py')
|
||||
for name in names:(root/'tools'/name).write_text('# fake')
|
||||
native=['gamescope','--backend','headless','--','/proton','run','/game','-errOrdinaryInputObserve',NONCE,'-errOrdinaryLookObserve',NONCE,'-errOrdinaryGateObserve',NONCE]
|
||||
for has_choices in (False,True):
|
||||
args=native+(['-errOrdinaryGateChoices',NONCE] if has_choices else [])
|
||||
wrapped,display=r.display_probe_command(args,root,Path('/compat'),profile,Path('/game'),NONCE,':0')
|
||||
call=(wrapped,root,Path('/compat'),profile,Path('/game'),NONCE,':0',display,True,True,True,True)
|
||||
if not has_choices:
|
||||
with self.assertRaises(ValueError):r.input_exercise_command(*call)
|
||||
else:
|
||||
cmd,context=r.input_exercise_command(*call);self.assertIn('--enable-private-selection-proof',cmd);self.assertEqual(set(context['selection_module_sha256']),set(names[-3:]))
|
||||
|
||||
def test_raw_selection_validator_owns_success_and_prior_failure_survives(self):
|
||||
for accepted,prior in ((True,'game-started'),(False,'game-started'),(True,'cleanup-failed')):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
session,context,report,observation=self.make_selection(Path(folder));Path(context['report']).write_text(json.dumps(report))
|
||||
approach=ModuleType('prison_approach_evidence');approach.validate_approach_evidence=lambda *a:{'verified':True}
|
||||
selected=ModuleType('prison_action_selection_evidence');calls=[]
|
||||
def validate(*args):calls.append(args);return {'verified':accepted}
|
||||
selected.validate_selection_evidence=validate
|
||||
with patch.dict(sys.modules,{'prison_approach_evidence':approach,'prison_action_selection_evidence':selected}):
|
||||
outcome,proof=r.input_exercise_result(prior,context,session,observation,{'verified':True})
|
||||
self.assertEqual(calls,[('native selection',NONCE,report['look_proof'],report['approach_proof'],report['facing_proof'],report['action_selection_proof'])]);self.assertEqual(proof['verified'],accepted)
|
||||
self.assertEqual(outcome,'input-exercise-incomplete' if not accepted and prior=='game-started' else prior)
|
||||
|
||||
def test_changed_hash_or_unconfirmed_unpaired_extra_wheel_refuses(self):
|
||||
for fault in ('hash','held','release','extra','wrong_button','unsent','time','finally'):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
session,context,report,observation=self.make_selection(Path(folder))
|
||||
if fault=='hash':report['selection_module_sha256']={}
|
||||
elif fault=='held':report['unconfirmed_held_button']=5
|
||||
elif fault=='release':report['wheel_release_errors']=['failed']
|
||||
elif fault=='extra':report['wheel_events'].append(dict(report['wheel_events'][-1]))
|
||||
elif fault=='wrong_button':report['wheel_events'][0]['button']=4
|
||||
elif fault=='unsent':report['wheel_events'][0]['sent']=False
|
||||
elif fault=='time':report['wheel_events'][0]['monotonic']=30.
|
||||
else:report['wheel_events'][1]['finally']=True
|
||||
Path(context['report']).write_text(json.dumps(report))
|
||||
approach=ModuleType('prison_approach_evidence');approach.validate_approach_evidence=lambda *a:{'verified':True}
|
||||
selected=ModuleType('prison_action_selection_evidence');selected.validate_selection_evidence=lambda *a:(_ for _ in ()).throw(AssertionError('malformed wheel reached validator'))
|
||||
with patch.dict(sys.modules,{'prison_approach_evidence':approach,'prison_action_selection_evidence':selected}):
|
||||
outcome,proof=r.input_exercise_result('game-started',context,session,observation,{'verified':True})
|
||||
self.assertFalse(proof['verified'],fault)
|
||||
|
||||
def test_unrequested_wheel_and_facing_do_not_qualify(self):
|
||||
for payload in ({'selection_requested':True},{'selection_requested':1},{'wheel_events':[{}]},{'action_selection_proof':{}},{'facing_proof':{}}):
|
||||
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))
|
||||
outcome,proof=r.input_exercise_result('game-started',context,session,observation,{'verified':True});self.assertFalse(proof['verified'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user