Files
EscapistsReintroduced/tests/test_settings_migration.py
T
jakub a951a96edc Fix faction ammunition resupply and settings recovery
Wait for both convoy cargo phases and use actual magazine stock. Resolve empty primary weapon ammunition from muzzle metadata and recover cleanly from failed initial settings saves.

Update native fixtures, scoped cleanup guards, packaging evidence and validation documentation. Make reviewed fixture tests checkout-relative. All repository checks pass against the isolated staged tree; native resupply completion and multiplayer qualification remain outstanding.
2026-09-30 14:34:46 +02:00

143 lines
7.6 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(keepWeaponOnUnconscious=1,reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,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('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);}
};
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;}
};
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')