Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
150 lines
8.2 KiB
Python
150 lines
8.2 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(aiDoctrine=0,aiShadow=0,planeAltitudeMetres=250,planeReplacementSeconds=60,planeSectorSeconds=120,planeReconEnabled=1,roadblockCount=4,civilianTrafficCount=3,startMode=0,keepWeaponOnUnconscious=1,reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,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')
|