feat(ui): port retained widget foundations

This commit is contained in:
Erik 2026-07-10 17:55:41 +02:00
parent 44f9ec13d9
commit d825572e31
44 changed files with 84813 additions and 292 deletions

View file

@ -1528,10 +1528,12 @@ public class CharacterStatControllerTests
};
}
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
/// <summary>Manufacture a minimal UiButton with retail normal/ghosted states.</summary>
private static UiButton MakeButton(uint id = 0u)
{
var info = new ElementInfo { Id = id, Type = 1 };
info.StateMedia["Normal"] = (1u, 1);
info.StateMedia["Ghosted"] = (2u, 1);
return new UiButton(info, static _ => (0u, 0, 0));
}

View file

@ -1,3 +1,4 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
@ -43,4 +44,18 @@ public class ChatLayoutConformanceTests
Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript)
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input)
}
[Fact]
public void ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType<UiText>(layout.FindElement(0x10000011u));
var input = Assert.IsType<UiField>(layout.FindElement(0x10000016u));
Assert.True(transcript.Selectable);
Assert.True(input.Selectable);
Assert.True(input.OneLine);
Assert.Equal(0x10000016u, input.DatElementId);
}
}

View file

@ -15,9 +15,8 @@ namespace AcDream.App.Tests.UI.Layout;
/// real chat layout hierarchy (root → transcript panel + input bar as Type-3
/// containers, with Type-12 children for transcript + input, plus a Type-3 track
/// and menu), call <see cref="LayoutImporter.Build"/> to get the widget tree
/// (Type-12 children skipped, Type-3 parents created), then call
/// <see cref="ChatWindowController.Bind"/> which reads rects from the info tree
/// and places behavioral widgets under the parent containers.
/// (Type-12 children become property-driven text/field widgets), then call
/// <see cref="ChatWindowController.Bind"/> which binds those widgets in place.
/// </summary>
public class ChatWindowControllerTests
{
@ -42,7 +41,7 @@ public class ChatWindowControllerTests
/// track (Type-3) [0x10000012] ← Type-3 in test (not Type-11); Bind skips scrollbar bind
/// inputBar (Type-3) [0x10000013]
/// menu (Type-6) [0x10000014]
/// input (Type-12, no media) [0x10000016] ← built as UiText by factory; Bind removes + replaces with UiField
/// input (Type-12, Editable+Selectable) [0x10000016] ← built as UiField
/// send (Type-3) [0x10000019]
/// maxmin (Type-3) [0x1000046F]
/// </summary>
@ -71,9 +70,26 @@ public class ChatWindowControllerTests
};
var inputNode = new ElementInfo
{
Id = 0x10000016u, Type = 12, // Type-12, no media → skipped by factory
Id = 0x10000016u, Type = 12,
X = 46, Y = 0, Width = 398, Height = 17,
};
var inputState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
inputState.Properties.Values[0x16u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputState.Properties.Values[0x20u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputState.Properties.Values[0x27u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputNode.States[UiStateInfo.DirectStateId] = inputState;
var sendNode = new ElementInfo
{
Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17,

View file

@ -42,7 +42,42 @@ public class DatWidgetFactoryTests
public void Type12_Text_MakesUiText()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
Assert.IsType<UiText>(e);
var text = Assert.IsType<UiText>(e);
Assert.False(text.Selectable);
Assert.True(text.ClickThrough);
Assert.False(text.AcceptsFocus);
Assert.False(text.CapturesPointerDrag);
}
[Fact]
public void Type12_EditableProperty_MakesUiFieldInPlace()
{
var info = TextInfo(
(0x16u, Bool(true)),
(0x20u, Bool(true)),
(0x27u, Bool(true)),
(0x1Eu, Integer(80)));
var field = Assert.IsType<UiField>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(info.Id, field.ElementId);
Assert.True(field.OneLine);
Assert.True(field.Selectable);
Assert.Equal(80, field.MaxCharacters);
}
[Fact]
public void Type12_SelectableProperty_MakesSelectableUiText()
{
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
TextInfo((0x27u, Bool(true))),
NoTex,
null));
Assert.True(text.Selectable);
Assert.False(text.ClickThrough);
Assert.True(text.AcceptsFocus);
Assert.True(text.CapturesPointerDrag);
}
// ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
@ -74,6 +109,46 @@ public class DatWidgetFactoryTests
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
}
[Fact]
public void ImportedDescendant_PreservesExactRawEdgePolicy()
{
var info = new ElementInfo
{
Type = 3,
X = 10,
Y = 20,
Width = 30,
Height = 40,
Left = 4,
Top = 3,
Right = 1,
Bottom = 0,
OriginalParentWidth = 100,
OriginalParentHeight = 200,
HasOriginalParentSize = true,
};
var element = DatWidgetFactory.Create(info, NoTex, null)!;
var policy = Assert.IsType<UiLayoutPolicy>(element.LayoutPolicy);
Assert.Equal(4u, policy.LeftMode);
Assert.Equal(3u, policy.TopMode);
Assert.Equal(1u, policy.RightMode);
Assert.Equal(0u, policy.BottomMode);
Assert.Equal(new UiPixelRect(0, 0, 99, 199), policy.OriginalParent);
}
[Fact]
public void ImportedRoot_HasNoRawEdgePolicy()
{
var element = DatWidgetFactory.Create(
new ElementInfo { Type = 3, Width = 100, Height = 200 },
NoTex,
null)!;
Assert.Null(element.LayoutPolicy);
}
[Fact]
public void Create_PropagatesStateCursors()
{
@ -339,6 +414,22 @@ public class DatWidgetFactoryTests
Assert.Equal(gold, t.DefaultColor);
}
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)
{
var info = new ElementInfo { Id = 0x10000016u, Type = 12, Width = 100, Height = 20 };
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
foreach (var property in properties)
state.Properties.Values[property.Id] = property.Value;
info.States[UiStateInfo.DirectStateId] = state;
return info;
}
private static UiPropertyValue Bool(bool value)
=> new() { Kind = UiPropertyKind.Bool, BoolValue = value };
private static UiPropertyValue Integer(int value)
=> new() { Kind = UiPropertyKind.Integer, IntegerValue = value };
/// <summary>
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
/// stay at white (Vector4.One) — the backward-compatible default.

