feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors

Implements Task 2 of the LayoutDesc Importer (Plan 1 — vitals conformance).

- ElementInfo POCO: GL-free/dat-free snapshot of a resolved layout element.
  Shape matches the plan spec exactly (Id, Type as uint, X/Y/Width/Height as
  float, raw Left/Top/Right/Bottom uint edge flags, ReadOrder, FontDid, StateMedia
  dict, Children list). Tasks 3–6 depend on this shape.

- ElementReader.ToAnchors(uint,uint,uint,uint): maps dat edge-flag values
  (0=none, 1=near-pin, 2=far-pin, 3=floating-center, 4=stretch) to AnchorEdges
  bit flags. Corrects the plan's stale assumption that value 4 was the only anchor
  trigger; the verified format doc §4 shows 1→Left/Top, 2→Right/Bottom, 4→both.
  All-zero falls back to Left|Top (default pin top-left).

- ElementReader.Merge(base_, derived): inheritance merge mirroring BaseElement/
  BaseLayoutId. Derived scalars win when non-zero; position/edge-flags/ReadOrder
  always from derived; StateMedia merged (base defaults, derived overrides);
  Children from derived only.

TDD: tests written first (9 tests covering ToAnchors near-pin/far-pin/stretch/
zero/value-3, Merge scalar override/font inheritance/StateMedia merge/children).
All 9 pass; dotnet build 0 errors 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 13:20:23 +02:00
parent 67819f35a4
commit f73422a79a
2 changed files with 304 additions and 0 deletions

View file

@ -0,0 +1,139 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class ElementReaderTests
{
// ── ToAnchors ────────────────────────────────────────────────────────────
/// <summary>
/// Edge value 4 = stretch (pinned to BOTH near AND far sides simultaneously).
/// LeftEdge=4 → Left anchor; RightEdge=4 → Right anchor.
/// TopEdge=1 → Top only (near-pin); BottomEdge=1 → near-pin (left/top axis), NOT Bottom.
/// </summary>
[Fact]
public void EdgeFlagsToAnchors_LeftRight_Stretches()
{
// left=4 (stretch ⇒ Left), top=1 (near-pin ⇒ Top), right=4 (stretch ⇒ Right), bottom=1 (near-pin of bottom axis ⇒ not Bottom)
var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1);
Assert.True(a.HasFlag(AnchorEdges.Left));
Assert.True(a.HasFlag(AnchorEdges.Right));
Assert.False(a.HasFlag(AnchorEdges.Bottom));
}
/// <summary>
/// Edge value 1 = pinned to the NEAR edge of that axis.
/// For LeftEdge: near = Left. For TopEdge: near = Top.
/// For RightEdge: value 1 means near-pin of the right axis → does NOT map to Right anchor.
/// For BottomEdge: value 1 means near-pin of the bottom axis → does NOT map to Bottom anchor.
/// </summary>
[Fact]
public void EdgeFlagsToAnchors_AllOnes_PinsTopLeftOnly()
{
// 1 everywhere: only Left and Top anchors set (near-pins). Right/Bottom are far edges and value 1 is near-pin.
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.False(a.HasFlag(AnchorEdges.Right));
Assert.False(a.HasFlag(AnchorEdges.Bottom));
}
/// <summary>
/// Edge value 2 = pinned to the FAR edge of that axis.
/// For RightEdge: far = Right anchor. For BottomEdge: far = Bottom anchor.
/// For LeftEdge: value 2 means far-pin of the left axis → does NOT map to Left anchor.
/// For TopEdge: value 2 means far-pin of the top axis → does NOT map to Top anchor.
/// </summary>
[Fact]
public void EdgeFlagsToAnchors_AllTwos_PinsRightBottomOnly()
{
// 2 everywhere: only Right and Bottom anchors set (far-pins).
var a = ElementReader.ToAnchors(left: 2, top: 2, right: 2, bottom: 2);
Assert.False(a.HasFlag(AnchorEdges.Left));
Assert.False(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 = floating/centered between both far edges on that axis.
/// Both LeftEdge=3 and RightEdge=3 → neither Left nor Right are set by the
/// near/stretch rules. The result is only Right+Bottom (the "far" semantics).
/// Specifically: left=3 → not Left (3 is not 1 or 4); right=3 → Right (3 is not 2 or 4, skip).
/// Wait — value 3 means "pinned to BOTH far edges" per format doc §4. Re-check the
/// mapping rule: Right anchor fires on right==2 || right==4, NOT on right==3.
/// So value 3 on LeftEdge, TopEdge, RightEdge, BottomEdge → no flags set → default Left|Top.
/// This test covers that corner case (element 0x100004A9 — expand-detail overlay).
/// </summary>
[Fact]
public void EdgeFlagsToAnchors_ValueThree_FallsBackToTopLeft()
{
// value 3 doesn't match any anchor rule; falls back to Left|Top default.
var a = ElementReader.ToAnchors(left: 3, top: 3, right: 3, bottom: 3);
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a);
}
// ── 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);
}
}