Files
EscapistsReintroduced/tests/test_settings_migration.py
T
2026-10-21 15:28:32 +02:00

150 lines
8.3 KiB
Python

from pathlib import Path
import importlib.util,json,tempfile,os,subprocess,sys
BASE=Path(os.environ.get("ERR_CONVOY_SETTINGS_ROOT", Path(__file__).resolve().parents[1]))
from policy_harness import ROOT,body,run
spec=importlib.util.spec_from_file_location('import_settings',BASE/'tools/import_settings.py')
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
result,deferred=m.merge({'configVersion':1,'reportIntervalSeconds':20.0,'supportSquadLimit':2,'responseRadiusMetres':1500,'convoyCount':3},{'aiIntelChancePercent':0,'intelRevealChancePercent':100})
assert result==dict(droneReconCount=0,droneCooldownSeconds=300,aiDoctrine=0,aiShadow=0,planeAltitudeMetres=250,planeReplacementSeconds=60,planeSectorSeconds=120,planeReconEnabled=1,roadblockCount=4,civilianTrafficCount=3,startMode=0,keepWeaponOnUnconscious=1,selfEpinephrine=0,restartOnEmpty=1,emptyRestartHours=5,reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,showAllSites=0,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,viewDistanceMetres=0,automaticEquipmentChancePercent=25,antiAirChancePercent=7,antiPersonnelMineChancePercent=60,automaticConvoyEnabled=1,convoyCount=3,heliTimerMinutes=15)
assert deferred==[]
for count in (0,1,3,20):
values,notes=m.merge(ai={'convoyCount':count})
assert values['automaticConvoyEnabled']==int(count>0)
assert values['convoyCount']==count and not notes
for invalid in (-1,21,True,False,None,'1',1.5,float('nan'),float('inf')):
try:m.merge(ai={'convoyCount':invalid})
except ValueError:pass
else:raise AssertionError(invalid)
try:m.merge(ai={'convoyCount':0,'automaticConvoyEnabled':1})
except ValueError:pass
else:raise AssertionError('Ambiguous old/new settings accepted')
for old,percent in [(0,0),(1,100),(15,7),(37,3),(10000,0)]:
assert m.merge(ai={'stingerChanceOneIn':old})[0]['antiAirChancePercent']==percent
assert m.merge(ai={'stingerChanceOneIn':15,'antiAirChancePercent':100})[0]['antiAirChancePercent']==100
for invalid in (-1,10001,True,None,'15',1.5,float('nan')):
try:m.merge(ai={'stingerChanceOneIn':invalid})
except ValueError:pass
else:raise AssertionError(invalid)
assert m.merge(ai={'stingerChanceOneIn':0})[0]['antiAirChancePercent']==0
assert m.merge()[0]=={k:v[0] for k,v in m.SCHEMA.items()}
for key,(_,lo,hi) in m.SCHEMA.items():
if key not in m.AI_KEYS and key not in m.INTEL_KEYS:continue
for invalid in (lo-1,hi+1,True,False,None,'10',lo+0.5,float('nan'),float('inf')):
try:m.merge(**({'ai':{key:invalid}} if key in m.AI_KEYS else {'intel':{key:invalid}}))
except ValueError:pass
else:raise AssertionError((key,invalid))
with tempfile.TemporaryDirectory() as tmp:
dest=Path(tmp)/'rereintroduced.json'
m.write_new(dest,json.dumps(result));before=dest.read_bytes()
try:m.write_new(dest,'overwrite')
except FileExistsError:pass
else:raise AssertionError('Existing destination overwritten')
assert dest.read_bytes()==before
assert sorted(p.name for p in Path(tmp).iterdir())==['rereintroduced.json']
legacy=Path(tmp)/'legacy-ai.json';legacy.write_text('{"convoyCount":3}');original=legacy.read_bytes()
preview=subprocess.run([sys.executable,str(BASE/'tools/import_settings.py'),'--ai',str(legacy)],capture_output=True,text=True,check=True)
assert json.loads(preview.stdout)['automaticConvoyEnabled']==1
assert json.loads(preview.stdout)['convoyCount']==3 and not preview.stderr and legacy.read_bytes()==original
# Test the actual runtime validator against the migration schema at each boundary.
validator=body(BASE/'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c','static bool Valid(').replace('string key','std::string key')
checks=[]
for key,(_,lo,hi) in m.SCHEMA.items():
for value,expected in [(lo-1,False),(lo,True),(hi,True),(hi+1,False)]:
checks.append(f'assert(Valid("{key}",{value})=={str(expected).lower()});')
run('#include <cassert>\n#include <string>\n'+validator+'\nint main(){'+''.join(checks)+'assert(!Valid("unknown",1));}\n')
print('PASS: legacy values preserved, unsupported fields reported, invalid data rejected, existing files protected, runtime bounds aligned')
# Actual settings transaction: failures during final file promotion must not
# poison the next round when there was no previous settings file.
source = Path(os.environ.get('ERR_SETTINGS_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c'))
method = body(source, 'bool Set(')
method = (method.replace('string ', 'std::string ').replace('Replication.', 'Replication::')
.replace('FileIO.', 'FileIO::').replace('ERR_ProfileConfig.', 'ERR_ProfileConfig::').replace('foreach (', 'for (')
.replace('JsonSaveContext json = new JsonSaveContext();', 'JsonSaveContext json;')
.replace('map<string, int> ', 'Values ').replace('map<std::string, int> ', 'Values '))
run(r'''
#include <cassert>
#include <map>
#include <string>
#include <vector>
using string = std::string;
struct Values {
std::map<string,int> data;
int Get(string key){return data.at(key);}
void Remove(string key){data.erase(key);}
void Set(string key,int value){data[key]=value;}
};
struct File {bool valid=true;Values values;};
std::map<string,File> disk;
int fault=0;
namespace Replication {bool server=true;bool IsServer(){return server;}}
namespace FileIO {
bool FileExists(string path){return disk.count(path);}
bool DeleteFile(string path){return disk.erase(path);}
bool CopyFile(string from,string to){
if(!disk.count(from))return false;
disk[to]=disk[from];
if(from=="settings.tmp"){
if(fault==1){disk[to].valid=false;return false;} // partial copy reports failure
if(fault==2)disk[to].valid=false; // copy succeeds, readback fails
if(fault==3)disk[to].values.data["radio"]=31; // valid but incorrect contents
}
return true;
}
}
struct JsonSaveContext {
File pending;
void EnableTypeDiscriminator(bool value){}
void WriteValue(string key,int value){pending.values.data[key]=value;}
bool SaveToFile(string path){disk[path]=pending;return true;}
};
// This fixture exercises the custom-path transaction; unified storage has its own fixture.
const string PATH="$profile:rereintroduced.json";
namespace ERR_ProfileConfig {
bool Exists(string path){return FileIO::FileExists(path);}
bool Save(string,JsonSaveContext&){assert(false);return false;}
}
struct Settings {
bool m_ReadOnly=false;
string m_Path="settings";
Values m_Values{{{"radio",180},{"limit",4}}},m_Resolved{{{"radio",60}}};
std::vector<string> Keys(){return {"radio","limit"};}
bool ValidSelection(string key,int value){return (key=="radio"&&value>=30&&value<=1800)||(key=="limit"&&value>=0&&value<=8);}
int Get(string key){return m_Values.Get(key);}
bool Read(string path,Values& values){
if(!disk.count(path)||!disk[path].valid)return false;
values=disk[path].values;return true;
}
bool Restart(){
Values loaded;
if(Read(m_Path,loaded)||Read(m_Path+".bak",loaded))m_Values=loaded;
else if(FileIO::FileExists(m_Path)||FileIO::FileExists(m_Path+".bak"))m_ReadOnly=true;
return !m_ReadOnly;
}
'''+method+r'''
};
int main(){
for(int injected : {1,2,3}){
// A failed first save must not install a partial or rejected file for restart.
disk.clear();fault=injected;Settings initial;
assert(!initial.Set("radio",300));
assert(initial.Get("radio")==180&&initial.m_Resolved.Get("radio")==60);
assert(!FileIO::FileExists("settings"));
Settings restarted;assert(restarted.Restart());assert(restarted.Get("radio")==180);
fault=0;assert(restarted.Set("radio",450));assert(restarted.Get("radio")==450);
Settings persisted;assert(persisted.Restart()&&persisted.Get("radio")==450);
// An existing valid save is restored from the newly captured backup.
disk.clear();fault=0;Settings saved;assert(saved.Set("radio",600));
fault=injected;assert(!saved.Set("radio",900));assert(saved.Get("radio")==600);
Settings restored;assert(restored.Restart()&&restored.Get("radio")==600);
// A preexisting fallback backup remains authoritative when primary is absent.
disk.clear();disk["settings.bak"]={true,Values{{{"radio",750},{"limit",4}}}};
Settings fallback;assert(fallback.Restart()&&fallback.Get("radio")==750);
assert(!fallback.Set("radio",900));
Settings recovered;assert(recovered.Restart()&&recovered.Get("radio")==750);
}
}
''')
print('PASS: failed initial settings promotion leaves defaults writable after restart; prior/fallback saves survive copy, readback and mismatch faults')