merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch
Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c83fd02642
94 changed files with 16216 additions and 199 deletions
|
|
@ -22,4 +22,10 @@
|
|||
<ProjectReference Include="..\..\src\AcDream.App\AcDream.App.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="UI\Layout\fixtures\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
21
tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
Normal file
21
tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using AcDream.App.Plugins;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
public class BufferedUiRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Drain_YieldsBufferedRegistrationsOnceThenEmpty()
|
||||
{
|
||||
var reg = new BufferedUiRegistry();
|
||||
reg.AddMarkupPanel("a.xml", new object());
|
||||
reg.AddMarkupPanel("b.xml", new object());
|
||||
|
||||
var drained = reg.Drain();
|
||||
Assert.Equal(2, drained.Count);
|
||||
Assert.Equal("a.xml", drained[0].MarkupPath);
|
||||
Assert.Equal("b.xml", drained[1].MarkupPath);
|
||||
|
||||
Assert.Empty(reg.Drain()); // consumed
|
||||
}
|
||||
}
|
||||
28
tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs
Normal file
28
tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App;
|
||||
|
||||
namespace AcDream.App.Tests;
|
||||
|
||||
public class RuntimeOptionsRetailUiTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_ReadsRetailUiAndAcDir()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["ACDREAM_RETAIL_UI"] = "1",
|
||||
["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call",
|
||||
};
|
||||
var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
|
||||
Assert.True(opts.RetailUi);
|
||||
Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_DefaultsRetailUiOffAndAcDirNull()
|
||||
{
|
||||
var opts = RuntimeOptions.Parse("dats", _ => null);
|
||||
Assert.False(opts.RetailUi);
|
||||
Assert.Null(opts.AcDir);
|
||||
}
|
||||
}
|
||||
38
tests/AcDream.App.Tests/UI/ControlsIniTests.cs
Normal file
38
tests/AcDream.App.Tests/UI/ControlsIniTests.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class ControlsIniTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_ReadsSectionTokens()
|
||||
{
|
||||
var ini = ControlsIni.Parse(
|
||||
"[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" +
|
||||
"[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n");
|
||||
|
||||
Assert.Equal("19", ini.Get("title", "height"));
|
||||
Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font"));
|
||||
Assert.Null(ini.Get("title", "missing"));
|
||||
Assert.Null(ini.Get("nosuch", "height"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryColor_ParsesAlphaFirstHex()
|
||||
{
|
||||
var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n");
|
||||
Assert.True(ini.TryColor("body", "color_border", out Vector4 c));
|
||||
Assert.Equal(0xFF / 255f, c.W, 5); // alpha
|
||||
Assert.Equal(0x4F / 255f, c.X, 5); // red
|
||||
Assert.Equal(0x65 / 255f, c.Y, 5); // green
|
||||
Assert.Equal(0x7D / 255f, c.Z, 5); // blue
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_MissingFileReturnsEmptyNotThrow()
|
||||
{
|
||||
var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini");
|
||||
Assert.Null(ini.Get("title", "height")); // empty, no throw
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Dat-free conformance tests for the committed chat_21000006.json golden fixture.
|
||||
/// Verifies that LayoutImporter.ImportInfos correctly resolves the BaseElement /
|
||||
/// BaseLayoutId inheritance chain for the chat window (LayoutDesc 0x21000006).
|
||||
/// </summary>
|
||||
public class ChatLayoutConformanceTests
|
||||
{
|
||||
private static ElementInfo? Find(ElementInfo n, uint id)
|
||||
{
|
||||
if (n.Id == id) return n;
|
||||
foreach (var c in n.Children)
|
||||
{
|
||||
var f = Find(c, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_ResolvesKnownElements()
|
||||
{
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
Assert.NotNull(Find(root, 0x10000011u)); // transcript
|
||||
Assert.NotNull(Find(root, 0x10000016u)); // input
|
||||
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
|
||||
Assert.NotNull(Find(root, 0x10000014u)); // channel menu
|
||||
Assert.NotNull(Find(root, 0x10000019u)); // send button
|
||||
Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
|
||||
{
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
Assert.Equal(6u, Find(root, 0x10000014u)!.Type); // Menu
|
||||
Assert.Equal(11u, Find(root, 0x10000012u)!.Type); // Scrollbar
|
||||
Assert.Equal(1u, Find(root, 0x10000019u)!.Type); // Button (Send)
|
||||
Assert.Equal(1u, Find(root, 0x1000046Fu)!.Type); // Button (Max/Min)
|
||||
Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript)
|
||||
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
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>
|
||||
/// One-off generator for the committed chat golden fixture. Skipped by default —
|
||||
/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
|
||||
/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
|
||||
/// </summary>
|
||||
public class ChatLayoutFixtureGenerator
|
||||
{
|
||||
[Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")]
|
||||
public void GenerateChatFixture()
|
||||
{
|
||||
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);
|
||||
var info = LayoutImporter.ImportInfos(dats, 0x21000006u);
|
||||
Assert.NotNull(info);
|
||||
|
||||
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
WriteIndented = true,
|
||||
});
|
||||
File.WriteAllText(FixturePath(), json);
|
||||
}
|
||||
|
||||
// Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
|
||||
private static string FixturePath([CallerFilePath] string thisFile = "")
|
||||
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
|
||||
}
|
||||
209
tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
Normal file
209
tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke tests for <see cref="ChatWindowController.Bind"/> — no dats, no GL.
|
||||
///
|
||||
/// Building the Type-12 "skipped" elements via the pure <see cref="LayoutImporter"/>
|
||||
/// path is the correct approach: we build a synthetic info tree that reflects the
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ChatWindowControllerTests
|
||||
{
|
||||
// ── Null-resolve helper (no GL needed) ─────────────────────────────────
|
||||
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
||||
|
||||
// ── Capture bus — records every Publish call ────────────────────────────
|
||||
private sealed class CaptureBus : ICommandBus
|
||||
{
|
||||
public readonly List<object> Published = new();
|
||||
public void Publish<T>(T cmd) where T : notnull => Published.Add(cmd!);
|
||||
}
|
||||
|
||||
// ── Synthetic element tree matching the real chat layout topology ────────
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal synthetic ElementInfo tree that mirrors the real chat
|
||||
/// layout (0x21000006) with enough fidelity for Bind to succeed:
|
||||
/// root (Type-3)
|
||||
/// transcriptPanel (Type-3) [0x10000010]
|
||||
/// transcript (Type-12, no media) [0x10000011] ← built as UiText by factory; Bind binds in place
|
||||
/// 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
|
||||
/// send (Type-3) [0x10000019]
|
||||
/// maxmin (Type-3) [0x1000046F]
|
||||
/// </summary>
|
||||
private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree()
|
||||
{
|
||||
var transcriptNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000011u, Type = 12, // Type-12, no media → skipped by factory
|
||||
X = 16, Y = 0, Width = 458, Height = 74,
|
||||
};
|
||||
var trackNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000012u, Type = 3,
|
||||
X = 474, Y = 6, Width = 16, Height = 68,
|
||||
};
|
||||
var transcriptPanel = new ElementInfo
|
||||
{
|
||||
Id = 0x10000010u, Type = 3, X = 0, Y = 9, Width = 490, Height = 74,
|
||||
};
|
||||
transcriptPanel.Children.Add(transcriptNode);
|
||||
transcriptPanel.Children.Add(trackNode);
|
||||
|
||||
var menuNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000014u, Type = 6, X = 0, Y = 0, Width = 46, Height = 17,
|
||||
};
|
||||
var inputNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000016u, Type = 12, // Type-12, no media → skipped by factory
|
||||
X = 46, Y = 0, Width = 398, Height = 17,
|
||||
};
|
||||
var sendNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17,
|
||||
};
|
||||
var inputBar = new ElementInfo
|
||||
{
|
||||
Id = 0x10000013u, Type = 3, X = 0, Y = 83, Width = 490, Height = 17,
|
||||
};
|
||||
inputBar.Children.Add(menuNode);
|
||||
inputBar.Children.Add(inputNode);
|
||||
inputBar.Children.Add(sendNode);
|
||||
|
||||
var maxMinNode = new ElementInfo
|
||||
{
|
||||
Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16,
|
||||
};
|
||||
|
||||
var root = new ElementInfo
|
||||
{
|
||||
Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100,
|
||||
};
|
||||
root.Children.Add(transcriptPanel);
|
||||
root.Children.Add(inputBar);
|
||||
root.Children.Add(maxMinNode);
|
||||
|
||||
var layout = LayoutImporter.Build(root, NoTex, null);
|
||||
var vm = new ChatVM(new ChatLog());
|
||||
return (root, layout, vm);
|
||||
}
|
||||
|
||||
// ── Test 1: Bind returns non-null with the minimal tree ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_Returns_NonNull_OnValidTree()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
|
||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
}
|
||||
|
||||
// ── Test 2: Transcript is placed as a child of the transcript panel ──────
|
||||
|
||||
[Fact]
|
||||
public void Bind_Transcript_IsChildOfTranscriptPanel()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
|
||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
var panel = layout.FindElement(0x10000010u);
|
||||
Assert.NotNull(panel);
|
||||
// The transcript widget must be a child of the transcript panel.
|
||||
Assert.Contains(ctrl!.Transcript, panel!.Children);
|
||||
}
|
||||
|
||||
// ── Test 3: Input is placed as a child of the input bar ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_Input_IsChildOfInputBar()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
|
||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
var bar = layout.FindElement(0x10000013u);
|
||||
Assert.NotNull(bar);
|
||||
Assert.Contains(ctrl!.Input, bar!.Children);
|
||||
}
|
||||
|
||||
// ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ─────
|
||||
|
||||
[Fact]
|
||||
public void Bind_InputSubmit_PublishesSendChatCmd()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
|
||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
ctrl!.Input.OnSubmit!.Invoke("hello world");
|
||||
|
||||
// ChatCommandRouter.Submit should have published a SendChatCmd.
|
||||
Assert.Single(bus.Published);
|
||||
var cmd = Assert.IsType<SendChatCmd>(bus.Published[0]);
|
||||
Assert.Equal("hello world", cmd.Text);
|
||||
}
|
||||
|
||||
// ── Test 5: Channel change updates the channel used by subsequent submits ─
|
||||
|
||||
[Fact]
|
||||
public void Bind_ChannelChange_UpdatesSubmitChannel()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
|
||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
// Switch channel to General via the generic OnSelect (payload is ChatChannelKind).
|
||||
ctrl!.Menu.OnSelect!.Invoke((object?)ChatChannelKind.General);
|
||||
ctrl.Input.OnSubmit!.Invoke("hey all");
|
||||
|
||||
Assert.Single(bus.Published);
|
||||
var cmd = Assert.IsType<SendChatCmd>(bus.Published[0]);
|
||||
Assert.Equal(ChatChannelKind.General, cmd.Channel);
|
||||
}
|
||||
|
||||
// ── Test 6: Bind returns null when required elements are absent ──────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_Returns_Null_WhenTranscriptPanelMissing()
|
||||
{
|
||||
// Build a layout that is missing the transcript panel entirely.
|
||||
var root = new ElementInfo { Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100 };
|
||||
// No children → TranscriptPanelId and InputBarId are absent from the widget tree.
|
||||
|
||||
var layout = LayoutImporter.Build(root, NoTex, null);
|
||||
var vm = new ChatVM(new ChatLog());
|
||||
var bus = new CaptureBus();
|
||||
|
||||
var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, null, null, NoTex);
|
||||
|
||||
Assert.Null(ctrl);
|
||||
}
|
||||
}
|
||||
180
tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
Normal file
180
tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class DatWidgetFactoryTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
||||
|
||||
// ── Test 1: Type 7 → UiMeter ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Type7_Meter_MakesUiMeter()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null);
|
||||
Assert.IsType<UiMeter>(e);
|
||||
}
|
||||
|
||||
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UnknownType_FallsBackToGeneric()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
|
||||
Assert.IsType<UiDatElement>(e);
|
||||
}
|
||||
|
||||
// ── Test 3: Type 12 → UiText (behavioral text widget) ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Type12_Text_MakesUiText()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
|
||||
Assert.IsType<UiText>(e);
|
||||
}
|
||||
|
||||
// ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=1 should have
|
||||
/// its rect + anchors copied onto the returned widget.
|
||||
/// Per UIElement::UpdateForParentSizeChange @0x00462640:
|
||||
/// Left=1 → AnchorEdges.Left (near-pin); Top=1 → AnchorEdges.Top;
|
||||
/// Right=1 → AnchorEdges.Right (stretch / track parent right); Bottom=0 → neither.
|
||||
/// Combined: Left | Top | Right.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RectAndAnchors_SetFromElementInfo()
|
||||
{
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Type = 3,
|
||||
X = 5, Y = 21,
|
||||
Width = 150, Height = 16,
|
||||
Left = 1, Top = 1,
|
||||
Right = 1, Bottom = 0,
|
||||
};
|
||||
var e = DatWidgetFactory.Create(info, NoTex, null)!;
|
||||
Assert.Equal(5f, e.Left);
|
||||
Assert.Equal(21f, e.Top);
|
||||
Assert.Equal(150f, e.Width);
|
||||
Assert.Equal(16f, e.Height);
|
||||
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
|
||||
}
|
||||
|
||||
// ── Test 5: ReadOrder propagated to ZOrder ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_PropagatesReadOrderToZOrder()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, ReadOrder = 7 }, NoTex, null);
|
||||
Assert.Equal(7, e!.ZOrder);
|
||||
}
|
||||
|
||||
// ── Test G1a: Type 12 always produces UiText (with or without own sprites) ──
|
||||
|
||||
[Fact]
|
||||
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
|
||||
{
|
||||
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
|
||||
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
|
||||
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
|
||||
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
|
||||
}
|
||||
|
||||
// ── Test 5c: Type 1 → UiButton ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Type1_Button_MakesUiButton()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
|
||||
Assert.IsType<UiButton>(e);
|
||||
}
|
||||
|
||||
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Type11_Scrollbar_MakesUiScrollbar()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
|
||||
Assert.IsType<UiScrollbar>(e);
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// bevel chrome + the transcript/input container panels) are inert sprite-bearing
|
||||
// chrome, not editable fields. They stay on the UiDatElement fallback so their
|
||||
// sprites render and they gain no spurious focus/edit affordance. The one true
|
||||
// editable field (the chat input, 0x10000016) resolves to Type 12 and is
|
||||
// controller-placed as a UiField. Register Type 3 → UiField only when a window
|
||||
// carries a factory-built editable Type-3 field.
|
||||
|
||||
[Fact]
|
||||
public void Type3_NotRegistered_FallsBackToGeneric()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
|
||||
Assert.IsType<UiDatElement>(e);
|
||||
}
|
||||
|
||||
// ── Test 5d: Type 6 → UiMenu ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Type6_Menu_MakesUiMenu()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
|
||||
Assert.IsType<UiMenu>(e);
|
||||
}
|
||||
|
||||
// ── Test 6: Meter slice extraction (the important one) ───────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A meter (Type 7) whose two Type-3 containers each carry 3 image children
|
||||
/// (ordered by X, bearing a DirectState "" sprite), plus the front container
|
||||
/// has a fourth expand-overlay child with ONLY a named "ShowDetail" state —
|
||||
/// that overlay must be excluded from the slice count.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay()
|
||||
{
|
||||
// Slice ids sourced from format doc §11 — real health-bar ids.
|
||||
const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u;
|
||||
const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u;
|
||||
const uint OverlayFile = 0x06007490u;
|
||||
|
||||
// Back container (ReadOrder 0 — drawn first / behind)
|
||||
var backChild = new ElementInfo { Type = 3, ReadOrder = 0 };
|
||||
backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } });
|
||||
backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } });
|
||||
backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } });
|
||||
|
||||
// Front container (ReadOrder 1 — drawn on top)
|
||||
var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 };
|
||||
frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } });
|
||||
frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } });
|
||||
frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } });
|
||||
// Expand-detail overlay: named state only — NO DirectState "" — must be ignored.
|
||||
frontChild.Children.Add(new ElementInfo
|
||||
{
|
||||
X = 0,
|
||||
StateMedia = { ["ShowDetail"] = (OverlayFile, 3) }
|
||||
});
|
||||
|
||||
var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 };
|
||||
meter.Children.Add(backChild);
|
||||
meter.Children.Add(frontChild);
|
||||
|
||||
var e = DatWidgetFactory.Create(meter, NoTex, null);
|
||||
|
||||
var m = Assert.IsType<UiMeter>(e);
|
||||
Assert.Equal(BackL, m.BackLeft);
|
||||
Assert.Equal(BackT, m.BackTile);
|
||||
Assert.Equal(BackR, m.BackRight);
|
||||
Assert.Equal(FrontL, m.FrontLeft);
|
||||
Assert.Equal(FrontT, m.FrontTile);
|
||||
Assert.Equal(FrontR, m.FrontRight);
|
||||
// Overlay (ShowDetail-only, no DirectState "") must not leak into any slice slot.
|
||||
Assert.NotEqual(OverlayFile, m.FrontRight);
|
||||
Assert.NotEqual(OverlayFile, m.FrontTile);
|
||||
}
|
||||
}
|
||||
164
tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
Normal file
164
tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class ElementReaderTests
|
||||
{
|
||||
// ── ToAnchors (decomp-backed: UIElement::UpdateForParentSizeChange @0x00462640) ─────────────
|
||||
|
||||
/// <summary>
|
||||
/// Top edge (L=1,T=1,R=1,B=2): LeftEdge==1 → Left; RightEdge==1 → Right (stretch);
|
||||
/// TopEdge==1 → Top; BottomEdge==2 (not 1/4, top≠2) → no Bottom.
|
||||
/// This is the top chrome edge — it pins left, stretches width, pins top, fixed height.
|
||||
/// Real vitals values from format doc §11 (0x10000634).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToAnchors_TopEdge_StretchesWidth()
|
||||
{
|
||||
var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 2);
|
||||
Assert.True(a.HasFlag(AnchorEdges.Left));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Top));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Right));
|
||||
Assert.False(a.HasFlag(AnchorEdges.Bottom));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TL corner (L=1,T=1,R=2,B=2): LeftEdge==1 → Left; RightEdge==2 (not 1/4), left≠2 → no Right;
|
||||
/// TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom. Fixed size, pinned top-left.
|
||||
/// Real vitals values from format doc §11 (0x10000633).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToAnchors_TlCorner_PinsTopLeftFixed()
|
||||
{
|
||||
var a = ElementReader.ToAnchors(left: 1, top: 1, right: 2, bottom: 2);
|
||||
Assert.True(a.HasFlag(AnchorEdges.Left));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Top));
|
||||
Assert.False(a.HasFlag(AnchorEdges.Right));
|
||||
Assert.False(a.HasFlag(AnchorEdges.Bottom));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TR corner (L=2,T=1,R=1,B=2): LeftEdge==2 → triggers Right (track-right); RightEdge==1 → Right;
|
||||
/// left≠1 → no Left; TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom.
|
||||
/// Fixed-width element whose left and right both track the parent's right edge.
|
||||
/// Real vitals values from format doc §11 (0x10000635).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToAnchors_TrCorner_TracksRight()
|
||||
{
|
||||
var a = ElementReader.ToAnchors(left: 2, top: 1, right: 1, bottom: 2);
|
||||
Assert.False(a.HasFlag(AnchorEdges.Left));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Top));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Right));
|
||||
Assert.False(a.HasFlag(AnchorEdges.Bottom));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Left edge (L=1,T=1,R=2,B=1): LeftEdge==1 → Left; RightEdge==2, left≠2 → no Right;
|
||||
/// TopEdge==1 → Top; BottomEdge==1 → Bottom. Pins left+top+bottom, fixed width, stretches height.
|
||||
/// Real vitals values from format doc §11 (0x10000636).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToAnchors_LeftEdge_StretchesHeight()
|
||||
{
|
||||
var a = ElementReader.ToAnchors(left: 1, top: 1, right: 2, bottom: 1);
|
||||
Assert.True(a.HasFlag(AnchorEdges.Left));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Top));
|
||||
Assert.False(a.HasFlag(AnchorEdges.Right));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Bottom));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All-ones (L=1,T=1,R=1,B=1): all four flags fire — Left, Right, Top, Bottom.
|
||||
/// A piece pinned to all four sides stretches both horizontally and vertically.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToAnchors_Meter_StretchesBoth()
|
||||
{
|
||||
var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 1);
|
||||
Assert.True(a.HasFlag(AnchorEdges.Left));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Top));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Right));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Bottom));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All-zero edge flags (prototype-only elements) fall back to Left|Top default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EdgeFlagsToAnchors_AllZero_DefaultsToTopLeft()
|
||||
{
|
||||
var a = ElementReader.ToAnchors(left: 0, top: 0, right: 0, bottom: 0);
|
||||
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Value 3 on left and right axes contributes no Left/Right anchor;
|
||||
/// TopEdge==1 → Top; BottomEdge==1 → Bottom.
|
||||
/// left=3 (not 1/4) → no Left; right=3 (not 1/4), left≠2 → no Right;
|
||||
/// top=1 → Top; bottom=1 → Bottom. Result: Top|Bottom.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EdgeFlagsToAnchors_ValueThree_HorizAxes_YieldsTopBottom()
|
||||
{
|
||||
var a = ElementReader.ToAnchors(left: 3, top: 1, right: 3, bottom: 1);
|
||||
Assert.False(a.HasFlag(AnchorEdges.Left));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Top));
|
||||
Assert.False(a.HasFlag(AnchorEdges.Right));
|
||||
Assert.True(a.HasFlag(AnchorEdges.Bottom));
|
||||
}
|
||||
|
||||
// ── Merge ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Merge_BaseThenOverride_DerivedWins()
|
||||
{
|
||||
var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 };
|
||||
var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height
|
||||
var merged = ElementReader.Merge(base_, derived);
|
||||
Assert.Equal(200, merged.Width); // override
|
||||
Assert.Equal(16, merged.Height); // inherited
|
||||
Assert.Equal(0x40000000u, merged.FontDid);// inherited
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_DerivedHasFontDid_OverridesBase()
|
||||
{
|
||||
var base_ = new ElementInfo { FontDid = 0x40000000, Width = 100, Height = 10 };
|
||||
var derived = new ElementInfo { FontDid = 0x40000001, Width = 100 };
|
||||
var merged = ElementReader.Merge(base_, derived);
|
||||
Assert.Equal(0x40000001u, merged.FontDid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_DerivedStateMediaOverridesBase()
|
||||
{
|
||||
var base_ = new ElementInfo();
|
||||
base_.StateMedia[""] = (0x06001000u, 1);
|
||||
base_.StateMedia["HideDetail"] = (0x06001001u, 1);
|
||||
|
||||
var derived = new ElementInfo();
|
||||
derived.StateMedia[""] = (0x06002000u, 3); // overrides base default state
|
||||
|
||||
var merged = ElementReader.Merge(base_, derived);
|
||||
// derived's "" overrides base's ""
|
||||
Assert.Equal((0x06002000u, 3), merged.StateMedia[""]);
|
||||
// base's "HideDetail" is kept (derived didn't provide it)
|
||||
Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_ChildrenComeFromDerived()
|
||||
{
|
||||
var base_ = new ElementInfo();
|
||||
base_.Children.Add(new ElementInfo { Id = 0x1u });
|
||||
|
||||
var derived = new ElementInfo();
|
||||
derived.Children.Add(new ElementInfo { Id = 0x2u });
|
||||
|
||||
var merged = ElementReader.Merge(base_, derived);
|
||||
// children must come from derived only
|
||||
Assert.Single(merged.Children);
|
||||
Assert.Equal(0x2u, merged.Children[0].Id);
|
||||
}
|
||||
}
|
||||
75
tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
Normal file
75
tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the committed layout ElementInfo fixtures and builds widget trees —
|
||||
/// no dats required. Fixtures were generated from the real portal.dat and
|
||||
/// serialized with <see cref="System.Text.Json"/>.
|
||||
/// </summary>
|
||||
public static class FixtureLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
IncludeFields = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the committed <c>vitals_2100006C.json</c> fixture (copied to
|
||||
/// the test output directory via the csproj <c>CopyToOutputDirectory</c> item)
|
||||
/// into an <see cref="ElementInfo"/> tree, then builds and returns the
|
||||
/// <see cref="ImportedLayout"/> using a null-returning sprite resolver and no
|
||||
/// dat font — sufficient for conformance checks on tree structure and slice ids.
|
||||
/// </summary>
|
||||
public static ImportedLayout LoadVitals()
|
||||
{
|
||||
var root = LoadVitalsInfos();
|
||||
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the committed <c>vitals_2100006C.json</c> fixture into a raw
|
||||
/// <see cref="ElementInfo"/> tree WITHOUT calling <see cref="LayoutImporter.Build"/>.
|
||||
/// Use this when the test needs to inspect the resolved <see cref="ElementInfo"/>
|
||||
/// tree directly (e.g. inheritance-resolution checks) without exercising the
|
||||
/// widget factory.
|
||||
/// </summary>
|
||||
public static AcDream.App.UI.Layout.ElementInfo LoadVitalsInfos()
|
||||
=> LoadInfos("vitals_2100006C.json");
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the committed <c>chat_21000006.json</c> fixture into a raw
|
||||
/// <see cref="ElementInfo"/> tree and builds the <see cref="ImportedLayout"/>
|
||||
/// using a null-returning sprite resolver and no dat font — sufficient for
|
||||
/// conformance checks on tree structure and resolved types.
|
||||
/// </summary>
|
||||
public static ImportedLayout LoadChat()
|
||||
=> LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the committed <c>chat_21000006.json</c> fixture into a raw
|
||||
/// <see cref="ElementInfo"/> tree WITHOUT calling <see cref="LayoutImporter.Build"/>.
|
||||
/// Use this when the test needs to inspect the resolved <see cref="ElementInfo"/>
|
||||
/// tree directly (e.g. resolved Type values per element id).
|
||||
/// </summary>
|
||||
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
|
||||
=> LoadInfos("chat_21000006.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName);
|
||||
if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}");
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
// Strip UTF-8 BOM (EF BB BF) if present so JsonSerializer.Deserialize<T>(ReadOnlySpan<byte>)
|
||||
// does not reject the first byte.
|
||||
ReadOnlySpan<byte> span = bytes;
|
||||
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF)
|
||||
span = span[3..];
|
||||
return JsonSerializer.Deserialize<AcDream.App.UI.Layout.ElementInfo>(span, _opts)
|
||||
?? throw new InvalidOperationException($"fixture deserialized to null: {path}");
|
||||
}
|
||||
}
|
||||
198
tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs
Normal file
198
tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Golden conformance tests for the vitals LayoutDesc importer.
|
||||
/// Uses the committed JSON fixture (<c>vitals_2100006C.json</c>) — no dats, no GL.
|
||||
///
|
||||
/// These tests lock the importer's tree-building (factory dispatch, meter slice
|
||||
/// extraction, rects) against the real portal.dat values captured when the
|
||||
/// fixture was generated. Any regression in <see cref="LayoutImporter"/>,
|
||||
/// <see cref="DatWidgetFactory"/>, or <see cref="ElementReader"/> will surface here.
|
||||
///
|
||||
/// Sprite ids sourced from <c>docs/research/2026-06-15-layoutdesc-format.md §11</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Conformance")]
|
||||
public class LayoutConformanceTests
|
||||
{
|
||||
// ── Test 1: Three meters at expected rects ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The three vital bars must be UiMeters positioned at x=5, width=150, height=16,
|
||||
/// at y=5 (health), y=21 (stamina), y=37 (mana).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void VitalsTree_HasThreeMetersAtExpectedRects()
|
||||
{
|
||||
var layout = FixtureLoader.LoadVitals();
|
||||
|
||||
(uint Id, float Y)[] expected =
|
||||
[
|
||||
(0x100000E6u, 5f), // health
|
||||
(0x100000ECu, 21f), // stamina
|
||||
(0x100000EEu, 37f), // mana
|
||||
];
|
||||
|
||||
foreach (var (id, y) in expected)
|
||||
{
|
||||
var elem = layout.FindElement(id);
|
||||
Assert.NotNull(elem);
|
||||
var meter = Assert.IsType<UiMeter>(elem);
|
||||
Assert.Equal(5f, meter.Left);
|
||||
Assert.Equal(y, meter.Top);
|
||||
Assert.Equal(150f, meter.Width);
|
||||
Assert.Equal(16f, meter.Height);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 2: All 18 slice ids ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The six back+front 3-slice sprite ids for each of the three meters must
|
||||
/// match the values confirmed from the dat dump (format doc §11).
|
||||
/// This proves the factory's grandchild slice extraction against committed data.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void VitalsTree_MetersHaveExpectedSliceIds()
|
||||
{
|
||||
var layout = FixtureLoader.LoadVitals();
|
||||
|
||||
// Columns: MeterId, then 6 slice ids in order:
|
||||
// BackLeft, BackTile, BackRight, FrontLeft, FrontTile, FrontRight
|
||||
(uint MeterId, uint[] Slices)[] cases =
|
||||
[
|
||||
(0x100000E6u, [0x0600747Eu, 0x0600747Fu, 0x06007480u, 0x06007481u, 0x06007482u, 0x06007483u]), // health
|
||||
(0x100000ECu, [0x06007484u, 0x06007485u, 0x06007486u, 0x06007487u, 0x06007488u, 0x06007489u]), // stamina
|
||||
(0x100000EEu, [0x0600748Au, 0x0600748Bu, 0x0600748Cu, 0x0600748Du, 0x0600748Eu, 0x0600748Fu]), // mana
|
||||
];
|
||||
|
||||
foreach (var (meterId, s) in cases)
|
||||
{
|
||||
var m = Assert.IsType<UiMeter>(layout.FindElement(meterId));
|
||||
Assert.Equal(s[0], m.BackLeft); Assert.Equal(s[1], m.BackTile); Assert.Equal(s[2], m.BackRight);
|
||||
Assert.Equal(s[3], m.FrontLeft); Assert.Equal(s[4], m.FrontTile); Assert.Equal(s[5], m.FrontRight);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 3: Chrome TL corner sprite ───────────────────────────────────────
|
||||
//
|
||||
// NOTE: Type 3 is retail UIElement_Field, but acdream's Type-3 elements here are
|
||||
// sprite-bearing CHROME (the 8-piece bevel corners), so they stay on the generic
|
||||
// UiDatElement fallback (NOT registered as UiField in the factory — see
|
||||
// DatWidgetFactory.Create). This test guards that the chrome corner keeps drawing
|
||||
// its dat sprite; if a future change routes Type 3 → UiField, the corner sprite
|
||||
// would vanish and this assertion fails — which is the intended early warning.
|
||||
|
||||
/// <summary>
|
||||
/// The top-left chrome corner element (id <c>0x10000633</c>) must be a
|
||||
/// <see cref="UiDatElement"/> whose active media file id is <c>0x060074C3</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void VitalsTree_ChromeCornerHasExpectedSprite()
|
||||
{
|
||||
var layout = FixtureLoader.LoadVitals();
|
||||
|
||||
var elem = layout.FindElement(0x10000633u);
|
||||
Assert.NotNull(elem);
|
||||
var datElem = Assert.IsType<UiDatElement>(elem);
|
||||
var (file, _) = datElem.ActiveMedia();
|
||||
Assert.Equal(0x060074C3u, file);
|
||||
}
|
||||
|
||||
// ── Test 4 (N4): Inheritance resolution — FontDid propagated from base ───
|
||||
|
||||
/// <summary>
|
||||
/// Proves that <c>Resolve()</c>'s inheritance merge fired against real dat data:
|
||||
/// at least one element in the fixture tree must have <c>FontDid == 0x40000000</c>
|
||||
/// (the vitals font), inherited from the base-layout prototype <c>0x10000376</c>
|
||||
/// in <c>0x2100003F</c> via the <c>BaseElement</c> / <c>BaseLayoutId</c> chain.
|
||||
///
|
||||
/// <para>
|
||||
/// The three text labels (<c>0x100000EB</c> health, <c>0x100000ED</c> stamina,
|
||||
/// <c>0x100000EF</c> mana) are Type=0 derived elements with no own font property.
|
||||
/// The base element <c>0x10000376</c> carries <c>Properties[0x1A]</c> →
|
||||
/// <c>ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]</c>.
|
||||
/// <see cref="ElementReader.Merge"/> propagates this via the "FontDid: derived wins
|
||||
/// if non-zero, otherwise inherit" rule.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This test verifies end-to-end inheritance resolution against the committed fixture
|
||||
/// (format doc §10, <c>docs/research/2026-06-15-layoutdesc-format.md</c>).
|
||||
/// It operates on the raw <see cref="ElementInfo"/> tree, NOT the widget tree,
|
||||
/// so the factory dispatch (Type 12 → skip) does not interfere.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void VitalsTree_TextLabel_InheritsFontDidFromBaseLayout()
|
||||
{
|
||||
var root = FixtureLoader.LoadVitalsInfos();
|
||||
|
||||
// Walk the full ElementInfo tree and collect all FontDid values.
|
||||
var fontDids = new System.Collections.Generic.List<uint>();
|
||||
CollectFontDids(root, fontDids);
|
||||
|
||||
// At least one element must carry FontDid == 0x40000000 (the vitals font).
|
||||
// In practice, the three text labels (health/stamina/mana) all inherit it.
|
||||
Assert.Contains(0x40000000u, fontDids);
|
||||
}
|
||||
|
||||
private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List<uint> acc)
|
||||
{
|
||||
if (node.FontDid != 0) acc.Add(node.FontDid);
|
||||
foreach (var child in node.Children)
|
||||
CollectFontDids(child, acc);
|
||||
}
|
||||
|
||||
// ── Test 5: Horizontal resize conformance (160→200) ──────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Proves end-to-end reflow for a 160→200 width change using the corrected
|
||||
/// ToAnchors mapping (UIElement::UpdateForParentSizeChange @0x00462640).
|
||||
///
|
||||
/// For each piece, margins are computed from the 160-wide design rect and then
|
||||
/// <see cref="UiElement.ComputeAnchoredRect"/> is applied at parentW=200.
|
||||
///
|
||||
/// Expected outcomes:
|
||||
/// - TL corner (L=1,R=2): Left only → fixed at x=0, w=5
|
||||
/// - top edge (L=1,R=1): Left+Right → stretches to w=190 at x=5
|
||||
/// - TR corner (L=2,R=1): Right only → tracks right at x=195, w=5
|
||||
/// - meter (L=1,R=1): Left+Right → stretches to w=190 at x=5
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HorizontalResize_160to200_ReflowsCorrectly()
|
||||
{
|
||||
const float designParentW = 160f;
|
||||
const float newParentW = 200f;
|
||||
const float parentH = 58f;
|
||||
|
||||
// (piece, designX, designW, LeftEdge, RightEdge, expectedX, expectedW)
|
||||
(string Piece, float DesignX, float DesignW, uint L, uint R, float ExpX, float ExpW)[] cases =
|
||||
[
|
||||
("TL corner", 0f, 5f, 1u, 2u, 0f, 5f ),
|
||||
("top edge", 5f, 150f, 1u, 1u, 5f, 190f),
|
||||
("TR corner", 155f, 5f, 2u, 1u, 195f, 5f ),
|
||||
("meter", 5f, 150f, 1u, 1u, 5f, 190f),
|
||||
];
|
||||
|
||||
foreach (var (piece, dX, dW, l, r, expX, expW) in cases)
|
||||
{
|
||||
// T/B values don't affect x/w; use real vitals values (top=1, bottom=2)
|
||||
var anchors = ElementReader.ToAnchors(l, top: 1u, r, bottom: 2u);
|
||||
|
||||
// Margins from the design rect at parentW=160
|
||||
float mL = dX;
|
||||
float mR = designParentW - (dX + dW);
|
||||
|
||||
// Reflow at parentW=200 (parentH irrelevant for x/w assertions)
|
||||
var (x, _, w, _) = UiElement.ComputeAnchoredRect(
|
||||
anchors, mL, mT: 0f, mR, mB: 0f, w0: dW, h0: 5f, parentW: newParentW, parentH);
|
||||
|
||||
// xUnit 2.x Assert.Equal(float,float,int) = decimal-place precision
|
||||
Assert.True(Math.Abs(x - expX) < 0.5f, $"{piece}: expected x={expX} got {x}");
|
||||
Assert.True(Math.Abs(w - expW) < 0.5f, $"{piece}: expected w={expW} got {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
106
tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs
Normal file
106
tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Pure unit tests for <see cref="LayoutImporter.BuildFromInfos"/> — no dats, no GL.
|
||||
/// Verifies the tree-builder: widget dispatch, Type-12 skipping, and meter child consumption.
|
||||
/// </summary>
|
||||
public class LayoutImporterTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
||||
|
||||
// ── Test 1: Health meter element → UiMeter with correct rect ─────────────
|
||||
|
||||
/// <summary>
|
||||
/// A Type-7 (meter) child element with X=5,Y=5,W=150,H=16 must produce a UiMeter
|
||||
/// that is findable by its id, positioned at Left=5, Width=150.
|
||||
/// The resolve lambda is a 1-arg Func<uint,(uint,int,int)>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildFromInfos_HealthMeter_IsUiMeterAtRect()
|
||||
{
|
||||
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
|
||||
var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 };
|
||||
|
||||
var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, NoTex, null);
|
||||
|
||||
var found = tree.FindElement(0x100000E6);
|
||||
Assert.IsType<UiMeter>(found);
|
||||
Assert.Equal(5f, found!.Left);
|
||||
Assert.Equal(150f, found.Width);
|
||||
}
|
||||
|
||||
// ── Test 2: Type-12 child builds a UiText; Type-3 sibling is also present ──
|
||||
|
||||
/// <summary>
|
||||
/// A root with two children: one Type-12 UIElement_Text and one Type-3 container.
|
||||
/// The Type-12 must appear as a <see cref="UiText"/> in the tree (transparent,
|
||||
/// draws nothing until a controller binds its <c>LinesProvider</c>);
|
||||
/// the Type-3 must also be present.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
|
||||
{
|
||||
var root = new ElementInfo { Id = 0x10000001, Type = 3, Width = 160, Height = 58 };
|
||||
var prototype = new ElementInfo { Id = 0x20000001, Type = 12, Width = 0, Height = 0 };
|
||||
var container = new ElementInfo { Id = 0x20000002, Type = 3, Width = 100, Height = 20 };
|
||||
|
||||
var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null);
|
||||
|
||||
// Type-12 is now a UiText (transparent, no lines) — present in the tree.
|
||||
Assert.IsType<UiText>(tree.FindElement(0x20000001));
|
||||
// Type-3 must also be present.
|
||||
Assert.NotNull(tree.FindElement(0x20000002));
|
||||
}
|
||||
|
||||
// ── Test 3: Meter consumes its children — child ids not in byId ──────────
|
||||
|
||||
/// <summary>
|
||||
/// A meter (Type 7) whose children are the 3-slice back/front containers.
|
||||
/// The meter itself must be findable; its direct children must NOT appear as
|
||||
/// separate nodes in the tree (meters own their children, not the generic tree).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree()
|
||||
{
|
||||
const uint MeterId = 0x100000E6u;
|
||||
const uint BackLayerId = 0x100000E7u;
|
||||
const uint FrontLayerId = 0x00000002u;
|
||||
|
||||
// Build a minimal meter with back + front containers, each with 3 slice children.
|
||||
var backContainer = BuildSliceContainer(BackLayerId, ReadOrder: 0,
|
||||
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
|
||||
var frontContainer = BuildSliceContainer(FrontLayerId, ReadOrder: 1,
|
||||
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
|
||||
|
||||
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 };
|
||||
meter.Children.Add(backContainer);
|
||||
meter.Children.Add(frontContainer);
|
||||
|
||||
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
|
||||
|
||||
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
|
||||
|
||||
// The meter widget is present.
|
||||
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
|
||||
// The meter's dat-children are NOT separate UiElement nodes.
|
||||
Assert.Null(tree.FindElement(BackLayerId));
|
||||
Assert.Null(tree.FindElement(FrontLayerId));
|
||||
// The UiMeter itself has no Ui children (meters consume their children internally).
|
||||
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
|
||||
Assert.Empty(uiMeter.Children);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r)
|
||||
{
|
||||
var c = new ElementInfo { Id = id, Type = 3, ReadOrder = ReadOrder };
|
||||
c.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (l, 1) } });
|
||||
c.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (t, 1) } });
|
||||
c.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (r, 1) } });
|
||||
return c;
|
||||
}
|
||||
}
|
||||
90
tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
Normal file
90
tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class UiDatElementTests
|
||||
{
|
||||
[Fact]
|
||||
public void ActiveMedia_PrefersNamedStateOverDirect()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
info.StateMedia[""] = (0x06000001, 1); // DirectState (DrawMode Normal=1)
|
||||
info.StateMedia["ShowDetail"] = (0x06000002, 3); // named (Alphablend=3)
|
||||
var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "ShowDetail" };
|
||||
Assert.Equal(0x06000002u, e.ActiveMedia().File);
|
||||
Assert.Equal(3, e.ActiveMedia().DrawMode);
|
||||
e.ActiveState = "";
|
||||
Assert.Equal(0x06000001u, e.ActiveMedia().File);
|
||||
Assert.Equal(1, e.ActiveMedia().DrawMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveMedia_NoMedia_ReturnsZero()
|
||||
{
|
||||
var e = new UiDatElement(new ElementInfo(), _ => (0, 0, 0));
|
||||
Assert.Equal(0u, e.ActiveMedia().File);
|
||||
Assert.Equal(0, e.ActiveMedia().DrawMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveMedia_MissingNamedState_FallsBackToDirect()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
info.StateMedia[""] = (0x06000005, 1);
|
||||
var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "NoSuchState" };
|
||||
Assert.Equal(0x06000005u, e.ActiveMedia().File);
|
||||
}
|
||||
|
||||
// ── G1 tests: DefaultStateName + "Normal" implicit default ───────────────
|
||||
|
||||
/// <summary>
|
||||
/// Task G1 change 5: when an element has no DefaultStateName but does have a "Normal"
|
||||
/// state sprite, the ctor should default ActiveState to "Normal" so the element
|
||||
/// renders its normal-state sprite without requiring explicit state assignment.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UiDatElement_DefaultsActiveStateToNormal_WhenNormalPresent()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
info.StateMedia["Normal"] = (0x0000AAAAu, 1);
|
||||
info.StateMedia["Hover"] = (0x0000BBBBu, 1);
|
||||
|
||||
var e = new UiDatElement(info, _ => (0, 0, 0));
|
||||
|
||||
// Should have defaulted to "Normal" state.
|
||||
Assert.Equal(0x0000AAAAu, e.ActiveMedia().File);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task G1 change 5: when DefaultStateName is set (e.g. "Minimized"),
|
||||
/// it takes priority over the "Normal" implicit default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UiDatElement_DefaultsActiveStateToDefaultStateName_WhenSet()
|
||||
{
|
||||
var info = new ElementInfo { DefaultStateName = "Minimized" };
|
||||
info.StateMedia["Minimized"] = (0x0000BBBBu, 1);
|
||||
info.StateMedia["Maximized"] = (0x0000CCCCu, 1);
|
||||
info.StateMedia["Normal"] = (0x0000DDDDu, 1);
|
||||
|
||||
var e = new UiDatElement(info, _ => (0, 0, 0));
|
||||
|
||||
// DefaultStateName "Minimized" wins over "Normal" implicit default.
|
||||
Assert.Equal(0x0000BBBBu, e.ActiveMedia().File);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task G1 change 5: elements with only a DirectState sprite and no "Normal" state
|
||||
/// should still default to "" (DirectState) — no regression for chrome/grip elements.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UiDatElement_NoDefaultStateName_NoNormal_DefaultsToDirectState()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
info.StateMedia[""] = (0x06007777u, 1); // DirectState only (e.g. vitals chrome corner)
|
||||
|
||||
var e = new UiDatElement(info, _ => (0, 0, 0));
|
||||
|
||||
// No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState).
|
||||
Assert.Equal(0x06007777u, e.ActiveMedia().File);
|
||||
}
|
||||
}
|
||||
113
tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs
Normal file
113
tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="VitalsController.Bind"/>: verifies that the controller
|
||||
/// correctly maps element ids to UiMeter instances and wires the Fill / Label providers.
|
||||
/// No dats, no GL — pure data-wiring tests.
|
||||
/// </summary>
|
||||
public class VitalsBindingTests
|
||||
{
|
||||
// ── Test 1: Health meter Fill + Label providers are bound ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_SetsHealthMeterFillFromProvider()
|
||||
{
|
||||
var health = new UiMeter();
|
||||
var layout = FakeLayout((VitalsController.Health, health));
|
||||
float hp = 0.42f;
|
||||
|
||||
VitalsController.Bind(layout,
|
||||
healthPct: () => hp,
|
||||
staminaPct: () => 1f,
|
||||
manaPct: () => 1f,
|
||||
healthText: () => "42/100",
|
||||
staminaText: () => "",
|
||||
manaText: () => "");
|
||||
|
||||
Assert.Equal(0.42f, health.Fill()!.Value);
|
||||
// The meter no longer draws its own label; the cur/max is a centered UiText child.
|
||||
Assert.Null(health.Label());
|
||||
Assert.Equal("42/100", NumberText(health));
|
||||
}
|
||||
|
||||
// ── Test 2: All three meters wired to distinct providers ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_AllThreeMeters_EachBoundToOwnProvider()
|
||||
{
|
||||
var health = new UiMeter();
|
||||
var stamina = new UiMeter();
|
||||
var mana = new UiMeter();
|
||||
var layout = FakeLayout(
|
||||
(VitalsController.Health, health),
|
||||
(VitalsController.Stamina, stamina),
|
||||
(VitalsController.Mana, mana));
|
||||
|
||||
VitalsController.Bind(layout,
|
||||
healthPct: () => 0.25f,
|
||||
staminaPct: () => 0.50f,
|
||||
manaPct: () => 0.75f,
|
||||
healthText: () => "25/100",
|
||||
staminaText: () => "50/100",
|
||||
manaText: () => "75/100");
|
||||
|
||||
// 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(0.50f, stamina.Fill()!.Value);
|
||||
Assert.Equal("50/100", NumberText(stamina));
|
||||
|
||||
Assert.Equal(0.75f, mana.Fill()!.Value);
|
||||
Assert.Equal("75/100", NumberText(mana));
|
||||
}
|
||||
|
||||
// ── Test 3: Missing meter ids are silently skipped (no throw) ─────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_MissingMeterIds_DoesNotThrow()
|
||||
{
|
||||
// Only Health is present; Stamina and Mana are absent from the layout.
|
||||
var health = new UiMeter();
|
||||
var layout = FakeLayout((VitalsController.Health, health));
|
||||
|
||||
// Should not throw even though Stamina/Mana are missing.
|
||||
VitalsController.Bind(layout,
|
||||
healthPct: () => 1f,
|
||||
staminaPct: () => 1f,
|
||||
manaPct: () => 1f,
|
||||
healthText: () => "100/100",
|
||||
staminaText: () => "100/100",
|
||||
manaText: () => "100/100");
|
||||
|
||||
// Health was present — it should be wired.
|
||||
Assert.Equal(1f, health.Fill()!.Value);
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
{
|
||||
var num = Assert.IsType<UiText>(m.Children[0]);
|
||||
Assert.True(num.Centered);
|
||||
var lines = num.LinesProvider();
|
||||
return lines.Count > 0 ? lines[0].Text : "";
|
||||
}
|
||||
|
||||
private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items)
|
||||
{
|
||||
var dict = new Dictionary<uint, UiElement>();
|
||||
var root = new UiPanel();
|
||||
foreach (var (id, e) in items)
|
||||
{
|
||||
root.AddChild(e);
|
||||
dict[id] = e;
|
||||
}
|
||||
return new ImportedLayout(root, dict);
|
||||
}
|
||||
}
|
||||
542
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json
Normal file
542
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
{
|
||||
"Id": 0,
|
||||
"Type": 3,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 0,
|
||||
"Height": 0,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 0,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268435484,
|
||||
"Type": 3,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 382,
|
||||
"Height": 104,
|
||||
"Left": 1,
|
||||
"Top": 2,
|
||||
"Right": 2,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 1,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100667980,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268435485,
|
||||
"Type": 5,
|
||||
"X": 0,
|
||||
"Y": 2,
|
||||
"Width": 382,
|
||||
"Height": 102,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 1,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": 268436774,
|
||||
"Type": 1,
|
||||
"X": 2,
|
||||
"Y": 0,
|
||||
"Width": 16,
|
||||
"Height": 16,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 2,
|
||||
"Bottom": 2,
|
||||
"ReadOrder": 3,
|
||||
"FontDid": 1073741861,
|
||||
"StateMedia": {
|
||||
"Normal": {
|
||||
"Item1": 100688408,
|
||||
"Item2": 1
|
||||
},
|
||||
"Highlight": {
|
||||
"Item1": 100688409,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "Normal",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435486,
|
||||
"Type": 12,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 191,
|
||||
"Height": 17,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 2,
|
||||
"FontDid": 1073741825,
|
||||
"StateMedia": {
|
||||
"Normal": {
|
||||
"Item1": 100667982,
|
||||
"Item2": 1
|
||||
},
|
||||
"Ghosted": {
|
||||
"Item1": 100667982,
|
||||
"Item2": 1
|
||||
},
|
||||
"Talkfocus_highlight": {
|
||||
"Item1": 100667981,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435470,
|
||||
"Type": 268435521,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 800,
|
||||
"Height": 100,
|
||||
"Left": 1,
|
||||
"Top": 2,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 0,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100667725,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268436772,
|
||||
"Type": 1,
|
||||
"X": 0,
|
||||
"Y": 46,
|
||||
"Width": 16,
|
||||
"Height": 16,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 2,
|
||||
"Bottom": 2,
|
||||
"ReadOrder": 6,
|
||||
"FontDid": 1073741861,
|
||||
"StateMedia": {
|
||||
"Normal": {
|
||||
"Item1": 100688408,
|
||||
"Item2": 1
|
||||
},
|
||||
"Highlight": {
|
||||
"Item1": 100688409,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "Normal",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268436773,
|
||||
"Type": 1,
|
||||
"X": 0,
|
||||
"Y": 64,
|
||||
"Width": 16,
|
||||
"Height": 16,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 2,
|
||||
"Bottom": 2,
|
||||
"ReadOrder": 7,
|
||||
"FontDid": 1073741861,
|
||||
"StateMedia": {
|
||||
"Normal": {
|
||||
"Item1": 100688408,
|
||||
"Item2": 1
|
||||
},
|
||||
"Highlight": {
|
||||
"Item1": 100688409,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "Normal",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268436591,
|
||||
"Type": 1,
|
||||
"X": 474,
|
||||
"Y": 0,
|
||||
"Width": 16,
|
||||
"Height": 16,
|
||||
"Left": 2,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 2,
|
||||
"ReadOrder": 3,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"Maximized": {
|
||||
"Item1": 100687460,
|
||||
"Item2": 1
|
||||
},
|
||||
"Minimized": {
|
||||
"Item1": 100687461,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "Minimized",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435471,
|
||||
"Type": 9,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 800,
|
||||
"Height": 9,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 2,
|
||||
"ReadOrder": 1,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100667685,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435472,
|
||||
"Type": 3,
|
||||
"X": 0,
|
||||
"Y": 9,
|
||||
"Width": 490,
|
||||
"Height": 74,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 2,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100667669,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268435473,
|
||||
"Type": 12,
|
||||
"X": 16,
|
||||
"Y": 0,
|
||||
"Width": 458,
|
||||
"Height": 74,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 1,
|
||||
"FontDid": 1073741824,
|
||||
"StateMedia": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268436620,
|
||||
"Type": 1,
|
||||
"X": 0,
|
||||
"Y": 58,
|
||||
"Width": 16,
|
||||
"Height": 16,
|
||||
"Left": 3,
|
||||
"Top": 2,
|
||||
"Right": 3,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 1,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"Normal": {
|
||||
"Item1": 100687630,
|
||||
"Item2": 1
|
||||
},
|
||||
"Normal_pressed": {
|
||||
"Item1": 100687630,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "Ghosted",
|
||||
"Children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": 268435474,
|
||||
"Type": 11,
|
||||
"X": 474,
|
||||
"Y": 6,
|
||||
"Width": 16,
|
||||
"Height": 68,
|
||||
"Left": 2,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 2,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100682847,
|
||||
"Item2": 3
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": 268435475,
|
||||
"Type": 3,
|
||||
"X": 0,
|
||||
"Y": 83,
|
||||
"Width": 490,
|
||||
"Height": 17,
|
||||
"Left": 1,
|
||||
"Top": 2,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 8,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100667706,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268435476,
|
||||
"Type": 6,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 46,
|
||||
"Height": 17,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 2,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 1,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"Normal": {
|
||||
"Item1": 100683109,
|
||||
"Item2": 3
|
||||
},
|
||||
"Normal_pressed": {
|
||||
"Item1": 100683110,
|
||||
"Item2": 3
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "Normal",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268435477,
|
||||
"Type": 12,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 46,
|
||||
"Height": 17,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 1,
|
||||
"FontDid": 1073741826,
|
||||
"StateMedia": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": 268435478,
|
||||
"Type": 12,
|
||||
"X": 46,
|
||||
"Y": 0,
|
||||
"Width": 398,
|
||||
"Height": 17,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 2,
|
||||
"FontDid": 1073741824,
|
||||
"StateMedia": {
|
||||
"Normal_focussed": {
|
||||
"Item1": 100667819,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268435479,
|
||||
"Type": 3,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 1,
|
||||
"Height": 17,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 2,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 1,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"Normal_focussed": {
|
||||
"Item1": 100683111,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435480,
|
||||
"Type": 3,
|
||||
"X": 397,
|
||||
"Y": 0,
|
||||
"Width": 1,
|
||||
"Height": 17,
|
||||
"Left": 2,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 2,
|
||||
"FontDid": 0,
|
||||
"StateMedia": {
|
||||
"Normal_focussed": {
|
||||
"Item1": 100683111,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": 268435481,
|
||||
"Type": 1,
|
||||
"X": 444,
|
||||
"Y": 0,
|
||||
"Width": 46,
|
||||
"Height": 17,
|
||||
"Left": 2,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 3,
|
||||
"FontDid": 1073741826,
|
||||
"StateMedia": {
|
||||
"Normal": {
|
||||
"Item1": 100669717,
|
||||
"Item2": 1
|
||||
},
|
||||
"Normal_pressed": {
|
||||
"Item1": 100669718,
|
||||
"Item2": 1
|
||||
},
|
||||
"Ghosted": {
|
||||
"Item1": 100669748,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "Normal",
|
||||
"Children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": 268436770,
|
||||
"Type": 1,
|
||||
"X": 0,
|
||||
"Y": 10,
|
||||
"Width": 16,
|
||||
"Height": 16,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 2,
|
||||
"Bottom": 2,
|
||||
"ReadOrder": 4,
|
||||
"FontDid": 1073741861,
|
||||
"StateMedia": {
|
||||
"Normal": {
|
||||
"Item1": 100688408,
|
||||
"Item2": 1
|
||||
},
|
||||
"Highlight": {
|
||||
"Item1": 100688409,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "Normal",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268436771,
|
||||
"Type": 1,
|
||||
"X": 0,
|
||||
"Y": 28,
|
||||
"Width": 16,
|
||||
"Height": 16,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 2,
|
||||
"Bottom": 2,
|
||||
"ReadOrder": 5,
|
||||
"FontDid": 1073741861,
|
||||
"StateMedia": {
|
||||
"Normal": {
|
||||
"Item1": 100688408,
|
||||
"Item2": 1
|
||||
},
|
||||
"Highlight": {
|
||||
"Item1": 100688409,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "Normal",
|
||||
"Children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1058
tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json
Normal file
1058
tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json
Normal file
File diff suppressed because it is too large
Load diff
78
tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs
Normal file
78
tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class MarkupDocumentTests
|
||||
{
|
||||
private sealed class FakeBinding
|
||||
{
|
||||
public float HealthPercent => 0.5f;
|
||||
public uint? HealthCurrent => 109;
|
||||
public uint? HealthMax => 218;
|
||||
public float? ManaPercent => null;
|
||||
public uint? ManaCurrent => null;
|
||||
public uint? ManaMax => null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CreatesPanelWithMeterFillLabelAndGeometry()
|
||||
{
|
||||
const string xml =
|
||||
"<panel id=\"acdream.vitals\" x=\"10\" y=\"30\" w=\"220\" h=\"96\" title=\"Vitals\">" +
|
||||
" <meter id=\"health\" x=\"8\" y=\"24\" w=\"200\" h=\"14\" fill=\"{HealthPercent}\" cur=\"{HealthCurrent}\" max=\"{HealthMax}\" color=\"#FFFF0000\"/>" +
|
||||
"</panel>";
|
||||
|
||||
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32));
|
||||
|
||||
Assert.IsType<UiNineSlicePanel>(panel);
|
||||
Assert.Equal(10f, panel.Left);
|
||||
Assert.Equal(220f, panel.Width);
|
||||
Assert.Equal(2, panel.Children.Count); // title UiLabel + 1 meter
|
||||
var meter = Assert.IsType<UiMeter>(panel.Children[1]);
|
||||
Assert.Equal(8f, meter.Left);
|
||||
Assert.Equal(200f, meter.Width);
|
||||
Assert.Equal(0.5f, meter.Fill());
|
||||
Assert.Equal("109/218", meter.Label());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NullBindingValuesYieldNullFillAndLabel()
|
||||
{
|
||||
const string xml =
|
||||
"<panel id=\"v\" x=\"0\" y=\"0\" w=\"10\" h=\"10\" title=\"V\">" +
|
||||
" <meter id=\"mana\" x=\"0\" y=\"0\" w=\"10\" h=\"2\" fill=\"{ManaPercent}\" cur=\"{ManaCurrent}\" max=\"{ManaMax}\" color=\"#FF0000FF\"/>" +
|
||||
"</panel>";
|
||||
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32));
|
||||
var meter = Assert.IsType<UiMeter>(panel.Children[1]);
|
||||
Assert.Null(meter.Fill());
|
||||
Assert.Null(meter.Label());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ResizeAttrX_SetsHorizontalOnly()
|
||||
{
|
||||
const string xml = "<panel id=\"v\" x=\"0\" y=\"0\" w=\"100\" h=\"50\" title=\"V\" resize=\"x\"></panel>";
|
||||
var panel = MarkupDocument.Build(xml, new object(), _ => ((uint)1, 32, 32));
|
||||
Assert.True(panel.ResizeX);
|
||||
Assert.False(panel.ResizeY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ParsesNineSliceBarSpriteIds()
|
||||
{
|
||||
const string xml = "<panel id=\"v\" x=\"0\" y=\"0\" w=\"100\" h=\"50\" title=\"V\">" +
|
||||
"<meter id=\"h\" x=\"0\" y=\"0\" w=\"100\" h=\"14\" fill=\"{HealthPercent}\" " +
|
||||
"backleft=\"0x06001141\" backtile=\"0x06001140\" backright=\"0x0600113F\" " +
|
||||
"frontleft=\"0x06001131\" fronttile=\"0x06001132\" frontright=\"0x06001133\"/>" +
|
||||
"</panel>";
|
||||
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32));
|
||||
var meter = Assert.IsType<UiMeter>(panel.Children[1]);
|
||||
Assert.Equal(0x06001141u, meter.BackLeft);
|
||||
Assert.Equal(0x06001140u, meter.BackTile);
|
||||
Assert.Equal(0x0600113Fu, meter.BackRight);
|
||||
Assert.Equal(0x06001131u, meter.FrontLeft);
|
||||
Assert.Equal(0x06001132u, meter.FrontTile);
|
||||
Assert.Equal(0x06001133u, meter.FrontRight);
|
||||
Assert.NotNull(meter.SpriteResolve);
|
||||
}
|
||||
}
|
||||
25
tests/AcDream.App.Tests/UI/UiButtonTests.cs
Normal file
25
tests/AcDream.App.Tests/UI/UiButtonTests.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiButtonTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
||||
private bool _clicked;
|
||||
|
||||
[Fact]
|
||||
public void Click_InvokesOnClick()
|
||||
{
|
||||
var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex)
|
||||
{ OnClick = () => _clicked = true };
|
||||
b.OnEvent(new UiEvent(0, null, UiEventType.Click));
|
||||
Assert.True(_clicked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotClickThrough_SoItReceivesClicks()
|
||||
{
|
||||
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
|
||||
Assert.False(b.ClickThrough);
|
||||
}
|
||||
}
|
||||
84
tests/AcDream.App.Tests/UI/UiDatFontTests.cs
Normal file
84
tests/AcDream.App.Tests/UI/UiDatFontTests.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Pure pen-advance / MeasureWidth math for the retail dat font (no GL, no dat).
|
||||
/// The advance per glyph is the retail
|
||||
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c>
|
||||
/// (SurfaceWindow::DrawCharacter, acclient 0x00442c3a), accumulated across the
|
||||
/// string the way the retail string loop does (0x00467ed4 edi_3 += var_98).
|
||||
/// </summary>
|
||||
public class UiDatFontTests
|
||||
{
|
||||
private static FontCharDesc Glyph(
|
||||
ushort unicode, byte width,
|
||||
sbyte before = 0, sbyte after = 0,
|
||||
ushort offsetX = 0, ushort offsetY = 0, byte height = 16, sbyte vBefore = 0)
|
||||
=> new()
|
||||
{
|
||||
Unicode = unicode,
|
||||
Width = width,
|
||||
Height = height,
|
||||
OffsetX = offsetX,
|
||||
OffsetY = offsetY,
|
||||
HorizontalOffsetBefore = before,
|
||||
HorizontalOffsetAfter = after,
|
||||
VerticalOffsetBefore = vBefore,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void GlyphAdvance_SumsBeforeWidthAfter()
|
||||
{
|
||||
var g = Glyph('A', width: 8, before: 1, after: 2);
|
||||
Assert.Equal(11f, UiDatFont.GlyphAdvance(g));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlyphAdvance_HandlesNegativeBearings()
|
||||
{
|
||||
// Kerned glyph: a negative left-bearing pulls it leftward; the advance
|
||||
// still nets out to before + width + after.
|
||||
var g = Glyph('j', width: 4, before: -1, after: 0);
|
||||
Assert.Equal(3f, UiDatFont.GlyphAdvance(g));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeasureWidth_SumsEachGlyphAdvance()
|
||||
{
|
||||
var table = new Dictionary<char, FontCharDesc>
|
||||
{
|
||||
['2'] = Glyph('2', width: 7, before: 1, after: 1), // advance 9
|
||||
['9'] = Glyph('9', width: 7, before: 1, after: 1), // advance 9
|
||||
['1'] = Glyph('1', width: 3, before: 2, after: 1), // advance 6
|
||||
['/'] = Glyph('/', width: 4, before: 0, after: 1), // advance 5
|
||||
};
|
||||
FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null;
|
||||
|
||||
// "291/291" = 9 + 9 + 6 + 5 + 9 + 9 + 6 = 53
|
||||
Assert.Equal(53f, UiDatFont.MeasureWidth("291/291", Lookup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeasureWidth_SkipsCharactersNotInFont()
|
||||
{
|
||||
var table = new Dictionary<char, FontCharDesc>
|
||||
{
|
||||
['5'] = Glyph('5', width: 6, before: 1, after: 1), // advance 8
|
||||
};
|
||||
FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null;
|
||||
|
||||
// 'X' has no glyph → contributes nothing; only the two '5's count.
|
||||
Assert.Equal(16f, UiDatFont.MeasureWidth("5X5", Lookup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeasureWidth_EmptyOrNullIsZero()
|
||||
{
|
||||
FontCharDesc? Lookup(char c) => null;
|
||||
Assert.Equal(0f, UiDatFont.MeasureWidth("", Lookup));
|
||||
Assert.Equal(0f, UiDatFont.MeasureWidth(null, Lookup));
|
||||
}
|
||||
}
|
||||
72
tests/AcDream.App.Tests/UI/UiFieldTests.cs
Normal file
72
tests/AcDream.App.Tests/UI/UiFieldTests.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiFieldTests
|
||||
{
|
||||
[Fact]
|
||||
public void InsertChar_AdvancesCaret()
|
||||
{
|
||||
var input = new UiField();
|
||||
input.InsertChar('h'); input.InsertChar('i');
|
||||
Assert.Equal("hi", input.Text);
|
||||
Assert.Equal(2, input.CaretPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backspace_DeletesBeforeCaret()
|
||||
{
|
||||
var input = new UiField();
|
||||
foreach (var c in "abc") input.InsertChar(c);
|
||||
input.MoveCaret(-1);
|
||||
input.Backspace();
|
||||
Assert.Equal("ac", input.Text);
|
||||
Assert.Equal(1, input.CaretPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_FiresCallback_ClearsText_PushesHistory()
|
||||
{
|
||||
string? sent = null;
|
||||
var input = new UiField { OnSubmit = t => sent = t };
|
||||
foreach (var c in "hello") input.InsertChar(c);
|
||||
input.Submit();
|
||||
Assert.Equal("hello", sent);
|
||||
Assert.Equal("", input.Text);
|
||||
Assert.Equal(0, input.CaretPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySubmit_DoesNotFire()
|
||||
{
|
||||
int n = 0;
|
||||
var input = new UiField { OnSubmit = _ => n++ };
|
||||
input.Submit();
|
||||
Assert.Equal(0, n);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void History_UpDownBrowsesPreviousSubmissions()
|
||||
{
|
||||
var input = new UiField { OnSubmit = _ => {} };
|
||||
foreach (var c in "first") input.InsertChar(c); input.Submit();
|
||||
foreach (var c in "second") input.InsertChar(c); input.Submit();
|
||||
input.HistoryPrev();
|
||||
Assert.Equal("second", input.Text);
|
||||
input.HistoryPrev();
|
||||
Assert.Equal("first", input.Text);
|
||||
input.HistoryNext();
|
||||
Assert.Equal("second", input.Text);
|
||||
input.HistoryNext();
|
||||
Assert.Equal("", input.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void History_CapsAt100()
|
||||
{
|
||||
var input = new UiField { OnSubmit = _ => {} };
|
||||
for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
|
||||
Assert.True(input.HistoryCount <= 100);
|
||||
}
|
||||
}
|
||||
178
tests/AcDream.App.Tests/UI/UiMenuTests.cs
Normal file
178
tests/AcDream.App.Tests/UI/UiMenuTests.cs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiMenuTests
|
||||
{
|
||||
// PopupH = RowsPerColumn(7) * RowHeight(17) = 119; popup opens upward so top = -119.
|
||||
// Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17).
|
||||
// Right column needs lx >= ColumnWidth(191) + Border(5) = lx >= 196 after bevel offset,
|
||||
// but the original tests used lx=200 which maps ix=195 -> col=(int)(195/191)=1. OK.
|
||||
|
||||
// The 14 channel items verbatim (matches ChannelItems in ChatWindowController).
|
||||
private static readonly UiMenu.MenuItem[] ChannelItems =
|
||||
{
|
||||
new("Squelch (ignore)", (object?)null),
|
||||
new("Tell to Selected", (object?)null),
|
||||
new("Chat to All", (object?)ChatChannelKind.Say),
|
||||
new("Tell to Fellows", (object?)ChatChannelKind.Fellowship),
|
||||
new("Tell to General Chat", (object?)ChatChannelKind.General),
|
||||
new("Tell to LFG Chat", (object?)ChatChannelKind.Lfg),
|
||||
new("Tell to Society Chat", (object?)ChatChannelKind.Society),
|
||||
new("Tell to Monarch", (object?)ChatChannelKind.Monarch),
|
||||
new("Tell to Patron", (object?)ChatChannelKind.Patron),
|
||||
new("Tell to Vassals", (object?)ChatChannelKind.Vassals),
|
||||
new("Tell to Allegiance", (object?)ChatChannelKind.Allegiance),
|
||||
new("Tell to Trade Chat", (object?)ChatChannelKind.Trade),
|
||||
new("Tell to Roleplay Chat", (object?)ChatChannelKind.Roleplay),
|
||||
new("Tell to Olthoi Chat", (object?)ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
// Availability gate identical to ChatWindowController's EnabledProvider: the null-payload
|
||||
// specials (Squelch/Tell-to-Selected) are ENABLED/white like retail; only talk-CHANNEL
|
||||
// items grey when unavailable. (The widget reports any enabled pick via OnSelect; the
|
||||
// controller decides whether to update Selected, so specials are inert no-ops anyway.)
|
||||
private static bool ChannelAvailable(object? p)
|
||||
=> p is not ChatChannelKind ch
|
||||
|| ch is ChatChannelKind.Say or ChatChannelKind.General
|
||||
or ChatChannelKind.Trade or ChatChannelKind.Lfg;
|
||||
|
||||
private UiMenu MakeMenu() => new UiMenu
|
||||
{
|
||||
Width = 80f, Height = 18f,
|
||||
Items = ChannelItems,
|
||||
Selected = (object?)ChatChannelKind.Say,
|
||||
EnabledProvider = ChannelAvailable,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Items_HasExpected14Entries()
|
||||
{
|
||||
Assert.Equal(14, ChannelItems.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Items_FirstEntry_IsSquelch_Special()
|
||||
{
|
||||
Assert.Equal("Squelch (ignore)", ChannelItems[0].Label);
|
||||
Assert.Null(ChannelItems[0].Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Items_LastEntry_IsOlthoi()
|
||||
{
|
||||
var last = ChannelItems[^1];
|
||||
Assert.Equal("Tell to Olthoi Chat", last.Label);
|
||||
Assert.Equal(ChatChannelKind.Olthoi, last.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Items_ContainAll12ChannelKinds()
|
||||
{
|
||||
var kinds = new HashSet<ChatChannelKind>(
|
||||
ChannelItems.Where(i => i.Payload is ChatChannelKind).Select(i => (ChatChannelKind)i.Payload!));
|
||||
foreach (var k in new[]
|
||||
{
|
||||
ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg,
|
||||
ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron,
|
||||
ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay,
|
||||
ChatChannelKind.Society, ChatChannelKind.Olthoi,
|
||||
})
|
||||
Assert.Contains(k, kinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSelected_IsNull_OnBlankMenu()
|
||||
{
|
||||
// A freshly constructed UiMenu has no Selected by default (controller sets it).
|
||||
Assert.Null(new UiMenu().Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_AvailableLeftColumnItem_FiresOnSelect()
|
||||
{
|
||||
var menu = MakeMenu();
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
|
||||
|
||||
object? fired = null;
|
||||
// Mirror the controller: the widget reports the pick, the controller sets Selected.
|
||||
menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; };
|
||||
|
||||
// "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available.
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76)));
|
||||
Assert.Equal(ChatChannelKind.Say, fired);
|
||||
Assert.Equal(ChatChannelKind.Say, menu.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_AvailableRightColumnItem_FiresOnSelect()
|
||||
{
|
||||
var menu = MakeMenu();
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
|
||||
|
||||
object? fired = null;
|
||||
// Mirror the controller: the widget reports the pick, the controller sets Selected.
|
||||
menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; };
|
||||
|
||||
// "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34).
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42)));
|
||||
Assert.Equal(ChatChannelKind.Trade, fired);
|
||||
Assert.Equal(ChatChannelKind.Trade, menu.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_SpecialItem_FiresNull_LeavesSelectionUnchanged()
|
||||
{
|
||||
var menu = MakeMenu(); // Selected = Say
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
|
||||
|
||||
// Mirror the controller: only channel payloads update Selected; the null-payload
|
||||
// specials are deferred no-ops that leave the active channel + highlight unchanged.
|
||||
bool fired = false; object? firedPayload = "sentinel";
|
||||
menu.OnSelect = p => { fired = true; firedPayload = p; if (p is ChatChannelKind) menu.Selected = p; };
|
||||
|
||||
// "Squelch (ignore)" is index 0 = left col, row 0 (null payload), white/enabled.
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110)));
|
||||
Assert.True(fired); // the pick IS reported...
|
||||
Assert.Null(firedPayload); // ...with the special's null payload
|
||||
Assert.Equal(ChatChannelKind.Say, menu.Selected); // ...but selection is unchanged (deferred no-op)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_UnavailableChannel_DoesNotFire()
|
||||
{
|
||||
var menu = MakeMenu();
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
|
||||
int fired = 0;
|
||||
menu.OnSelect = _ => fired++;
|
||||
|
||||
// "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51).
|
||||
// Fellowship is unavailable by the default static gate, so the click is inert.
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
|
||||
Assert.Equal(0, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledProvider_Overrides_DefaultGate()
|
||||
{
|
||||
// Override: all items enabled (even Fellowship which is normally greyed).
|
||||
var menu = new UiMenu
|
||||
{
|
||||
Width = 80f, Height = 18f,
|
||||
Items = ChannelItems,
|
||||
Selected = (object?)ChatChannelKind.Say,
|
||||
EnabledProvider = _ => true,
|
||||
};
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
|
||||
|
||||
object? fired = null;
|
||||
menu.OnSelect = p => fired = p;
|
||||
|
||||
// With every item enabled, "Tell to Fellows" (idx 3, row 3) now fires.
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
|
||||
Assert.Equal(ChatChannelKind.Fellowship, fired);
|
||||
}
|
||||
}
|
||||
25
tests/AcDream.App.Tests/UI/UiMeterTests.cs
Normal file
25
tests/AcDream.App.Tests/UI/UiMeterTests.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiMeterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeFillRect_HalfFillIsHalfWidth()
|
||||
{
|
||||
var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f);
|
||||
Assert.Equal(0f, x); Assert.Equal(0f, y);
|
||||
Assert.Equal(100f, w); Assert.Equal(12f, h);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f, 0f)] // clamps below 0
|
||||
[InlineData(2f, 200f)] // clamps above 1
|
||||
[InlineData(0f, 0f)]
|
||||
[InlineData(1f, 200f)]
|
||||
public void ComputeFillRect_ClampsFraction(float pct, float expectedW)
|
||||
{
|
||||
var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f);
|
||||
Assert.Equal(expectedW, w);
|
||||
}
|
||||
}
|
||||
27
tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs
Normal file
27
tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiNineSlicePanelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeFrameRects_PlacesCornersEdgesAndCenter()
|
||||
{
|
||||
var r = UiNineSlicePanel.ComputeFrameRects(100, 80, 5);
|
||||
|
||||
// 5x5 corners at the four corners
|
||||
Assert.Equal(new UiNineSlicePanel.Rect(0, 0, 5, 5), r.TL);
|
||||
Assert.Equal(new UiNineSlicePanel.Rect(95, 0, 5, 5), r.TR);
|
||||
Assert.Equal(new UiNineSlicePanel.Rect(0, 75, 5, 5), r.BL);
|
||||
Assert.Equal(new UiNineSlicePanel.Rect(95, 75, 5, 5), r.BR);
|
||||
|
||||
// edges span the interior (100-2*5 = 90 wide, 80-2*5 = 70 tall)
|
||||
Assert.Equal(new UiNineSlicePanel.Rect(5, 0, 90, 5), r.Top);
|
||||
Assert.Equal(new UiNineSlicePanel.Rect(5, 75, 90, 5), r.Bottom);
|
||||
Assert.Equal(new UiNineSlicePanel.Rect(0, 5, 5, 70), r.Left);
|
||||
Assert.Equal(new UiNineSlicePanel.Rect(95, 5, 5, 70), r.Right);
|
||||
|
||||
// center fills the interior
|
||||
Assert.Equal(new UiNineSlicePanel.Rect(5, 5, 90, 70), r.Center);
|
||||
}
|
||||
}
|
||||
239
tests/AcDream.App.Tests/UI/UiRootInputTests.cs
Normal file
239
tests/AcDream.App.Tests/UI/UiRootInputTests.cs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiRootInputTests
|
||||
{
|
||||
[Fact]
|
||||
public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks()
|
||||
{
|
||||
// Regression: the per-frame anchor pass must NOT reset a window's rect,
|
||||
// or move/resize get undone every frame. Windows are user-positioned.
|
||||
var panel = new UiNineSlicePanel(_ => ((uint)1, 32, 32));
|
||||
Assert.Equal(AnchorEdges.None, panel.Anchors);
|
||||
}
|
||||
|
||||
private sealed class CoordRecorder : UiElement
|
||||
{
|
||||
public (int x, int y)? Down, Move;
|
||||
public CoordRecorder() { CapturesPointerDrag = true; }
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.MouseDown) { Down = (e.Data1, e.Data2); return true; }
|
||||
if (e.Type == UiEventType.MouseMove) { Move = (e.Data1, e.Data2); return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDown_And_MouseMove_DeliverSameTargetLocalFrame_ForNestedChild()
|
||||
{
|
||||
// Regression (adversarial review): a nested child must receive target-LOCAL
|
||||
// coords on MouseDown AND MouseMove for the same physical point — otherwise
|
||||
// drag-select anchors ~(child offset) px off from where you click. Before the
|
||||
// fix MouseDown used HitTestTopDown's window-relative coords (50,40) while
|
||||
// MouseMove used target-local (42,32).
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var panel = new UiPanel { Left = 50, Top = 60, Width = 200, Height = 100 };
|
||||
var child = new CoordRecorder { Left = 8, Top = 8, Width = 150, Height = 80 };
|
||||
panel.AddChild(child);
|
||||
root.AddChild(panel);
|
||||
|
||||
// child ScreenPosition = (58,68). Click screen (100,100) -> local (42,32).
|
||||
root.OnMouseDown(UiMouseButton.Left, 100, 100);
|
||||
Assert.Equal((42, 32), child.Down);
|
||||
|
||||
// drag to (120,110) -> local (62,42); MUST share the MouseDown frame.
|
||||
root.OnMouseMove(120, 110);
|
||||
Assert.Equal((62, 42), child.Move);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAnchor_None_IsNoOp()
|
||||
{
|
||||
var e = new UiPanel { Left = 50, Top = 60, Width = 100, Height = 40, Anchors = AnchorEdges.None };
|
||||
e.ApplyAnchor(800, 600);
|
||||
Assert.Equal(50f, e.Left);
|
||||
Assert.Equal(60f, e.Top);
|
||||
Assert.Equal(100f, e.Width);
|
||||
Assert.Equal(40f, e.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WantsMouse_TrueOverWidget_FalseOverEmptySpace()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 };
|
||||
root.AddChild(panel);
|
||||
|
||||
root.OnMouseMove(50, 30); // inside the panel
|
||||
Assert.True(root.WantsMouse);
|
||||
|
||||
root.OnMouseMove(500, 400); // empty space
|
||||
Assert.False(root.WantsMouse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowDrag_RepositionsDraggablePanel_StopsOnRelease()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50, Draggable = true };
|
||||
root.AddChild(panel);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 20); // grab at (10,10) into the panel
|
||||
root.OnMouseMove(120, 90); // drag
|
||||
Assert.Equal(110f, panel.Left); // 120 - 10
|
||||
Assert.Equal(80f, panel.Top); // 90 - 10
|
||||
|
||||
root.OnMouseUp(UiMouseButton.Left, 120, 90);
|
||||
root.OnMouseMove(300, 300); // released — must not move
|
||||
Assert.Equal(110f, panel.Left);
|
||||
Assert.Equal(80f, panel.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonDraggablePanel_DoesNotMoveOnDrag()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; // Draggable defaults false
|
||||
root.AddChild(panel);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
||||
root.OnMouseMove(120, 90);
|
||||
Assert.Equal(10f, panel.Left);
|
||||
Assert.Equal(10f, panel.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturesPointerDragChild_DoesNotMoveDraggableAncestor_OnInteriorDrag()
|
||||
{
|
||||
// A child that captures pointer drags (text selection) must NOT move its
|
||||
// draggable ancestor window when the user drags inside it.
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = new UiPanel { Left = 10, Top = 10, Width = 200, Height = 100, Draggable = true };
|
||||
var child = new UiPanel { Left = 20, Top = 20, Width = 120, Height = 60, CapturesPointerDrag = true };
|
||||
window.AddChild(child);
|
||||
root.AddChild(window);
|
||||
|
||||
// Press deep inside the child, then drag.
|
||||
root.OnMouseDown(UiMouseButton.Left, 60, 60);
|
||||
root.OnMouseMove(160, 160);
|
||||
|
||||
// Window stays put; the captured child receives the drag itself.
|
||||
Assert.Equal(10f, window.Left);
|
||||
Assert.Equal(10f, window.Top);
|
||||
Assert.Same(child, root.Captured);
|
||||
|
||||
root.OnMouseUp(UiMouseButton.Left, 160, 160);
|
||||
Assert.Equal(10f, window.Left);
|
||||
Assert.Equal(10f, window.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturesPointerDragChild_StillAllowsEdgeResizeOfResizableWindow()
|
||||
{
|
||||
// Edge resize must still win even when a CapturesPointerDrag child covers
|
||||
// the frame: a resizable chat window can be resized from its border.
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
|
||||
Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 };
|
||||
// Child fills the whole window (anchored) and captures interior drags.
|
||||
var child = new UiPanel { Left = 0, Top = 0, Width = 200, Height = 100,
|
||||
CapturesPointerDrag = true,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom };
|
||||
window.AddChild(child);
|
||||
root.AddChild(window);
|
||||
|
||||
// Grab within ResizeGrip(5) of the right edge (x=298 of right edge x=300) → resize.
|
||||
root.OnMouseDown(UiMouseButton.Left, 298, 150);
|
||||
root.OnMouseMove(338, 150);
|
||||
Assert.Equal(240f, window.Width);
|
||||
Assert.Equal(100f, window.Left);
|
||||
root.OnMouseUp(UiMouseButton.Left, 338, 150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResizeRect_RightBottom_GrowsSizeOnly()
|
||||
{
|
||||
var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40);
|
||||
Assert.Equal(10f, x); Assert.Equal(20f, y);
|
||||
Assert.Equal(130f, w); Assert.Equal(65f, h);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResizeRect_LeftTop_MovesOriginAndClampsToMin()
|
||||
{
|
||||
// Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40,
|
||||
// origin shifts so the RIGHT edge (110) stays put → x = 70.
|
||||
var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40);
|
||||
Assert.Equal(40f, w);
|
||||
Assert.Equal(70f, x);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitEdges_DetectsCornerAndInteriorNone()
|
||||
{
|
||||
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100 };
|
||||
// bottom-right corner (300,200)
|
||||
Assert.Equal(ResizeEdges.Right | ResizeEdges.Bottom, UiRoot.HitEdges(panel, 300, 200, 5));
|
||||
// deep interior → no edges
|
||||
Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 150, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeDrag_ResizesPanel_InteriorDragMoves()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
|
||||
Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 };
|
||||
root.AddChild(panel);
|
||||
|
||||
// grab just inside the right edge (x=298, within ResizeGrip=5 of x=300) and drag right → wider, same origin
|
||||
root.OnMouseDown(UiMouseButton.Left, 298, 150);
|
||||
root.OnMouseMove(338, 150);
|
||||
Assert.Equal(240f, panel.Width);
|
||||
Assert.Equal(100f, panel.Left);
|
||||
root.OnMouseUp(UiMouseButton.Left, 338, 150);
|
||||
|
||||
// grab the interior and drag → moves
|
||||
root.OnMouseDown(UiMouseButton.Left, 200, 150);
|
||||
root.OnMouseMove(220, 170);
|
||||
Assert.Equal(120f, panel.Left);
|
||||
Assert.Equal(120f, panel.Top);
|
||||
root.OnMouseUp(UiMouseButton.Left, 220, 170);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitEdges_RespectsResizeAxisLock()
|
||||
{
|
||||
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, ResizeY = false };
|
||||
// right edge still detected (X allowed)
|
||||
Assert.True((UiRoot.HitEdges(panel, 300, 150, 5) & ResizeEdges.Right) != 0);
|
||||
// bottom edge masked out (Y locked)
|
||||
Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAnchoredRect_LeftRight_StretchesWidth()
|
||||
{
|
||||
// bar at x=8,w=200 in a 220-wide parent (right margin 12). Parent grows to 300.
|
||||
var (x, _, w, _) = UiElement.ComputeAnchoredRect(
|
||||
AnchorEdges.Left | AnchorEdges.Right | AnchorEdges.Top,
|
||||
mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96);
|
||||
Assert.Equal(8f, x);
|
||||
Assert.Equal(280f, w); // 300 - 12 - 8
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAnchoredRect_LeftTopOnly_KeepsFixedSizeAndOrigin()
|
||||
{
|
||||
var (x, y, w, h) = UiElement.ComputeAnchoredRect(
|
||||
AnchorEdges.Left | AnchorEdges.Top,
|
||||
mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96);
|
||||
Assert.Equal(8f, x); Assert.Equal(24f, y);
|
||||
Assert.Equal(200f, w); Assert.Equal(14f, h);
|
||||
}
|
||||
}
|
||||
73
tests/AcDream.App.Tests/UI/UiScrollableTests.cs
Normal file
73
tests/AcDream.App.Tests/UI/UiScrollableTests.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiScrollableTests
|
||||
{
|
||||
[Fact]
|
||||
public void Clamp_KeepsScrollWithinContent()
|
||||
{
|
||||
var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
|
||||
s.SetScrollY(500);
|
||||
Assert.Equal(200, s.ScrollY);
|
||||
s.SetScrollY(-50);
|
||||
Assert.Equal(0, s.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FitsView_PinsToZero()
|
||||
{
|
||||
var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 };
|
||||
s.SetScrollY(40);
|
||||
Assert.Equal(0, s.ScrollY);
|
||||
Assert.False(s.HasOverflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThumbRatio_IsViewOverContent_ClampedToOne()
|
||||
{
|
||||
var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
||||
Assert.Equal(0.25f, s.ThumbRatio, 3);
|
||||
var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 };
|
||||
Assert.Equal(1f, full.ThumbRatio, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositionRatio_MapsScrollToZeroOne()
|
||||
{
|
||||
var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
|
||||
s.SetScrollY(100);
|
||||
Assert.Equal(0.5f, s.PositionRatio, 3);
|
||||
s.SetScrollY(200);
|
||||
Assert.Equal(1f, s.PositionRatio, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPositionRatio_IsInverseOfPositionRatio()
|
||||
{
|
||||
var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
|
||||
s.SetPositionRatio(0.5f);
|
||||
Assert.Equal(100, s.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollByLines_AdvancesByLineHeight()
|
||||
{
|
||||
var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 };
|
||||
s.ScrollByLines(-2);
|
||||
Assert.Equal(0, s.ScrollY);
|
||||
s.SetScrollY(50);
|
||||
s.ScrollByLines(2);
|
||||
Assert.Equal(82, s.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollByPage_AdvancesByViewHeight()
|
||||
{
|
||||
var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 };
|
||||
s.SetScrollY(200);
|
||||
s.ScrollByPage(1);
|
||||
Assert.Equal(300, s.ScrollY);
|
||||
}
|
||||
}
|
||||
81
tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
Normal file
81
tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Pure unit tests for <see cref="UiScrollbar.ThumbRect"/> — no GL dependency.
|
||||
/// </summary>
|
||||
public class UiScrollbarTests
|
||||
{
|
||||
// Model: content=400, view=100, trackLen=200.
|
||||
// ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50.
|
||||
// Travel = 200 - 50 = 150.
|
||||
|
||||
[Fact]
|
||||
public void ThumbRect_AtStart_HasCorrectSizeAndZeroOffset()
|
||||
{
|
||||
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
||||
// PositionRatio = 0 (start).
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
||||
Assert.Equal(50f, h, 3f);
|
||||
Assert.Equal(0f, y, 3f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThumbRect_AtEnd_PinsToBottomOfTrack()
|
||||
{
|
||||
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
||||
m.ScrollToEnd(); // PositionRatio = 1.
|
||||
float trackTop = 16f, trackLen = 200f;
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop, trackLen);
|
||||
Assert.Equal(50f, h, 3f);
|
||||
// y = trackTop + travel * 1 = 16 + 150 = 166.
|
||||
Assert.Equal(166f, y, 3f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThumbRect_WithButtonH_CorrectlyOffsetsFromTrackTop()
|
||||
{
|
||||
// Matches task spec: content=400, view=100, trackLen=200, PositionRatio=1.
|
||||
// thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150.
|
||||
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
||||
m.ScrollToEnd();
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f);
|
||||
Assert.Equal(50f, h, 3f);
|
||||
Assert.Equal(166f, y, 3f); // 16 + 150
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThumbRect_MidScroll_InterpolatesPosition()
|
||||
{
|
||||
// content=400 view=100 → MaxScroll=300; ScrollY=150 → PositionRatio=0.5.
|
||||
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
||||
m.SetScrollY(150);
|
||||
Assert.Equal(0.5f, m.PositionRatio, 3);
|
||||
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
||||
Assert.Equal(50f, h, 3f);
|
||||
// y = 0 + 150 * 0.5 = 75.
|
||||
Assert.Equal(75f, y, 3f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThumbRect_SmallContent_EnforcesMinThumb()
|
||||
{
|
||||
// content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8.
|
||||
var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 };
|
||||
var (_, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
||||
Assert.Equal(8f, h, 3f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThumbRect_NoOverflow_ThumbFillsTrack()
|
||||
{
|
||||
// content <= view → ThumbRatio = 1 → thumbH = trackLen.
|
||||
var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 };
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f);
|
||||
Assert.Equal(100f, h, 3f);
|
||||
Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop
|
||||
}
|
||||
}
|
||||
30
tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs
Normal file
30
tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
using AcDream.App.UI;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiTextDatFontTests
|
||||
{
|
||||
// Synthetic per-char advance: each glyph 10px (Before=2,Width=6,After=2).
|
||||
private static FontCharDesc Glyph(char c) => new()
|
||||
{
|
||||
Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2,
|
||||
OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void CharIndexAt_UsesDatGlyphAdvance()
|
||||
{
|
||||
float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c));
|
||||
Assert.Equal(0, UiText.CharIndexAt("abc", Adv, 4f));
|
||||
Assert.Equal(1, UiText.CharIndexAt("abc", Adv, 12f));
|
||||
Assert.Equal(3, UiText.CharIndexAt("abc", Adv, 100f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlyphAdvance_MatchesRetailFormula()
|
||||
{
|
||||
Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x')));
|
||||
}
|
||||
}
|
||||
143
tests/AcDream.App.Tests/UI/UiTextTests.cs
Normal file
143
tests/AcDream.App.Tests/UI/UiTextTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClampScroll_PinsToZero_WhenContentFitsView()
|
||||
{
|
||||
// 5 lines of content in a taller view → nothing to scroll, pinned at 0.
|
||||
Assert.Equal(0f, UiText.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f));
|
||||
Assert.Equal(0f, UiText.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampScroll_CapsAtContentMinusView_WhenOverflowing()
|
||||
{
|
||||
// Content 500, view 200 → max scrollback is 300px (oldest line at top).
|
||||
Assert.Equal(300f, UiText.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f));
|
||||
Assert.Equal(120f, UiText.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampScroll_NeverNegative()
|
||||
{
|
||||
Assert.Equal(0f, UiText.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f));
|
||||
}
|
||||
|
||||
// ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ──
|
||||
|
||||
private static readonly Func<char, float> Mono10 = static _ => 10f;
|
||||
|
||||
[Fact]
|
||||
public void CharIndexAt_ZeroOrNegative_IsColumnZero()
|
||||
{
|
||||
Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 0f));
|
||||
Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, -5f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharIndexAt_SnapsToGlyphMidpoint()
|
||||
{
|
||||
// glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ...
|
||||
Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0
|
||||
Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0
|
||||
Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1
|
||||
Assert.Equal(2, UiText.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharIndexAt_PastEnd_IsLength()
|
||||
{
|
||||
Assert.Equal(5, UiText.CharIndexAt("hello", Mono10, 1000f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharIndexAt_EmptyString_IsZero()
|
||||
{
|
||||
Assert.Equal(0, UiText.CharIndexAt("", Mono10, 50f));
|
||||
}
|
||||
|
||||
// ── SelectedText assembly ────────────────────────────────────────────
|
||||
|
||||
private static IReadOnlyList<UiText.Line> Lines(params string[] texts)
|
||||
{
|
||||
var list = new List<UiText.Line>(texts.Length);
|
||||
foreach (var t in texts)
|
||||
list.Add(new UiText.Line(t, new Vector4(1, 1, 1, 1)));
|
||||
return list;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedText_SingleLine_Substring()
|
||||
{
|
||||
var lines = Lines("hello world");
|
||||
var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(0, 11));
|
||||
Assert.Equal("world", s);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedText_SingleLine_ReversedAnchorCaret_IsNormalised()
|
||||
{
|
||||
var lines = Lines("hello world");
|
||||
// caret BEFORE anchor — Order() must normalise.
|
||||
var s = UiText.SelectedText(lines, new UiText.Pos(0, 11), new UiText.Pos(0, 6));
|
||||
Assert.Equal("world", s);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedText_SamePosition_IsEmpty()
|
||||
{
|
||||
var lines = Lines("hello");
|
||||
Assert.Equal("", UiText.SelectedText(lines, new UiText.Pos(0, 3), new UiText.Pos(0, 3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedText_MultiLine_JoinsWithNewline()
|
||||
{
|
||||
var lines = Lines("first line", "second line", "third line");
|
||||
// from col 6 of line 0 ("line") through col 5 of line 2 ("third")
|
||||
var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(2, 5));
|
||||
Assert.Equal("line\nsecond line\nthird", s);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedText_MultiLine_TwoLines_NoMiddle()
|
||||
{
|
||||
var lines = Lines("alpha", "bravo");
|
||||
var s = UiText.SelectedText(lines, new UiText.Pos(0, 2), new UiText.Pos(1, 3));
|
||||
Assert.Equal("pha\nbra", s);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedText_MultiLine_ReversedAnchorCaret_IsNormalised()
|
||||
{
|
||||
var lines = Lines("alpha", "bravo");
|
||||
// end before start → Order() swaps them.
|
||||
var s = UiText.SelectedText(lines, new UiText.Pos(1, 3), new UiText.Pos(0, 2));
|
||||
Assert.Equal("pha\nbra", s);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedText_EmptyLineList_IsEmpty()
|
||||
{
|
||||
Assert.Equal("", UiText.SelectedText(Array.Empty<UiText.Line>(),
|
||||
new UiText.Pos(0, 0), new UiText.Pos(0, 0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Order_SortsByLineThenColumn()
|
||||
{
|
||||
var (s1, e1) = UiText.Order(new UiText.Pos(2, 1), new UiText.Pos(0, 5));
|
||||
Assert.Equal(new UiText.Pos(0, 5), s1);
|
||||
Assert.Equal(new UiText.Pos(2, 1), e1);
|
||||
|
||||
var (s2, e2) = UiText.Order(new UiText.Pos(1, 8), new UiText.Pos(1, 2));
|
||||
Assert.Equal(new UiText.Pos(1, 2), s2);
|
||||
Assert.Equal(new UiText.Pos(1, 8), e2);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue