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); } }