View file

@ -65,6 +65,30 @@ public static class FixtureLoader
public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos()
=> LoadInfos("radar_21000074.json");
public static ImportedLayout LoadToolbar()
=> LayoutImporter.Build(LoadToolbarInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadToolbarInfos()
=> LoadInfos("toolbar_21000016.json");
public static ImportedLayout LoadInventory()
=> LayoutImporter.Build(LoadInventoryInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadInventoryInfos()
=> LoadInfos("inventory_21000023.json");
public static ImportedLayout LoadPaperdoll()
=> LayoutImporter.Build(LoadPaperdollInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadPaperdollInfos()
=> LoadInfos("paperdoll_21000024.json");
public static ImportedLayout LoadCharacter()
=> LayoutImporter.Build(LoadCharacterInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadCharacterInfos()
=> LoadInfos("character_2100002E.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -139,6 +139,36 @@ public class LayoutConformanceTests
Assert.Contains(0x40000000u, fontDids);
}
[Fact]
public void VitalsBinding_ReusesAuthoredTextNodesWithoutDuplicates()
{
var layout = FixtureLoader.LoadVitals();
VitalsController.Bind(
layout,
() => 1f,
() => 1f,
() => 1f,
() => "100/100",
() => "90/100",
() => "80/100");
(uint MeterId, uint TextId)[] cases =
[
(VitalsController.Health, VitalsController.HealthText),
(VitalsController.Stamina, VitalsController.StaminaText),
(VitalsController.Mana, VitalsController.ManaText),
];
foreach (var (meterId, textId) in cases)
{
var meter = Assert.IsType<UiMeter>(layout.FindElement(meterId));
var text = Assert.IsType<UiText>(layout.FindElement(textId));
Assert.Same(meter, text.Parent);
Assert.Single(meter.Children, child => child == text);
Assert.False(text.Selectable);
}
}
private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List<uint> acc)
{
if (node.FontDid != 0) acc.Add(node.FontDid);

View file

@ -0,0 +1,88 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailFixtureConformanceTests
{
[Theory]
[MemberData(nameof(LayoutCases))]
public void ProductionFixture_PreservesRootAndCanonicalStates(
Func<ElementInfo> load,
uint rootId,
uint rootType,
float width,
float height,
int minimumNodeCount)
{
var root = load();
Assert.Equal(rootId, root.Id);
Assert.Equal(rootType, root.Type);
Assert.Equal(width, root.Width);
Assert.Equal(height, root.Height);
Assert.True(CountNodes(root) >= minimumNodeCount);
Assert.Contains(UiStateInfo.DirectStateId, root.States.Keys);
Assert.Contains(Enumerate(root), element => element.States.Count > 1);
Assert.Contains(Enumerate(root), element =>
element.States.Values.Any(state => state.Properties.Values.Count > 0));
}
[Fact]
public void ToolbarFixture_ContainsObjectButtonsAndRetailButtonStates()
{
var root = FixtureLoader.LoadToolbarInfos();
var character = Find(root, 0x10000199u);
var inventory = Find(root, 0x100001B1u);
Assert.NotNull(character);
Assert.NotNull(inventory);
Assert.Contains(1u, character.States.Keys); // Normal
Assert.Contains(3u, character.States.Keys); // Normal_pressed
Assert.Contains(6u, character.States.Keys); // Highlight
}
[Fact]
public void InventoryFixture_ContainsMountedBackpackAndPaperdollContent()
{
var root = FixtureLoader.LoadInventoryInfos();
Assert.NotNull(Find(root, InventoryController.ContentsGridId));
Assert.NotNull(Find(root, InventoryController.ContainerListId));
Assert.NotNull(Find(root, InventoryController.BurdenMeterId));
Assert.NotNull(Find(root, 0x100001D5u)); // mounted paperdoll viewport
}
[Fact]
public void CharacterFixture_ContainsAllThreeRuntimePages()
{
var root = FixtureLoader.LoadCharacterInfos();
Assert.NotNull(Find(root, CharacterStatController.AttributesPageId));
Assert.NotNull(Find(root, CharacterStatController.SkillsPageId));
Assert.NotNull(Find(root, CharacterStatController.TitlesPageId));
}
public static TheoryData<Func<ElementInfo>, uint, uint, float, float, int> LayoutCases
=> new()
{
{ FixtureLoader.LoadToolbarInfos, 0x10000191u, 0x10000007u, 300f, 122f, 20 },
{ FixtureLoader.LoadInventoryInfos, 0x100001CCu, 0x10000023u, 300f, 362f, 40 },
{ FixtureLoader.LoadPaperdollInfos, 0x100001D4u, 0x10000024u, 224f, 214f, 20 },
{ FixtureLoader.LoadCharacterInfos, 0x10000227u, 8u, 300f, 600f, 60 },
};
private static int CountNodes(ElementInfo root)
=> Enumerate(root).Count();
private static IEnumerable<ElementInfo> Enumerate(ElementInfo root)
{
yield return root;
foreach (var child in root.Children)
foreach (var descendant in Enumerate(child))
yield return descendant;
}
private static ElementInfo? Find(ElementInfo root, uint id)
=> Enumerate(root).FirstOrDefault(element => element.Id == id);
}

View file

@ -0,0 +1,60 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Regenerates every committed retail layout fixture from production portal.dat.
/// The test is inert unless <c>ACDREAM_REGENERATE_UI_FIXTURES=1</c> is set, keeping
/// normal test runs deterministic and dat-independent.
/// </summary>
public sealed class RetailLayoutFixtureGenerator
{
private static readonly (uint Id, string FileName)[] Layouts =
{
(0x21000006u, "chat_21000006.json"),
(0x21000016u, "toolbar_21000016.json"),
(0x21000023u, "inventory_21000023.json"),
(0x21000024u, "paperdoll_21000024.json"),
(0x2100002Eu, "character_2100002E.json"),
(0x2100006Cu, "vitals_2100006C.json"),
(0x21000074u, "radar_21000074.json"),
};
[Fact]
public void RegenerateAllRetailFixtures_WhenExplicitlyRequested()
{
if (!string.Equals(
Environment.GetEnvironmentVariable("ACDREAM_REGENERATE_UI_FIXTURES"),
"1",
StringComparison.Ordinal))
{
return;
}
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
foreach (var (layoutId, fileName) in Layouts)
{
var info = LayoutImporter.ImportInfos(dats, layoutId);
Assert.NotNull(info);
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), json);
}
}
private static string FixtureDirectory([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures");
}

View file

@ -1,3 +1,4 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
@ -87,4 +88,42 @@ public class UiDatElementTests
// No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState).
Assert.Equal(0x06007777u, e.ActiveMedia().File);
}
[Fact]
public void NumericStateBridge_SetsNamedStateWithoutChangingGeometry()
{
var info = new ElementInfo { X = 4, Y = 5, Width = 30, Height = 40 };
info.States[RetailUiStateIds.ShowDetail] = new UiStateInfo
{
Id = RetailUiStateIds.ShowDetail,
Name = "ShowDetail",
Image = new UiImageMedia(0x06000009u, 3),
};
info.StateMedia["ShowDetail"] = (0x06000009u, 3);
var element = new UiDatElement(info, _ => (0u, 0, 0))
{
Left = info.X,
Top = info.Y,
Width = info.Width,
Height = info.Height,
};
Assert.True(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
Assert.Equal(RetailUiStateIds.ShowDetail, element.ActiveRetailStateId);
Assert.Equal((0x06000009u, 3), element.ActiveMedia());
Assert.Equal((4f, 5f, 30f, 40f),
(element.Left, element.Top, element.Width, element.Height));
}
[Fact]
public void NumericStateBridge_MissingStatePreservesCurrentState()
{
var info = new ElementInfo { DefaultStateName = "Normal" };
info.StateMedia["Normal"] = (0x06000001u, 1);
var element = new UiDatElement(info, _ => (0u, 0, 0));
Assert.False(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
Assert.Equal("Normal", element.ActiveState);
}
}

View file

@ -0,0 +1,138 @@
using System.Numerics;
using AcDream.App.UI.Layout;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.UI.Layout;
public sealed class UiPropertyBagTests
{
[Fact]
public void Merge_UsesKeyPresence_ForExplicitFalseAndZero()
{
var baseBag = new UiPropertyBag();
baseBag.Values[0x16u] = Bool(true);
baseBag.Values[0x14u] = Enum(3u);
var derivedBag = new UiPropertyBag();
derivedBag.Values[0x16u] = Bool(false);
derivedBag.Values[0x14u] = Enum(0u);
var merged = UiPropertyBag.Merge(baseBag, derivedBag);
Assert.False(merged.Values[0x16u].BoolValue);
Assert.Equal(0ul, merged.Values[0x14u].UnsignedValue);
}
[Fact]
public void EffectiveProperty_NamedStateOverridesDirectStateByPresence()
{
var info = new ElementInfo { DefaultStateId = 1u };
info.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x16u, Bool(true)));
info.States[1u] = State(1u, "Normal", (0x16u, Bool(false)));
Assert.True(info.TryGetEffectiveProperty(0x16u, out var property));
Assert.False(property.BoolValue);
}
[Fact]
public void ElementMerge_ExplicitCenteredStatePropertyOverridesBaseLeft()
{
var baseInfo = new ElementInfo();
baseInfo.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x14u, Enum(0u)));
var derivedInfo = new ElementInfo();
derivedInfo.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x14u, Enum(1u)));
var merged = ElementReader.Merge(baseInfo, derivedInfo);
Assert.Equal(HJustify.Center, merged.HJustify);
Assert.Equal(1ul, merged.States[UiStateInfo.DirectStateId]
.Properties.Values[0x14u].UnsignedValue);
}
[Fact]
public void ConvertProperty_PreservesNestedRetailValues()
{
var source = new StructBaseProperty { MasterPropertyId = 0xA0u };
var array = new ArrayBaseProperty { MasterPropertyId = 0xA1u };
array.Value.Add(new IntegerBaseProperty { MasterPropertyId = 0xA2u, Value = -17 });
array.Value.Add(new Bitfield64BaseProperty
{
MasterPropertyId = 0xA3u,
Value = 0xFEDCBA9876543210ul,
});
source.Value[0x55u] = array;
source.Value[0x56u] = new VectorBaseProperty
{
MasterPropertyId = 0xA4u,
Value = new Vector3(1.25f, -2.5f, 9.75f),
};
var converted = LayoutImporter.ConvertProperty(source);
Assert.Equal(UiPropertyKind.Struct, converted.Kind);
Assert.Equal(0xA0u, converted.MasterPropertyId);
var convertedArray = converted.StructValue[0x55u];
Assert.Equal(UiPropertyKind.Array, convertedArray.Kind);
Assert.Equal(0xA1u, convertedArray.MasterPropertyId);
Assert.Equal(-17, convertedArray.ArrayValue[0].IntegerValue);
Assert.Equal(0xFEDCBA9876543210ul, convertedArray.ArrayValue[1].UnsignedValue);
Assert.Equal(new Vector3(1.25f, -2.5f, 9.75f), converted.StructValue[0x56u].VectorValue);
}
[Fact]
public void ConvertProperty_PreservesStringInfoAndColorBytes()
{
var text = new StringInfoBaseProperty
{
Value = new StringInfo
{
Token = 7,
StringId = 0x12345678u,
TableId = 0x23000001u,
Override = StringInfoOverrideFlag.AutoGen,
English = 2,
Comment = 3,
},
};
var color = new ColorBaseProperty
{
Value = new ColorARGB { Blue = 1, Green = 2, Red = 3, Alpha = 4 },
};
var convertedText = LayoutImporter.ConvertProperty(text);
var convertedColor = LayoutImporter.ConvertProperty(color);
Assert.Equal(
new UiStringInfoValue(7, 0x12345678u, 0x23000001u, 2, 2, 3),
convertedText.StringInfoValue);
Assert.Equal(new UiColorValue(1, 2, 3, 4), convertedColor.ColorValue);
}
private static UiPropertyValue Bool(bool value)
=> new() { Kind = UiPropertyKind.Bool, BoolValue = value };
private static UiPropertyValue Enum(uint value)
=> new() { Kind = UiPropertyKind.Enum, UnsignedValue = value };
private static UiStateInfo State(
uint id,
string name,
params (uint Id, UiPropertyValue Value)[] properties)
{
var state = new UiStateInfo { Id = id, Name = name };
foreach (var property in properties)
state.Properties.Values[property.Id] = property.Value;
return state;
}
}

