Files
mod_menu_base/test_suites/resettables/src/ResettablesTestDriverNode.cs
T

238 lines
10 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Loader;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Games;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Godot;
namespace Mods.ResettablesTest
{
/// <summary>
/// Plan 3.1: a mod that leaves the set takes its new-game owner
/// (<c>IResettable</c>) with it, and a mod loaded again registers exactly one
/// owner, from its new assembly. The stats mod (<c>stats_base</c>, id
/// <c>stats</c>) is the probe: it is in <c>rpg_plus</c> only.
/// <list type="number">
/// <item><c>rpg_plus</c> (new game): one <c>stats</c> owner, from the live
/// <c>stats_base</c> context; a character gains a stat.</item>
/// <item><c>novel_template</c>: <c>stats_base</c> unloaded, no <c>stats</c>
/// owner left, its old context released.</item>
/// <item><c>rpg_plus</c> again: exactly one <c>stats</c> owner, from the new
/// context; the character gains a stat again.</item>
/// <item>New game: the loaded state is reset (no characters left).</item>
/// </list>
/// The state is read through the mod's own types (reflection), because a suite
/// cannot depend on <c>stats_base</c> without keeping it loaded in every game.
/// Prints <c>[TEST_RESULT] name=resettables</c>.
/// </summary>
public partial class ResettablesTestDriverNode : Node
{
public const string Flag = "--itest=resettables";
private const string StatsMod = "stats_base";
private const string StatsOwnerId = "stats";
private const string StatStateType = "Cthangover.Mods.Stats.StatState";
private const string Probe = "itest_resettables";
private const string GameWithStats = "rpg_plus";
private const string GameWithoutStats = "novel_template";
private const float TimeoutSeconds = 420f;
private readonly List<string> failures = new();
private readonly List<string> unloadReport = new();
private int passed;
private double elapsed;
private bool started;
private bool done;
public override void _Ready()
{
// The driver keeps ticking across every mod-set switch of the run.
ProcessMode = ProcessModeEnum.Always;
GameLogger.LogEmitted += OnLogEmitted;
}
public override void _ExitTree()
{
GameLogger.LogEmitted -= OnLogEmitted;
}
private void OnLogEmitted(string category, string message, LogLevel level)
{
if (category != "MODS_UNLOAD")
return;
lock (unloadReport)
unloadReport.Add(message);
}
public override void _Process(double delta)
{
if (done)
return;
elapsed += delta;
if (elapsed > TimeoutSeconds)
{
failures.Add("timeout");
Finish();
return;
}
if (started || elapsed < 3 || !Boot.IsReady || GetTree().CurrentScene == null)
return;
if (GameSession.IsSwitching || ModManager.Instance.IsReloading)
return;
started = true;
RunAsync();
}
private async void RunAsync()
{
try
{
await Run();
}
catch (Exception ex)
{
failures.Add($"unhandled {ex.GetType().Name}: {ex.Message}");
}
Finish();
}
private async System.Threading.Tasks.Task Run()
{
// 1. Enter the game with the stats mod: one owner, from the live context.
var entered = await GameSession.SwitchToAsync(this, GameWithStats);
Check($"switched into '{GameWithStats}'", entered && ActiveGame.Id == GameWithStats);
Check($"'{GameWithStats}' released every old context", ModManager.Instance.LastReload?.Leaked.Count == 0);
Check($"'{StatsMod}' is loaded in '{GameWithStats}'", LiveStatsContexts().Count == 1);
Check("one stats owner is registered", StatsEntries().Count == 1);
Check("the stats owner comes from the loaded stats mod", OwnerIsLive());
Check("stats state is reachable", StateReachable());
MarkProbe();
Check("the stats state holds the probe before leaving", ProbeCount() == 1);
// 2. Leave for a game without the stats mod: its owner goes with it.
lock (unloadReport)
unloadReport.Clear();
entered = await GameSession.SwitchToAsync(this, GameWithoutStats);
Check($"switched into '{GameWithoutStats}'", entered && ActiveGame.Id == GameWithoutStats);
Check($"'{StatsMod}' left the set", ModManager.Instance.LastReload?.Plan?.Unload.Contains(StatsMod) == true);
Check($"'{GameWithoutStats}' released the old stats context", ModManager.Instance.LastReload?.Leaked.Count == 0);
Check("no stats owner remains after it left", StatsEntries().Count == 0);
Check("no stats context stays alive", LiveStatsContexts().Count == 0);
// The unload pass drops the owners itself; the static sweep dropping them
// behind the owner's back is only a fallback that the report names.
lock (unloadReport)
{
Check("the unload pass dropped the mod's new-game owners itself", unloadReport.Any(m => m.Contains("new-game resettable(s) of mod code")));
Check("the static sweep did not have to drop a new-game owner", !unloadReport.Any(m => m.Contains("AppServices.resettables: removed")));
}
// 3. Back with the stats mod: a single owner, from the new context.
entered = await GameSession.SwitchToAsync(this, GameWithStats);
Check($"switched back into '{GameWithStats}'", entered && ActiveGame.Id == GameWithStats);
Check($"'{GameWithStats}' (again) released every old context", ModManager.Instance.LastReload?.Leaked.Count == 0);
Check("exactly one stats owner after the reload", StatsEntries().Count == 1);
Check("the stats owner comes from the reloaded stats mod", OwnerIsLive());
Check("stats state is reachable after the reload", StateReachable());
MarkProbe();
Check("the reloaded state holds the probe before New game", ProbeCount() == 1);
// 4. New game resets the state of the loaded mod.
GameData.Instance.StartNewGame();
Check("New game reset the stats of the loaded mod", ProbeCount() == 0);
Check($"still in '{GameWithStats}' after New game", ActiveGame.Id == GameWithStats);
}
/// <summary>Live (not yet released) contexts of the stats mod.</summary>
private static List<ModLoadContext> LiveStatsContexts()
=> AssemblyLoadContext.All.OfType<ModLoadContext>()
.Where(c => c.ModId == StatsMod && !c.IsReleased)
.ToList();
private static List<IResettable> StatsEntries()
=> AppServices.Current.Resettables.Where(r => r.Id == StatsOwnerId).ToList();
/// <summary>True when the single stats owner's assembly is one of the live stats mod's assemblies.</summary>
private static bool OwnerIsLive()
{
var entries = StatsEntries();
if (entries.Count != 1)
return false;
var owner = entries[0].GetType().Assembly;
return LiveStatsContexts().Any(c => c.Assemblies.Contains(owner));
}
// The state objects below are only ever used inside these helpers: a local
// holding a StatState would keep the stats context alive across the next
// switch (debug builds extend locals to the end of the method).
/// <summary>Reads <c>StatState.Instance</c> of the live stats mod; null when it is not reachable.</summary>
private static object CurrentStatState()
{
foreach (var context in LiveStatsContexts())
{
foreach (var assembly in context.Assemblies)
{
var type = assembly.GetType(StatStateType);
if (type != null)
return type.GetProperty("Instance")?.GetValue(null);
}
}
return null;
}
private static bool StateReachable() => CurrentStatState() != null;
/// <summary>Gives the loaded stats state one character with a gained stat.</summary>
private static void MarkProbe()
{
var state = CurrentStatState();
state?.GetType().GetMethod("Of", new[] { typeof(string) })?.Invoke(state, new object[] { Probe });
}
/// <summary>Number of characters with stat gains in the loaded stats state; -1 when unreachable.</summary>
private static int ProbeCount()
{
var state = CurrentStatState();
var data = state?.GetType().GetProperty("Data")?.GetValue(state);
var characters = data?.GetType().GetProperty("Characters")?.GetValue(data) as IDictionary;
return characters?.Count ?? -1;
}
private void Check(string name, bool ok)
{
if (ok)
{
passed++;
GameLogger.Log("TEST", $"ResettablesTest: [OK] {name}");
}
else
{
failures.Add(name);
GameLogger.Log("TEST", $"ResettablesTest: [FAIL] {name}", LogLevel.Error);
}
}
private void Finish()
{
if (done)
return;
done = true;
foreach (var failure in failures)
GameLogger.Log("TEST", $"ResettablesTest: failed: {failure}");
GameLogger.Log("TEST", $"[TEST_RESULT] name=resettables passed={passed} failed={failures.Count}");
ApplicationExit.Quit(GetTree(), failures.Count == 0 ? 0 : 1);
}
}
}