#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Http; using System.Reflection; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using game; using game.Account; using game.PlayerDataStorage; using Newtonsoft.Json; using UnityEditor; using UnityEngine; // Fixed B01 synthetic cases only. No global container replacement, permission UI or Play mode. public static class KVPromptStateValidation { private static bool running; private static object failedCleanup; private static readonly BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic; public static async Task RunAsync(string caseId) { if ((caseId != "B01" && caseId != "CP3A" && caseId != "P6D") || EditorApplication.isPlayingOrWillChangePlaymode || running || failedCleanup != null) { throw new InvalidOperationException("B01 requires an idle Editor and completed prior cleanup."); } running = true; var run = new FixedRun(); var staticField = typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic); object previous = null; var isolated = false; var errors = new List(); string result = null; try { previous = staticField.GetValue(null); staticField.SetValue(null, new RedPointManager()); isolated = true; result = caseId == "P6D" ? await run.ExecuteP6D() : caseId == "B01" ? await run.Execute() : await run.ExecuteCP3A(); } catch (Exception ex) { errors.Add(ex); } finally { try { await run.Close(); } catch (Exception ex) { failedCleanup = run; errors.Add(ex); } try { if (isolated) { staticField.SetValue(null, previous); if (!ReferenceEquals(previous, staticField.GetValue(null))) { throw new InvalidOperationException("Red point instance was not restored."); } } } catch (Exception ex) { failedCleanup = run; errors.Add(ex); } finally { running = false; } } if (errors.Count != 0) { throw new AggregateException(errors); } var report = Newtonsoft.Json.Linq.JObject.Parse(result); report["restored"] = true; report["remainingTestObjects"] = 0; return report.ToString(Formatting.None); } sealed class Handler : HttpMessageHandler { readonly Func send; internal Handler(Func send) { this.send = send; } protected override Task SendAsync(HttpRequestMessage r, CancellationToken t) => Task.FromResult(send(r)); } static HttpResponseMessage Json(object value) => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonConvert.SerializeObject(value)) }; sealed class FixedRun { private readonly List children = new List(); private readonly List restoreFaults = new List(); readonly Uri api = new Uri("http://b01-editor.invalid/"); readonly string cache = Path.GetFullPath(Path.Combine(Application.dataPath, "../../CodexOutput/upserver/B3BatchMigration/B01Tests/unity-runs", Guid.NewGuid().ToString("N"))); readonly List sessions = new List(); readonly List objects = new List(); PlayerKVGameSessionBridge bridge; long epoch = 1; int assertions, requests; void Check(bool value, string label) { assertions++; if (!value) throw new InvalidOperationException("B01: " + label); } static object Call(object target, string method, params object[] args) { try { return target.GetType().GetMethod(method, PrivateInstance | BindingFlags.Public).Invoke(target, args); } catch (TargetInvocationException ex) { ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); throw; } } static void Field(object target, string name, object value) => target.GetType().GetField(name, PrivateInstance).SetValue(target, value); static T Bound(PlayerKVGameSessionHandle handle) => (T)typeof(T).GetConstructor(PrivateInstance, null, new[] { typeof(PlayerKVGameSessionHandle) }, null).Invoke(new object[] { handle }); void Reject(Action action) { try { action(); } catch (InvalidOperationException) { assertions++; return; } throw new InvalidOperationException("B01: expected owner rejection"); } async Task Open() { var session = new S101AccountSession(api, jar => new Handler(r => { Check(r.RequestUri == new Uri(api, "account/Login"), "synthetic login only"); jar.Add(api, new Cookie("sid", "101")); return Json(new { state = 0, internalId = 101, newCreate = false, createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-01T00:00:00Z" }); })); sessions.Add(session); Check((await session.LoginAsync("101")).Success, "synthetic login"); var opened = await bridge.OpenAsync(session, cache, "101", api, epoch, e => e == epoch); Check(opened.Success, "synthetic owner open"); return opened.Handle; } T Panel() where T : Component { var go = new GameObject("B01-" + typeof(T).Name) { hideFlags = HideFlags.HideAndDontSave }; go.SetActive(false); objects.Add(go); return go.AddComponent(); } internal async Task Execute() { Func factory = jar => new Handler(r => { Check(r.RequestUri == new Uri(api, "playerdata/Get"), "no write/no real transport"); requests++; return Json(new { data = new Dictionary() }); }); bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance, null, new[] { typeof(Func), typeof(bool) }, null).Invoke(new object[] { factory, false }); var handle = await Open(); var settings = Bound(handle); var club = Bound(handle); Check(!settings.ShowNotice, "valid missing key"); settings.ShowNotice = true; Check(settings.ShowNotice && requests == 1, "accepted local only"); var original = settings.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(settings); settings.ShowNotice = true; Check(ReferenceEquals(original, settings.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(settings)), "same value no duplicate"); // A fresh real ClubService retains its original initialized flag. Check(club.IsOpenClubRed, "fresh real club retains original flag"); await bridge.CloseAsync(); Reject(() => settings.ShowNotice = false); Reject(club.SetOpenClubRed); Check(club.IsOpenClubRed, "stale real club preserves flag"); // Notification constructor validates the current owner. Create a fresh fixed instance, then revoke it. epoch++; var nextHandle = await Open(); var nextSettings = Bound(nextHandle); var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText( Path.Combine(Application.dataPath, "../../cfgData", name + ".json")))); var notifications = (LocalNotificationService)typeof(LocalNotificationService).GetConstructor(PrivateInstance, null, new[] { typeof(DefaultSettingService), typeof(cfg.Tables) }, null).Invoke(new object[] { nextSettings, tables }); var pendingInit = notifications.Init(); await bridge.CloseAsync(); try { await pendingInit; throw new InvalidOperationException("B01: stale init succeeded"); } catch (InvalidOperationException ex) { Check(!ex.Message.Contains("stale init succeeded"), "stale Editor permission continuation rejected"); } var grade = Panel(); Field(grade, "settings", nextSettings); Field(grade, "notifications", notifications); var popup = Panel(); Field(popup, "notifications", notifications); var settingPanel = Panel(); Field(settingPanel, "settingService", nextSettings); Field(settingPanel, "notificationService", notifications); var gift = Panel(); Field(gift, "notificationService", notifications); var social = Panel(); Field(social, "_socialData", club); var errors = new List(); void Capture(string message, string stack, LogType type) { if (type == LogType.Error || type == LogType.Exception) errors.Add(message); } Application.logMessageReceived += Capture; try { Reject(() => Call(grade, "DestroyUI")); Reject(() => Call(grade, "OnBtnOk")); Reject(() => Call(popup, "OnBtnOk")); Reject(() => Call(popup, "DestroyUI")); Reject(() => Call(settingPanel, "OnToggleNotification", true)); Reject(() => Call(settingPanel, "OnToggleNotification", false)); Reject(() => Call(gift, "OnClickYES")); Call(social, "Start"); Check(errors.Count == 1, "only unchanged social entry logs its owner rejection"); Check(errors.TrueForAll(e => e.Contains("ShowNotice owner=") || e.Contains("IsOpenClubRed owner=")), "no unexpected exception"); foreach (var go in objects) Check(go != null, "stale callback did not destroy another UI"); } finally { Application.logMessageReceived -= Capture; } Check(requests == 2, "read-only synthetic transport"); return JsonConvert.SerializeObject(new { success = true, assertions, requests, formalRequests = 0, playing = EditorApplication.isPlaying, globalContainerChanged = false, caseId = "B01", cache }); } internal async Task ExecuteP6D() { foreach (var value in new[] { null, "0", "1", "" }) { var fixture = new FixedRun(); children.Add(fixture); await fixture.HealthyPrompt(value); } foreach (var club in new[] { false, true }) { foreach (var stage in new[] { "BeforeWrite", "AfterReplace" }) { var fixture = new FixedRun(); children.Add(fixture); await fixture.FaultPrompt(club, stage); } var revoked = new FixedRun(); children.Add(revoked); await revoked.RevokedDuringPrompt(club); } var callback = new FixedRun(); children.Add(callback); await callback.RevokedDuringClubCallback(); foreach (var fixture in children) { assertions += fixture.assertions; requests += fixture.requests; } return JsonConvert.SerializeObject(new { success = true, caseId = "P6D", assertions, cases = children.Count, requests, formalRequests = 0, playing = EditorApplication.isPlaying, globalContainerChanged = false }); } private async Task OpenPrompt() { Func factory = jar => new Handler(r => { Check(r.RequestUri == new Uri(api, "playerdata/Get"), "P6D synthetic Get only"); requests++; return Json(new { data = new Dictionary() }); }); bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance, null, new[] { typeof(Func), typeof(bool) }, null).Invoke(new object[] { factory, false }); return await Open(); } private static PlayerKVEditResult Edit(object service) { return (PlayerKVEditResult)service.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(service); } private static bool? Cached(DefaultSettingService settings) { return (bool?)typeof(DefaultSettingService).GetField("_showNotice", PrivateInstance).GetValue(settings); } private sealed class RedProbe : IUIRedPoint { private readonly Action callback; internal RedProbe(Action callback) { this.callback = callback; } public void SetRedPointState(bool state) { callback(state); } } private void ObserveRed(Action callback) { RedPointManager.Instance.RemoveRedPoint(RedPointName.Home_Club); RedPointManager.Instance.AddRedPoint(RedPointName.Home_Club, new RedProbe(callback)); } private static Delegate FaultDelegate(Action callback) { return new Action(stage => callback(stage.ToString())); } private void InjectFault(PlayerKVGameSessionHandle handle, Action callback) { var owner = handle.GetType().GetProperty("Owner", PrivateInstance).GetValue(handle); var state = owner.GetType().GetField("state", PrivateInstance).GetValue(owner); var store = state.GetType().GetField("store", PrivateInstance).GetValue(state); var field = store.GetType().GetField("fault", PrivateInstance); var previous = field.GetValue(store); var method = typeof(FixedRun).GetMethod("FaultDelegate", BindingFlags.Static | BindingFlags.NonPublic) .MakeGenericMethod(field.FieldType.GetGenericArguments()[0]); var fault = method.Invoke(null, new object[] { callback }); restoreFaults.Add(() => field.SetValue(store, previous)); field.SetValue(store, fault); Check(ReferenceEquals(field.GetValue(store), fault), "actual cache fault installed"); } private async Task HealthyPrompt(string initial) { var handle = await OpenPrompt(); if (initial != null) { Check(handle.Update("ShowNotice", initial).Accepted, "seed initial value"); } var settings = Bound(handle); var expected = initial == "1"; Check(settings.ShowNotice == expected, "first read follows original string comparison"); Check(handle.Update("ShowNotice", expected ? "0" : "1").Accepted, "other consumer updates actual cache"); Check(settings.ShowNotice == expected, "first-read nullable cache survives other consumer writes"); var fresh = Bound(handle); var target = !expected; var observedWrites = 0; InjectFault(handle, stage => { if (stage == "BeforeWrite") { observedWrites++; Check(Cached(fresh) == target, "nullable cache assigned before actual disk write"); } }); fresh.ShowNotice = target; Check(Edit(fresh)?.Accepted == true && observedWrites == 1, "setter before getter writes even when stored value matches"); var receipt = Edit(fresh); fresh.ShowNotice = target; Check(ReferenceEquals(receipt, Edit(fresh)) && observedWrites == 1, "same cached value does not write"); target = !target; fresh.ShowNotice = target; Check(observedWrites == 2 && !ReferenceEquals(receipt, Edit(fresh)), "changed cached value writes"); RestoreFaults(); Check(handle.Update("IsOpenClubRed", "0").Accepted, "seed present club value"); var club = Bound(handle); var calls = 0; ObserveRed(red => { calls++; Check(!red && !club.IsOpenClubRed, "flag clears before red callback"); Check(handle.ReadLocalSnapshot().Data["IsOpenClubRed"] == (calls == 1 ? "0" : "1"), "red callback precedes write"); }); club.SetOpenClubRed(); Check(calls == 1 && Edit(club)?.Accepted == true && handle.ReadLocalSnapshot().Data["IsOpenClubRed"] == "1", "present key does not replace original flag gate"); var clubReceipt = Edit(club); club.SetOpenClubRed(); Check(calls == 1 && ReferenceEquals(clubReceipt, Edit(club)), "false flag skips red and write"); club.IsOpenClubRed = true; club.SetOpenClubRed(); Check(calls == 2 && !ReferenceEquals(clubReceipt, Edit(club)), "true flag writes existing value"); // A diagnostic result is not a second owner state. Do not unlock a real failed owner. var rejected = handle.Update(null, "1"); Check(!rejected.Accepted, "actual rejected diagnostic"); fresh.GetType().GetProperty("LastPromptEdit", PrivateInstance).SetValue(fresh, rejected); club.GetType().GetProperty("LastPromptEdit", PrivateInstance).SetValue(club, rejected); Check(fresh.ShowNotice == target, "healthy owner ignores old rejected diagnostic"); club.SetOpenClubRed(); Check(calls == 2, "healthy false club ignores diagnostic without business effects"); await bridge.CloseAsync(); Reject(() => { _ = fresh.ShowNotice; }); Reject(() => fresh.ShowNotice = target); Reject(club.SetOpenClubRed); Check(calls == 2 && Cached(fresh) == target && !club.IsOpenClubRed, "stale cache-hit entries cause no effects"); } private async Task FaultPrompt(bool useClub, string failureStage) { var handle = await OpenPrompt(); var settings = Bound(handle); var club = Bound(handle); var redCalls = 0; ObserveRed(red => { redCalls++; Check(!red && !club.IsOpenClubRed, "failed write still follows original red order"); }); var faults = 0; InjectFault(handle, stage => { if (stage == failureStage) { faults++; Check(useClub ? !club.IsOpenClubRed && redCalls == 1 : Cached(settings) == true, "business state precedes actual storage failure"); throw new IOException("P6D synthetic " + failureStage); } }); Reject(() => { if (useClub) { club.SetOpenClubRed(); } else { settings.ShowNotice = true; } }); var edit = Edit(useClub ? (object)club : settings); Check(faults == 1 && edit != null && !edit.Accepted, "actual rejected write preserved"); Check(edit.StateResult.Error == PlayerKVChangeError.StorageError && edit.StateResult.LocalCommitMayHaveSucceeded == (failureStage == "AfterReplace"), "actual uncertainty preserved"); Check(handle.Status == PlayerKVSyncStatus.StorageError, "actual storage terminal state retained"); Reject(() => { _ = settings.ShowNotice; }); Reject(() => settings.ShowNotice = true); Reject(club.SetOpenClubRed); Check(faults == 1 && redCalls == (useClub ? 1 : 0), "actual unreadable owner blocks repeat effects"); Check(useClub ? !club.IsOpenClubRed : Cached(settings) == true, "no added rollback"); } private async Task RevokedDuringPrompt(bool useClub) { var handle = await OpenPrompt(); var settings = Bound(handle); var club = Bound(handle); var redCalls = 0; ObserveRed(red => { redCalls++; }); InjectFault(handle, stage => { if (stage == "AfterReplace") { epoch++; } }); Reject(() => { if (useClub) { club.SetOpenClubRed(); } else { settings.ShowNotice = true; } }); Check(Edit(useClub ? (object)club : settings)?.Accepted == true, "completed disk write not disguised as unaccepted"); Check(useClub ? !club.IsOpenClubRed && redCalls == 1 : Cached(settings) == true, "revocation does not roll back original front effects"); Check(!handle.ReadLocalSnapshot().Success && requests == 1, "revoked old owner has no replay or transport write"); } private async Task RevokedDuringClubCallback() { var handle = await OpenPrompt(); var club = Bound(handle); var redCalls = 0; ObserveRed(red => { redCalls++; epoch++; }); var writes = 0; InjectFault(handle, stage => { writes++; }); Reject(club.SetOpenClubRed); Check(!club.IsOpenClubRed && redCalls == 1, "callback revocation preserves front effects"); Check(Edit(club)?.Accepted == false && writes == 0 && requests == 1, "callback revocation refuses old write without replay"); } internal async Task ExecuteCP3A() { Func factory = jar => new Handler(r => { Check(r.RequestUri == new Uri(api, "playerdata/Get"), "CP3A synthetic Get only"); requests++; return Json(new { data = new Dictionary() }); }); bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance, null, new[] { typeof(Func), typeof(bool) }, null).Invoke(new object[] { factory, false }); var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText( Path.Combine(Application.dataPath, "../../cfgData", name + ".json")))); var settingCtor = typeof(DefaultSettingService).GetConstructor(PrivateInstance, null, new[] { typeof(bool) }, null); var clubCtor = typeof(ClubService).GetConstructor(PrivateInstance, null, new[] { typeof(bool) }, null); var notificationCtor = typeof(LocalNotificationService).GetConstructor(PrivateInstance, null, new[] { typeof(cfg.Tables) }, null); Check(settingCtor != null && clubCtor != null && notificationCtor != null, "formal service constructors exist"); var settings = (DefaultSettingService)settingCtor.Invoke(new object[] { true }); var club = (ClubService)clubCtor.Invoke(new object[] { true }); var notifications = (LocalNotificationService)notificationCtor.Invoke(new object[] { tables }); var pendingNotifications = (LocalNotificationService)notificationCtor.Invoke(new object[] { tables }); var pendingInit = pendingNotifications.Init(); Reject(() => Call(pendingNotifications, "PreflightFormalPromptSettings", settings)); await pendingInit; await notifications.Init(); Reject(() => { _ = settings.ShowNotice; }); Reject(() => settings.ShowNotice = true); Reject(club.SetOpenClubRed); var handle = await Open(); Call(settings, "BindFormalPromptOwner", handle); Call(club, "BindFormalPromptOwner", handle); Call(notifications, "BindFormalPromptSettings", settings); Call(settings, "BindFormalPromptOwner", handle); Call(club, "BindFormalPromptOwner", handle); Call(notifications, "BindFormalPromptSettings", settings); Check(!settings.ShowNotice, "bound missing ShowNotice"); settings.ShowNotice = true; club.SetOpenClubRed(); Check(settings.ShowNotice && !club.IsOpenClubRed, "bound prompt effects"); Check(requests == 1, "one synthetic KV Get and no transport write"); await bridge.CloseAsync(); Reject(() => { _ = settings.ShowNotice; }); Reject(club.SetOpenClubRed); epoch++; var nextHandle = await Open(); Reject(() => Call(settings, "BindFormalPromptOwner", nextHandle)); Reject(() => Call(club, "BindFormalPromptOwner", nextHandle)); var partialSetting = (DefaultSettingService)settingCtor.Invoke(new object[] { true }); var partialClub = (ClubService)clubCtor.Invoke(new object[] { true }); Call(partialSetting, "BindFormalPromptOwner", nextHandle); await bridge.CloseAsync(); Reject(() => Call(partialClub, "BindFormalPromptOwner", nextHandle)); Reject(() => { _ = partialSetting.ShowNotice; }); epoch++; var thirdHandle = await Open(); Reject(() => Call(partialSetting, "BindFormalPromptOwner", thirdHandle)); Check(requests == 3, "same owner binds and partial failure add no transport requests"); return JsonConvert.SerializeObject(new { success = true, caseId = "CP3A", assertions, requests, formalRequests = 0, globalContainerChanged = false }); } internal async Task Close() { var errors = new List(); foreach (var child in children) { try { await child.Close(); } catch (Exception ex) { errors.Add(ex); } } try { RestoreFaults(); } catch (Exception ex) { errors.Add(ex); } try { if (bridge != null) { await bridge.CloseAsync(); } } catch (Exception ex) { errors.Add(ex); } foreach (var session in sessions) { try { session.Dispose(); } catch (Exception ex) { errors.Add(ex); } } foreach (var go in objects) { try { if (go != null) { UnityEngine.Object.DestroyImmediate(go); } if (go != null) { throw new InvalidOperationException("Test object survived cleanup."); } } catch (Exception ex) { errors.Add(ex); } } if (errors.Count != 0) { throw new AggregateException(errors); } } private void RestoreFaults() { var errors = new List(); foreach (var restore in restoreFaults) { try { restore(); } catch (Exception ex) { errors.Add(ex); } } if (errors.Count != 0) { throw new AggregateException(errors); } restoreFaults.Clear(); } } } #endif