Files
ft/Client/Assets/Editor/KVPromptStateValidation.cs

633 lines
29 KiB
C#

#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<string> 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<Exception>();
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<HttpRequestMessage, HttpResponseMessage> send;
internal Handler(Func<HttpRequestMessage, HttpResponseMessage> send) { this.send = send; }
protected override Task<HttpResponseMessage> 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<FixedRun> children = new List<FixedRun>();
private readonly List<Action> restoreFaults = new List<Action>();
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<S101AccountSession> sessions = new List<S101AccountSession>();
readonly List<GameObject> objects = new List<GameObject>();
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<T>(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<PlayerKVGameSessionHandle> 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<T>() where T : Component
{
var go = new GameObject("B01-" + typeof(T).Name) { hideFlags = HideFlags.HideAndDontSave };
go.SetActive(false); objects.Add(go);
return go.AddComponent<T>();
}
internal async Task<string> Execute()
{
Func<CookieContainer, HttpMessageHandler> 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<string, string>() });
});
bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance,
null, new[] { typeof(Func<CookieContainer, HttpMessageHandler>), typeof(bool) }, null).Invoke(new object[] { factory, false });
var handle = await Open();
var settings = Bound<DefaultSettingService>(handle);
var club = Bound<ClubService>(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<DefaultSettingService>(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<SettingNotificationsGradePopupPanel>();
Field(grade, "settings", nextSettings); Field(grade, "notifications", notifications);
var popup = Panel<SettingNotificationsPopupPanel>(); Field(popup, "notifications", notifications);
var settingPanel = Panel<SettingPopupPanel>(); Field(settingPanel, "settingService", nextSettings); Field(settingPanel, "notificationService", notifications);
var gift = Panel<GiftNoticePanel>(); Field(gift, "notificationService", notifications);
var social = Panel<FishingSocialPanel>(); Field(social, "_socialData", club);
var errors = new List<string>();
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<string> 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<PlayerKVGameSessionHandle> OpenPrompt()
{
Func<CookieContainer, HttpMessageHandler> factory = jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "playerdata/Get"), "P6D synthetic Get only");
requests++;
return Json(new { data = new Dictionary<string, string>() });
});
bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance,
null, new[] { typeof(Func<CookieContainer, HttpMessageHandler>), 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<bool> callback;
internal RedProbe(Action<bool> callback)
{
this.callback = callback;
}
public void SetRedPointState(bool state)
{
callback(state);
}
}
private void ObserveRed(Action<bool> callback)
{
RedPointManager.Instance.RemoveRedPoint(RedPointName.Home_Club);
RedPointManager.Instance.AddRedPoint(RedPointName.Home_Club, new RedProbe(callback));
}
private static Delegate FaultDelegate<T>(Action<string> callback)
{
return new Action<T>(stage => callback(stage.ToString()));
}
private void InjectFault(PlayerKVGameSessionHandle handle, Action<string> 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<DefaultSettingService>(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<DefaultSettingService>(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<ClubService>(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<DefaultSettingService>(handle);
var club = Bound<ClubService>(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<DefaultSettingService>(handle);
var club = Bound<ClubService>(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<ClubService>(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<string> ExecuteCP3A()
{
Func<CookieContainer, HttpMessageHandler> factory = jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "playerdata/Get"), "CP3A synthetic Get only");
requests++;
return Json(new { data = new Dictionary<string, string>() });
});
bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance,
null, new[] { typeof(Func<CookieContainer, HttpMessageHandler>), 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<Exception>();
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<Exception>();
foreach (var restore in restoreFaults)
{
try
{
restore();
}
catch (Exception ex)
{
errors.Add(ex);
}
}
if (errors.Count != 0)
{
throw new AggregateException(errors);
}
restoreFaults.Clear();
}
}
}
#endif