using System.Numerics;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Panels.Debug;
namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
///
/// Tests for the Phase I.2 — read-through ViewModel
/// for the migrated debug panel. Verifies the combat-event subscription
/// (replacing the old DebugOverlay.BindCombat), the toast ring
/// cap, and that the diagnostic-flag bools round-trip without affecting
/// the rest of the VM.
///
public sealed class DebugVMTests
{
///
/// Build a minimal with safe defaults for every
/// constructor source. Tests that don't care about a particular source
/// just leave it stubbed out.
///
private static DebugVM NewVm(CombatState? combat = null)
{
combat ??= new CombatState();
return new DebugVM(
getPlayerPosition: () => Vector3.Zero,
getPlayerHeadingDeg: () => 0f,
getPlayerCellId: () => 0u,
getPlayerOnGround: () => true,
getInPlayerMode: () => false,
getInFlyMode: () => false,
getVerticalVelocity: () => 0f,
getEntityCount: () => 0,
getAnimatedCount: () => 0,
getLandblocksVisible: () => 0,
getLandblocksTotal: () => 0,
getShadowObjectCount: () => 0,
getNearestObjDist: () => float.PositiveInfinity,
getNearestObjLabel: () => "-",
getColliding: () => false,
getDebugWireframes: () => false,
getStreamingRadius: () => 2,
getMouseSensitivity: () => 1f,
getChaseDistance: () => 0f,
getRmbOrbit: () => false,
getHourName: () => "Dawnsong",
getDayFraction: () => 0.25f,
getWeather: () => "Clear",
getActiveLights: () => 0,
getRegisteredLights: () => 0,
getParticleCount: () => 0,
getFps: () => 60f,
getFrameMs: () => 16.7f,
combat: combat);
}
[Fact]
public void Constructor_ThrowsOnNullCombat()
{
Assert.Throws(() => new DebugVM(
getPlayerPosition: () => Vector3.Zero,
getPlayerHeadingDeg: () => 0f,
getPlayerCellId: () => 0u,
getPlayerOnGround: () => true,
getInPlayerMode: () => false,
getInFlyMode: () => false,
getVerticalVelocity: () => 0f,
getEntityCount: () => 0,
getAnimatedCount: () => 0,
getLandblocksVisible: () => 0,
getLandblocksTotal: () => 0,
getShadowObjectCount: () => 0,
getNearestObjDist: () => 0f,
getNearestObjLabel: () => "-",
getColliding: () => false,
getDebugWireframes: () => false,
getStreamingRadius: () => 0,
getMouseSensitivity: () => 1f,
getChaseDistance: () => 0f,
getRmbOrbit: () => false,
getHourName: () => "",
getDayFraction: () => 0f,
getWeather: () => "",
getActiveLights: () => 0,
getRegisteredLights: () => 0,
getParticleCount: () => 0,
getFps: () => 0f,
getFrameMs: () => 0f,
combat: null!));
}
[Fact]
public void ReadThrough_PullsLiveValuesPerAccess_NoCache()
{
// The VM does NOT cache; every property read goes through the
// backing Func. Mutating an external box between two reads
// must surface immediately.
int counter = 0;
var vm = new DebugVM(
getPlayerPosition: () => Vector3.Zero,
getPlayerHeadingDeg: () => 0f,
getPlayerCellId: () => 0u,
getPlayerOnGround: () => true,
getInPlayerMode: () => false,
getInFlyMode: () => false,
getVerticalVelocity: () => 0f,
getEntityCount: () => ++counter,
getAnimatedCount: () => 0,
getLandblocksVisible: () => 0,
getLandblocksTotal: () => 0,
getShadowObjectCount: () => 0,
getNearestObjDist: () => 0f,
getNearestObjLabel: () => "-",
getColliding: () => false,
getDebugWireframes: () => false,
getStreamingRadius: () => 0,
getMouseSensitivity: () => 1f,
getChaseDistance: () => 0f,
getRmbOrbit: () => false,
getHourName: () => "",
getDayFraction: () => 0f,
getWeather: () => "",
getActiveLights: () => 0,
getRegisteredLights: () => 0,
getParticleCount: () => 0,
getFps: () => 0f,
getFrameMs: () => 0f,
combat: new CombatState());
Assert.Equal(1, vm.EntityCount);
Assert.Equal(2, vm.EntityCount);
Assert.Equal(3, vm.EntityCount);
}
[Fact]
public void DamageTaken_AppendsErrorEvent()
{
var combat = new CombatState();
var vm = NewVm(combat);
combat.OnVictimNotification(
attackerName: "Drudge", attackerGuid: 0x10u,
damageType: 0u, damage: 12u, hitQuadrant: 0u,
critical: 0u, attackType: 0u);
var events = vm.CombatEvents.ToList();
Assert.Single(events);
Assert.Equal(CombatEventKind.Error, events[0].Kind);
Assert.Contains("Drudge", events[0].Text);
Assert.Contains("12", events[0].Text);
}
[Fact]
public void DamageDealt_AppendsInfoEvent()
{
var combat = new CombatState();
var vm = NewVm(combat);
combat.OnAttackerNotification(
defenderName: "Drudge", damageType: 0u,
damage: 8u, damagePercent: 0.1f);
var events = vm.CombatEvents.ToList();
Assert.Single(events);
Assert.Equal(CombatEventKind.Info, events[0].Kind);
Assert.Contains("Drudge", events[0].Text);
}
[Fact]
public void EvadedIncoming_AppendsWarnEvent()
{
var combat = new CombatState();
var vm = NewVm(combat);
combat.OnEvasionDefenderNotification("Mosswart");
var events = vm.CombatEvents.ToList();
Assert.Single(events);
Assert.Equal(CombatEventKind.Warn, events[0].Kind);
Assert.Contains("Mosswart", events[0].Text);
}
[Fact]
public void CombatEventRing_CapsAtMax_DropsOldest()
{
var combat = new CombatState();
var vm = NewVm(combat);
// Pump well past the cap (25). The oldest entries must drop out.
for (int i = 0; i < 40; i++)
{
combat.OnAttackerNotification(
defenderName: $"Foe{i}", damageType: 0u,
damage: (uint)i, damagePercent: 0.1f);
}
var events = vm.CombatEvents.ToList();
Assert.Equal(DebugVM.MaxCombatEvents, events.Count);
// The newest entry must still be present.
Assert.Contains(events, e => e.Text.Contains("Foe39"));
// The oldest must have dropped.
Assert.DoesNotContain(events, e => e.Text.Contains("Foe0,") || e.Text == "Foe0");
Assert.DoesNotContain(events, e => e.Text.Contains("Foe5"));
}
[Fact]
public void Toast_RingCapsAtMaxRecent()
{
var vm = NewVm();
for (int i = 0; i < 30; i++)
vm.AddToast($"toast-{i}");
var toasts = vm.RecentToasts.ToList();
Assert.Equal(DebugVM.MaxRecentToasts, toasts.Count);
Assert.Contains(toasts, t => t.Text == "toast-29");
Assert.DoesNotContain(toasts, t => t.Text == "toast-0");
}
[Fact]
public void Toast_PreservesKind()
{
var vm = NewVm();
vm.AddToast("warning!", ToastKind.Warn);
vm.AddToast("error!", ToastKind.Error);
var toasts = vm.RecentToasts.ToList();
Assert.Equal(2, toasts.Count);
Assert.Contains(toasts, t => t.Text == "warning!" && t.Kind == ToastKind.Warn);
Assert.Contains(toasts, t => t.Text == "error!" && t.Kind == ToastKind.Error);
}
[Fact]
public void DiagnosticFlags_DefaultFalse_RoundTripIndependently()
{
var vm = NewVm();
Assert.False(vm.DumpMotion);
Assert.False(vm.DumpVitals);
Assert.False(vm.DumpOpcodes);
Assert.False(vm.DumpSky);
vm.DumpMotion = true;
Assert.True(vm.DumpMotion);
Assert.False(vm.DumpVitals);
Assert.False(vm.DumpOpcodes);
Assert.False(vm.DumpSky);
vm.DumpSky = true;
vm.DumpMotion = false;
Assert.False(vm.DumpMotion);
Assert.True(vm.DumpSky);
}
[Fact]
public void ToggleFlags_DoNotAffectCombatRing()
{
var combat = new CombatState();
var vm = NewVm(combat);
combat.OnAttackerNotification("X", 0u, 1u, 0.1f);
Assert.Single(vm.CombatEvents);
vm.DumpMotion = true;
vm.DumpVitals = true;
Assert.Single(vm.CombatEvents);
}
[Fact]
public void ActionHooks_InvokeSuppliedDelegates()
{
// The panel needs to invoke "cycle time", "cycle weather", "toggle
// collision wires" actions when the corresponding Button is
// clicked. The VM exposes these as Action; the panel calls them.
int timeHits = 0, weatherHits = 0, wireHits = 0;
var vm = NewVm();
vm.CycleTimeOfDay = () => timeHits++;
vm.CycleWeather = () => weatherHits++;
vm.ToggleCollisionWires = () => wireHits++;
vm.CycleTimeOfDay?.Invoke();
vm.CycleTimeOfDay?.Invoke();
vm.CycleWeather?.Invoke();
vm.ToggleCollisionWires?.Invoke();
Assert.Equal(2, timeHits);
Assert.Equal(1, weatherHits);
Assert.Equal(1, wireHits);
}
}