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:
parent
dc1649c493
commit
a20e5c68c7
19 changed files with 595 additions and 51 deletions
|
|
@ -199,6 +199,42 @@ public class DatWidgetFactoryTests
|
|||
Assert.IsType<UiScrollbar>(e);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Type11_HorizontalScrollbar_usesDirectRetailTrackAndThumbChildren()
|
||||
{
|
||||
const uint Track = 0x06004CF6u;
|
||||
const uint Thumb = 0x06005DC3u;
|
||||
var info = new ElementInfo { Type = 11, Id = 0x100001A4u, Width = 90, Height = 14 };
|
||||
var track = new ElementInfo { Id = 4u, Type = 3u, Width = 90, Height = 14 };
|
||||
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||||
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Track, 1) };
|
||||
var thumb = new ElementInfo { Id = 1u, Type = 3u, Width = 16, Height = 14 };
|
||||
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||||
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Thumb, 1) };
|
||||
info.Children.Add(track);
|
||||
info.Children.Add(thumb);
|
||||
|
||||
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
|
||||
|
||||
Assert.True(bar.Horizontal);
|
||||
Assert.Equal(Track, bar.TrackSprite);
|
||||
Assert.Equal(Thumb, bar.ThumbSprite);
|
||||
Assert.Equal(0u, bar.UpSprite);
|
||||
Assert.Equal(0u, bar.DownSprite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||||
|
||||
Assert.IsType<UiField>(layout.FindElement(0x100001A3u));
|
||||
var bar = Assert.IsType<UiScrollbar>(layout.FindElement(0x100001A4u));
|
||||
Assert.True(bar.Horizontal);
|
||||
Assert.Equal(0x06004CF6u, bar.TrackSprite);
|
||||
Assert.Equal(0x06005DC3u, bar.ThumbSprite);
|
||||
}
|
||||
|
||||
// ── Test 5e: Type 3 is NOT registered — chrome/containers stay generic ────
|
||||
//
|
||||
// Retail Type 3 = UIElement_Field, but acdream's Type-3 dat elements (vitals/chat
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ public class InventoryControllerTests
|
|||
List<(uint source, uint target)>? mergeNotices = null,
|
||||
string? ownerName = null,
|
||||
Action? onClose = null,
|
||||
SelectionState? selection = null)
|
||||
SelectionState? selection = null,
|
||||
StackSplitQuantityState? stackSplitQuantity = null)
|
||||
=> InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
strength: () => strength, datFont: null,
|
||||
|
|
@ -71,7 +72,8 @@ public class InventoryControllerTests
|
|||
sendStackableMerge: merges is null ? null : (s, t, a) => merges.Add((s, t, a)),
|
||||
notifyMergeAttempt: mergeNotices is null ? null : (s, t) => mergeNotices.Add((s, t)),
|
||||
onClose: onClose,
|
||||
selection: selection ?? new SelectionState());
|
||||
selection: selection ?? new SelectionState(),
|
||||
stackSplitQuantity: stackSplitQuantity);
|
||||
|
||||
private static UiButton MakeButton(uint id)
|
||||
{
|
||||
|
|
@ -604,6 +606,35 @@ public class InventoryControllerTests
|
|||
Assert.Equal(0xBu, selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_selectedStack_usesSharedToolbarSplitQuantity()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xA, WeenieClassId = 0x1234, StackSize = 40, StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(0xA, Player, 0);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xB, WeenieClassId = 0x1234, StackSize = 50, StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(0xB, Player, 1);
|
||||
var selection = new SelectionState();
|
||||
selection.Select(0xAu, SelectionChangeSource.Inventory);
|
||||
var split = new StackSplitQuantityState();
|
||||
split.Reset(40u);
|
||||
split.SetValue(7u);
|
||||
var merges = new List<(uint source, uint target, uint amount)>();
|
||||
var ctrl = Bind(layout, objects, merges: merges, selection: selection,
|
||||
stackSplitQuantity: split);
|
||||
|
||||
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
|
||||
|
||||
Assert.Equal(new[] { (0xAu, 0xBu, 7u) }, merges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_differentStackType_fallsThroughToNormalInventoryInsert()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -69,4 +69,29 @@ public class UiFieldTests
|
|||
for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
|
||||
Assert.True(input.HistoryCount <= 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterFilter_rejectsDisallowedInput()
|
||||
{
|
||||
var input = new UiField { CharacterFilter = static c => char.IsAsciiDigit(c) };
|
||||
|
||||
input.InsertChar('4');
|
||||
input.InsertChar('x');
|
||||
input.InsertChar('2');
|
||||
|
||||
Assert.Equal("42", input.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectAllOnFocus_survivesInitiatingMouseDown_andTypingReplacesValue()
|
||||
{
|
||||
var input = new UiField { SelectAllOnFocus = true, Selectable = true };
|
||||
input.SetText("17");
|
||||
|
||||
input.OnEvent(new UiEvent(0u, input, UiEventType.FocusGained));
|
||||
input.OnEvent(new UiEvent(0u, input, UiEventType.MouseDown, Data1: 2));
|
||||
input.InsertChar('5');
|
||||
|
||||
Assert.Equal("5", input.Text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,4 +78,26 @@ public class UiScrollbarTests
|
|||
Assert.Equal(100f, h, 3f);
|
||||
Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalScalar_clickAndDrag_updatesNormalizedValue()
|
||||
{
|
||||
float value = 1f;
|
||||
var bar = new UiScrollbar
|
||||
{
|
||||
Width = 90f,
|
||||
Height = 14f,
|
||||
Horizontal = true,
|
||||
ScalarValue = () => value,
|
||||
ScalarChanged = next => value = next,
|
||||
};
|
||||
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8)));
|
||||
Assert.Equal(0f, value, 3);
|
||||
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 45)));
|
||||
Assert.Equal(0.5f, value, 3);
|
||||
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class StackSplitQuantityStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reset_initializesToFullStack_andClampsZeroMaximumToOne()
|
||||
{
|
||||
var state = new StackSplitQuantityState();
|
||||
|
||||
state.Reset(17u);
|
||||
Assert.Equal(17u, state.Value);
|
||||
Assert.Equal(17u, state.Maximum);
|
||||
Assert.Equal(1f, state.Ratio);
|
||||
|
||||
state.Reset(0u);
|
||||
Assert.Equal(1u, state.Value);
|
||||
Assert.Equal(1u, state.Maximum);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 1u)]
|
||||
[InlineData("", 1u)]
|
||||
[InlineData("invalid", 1u)]
|
||||
[InlineData("0", 1u)]
|
||||
[InlineData("7", 7u)]
|
||||
[InlineData("999", 10u)]
|
||||
public void Text_usesWcstoulStyleParse_thenClamps(string? text, uint expected)
|
||||
{
|
||||
var state = new StackSplitQuantityState();
|
||||
state.Reset(10u);
|
||||
|
||||
state.SetFromText(text);
|
||||
|
||||
Assert.Equal(expected, state.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f, 1u)]
|
||||
[InlineData(0f, 1u)]
|
||||
[InlineData(0.099f, 1u)]
|
||||
[InlineData(0.1f, 2u)]
|
||||
[InlineData(0.5f, 6u)]
|
||||
[InlineData(0.999f, 10u)]
|
||||
[InlineData(1f, 10u)]
|
||||
[InlineData(2f, 10u)]
|
||||
public void Slider_matchesRetailThousandStepConversion(float ratio, uint expected)
|
||||
{
|
||||
var state = new StackSplitQuantityState();
|
||||
state.Reset(10u);
|
||||
|
||||
state.SetFromSliderRatio(ratio);
|
||||
|
||||
Assert.Equal(expected, state.Value);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue