Improve base guidance and qualify bounded aircraft route and hidden test lifecycle
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"""Actual immutable corridor geometry and native-survey orchestration with fake traces."""
|
||||
from policy_harness import ROOT, body, run
|
||||
P=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftRouteEnvelope.c'
|
||||
BASE=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftFlight.c'
|
||||
def cpp(s):
|
||||
for name in ('Math','vector','ERR_AircraftRouteRules','ERR_AircraftFlightRules','SCR_TerrainHelper','TraceFlags'):
|
||||
s=s.replace(name+'.',name+'::')
|
||||
s=s.replace('array<IEntity> excluded = null','void* excluded = nullptr')
|
||||
s=s.replace('TraceBox corridor','TraceBox* corridor').replace('TraceBox low','TraceBox* low')
|
||||
for name in ('corridor','low'):s=s.replace(name+'.',name+'->')
|
||||
return s.replace('GetGame().','GetGame()->').replace('GetWorld().','GetWorld()->').replace(', null)', ', nullptr)')
|
||||
rules='\n'.join(cpp(body(P,key)) for key in ('static bool Length(', 'static vector Nearest(', 'static bool Contains(', 'static bool Held('))
|
||||
run(r'''#include <cassert>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
#include <cstdio>
|
||||
struct Math {static float Clamp(float v,float lo,float hi){return std::clamp(v,lo,hi);}static float Min(float a,float b){return std::min(a,b);}static float Max(float a,float b){return std::max(a,b);}static int Ceil(float x){return std::ceil(x);}static float AbsFloat(float x){return std::abs(x);}};
|
||||
struct vector {float v[3]={};vector(){}vector(float x,float y,float z){v[0]=x;v[1]=y;v[2]=z;}vector(const char*s){sscanf(s,"%f %f %f",v,v+1,v+2);}float&operator[](int i){return v[i];}float operator[](int i)const{return v[i];}vector operator+(vector b)const{return {v[0]+b[0],v[1]+b[1],v[2]+b[2]};}vector operator-(vector b)const{return {v[0]-b[0],v[1]-b[1],v[2]-b[2]};}vector operator*(float n)const{return {v[0]*n,v[1]*n,v[2]*n};}static float Dot(vector a,vector b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}static float DistanceXZ(vector a,vector b){return std::hypot(a[0]-b[0],a[2]-b[2]);}};
|
||||
vector Vector(float x,float y,float z){return {x,y,z};}
|
||||
struct ERR_AircraftFlightRules {
|
||||
'''+cpp(body(BASE,'static bool Hover('))+r'''
|
||||
};
|
||||
struct ERR_AircraftRouteRules {
|
||||
'''+rules+r'''
|
||||
};
|
||||
namespace TraceFlags {enum {ENTS=1,WORLD=2};}
|
||||
struct TraceBox {vector Start,End,Mins,Maxs;int Flags=0;void* ExcludeArray=nullptr;};
|
||||
struct World {int calls=0;int refuseAt=0;std::vector<TraceBox> seen;float TraceMove(TraceBox* t,void*){calls++;seen.push_back(*t);delete t;return calls==refuseAt?0.5:1;}} world;
|
||||
struct Game {World* GetWorld(){return &world;}} game;
|
||||
Game* GetGame(){return &game;}
|
||||
struct SCR_TerrainHelper {static int samples,kind;static float GetTerrainY(vector){samples++;if(kind==1)return 10.31f;if(kind==2&&samples==60)return 9.69f;if(kind==3)return NAN;return 10;}};
|
||||
int SCR_TerrainHelper::samples=0;int SCR_TerrainHelper::kind=0;
|
||||
'''+cpp(body(P,'static bool Survey('))+r'''
|
||||
int main(){
|
||||
assert(ERR_AircraftRouteRules::Length(18)&&ERR_AircraftRouteRules::Length(20)&&!ERR_AircraftRouteRules::Length(17.99f)&&!ERR_AircraftRouteRules::Length(20.06f)&&!ERR_AircraftRouteRules::Length(NAN));
|
||||
vector a={0,10,0},b={20,10,0};
|
||||
assert(ERR_AircraftRouteRules::Contains(a,b,{10,18,7.99f},1));
|
||||
assert(!ERR_AircraftRouteRules::Contains(a,b,{10,18,8},1));
|
||||
assert(ERR_AircraftRouteRules::Contains(a,b,{-7.99f,18,0},1));
|
||||
assert(!ERR_AircraftRouteRules::Contains(a,b,{-8,18,0},1));
|
||||
assert(!ERR_AircraftRouteRules::Contains(a,b,{28,18,0},1));
|
||||
assert(!ERR_AircraftRouteRules::Contains(a,b,{10,28.01f,0},1));
|
||||
assert(!ERR_AircraftRouteRules::Contains(a,b,{10,18,0},.84f));
|
||||
assert(!ERR_AircraftRouteRules::Contains(a,b,{NAN,18,0},1));
|
||||
assert(ERR_AircraftRouteRules::Contains(a,{12,10,16},{6,18,8},1));
|
||||
assert(ERR_AircraftRouteRules::Held(false,8,0,0,1,0,.75f));
|
||||
assert(!ERR_AircraftRouteRules::Held(true,8,0,0,1,0,0));
|
||||
assert(!ERR_AircraftRouteRules::Held(false,8,0,0,1,.25f,0));
|
||||
int exclusion=1;assert(Survey(a,b,&exclusion));assert(world.calls==2&&SCR_TerrainHelper::samples==33*23);
|
||||
auto high=world.seen[0],low=world.seen[1];assert(high.Flags==(TraceFlags::ENTS|TraceFlags::WORLD)&&low.Flags==TraceFlags::ENTS);
|
||||
assert(high.Start[0]==10&&high.Mins[0]==-50&&high.Maxs[0]==50&&high.Mins[2]==-40&&high.Maxs[2]==40);
|
||||
assert(high.Start[1]+high.Mins[1]==12&&high.End[1]+high.Maxs[1]==71);
|
||||
assert(high.ExcludeArray==&exclusion&&low.ExcludeArray==&exclusion);
|
||||
for(int fault=1;fault<=3;fault++){world=World();SCR_TerrainHelper::samples=0;SCR_TerrainHelper::kind=fault;assert(!Survey(a,b));assert(world.calls==0&&SCR_TerrainHelper::samples<=33*23);}
|
||||
SCR_TerrainHelper::kind=0;
|
||||
for(int trace=1;trace<=2;trace++){world=World();world.refuseAt=trace;assert(!Survey(a,b));assert(world.calls==trace);}
|
||||
world=World();assert(!Survey(a,{100,10,0}));assert(world.calls==0);
|
||||
}
|
||||
''')
|
||||
s=P.read_text();qualify=body(P,'static ERR_AircraftRouteEnvelope QualifyRoute(')
|
||||
assert 'ERR_AircraftFlightEnvelope.Qualify(mode, aircraft, pilot)' in qualify
|
||||
assert qualify.index('ClearColumn(')<qualify.index('new ERR_AircraftRouteEnvelope')
|
||||
assert qualify.index('Survey(')<qualify.index('new ERR_AircraftRouteEnvelope')
|
||||
assert 'm_Home = point.Home()' in qualify and 'm_Radius = point.Radius()' in qualify
|
||||
assert 'bool Claim(' not in s and 'bool ClaimedBy(' not in s
|
||||
assert s.count('m_Destination = end')==1
|
||||
print('PASS: actual corridor caps/height/hold and whole-rectangle survey bounds, terrain refusal, native trace refusal and exact certificate lineage; native route unqualified')
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Actual route entry/stage/cancellation bodies with physical/native boundaries stubbed."""
|
||||
from policy_harness import ROOT, body, run
|
||||
P=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftRouteFlight.c'
|
||||
BASE=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftFlight.c'
|
||||
ENV=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftRouteEnvelope.c'
|
||||
def cpp(s):
|
||||
s=s.replace('protected ','').replace('override ','').replace('super.', 'Base::')
|
||||
for a,b in [('ERR_AircraftFlightState.','State::'),('ERR_AircraftFlightRules.','FlightRules::'),('ERR_AircraftRouteRules.','RouteRules::'),('Math.','Math::'),('vector.','vector::')]:s=s.replace(a,b)
|
||||
s=s.replace('ERR_AircraftRouteEnvelope route','ERR_AircraftRouteEnvelope* route').replace('SignalsManagerComponent signals','SignalsManagerComponent* signals')
|
||||
for var in ('route','m_Route','signals'):s=s.replace(var+'.',var+'->')
|
||||
return s
|
||||
methods='\n'.join(cpp(body(P,key)) for key in ('bool StartRoute(', 'protected bool RouteContains(', 'protected override bool Grounded(', 'protected override bool Landing(', 'protected override bool BeginTranslation(', 'protected override void CheckTranslation(', 'protected override bool ControlLease('))
|
||||
run(r'''#include <cassert>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <initializer_list>
|
||||
#define PrintFormat(...) ((void)0)
|
||||
using string=std::string;using ERR_GameMode=int;using Vehicle=int;using SCR_AIGroup=int;using IEntity=int;using PilotCompartmentSlot=int;
|
||||
namespace State {enum {IDLE,HOOKS,STARTING,POSITIVE,NEGATIVE,CLIMB,LANDING,SHUTDOWN,COMPLETE,CANCELLED,FAILED,RELINQUISHED,TRANSLATING};}
|
||||
struct Math {static float Clamp(float a,float lo,float hi){return std::clamp(a,lo,hi);}static float Min(float a,float b){return std::min(a,b);}static float AbsFloat(float a){return std::abs(a);}};
|
||||
struct vector {float x=0,y=0,z=0;float& operator[](int i){return i==0?x:(i==1?y:z);}float operator[](int i)const{return i==0?x:(i==1?y:z);}vector operator-(vector b)const{return {x-b.x,y-b.y,z-b.z};}vector operator+(vector b)const{return {x+b.x,y+b.y,z+b.z};}vector operator*(float n)const{return {x*n,y*n,z*n};}float Length()const{return std::sqrt(x*x+y*y+z*z);}static float Dot(vector a,vector b){return a.x*b.x+a.y*b.y+a.z*b.z;}static float DistanceXZ(vector a,vector b){return std::hypot(a.x-b.x,a.z-b.z);}};
|
||||
struct FlightRules {
|
||||
'''+cpp(body(BASE,'static bool Hover('))+cpp(body(BASE,'static int CancelAction('))+r'''
|
||||
};
|
||||
struct RouteRules {
|
||||
'''+''.join(cpp(body(ENV,k)) for k in ('static bool Length(', 'static vector Nearest(', 'static bool Contains(', 'static bool Held('))+r'''
|
||||
};
|
||||
struct ERR_AircraftRouteEnvelope {vector home={0,0,0},target={20,0,0};vector Home(){return home;}vector Destination(){return target;}bool Contains(vector p,float upright){return RouteRules::Contains(home,target,p,upright);}};
|
||||
struct SignalsManagerComponent {bool present=true;int FindSignal(const char*){return present?0:-1;}};
|
||||
struct Controller {bool hover=true,engine=true;SignalsManagerComponent signals;explicit operator bool()const{return true;}SignalsManagerComponent* GetSignalsManager(){return &signals;}bool GetAutohoverEnabled(){return hover;}bool IsEngineOn(){return engine;}};
|
||||
struct Simulation {vector velocity;bool contact=false;vector GetVelocity(){return velocity;}bool HasAnyGroundContact(){return contact;}};
|
||||
struct Aircraft {vector origin;float upright=1;vector GetOrigin(){return origin;}void GetWorldTransform(vector (&t)[4]){t[3]=origin;t[1]={0,upright,0};}};
|
||||
struct Base {
|
||||
bool m_StartAttempted=false,m_TranslationRequested=false,m_TranslationStarted=false,m_TranslationProven=false,m_Readback=true,m_Cancelled=false;
|
||||
vector m_TranslationTarget,m_TranslationOrigin,m_Home;
|
||||
float m_TranslationDistance=0,m_Stable=-1,m_Deadline=240000,m_StageDeadline=200000,clock=1000;
|
||||
int m_State=State::CLIMB,m_Token=123,m_TranslationUpdates=10,landings=0,starts=0,failures=0,relinquished=0,restored=0;
|
||||
bool owner=true,healthy=true,leased=true,grounded=false;
|
||||
Controller m_Controller;Simulation m_Simulation;Aircraft m_Aircraft;string m_Reason;
|
||||
bool Start(ERR_GameMode,Vehicle,SCR_AIGroup,IEntity,PilotCompartmentSlot,ERR_AircraftRouteEnvelope* q,int,float){m_StartAttempted=true;starts++;m_Home=q->Home();return true;}
|
||||
bool Grounded(){return grounded;}bool Landing(float now){landings++;m_State=State::LANDING;m_Stable=-1;m_StageDeadline=Math::Min(m_Deadline,now+65000);return true;}
|
||||
bool Owns(){return owner;}bool Healthy(){return healthy;}bool Lease(){return leased;}float Now(){return clock;}void Fail(string r){m_Reason=r;failures++;m_State=State::FAILED;}void Relinquish(string r){m_Reason=r;relinquished++;m_State=State::RELINQUISHED;}void RecordState(){}void Release(bool){restored++;}void Shutdown(float){m_State=State::SHUTDOWN;}
|
||||
};
|
||||
struct Probe:Base {
|
||||
ERR_AircraftRouteEnvelope* m_Route=nullptr;vector m_LandingPoint;bool m_RouteArrived=false;
|
||||
'''+methods+cpp(body(BASE,'void Cancel('))+r'''
|
||||
};
|
||||
int main(){
|
||||
ERR_AircraftRouteEnvelope q;Probe invalid;assert(!invalid.StartRoute(1,1,1,1,1,nullptr,3,200000)&&invalid.starts==0);
|
||||
q.target={21,0,0};assert(!invalid.StartRoute(1,1,1,1,1,&q,3,200000)&&invalid.starts==0);q.target={20,0,0};
|
||||
Probe flight;assert(flight.StartRoute(1,1,1,1,1,&q,3,200000)&&flight.starts==1&&flight.m_TranslationRequested);
|
||||
assert(flight.m_TranslationTarget.x==20&&flight.m_TranslationTarget.y==8&&flight.m_Home.x==0);
|
||||
assert(!flight.StartRoute(1,1,1,1,1,&q,3,200000)&&flight.starts==1);
|
||||
assert(!flight.RouteContains({9,8,0},1));assert(flight.BeginTranslation(1000,{2,8,0}));assert(flight.m_StageDeadline==161000&&flight.m_Deadline==240000);
|
||||
assert(flight.RouteContains({19,8,0},1)&&!flight.RouteContains({10,8,8},1));
|
||||
flight.m_Aircraft.origin={19.5f,8,0};
|
||||
flight.CheckTranslation(2000,flight.m_Aircraft.origin,8,0,1);flight.CheckTranslation(5000,{18,8,0},8,0,1);assert(flight.m_Stable==-1);
|
||||
flight.CheckTranslation(6000,flight.m_Aircraft.origin,8,0,1);flight.CheckTranslation(10000,flight.m_Aircraft.origin,8,0,1);assert(!flight.m_RouteArrived);
|
||||
flight.CheckTranslation(11000,flight.m_Aircraft.origin,8,0,1);assert(flight.m_RouteArrived&&flight.m_TranslationProven&&flight.m_LandingPoint.x==20&&flight.landings==1);
|
||||
flight.grounded=true;flight.m_Aircraft.origin={19,0,0};assert(!flight.Grounded());flight.m_Aircraft.origin={19.5f,0,0};assert(flight.Grounded());
|
||||
Probe limited;limited.m_Deadline=60000;assert(limited.StartRoute(1,1,1,1,1,&q,3,60000));assert(limited.BeginTranslation(1000,{2,8,0}));assert(limited.m_StageDeadline==60000&&limited.m_Deadline==60000);
|
||||
// Same parent cancellation body calls route Landing: freeze current centre,
|
||||
// preserve absolute deadline and never move it on repeated cancellation.
|
||||
Probe cancel;assert(cancel.StartRoute(1,1,1,1,1,&q,3,200000));assert(cancel.BeginTranslation(1000,{2,8,0}));cancel.m_Aircraft.origin={10,8,1};
|
||||
cancel.Cancel("radio lost");assert(cancel.m_State==State::LANDING&&cancel.m_Cancelled&&cancel.m_LandingPoint.x==10&&cancel.m_LandingPoint.z==1&&cancel.m_Deadline==240000&&cancel.landings==1);
|
||||
float stage=cancel.m_StageDeadline;cancel.clock=9000;cancel.m_Aircraft.origin={12,8,1};cancel.Cancel("again");assert(cancel.m_LandingPoint.x==10&&cancel.landings==1&&cancel.m_StageDeadline==stage);
|
||||
// Shared native write gate refuses stale ownership, expiry and corridor escape.
|
||||
Probe capture;capture.m_Route=&q;capture.owner=false;assert(!capture.ControlLease()&&capture.relinquished==1);
|
||||
Probe expiry;expiry.m_Route=&q;expiry.clock=240000;assert(!expiry.ControlLease()&&expiry.failures==1);
|
||||
Probe drift;drift.m_Route=&q;drift.m_TranslationStarted=true;drift.m_State=State::TRANSLATING;drift.m_Aircraft.origin={10,8,8};assert(!drift.ControlLease()&&drift.failures==1);
|
||||
Probe healthy;healthy.m_Route=&q;healthy.m_TranslationStarted=true;healthy.m_State=State::TRANSLATING;healthy.m_Aircraft.origin={10,8,0};assert(healthy.ControlLease());
|
||||
Probe ownershipCancel;ownershipCancel.m_Route=&q;ownershipCancel.owner=false;ownershipCancel.Cancel("captured");assert(ownershipCancel.relinquished==1&&ownershipCancel.landings==0&&ownershipCancel.restored==0);
|
||||
}
|
||||
''')
|
||||
s=P.read_text()
|
||||
assert 'class ERR_AircraftRouteFlight : ERR_AircraftFlight' in s
|
||||
for unchanged in ('protected bool Owns(', 'protected bool Healthy(', 'protected bool Lease(', 'protected void Release(', 'void Cancel('):assert unchanged not in s
|
||||
assert 'return super.Start(' in body(P,'bool StartRoute(')
|
||||
assert 's_Owners' not in s, 'Use parent shared airframe registry, never another exclusivity domain'
|
||||
prepare=body(P,'override void PrepareActions(')
|
||||
assert prepare.index('!ControlLease()')<prepare.index('SetActionValue') if 'SetActionValue' in prepare else prepare.index('!ControlLease()')<prepare.index('Pair(')
|
||||
assert 'target = m_LandingPoint' in prepare and 'ERR_AircraftTranslationRules.Demand(target,' in prepare
|
||||
assert 'forward = 0; aside = 0;' in prepare
|
||||
assert 'SetWorldTransform' not in s and 'SetVelocity' not in s and 'SetActive' not in s
|
||||
# The large native startup/tick sequence is preserved verbatim except route geometry.
|
||||
expected=body(BASE,'void Tick(').replace('void Tick(', 'override void Tick(',1).replace('ERR_AircraftFlightRules.Envelope(height, vector.DistanceXZ(transform[3], m_Home), transform[1][1])','RouteContains(transform[3], transform[1][1])')
|
||||
assert body(P,'override void Tick(')==expected
|
||||
print('PASS: actual route entry, fixed corridor/target, hover hold, cancellation landing freeze, shared lease/capture/deadline gate and unchanged native startup/tick sequence')
|
||||
# Native compile exposed a getter return-path diagnostic. Keep the actual
|
||||
# defensive getter executable, including its default pre-start observation.
|
||||
run(r'''#include <cassert>
|
||||
#include <cstdio>
|
||||
struct vector {float x=0,y=0,z=0;vector(float a,float b,float c):x(a),y(b),z(c){}vector(const char* s){sscanf(s,"%f %f %f",&x,&y,&z);}};
|
||||
struct Route {vector Destination(){return {20,3,4};}};
|
||||
struct Observer {Route* m_Route=nullptr;
|
||||
'''+cpp(body(P,'vector Destination('))+r'''
|
||||
};
|
||||
int main(){Observer p;auto absent=p.Destination();assert(absent.x==0&&absent.y==0&&absent.z==0);Route q;p.m_Route=&q;auto value=p.Destination();assert(value.x==20&&value.y==3&&value.z==4);}
|
||||
''')
|
||||
|
||||
assert "m_StageDeadline = Math.Min(m_Deadline, now + 160000);" in body(P,"protected override bool BeginTranslation(")
|
||||
assert "m_Deadline =" not in body(P,"protected override bool BeginTranslation(")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Execute route result policy and audit opt-in finite/native fixture boundaries."""
|
||||
from policy_harness import ROOT, body, run
|
||||
P=ROOT/'addons/ai/Scripts/Game/ERR/ERR_NativeAircraftRouteProbe.c'
|
||||
source=P.read_text()
|
||||
rules='\n'.join(body(P,k) for k in ('static bool Terminal(', 'static bool TranslateProof(', 'static bool PhysicalFinish(', 'static bool CanDispose(')).replace('ERR_AircraftFlightState.','State::')
|
||||
run('''#include <cassert>
|
||||
namespace State {enum {COMPLETE=8,CANCELLED,FAILED,RELINQUISHED};}
|
||||
'''+rules+'''
|
||||
int main(){
|
||||
assert(Terminal(false,State::COMPLETE,0));assert(!Terminal(false,State::CANCELLED,0));assert(!Terminal(false,State::FAILED,0));
|
||||
assert(TranslateProof(true,true,20,19.5,.5));
|
||||
assert(!TranslateProof(true,true,20,1,.5));assert(!TranslateProof(true,false,20,20,0));assert(!TranslateProof(false,true,20,20,0));assert(!TranslateProof(true,true,20,20,.76f));
|
||||
assert(!TranslateProof(true,true,29,29,0));assert(!TranslateProof(true,true,9,9,0));
|
||||
assert(PhysicalFinish(true,true,true,.1f,1,1,1,101,101));
|
||||
assert(!PhysicalFinish(true,true,false,.1f,1,1,1,101,101));assert(!PhysicalFinish(true,true,true,0,1,1,1,101,101));assert(!PhysicalFinish(true,true,true,.1f,2,1,1,101,101));assert(!PhysicalFinish(false,true,true,.1f,1,1,1,101,101));
|
||||
assert(CanDispose(true,true,true,true,true));for(int i=0;i<5;i++)assert(!CanDispose(i!=0,i!=1,i!=2,i!=3,i!=4));
|
||||
}
|
||||
''')
|
||||
assert source.startswith('#ifdef WORKBENCH') and source.rstrip().endswith('#endif')
|
||||
assert 'outcome == "aircraft-route"' in body(P,'static bool Enabled(')
|
||||
assert 'outcome == "aircraft-control"' not in source
|
||||
assert source.count('ERR_Actors.Spawn(AIRCRAFT,')==1 and source.count('SpawnCharacterInCompartment(')==1
|
||||
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 '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())')
|
||||
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 '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()')
|
||||
assert source.index('super.OnPrepareControls();',start)<source.index('probe.PrepareHelicopter(this);',start)
|
||||
print('PASS: finite opt-in route fixture, original native input order, meaningful physical-arrival evidence and guarded cleanup; no native launch')
|
||||
|
||||
# Integration is separate from physical route proof. Exercise real early CLI
|
||||
# refusals against a deliberately absent prefix: no engine or audio can start.
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
runner=ROOT/'tools/headless_workbench.py'
|
||||
cases=[
|
||||
('core','1','420',[], 'requires Chat, one round, and at least 420 seconds'),
|
||||
('chat','2','420',[], 'requires Chat, one round, and at least 420 seconds'),
|
||||
('chat','1','419',[], 'requires Chat, one round, and at least 420 seconds'),
|
||||
('chat','1','420',['--flight-action-inputs'], 'flight-action-inputs requires the Core powered-flight fixture'),
|
||||
('chat','1','420',['--flight-autohover'], 'flight-autohover requires Core powered-flight with flight-action-inputs'),
|
||||
('chat','1','420',[], 'must be a disposable cloned prefix'),
|
||||
]
|
||||
for project,rounds,timeout,extra,message in cases:
|
||||
with tempfile.TemporaryDirectory(prefix='erri-route-parse-') as directory:
|
||||
temporary=Path(directory)
|
||||
result=subprocess.run([sys.executable,str(runner),'--compat',str(temporary/'missing-prefix'),'--profile',str(temporary/'missing-profile'),'--project',project,'--smoke','aircraft-route','--rounds',rounds,'--timeout',timeout,*extra],capture_output=True,text=True,timeout=10)
|
||||
assert result.returncode==2, (project,rounds,timeout,extra,result.stderr)
|
||||
assert message in result.stderr, (project,rounds,timeout,extra,result.stderr)
|
||||
assert list(temporary.iterdir())==[], 'Refused route invocation must not create profiles or prefix locks'
|
||||
|
||||
chat=ROOT/'addons/chat/Scripts/Game/ERR/ERR_NativeSmoke.c'
|
||||
text=chat.read_text()
|
||||
assert 'm_ERRTestAircraftRoute = outcome == "aircraft-route";' in text
|
||||
assert 'if (m_ERRTestAircraftControl || m_ERRTestAircraftRoute) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 330000;' in text
|
||||
assert 'OnStop().Insert(ERR_StopHQPatrol);' in text
|
||||
assert 'if (m_ERRAircraftRoute) m_ERRAircraftRoute.Stop();' in body(chat,'protected void ERR_StopHQPatrol(')
|
||||
assert 'ERR_StopHQPatrol();' in body(chat,'protected bool ERR_Check(')
|
||||
|
||||
def block(text,anchor):
|
||||
start=text.index(anchor)
|
||||
opening=text.index('{',start);depth=1;end=opening+1
|
||||
while depth:
|
||||
depth+=(text[end]=='{')-(text[end]=='}');end+=1
|
||||
return text[start:end]
|
||||
branch=block(body(chat,'protected void ERR_SmokeTick('),'if (m_ERRSmokeStep == 230)')
|
||||
assert 'int result = m_ERRAircraftRoute.Tick(now);' in branch
|
||||
assert 'if (result < 0) { ERR_Check(false, "native actual aircraft route fixture"); return; }' in branch
|
||||
assert 'if (result == 1) { m_ERRSmokeStep = 231; Transition(ERR_Phase.DEFEAT); }' in branch
|
||||
# Execute the unchanged actual branch to prove pending/failure cannot advance
|
||||
# to the fixture terminal and successful transition sees the completed step.
|
||||
run('''#include <cassert>
|
||||
#include <initializer_list>
|
||||
namespace ERR_Phase {enum {DEFEAT};}
|
||||
struct Probe {int result=0,ticks=0;int Tick(float){ticks++;return result;}};
|
||||
struct Fixture {int m_ERRSmokeStep=230,failures=0,transitions=0;Probe m_ERRAircraftRoute;
|
||||
void ERR_Check(bool,const char*){failures++;}
|
||||
void Transition(int phase){assert(phase==ERR_Phase::DEFEAT&&m_ERRSmokeStep==231);transitions++;}
|
||||
void Tick(float now){
|
||||
'''+branch.replace('ERR_Phase.','ERR_Phase::')+'''
|
||||
}
|
||||
};
|
||||
int main(){
|
||||
for(int result:{-1,0,1}){Fixture f;f.m_ERRAircraftRoute.result=result;f.Tick(1000);assert(f.m_ERRAircraftRoute.ticks==1);assert(f.failures==(result<0));assert(f.transitions==(result==1));assert(f.m_ERRSmokeStep==(result==1?231:230));}
|
||||
}
|
||||
''')
|
||||
terminal=block(body(chat,'protected void ERR_SmokePhase('),'if (m_ERRTestAircraftRoute)')
|
||||
assert 'current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 231' in terminal
|
||||
assert terminal.index('ERR_StopHQPatrol();')<terminal.index('ERR SMOKE COMPLETE:')
|
||||
assert terminal.count('ERR SMOKE COMPLETE:')==1
|
||||
initial=block(body(chat,'protected void ERR_SmokeTick('),'if (m_ERRTestAircraftRoute)')
|
||||
assert initial.count('new ERR_NativeAircraftRouteProbe(this)')==1
|
||||
assert 'm_ERRSmokeStep = 230;' in initial and initial.rstrip().endswith('return;\n\t\t\t}')
|
||||
print('PASS: actual Chat route step result dispatch and scoped completion/cleanup wiring; six real early-parser refusal paths leave no prefix/profile output')
|
||||
|
||||
# The added two-second telemetry only observes, and cannot extend/control flight.
|
||||
diagnostic=body(P,'protected void Diagnostic(')
|
||||
assert diagnostic.index('now < m_NextLog')<diagnostic.index('m_NextLog = now + 2000')<diagnostic.index('ERR AIRCRAFT ROUTE MOTION:')
|
||||
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; }'
|
||||
@@ -0,0 +1,484 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Private-display observer policy tests; native libraries/processes are substituted."""
|
||||
import ctypes as C
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
|
||||
import headless_display_probe as probe
|
||||
|
||||
|
||||
def proc(pid, parent=1, exe='/usr/bin/helper', args=None, start=10):
|
||||
return {'pid':pid,'ppid':parent,'exe':exe,'args':args or [exe], 'start':start,'uid':os.getuid()}
|
||||
|
||||
|
||||
class FakeWindowQuery:
|
||||
def __init__(self):
|
||||
self.root_id=2;self.focused=(20,2)
|
||||
self.attrs={2:self.attribute(1280,720),20:self.attribute(800,600),21:self.attribute(200,100)}
|
||||
self.positions={20:(20,30),21:(40,50)};self.parents={20:2,21:20};self.pids={20:90,21:90}
|
||||
def attribute(self,w,h):return {'root':2,'window_class':1,'map_state':2,'width':w,'height':h}
|
||||
def root(self):return self.root_id
|
||||
def attributes(self,window):return dict(self.attrs[window])
|
||||
def translate(self,window,root):return self.positions[window]
|
||||
def focus(self):return self.focused
|
||||
def server_pid(self,window):return self.pids.get(window)
|
||||
def tree(self,window):return 2,self.parents[window],[]
|
||||
|
||||
|
||||
class DisplayTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory(prefix='erri-display-test-')
|
||||
self.root=Path(self.temp.name)
|
||||
self.profile=self.root/'profile';self.profile.mkdir()
|
||||
(self.profile/'.erri-game-profile.json').write_text(json.dumps(probe.PROFILE_ID))
|
||||
self.prefix=self.root/'pfx';self.prefix.mkdir()
|
||||
self.exe=self.root/'ArmaReforgerSteam.exe';self.exe.write_bytes(b'test-fixture-not-executable')
|
||||
self.proton=self.root/'proton';self.proton.write_text('test-fixture-not-executable')
|
||||
self.report=self.profile/'display-ownership-fixture.json'
|
||||
self.bound={'display':':2','gamescope':{'pid':40,'start':10,'uid':os.getuid()},'xwayland':{'pid':41,'start':11,'uid':os.getuid()},'sockets':[{'path':'@/tmp/.X11-unix/X2','inode':100}], 'relationship':{'mode':'descendant','descendant':True}}
|
||||
self.args=SimpleNamespace(host_display=':0',prefix=self.prefix,game_executable=self.exe,profile=self.profile,
|
||||
report=self.report,observe_seconds=20,query_worker=False)
|
||||
def tearDown(self): self.temp.cleanup()
|
||||
def cli(self):
|
||||
return ['--host-display',':0','--prefix',str(self.prefix),'--game-executable',str(self.exe),'--profile',str(self.profile),
|
||||
'--report',str(self.report),'--observe-seconds','20','--',str(self.proton),'run',str(self.exe),'-noBackend']
|
||||
def test_display_refuses_host_alias_remote_empty_or_malformed(self):
|
||||
for display,host in [(':0',':0.1'),('unix:0.0',':0'),(':0','localhost:0'),('',':0'),('host:2',':0'),(':2;evil',':0'),(':2/0',':0')]:
|
||||
with self.subTest(display=display,host=host),self.assertRaises(ValueError):probe.validate_display(display,host)
|
||||
self.assertEqual(probe.validate_display(':2.1',':0'),2)
|
||||
self.assertEqual(probe.validate_display('unix:2',''),2)
|
||||
def test_ancestor_requires_native_same_user_headless_before_separator(self):
|
||||
table={50:proc(50,40),40:proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--','proton'])}
|
||||
self.assertEqual(probe.ancestor(50,table.__getitem__)['pid'],40)
|
||||
for args in (['gamescope','--','--backend','headless'],['gamescope','--backend','wayland','--'],['gamescope','--backend','headless','--backend=drm','--']):
|
||||
table[40]['args']=args
|
||||
with self.assertRaises(ValueError):probe.ancestor(50,table.__getitem__)
|
||||
table[40]['uid']=os.getuid()+1
|
||||
with self.assertRaises(ValueError):probe.ancestor(50,table.__getitem__)
|
||||
def binding_fixture(self,extra=None):
|
||||
table={50:proc(50,40),40:proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--','proton']),
|
||||
41:proc(41,40,'/usr/bin/Xwayland',['Xwayland',':2','-rootless'],11)}
|
||||
if extra:extra(table)
|
||||
folder=self.root/'proc';folder.mkdir(exist_ok=True)
|
||||
for p in table: (folder/str(p)).mkdir(exist_ok=True)
|
||||
(folder/'net').mkdir(exist_ok=True)
|
||||
(folder/'net/unix').write_text('header\n0000: 00000002 00000000 00010000 0001 01 100 @/tmp/.X11-unix/X2\n')
|
||||
fds=folder/'41/fd';fds.mkdir(exist_ok=True)
|
||||
if not (fds/'7').is_symlink(): (fds/'7').symlink_to('socket:[100]')
|
||||
with patch.dict(os.environ,{'DISPLAY':':2','WINEPREFIX':str(self.prefix)}),patch.object(probe.os,'getppid',return_value=50):
|
||||
return probe.binding(':0',self.prefix,read=table.__getitem__,proc_root=folder)
|
||||
def test_binding_exact_nested_server_and_portal_refusal(self):
|
||||
self.assertEqual(self.binding_fixture(),self.bound)
|
||||
with self.assertRaisesRegex(ValueError,'portal'):self.binding_fixture(lambda t:t[41]['args'].append('-enable-ei-portal'))
|
||||
with self.assertRaisesRegex(ValueError,'exactly one'):self.binding_fixture(lambda t:t[41].update(ppid=1))
|
||||
with self.assertRaisesRegex(ValueError,'exactly one'):self.binding_fixture(lambda t:t.update({42:proc(42,40,'/usr/bin/Xwayland',['Xwayland',':2'])}))
|
||||
def test_failed_binding_reports_sanitized_names_and_ancestry_without_relaxing(self):
|
||||
def change(table):
|
||||
table[41]['exe']='/private/runtime/Xwayland-versioned'
|
||||
table[41]['args']=['Xwayland-versioned',':2','-auth','/private/secret-cookie','--unknown-secret']
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
self.binding_fixture(change)
|
||||
text=str(raised.exception)
|
||||
details=json.loads(text.split('preflight=',1)[1])
|
||||
self.assertFalse(details['display_opened']);self.assertFalse(details['input_sent'])
|
||||
self.assertEqual(details['gamescope']['pid'],40)
|
||||
item=details['same_user_xwayland_candidates'][0]
|
||||
self.assertEqual(item['executable_basename'],'Xwayland-versioned')
|
||||
self.assertTrue(item['descendant']);self.assertFalse(item['exact_executable_name'])
|
||||
self.assertEqual(item['display_args'],[':2'])
|
||||
self.assertNotIn('/private',text);self.assertNotIn('secret',text)
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
self.binding_fixture(lambda t:t[41].update(ppid=1))
|
||||
self.assertFalse(json.loads(str(raised.exception).split('preflight=',1)[1])['same_user_xwayland_candidates'][0]['descendant'])
|
||||
def test_failed_binding_diagnostic_candidates_are_bounded(self):
|
||||
def change(table):
|
||||
table[41]['ppid']=1
|
||||
for pid in range(100,140):
|
||||
table[pid]=proc(pid,1,'/usr/bin/Xwayland',['Xwayland',':3','-auth','private'])
|
||||
with self.assertRaises(ValueError) as raised:self.binding_fixture(change)
|
||||
details=json.loads(str(raised.exception).split('preflight=',1)[1])
|
||||
self.assertEqual(details['candidate_count'],41)
|
||||
self.assertEqual(len(details['same_user_xwayland_candidates']),16)
|
||||
self.assertNotIn('private',str(raised.exception))
|
||||
def test_reparented_candidate_socket_nonce_diagnostics_require_every_check(self):
|
||||
self.binding_fixture()
|
||||
folder=self.root/'proc';(folder/'40/fd').mkdir();(folder/'40/fd/7').symlink_to('socket:[100]')
|
||||
scope=proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--'])
|
||||
candidate=proc(41,1,'/usr/bin/Xwayland',['Xwayland',':2'],11)
|
||||
records={40:scope,41:candidate}
|
||||
nonce='application.id=org.escapistsrereintroduced.game.'+'a'*32
|
||||
env={'WINEPREFIX':str(self.prefix),'PULSE_PROP':nonce}
|
||||
with patch.dict(os.environ,{'PULSE_PROP':nonce}):
|
||||
report=probe.candidate_relationship(candidate,scope,2,self.prefix,records.__getitem__,lambda _:env,folder)
|
||||
for key in ('start_not_older_than_gamescope','wrapper_run_nonce_valid','xwayland_prefix_matches','gamescope_prefix_matches',
|
||||
'xwayland_run_nonce_matches','gamescope_run_nonce_matches','same_listener_inodes','process_identities_stable'):
|
||||
self.assertTrue(report[key],key)
|
||||
self.assertEqual(report['listener_inodes'],[100])
|
||||
self.assertNotIn(nonce,json.dumps(report));self.assertNotIn(str(self.prefix),json.dumps(report))
|
||||
(folder/'40/fd/7').unlink()
|
||||
with patch.dict(os.environ,{'PULSE_PROP':'wrong'}):
|
||||
report=probe.candidate_relationship(candidate,scope,2,self.prefix,records.__getitem__,lambda _:env,folder)
|
||||
self.assertFalse(report['same_listener_inodes']);self.assertFalse(report['wrapper_run_nonce_valid'])
|
||||
# Missing Gamescope socket/nonce evidence still cannot substitute for ancestry.
|
||||
with self.assertRaisesRegex(ValueError,'exactly one'):
|
||||
self.binding_fixture(lambda t:t[41].update(ppid=1))
|
||||
def test_shared_relationship_gate_rejects_every_missing_false_nonboolean_and_inode_fault(self):
|
||||
good={key:True for key in probe.SHARED_CHECKS};good['listener_inodes']=[100,101]
|
||||
self.assertTrue(probe.shared_relationship_valid(good))
|
||||
for key in probe.SHARED_CHECKS:
|
||||
for value in (False,None,1):
|
||||
bad={**good,key:value};self.assertFalse(probe.shared_relationship_valid(bad),(key,value))
|
||||
bad=dict(good);del bad[key];self.assertFalse(probe.shared_relationship_valid(bad),key)
|
||||
for inodes in (None,[],[True],[0],[-1],[100,100],[1,2,3]):
|
||||
self.assertFalse(probe.shared_relationship_valid({**good,'listener_inodes':inodes}))
|
||||
def test_actual_reparented_binding_requires_shared_fds_environment_and_stable_checks(self):
|
||||
self.binding_fixture();folder=self.root/'proc';(folder/'40/fd').mkdir();(folder/'40/fd/7').symlink_to('socket:[100]')
|
||||
table={50:proc(50,40),40:proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--']),
|
||||
41:proc(41,1,'/usr/bin/Xwayland',['Xwayland',':2'],11)}
|
||||
nonce='application.id=org.escapistsrereintroduced.game.'+'b'*32
|
||||
env={'WINEPREFIX':str(self.prefix),'PULSE_PROP':nonce}
|
||||
def bind(env_read=lambda _:env,read=table.__getitem__):
|
||||
with patch.dict(os.environ,{'DISPLAY':':2','WINEPREFIX':str(self.prefix),'PULSE_PROP':nonce}),patch.object(probe.os,'getppid',return_value=50):
|
||||
return probe.binding(':0',self.prefix,read=read,env_read=env_read,proc_root=folder)
|
||||
result=bind();self.assertEqual(result['relationship']['mode'],'shared-listeners')
|
||||
self.assertTrue(probe.shared_relationship_valid(result['relationship']['checks']))
|
||||
for pid in (40,41):
|
||||
for key,value in [('WINEPREFIX','/foreign'),('PULSE_PROP',nonce+'mismatch')]:
|
||||
with self.assertRaises(ValueError):bind(lambda current: {**env,key:value} if current==pid else env)
|
||||
table[41]['start']=9
|
||||
with self.assertRaises(ValueError):bind()
|
||||
table[41]['start']=11
|
||||
table[41]['args'].append('-enable-ei-portal')
|
||||
with self.assertRaisesRegex(ValueError,'portal'):bind()
|
||||
table[41]['args'].pop()
|
||||
(folder/'40/fd/7').unlink()
|
||||
with self.assertRaisesRegex(ValueError,'exactly one'):bind()
|
||||
def test_shared_binding_refuses_changed_listener_evidence_before_connection(self):
|
||||
self.binding_fixture();folder=self.root/'proc'
|
||||
table={50:proc(50,40),40:proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--']),
|
||||
41:proc(41,1,'/usr/bin/Xwayland',['Xwayland',':2'],11)}
|
||||
good={key:True for key in probe.SHARED_CHECKS};good['listener_inodes']=[100]
|
||||
changed={**good,'listener_inodes':[101]}
|
||||
with patch.dict(os.environ,{'DISPLAY':':2','WINEPREFIX':str(self.prefix)}),patch.object(probe.os,'getppid',return_value=50),\
|
||||
patch.object(probe,'candidate_relationship',side_effect=[good,changed]):
|
||||
with self.assertRaisesRegex(ValueError,'changed during binding'):
|
||||
probe.binding(':0',self.prefix,read=table.__getitem__,proc_root=folder)
|
||||
def test_relationship_unreadable_environment_and_reused_pid_fail_diagnostics(self):
|
||||
self.binding_fixture();folder=self.root/'proc'
|
||||
scope=proc(40);candidate=proc(41,start=1)
|
||||
def inaccessible(pid):raise PermissionError('sensitive-path-must-not-be-emitted')
|
||||
result=probe.candidate_relationship(candidate,scope,2,self.prefix,lambda pid:proc(pid,start=99),inaccessible,folder)
|
||||
self.assertIsNone(result['xwayland_prefix_matches'])
|
||||
self.assertIsNone(result['gamescope_run_nonce_matches'])
|
||||
self.assertFalse(result['process_identities_stable']);self.assertFalse(result['start_not_older_than_gamescope'])
|
||||
self.assertNotIn('sensitive',json.dumps(result))
|
||||
def test_display_socket_ownership_prevents_accidental_host_query(self):
|
||||
self.binding_fixture()
|
||||
folder=self.root/'proc'
|
||||
self.assertEqual(probe.owned_sockets(2,41,folder),self.bound['sockets'])
|
||||
with (folder/'net/unix').open('a') as out:
|
||||
out.write('0000: 00000002 00000000 00010000 0001 01 101 /tmp/.X11-unix/X2\n')
|
||||
with self.assertRaisesRegex(ValueError,'not held'):probe.owned_sockets(2,41,folder)
|
||||
(folder/'41/fd/8').symlink_to('socket:[101]')
|
||||
self.assertEqual(len(probe.owned_sockets(2,41,folder)),2)
|
||||
(folder/'net/unix').write_text('header\n')
|
||||
with self.assertRaisesRegex(ValueError,'No unique'):probe.owned_sockets(2,41,folder)
|
||||
def test_exact_game_window_requires_server_pid_and_native_process_metadata(self):
|
||||
window={'window':17,'pid':90,'server_pid':90,'title':'Arma Reforger'}
|
||||
p=proc(90,args=[str(self.exe)])
|
||||
env={'WINEPREFIX':str(self.prefix),'DISPLAY':':2'}
|
||||
def owns():return probe.owned_window(window,self.bound,self.prefix,self.exe,lambda _:p,lambda _:env)
|
||||
self.assertIsNotNone(owns())
|
||||
for key,value in [('server_pid',91),('pid',None)]:
|
||||
old=window[key];window[key]=value;self.assertIsNone(owns());window[key]=old
|
||||
env['WINEPREFIX']='/foreign';self.assertIsNone(owns());env['WINEPREFIX']=str(self.prefix)
|
||||
env['DISPLAY']=':0';self.assertIsNone(owns());env['DISPLAY']=':2'
|
||||
p['args']=['ArmaReforgerSteam.exe'];self.assertIsNone(owns())
|
||||
p['args']=['/usr/bin/foreign',str(self.exe)];self.assertIsNone(owns())
|
||||
p['args']=['Z:'+str(self.exe).replace('/','\\')];self.assertIsNotNone(owns())
|
||||
with patch.object(probe,'process'):
|
||||
self.assertIsNone(probe.owned_window(window,self.bound,self.prefix,self.exe,Mock(side_effect=[p,{**p,'start':99}]),lambda _:env))
|
||||
def test_observe_closes_native_connection_and_rejects_process_reuse(self):
|
||||
native=Mock();native.windows.return_value=[]
|
||||
with patch.object(probe,'binding',side_effect=[self.bound,{**self.bound,'xwayland':{**self.bound['xwayland'],'start':999}}]):
|
||||
with self.assertRaisesRegex(ValueError,'changed'):probe.observe(self.args,lambda _:native)
|
||||
native.close.assert_called_once()
|
||||
native=Mock();native.windows.side_effect=ValueError('query failed')
|
||||
with patch.object(probe,'binding',return_value=self.bound):
|
||||
with self.assertRaises(ValueError):probe.observe(self.args,lambda _:native)
|
||||
native.close.assert_called_once()
|
||||
def test_new_direct_profile_report_required_before_child(self):
|
||||
probe.validate_paths(self.args)
|
||||
for report in (self.root/'elsewhere.json',self.profile/'sub'/'out.json',self.profile/'..'/'out.json'):
|
||||
self.args.report=report
|
||||
with self.assertRaises(ValueError):probe.validate_paths(self.args)
|
||||
self.args.report=self.report;self.report.write_text('preserve historical evidence')
|
||||
with patch.object(probe.subprocess,'Popen') as popen,patch.object(probe,'binding',return_value=self.bound):
|
||||
with self.assertRaises(ValueError):probe.main(self.cli())
|
||||
popen.assert_not_called()
|
||||
self.assertEqual(self.report.read_text(),'preserve historical evidence')
|
||||
self.report.unlink();self.report.symlink_to(self.root/'absent')
|
||||
with self.assertRaises(ValueError):probe.validate_paths(self.args)
|
||||
def test_marker_alias_nonfinite_and_observation_budget(self):
|
||||
for seconds in (0,31,float('nan'),float('inf')):
|
||||
self.args.observe_seconds=seconds
|
||||
with self.assertRaises(ValueError):probe.validate_paths(self.args)
|
||||
self.args.observe_seconds=20
|
||||
marker=self.profile/'.erri-game-profile.json';marker.write_text('{}')
|
||||
with self.assertRaises(ValueError):probe.validate_paths(self.args)
|
||||
def test_actual_xres_status_zero_is_success_and_pid_mask_required(self):
|
||||
native=object.__new__(probe.X11);native.d=123
|
||||
value=probe.X11.Client(probe.X11.Spec(17,2),4,None)
|
||||
keep=(probe.X11.Client*1)(value)
|
||||
def query(display,n,spec,count,out):
|
||||
C.cast(count,C.POINTER(C.c_long))[0]=1
|
||||
C.cast(out,C.POINTER(C.POINTER(probe.X11.Client)))[0]=C.cast(keep,C.POINTER(probe.X11.Client))
|
||||
return 0
|
||||
native.r=SimpleNamespace(XResQueryClientIds=query,XResGetClientPid=Mock(return_value=90),XResClientIdsDestroy=Mock())
|
||||
self.assertEqual(native.server_pid(17),90)
|
||||
native.r.XResClientIdsDestroy.assert_called_once()
|
||||
keep[0].spec.mask=1;self.assertIsNone(native.server_pid(17))
|
||||
native.r.XResQueryClientIds=lambda *args:1;self.assertIsNone(native.server_pid(17))
|
||||
def test_read_only_wrapper_records_binding_preserves_child_exit(self):
|
||||
child=Mock();child.poll.return_value=None;child.wait.return_value=17
|
||||
observed={'binding':self.bound,'owned_windows':[{'window':17,'pid':90,'server_pid':90}],
|
||||
'owned_game_window_verified':True,'unverified_window_count':0,'window_count':1}
|
||||
worker=SimpleNamespace(returncode=0,stdout=json.dumps(observed))
|
||||
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'process',return_value=proc(55)),\
|
||||
patch.object(probe.subprocess,'Popen',return_value=child) as spawn,patch.object(probe.subprocess,'run',return_value=worker) as query,\
|
||||
patch.object(probe.signal,'signal'),patch.object(probe.time,'sleep'):
|
||||
self.assertEqual(probe.main(self.cli()),17)
|
||||
self.assertEqual(spawn.call_args.args[0],[str(self.proton),'run',str(self.exe),'-noBackend'])
|
||||
self.assertEqual(spawn.call_args.kwargs,{}) # inherit parent's owned process group
|
||||
self.assertLessEqual(query.call_args.kwargs['timeout'],3)
|
||||
report=json.loads(self.report.read_text())
|
||||
self.assertTrue(report['owned_game_window_verified']);self.assertTrue(report['read_only'])
|
||||
self.assertFalse(report['input_sent']);self.assertFalse(report['release_qualified'])
|
||||
self.assertEqual(report['command'],spawn.call_args.args[0]);self.assertEqual(len(report['helper_sha256']),64)
|
||||
self.assertEqual(self.report.stat().st_mode&0o777,0o600)
|
||||
self.assertEqual(report['first_window_count'],1);self.assertEqual(report['last_window_count'],1)
|
||||
self.assertEqual(report['successful_queries'],1);self.assertEqual(report['last_query_status'],'ok')
|
||||
child.terminate.assert_not_called();child.kill.assert_not_called()
|
||||
def test_query_failure_does_not_become_ownership_or_change_child_exit(self):
|
||||
child=Mock();child.poll.side_effect=[None,0];child.wait.return_value=0
|
||||
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'process',return_value=proc(55)),\
|
||||
patch.object(probe.subprocess,'Popen',return_value=child),patch.object(probe.subprocess,'run',return_value=SimpleNamespace(returncode=2)),\
|
||||
patch.object(probe.signal,'signal'),patch.object(probe.time,'sleep'):
|
||||
self.assertEqual(probe.main(self.cli()),0)
|
||||
self.assertFalse(json.loads(self.report.read_text())['owned_game_window_verified'])
|
||||
def test_timeout_and_signal_forwarding_leave_native_lifetime_with_parent(self):
|
||||
child=Mock();child.poll.return_value=None;child.wait.return_value=23
|
||||
handlers={}
|
||||
def handle(sig,callback):
|
||||
old=handlers.get(sig,signal.SIG_DFL);handlers[sig]=callback;return old
|
||||
def timed_out(*args,**kwargs):
|
||||
handlers[signal.SIGTERM](signal.SIGTERM,None)
|
||||
raise probe.subprocess.TimeoutExpired('read-only observer',3)
|
||||
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'process',return_value=proc(55)),\
|
||||
patch.object(probe.subprocess,'Popen',return_value=child),patch.object(probe.subprocess,'run',side_effect=timed_out),\
|
||||
patch.object(probe.signal,'signal',side_effect=handle),patch.object(probe.time,'sleep'):
|
||||
self.assertEqual(probe.main(self.cli()),23)
|
||||
child.send_signal.assert_called_once_with(signal.SIGTERM)
|
||||
self.assertFalse(json.loads(self.report.read_text())['owned_game_window_verified'])
|
||||
self.assertEqual(handlers[signal.SIGTERM],signal.SIG_DFL)
|
||||
child.kill.assert_not_called()
|
||||
def test_foreign_or_malformed_command_refused_without_child_or_report(self):
|
||||
original=self.cli()
|
||||
for command in ([str(self.proton),'other',str(self.exe)],['/bin/echo',str(self.exe)],[str(self.proton),'run','foreign.exe']):
|
||||
args=original[:original.index('--')]+['--']+command
|
||||
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe.subprocess,'Popen') as spawn:
|
||||
with self.assertRaises(ValueError):probe.main(args)
|
||||
spawn.assert_not_called();self.assertFalse(self.report.exists())
|
||||
def test_window_diagnostics_pin_rejection_without_titles_or_private_values(self):
|
||||
secret_title='private unrelated window title'
|
||||
window={'window':17,'pid':90,'server_pid':91,'title':secret_title,'xres_query':{'status':0,'count':1,'mask':2}}
|
||||
records={90:proc(90,args=['/private/other.exe']),91:proc(91,args=[str(self.exe)])}
|
||||
environment={'WINEPREFIX':str(self.prefix),'DISPLAY':':2','SECRET':'never emit'}
|
||||
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,records.__getitem__,lambda _:environment)
|
||||
self.assertEqual(report['rejection_stage'],'property-server-pid-disagreement')
|
||||
self.assertFalse(report['property_process']['game_executable_match'])
|
||||
self.assertTrue(report['server_process']['game_executable_match'])
|
||||
text=json.dumps(report)
|
||||
for secret in (secret_title,str(self.exe),str(self.prefix),'/private','never emit'):
|
||||
self.assertNotIn(secret,text)
|
||||
window['pid']=91
|
||||
self.assertEqual(probe.window_diagnostic(window,self.bound,self.prefix,self.exe,records.__getitem__,lambda _:environment)['rejection_stage'],'checks-passed')
|
||||
records[91]['args']=['z:'+str(self.exe).replace('/','\\')]
|
||||
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,records.__getitem__,lambda _:environment)
|
||||
self.assertEqual(report['rejection_stage'],'game-executable-mismatch')
|
||||
self.assertEqual(report['server_process']['argv0_class'],'game-basename-noncanonical-path')
|
||||
self.assertTrue(report['server_process']['argv0_case_insensitive_exact'])
|
||||
self.assertIsNone(probe.owned_window(window,self.bound,self.prefix,self.exe,records.__getitem__,lambda _:environment))
|
||||
def test_window_diagnostics_unavailable_pid_environment_and_mutation(self):
|
||||
window={'window':17,'pid':90,'server_pid':None}
|
||||
def unreadable(_):raise PermissionError('private error detail')
|
||||
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,unreadable,unreadable)
|
||||
self.assertEqual(report['rejection_stage'],'server-pid-unavailable')
|
||||
self.assertFalse(report['property_process']['readable'])
|
||||
self.assertNotIn('private error',json.dumps(report))
|
||||
window['server_pid']=90
|
||||
p=proc(90,args=[str(self.exe)])
|
||||
env={'WINEPREFIX':str(self.prefix),'DISPLAY':':2'}
|
||||
for key,value,stage in [('WINEPREFIX','/foreign','private-prefix-unverified'),('DISPLAY',':0','private-display-unverified')]:
|
||||
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,lambda _:p,lambda _:{**env,key:value})
|
||||
self.assertEqual(report['rejection_stage'],stage)
|
||||
changed=Mock(side_effect=[p,{**p,'start':99}])
|
||||
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,changed,lambda _:env)
|
||||
self.assertEqual(report['rejection_stage'],'native-process-changed')
|
||||
def test_observe_diagnostics_bounded_and_no_unverified_titles_serialized(self):
|
||||
native=Mock();native.windows.return_value=[{'window':i,'pid':90,'server_pid':91,'title':'never serialize'} for i in range(20)]
|
||||
safe={'rejection_stage':'property-server-pid-disagreement'}
|
||||
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'owned_window',return_value=None),\
|
||||
patch.object(probe,'window_diagnostic',return_value=safe) as diagnostic:
|
||||
report=probe.observe(self.args,lambda _:native)
|
||||
self.assertEqual(diagnostic.call_count,16);self.assertEqual(report['window_count'],20)
|
||||
self.assertFalse(report['owned_game_window_verified']);self.assertNotIn('never serialize',json.dumps(report))
|
||||
def test_prefix_equivalence_is_only_exact_spelling_plus_trailing_slashes(self):
|
||||
alias=self.root/'alias';alias.symlink_to(self.prefix,target_is_directory=True)
|
||||
for suffix in ('','/','////'):
|
||||
self.assertTrue(probe.prefix_matches(str(self.prefix)+suffix,self.prefix))
|
||||
for value in (None,b'',str(self.prefix)+'-other/',str(self.prefix)+'/./',str(self.prefix)+'/../pfx/',
|
||||
str(alias)+'/',str(self.prefix).replace('/pfx','//pfx'),str(self.prefix)+'\\',str(self.prefix)+' /','pfx/'):
|
||||
with self.subTest(value=value):self.assertFalse(probe.prefix_matches(value,self.prefix))
|
||||
def test_actual_window_and_relationship_accept_proton_trailing_slash_only(self):
|
||||
window={'window':17,'pid':90,'server_pid':90}
|
||||
p=proc(90,args=[str(self.exe)])
|
||||
for suffix in ('','/','///'):
|
||||
env={'WINEPREFIX':str(self.prefix)+suffix,'DISPLAY':':2'}
|
||||
self.assertIsNotNone(probe.owned_window(window,self.bound,self.prefix,self.exe,lambda _:p,lambda _:env))
|
||||
self.assertEqual(probe.window_diagnostic(window,self.bound,self.prefix,self.exe,lambda _:p,lambda _:env)['rejection_stage'],'checks-passed')
|
||||
self.binding_fixture();folder=self.root/'proc'
|
||||
scope=proc(40);candidate=proc(41,start=11)
|
||||
nonce='application.id=org.escapistsrereintroduced.game.'+'c'*32
|
||||
records={40:scope,41:candidate}
|
||||
for value,expected in ((str(self.prefix)+'///',True),(str(self.prefix)+'-other/',False),(str(self.prefix)+'/../pfx/',False)):
|
||||
env={'WINEPREFIX':value,'PULSE_PROP':nonce}
|
||||
with patch.dict(os.environ,{'PULSE_PROP':nonce}):
|
||||
result=probe.candidate_relationship(candidate,scope,2,self.prefix,records.__getitem__,lambda _:env,folder)
|
||||
self.assertIs(result['xwayland_prefix_matches'],expected)
|
||||
self.assertIs(result['gamescope_prefix_matches'],expected)
|
||||
def test_native_xwindow_attributes_abi_and_signatures_match_installed_headers(self):
|
||||
compiler=shutil.which('cc')
|
||||
self.assertIsNotNone(compiler,'Installed C compiler required for native ABI check')
|
||||
fields=[name for name,_ in probe.WindowAttributes._fields_]
|
||||
lines=['#include <X11/Xlib.h>','#include <stddef.h>','#include <stdio.h>',
|
||||
'_Static_assert(__builtin_types_compatible_p(__typeof__(&XGetWindowAttributes), Status (*)(Display*,Window,XWindowAttributes*)), "attributes signature");',
|
||||
'_Static_assert(__builtin_types_compatible_p(__typeof__(&XGetInputFocus), int (*)(Display*,Window*,int*)), "focus signature");',
|
||||
'_Static_assert(__builtin_types_compatible_p(__typeof__(&XTranslateCoordinates), Bool (*)(Display*,Window,Window,int,int,int*,int*,Window*)), "translation signature");',
|
||||
'int main(void) { printf("%zu\\n", sizeof(XWindowAttributes));']
|
||||
for field in fields:
|
||||
native='class' if field=='window_class' else field
|
||||
lines.append('printf("%zu\\n", offsetof(XWindowAttributes, '+native+'));')
|
||||
lines.append('return 0;}')
|
||||
source=self.root/'abi.c';binary=self.root/'abi'
|
||||
source.write_text('\n'.join(lines))
|
||||
subprocess.run([compiler,'-std=gnu11','-Werror',str(source),'-o',str(binary)],check=True,capture_output=True,timeout=15)
|
||||
# This tiny binary only prints sizeof/offsetof; it does not link X11 or open a display.
|
||||
values=[int(value) for value in subprocess.check_output([str(binary)],text=True,timeout=3).splitlines()]
|
||||
self.assertEqual(values[0],C.sizeof(probe.WindowAttributes))
|
||||
self.assertEqual(values[1:],[getattr(probe.WindowAttributes,field).offset for field in fields])
|
||||
def test_ctypes_geometry_focus_adapter_signatures_and_values_without_connection(self):
|
||||
x=Mock();r=Mock();x.XOpenDisplay.return_value=123
|
||||
with patch.object(probe.C,'CDLL',side_effect=[x,r]):native=probe.X11(':2')
|
||||
self.assertEqual(x.XGetWindowAttributes.argtypes,[C.c_void_p,C.c_ulong,C.POINTER(probe.WindowAttributes)])
|
||||
self.assertEqual(x.XGetInputFocus.argtypes,[C.c_void_p,C.POINTER(C.c_ulong),C.POINTER(C.c_int)])
|
||||
def attrs(display,window,out):
|
||||
value=C.cast(out,C.POINTER(probe.WindowAttributes)).contents
|
||||
value.root=2;value.window_class=1;value.map_state=2;value.width=1280;value.height=720
|
||||
return 1
|
||||
def focus(display,out,revert):
|
||||
C.cast(out,C.POINTER(C.c_ulong))[0]=20;C.cast(revert,C.POINTER(C.c_int))[0]=2;return 1
|
||||
def translate(display,window,root,sx,sy,dx,dy,child):
|
||||
C.cast(dx,C.POINTER(C.c_int))[0]=-20;C.cast(dy,C.POINTER(C.c_int))[0]=30;return 1
|
||||
x.XGetWindowAttributes.side_effect=attrs;x.XGetInputFocus.side_effect=focus;x.XTranslateCoordinates.side_effect=translate
|
||||
self.assertEqual(native.attributes(20)['width'],1280);self.assertEqual(native.focus(),(20,2))
|
||||
self.assertEqual(native.translate(20,2),(-20,30))
|
||||
x.XGetWindowAttributes.side_effect=None;x.XGetWindowAttributes.return_value=0
|
||||
with self.assertRaises(ValueError):native.attributes(20)
|
||||
native.query_budget=0
|
||||
with self.assertRaisesRegex(ValueError,'budget'):native.focus()
|
||||
native.close();x.XCloseDisplay.assert_called_once_with(123)
|
||||
def test_exact_mapped_focus_and_owned_descendant_qualify_read_only(self):
|
||||
query=FakeWindowQuery();owner={'window':20,'pid':90}
|
||||
direct=probe.input_target(query,owner)
|
||||
self.assertTrue(direct['verified']);self.assertEqual(direct['focus_chain'],[20])
|
||||
self.assertEqual(direct['geometry']['clipped_bounds'],[20,30,820,630])
|
||||
query.focused=(21,2)
|
||||
child=probe.input_target(query,owner)
|
||||
self.assertTrue(child['verified']);self.assertEqual(child['focus_chain'],[21,20])
|
||||
self.assertEqual(child['focused_clipped_bounds'],[40,50,240,150])
|
||||
query.positions[20]=(-10,-20)
|
||||
self.assertTrue(probe.input_target(query,owner)['verified'])
|
||||
self.assertEqual(probe.input_target(query,owner)['geometry']['clipped_bounds'],[0,0,790,580])
|
||||
def test_unmapped_input_only_invalid_root_geometry_and_foreign_focus_refused(self):
|
||||
for window,key,value in ((20,'map_state',0),(20,'window_class',2),(20,'width',0),(20,'height',65536),
|
||||
(20,'root',99),(20,'width',True),(2,'width',0),(2,'window_class',2)):
|
||||
query=FakeWindowQuery();query.attrs[window][key]=value
|
||||
with self.subTest(window=window,key=key,value=value):self.assertFalse(probe.input_target(query,{'window':20,'pid':90})['verified'])
|
||||
for focused in ((0,2),(1,2),(True,2),(20,9),(20,True)):
|
||||
query=FakeWindowQuery();query.focused=focused
|
||||
self.assertFalse(probe.input_target(query,{'window':20,'pid':90})['verified'])
|
||||
query=FakeWindowQuery();query.positions[20]=(2000,0)
|
||||
self.assertFalse(probe.input_target(query,{'window':20,'pid':90})['verified'])
|
||||
query=FakeWindowQuery();query.focused=(21,2);query.pids[21]=99
|
||||
result=probe.input_target(query,{'window':20,'pid':90})
|
||||
self.assertFalse(result['verified']);self.assertEqual(result['reason'],'focus-client-not-owned-game')
|
||||
query.pids[21]=90;query.parents[21]=2
|
||||
self.assertFalse(probe.input_target(query,{'window':20,'pid':90})['verified'])
|
||||
def test_child_clipping_cycle_transient_focus_or_geometry_refused(self):
|
||||
owner={'window':20,'pid':90}
|
||||
query=FakeWindowQuery();query.focused=(21,2);query.positions[21]=(900,50)
|
||||
self.assertFalse(probe.input_target(query,owner)['verified'])
|
||||
query=FakeWindowQuery();query.focused=(21,2);query.parents[21]=21
|
||||
self.assertFalse(probe.input_target(query,owner)['verified'])
|
||||
query=FakeWindowQuery();query.focus=Mock(side_effect=[(20,2),(21,2)])
|
||||
self.assertFalse(probe.input_target(query,owner)['verified'])
|
||||
query=FakeWindowQuery();query.translate=Mock(side_effect=[(20,30),(20,30),(21,30)])
|
||||
self.assertFalse(probe.input_target(query,owner)['verified'])
|
||||
query=FakeWindowQuery();query.attributes=Mock(side_effect=ValueError('native-window-attributes-unavailable'))
|
||||
self.assertFalse(probe.input_target(query,owner)['verified'])
|
||||
def test_focus_ancestry_has_fixed_depth_bound_and_stale_process_is_not_target(self):
|
||||
query=FakeWindowQuery();query.focused=(100,2)
|
||||
for window in range(100,133):
|
||||
query.attrs[window]=query.attribute(100,100);query.positions[window]=(40,50)
|
||||
query.parents[window]=window+1;query.pids[window]=90
|
||||
query.parents[132]=20
|
||||
result=probe.input_target(query,{'window':20,'pid':90})
|
||||
self.assertFalse(result['verified']);self.assertEqual(result['reason'],'focus-ancestry-budget-exceeded')
|
||||
owner={'window':20,'pid':90,'server_pid':90,'process':{'pid':90,'start':100,'uid':os.getuid()}}
|
||||
query=FakeWindowQuery();query.windows=lambda:[owner];query.close=Mock()
|
||||
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'owned_window',side_effect=[owner,None]),\
|
||||
patch.object(probe,'window_diagnostic',return_value={}):
|
||||
result=probe.observe(self.args,lambda _:query)
|
||||
self.assertTrue(result['owned_game_window_verified']);self.assertFalse(result['input_target_verified'])
|
||||
self.assertEqual(result['input_target_observations'][0]['reason'],'game-process-changed-during-target-observation')
|
||||
def test_observe_preserves_ownership_when_input_target_is_unverified(self):
|
||||
window={'window':20,'pid':90,'server_pid':90,'process':{'pid':90,'start':100,'uid':os.getuid()}}
|
||||
query=FakeWindowQuery();query.windows=lambda:[window];query.close=Mock();query.focused=(0,0)
|
||||
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'owned_window',return_value=window),\
|
||||
patch.object(probe,'window_diagnostic',return_value={}):
|
||||
result=probe.observe(self.args,lambda _:query)
|
||||
self.assertTrue(result['owned_game_window_verified']);self.assertFalse(result['input_target_verified'])
|
||||
self.assertIsNone(result['input_target']);query.close.assert_called_once()
|
||||
query.focused=(20,2)
|
||||
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'owned_window',return_value=window),\
|
||||
patch.object(probe,'window_diagnostic',return_value={}):
|
||||
result=probe.observe(self.args,lambda _:query)
|
||||
self.assertTrue(result['input_target_verified']);self.assertEqual(result['input_target']['window'],20)
|
||||
def test_no_native_input_or_focus_functions_are_bound(self):
|
||||
source=Path(probe.__file__).read_text()
|
||||
for forbidden in ('XTestFake','XSendEvent','XSetInputFocus','XWarpPointer','XRaiseWindow','XGrabPointer','XGrabKeyboard'):
|
||||
self.assertNotIn(forbidden,source)
|
||||
self.assertIn('XOpenDisplay(display.encode',source)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runner integration policy tests: fake native processes and no display connections."""
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
|
||||
import headless_game as runner
|
||||
import headless_display_probe as helper
|
||||
|
||||
DEFAULT_REPORT = object()
|
||||
|
||||
|
||||
class DisplayRunnerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory(prefix='erri-display-runner-test-')
|
||||
self.base=Path(self.tmp.name);self.root=self.base/'root';(self.root/'tools').mkdir(parents=True)
|
||||
(self.root/'tools/headless_display_probe.py').write_text('synthetic helper bytes for provenance test')
|
||||
self.compat=self.base/'compat';self.profile=self.base/'profile';self.profile.mkdir()
|
||||
self.steam=self.base/'Steam';self.exe=self.steam/'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe'
|
||||
self.proton=self.steam/'steamapps/common/Proton - Experimental/proton'
|
||||
for path in (self.exe,self.proton):path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(b'synthetic file, never launched')
|
||||
self.command=['gamescope','--backend','headless','-W','1280','--',str(self.proton),'run',str(self.exe),'-noBackend']
|
||||
self.wrapped,self.context=runner.display_probe_command(self.command,self.root,self.compat,self.profile,self.exe,'fixed-run-id',':0')
|
||||
self.session=self.base/'session';self.session.mkdir()
|
||||
def ident(pid):return {'pid':pid,'start':100,'uid':os.getuid()}
|
||||
self.report={**{k:self.context[k] for k in ('helper_sha256','prefix','profile','game_executable','report','command')},
|
||||
'schema':1,'kind':'headless-display-read-only','owned_game_window_verified':True,
|
||||
'read_only':True,'input_sent':False,'observation_finished':True,'wrapper':ident(10),
|
||||
'binding':{'display':':2','gamescope':ident(20),'xwayland':ident(30),
|
||||
'sockets':[{'path':'@/tmp/.X11-unix/X2','inode':1000}],
|
||||
'relationship':{'mode':'descendant','descendant':True}},
|
||||
'owned_windows':[{'window':12345,'pid':40,'server_pid':40,'process':ident(40),'title':'fixture game'}]}
|
||||
def tearDown(self):self.tmp.cleanup()
|
||||
def save(self,value=DEFAULT_REPORT):
|
||||
raw=(json.dumps(self.report if value is DEFAULT_REPORT else value,indent=3)+'\n').encode()
|
||||
Path(self.context['report']).write_bytes(raw);return raw
|
||||
def test_exact_gamescope_boundary_and_unmodified_proton_child(self):
|
||||
separator=self.command.index('--')
|
||||
self.assertEqual(self.wrapped[:separator+1],self.command[:separator+1])
|
||||
self.assertEqual(self.wrapped[-len(self.context['command']):],self.command[separator+1:])
|
||||
self.assertEqual(self.wrapped[separator+1],sys.executable)
|
||||
self.assertEqual(self.wrapped[separator+2],str(self.root/'tools/headless_display_probe.py'))
|
||||
for flag,value in (('--report',self.context['report']),('--host-display',':0'),('--prefix',str(self.compat/'pfx'))):
|
||||
self.assertEqual(self.wrapped[self.wrapped.index(flag)+1],value)
|
||||
self.assertEqual(self.command[separator+1],str(self.proton)) # caller array untouched
|
||||
def test_default_command_has_no_wrapper_and_remains_ordinary(self):
|
||||
command=runner.command_for(runner.ROOT,self.steam,self.compat,self.profile,'core')
|
||||
separator=command.index('--')
|
||||
self.assertEqual(command[separator+1:separator+4],[str(self.proton),'run',str(self.exe)])
|
||||
self.assertNotIn('--host-display',command);self.assertNotIn('--query-worker',command)
|
||||
self.assertIn('-noSound',command);self.assertIn('-noBackend',command)
|
||||
def test_bad_boundary_missing_helper_and_existing_report_refused(self):
|
||||
wrong=self.command.copy();wrong[2]='wayland'
|
||||
with self.assertRaises(ValueError):runner.display_probe_command(wrong,self.root,self.compat,self.profile,self.exe,'fixed-run-id',':0')
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):runner.display_probe_command(self.command,self.root,self.compat,self.profile,self.exe,'fixed-run-id',':0')
|
||||
Path(self.context['report']).unlink()
|
||||
helper=self.root/'tools/headless_display_probe.py';helper.unlink();helper.symlink_to(self.base/'absent')
|
||||
with self.assertRaises(ValueError):runner.display_probe_command(self.command,self.root,self.compat,self.profile,self.exe,'fixed-run-id',':0')
|
||||
def test_packed_and_vanilla_reject_before_resolution_or_launch(self):
|
||||
for project,packed in [('core',{'session':self.base/'packed'}),(runner.CONTROL,None)]:
|
||||
args=SimpleNamespace(project=project,inspect_headless_display=True)
|
||||
with patch.object(runner,'packed_request',return_value=packed),patch.object(runner.subprocess,'Popen') as spawn,patch.object(runner,'assert_no_game') as process_check:
|
||||
with self.assertRaisesRegex(ValueError,'loose Core or Chat'):runner.run(args)
|
||||
spawn.assert_not_called();process_check.assert_not_called()
|
||||
def test_exact_bytes_archived_with_hash_without_promoting_gameplay(self):
|
||||
raw=self.save();result=runner.display_probe_result(self.context,self.session)
|
||||
self.assertTrue(result['verified']);self.assertTrue(result['read_only']);self.assertFalse(result['input_sent'])
|
||||
self.assertEqual((self.session/'display-ownership.json').read_bytes(),raw)
|
||||
self.assertEqual(result['sha256'],hashlib.sha256(raw).hexdigest())
|
||||
self.assertEqual(result['archive_file'],'display-ownership.json')
|
||||
self.assertFalse(runner.display_probe_result(self.context,self.session)['verified']) # exclusive archive copy
|
||||
self.assertEqual((self.session/'display-ownership.json').read_bytes(),raw)
|
||||
def test_each_launch_binding_mismatch_fails(self):
|
||||
for key in ('helper_sha256','prefix','profile','game_executable','report','command'):
|
||||
altered=copy.deepcopy(self.report);altered[key]=['foreign'] if key=='command' else 'foreign'
|
||||
self.save(altered)
|
||||
with self.subTest(key=key):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
def test_absent_malformed_unverified_and_input_claims_fail(self):
|
||||
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
for value in ([],None,{}, {**self.report,'owned_game_window_verified':False}, {**self.report,'read_only':False},
|
||||
{**self.report,'input_sent':True}, {**self.report,'observation_finished':False}, {**self.report,'schema':2}):
|
||||
self.save(value);self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
Path(self.context['report']).write_text('{truncated')
|
||||
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
Path(self.context['report']).unlink();Path(self.context['report']).symlink_to(self.base/'absent')
|
||||
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
def test_host_display_uid_pid_start_or_window_disagreement_fails(self):
|
||||
faults=[]
|
||||
for target in ('wrapper','gamescope','xwayland','window'):
|
||||
for key,value in [('uid',os.getuid()+1),('pid',0),('start',0),('pid',True)]:
|
||||
bad=copy.deepcopy(self.report)
|
||||
item=(bad['wrapper'] if target=='wrapper' else bad['owned_windows'][0]['process'] if target=='window' else bad['binding'][target])
|
||||
item[key]=value;faults.append(bad)
|
||||
for display in (':0',':0.1','unix:0','remote:2',''):
|
||||
bad=copy.deepcopy(self.report);bad['binding']['display']=display;faults.append(bad)
|
||||
for key,value in [('pid',41),('server_pid',41),('window',0),('window',None),('window',True)]:
|
||||
bad=copy.deepcopy(self.report);bad['owned_windows'][0][key]=value;faults.append(bad)
|
||||
bad=copy.deepcopy(self.report);bad['owned_windows']=[];faults.append(bad)
|
||||
for bad in faults:
|
||||
self.save(bad)
|
||||
with self.subTest(report=bad):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
def test_missing_foreign_malformed_duplicate_socket_evidence_fails(self):
|
||||
for sockets in (None,[],[{'path':'@/tmp/.X11-unix/X0','inode':1000}],
|
||||
[{'path':'@/tmp/.X11-unix/X2','inode':0}], [{'path':'@/tmp/.X11-unix/X2','inode':True}],
|
||||
[{'path':'@/tmp/.X11-unix/X2','inode':1000}]*2):
|
||||
bad=copy.deepcopy(self.report);bad['binding']['sockets']=sockets;self.save(bad)
|
||||
with self.subTest(sockets=sockets):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
def test_missing_unknown_or_unproven_relationship_refused(self):
|
||||
for relationship in (None,{}, {'mode':'unknown'}, {'mode':'descendant'},
|
||||
{'mode':'descendant','descendant':False},{'mode':'descendant','descendant':1},
|
||||
{'mode':'shared-listeners'}, {'mode':'shared-listeners','checks':{}},'descendant'):
|
||||
bad=copy.deepcopy(self.report);bad['binding']['relationship']=relationship;self.save(bad)
|
||||
with self.subTest(relationship=relationship):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
bad=copy.deepcopy(self.report);del bad['binding']['relationship'];self.save(bad)
|
||||
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
def test_shared_relationship_requires_every_check_and_exact_socket_inodes(self):
|
||||
checks={key:True for key in helper.SHARED_CHECKS};checks['listener_inodes']=[1000]
|
||||
good=copy.deepcopy(self.report);good['binding']['relationship']={'mode':'shared-listeners','checks':checks}
|
||||
self.save(good);self.assertTrue(runner.display_probe_result(self.context,None)['verified'])
|
||||
for key in helper.SHARED_CHECKS:
|
||||
for value in (False,None,1):
|
||||
bad=copy.deepcopy(good);bad['binding']['relationship']['checks'][key]=value;self.save(bad)
|
||||
with self.subTest(key=key,value=value):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
bad=copy.deepcopy(good);del bad['binding']['relationship']['checks'][key];self.save(bad)
|
||||
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
for inodes in (None,[],[1001],[True],[1000,1000],[1000,1001]):
|
||||
bad=copy.deepcopy(good);bad['binding']['relationship']['checks']['listener_inodes']=inodes;self.save(bad)
|
||||
with self.subTest(inodes=inodes):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
bad=copy.deepcopy(good);bad['binding']['xwayland']['start']=99;self.save(bad)
|
||||
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
good['binding']['sockets'].append({'path':'/tmp/.X11-unix/X2','inode':1001})
|
||||
good['binding']['relationship']['checks']['listener_inodes']=[1000,1001]
|
||||
self.save(good);self.assertTrue(runner.display_probe_result(self.context,None)['verified'])
|
||||
def test_boolean_server_pid_is_not_an_integer_process_identity(self):
|
||||
bad=copy.deepcopy(self.report);window=bad['owned_windows'][0]
|
||||
window['pid']=1;window['process']['pid']=1;window['server_pid']=True
|
||||
self.save(bad);self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
|
||||
def simulate_run_locked(self,display_ok,ordinary_failed=False):
|
||||
(self.exe.parent/'addons/data').mkdir(parents=True,exist_ok=True)
|
||||
args=SimpleNamespace(project='core',capture_prison_view=False,inspect_headless_display=True,timeout=120,settle=15,failure_grace=0)
|
||||
session=self.profile/'logs/logs_fixture'
|
||||
child=Mock();child.poll.return_value=None
|
||||
def spawn(*args,**kwargs):
|
||||
session.mkdir(parents=True);(session/'console.log').write_text('synthetic ordinary startup fixture')
|
||||
return child
|
||||
def output(args,**kwargs):return '[]' if '-f' in args else '11'
|
||||
monitor=SimpleNamespace(deadline=time.monotonic()+60,started=0,first_failure_at=None,
|
||||
observe=lambda *args:'game-started',annotate=lambda value:value)
|
||||
evidence={'ready':not ordinary_failed,'failed':ordinary_failed,'scope':'synthetic test only'}
|
||||
with contextlib.ExitStack() as stack:
|
||||
for name in ('assert_no_game','assert_prefix_idle','persist','cleanup'):
|
||||
stack.enter_context(patch.object(runner,name))
|
||||
stack.enter_context(patch.object(runner,'snapshot',return_value={}))
|
||||
stack.enter_context(patch.object(runner,'bindings',return_value={}))
|
||||
stack.enter_context(patch.object(runner,'seal_run',return_value={'source_unchanged_after_run':True}))
|
||||
stack.enter_context(patch.object(runner,'StartupMonitor',return_value=monitor))
|
||||
stack.enter_context(patch.object(runner,'game_evidence',return_value=evidence))
|
||||
stack.enter_context(patch.object(runner,'display_probe_result',return_value={'verified':display_ok}))
|
||||
stack.enter_context(patch.object(runner.shutil,'which',return_value='/usr/bin/synthetic'))
|
||||
stack.enter_context(patch.object(runner.subprocess,'Popen',side_effect=spawn))
|
||||
stack.enter_context(patch.object(runner.subprocess,'check_output',side_effect=output))
|
||||
stack.enter_context(patch.object(runner.subprocess,'run'))
|
||||
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
||||
status=runner.run_locked(args,self.steam,self.compat,self.profile)
|
||||
return status,json.loads((session/'runner.json').read_text())
|
||||
def test_missing_display_proof_turns_actual_runner_path_into_failure(self):
|
||||
status,report=self.simulate_run_locked(False)
|
||||
self.assertEqual(status,1);self.assertEqual(report['result'],'display-probe-incomplete')
|
||||
self.assertEqual(report['startup_result_before_display_check'],'game-started')
|
||||
def test_valid_display_does_not_clear_native_failure(self):
|
||||
status,report=self.simulate_run_locked(True,ordinary_failed=True)
|
||||
self.assertEqual(status,1);self.assertEqual(report['result'],'game-failed')
|
||||
def test_valid_ownership_preserves_clean_startup_only(self):
|
||||
status,report=self.simulate_run_locked(True)
|
||||
self.assertEqual(status,0);self.assertEqual(report['result'],'game-started')
|
||||
self.assertTrue(report['display_ownership']['verified'])
|
||||
self.assertNotIn('multiplayer_verified',report)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -60,6 +60,28 @@ class GameRunnerTests(unittest.TestCase):
|
||||
runner.assert_prefix_idle(compat, root)
|
||||
runner.assert_prefix_idle(root/'different', root)
|
||||
|
||||
def test_proton_trailing_prefix_slashes_refuse_existing_workbench_and_wine(self):
|
||||
for command in (b'wineserver\0', b'Z:/Workbench/ArmaReforgerWorkbenchSteamDiag.exe\0'):
|
||||
for suffix in ('','/','////'):
|
||||
with self.subTest(command=command,suffix=suffix),tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory);compat=root/'compat';proc=self.process(root,command)
|
||||
data=('WINEPREFIX='+str(compat/'pfx')+suffix).encode()+b'\0OTHER=unchanged\0'
|
||||
(proc/'environ').write_bytes(data)
|
||||
with self.assertRaisesRegex(ValueError,'prefix already has a process'):
|
||||
runner.assert_prefix_idle(compat,root)
|
||||
self.assertEqual((proc/'environ').read_bytes(),data)
|
||||
def test_prefix_idle_does_not_resolve_neighbours_dot_components_or_symlink_spellings(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory);compat=root/'compat';prefix=compat/'pfx';prefix.mkdir(parents=True)
|
||||
alias=root/'alias';alias.symlink_to(prefix,target_is_directory=True)
|
||||
proc=self.process(root,b'wineserver\0')
|
||||
for value in (str(prefix)+'-other/',str(prefix)+'/./',str(prefix)+'/../pfx/',str(alias)+'/',
|
||||
str(prefix).replace('/pfx','//pfx'),str(prefix)+'\\','pfx/',str(prefix)+' /'):
|
||||
data=('WINEPREFIX='+value).encode()+b'\0';(proc/'environ').write_bytes(data)
|
||||
with self.subTest(value=value):runner.assert_prefix_idle(compat,root)
|
||||
self.assertEqual((proc/'environ').read_bytes(),data)
|
||||
(proc/'environ').write_bytes(('OTHER_WINEPREFIX='+str(prefix)+'/').encode()+b'\0')
|
||||
runner.assert_prefix_idle(compat,root)
|
||||
def test_nondumpable_native_service_does_not_require_environment(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Execute both production monitor bodies; native hint/camera boundaries are fakes."""
|
||||
from policy_harness import ROOT, body, run
|
||||
GUIDE=ROOT/'addons/core/Scripts/Game/ERR/ERR_PlayerGuide.c'
|
||||
OBSERVER=ROOT/'addons/core/Scripts/Game/ERR/ERR_Observer.c'
|
||||
def cpp(text):
|
||||
text=text.replace('protected ', '')
|
||||
for name in ('ERR_GameMode','ERR_Phase','ERR_ObserverReason','ERR_Actors','ERR_ObjectiveText','SCR_HintManagerComponent','ERR_ObserverGuidance','Resource','ETransformMode','SCR_ManualCamera','SCR_RespawnSystemComponent','RplId'):
|
||||
text=text.replace(name+'.',name+'::')
|
||||
for typename in ('ERR_GameMode','SCR_HintManagerComponent','Resource','SCR_RespawnSystemComponent','CameraManager'):
|
||||
text=text.replace(typename+' ',typename+'* ')
|
||||
for variable in ('mode','hints','resource','respawn','manager'):
|
||||
text=text.replace(variable+'.',variable+'->')
|
||||
text=text.replace('GetGame().','GetGame()->').replace('EntitySpawnParams params = new EntitySpawnParams();','EntitySpawnParams params;')
|
||||
return text
|
||||
methods=cpp(body(GUIDE,'protected void ERR_UpdateGuide('))+'\n'+cpp(body(OBSERVER,'protected void ERR_UpdateObserverClient('))
|
||||
guidance=cpp(body(OBSERVER,'static string Message('))+'\n'+cpp(body(OBSERVER,'static bool Refresh('))
|
||||
run(r'''#include <cassert>
|
||||
#include <string>
|
||||
struct string:std::string {using std::string::string;using std::string::operator=;string(std::string s):std::string(s){}bool IsEmpty()const{return empty();}};
|
||||
namespace ERR_Phase {enum {PREPARING,PRISON,RADIO_SEARCH,RADIO_DEFENCE,EXTRACTION};}
|
||||
namespace ERR_ObserverReason {enum {NONE,SPENT_LIFE,LATE_EXTRACTION,SPAWN_UNAVAILABLE};}
|
||||
struct Actor {bool alive=true;bool unconscious=false;} actor;
|
||||
struct ERR_Actors {static bool Alive(Actor* a){return a&&a->alive;}};
|
||||
struct ERR_GameMode {int phase=ERR_Phase::PRISON;bool active=true;string message="prison";static ERR_GameMode* current;static ERR_GameMode* Get(){return current;}int Phase(){return phase;}bool Active(){return active;}} mode;
|
||||
ERR_GameMode* ERR_GameMode::current=&mode;
|
||||
struct ERR_ObjectiveText {static string Description(ERR_GameMode* m,bool){return m->message;}static string Title(int){return "objective";}};
|
||||
struct SCR_HintManagerComponent {static SCR_HintManagerComponent* current;bool accepts=true;int attempts=0,shown=0;string last;static SCR_HintManagerComponent* GetInstance(){return current;}bool ShowCustom(string text,string,int){attempts++;if(!accepts)return false;shown++;last=text;return true;}} hints;
|
||||
SCR_HintManagerComponent* SCR_HintManagerComponent::current=&hints;
|
||||
struct ERR_ObserverGuidance {
|
||||
'''+guidance+r'''
|
||||
};
|
||||
struct Vec {Vec operator+(const char*)const{return {};}};
|
||||
struct EntitySpawnParams {int TransformMode=0;Vec Transform[4];};
|
||||
namespace ETransformMode {enum {WORLD};}
|
||||
struct Resource {bool valid=true;static Resource* current;static Resource* Load(const char*){return current;}bool IsValid(){return valid;}} resource;
|
||||
Resource* Resource::current=&resource;
|
||||
struct SCR_ManualCamera {static SCR_ManualCamera* Cast(SCR_ManualCamera* c){return c;}} camera;
|
||||
struct CameraManager {SCR_ManualCamera* current=&camera;SCR_ManualCamera* CurrentCamera(){return current;}} manager;
|
||||
struct SCR_RespawnSystemComponent {int destroys=0;static SCR_RespawnSystemComponent* current;static SCR_RespawnSystemComponent* GetInstance(){return current;}void DestroyLoadingPlaceholder(){destroys++;}} respawn;
|
||||
SCR_RespawnSystemComponent* SCR_RespawnSystemComponent::current=&respawn;
|
||||
struct RplId {static int Invalid(){return -1;}};
|
||||
struct Game {int spawns=0;SCR_ManualCamera* SpawnEntityPrefab(Resource*,int,EntitySpawnParams){spawns++;return &camera;}int GetWorld(){return 1;}CameraManager* GetCameraManager(){return &manager;}} game;
|
||||
Game* GetGame(){return &game;}
|
||||
struct Controller {
|
||||
int m_ERRGuidePhase=-1;string m_ERRGuideMessage;
|
||||
Actor* controlled=&actor;int m_ERRObserverReason=ERR_ObserverReason::NONE;
|
||||
SCR_ManualCamera* m_ERRObserverCamera=nullptr;Vec m_ERRObserverAnchor;
|
||||
int m_ERRObservedId=1;bool m_ERRObserverReady=false;
|
||||
int m_ERRShownObserverReason=0;bool m_ERRShownObserverAvailable=false;int closes=0;
|
||||
Actor* GetControlledEntity(){return controlled;}int ERR_ObserverState(){return m_ERRObserverReason;}
|
||||
void ERR_CloseObserverCamera(){closes++;m_ERRObserverReady=false;m_ERRObserverCamera=nullptr;}
|
||||
'''+methods+r'''
|
||||
};
|
||||
void reset(){mode=ERR_GameMode();ERR_GameMode::current=&mode;hints=SCR_HintManagerComponent();SCR_HintManagerComponent::current=&hints;actor=Actor();Resource::current=&resource;manager.current=&camera;}
|
||||
int main(){
|
||||
reset();Controller g;
|
||||
SCR_HintManagerComponent::current=nullptr;g.ERR_UpdateGuide();assert(g.m_ERRGuidePhase==-1&&hints.attempts==0);
|
||||
SCR_HintManagerComponent::current=&hints;hints.accepts=false;
|
||||
g.ERR_UpdateGuide();g.ERR_UpdateGuide();assert(hints.attempts==2&&hints.shown==0&&g.m_ERRGuidePhase==-1&&g.m_ERRGuideMessage.empty());
|
||||
hints.accepts=true;g.ERR_UpdateGuide();assert(hints.shown==1&&g.m_ERRGuidePhase==mode.phase&&g.m_ERRGuideMessage==mode.message);
|
||||
g.ERR_UpdateGuide();assert(hints.attempts==3);
|
||||
mode.message="";g.ERR_UpdateGuide();assert(hints.attempts==3&&g.m_ERRGuideMessage=="prison");
|
||||
// Same-phase content updates are retryable and do not erase the delivered cache.
|
||||
mode.message="transmission interrupted";hints.accepts=false;g.ERR_UpdateGuide();assert(g.m_ERRGuideMessage=="prison");
|
||||
hints.accepts=true;g.ERR_UpdateGuide();assert(hints.shown==2&&g.m_ERRGuideMessage==mode.message);
|
||||
mode.phase=ERR_Phase::RADIO_SEARCH;g.ERR_UpdateGuide();assert(hints.shown==3);
|
||||
// Explicit observation, death and absence suppress ordinary phase instructions.
|
||||
for(int excluded=0;excluded<3;excluded++){
|
||||
Controller c;if(excluded==0)c.m_ERRObserverReason=ERR_ObserverReason::SPENT_LIFE;
|
||||
if(excluded==1){actor.alive=false;}if(excluded==2){c.controlled=nullptr;}
|
||||
int attempts=hints.attempts;c.ERR_UpdateGuide();assert(hints.attempts==attempts&&c.m_ERRGuidePhase==-1);
|
||||
actor.alive=true;c.controlled=&actor;c.m_ERRObserverReason=ERR_ObserverReason::NONE;c.ERR_UpdateGuide();assert(hints.attempts==attempts+1);
|
||||
}
|
||||
// Unconscious living prisoners retain recovery guidance. Preparing needs no body.
|
||||
Controller unconscious;actor.unconscious=true;unconscious.ERR_UpdateGuide();assert(unconscious.m_ERRGuidePhase==mode.phase);actor.unconscious=false;
|
||||
Controller preparing;preparing.controlled=nullptr;mode.phase=ERR_Phase::PREPARING;mode.message="preparing";preparing.ERR_UpdateGuide();assert(preparing.m_ERRGuideMessage=="preparing");
|
||||
ERR_GameMode::current=nullptr;int attempts=hints.attempts;preparing.ERR_UpdateGuide();assert(hints.attempts==attempts);
|
||||
reset();Controller s;s.controlled=nullptr;s.m_ERRObserverReason=ERR_ObserverReason::SPENT_LIFE;
|
||||
// Actual camera setup executes; missing manager cannot consume delivery state.
|
||||
SCR_HintManagerComponent::current=nullptr;s.ERR_UpdateObserverClient();assert(s.m_ERRObserverCamera==&camera&&!s.m_ERRObserverReady);
|
||||
SCR_HintManagerComponent::current=&hints;hints.accepts=false;s.ERR_UpdateObserverClient();s.ERR_UpdateObserverClient();assert(hints.attempts==2&&!s.m_ERRObserverReady&&s.m_ERRShownObserverReason==0);
|
||||
hints.accepts=true;s.ERR_UpdateObserverClient();assert(hints.shown==1&&s.m_ERRObserverReady&&s.m_ERRShownObserverReason==ERR_ObserverReason::SPENT_LIFE&&s.m_ERRShownObserverAvailable);
|
||||
s.ERR_UpdateObserverClient();assert(hints.attempts==3);
|
||||
// Reason changes and a genuinely empty survivor set retry independently.
|
||||
s.m_ERRObserverReason=ERR_ObserverReason::LATE_EXTRACTION;hints.accepts=false;s.ERR_UpdateObserverClient();assert(s.m_ERRShownObserverReason==ERR_ObserverReason::SPENT_LIFE);
|
||||
hints.accepts=true;s.ERR_UpdateObserverClient();assert(s.m_ERRShownObserverReason==ERR_ObserverReason::LATE_EXTRACTION&&hints.shown==2);
|
||||
s.m_ERRObservedId=RplId::Invalid();hints.accepts=false;s.ERR_UpdateObserverClient();assert(s.m_ERRShownObserverAvailable);
|
||||
hints.accepts=true;s.ERR_UpdateObserverClient();assert(!s.m_ERRShownObserverAvailable&&hints.last.find("No survivor")!=string::npos);
|
||||
s.ERR_UpdateObserverClient();assert(hints.shown==3);
|
||||
// A living body closes observation without attempting a spectator hint.
|
||||
s.controlled=&actor;attempts=hints.attempts;s.ERR_UpdateObserverClient();assert(s.closes==1&&!s.m_ERRObserverReady&&hints.attempts==attempts);
|
||||
s.controlled=nullptr;s.ERR_UpdateObserverClient();assert(hints.shown==4&&s.m_ERRObserverReady);
|
||||
}
|
||||
''')
|
||||
for path,signature in ((GUIDE,'protected void ERR_UpdateGuide('),(OBSERVER,'protected void ERR_UpdateObserverClient(')):
|
||||
assert 'CallLater' not in body(path,signature), 'Delivery retry uses the existing monitor only'
|
||||
print('PASS: actual guide/observer monitors retry native refusal, cache successful delivery, deduplicate and preserve living/preparing/spectator boundaries; native UI not exercised')
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Execute observer policies/lifecycle against native read-boundary adapters, not a game."""
|
||||
from policy_harness import ROOT, body, run
|
||||
P=ROOT/'addons/core/Scripts/Game/ERR/ERR_OrdinaryInputObserver.c'
|
||||
def cpp(s):
|
||||
s=s.replace('protected ', '').replace('out string nonce','string& nonce')
|
||||
for t in ('ERR_GameMode','PlayerController','IEntity','ERR_PlayerLife','ERR_OrdinaryInputObserver','PlayerManager','SCR_InventoryMenuUI','Widget'):
|
||||
s=s.replace(t+' mode',t+'* mode').replace(t+' controller',t+'* controller').replace(t+' actor',t+'* actor').replace(t+' life',t+'* life').replace(t+' request',t+'* request').replace(t+' current',t+'* current').replace(t+' manager',t+'* manager').replace(t+' menu',t+'* menu').replace(t+' root',t+'* root')
|
||||
for v in ('mode','controller','actor','request','life','current','manager','menu','root','m_Mode','m_World','m_Controller','m_Actor','m_Character','m_Menu','s_Request'):
|
||||
s=s.replace(v+'.',v+'->')
|
||||
for scope in ('System','Replication','ERR_Phase','ERR_GameMode','ERR_Actors','SCR_InventoryMenuUI','ERR_OrdinaryInputObserverRules','vector','Math'):
|
||||
s=s.replace(scope+'.',scope+'::')
|
||||
s=s.replace('GetGame().','GetGame()->').replace('GetPlayerManager().','GetPlayerManager()->').replace('GetInputManager().','GetInputManager()->').replace('GetMenuManager().','GetMenuManager()->').replace('GetCallqueue().','GetCallqueue()->').replace('Players().','Players()->')
|
||||
s=s.replace('CharacterControllerComponent.Cast(actor->FindComponent(CharacterControllerComponent))','actor->FindComponent(0)').replace('FindComponent(CharacterControllerComponent)','FindComponent(0)')
|
||||
s=s.replace('.Insert(request->Cancel)', '.Insert()').replace('.Remove(Cancel)', '.Remove()')
|
||||
s=s.replace('.CallLater(request->Sample, 100, false)', '.CallLater()').replace('->CallLater(request->Sample, 100, false)', '->CallLater()').replace('->CallLater(Sample, 100, false)', '->CallLater()').replace('->Remove(Sample)', '->Remove()')
|
||||
s=s.replace('null','nullptr').replace('bool open;', 'bool open = false;')
|
||||
s=s.replace('nonce.Length()', 'int(nonce.size())').replace('allowed.Contains(nonce[i])', '(allowed.find(nonce[i]) != string::npos)')
|
||||
return s
|
||||
rules='\n'.join(cpp(body(P,x)) for x in ('static bool Nonce(', 'static bool Within(', 'static bool Stationary('))
|
||||
methods='\n'.join(cpp(body(P,x)) for x in ('protected static bool Enabled(', 'static void Queue(', 'protected bool Valid(', 'protected void Record(', 'protected void Capture(', 'protected void Observe(', 'protected void Sample(', 'protected void End(', 'void Cancel(', 'static void ClearWorld('))
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
using string=std::string;using UUID=string;
|
||||
struct vector {float x=0,y=0,z=0;float Length(){return std::sqrt(x*x+y*y+z*z);}static float Distance(vector a,vector b){return vector{a.x-b.x,a.y-b.y,a.z-b.z}.Length();}};
|
||||
namespace Math {float AbsFloat(float x){return std::abs(x);}}
|
||||
namespace System {bool console=false;int now=0;std::map<string,string> cli;std::vector<string> shots;bool IsConsoleApp(){return console;}int GetTickCount(){return now;}bool GetCLIParam(string key,string& value){if(!cli.count(key))return false;value=cli[key];return true;}void MakeScreenshot(string path){shots.push_back(path);}}
|
||||
namespace Replication {bool server=true;bool IsServer(){return server;}}
|
||||
namespace ERR_Phase {enum {PRISON,PREPARING,DEFEAT};}
|
||||
struct BaseWorld {float time=0;float GetWorldTime(){return time;}};
|
||||
struct CharacterControllerComponent {bool unconscious=false,disabled=false;vector input,velocity;bool IsUnconscious(){return unconscious;}bool GetDisableMovementControls(){return disabled;}vector GetMovementInput(){return input;}vector GetVelocity(){return velocity;}};
|
||||
struct IEntity {BaseWorld* world=nullptr;CharacterControllerComponent* component=nullptr;bool conscious=true;vector origin;BaseWorld* GetWorld(){return world;}CharacterControllerComponent* FindComponent(int){return component;}vector GetOrigin(){return origin;}};
|
||||
namespace ERR_Actors {bool CanFight(IEntity* actor){return actor&&actor->conscious;}}
|
||||
struct PlayerController {int id=1;IEntity* actor=nullptr;int GetPlayerId(){return id;}IEntity* GetControlledEntity(){return actor;}};
|
||||
struct ERR_PlayerLife {UUID AccountId="private-account";};
|
||||
struct Roster {IEntity* actor=nullptr;ERR_PlayerLife* life=nullptr;ERR_PlayerLife* CurrentActor(IEntity* a){return a==actor?life:nullptr;}};
|
||||
struct Invoker {int inserted=0,removed=0;void Insert(){inserted++;}void Remove(){removed++;}};
|
||||
struct ERR_GameMode {static ERR_GameMode* current;BaseWorld* world=nullptr;bool active=true;int phase=ERR_Phase::PRISON;Roster roster;Invoker stop;static ERR_GameMode* Get(){return current;}BaseWorld* GetWorld(){return world;}bool Active(){return active;}int Phase(){return phase;}Roster* Players(){return &roster;}Invoker& OnStop(){return stop;}};ERR_GameMode* ERR_GameMode::current=nullptr;
|
||||
struct PlayerManager {PlayerController* controller=nullptr;IEntity* actor=nullptr;PlayerController* GetPlayerController(int){return controller;}IEntity* GetPlayerControlledEntity(int){return actor;}};
|
||||
struct Widget {bool visible=true;bool IsVisible(){return visible;}};
|
||||
struct SCR_InventoryMenuUI {static SCR_InventoryMenuUI* current;CharacterControllerComponent* character=nullptr;Widget* root=nullptr;static SCR_InventoryMenuUI* GetInventoryMenu(){return current;}CharacterControllerComponent* GetCharacterController(){return character;}Widget* GetRootWidget(){return root;}};SCR_InventoryMenuUI* SCR_InventoryMenuUI::current=nullptr;
|
||||
struct MenuManager {SCR_InventoryMenuUI* top=nullptr;SCR_InventoryMenuUI* GetTopMenu(){return top;}};
|
||||
struct InputManager {float forward=0;float GetActionValue(string name){assert(name=="CharacterForward");return forward;}};
|
||||
struct Callqueue {int queued=0,removed=0;void CallLater(){queued++;}void Remove(){removed++;}};
|
||||
struct Game {BaseWorld* world=nullptr;PlayerController* controller=nullptr;PlayerManager manager;MenuManager menus;InputManager input;Callqueue queue;BaseWorld* GetWorld(){return world;}PlayerController* GetPlayerController(){return controller;}PlayerManager* GetPlayerManager(){return &manager;}MenuManager* GetMenuManager(){return &menus;}InputManager* GetInputManager(){return &input;}Callqueue* GetCallqueue(){return &queue;}} game;Game* GetGame(){return &game;}
|
||||
std::vector<string> events;int samples=0;
|
||||
void PrintFormat(const char*,string nonce,int,int,float,string detail){assert(nonce=="Run42");events.push_back(detail);}
|
||||
void PrintFormat(const char*,string nonce,int,int,float,vector,float,vector,vector,bool){assert(nonce=="Run42");samples++;}
|
||||
struct ERR_OrdinaryInputObserverRules {
|
||||
'''+rules+r'''
|
||||
};
|
||||
struct ERR_OrdinaryInputObserver {
|
||||
static ERR_OrdinaryInputObserver* s_Request;static bool s_Attempted;
|
||||
ERR_GameMode* m_Mode=nullptr;BaseWorld* m_World=nullptr;PlayerController* m_Controller=nullptr;IEntity* m_Actor=nullptr;CharacterControllerComponent* m_Character=nullptr;ERR_PlayerLife* m_Life=nullptr;
|
||||
UUID m_Account;int m_PlayerId=0;string m_Nonce;bool m_Screenshots=false,m_Finished=false,m_Ready=false,m_Opened=false,m_Closed=false;SCR_InventoryMenuUI* m_Menu=nullptr;
|
||||
int m_Start=0,m_Previous=0,m_Samples=0,m_BaselineSince=-1;vector m_BaselineOrigin;float m_LastWorldTime=0;
|
||||
'''+methods+r'''
|
||||
};
|
||||
ERR_OrdinaryInputObserver* ERR_OrdinaryInputObserver::s_Request=nullptr;bool ERR_OrdinaryInputObserver::s_Attempted=false;
|
||||
struct Fixture {ERR_GameMode mode;BaseWorld world;PlayerController controller;IEntity actor;CharacterControllerComponent character;ERR_PlayerLife life;Widget widget;SCR_InventoryMenuUI menu;
|
||||
Fixture(){game={};System::cli={{"errOrdinaryScenario","core"},{"errOrdinaryInputObserve","Run42"},{"errOrdinaryInputScreenshots","1"}};System::now=0;System::console=false;System::shots.clear();Replication::server=true;events.clear();samples=0;ERR_OrdinaryInputObserver::s_Request=nullptr;ERR_OrdinaryInputObserver::s_Attempted=false;SCR_InventoryMenuUI::current=nullptr;ERR_GameMode::current=&mode;mode.world=&world;mode.roster.actor=&actor;mode.roster.life=&life;actor.world=&world;actor.component=&character;controller.actor=&actor;game.world=&world;game.controller=&controller;game.manager.controller=&controller;game.manager.actor=&actor;menu.character=&character;menu.root=&widget;}
|
||||
ERR_OrdinaryInputObserver* queue(){ERR_OrdinaryInputObserver::Queue(&mode,&controller,&actor);return ERR_OrdinaryInputObserver::s_Request;}
|
||||
void sample(int ms){System::now=ms;world.time=ms;ERR_OrdinaryInputObserver::s_Request->Sample();}
|
||||
void baseline(){for(int t=100;t<=2100;t+=100)sample(t);assert(ERR_OrdinaryInputObserver::s_Request->m_Ready);}
|
||||
void open(){SCR_InventoryMenuUI::current=&menu;game.menus.top=&menu;}
|
||||
void close(){SCR_InventoryMenuUI::current=nullptr;game.menus.top=nullptr;}
|
||||
};
|
||||
int count(string value){int n=0;for(auto event:events)if(event==value)n++;return n;}
|
||||
int main(){
|
||||
assert(ERR_OrdinaryInputObserverRules::Nonce("aZ09"));for(string n:std::vector<string>{"","../bad","a-b","a_b","line\n","a b","$logs:x",string(33,'x')})assert(!ERR_OrdinaryInputObserverRules::Nonce(n));
|
||||
assert(ERR_OrdinaryInputObserverRules::Within(0,44900,44999,449));assert(!ERR_OrdinaryInputObserverRules::Within(0,44900,45000,449));assert(!ERR_OrdinaryInputObserverRules::Within(0,100,99,2));assert(!ERR_OrdinaryInputObserverRules::Within(0,1,2,450));
|
||||
for(int fault=0;fault<6;fault++){Fixture f;
|
||||
if(fault==0)System::cli.erase("errOrdinaryInputObserve");
|
||||
if(fault==1)System::cli["errOrdinaryInputObserve"]="../bad";
|
||||
if(fault==2)System::cli["errOrdinaryScenario"]="other";
|
||||
if(fault==3)System::console=true;
|
||||
if(fault==4)Replication::server=false;
|
||||
if(fault==5)f.mode.roster.life=nullptr;
|
||||
assert(!f.queue()&&game.queue.queued==0&&!ERR_OrdinaryInputObserver::s_Attempted);}
|
||||
for(int fault=0;fault<14;fault++){Fixture f;auto p=f.queue();assert(p&&p->Valid());BaseWorld otherWorld;PlayerController otherController;IEntity otherActor;CharacterControllerComponent otherCharacter;ERR_PlayerLife otherLife;ERR_GameMode otherMode;
|
||||
if(fault==0)ERR_GameMode::current=&otherMode;
|
||||
if(fault==1)game.world=&otherWorld;
|
||||
if(fault==2)f.mode.active=false;
|
||||
if(fault==3)f.mode.phase=ERR_Phase::DEFEAT;
|
||||
if(fault==4)game.controller=&otherController;
|
||||
if(fault==5)f.controller.id=2;
|
||||
if(fault==6)game.manager.controller=&otherController;
|
||||
if(fault==7)game.manager.actor=&otherActor;
|
||||
if(fault==8)f.controller.actor=&otherActor;
|
||||
if(fault==9)f.actor.conscious=false;
|
||||
if(fault==10)f.actor.component=&otherCharacter;
|
||||
if(fault==11)f.character.unconscious=true;
|
||||
if(fault==12)f.mode.roster.life=&otherLife;
|
||||
if(fault==13)f.life.AccountId="changed";
|
||||
assert(!p->Valid());f.sample(100);assert(p->m_Finished&&samples==0&&game.queue.removed==1&&f.mode.stop.removed==1);p->Cancel();assert(game.queue.removed==1);}
|
||||
{Fixture f;auto p=f.queue();f.baseline();assert(count("READY")==1&&System::shots.size()==1);game.input.forward=1;f.character.input={0,0,1};f.actor.origin={0,0,1};f.sample(2200);assert(samples==22&&!p->m_Opened);game.input.forward=0;f.character.input={};f.character.disabled=true;f.open();f.sample(2300);assert(p->m_Opened&&count("INVENTORY_OPEN")==1&&System::shots.size()==2);f.sample(2400);assert(System::shots.size()==2);f.close();f.sample(2500);assert(p->m_Closed&&count("INVENTORY_CLOSED")==1&&System::shots.size()==3);f.sample(2600);assert(System::shots.size()==3);for(auto path:System::shots)assert(path.find("$logs:err-input-Run42-")==0);p->Cancel();int writes=samples;f.sample(2700);assert(samples==writes);ERR_OrdinaryInputObserver::ClearWorld();assert(ERR_OrdinaryInputObserver::s_Attempted&&!ERR_OrdinaryInputObserver::s_Request);assert(!f.queue());}
|
||||
{Fixture f;auto p=f.queue();System::cli["errOrdinaryInputScreenshots"]="0";p->m_Screenshots=false;f.open();for(int t=100;t<2300;t+=100)f.sample(t);assert(!p->m_Ready);f.close();for(int t=2300;t<=4300;t+=100)f.sample(t);assert(p->m_Ready&&System::shots.empty());}
|
||||
for(int fault=0;fault<4;fault++){Fixture f;auto p=f.queue();f.baseline();f.open();
|
||||
if(fault==0)f.menu.character=nullptr;
|
||||
if(fault==1)f.menu.root=nullptr;
|
||||
if(fault==2)f.widget.visible=false;
|
||||
if(fault==3)game.menus.top=nullptr;
|
||||
f.sample(2200);assert(p->m_Finished&&!p->m_Opened&&System::shots.size()==1);}
|
||||
{Fixture f;auto p=f.queue();f.baseline();f.open();f.sample(2200);SCR_InventoryMenuUI replacement=f.menu;SCR_InventoryMenuUI::current=&replacement;game.menus.top=&replacement;f.sample(2300);assert(p->m_Finished&&!p->m_Closed);}
|
||||
{Fixture f;auto p=f.queue();f.sample(100);f.sample(200);f.sample(1000);assert(p->m_BaselineSince==-1);f.sample(1100);assert(!p->m_Ready);f.sample(45000);assert(p->m_Finished&&count("FINISHED observation-bound")==1);}
|
||||
{Fixture f;auto p=f.queue();for(int t=1;t<=450;t++)f.sample(t);assert(p->m_Finished&&samples==450&&count("FINISHED sample-bound")==1);}
|
||||
{Fixture f;auto p=f.queue();f.sample(100);System::now=200;f.world.time=99;p->Sample();assert(p->m_Finished&&count("CANCELLED world-clock-reversed")==1);}
|
||||
{Fixture f;auto p=f.queue();f.sample(100);System::now=99;f.world.time=101;p->Sample();assert(p->m_Finished);}
|
||||
}
|
||||
''')
|
||||
s=P.read_text()
|
||||
for forbidden in ('SetActionValue(', 'ActivateContext(', 'OpenMenu(', 'CloseMenu(', 'OpenInventory(', 'CloseInventory(', 'SetCamera(', 'SetOrigin(', 'SetWorldTransform(', 'SetVelocity(', 'SetHealth(', 'SetUnconscious(', 'SetInvulnerable(', 'SpawnEntity'):
|
||||
assert forbidden not in s,forbidden
|
||||
assert s.startswith('#ifndef WORKBENCH')
|
||||
assert 's_Attempted = false' not in body(P,'static void ClearWorld(')
|
||||
assert s.count('System.MakeScreenshot(')==1
|
||||
for line in s.splitlines():
|
||||
if 'PrintFormat(' in line:assert all(x not in line for x in ('m_Account','m_PlayerId','m_Life','m_Controller','m_Actor'))
|
||||
print('PASS: actual observer nonce, admission pins, baseline/menu sequencing, snapshots, bounded clocks/samples, cleanup and one-attempt latch; native untested')
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Ordinary observer runner tests: fake process boundaries; never launch a game."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
|
||||
import headless_game as runner
|
||||
|
||||
NONCE='a1'*16
|
||||
|
||||
def records(nonce=NONCE, menus=False):
|
||||
def line(index,real,event):
|
||||
return f'SCRIPT : ERR INPUT OBSERVE: run={nonce} sample={index} real={1000+real} world={5000+real} {event}\n'
|
||||
result=line(0,0,'QUEUED')
|
||||
for i in range(1,450):
|
||||
opened=menus and 22<=i<=24
|
||||
result+=line(i,i*100,f'SAMPLE origin=<1, 2, 3> forward=0 input=<0, 0, 0> velocity=<0, 0, 0> inventory={int(opened)}')
|
||||
if i==21:result+=line(i,i*100,'READY')+line(i,i*100,'CAPTURE_REQUEST baseline')
|
||||
if menus and i==22:result+=line(i,i*100,'INVENTORY_OPEN')+line(i,i*100,'CAPTURE_REQUEST open')
|
||||
if menus and i==25:result+=line(i,i*100,'INVENTORY_CLOSED')+line(i,i*100,'CAPTURE_REQUEST closed')
|
||||
return result+line(449,45000,'FINISHED observation-bound')
|
||||
|
||||
class InputRunnerTests(unittest.TestCase):
|
||||
def test_default_and_explicit_command_scope(self):
|
||||
args=(runner.ROOT,Path('/steam'),Path('/compat'),Path('/profile'))
|
||||
for project in ('core','chat'):
|
||||
original=runner.command_for(*args,project)
|
||||
self.assertNotIn('-errOrdinaryInputObserve',original)
|
||||
command=runner.input_observer_command(original,project,None,NONCE)
|
||||
self.assertEqual(command[:-4],original)
|
||||
self.assertEqual(command[-4:],['-errOrdinaryInputObserve',NONCE,'-errOrdinaryInputScreenshots','1'])
|
||||
self.assertNotIn('-errOrdinaryInputObserve',original)
|
||||
for project,packed,nonce in ((runner.CONTROL,None,NONCE),('core',{},NONCE),('core',None,'../bad'),('core',None,''),('core',None,'x'*33)):
|
||||
with self.assertRaises(ValueError):runner.input_observer_command([],project,packed,nonce)
|
||||
with self.assertRaises(ValueError):runner.input_observer_command(['-errOrdinaryInputObserve',NONCE],'core',None,NONCE)
|
||||
|
||||
def test_refusal_before_any_launch_or_profile_work(self):
|
||||
for changes in ({'project':runner.CONTROL},{'packed_core':Path('/candidate')},{'packed_addons':Path('/candidate')},{'settle':49}):
|
||||
args=SimpleNamespace(project='core',observe_player_input=True,packed_core=None,packed_addons=None,settle=50)
|
||||
for key,value in changes.items():setattr(args,key,value)
|
||||
with patch.object(runner,'packed_request',side_effect=AssertionError('early refusal required')):
|
||||
with self.assertRaises(ValueError):runner.run(args)
|
||||
runner.validate_input_observer(SimpleNamespace(observe_player_input=False))
|
||||
runner.validate_input_observer(SimpleNamespace(observe_player_input=True,project='chat',settle=50))
|
||||
|
||||
def test_strict_complete_sampler_is_not_input_success(self):
|
||||
evidence=runner.input_observer_evidence(records(),NONCE)
|
||||
self.assertTrue(evidence['sampler_complete'],evidence)
|
||||
self.assertEqual(evidence['sample_count'],449)
|
||||
self.assertFalse(evidence['input_sent'])
|
||||
self.assertFalse(evidence['inventory_open_observed'])
|
||||
self.assertNotIn('movement_success',evidence)
|
||||
menu=runner.input_observer_evidence(records(menus=True),NONCE)
|
||||
self.assertTrue(menu['sampler_complete'],menu)
|
||||
self.assertTrue(menu['inventory_open_observed'] and menu['inventory_close_observed'])
|
||||
self.assertEqual(menu['capture_requests'],['baseline','open','closed'])
|
||||
|
||||
def test_malformed_missing_reordered_unbounded_and_foreign_records_refused(self):
|
||||
good=records()
|
||||
cases=['',good.replace(' QUEUED',' WRONG'),good.replace(' READY',' MISSING'),good.replace(' FINISHED observation-bound',' CANCELLED original-admission-lost'),
|
||||
good.replace(NONCE,'b'*32,1),good.replace('sample=2 real=1200','sample=3 real=1200',1),good.replace('real=1200','real=1000',1),
|
||||
good.replace('world=5200','world=4900',1),good.replace('origin=<1, 2, 3>','origin=<NaN, 2, 3>',1),good.replace('forward=0','forward=1e999',1),
|
||||
good.replace('real=46000 world=50000','real=45999 world=50000'),good.replace('sample=449 real=46000','sample=450 real=46000'),
|
||||
good.replace('FINISHED observation-bound','FINISHED sample-bound'),good+good.splitlines()[0]+'\n',good.replace('inventory=0','inventory=unknown',1),
|
||||
good.replace('real=1200','real=46000',1),good.replace('real=3100 world=7100 READY','real=3000 world=7100 READY')]
|
||||
for candidate in cases:
|
||||
with self.subTest(candidate=candidate[-150:]):self.assertFalse(runner.input_observer_evidence(candidate,NONCE)['sampler_complete'])
|
||||
self.assertFalse(runner.input_observer_evidence(good*2,NONCE)['sampler_complete'])
|
||||
self.assertFalse(runner.input_observer_evidence(good,'../bad')['sampler_complete'])
|
||||
|
||||
def test_transition_and_capture_cannot_manufacture_inventory_observation(self):
|
||||
good=records(menus=True)
|
||||
for bad in (good.replace('INVENTORY_OPEN','INVENTORY_CLOSED'),good.replace('inventory=1','inventory=0'),good.replace('CAPTURE_REQUEST open','CAPTURE_REQUEST baseline')):
|
||||
self.assertFalse(runner.input_observer_evidence(bad,NONCE)['sampler_complete'])
|
||||
|
||||
def test_prior_failure_results_are_never_replaced(self):
|
||||
for previous in ('game-failed','cleanup-failed','source-changed','display-probe-incomplete','timeout','process-exited'):
|
||||
for content in ('',records()):self.assertEqual(runner.input_observer_result(previous,content,NONCE)[0],previous)
|
||||
self.assertEqual(runner.input_observer_result('game-started','',NONCE)[0],'observation-incomplete')
|
||||
self.assertEqual(runner.input_observer_result('game-started',records(),NONCE)[0],'game-started')
|
||||
|
||||
def simulate(self,observe,content,display_ok=True,native_failed=False):
|
||||
with tempfile.TemporaryDirectory(prefix='erri-input-runner-test-') as tmp:
|
||||
root=Path(tmp);steam=root/'steam';compat=root/'compat';profile=root/'profile'
|
||||
exe=steam/'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe';proton=steam/'steamapps/common/Proton - Experimental/proton'
|
||||
for path in (exe,proton):path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(b'fake never launched')
|
||||
(exe.parent/'addons/data').mkdir(parents=True)
|
||||
session=profile/'logs/logs_fixture';child=Mock();child.poll.return_value=None
|
||||
def spawn(*args,**kwargs):session.mkdir(parents=True);(session/'console.log').write_text(content);return child
|
||||
def wrap(command,*args):
|
||||
if observe:self.assertEqual(command[-4:],['-errOrdinaryInputObserve',NONCE,'-errOrdinaryInputScreenshots','1'])
|
||||
return command,{'fake':True}
|
||||
args=SimpleNamespace(project='core',capture_prison_view=False,inspect_headless_display=True,observe_player_input=observe,timeout=180,settle=50,failure_grace=0)
|
||||
monitor=SimpleNamespace(deadline=time.monotonic()+60,started=0,first_failure_at=None,observe=lambda *args:'game-started',annotate=lambda x:x)
|
||||
captured=[]
|
||||
def snapshot(root,command):captured.append(command.copy());return {}
|
||||
with contextlib.ExitStack() as stack:
|
||||
for name in ('assert_no_game','assert_prefix_idle','persist','cleanup'):stack.enter_context(patch.object(runner,name))
|
||||
stack.enter_context(patch.object(runner.uuid,'uuid4',return_value=SimpleNamespace(hex=NONCE)))
|
||||
stack.enter_context(patch.object(runner,'snapshot',side_effect=snapshot))
|
||||
stack.enter_context(patch.object(runner,'bindings',return_value={}))
|
||||
stack.enter_context(patch.object(runner,'seal_run',return_value={'source_unchanged_after_run':True}))
|
||||
stack.enter_context(patch.object(runner,'StartupMonitor',return_value=monitor))
|
||||
stack.enter_context(patch.object(runner,'game_evidence',return_value={'ready':not native_failed,'failed':native_failed}))
|
||||
stack.enter_context(patch.object(runner,'display_probe_command',side_effect=wrap))
|
||||
stack.enter_context(patch.object(runner,'display_probe_result',return_value={'verified':display_ok}))
|
||||
stack.enter_context(patch.object(runner.shutil,'which',return_value='/fake'))
|
||||
stack.enter_context(patch.object(runner.subprocess,'Popen',side_effect=spawn))
|
||||
stack.enter_context(patch.object(runner.subprocess,'check_output',side_effect=lambda args,**kw:'[]' if '-f' in args else '11'))
|
||||
stack.enter_context(patch.object(runner.subprocess,'run'))
|
||||
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
||||
status=runner.run_locked(args,steam,compat,profile)
|
||||
return status,json.loads((session/'runner.json').read_text()),json.loads((session/'game-startup-report.json').read_text()),captured[0]
|
||||
|
||||
def test_actual_report_flow_default_success_missing_and_prior_failure(self):
|
||||
status,report,final,command=self.simulate(False,'')
|
||||
self.assertEqual(status,0);self.assertNotIn('input_observation',report);self.assertNotIn('-errOrdinaryInputObserve',command)
|
||||
for content,display_ok,native_failed,result in ((records(),True,False,'game-started'),('',True,False,'observation-incomplete'),('',False,False,'display-probe-incomplete'),(records(),True,True,'game-failed')):
|
||||
status,report,final,command=self.simulate(True,content,display_ok,native_failed)
|
||||
self.assertEqual(report['result'],result)
|
||||
self.assertEqual(status,0 if result=='game-started' else 1)
|
||||
self.assertEqual(report['input_observation'],final['input_observation'])
|
||||
self.assertEqual(report['input_observation']['nonce'],NONCE)
|
||||
self.assertIn(NONCE,command)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Execute loading presentation/cleanup and native observation bodies with UI adapters.
|
||||
No native layout rendering, multiplayer, or real startup-failure claim.
|
||||
"""
|
||||
from policy_harness import ROOT, body, run
|
||||
SOURCE=ROOT/'addons/core/Scripts/Game/ERR/ERR_PreparationStatus.c'
|
||||
def cpp(s):
|
||||
s=s.replace('protected ','').replace('override ','').replace('null','nullptr')
|
||||
s=s.replace('ERR_GameMode mode = ERR_GameMode.Get();','ERR_GameMode* mode = ERR_GameMode::Get();')
|
||||
s=s.replace('ERR_GameMode mode,','ERR_GameMode* mode,')
|
||||
for name in ['System','ERR_GameMode','ERR_Phase','ERR_ObjectiveText']:s=s.replace(name+'.',name+'::')
|
||||
s=s.replace('TextWidget.Cast(','CastText(').replace('Widget widget)', 'Widget* widget)').replace('widget.', 'widget->')
|
||||
for name in ['mode','m_wLoadingPlaceholder','m_ERRPreparationSpinner','m_ERRPreparationRoot','m_ERRPreparationTitle','m_ERRPreparationDescription']:
|
||||
s=s.replace(name+'.',name+'->')
|
||||
s=s.replace('GetGame().','GetGame()->').replace('GetWorkspace().','GetWorkspace()->').replace('.IsEmpty()','.empty()')
|
||||
return s.replace('super.','Native::')
|
||||
methods='\n'.join(cpp(body(SOURCE,key)) for key in ('protected void ERR_ClearPreparationStatus(', 'override void UpdateLoadingPlaceholder(', 'override void DestroyLoadingPlaceholder(', 'protected bool ERR_PreparationWidgetVisible(', 'bool ERR_ProbePreparationVisible('))
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <map>
|
||||
using string=std::string;
|
||||
namespace System{bool console=false;bool IsConsoleApp(){return console;}}
|
||||
namespace ERR_Phase{enum{PREPARING,PRISON};}
|
||||
struct ERR_GameMode{static ERR_GameMode* current;int phase=ERR_Phase::PREPARING;string error;static ERR_GameMode* Get(){return current;}int Phase(){return phase;}string PreparationError(){return error;}};ERR_GameMode* ERR_GameMode::current=nullptr;
|
||||
namespace ERR_ObjectiveText{string Description(ERR_GameMode* mode,bool){return mode->error.empty()?"Preparing sites":"Mission failed; ask an administrator to correct and restart";}}
|
||||
struct Widget{Widget* parent=nullptr;bool visible=true,removed=false,textWidget=false;string text;int textWrites=0,visibilityWrites=0;float width=900,height=170;std::map<string,Widget*> children;
|
||||
void GetScreenSize(float& w,float& h){w=width;h=height;}Widget* GetParent(){return parent;}void RemoveFromHierarchy(){removed=true;parent=nullptr;}void SetVisible(bool value){visible=value;visibilityWrites++;}bool IsVisible(){return visible;}
|
||||
Widget* FindAnyWidget(string name){return children.count(name)?children[name]:nullptr;}void SetText(string value){text=value;textWrites++;}string GetText(){return text;}};
|
||||
Widget* CastText(Widget* w){return w&&w->textWidget?w:nullptr;}
|
||||
struct Workspace{int creates=0;bool fail=false,missingText=false;Widget* CreateWidgets(const char*,Widget* parent){creates++;if(fail)return nullptr;auto root=new Widget;root->parent=parent;for(string name:{"ERR_PreparationTitle","ERR_PreparationDescription"}){auto child=new Widget;child->parent=root;child->textWidget=true;root->children[name]=child;}if(missingText)root->children.erase("ERR_PreparationTitle");return root;}};
|
||||
struct Game{Workspace workspace;Workspace* GetWorkspace(){return &workspace;}};Game game;Game* GetGame(){return &game;}
|
||||
struct Native{Widget* m_wLoadingPlaceholder=nullptr;ERR_GameMode* m_pGameMode=nullptr;int nativeUpdates=0,destroys=0;void UpdateLoadingPlaceholder(float){nativeUpdates++;}void DestroyLoadingPlaceholder(){destroys++;if(m_wLoadingPlaceholder)m_wLoadingPlaceholder->RemoveFromHierarchy();m_wLoadingPlaceholder=nullptr;}};
|
||||
struct Probe:Native{
|
||||
Widget *m_ERRPreparationParent=nullptr,*m_ERRPreparationRoot=nullptr,*m_ERRPreparationTitle=nullptr,*m_ERRPreparationDescription=nullptr,*m_ERRPreparationSpinner=nullptr;
|
||||
bool m_ERRPreparationSpinnerHidden=false,m_ERRPreparationSpinnerWasVisible=false;string m_ERRPreparationLastTitle,m_ERRPreparationLastDescription;
|
||||
'''+methods+r'''
|
||||
};
|
||||
struct Fixture{ERR_GameMode mode;Widget parent,spinner;Probe p;Fixture(){game.workspace={};System::console=false;ERR_GameMode::current=&mode;p.m_pGameMode=&mode;p.m_wLoadingPlaceholder=&parent;parent.children["Spinner"]=&spinner;spinner.parent=&parent;}};
|
||||
int main(){
|
||||
{Fixture f;f.p.UpdateLoadingPlaceholder(.1f);assert(f.p.nativeUpdates==1&&game.workspace.creates==1&&f.p.ERR_ProbePreparationVisible(&f.mode,false));
|
||||
Widget* root=f.p.m_ERRPreparationRoot;auto title=f.p.m_ERRPreparationTitle;auto description=f.p.m_ERRPreparationDescription;
|
||||
for(int i=0;i<200;i++){f.p.UpdateLoadingPlaceholder(.1f);}assert(game.workspace.creates==1&&title->textWrites==1&&description->textWrites==1);
|
||||
f.mode.error="invalid catalog";f.p.UpdateLoadingPlaceholder(.1f);assert(f.p.ERR_ProbePreparationVisible(&f.mode,true)&&!f.spinner.visible);
|
||||
for(int i=0;i<200;i++){f.p.UpdateLoadingPlaceholder(.1f);}assert(f.p.m_ERRPreparationRoot==root&&title->textWrites==2&&description->textWrites==2&&f.spinner.visibilityWrites==1);
|
||||
title->width=0;assert(!f.p.ERR_ProbePreparationVisible(&f.mode,true));title->width=900;f.mode.error.clear();f.p.UpdateLoadingPlaceholder(.1f);assert(f.spinner.visible&&f.p.ERR_ProbePreparationVisible(&f.mode,false));
|
||||
f.mode.phase=ERR_Phase::PRISON;f.p.UpdateLoadingPlaceholder(.1f);assert(root->removed&&!f.parent.removed&&f.spinner.visible&&!f.p.m_ERRPreparationRoot);
|
||||
}
|
||||
for(int mismatch=0;mismatch<6;mismatch++){Fixture f;ERR_GameMode other;if(mismatch==0)ERR_GameMode::current=nullptr;if(mismatch==1)f.p.m_pGameMode=&other;if(mismatch==2)f.mode.phase=ERR_Phase::PRISON;if(mismatch==3)System::console=true;if(mismatch==4)f.p.m_wLoadingPlaceholder=nullptr;if(mismatch==5)f.p.m_pGameMode=nullptr;f.p.UpdateLoadingPlaceholder(.1f);assert(game.workspace.creates==0&&f.p.nativeUpdates==1&&!f.parent.removed&&f.spinner.visibilityWrites==0);}
|
||||
{Fixture f;f.mode.error="bad";f.spinner.visible=false;f.p.UpdateLoadingPlaceholder(.1f);assert(f.p.ERR_ProbePreparationVisible(&f.mode,true));f.p.ERR_ClearPreparationStatus();assert(!f.spinner.visible);}
|
||||
{Fixture f;f.mode.error="bad";f.p.UpdateLoadingPlaceholder(.1f);Widget* old=f.p.m_ERRPreparationRoot;Widget newParent,newSpinner;newParent.children["Spinner"]=&newSpinner;newSpinner.parent=&newParent;f.p.m_wLoadingPlaceholder=&newParent;f.p.UpdateLoadingPlaceholder(.1f);assert(old->removed&&f.spinner.visible&&!newSpinner.visible&&f.p.m_ERRPreparationParent==&newParent&&game.workspace.creates==2);f.p.DestroyLoadingPlaceholder();assert(newParent.removed&&!f.parent.removed&&newSpinner.visible&&!f.p.m_ERRPreparationRoot&&f.p.destroys==1);}
|
||||
{Fixture f;f.mode.error="bad";f.p.UpdateLoadingPlaceholder(.1f);f.p.m_ERRPreparationParent=nullptr;f.p.m_ERRPreparationRoot=nullptr;f.p.m_ERRPreparationTitle=nullptr;f.p.m_ERRPreparationDescription=nullptr;f.p.m_ERRPreparationSpinner=nullptr;f.p.m_wLoadingPlaceholder=nullptr;f.p.UpdateLoadingPlaceholder(.1f);assert(!f.p.m_ERRPreparationSpinnerHidden);}
|
||||
{Fixture f;game.workspace.fail=true;f.p.UpdateLoadingPlaceholder(.1f);assert(!f.p.m_ERRPreparationRoot&&f.spinner.visibilityWrites==0);game.workspace.fail=false;f.p.UpdateLoadingPlaceholder(.1f);assert(f.p.ERR_ProbePreparationVisible(&f.mode,false));}
|
||||
{Fixture f;game.workspace.missingText=true;f.p.UpdateLoadingPlaceholder(.1f);assert(!f.p.m_ERRPreparationRoot&&f.spinner.visibilityWrites==0);}
|
||||
{Fixture f;f.p.UpdateLoadingPlaceholder(.1f);Widget other;Widget* moved=f.p.m_ERRPreparationRoot;moved->parent=&other;f.p.UpdateLoadingPlaceholder(.1f);assert(moved->removed&&f.p.m_ERRPreparationRoot!=moved&&f.p.m_ERRPreparationParent==&f.parent);}
|
||||
}
|
||||
''')
|
||||
probe=ROOT/'addons/core/Scripts/Game/ERR/ERR_NativePreparationStatusProbe.c'
|
||||
s=body(probe,'static int Tick(').replace('ERR_GameMode mode,','ERR_GameMode* mode,').replace('ERR_GameMode.Get()','ERR_GameMode::Get()').replace('ERR_Phase.PREPARING','PREPARING').replace('mode.','mode->').replace('.IsEmpty()','.empty()').replace('SCR_RespawnSystemComponent respawn','SCR_RespawnSystemComponent* respawn').replace('SCR_RespawnSystemComponent.GetInstance()','SCR_RespawnSystemComponent::GetInstance()').replace('respawn.','respawn->').replace('System.MakeScreenshot','System::MakeScreenshot')
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
using string=std::string;const int PREPARING=0;
|
||||
struct ERR_GameMode{static ERR_GameMode* current;int phase=0;string error="bad";static ERR_GameMode* Get(){return current;}int Phase(){return phase;}string PreparationError(){return error;}};ERR_GameMode* ERR_GameMode::current=nullptr;
|
||||
struct SCR_RespawnSystemComponent{bool visible=true;static SCR_RespawnSystemComponent instance;static SCR_RespawnSystemComponent* GetInstance(){return &instance;}bool ERR_ProbePreparationVisible(ERR_GameMode*,bool failed){assert(failed);return visible;}};SCR_RespawnSystemComponent SCR_RespawnSystemComponent::instance;
|
||||
namespace System{int captures=0;void MakeScreenshot(const char*){captures++;}}void Print(const char*){}
|
||||
struct Probe{static ERR_GameMode* s_Mode;static float s_Deadline,s_Since,s_Last,s_CaptureAt;
|
||||
'''+s+r'''
|
||||
};ERR_GameMode* Probe::s_Mode=nullptr;float Probe::s_Deadline=0,Probe::s_Since=-1,Probe::s_Last=-1,Probe::s_CaptureAt=-1;
|
||||
int main(){ERR_GameMode mode;ERR_GameMode::current=&mode;
|
||||
for(int i=0;i<=33;i++){assert(Probe::Tick(&mode,i*500)==0);}assert(System::captures==0);assert(Probe::Tick(&mode,17000)==0&&System::captures==1);assert(Probe::Tick(&mode,17500)==1&&System::captures==1);
|
||||
ERR_GameMode next;ERR_GameMode::current=&next;assert(Probe::Tick(&next,20000)==0);assert(Probe::Tick(&next,22000)==0&&Probe::s_Since==22000);SCR_RespawnSystemComponent::instance.visible=false;assert(Probe::Tick(&next,22500)==0&&Probe::s_Since==-1);assert(Probe::Tick(&next,50000)==-1);
|
||||
}
|
||||
''')
|
||||
text=SOURCE.read_text()
|
||||
assert 'GetOwner(' not in text
|
||||
assert text.count('m_pGameMode != mode') == 2
|
||||
# m_pGameMode is the installed vanilla protected owner field, assigned by
|
||||
# SCR_RespawnSystemComponent.OnInit(IEntity owner). The adapter deliberately
|
||||
# exposes that field rather than inventing a ScriptComponent.GetOwner API.
|
||||
for forbidden in ('CallLater(', 'SetCamera(', 'SpawnEntity', 'SetControlledEntity(', 'Transition(', 'PreparationError() ='):
|
||||
assert forbidden not in text,forbidden
|
||||
native=probe.read_text()
|
||||
for forbidden in ('CreateWidgets(', 'UpdateLoadingPlaceholder(', 'SetText(', 'SetVisible('):assert forbidden not in native
|
||||
base=(ROOT/'addons/core/Scripts/Game/ERR/ERR_NativeBaseSmoke.c').read_text()
|
||||
assert 'ERR_NativePreparationStatusProbe.Tick(this, now)' in base
|
||||
assert 'native-end-active' in base and 'native-end-preparing' in base
|
||||
print('PASS: actual preparation status scope, persistent updates, spinner restoration, placeholder replacement/deletion/destruction, native observation persistence/timeout; rendering remains native-pending')
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Fake process/pidfd tests only. Never invoke native cleanup or send a signal."""
|
||||
import dataclasses
|
||||
import importlib.util
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('private_prefix_cleanup',ROOT/'tools/private_prefix_cleanup.py')
|
||||
m=importlib.util.module_from_spec(spec);sys.modules[spec.name]=m;spec.loader.exec_module(m)
|
||||
UID=1000
|
||||
EXE='/installed/Proton/files/lib/wine/i386-unix/wine64-preloader'
|
||||
INODE=(2,44)
|
||||
PARENT=m.Process(1670,UID,1,100,'S','systemd',m.SYSTEMD_ARGV,None,None)
|
||||
def proc(pid=10,**changes):
|
||||
p=m.Process(pid,UID,PARENT.pid,1000+pid,'S','winedevice.exe',m.DEVICE_ARGV,EXE,INODE,m.PREFIX)
|
||||
return dataclasses.replace(p,**changes)
|
||||
class Inspector:
|
||||
def __init__(self,processes=()):self.processes={p.pid:p for p in processes};self.parent=PARENT;self.reads=0
|
||||
def devices(self):
|
||||
self.reads+=1;result=[]
|
||||
for p in self.processes.values():
|
||||
if p.state in {'Z','X'} or m.prefix_kind(p.prefix)=='other':continue
|
||||
if m.prefix_kind(p.prefix)=='alias':raise m.CleanupRefused('ambiguous private-prefix spelling')
|
||||
result.append(m.prove_device(p,self.parent,UID,EXE,INODE))
|
||||
return result
|
||||
class FakePidfds:
|
||||
def __init__(self,inspector,term_exits=True,kill_exits=True):
|
||||
self.inspector=inspector;self.term_exits=term_exits;self.kill_exits=kill_exits;self.opened={};self.closed=[];self.sent=[];self.waits=[];self.on_open=None;self.on_send=None
|
||||
def open(self,pid):
|
||||
if pid not in self.inspector.processes:raise ProcessLookupError()
|
||||
fd=pid+1000;self.opened[fd]=self.inspector.processes[pid]
|
||||
if self.on_open:self.on_open(pid)
|
||||
return fd
|
||||
def close(self,fd):self.closed.append(fd)
|
||||
def send(self,fd,sig):
|
||||
original=self.opened[fd];self.sent.append((original.pid,sig))
|
||||
if self.on_send:self.on_send(original.pid,sig)
|
||||
if (sig==signal.SIGTERM and self.term_exits) or (sig==signal.SIGKILL and self.kill_exits):
|
||||
current=self.inspector.processes.get(original.pid)
|
||||
if current and current.start==original.start:self.inspector.processes.pop(original.pid)
|
||||
def wait(self,handles,timeout):
|
||||
self.waits.append(timeout);finished=set()
|
||||
for pid,fd in handles.items():
|
||||
current=self.inspector.processes.get(pid);original=self.opened[fd]
|
||||
if not current or current.start!=original.start or current.state in {'Z','X'}:finished.add(pid)
|
||||
return finished
|
||||
|
||||
class PrivatePrefixCleanup(unittest.TestCase):
|
||||
def test_exact_prefix_and_observed_device(self):
|
||||
self.assertEqual(m.DEVICE_ARGV[0].hex(),'433a5c77696e646f77735c73797374656d33325c77696e656465766963652e657865')
|
||||
for suffix in (b'',b'/',b'//'):
|
||||
p=proc(prefix=m.PREFIX+suffix);self.assertEqual(m.prefix_kind(p.prefix),'exact');self.assertEqual(m.prove_device(p,PARENT,UID,EXE,INODE).process.pid,10)
|
||||
for other in (None,b'/other/pfx',m.PREFIX+b'-sibling',b'/tmp/another/pfx'):
|
||||
self.assertEqual(m.prefix_kind(other),'other')
|
||||
self.assertEqual(m.prefix_kind(m.PREFIX+b'/../pfx'),'alias')
|
||||
def test_identity_and_parent_refusals(self):
|
||||
mutations=[{'uid':UID+1},{'state':'Z'},{'argv':(*m.DEVICE_ARGV,b'extra')},{'argv':(b'Z:\\winedevice.exe',)},{'comm':'winedbg.exe'},{'exe':'/untrusted/wine64-preloader'},{'executable_id':(2,45)},{'prefix':b'/other/pfx'}]
|
||||
for mutation in mutations:
|
||||
with self.subTest(mutation=mutation),self.assertRaises(m.CleanupRefused):m.prove_device(proc(**mutation),PARENT,UID,EXE,INODE)
|
||||
for mutation in ({'uid':UID+1},{'pid':3},{'ppid':2},{'comm':'bash'},{'argv':(b'/usr/lib/systemd/systemd',)},{'exe':'/tmp/systemd'},{'state':'Z'}):
|
||||
with self.subTest(parent=mutation),self.assertRaises(m.CleanupRefused):m.prove_device(proc(),dataclasses.replace(PARENT,**mutation),UID,EXE,INODE)
|
||||
self.assertTrue(m.prove_device(proc(),dataclasses.replace(PARENT,exe='/usr/lib/systemd/systemd'),UID,EXE,INODE))
|
||||
def test_clean_term_ignores_other_prefix(self):
|
||||
i=Inspector([proc(10),proc(11,prefix=m.PREFIX+b'/'),proc(12,prefix=b'/other/pfx')]);f=FakePidfds(i)
|
||||
result=m._cleanup(i,f);self.assertTrue(result['cleared']);self.assertEqual(result['term_sent'],[10,11]);self.assertEqual(result['kill_sent'],[])
|
||||
self.assertEqual(set(i.processes),{12});self.assertEqual(set(f.closed),set(f.opened));self.assertEqual(max(f.waits),3.0)
|
||||
self.assertNotIn('argv',str(result));self.assertNotIn('environment',str(result))
|
||||
def test_other_live_private_process_refuses_before_open_or_signal(self):
|
||||
for other in (proc(11,comm='ArmaReforger',argv=(b'ArmaReforger.exe',)),proc(11,ppid=42),proc(11,prefix=m.PREFIX+b'/../pfx')):
|
||||
i=Inspector([proc(),other]);f=FakePidfds(i)
|
||||
with self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
|
||||
self.assertEqual(f.opened,{});self.assertEqual(f.sent,[])
|
||||
def test_pid_reuse_after_open_refuses(self):
|
||||
i=Inspector([proc()]);f=FakePidfds(i)
|
||||
f.on_open=lambda pid:i.processes.update({pid:proc(pid,start=8888)})
|
||||
with self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
|
||||
self.assertEqual(f.sent,[]);self.assertEqual(f.closed,[1010])
|
||||
def test_parent_reuse_or_role_change_refuses(self):
|
||||
for change in ('start','role','prefix','exe'):
|
||||
i=Inspector([proc()]);f=FakePidfds(i)
|
||||
def mutate(pid):
|
||||
if change=='start':i.parent=dataclasses.replace(PARENT,start=999)
|
||||
elif change=='role':i.processes[pid]=proc(pid,argv=(b'winedbg.exe',))
|
||||
elif change=='prefix':i.processes[pid]=proc(pid,prefix=b'/other/pfx')
|
||||
else:i.processes[pid]=proc(pid,executable_id=(9,9))
|
||||
f.on_open=mutate
|
||||
with self.subTest(change=change),self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
|
||||
self.assertEqual(f.sent,[])
|
||||
def test_bounded_kill_uses_same_pinned_handles(self):
|
||||
i=Inspector([proc(10),proc(11)]);f=FakePidfds(i,term_exits=False)
|
||||
result=m._cleanup(i,f)
|
||||
self.assertEqual(f.sent,[(10,signal.SIGTERM),(11,signal.SIGTERM),(10,signal.SIGKILL),(11,signal.SIGKILL)])
|
||||
self.assertTrue(result['cleared']);self.assertEqual(result['kill_sent'],[10,11]);self.assertEqual(f.waits.count(3.0),1);self.assertEqual(f.waits.count(1.0),1)
|
||||
def test_new_active_process_after_partial_term_preserved(self):
|
||||
i=Inspector([proc(10),proc(11)]);f=FakePidfds(i)
|
||||
def new_process(pid,sig):
|
||||
if pid==10:i.processes[99]=proc(99,comm='active-game',argv=(b'game.exe',))
|
||||
f.on_send=new_process
|
||||
with self.assertRaises(m.CleanupRefused) as failure:m._cleanup(i,f)
|
||||
self.assertEqual(f.sent,[(10,signal.SIGTERM)]);self.assertEqual(failure.exception.report['term_sent'],[10]);self.assertFalse(failure.exception.report['cleared']);self.assertIn(11,i.processes);self.assertIn(99,i.processes)
|
||||
def test_partial_cleanup_failure_has_no_broad_fallback(self):
|
||||
i=Inspector([proc()]);f=FakePidfds(i,term_exits=False,kill_exits=False)
|
||||
with self.assertRaises(m.CleanupRefused) as failure:m._cleanup(i,f)
|
||||
self.assertEqual(failure.exception.report['kill_sent'],[10]);self.assertFalse(failure.exception.report['cleared']);self.assertEqual(f.closed,[1010]);self.assertEqual(len(f.sent),2)
|
||||
def test_new_adopted_helper_not_in_snapshot_refused(self):
|
||||
i=Inspector([proc()]);f=FakePidfds(i);f.on_open=lambda pid:i.processes.update({11:proc(11)})
|
||||
with self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
|
||||
self.assertEqual(f.sent,[])
|
||||
def test_production_scope_rejects_other_prefix_before_backend(self):
|
||||
with patch.object(m,'Pidfds',side_effect=AssertionError('No signal backend permitted')):
|
||||
for other in ('/tmp/other','/home/user/.wine','/tmp/erri-headless-compat/../other'):
|
||||
with self.subTest(other=other),self.assertRaises(m.CleanupRefused):m.cleanup_private_prefix(other,'/installed/proton')
|
||||
def test_exited_zombies_are_not_signalled(self):
|
||||
i=Inspector([proc(state='Z')]);f=FakePidfds(i);r=m._cleanup(i,f);self.assertTrue(r['cleared']);self.assertEqual(f.opened,{});self.assertEqual(f.sent,[])
|
||||
def test_metadata_permissions_do_not_hide_runtime_candidates(self):
|
||||
self.assertTrue(m.native_metadata_proves_unrelated(PARENT))
|
||||
self.assertFalse(m.native_metadata_proves_unrelated(proc(exe=None,executable_id=None)))
|
||||
self.assertFalse(m.native_metadata_proves_unrelated(proc(comm='python',argv=(b'/usr/bin/python',b'/runtime/proton'),exe='/usr/bin/python')))
|
||||
def test_observed_nondumpable_native_services_are_unrelated(self):
|
||||
agent=proc(comm='ssh-agent',argv=(b'/usr/bin/ssh-agent',b'-D',b'-a',f'/run/user/{UID}/ssh-agent.socket'.encode()),exe=None,executable_id=None)
|
||||
self.assertTrue(m.native_metadata_proves_unrelated(agent))
|
||||
for comm,argv in [('kwin_wayland',(b'/usr/bin/kwin_wayland',)),('polkit-kde-auth',(b'/usr/lib/polkit-kde-authentication-agent-1',)),('apache2',(b'apache2',b'-DFOREGROUND'))]:
|
||||
self.assertTrue(m.native_metadata_proves_unrelated(dataclasses.replace(agent,comm=comm,argv=argv)))
|
||||
for mutation in ({'comm':'winedevice.exe'},{'argv':agent.argv+(b'proton',)},{'argv':(b'/usr/bin/something-else',)},{'argv':(b'ssh-agent',)},{'comm':'apache2','argv':(b'apache2',b'-unknown')},{'comm':'wine64-preloade','argv':(b'/runtime/wine64-preloader',)}):
|
||||
self.assertFalse(m.native_metadata_proves_unrelated(dataclasses.replace(agent,**mutation)))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Run actual shared objective selection and map grid methods; no native UI claim."""
|
||||
from policy_harness import ROOT, body, run
|
||||
|
||||
source = ROOT / 'addons/core/Scripts/Game/ERR/ERR_ObjectiveText.c'
|
||||
s = body(source, 'protected static string PhaseDescription(')
|
||||
s = s.replace('protected ', '').replace('ERR_GameMode mode,', 'ERR_GameMode* mode,')
|
||||
s = s.replace('ERR_Phase phase', 'int phase').replace('mode.', 'mode->')
|
||||
s = s.replace('array<ERR_LocationComponent>', 'std::vector<ERR_LocationComponent*>')
|
||||
s = s.replace('foreach (ERR_LocationComponent location : locations)', 'for (ERR_LocationComponent* location : locations)')
|
||||
s = s.replace('location.', 'location->').replace('null', 'nullptr')
|
||||
s = s.replace('bool roughRadio;', 'bool roughRadio = false;') # Enforce default initialization
|
||||
s = s.replace('.IsEmpty()', '.empty()').replace('string.Format(', 'Format(')
|
||||
for scope in ('ERR_Phase', 'ERR_LocationKind', 'Math'):
|
||||
s = s.replace(scope + '.', scope + '::')
|
||||
coordinate = body(ROOT / 'addons/core/Scripts/Game/ERR/ERR_DiscoveryMap.c', 'static int Coordinate(').replace('Math.', 'Math::')
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
using string = std::string;
|
||||
namespace ERR_Phase {enum {PREPARING,PRISON,RADIO_SEARCH,RADIO_DEFENCE,EXTRACTION,VICTORY,DEFEAT};}
|
||||
namespace ERR_LocationKind {enum {PRISON,RADIO,EXTRACTION,OUTPOST,DEPOT};}
|
||||
namespace Math {float Ceil(float v){return std::ceil(v);}float Floor(float v){return std::floor(v);}float Round(float v){return std::round(v);}}
|
||||
struct ERR_LocationComponent {int kind,knowledge;int Kind(){return kind;}int Knowledge(){return knowledge;}};
|
||||
struct ERR_GameMode {
|
||||
int phase=ERR_Phase::RADIO_SEARCH;string error;bool occupied=false;
|
||||
std::vector<ERR_LocationComponent*> locations;
|
||||
int Phase(){return phase;}string PreparationError(){return error;}
|
||||
void Locations(std::vector<ERR_LocationComponent*>& result){result=locations;}
|
||||
int RadioFeedback(){return 7;}int DefenceSecondsRemaining(){return 30;}
|
||||
bool DefenceOccupied(){return occupied;}float EscapeRadius(){return 9000;}
|
||||
};
|
||||
string Format(string value,int){return value;}
|
||||
string RadioFeedbackMessage(int value){assert(value==7);return "radio feedback boundary";}
|
||||
string Extraction(float value){assert(value==9000);return "extraction boundary";}
|
||||
struct Actual {
|
||||
''' + s + '\n' + coordinate + r'''
|
||||
};
|
||||
bool has(string text,string part){return text.find(part)!=string::npos;}
|
||||
int main(){
|
||||
ERR_GameMode mode;ERR_LocationComponent radio{ERR_LocationKind::RADIO,0};
|
||||
mode.locations={&radio};
|
||||
string unknown=Actual::PhaseDescription(&mode,true);
|
||||
assert(has(unknown,"Choose either marked supply cache")&&!has(unknown,"Intel recovered"));
|
||||
radio.knowledge=1;
|
||||
string rough=Actual::PhaseDescription(&mode,true);
|
||||
assert(rough!=unknown&&has(rough,"Intel recovered")&&has(rough,"500 m search square"));
|
||||
assert(has(rough,"carried field map")&&has(rough,"different intelligence folder"));
|
||||
assert(has(rough,"already recovered cannot provide another clue"));
|
||||
assert(Actual::PhaseDescription(&mode,false)==rough); // Same task and guide copy.
|
||||
for(int i=0;i<10;i++) assert(Actual::PhaseDescription(&mode,true)==rough);
|
||||
radio.knowledge=2;
|
||||
string exact=Actual::PhaseDescription(&mode,true);
|
||||
assert(exact!=rough&&has(exact,"identified on your map")&&has(exact,"Contact extraction HQ"));
|
||||
ERR_LocationComponent roughRadio{ERR_LocationKind::RADIO,1};
|
||||
ERR_LocationComponent depot{ERR_LocationKind::DEPOT,1};
|
||||
mode.locations={nullptr,&roughRadio,&depot,&radio};
|
||||
assert(Actual::PhaseDescription(&mode,true)==exact);
|
||||
mode.locations={&radio,&depot,&roughRadio,nullptr};
|
||||
assert(Actual::PhaseDescription(&mode,true)==exact);
|
||||
radio.knowledge=0;mode.locations={&depot,nullptr,&radio};
|
||||
assert(Actual::PhaseDescription(&mode,true)==unknown);
|
||||
mode.locations={};assert(Actual::PhaseDescription(&mode,true)==unknown);
|
||||
mode.locations={&roughRadio};assert(Actual::PhaseDescription(&mode,true)==rough);
|
||||
// Exact production grid dimensions agree with the player-facing 500 m claim.
|
||||
assert(Actual::Coordinate(6500.0f,1)==6750);
|
||||
assert(Actual::Coordinate(6999.9f,1)==6750);
|
||||
assert(Actual::Coordinate(7000.0f,1)==7250);
|
||||
assert(Actual::Coordinate(-.1f,1)==-250);
|
||||
assert(Actual::Coordinate(6501.4f,2)==6501);
|
||||
assert(Actual::PhaseDescription(nullptr,true).empty());
|
||||
mode.phase=ERR_Phase::PREPARING;assert(has(Actual::PhaseDescription(&mode,true),"Preparing mission"));
|
||||
mode.error="bad";assert(has(Actual::PhaseDescription(&mode,true),"could not start"));
|
||||
mode.phase=ERR_Phase::PRISON;assert(has(Actual::PhaseDescription(&mode,true),"Pick the gate lock"));
|
||||
mode.phase=ERR_Phase::RADIO_DEFENCE;assert(has(Actual::PhaseDescription(&mode,true),"interrupted"));
|
||||
mode.occupied=true;assert(has(Actual::PhaseDescription(&mode,true),"in progress"));
|
||||
assert(Actual::PhaseDescription(&mode,false)=="radio feedback boundary");
|
||||
mode.phase=ERR_Phase::EXTRACTION;assert(Actual::PhaseDescription(&mode,true)=="extraction boundary");
|
||||
mode.phase=ERR_Phase::VICTORY;assert(Actual::PhaseDescription(&mode,true).empty());
|
||||
mode.phase=ERR_Phase::DEFEAT;assert(Actual::PhaseDescription(&mode,true).empty());
|
||||
}
|
||||
''')
|
||||
print('PASS: actual rough-intel objective selection, exact precedence, source grid dimensions and unchanged other-phase dispatch; native UI pending')
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runner cleanup integration with fake processes; no native launch or real signals."""
|
||||
import ast
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
|
||||
import headless_game as game
|
||||
import native_provenance
|
||||
import private_prefix_cleanup as private
|
||||
import test_headless_display_runner as display_tests
|
||||
|
||||
WB=Path(__file__).resolve().parents[1]/'tools/headless_workbench.py'
|
||||
|
||||
|
||||
class CleanupIntegrationTests(unittest.TestCase):
|
||||
def test_canonical_cleanup_order_then_idle_verified(self):
|
||||
order=[];proof={'cleared':True,'term_sent':[12],'kill_sent':[]}
|
||||
with patch.object(game,'cleanup',side_effect=lambda *a:order.append('normal')),\
|
||||
patch.object(private,'cleanup_private_prefix',side_effect=lambda *a:(order.append('private') or proof)),\
|
||||
patch.object(game,'assert_prefix_idle',side_effect=lambda *a:order.append('idle')):
|
||||
report=game.cleanup_owned_run(Mock(),private.INSTALLED_PROTON,{},private.COMPAT)
|
||||
self.assertEqual(order,['normal','private','idle']);self.assertTrue(report['cleanup_confirmed'])
|
||||
self.assertEqual(report['orphan_cleanup']['report'],proof);self.assertTrue(report['idle_verified'])
|
||||
def test_noncanonical_private_prefix_never_gets_orphan_signalling(self):
|
||||
with patch.object(game,'cleanup'),patch.object(game,'assert_prefix_idle') as idle,\
|
||||
patch.object(private,'cleanup_private_prefix') as narrow:
|
||||
result=game.cleanup_owned_run(Mock(),Path('/other/proton'),{},Path('/tmp/other-private-compat'))
|
||||
narrow.assert_not_called();idle.assert_called_once_with(Path('/tmp/other-private-compat'))
|
||||
self.assertTrue(result['cleanup_confirmed']);self.assertFalse(result['orphan_cleanup']['attempted'])
|
||||
def test_errors_preserve_partial_helper_report_and_still_run_idle_check(self):
|
||||
partial={'cleared':False,'term_sent':[12],'kill_sent':[]};order=[]
|
||||
def narrow(*args):order.append('private');raise private.CleanupRefused('remaining identity unverified',partial)
|
||||
with patch.object(game,'cleanup',side_effect=OSError('normal shutdown failed')),\
|
||||
patch.object(private,'cleanup_private_prefix',side_effect=narrow),\
|
||||
patch.object(game,'assert_prefix_idle',side_effect=lambda *a:(order.append('idle') or None)):
|
||||
result=game.cleanup_owned_run(Mock(),private.INSTALLED_PROTON,{},private.COMPAT)
|
||||
self.assertEqual(order,['private','idle']);self.assertFalse(result['cleanup_confirmed'])
|
||||
self.assertTrue(result['idle_verified']);self.assertEqual(result['orphan_cleanup']['report'],partial)
|
||||
self.assertEqual(len(result['errors']),2)
|
||||
def test_false_helper_completion_or_busy_final_prefix_never_qualifies(self):
|
||||
for details in ({'cleared':False},{'cleared':1},None):
|
||||
with patch.object(game,'cleanup'),patch.object(private,'cleanup_private_prefix',return_value=details),patch.object(game,'assert_prefix_idle'):
|
||||
self.assertFalse(game.cleanup_owned_run(Mock(),private.INSTALLED_PROTON,{},private.COMPAT)['cleanup_confirmed'])
|
||||
with patch.object(game,'cleanup'),patch.object(private,'cleanup_private_prefix',return_value={'cleared':True}),\
|
||||
patch.object(game,'assert_prefix_idle',side_effect=ValueError('private prefix still busy')):
|
||||
result=game.cleanup_owned_run(Mock(),private.INSTALLED_PROTON,{},private.COMPAT)
|
||||
self.assertFalse(result['cleanup_confirmed']);self.assertFalse(result['idle_verified'])
|
||||
|
||||
def test_game_runner_cleanup_failure_is_recorded_and_nonzero(self):
|
||||
fixture=display_tests.DisplayRunnerTests();fixture.setUp()
|
||||
proof={'cleanup_confirmed':False,'idle_verified':False,'errors':['idle verification: still busy'],
|
||||
'orphan_cleanup':{'attempted':True,'report':{'cleared':False}}}
|
||||
try:
|
||||
with patch.object(game,'cleanup_owned_run',return_value=proof):
|
||||
status,summary=fixture.simulate_run_locked(True)
|
||||
self.assertEqual(status,1);self.assertEqual(summary['result'],'cleanup-failed')
|
||||
self.assertFalse(summary['cleanup_confirmed']);self.assertEqual(summary['cleanup_report'],proof)
|
||||
self.assertIn('idle verification',summary['runner_error'])
|
||||
self.assertNotIn('source_unchanged_after_run',summary)
|
||||
finally:fixture.tearDown()
|
||||
|
||||
|
||||
class WorkbenchIntegrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory(prefix='erri-cleanup-runner-test-');self.base=Path(self.tmp.name)
|
||||
self.steam=self.base/'Steam';self.compat=self.base/'compat';self.profile=self.base/'profile'
|
||||
(self.compat/'pfx').mkdir(parents=True);(self.compat/'pfx/system.reg').write_text('synthetic fixture')
|
||||
self.proton=self.steam/'steamapps/common/Proton - Experimental/proton'
|
||||
self.exe=self.steam/'steamapps/common/Arma Reforger Tools/Workbench/ArmaReforgerWorkbenchSteamDiag.exe'
|
||||
for file in (self.proton,self.exe):file.parent.mkdir(parents=True,exist_ok=True);file.write_text('not executable')
|
||||
(self.steam/'steamapps/common/Arma Reforger/addons/data').mkdir(parents=True)
|
||||
self.open_locks=[]
|
||||
original_open=Path.open
|
||||
def tracked_open(path,*args,**kwargs):
|
||||
stream=original_open(path,*args,**kwargs)
|
||||
if path.name=='.erri-workbench.lock':self.open_locks.append(stream)
|
||||
return stream
|
||||
self.tracked_open=tracked_open
|
||||
self.argv=[str(WB),'--steam',str(self.steam),'--compat',str(self.compat),'--profile',str(self.profile),'--project','core','--rounds','1','--timeout','60']
|
||||
def tearDown(self):
|
||||
for stream in self.open_locks:stream.close()
|
||||
self.tmp.cleanup()
|
||||
def test_busy_prefix_refused_under_lock_before_any_launch_or_audio(self):
|
||||
events=[]
|
||||
def busy(*a):events.append('idle');raise ValueError('existing private Workbench')
|
||||
with patch.object(Path,'open',self.tracked_open),patch.object(sys,'argv',self.argv),patch('fcntl.flock',side_effect=lambda *a:events.append('lock')),\
|
||||
patch.object(game,'assert_prefix_idle',side_effect=busy),patch.object(game,'cleanup_owned_run') as cleanup,\
|
||||
patch.object(game.subprocess,'Popen') as launch,patch.object(game.subprocess,'check_output') as audio,\
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as ended:runpy.run_path(str(WB),run_name='__main__')
|
||||
self.assertEqual(ended.exception.code,2);self.assertEqual(events,['lock','idle'])
|
||||
launch.assert_not_called();audio.assert_not_called();cleanup.assert_not_called();self.assertFalse(self.profile.exists())
|
||||
def execute(self,cleanup_report,native_failure=False,launch_error=False):
|
||||
events=[];session=self.profile/'logs/logs_fixture';child=Mock();child.pid=12345;child.poll.return_value=None
|
||||
def spawn(*args,**kwargs):
|
||||
events.append('launch')
|
||||
if launch_error:raise OSError('synthetic launch failure')
|
||||
session.mkdir(parents=True);(session/'console.log').write_text('ERR SMOKE FAIL: fixture failure\n' if native_failure else 'ERR SMOKE COMPLETE:\n')
|
||||
return child
|
||||
def audio(args,**kwargs):
|
||||
if 'sink-inputs' in args:return '[]'
|
||||
return json.dumps([{'name':'erri_tests_silent','index':7}])
|
||||
def persist(path,snapshot):events.append('persist');(path/'launch-source.json').write_text('retained launch fixture')
|
||||
old_iterdir=Path.iterdir
|
||||
def iterdir(path):return iter(()) if path==Path('/proc') else old_iterdir(path)
|
||||
clock=[0]
|
||||
def now():clock[0]+=3;return clock[0]
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(patch.object(Path,'open',self.tracked_open))
|
||||
stack.enter_context(patch.object(sys,'argv',self.argv))
|
||||
stack.enter_context(patch('fcntl.flock',side_effect=lambda *a:events.append('lock')))
|
||||
stack.enter_context(patch.object(game,'assert_prefix_idle',side_effect=lambda *a:events.append('idle')))
|
||||
cleaner=stack.enter_context(patch.object(game,'cleanup_owned_run',side_effect=lambda *a:(events.append('cleanup') or cleanup_report)))
|
||||
stack.enter_context(patch.object(game.shutil,'which',return_value='/synthetic/installed'))
|
||||
stack.enter_context(patch.object(game.subprocess,'Popen',side_effect=spawn))
|
||||
stack.enter_context(patch.object(game.subprocess,'check_output',side_effect=audio))
|
||||
stack.enter_context(patch.object(game.subprocess,'run',return_value=SimpleNamespace(returncode=0)))
|
||||
native_report=stack.enter_context(patch.object(game.subprocess,'call',return_value=0))
|
||||
stack.enter_context(patch.object(Path,'iterdir',iterdir))
|
||||
stack.enter_context(patch.object(game.time,'time',side_effect=now))
|
||||
stack.enter_context(patch.object(game.time,'sleep'))
|
||||
stack.enter_context(patch.object(native_provenance,'snapshot',return_value={'synthetic':True}))
|
||||
stack.enter_context(patch.object(native_provenance,'bindings',return_value={'launch_binding':'retained'}))
|
||||
stack.enter_context(patch.object(native_provenance,'persist',side_effect=persist))
|
||||
seal=stack.enter_context(patch.object(native_provenance,'seal_run',return_value={'source_unchanged_after_run':True}))
|
||||
stdout=stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
||||
with self.assertRaises(SystemExit) as ended:runpy.run_path(str(WB),run_name='__main__')
|
||||
summary=json.loads(stdout.getvalue())
|
||||
return ended.exception.code,summary,events,cleaner.call_count,seal.call_count,native_report.call_count
|
||||
def test_workbench_success_uses_cleanup_before_sealing_and_retains_report(self):
|
||||
proof={'cleanup_confirmed':True,'idle_verified':True,'errors':[],'orphan_cleanup':{'attempted':False}}
|
||||
code,summary,events,cleaned,sealed,native=self.execute(proof)
|
||||
self.assertEqual(code,0);self.assertTrue(summary['cleanup_confirmed']);self.assertEqual(summary['cleanup_report'],proof)
|
||||
self.assertEqual(events[:3],['lock','idle','launch']);self.assertEqual(events[-1],'cleanup')
|
||||
self.assertEqual((cleaned,sealed,native),(1,1,1))
|
||||
self.assertTrue((self.profile/'logs/logs_fixture/launch-source.json').is_file())
|
||||
def test_workbench_cleanup_failure_preserves_native_and_launch_evidence_without_seal(self):
|
||||
proof={'cleanup_confirmed':False,'idle_verified':False,'errors':['idle verification: still busy'],
|
||||
'orphan_cleanup':{'attempted':True,'report':{'cleared':False,'term_sent':[12]}}}
|
||||
code,summary,events,cleaned,sealed,native=self.execute(proof)
|
||||
self.assertEqual(code,1);self.assertEqual(summary['result'],'cleanup-failed')
|
||||
self.assertEqual(summary['result_before_cleanup'],'smoke-completed');self.assertFalse(summary['cleanup_confirmed'])
|
||||
self.assertEqual((cleaned,sealed,native),(1,0,0));self.assertEqual(summary['launch_binding'],'retained')
|
||||
session=self.profile/'logs/logs_fixture'
|
||||
self.assertIn('ERR SMOKE COMPLETE:',(session/'console.log').read_text())
|
||||
self.assertTrue((session/'launch-source.json').is_file());self.assertTrue((session/'runner.json').is_file())
|
||||
def test_workbench_native_failure_stays_failed_despite_successful_cleanup(self):
|
||||
proof={'cleanup_confirmed':True,'idle_verified':True,'errors':[]}
|
||||
code,summary,*_=self.execute(proof,native_failure=True)
|
||||
self.assertEqual(code,1);self.assertEqual(summary['result'],'test-failed')
|
||||
def test_workbench_launch_failure_does_not_invoke_post_run_cleanup(self):
|
||||
code,summary,events,cleaned,sealed,native=self.execute({},launch_error=True)
|
||||
self.assertEqual(code,1);self.assertEqual(summary['result'],'runner-failed')
|
||||
self.assertEqual((cleaned,sealed,native),(0,0,0));self.assertEqual(summary['runner_error'],'synthetic launch failure')
|
||||
def test_audio_fallback_accepts_only_trailing_slash_prefix_spelling(self):
|
||||
tree=ast.parse(WB.read_text());function=next(node for node in tree.body if isinstance(node,ast.FunctionDef) and node.name=='silence_test_streams')
|
||||
namespace={'json':json,'subprocess':game.subprocess,'Path':Path,'compat':self.compat,'silent_index':7,
|
||||
'silent_sink':'owned-fixture-sink','muted_streams':set()}
|
||||
exec(compile(ast.Module(body=[function],type_ignores=[]),str(WB),'exec'),namespace)
|
||||
stream={'index':8,'sink':0,'mute':False,'properties':{'application.process.id':'555'}}
|
||||
for value,expected in ((str(self.compat/'pfx')+'/',2),(str(self.compat/'pfx')+'///',2),(str(self.compat/'pfx')+'-foreign/',0)):
|
||||
with patch.object(game.subprocess,'check_output',return_value=json.dumps([stream])),\
|
||||
patch.object(Path,'read_bytes',return_value=('WINEPREFIX='+value).encode()+b'\0'),\
|
||||
patch.object(game.subprocess,'run') as audio,contextlib.redirect_stdout(io.StringIO()):
|
||||
namespace['silence_test_streams']()
|
||||
self.assertEqual(audio.call_count,expected)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in New Issue
Block a user