using System.Net; using System.Reflection; using System.Runtime.ExceptionServices; using Newtonsoft.Json.Linq; using game.Account; using game.PlayerDataStorage; partial class RouteTests { internal static readonly string Root=Path.GetFullPath(Path.Combine("CodexOutput/upserver/KVSaveRouteTests/runs",DateTime.UtcNow.ToString("yyyyMMddHHmmss")+"_"+Guid.NewGuid().ToString("N"))); static readonly Uri Api=new("http://kv-route.invalid/"); static int assertions,failed; static readonly List Results=new(); static async Task Main(string[] args) { Directory.CreateDirectory(Root); if(args.Contains("--diagnostic")) AppDomain.CurrentDomain.FirstChanceException += (_,e)=>{ if(e.Exception is IOException || e.Exception is UnauthorizedAccessException) Console.WriteLine("IO DIAGNOSTIC "+e.Exception); }; await Run("C1-ReadKV-contract",ReadContract); if(args.Contains("--all")) await More(); File.WriteAllText(Path.Combine(Root,"result.json"),System.Text.Json.JsonSerializer.Serialize(new{groups=Results.Count,assertions,failed,results=Results},new System.Text.Json.JsonSerializerOptions{WriteIndented=true})); Console.WriteLine($"RESULT groups={Results.Count} assertions={assertions} failed={failed} run={Root}");return failed==0?0:1; } static async Task Run(string name,Func action) { int before=assertions;try{await action();Results.Add(new{name,passed=true,assertions=assertions-before});Console.WriteLine("PASS "+name);}catch(Exception e){failed++;Results.Add(new{name,passed=false,error=e.ToString()});Console.WriteLine("FAIL "+name+" "+e);} } static void Check(bool ok,string message){Interlocked.Increment(ref assertions);if(!ok)throw new InvalidOperationException(message);} static object Call(string name,params object[] args) { var method=typeof(PlayFabMgr).GetMethod(name,BindingFlags.Static|BindingFlags.NonPublic); Check(method!=null,"Required fixed access missing: "+name); try{return method.Invoke(null,args);}catch(TargetInvocationException e){ExceptionDispatchInfo.Capture(e.InnerException).Throw();throw;} } static dynamic Read(PlayerKVGameSessionHandle h,string key)=>Call("ReadKV",h,key); static void ReadIs(dynamic r,string error,bool? found,string value){Check(r.Error.ToString()==error,"read error "+error);Check((bool?)r.Found==found,"found tri-state");Check((string)r.Value==value,"read value");} static async Task ReadContract(){ // Missing method fails this behavioral test before implementation, without a compiler error. ReadIs(Read(null,"k"),"InvalidInput",null,null); await using var f=new Fixture(); var h=await f.Open(); ReadIs(Read(h,"k"),"None",true,"base");ReadIs(Read(h,"missing"),"None",false,null);ReadIs(Read(h,"empty"),"None",true,""); ReadIs(Read(h,"K"),"None",false,null); foreach(var key in new[]{" ","姹夊瓧",new string('x',128)})ReadIs(Read(h,key),"None",false,null); foreach(var key in new[]{null,"",new string('x',129)})ReadIs(Read(h,key),"InvalidInput",null,null); Check(f.Fake.Gets==1,"reads do not get network"); Check(PlayerKVGameLoadGuard.TryEnter(out var guard),"own guard"); try{ReadIs(Read(null,null),"LoadGuardActive",null,null);Check(guard.Count(PlayerKVGuardPoint.ReadLocal)==1,"guard precedes input");}finally{guard.Dispose();} await f.Bridge.CloseAsync();ReadIs(Read(h,"k"),"StaleOrClosing",null,null); Check(UnityEngine.PlayerPrefs.Calls==0&&PlayFab.PlayFabClientAPI.Calls==0,"no PF/Prefs"); } sealed class Handler:HttpMessageHandler { readonly Func> send; internal Handler(Func> send){this.send=send;} protected override Task SendAsync(HttpRequestMessage r,CancellationToken t)=>send(r,t); } static HttpResponseMessage Json(HttpStatusCode status,object o)=>new(status){Content=new StringContent(Newtonsoft.Json.JsonConvert.SerializeObject(o))}; sealed class Fake { internal readonly Dictionary> Remote=new(){["101"]=new(){{"k","base"},{"empty",""}},["102"]=new(){{"k","B"}}}; internal int Gets,Writes; internal bool DropResponse,Unauthorized; internal Func Before; internal HttpMessageHandler Create(CookieContainer jar){var id=jar.GetCookies(Api)["sid"].Value;return new Handler(async(r,t)=>{ Check(r.RequestUri.Host=="kv-route.invalid"&&r.RequestUri.Port==80,"synthetic host only"); var path=r.RequestUri.AbsolutePath;Check(path is "/playerdata/Get" or "/playerdata/Set" or "/playerdata/Delete","fixed KV path"); if(path.EndsWith("Get"))Interlocked.Increment(ref Gets);else Interlocked.Increment(ref Writes); if(Before!=null)await Before(r,t); if(Unauthorized)return Json(HttpStatusCode.Unauthorized,new{}); var body=JObject.Parse(await r.Content.ReadAsStringAsync()); lock(Remote){ if(path.EndsWith("Get"))return Json(HttpStatusCode.OK,new{data=new Dictionary(Remote[id])}); if(path.EndsWith("Set"))foreach(var p in ((JObject)body["data"]).Properties())Remote[id][p.Name]=(string)p.Value; else foreach(var k in body["keys"].Values())Remote[id].Remove(k); } if(DropResponse)throw new HttpRequestException("Synthetic response lost after apply"); return Json(HttpStatusCode.OK,new{}); });} } sealed class Fixture:IAsyncDisposable { internal readonly string Cache=Path.Combine(Root,Guid.NewGuid().ToString("N")); internal readonly Fake Fake=new(); internal readonly PlayerKVGameSessionBridge Bridge; readonly List sessions=new(); internal long Current=1; internal bool ThrowCurrent; internal Fixture(){Directory.CreateDirectory(Cache);Bridge=new PlayerKVGameSessionBridge(Fake.Create);} internal async Task Open(string id="101"){ var s=new S101AccountSession(Api,jar=>new Handler((r,t)=>{ Check(r.RequestUri.Host=="kv-route.invalid"&&r.RequestUri.AbsolutePath=="/account/Login","synthetic auth only");jar.Add(Api,new Cookie("sid",id)); return Task.FromResult(Json(HttpStatusCode.OK,new{state=0,internalId=int.Parse(id),newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"}));})); sessions.Add(s);Check((await s.LoginAsync(id)).Success,"login"); var result=await Bridge.OpenAsync(s,Cache,id,Api,Current,e=>ThrowCurrent?throw new InvalidOperationException("current-predicate-fault"):e==Current); Check(result.Success,"open "+result.Error);return result.Handle; } public async ValueTask DisposeAsync(){await Bridge.CloseAsync();foreach(var s in sessions)s.Dispose();} } }