feat(ui): port retail selected-stack quantity

Bind the authored stack count entry and horizontal slider to one Core split-quantity owner, preserve retail count-first naming and exact 1000-step rounding, refresh on stack changes, and consume the selected amount during merges. Conformance covers the production DAT fixture and retained pointer/focus paths.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 10:07:25 +02:00
parent dc1649c493
commit a20e5c68c7
19 changed files with 595 additions and 51 deletions

View file

@ -4,6 +4,7 @@ using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using Xunit;
@ -59,6 +60,14 @@ public class SelectedObjectControllerTests
dict[SelectedObjectController.HealthMeterId] = healthMeterEl;
root.AddChild(healthMeterEl);
var stackEntry = new UiField { Width = 50, Height = 14, Visible = true };
dict[SelectedObjectController.StackSizeEntryId] = stackEntry;
root.AddChild(stackEntry);
var stackSlider = new UiScrollbar { Width = 90, Height = 14, Visible = true };
dict[SelectedObjectController.StackSizeSliderId] = stackSlider;
root.AddChild(stackSlider);
return (new ImportedLayout(root, dict), nameEl, overlayEl, healthMeterEl);
}
@ -67,7 +76,9 @@ public class SelectedObjectControllerTests
private sealed class Harness
{
public readonly SelectionState Selection = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public Action<uint, float>? HealthHandler;
public Action<ClientObject>? ObjectUpdatedHandler;
public readonly List<uint> QueryHealthCalls = new();
public readonly Dictionary<uint, bool> HealthTargetMap = new();
@ -100,7 +111,13 @@ public class SelectedObjectControllerTests
hasHealth: g => HasHealthMap.TryGetValue(g, out var v) && v,
stackSize: g => StackMap.TryGetValue(g, out var v) ? v : 0u,
sendQueryHealth: g => QueryHealthCalls.Add(g),
datFont: datFont);
datFont: datFont,
splitQuantity: SplitQuantity,
subscribeObjectUpdated: h => ObjectUpdatedHandler = h,
unsubscribeObjectUpdated: h =>
{
if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null;
});
}
// ── B1: Bind initialisation ──────────────────────────────────────────────
@ -247,6 +264,58 @@ public class SelectedObjectControllerTests
Assert.False(healthMeterEl.Visible);
}
[Fact]
public void SelectStackedItem_showsRetailCountEntryAndSlider_atFullStack()
{
const uint Guid = 0xBB03u;
var (layout, nameEl, _, _) = FakeLayout();
var h = new Harness();
h.NameMap[Guid] = "Healing Kits";
h.StackMap[Guid] = 17u;
h.Bind(layout);
h.FireSelection(Guid);
var entry = Assert.IsType<UiField>(layout.FindElement(SelectedObjectController.StackSizeEntryId));
var slider = Assert.IsType<UiScrollbar>(layout.FindElement(SelectedObjectController.StackSizeSliderId));
Assert.True(entry.Visible);
Assert.True(slider.Visible);
Assert.Equal("17", entry.Text);
Assert.Equal(17u, h.SplitQuantity.Value);
Assert.Equal(17u, h.SplitQuantity.Maximum);
Assert.Equal(1f, slider.ScalarValue!());
Assert.Equal("17 Healing Kits", nameEl.Children.OfType<UiText>().Single().LinesProvider().Single().Text);
slider.ScalarChanged!(0.5f);
Assert.Equal(9u, h.SplitQuantity.Value);
Assert.Equal("9", entry.Text);
entry.SetText("999");
entry.Submit();
Assert.Equal(17u, h.SplitQuantity.Value);
Assert.Equal("17", entry.Text);
}
[Fact]
public void StackSizeUpdate_refreshesSelectedControls_andHidesThemAtOne()
{
const uint Guid = 0xBB04u;
var (layout, _, _, _) = FakeLayout();
var h = new Harness();
h.NameMap[Guid] = "Healing Kits";
h.StackMap[Guid] = 5u;
h.Bind(layout);
h.FireSelection(Guid);
h.StackMap[Guid] = 1u;
h.ObjectUpdatedHandler!(new ClientObject { ObjectId = Guid, StackSize = 1 });
Assert.False(layout.FindElement(SelectedObjectController.StackSizeEntryId)!.Visible);
Assert.False(layout.FindElement(SelectedObjectController.StackSizeSliderId)!.Visible);
Assert.Equal(1u, h.SplitQuantity.Value);
Assert.Equal(1u, h.SplitQuantity.Maximum);
}
// ── H3: Non-health target (friendly NPC / scenery / Door) ───────────────
[Fact]