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:
Erik 2026-06-20 09:29:53 +02:00
commit c83fd02642
94 changed files with 16216 additions and 199 deletions

View file

@ -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>

View 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
}
}

View 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);
}
}

View 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
}
}

View file

@ -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)
}
}

View file

@ -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");
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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}");
}
}

View 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}");
}
}
}

View 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&lt;uint,(uint,int,int)&gt;.
/// </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;
}
}

View 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);
}
}

View 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);
}
}

View 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": []
}
]
}
]
}

File diff suppressed because it is too large Load diff

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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
}
}

View 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')));
}
}

View 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);
}
}