View file

@ -16,7 +16,10 @@ public class VitalsBindingTests
public void Bind_SetsHealthMeterFillFromProvider()
{
var health = new UiMeter();
var layout = FakeLayout((VitalsController.Health, health));
var healthText = new UiText();
var layout = FakeLayout(
(VitalsController.Health, health),
(VitalsController.HealthText, healthText));
float hp = 0.42f;
VitalsController.Bind(layout,
@ -28,9 +31,9 @@ public class VitalsBindingTests
manaText: () => "");
Assert.Equal(0.42f, health.Fill()!.Value);
// The meter no longer draws its own label; the cur/max is a centered UiText child.
// The meter no longer draws its own label; the authored text node is bound in place.
Assert.Null(health.Label());
Assert.Equal("42/100", NumberText(health));
Assert.Equal("42/100", NumberText(healthText));
}
// ── Test 2: All three meters wired to distinct providers ──────────────────
@ -41,10 +44,16 @@ public class VitalsBindingTests
var health = new UiMeter();
var stamina = new UiMeter();
var mana = new UiMeter();
var healthText = new UiText();
var staminaText = new UiText();
var manaText = new UiText();
var layout = FakeLayout(
(VitalsController.Health, health),
(VitalsController.Stamina, stamina),
(VitalsController.Mana, mana));
(VitalsController.Mana, mana),
(VitalsController.HealthText, healthText),
(VitalsController.StaminaText, staminaText),
(VitalsController.ManaText, manaText));
VitalsController.Bind(layout,
healthPct: () => 0.25f,
@ -56,13 +65,13 @@ public class VitalsBindingTests
// Each meter should reflect its own provider, not another's.
Assert.Equal(0.25f, health.Fill()!.Value);
Assert.Equal("25/100", NumberText(health));
Assert.Equal("25/100", NumberText(healthText));
Assert.Equal(0.50f, stamina.Fill()!.Value);
Assert.Equal("50/100", NumberText(stamina));
Assert.Equal("50/100", NumberText(staminaText));
Assert.Equal(0.75f, mana.Fill()!.Value);
Assert.Equal("75/100", NumberText(mana));
Assert.Equal("75/100", NumberText(manaText));
}
// ── Test 3: Missing meter ids are silently skipped (no throw) ─────────────
@ -89,12 +98,12 @@ public class VitalsBindingTests
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>The cur/max text from the centered <see cref="UiText"/> number that
/// <see cref="VitalsController"/> attaches as the meter's child.</summary>
private static string NumberText(UiMeter m)
/// <summary>The cur/max text from the authored <see cref="UiText"/> node.</summary>
private static string NumberText(UiText num)
{
var num = Assert.IsType<UiText>(m.Children[0]);
Assert.True(num.Centered);
Assert.True(num.OneLine);
Assert.False(num.Selectable);
var lines = num.LinesProvider();
return lines.Count > 0 ? lines[0].Text : "";
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiButtonStateMachineTests
{
public static TheoryData<UiButtonVisualInput, uint> RequestedCases
=> new()
{
{ new(false, false, false, false, false), UiButtonStateMachine.Normal },
{ new(false, false, true, false, true), UiButtonStateMachine.NormalRollover },
{ new(false, false, true, true, false), UiButtonStateMachine.NormalRollover },
{ new(false, false, true, true, true), UiButtonStateMachine.NormalPressed },
{ new(false, true, false, false, false), UiButtonStateMachine.Highlight },
{ new(false, true, true, false, true), UiButtonStateMachine.HighlightRollover },
{ new(false, true, true, true, false), UiButtonStateMachine.HighlightRollover },
{ new(false, true, true, true, true), UiButtonStateMachine.HighlightPressed },
{ new(true, false, true, true, true), UiButtonStateMachine.Ghosted },
{ new(true, true, true, true, true), UiButtonStateMachine.Ghosted },
};
[Theory]
[MemberData(nameof(RequestedCases))]
public void RequestedState_MatchesRetailPrecedence(UiButtonVisualInput input, uint expected)
=> Assert.Equal(expected, UiButtonStateMachine.RequestedState(input));
[Fact]
public void ResolveState_MissingRequestedState_PreservesCustomState()
{
var available = new HashSet<uint> { 0x10000053u };
uint resolved = UiButtonStateMachine.ResolveState(
0x10000053u,
new UiButtonVisualInput(false, false, true, false, true),
available);
Assert.Equal(0x10000053u, resolved);
}
}

View file

@ -22,4 +22,140 @@ public class UiButtonTests
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
Assert.False(b.ClickThrough);
}
[Fact]
public void PointerTransitions_UseRetailNormalStates()
{
var b = ButtonWithStates("Normal", "Normal_rollover", "Normal_pressed");
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 50, Data2: 50));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.Equal("Normal_rollover", b.ActiveState);
}
[Fact]
public void ToggleRelease_SelectsHighlightState()
{
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.True(b.Selected);
Assert.Equal("Highlight", b.ActiveState);
}
[Fact]
public void DisabledProperty_SelectsGhostedAndSuppressesClick()
{
var info = ButtonInfo("Normal", "Ghosted");
AddBoolProperty(info, 0x0Du, true);
var b = CreateButton(info);
b.OnClick = () => _clicked = true;
b.OnEvent(new UiEvent(0, b, UiEventType.Click));
Assert.False(b.Enabled);
Assert.Equal("Ghosted", b.ActiveState);
Assert.False(_clicked);
}
[Fact]
public void MissingStandardState_PreservesCustomSemanticState()
{
var info = ButtonInfo("LockedUI");
info.DefaultStateName = "LockedUI";
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("LockedUI", b.ActiveState);
}
[Fact]
public void HotClick_FiresImmediatelyRepeatsAndSuppressesReleaseClick()
{
var info = ButtonInfo("Normal");
AddBoolProperty(info, 0x0Fu, true);
AddFloatProperty(info, 0x10u, 0.10f);
AddFloatProperty(info, 0x11u, 0.05f);
int clicks = 0;
var b = CreateButton(info);
b.OnClick = () => clicks++;
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.00);
b.OnGlobalUiTime(1.09);
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.11);
Assert.Equal(2, clicks);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 5, Data2: 5));
Assert.Equal(2, clicks);
}
private static UiButton ButtonWithStates(params string[] states)
{
var info = ButtonInfo(states);
AddBoolProperty(info, 0x13u, true);
return CreateButton(info);
}
private static ElementInfo ButtonInfo(params string[] states)
{
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };
uint file = 1;
foreach (string state in states)
info.StateMedia[state] = (file++, 1);
if (states.Length > 0)
info.DefaultStateName = states[0];
return info;
}
private static void AddBoolProperty(ElementInfo info, uint id, bool value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = value,
};
}
private static void AddFloatProperty(ElementInfo info, uint id, float value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Float,
FloatValue = value,
};
}
private static UiButton CreateButton(ElementInfo info)
=> new(info, NoTex) { Width = info.Width, Height = info.Height };
}

View file

@ -0,0 +1,132 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiLayoutPolicyTests
{
private static readonly UiPixelRect OriginalParent =
UiPixelRect.FromPositionAndSize(0, 0, 800, 600);
private static readonly UiPixelRect OriginalChild =
UiPixelRect.FromPositionAndSize(100, 50, 200, 100);
[Fact]
public void ModeZero_PreservesEveryCurrentEdge()
{
var current = new UiPixelRect(111, 66, 333, 177);
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1600, 900);
var result = UiLayoutPolicy.Apply(
0, 0, 0, 0,
OriginalChild,
OriginalParent,
current,
resizedParent);
Assert.Equal(current, result);
}
[Fact]
public void ModeOne_KeepsNearEdgesAndAddsParentDeltaToFarEdges()
{
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1000, 700);
var result = UiLayoutPolicy.Apply(
1, 1, 1, 1,
OriginalChild,
OriginalParent,
OriginalChild,
resizedParent);
Assert.Equal(new UiPixelRect(100, 50, 499, 249), result);
Assert.Equal(400, result.Width);
Assert.Equal(200, result.Height);
}
[Fact]
public void MixedModeTwoAndOne_MovesFixedSizeChildWithFarSide()
{
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1000, 700);
var result = UiLayoutPolicy.Apply(
2, 2, 1, 1,
OriginalChild,
OriginalParent,
OriginalChild,
resizedParent);
Assert.Equal(new UiPixelRect(300, 150, 499, 249), result);
Assert.Equal(OriginalChild.Width, result.Width);
Assert.Equal(OriginalChild.Height, result.Height);
}
[Fact]
public void ModeThree_UsesInclusivePixelCenterFormula()
{
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1001, 701);
var result = UiLayoutPolicy.Apply(
3, 3, 3, 3,
OriginalChild,
OriginalParent,
OriginalChild,
resizedParent);
Assert.Equal(new UiPixelRect(400, 300, 599, 399), result);
}
[Fact]
public void ModeFour_ScalesEachCoordinateWithTruncationTowardZero()
{
var originalParent = UiPixelRect.FromPositionAndSize(0, 0, 10, 10);
var originalChild = new UiPixelRect(-3, -3, -1, -1);
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 15, 15);
var result = UiLayoutPolicy.Apply(
4, 4, 4, 4,
originalChild,
originalParent,
originalChild,
resizedParent);
Assert.Equal(new UiPixelRect(-4, -4, -1, -1), result);
}
[Fact]
public void AssigningCompatibilityAnchors_OptsOutOfImportedPolicy()
{
var element = new UiPanel
{
LayoutPolicy = new UiLayoutPolicy(
1, 1, 1, 1,
OriginalChild,
OriginalParent),
};
element.Anchors = AnchorEdges.Left | AnchorEdges.Top;
Assert.Null(element.LayoutPolicy);
}
[Fact]
public void ApplyAnchor_UsesImportedPolicyInsteadOfCompatibilityMargins()
{
var element = new UiPanel
{
Left = 100,
Top = 50,
Width = 200,
Height = 100,
LayoutPolicy = new UiLayoutPolicy(
1, 1, 1, 1,
OriginalChild,
OriginalParent),
};
element.ApplyAnchor(1000, 700);
Assert.Equal(100f, element.Left);
Assert.Equal(50f, element.Top);
Assert.Equal(400f, element.Width);
Assert.Equal(200f, element.Height);
}
}

View file

@ -482,4 +482,99 @@ public class UiRootInputTests
Assert.True(clicked.ZOrder > peer.ZOrder);
root.OnMouseUp(UiMouseButton.Left, 50, 50);
}
[Fact]
public void AddChild_AssignsEventIdsRecursively()
{
var root = new UiRoot { Width = 800, Height = 600 };
var parent = new UiPanel { Width = 100, Height = 100 };
var child = new UiPanel { Width = 50, Height = 50 };
var grandchild = new UiPanel { Width = 25, Height = 25 };
child.AddChild(grandchild);
parent.AddChild(child);
root.AddChild(parent);
Assert.NotEqual(0u, parent.EventId);
Assert.NotEqual(0u, child.EventId);
Assert.NotEqual(0u, grandchild.EventId);
Assert.Equal(3, new[] { parent.EventId, child.EventId, grandchild.EventId }.Distinct().Count());
}
[Fact]
public void RemovingSubtree_ClearsAllRootOwnership()
{
var root = new UiRoot { Width = 800, Height = 600 };
var window = new UiPanel { Width = 200, Height = 100 };
var field = new UiField { Width = 100, Height = 20 };
window.AddChild(field);
root.AddChild(window);
root.RegisterWindow("test", window);
root.DefaultTextInput = field;
root.Modal = window;
root.SetKeyboardFocus(field);
root.SetCapture(field);
Assert.True(root.RemoveChild(window));
Assert.Null(root.KeyboardFocus);
Assert.Null(root.Captured);
Assert.Null(root.DefaultTextInput);
Assert.Null(root.Modal);
Assert.False(root.ShowWindow("test"));
}
[Fact]
public void Tick_ChecksTooltipBeforeGlobalTimeMessage_AtRetailDelay()
{
var root = new UiRoot { Width = 800, Height = 600 };
var recorder = new TimeOrderRecorder { Width = 100, Height = 100 };
root.AddChild(recorder);
root.Tick(0, 1_000);
root.OnMouseMove(10, 10);
recorder.Events.Clear();
root.Tick(0, 1_249);
Assert.DoesNotContain("tooltip", recorder.Events);
recorder.Events.Clear();
root.Tick(0, 1_250);
Assert.Equal(new[] { "tooltip", "global" }, recorder.Events);
}
[Fact]
public void Tick_GlobalTimeRemoval_SkipsDetachedSiblingWithoutInvalidatingWalk()
{
var root = new UiRoot { Width = 800, Height = 600 };
var victim = new TimeOrderRecorder { Width = 100, Height = 100 };
var remover = new GlobalTimeAction(() => root.RemoveChild(victim))
{ Width = 100, Height = 100 };
root.AddChild(remover);
root.AddChild(victim);
root.Tick(0, 1_000);
Assert.Empty(victim.Events);
Assert.Null(victim.Parent);
}
private sealed class TimeOrderRecorder : UiElement, IUiGlobalTimeListener
{
public List<string> Events { get; } = new();
public override bool OnEvent(in UiEvent e)
{
if (e.Type != UiEventType.Tooltip) return false;
Events.Add("tooltip");
return true;
}
public void OnGlobalUiTime(double nowSeconds) => Events.Add("global");
}
private sealed class GlobalTimeAction(Action action) : UiElement, IUiGlobalTimeListener
{
public void OnGlobalUiTime(double nowSeconds) => action();
}
}