acdream/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs
Erik d42bf5735d feat(player): #5 LocalPlayerState — Stam/Mana wired through PlayerDescription
Closes ISSUES.md #5. The Vitals devtools window now draws three bars
(HP / Stamina / Mana) once the server sends the first PlayerDescription
(0x0013), instead of HP only. Built test-first per CLAUDE.md TDD rule —
16 new tests went red before the implementation went in.

New AcDream.Core.Player.LocalPlayerState (cache):
  - {CurrentStamina, MaxStamina, CurrentMana, MaxMana} as uint? — null
    until first received.
  - StaminaPercent / ManaPercent: 0..1 fraction or null when either
    field is missing or max is zero. Clamps to 1.0 if current > max
    (server can briefly report this during buff transitions).
  - OnPlayerDescription preserves any previously known good value when
    an incoming field is null — partial profiles don't wipe state.
  - Changed event for future subscribers.

GameEventWiring.WireAll:
  - New optional 6th parameter: LocalPlayerState? localPlayer = null.
    Existing 5-arg call sites still work; without the parameter the new
    PlayerDescription handler still parses + feeds the spellbook but
    skips the cache update.
  - PlayerDescription (0x0013) shares AppraiseInfo wire format with
    IdentifyObjectResponse (0x00C9) per AppraiseInfoParser docstring,
    so the new handler reuses the existing parser and pulls
    CreatureProfile.{Stamina, StaminaMax, Mana, ManaMax}.
  - Player's full learned spellbook also lands here (previously only
    item-scoped Identify responses fed the spellbook).

VitalsVM:
  - Constructor adds optional LocalPlayerState? parameter (default null
    keeps every existing caller compiling).
  - StaminaPercent / ManaPercent now read through to LocalPlayerState
    every access — no VM-side caching, so a server-side delta to the
    cache surfaces next frame without any explicit refresh.

GameWindow:
  - Public readonly LocalPlayer field alongside Combat / Chat / Items /
    SpellBook so plugins + future panels can bind directly.
  - WireAll call updated to pass LocalPlayer.
  - VitalsVM construction passes LocalPlayer so the existing
    VitalsPanel automatically picks up the two new bars.

Test counts:
  - AcDream.Core.Tests:           550 → 561  (+11 LocalPlayerStateTests)
  - AcDream.UI.Abstractions.Tests: 23 →  26  (+3 VitalsVM through-cache)
  - AcDream.Core.Net.Tests:       192 → 194  (+2 PlayerDescription wiring)
  - Total:                        765 → 781

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:02:00 +02:00

126 lines
4.1 KiB
C#

using AcDream.Core.Combat;
using AcDream.Core.Player;
using AcDream.UI.Abstractions.Panels.Vitals;
namespace AcDream.UI.Abstractions.Tests;
public sealed class VitalsVMTests
{
[Fact]
public void HealthPercent_ReturnsCombatStateValue_AfterUpdateHealth()
{
var combat = new CombatState();
uint guid = 0x5000_0042u;
combat.OnUpdateHealth(guid, 0.42f);
var vm = new VitalsVM(combat);
vm.SetLocalPlayerGuid(guid);
Assert.Equal(0.42f, vm.HealthPercent, precision: 3);
}
[Fact]
public void HealthPercent_ReturnsOne_WhenGuidUnknown()
{
var combat = new CombatState();
var vm = new VitalsVM(combat);
// No SetLocalPlayerGuid call — defaults to 0 which CombatState has never seen.
Assert.Equal(1f, vm.HealthPercent);
}
[Fact]
public void HealthPercent_ReturnsOne_WhenGuidSetButNeverUpdated()
{
var combat = new CombatState();
var vm = new VitalsVM(combat);
vm.SetLocalPlayerGuid(0xDEAD_BEEFu);
Assert.Equal(1f, vm.HealthPercent);
}
[Fact]
public void StaminaPercent_IsNull_WhenNoLocalPlayerStateProvided()
{
// Back-compat with the original D.2a constructor — when no
// LocalPlayerState is wired, Stam/Mana remain null and
// VitalsPanel skips drawing those bars.
var vm = new VitalsVM(new CombatState());
Assert.Null(vm.StaminaPercent);
}
[Fact]
public void ManaPercent_IsNull_WhenNoLocalPlayerStateProvided()
{
var vm = new VitalsVM(new CombatState());
Assert.Null(vm.ManaPercent);
}
[Fact]
public void StaminaPercent_FromLocalPlayerState_AfterPlayerDescription()
{
// Issue #5 — once a LocalPlayerState is wired and the server has
// sent a PlayerDescription with CreatureProfile, the Stam bar
// surfaces the correct percent without any VM-level caching.
var local = new LocalPlayerState();
local.OnPlayerDescription(currentStamina: 80, maxStamina: 100,
currentMana: null, maxMana: null);
var vm = new VitalsVM(new CombatState(), local);
Assert.Equal(0.8f, vm.StaminaPercent!.Value, precision: 3);
}
[Fact]
public void ManaPercent_FromLocalPlayerState_AfterPlayerDescription()
{
var local = new LocalPlayerState();
local.OnPlayerDescription(currentStamina: null, maxStamina: null,
currentMana: 25, maxMana: 100);
var vm = new VitalsVM(new CombatState(), local);
Assert.Equal(0.25f, vm.ManaPercent!.Value, precision: 3);
}
[Fact]
public void Vm_ReadsThroughToLocalPlayerState_NoStaleCache()
{
// Verify the VM doesn't snapshot — it should read live from the
// LocalPlayerState every property access so a server delta picks
// up next frame without any explicit refresh call.
var local = new LocalPlayerState();
var vm = new VitalsVM(new CombatState(), local);
Assert.Null(vm.StaminaPercent); // no data yet
local.OnPlayerDescription(currentStamina: 50, maxStamina: 100,
currentMana: 50, maxMana: 100);
Assert.Equal(0.5f, vm.StaminaPercent!.Value, precision: 3);
Assert.Equal(0.5f, vm.ManaPercent!.Value, precision: 3);
}
[Fact]
public void SetLocalPlayerGuid_ReroutesHealthLookup_WithoutStaleCache()
{
// Simulate the realistic GameWindow flow: VM is constructed pre-login
// with GUID=0, then SetLocalPlayerGuid is called at EnterWorld.
var combat = new CombatState();
uint playerGuid = 0x5003_E219u;
combat.OnUpdateHealth(playerGuid, 0.75f);
var vm = new VitalsVM(combat);
// Before SetLocalPlayerGuid — reads GUID=0 → returns safe 1.0.
Assert.Equal(1f, vm.HealthPercent);
vm.SetLocalPlayerGuid(playerGuid);
Assert.Equal(0.75f, vm.HealthPercent, precision: 3);
}
[Fact]
public void Constructor_ThrowsOnNullCombat()
{
Assert.Throws<ArgumentNullException>(() => new VitalsVM(null!));
}
}