refactor(ui): own retained controller lifetimes

This commit is contained in:
Erik 2026-07-10 23:35:26 +02:00
parent 921c388e2c
commit 5d9e98c118
21 changed files with 373 additions and 35 deletions

View file

@ -82,7 +82,15 @@ public class SelectedObjectControllerTests
=> SelectedObjectController.Bind(
layout,
subscribeSelectionChanged: h => SelectionHandler = h,
unsubscribeSelectionChanged: h =>
{
if (SelectionHandler == h) SelectionHandler = null;
},
subscribeHealthChanged: h => HealthHandler = h,
unsubscribeHealthChanged: h =>
{
if (HealthHandler == h) HealthHandler = null;
},
isHealthTarget: g => HealthTargetMap.TryGetValue(g, out var v) && v,
name: g => NameMap.TryGetValue(g, out var v) ? v : null,
healthPercent: g => HealthMap.TryGetValue(g, out var v) ? v : 1f,
@ -403,4 +411,23 @@ public class SelectedObjectControllerTests
h.FireSelection(null);
Assert.Equal(0f, healthMeterEl.Fill() ?? 0f);
}
[Fact]
public void Dispose_UnsubscribesBothHandlers_AndIsIdempotent()
{
var (layout, _, _, healthMeterEl) = FakeLayout();
var h = new Harness();
h.HealthTargetMap[0xAA09u] = true;
var controller = h.Bind(layout);
controller.Dispose();
controller.Dispose();
h.FireSelection(0xAA09u);
h.FireHealth(0xAA09u, 0.5f);
Assert.Null(h.SelectionHandler);
Assert.Null(h.HealthHandler);
Assert.False(healthMeterEl.Visible);
Assert.Empty(h.QueryHealthCalls);
}
}

View file

@ -98,6 +98,28 @@ public class ToolbarControllerTests
Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded
}
[Fact]
public void Dispose_UnsubscribesDeferredRebind_AndIsIdempotent()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
var controller = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { });
controller.Dispose();
controller.Dispose();
repo.AddOrUpdate(new ClientObject
{
ObjectId = 0x5002u,
WeenieClassId = 1u,
IconId = 0x06005678u,
});
Assert.Equal(0u, slots[Row1[2]].Cell.ItemId);
}
[Fact]
public void Click_emitsUseForBoundItem()
{

View file

@ -179,6 +179,28 @@ public sealed class RetailWindowManagerTests
Assert.Equal(1, secondController.DisposeCount);
}
[Fact]
public void AttachController_AfterRegistration_DeliversVisibilityAndOwnsDisposal()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Width = 100, Height = 100 };
root.AddChild(frame);
RetailWindowHandle handle = root.RegisterWindow("inventory", frame);
var controller = new RecordingController();
root.WindowManager.AttachController("inventory", controller);
Assert.Same(controller, handle.Controller);
Assert.Equal(1, controller.ShownCount);
Assert.Throws<InvalidOperationException>(() =>
root.WindowManager.AttachController("inventory", new RecordingController()));
root.WindowManager.Dispose();
root.WindowManager.Dispose();
Assert.Equal(1, controller.HiddenCount);
Assert.Equal(1, controller.DisposeCount);
}
private sealed class RecordingController : IRetainedPanelController
{
public int ShownCount { get; private set; }

View file

@ -0,0 +1,42 @@
using System.Collections.Generic;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class RetainedPanelControllerGroupTests
{
[Fact]
public void Lifecycle_ForwardsInOwnershipOrder_AndDisposesInReverseExactlyOnce()
{
var calls = new List<string>();
var first = new RecordingController("first", calls);
var second = new RecordingController("second", calls);
var group = new RetainedPanelControllerGroup(first, second);
var focus = new UiPanel();
group.OnShown();
group.OnDescendantFocusChanged(focus);
group.OnHidden();
group.Dispose();
group.Dispose();
Assert.Equal(
[
"first:shown", "second:shown",
"first:focus", "second:focus",
"second:hidden", "first:hidden",
"second:dispose", "first:dispose",
], calls);
}
private sealed class RecordingController(
string name,
List<string> calls) : IRetainedPanelController
{
public void OnShown() => calls.Add($"{name}:shown");
public void OnHidden() => calls.Add($"{name}:hidden");
public void OnDescendantFocusChanged(UiElement? focusedDescendant)
=> calls.Add($"{name}:focus");
public void Dispose() => calls.Add($"{name}:dispose");
}
}