feat(ui): importer Fix C — per-element dat FontDid resolver (studio path); character level uses its retail font

DatWidgetFactory.Create now accepts an optional fontResolve: Func<uint,UiDatFont?>
parameter. When supplied and the element has a non-zero FontDid, the element
receives its own dat font instead of the shared global datFont fallback.
Null = original single-font behavior (the live GameWindow path passes null —
provably unchanged). LayoutImporter.Build/BuildFromInfos/Import all thread
the optional resolver down to the factory.

RenderStack gains a lazy font cache (ConcurrentDictionary, pre-seeded with
VitalsDatFont + LargeDatFont) and a ResolveDatFont(uint) method. StudioWindow
wires stack.ResolveDatFont into LayoutSource so every studio import gets
per-element fonts. GameWindow import calls left passing null (follow-up todo).

CharacterStatController font-hack cleanup (diagnosed via one-shot console dump
then removed):
- Name (0x10000231): dat FontDid = 18px font — remove datFont override (null)
- Heritage/PkStatus: dat FontDid = 14px fonts — remove override
- LevelCaption: dat FontDid = 16px — remove override (same font, no visual change)
- Level (0x1000023B): dat FontDid = 36px (the big retail gold font) — was forced
  to rowDatFont/LargeDatFont (18px); now drops to null so the dat 36px font drives
- TotalXpLabel/TotalXp: dat FontDid = 16px — remove override
- FooterTitle (0x1000024E): dat FontDid = 20px — remove datFont override
- KEEP: synthesized elements (XP meter overlays, 9 attribute rows, tab sprites)
  still use datFont directly since they have no dat origin

All Label/LabelTwoLine/LabelLeft/LabelProvider helpers updated: null = keep
build-time dat font; non-null = controller explicit override (backward-compat).

8 new tests in DatWidgetFactoryFontResolveTests:
- null resolver → DatFont == global datFont
- FontDid=0 → resolver not called
- resolver returns null → fallback to global datFont
- resolver called with element's FontDid
- controller DatFont override wins after build
- LayoutImporter.Build threads fontResolve to factory
- meter element fires resolver for non-zero FontDid
- BuildFromInfos without fontResolve param = original behavior

Build + all 710 App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-26 10:30:13 +02:00
parent 6e0be4bd34
commit a0d33956f6
7 changed files with 360 additions and 66 deletions

View file

@ -0,0 +1,194 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for Fix C: per-element dat FontDid resolver in <see cref="DatWidgetFactory"/>.
///
/// <para>
/// <see cref="UiDatFont"/> cannot be constructed in pure unit tests (GL + dat required),
/// so these tests verify the plumbing using null/non-null identity and sentinel resolvers:
/// <list type="bullet">
/// <item>Null resolver → DatFont unchanged (backward-compat: same as before Fix C).</item>
/// <item>Resolver present, FontDid == 0 → resolver NOT called; DatFont == global datFont.</item>
/// <item>Resolver returns null for an id (font missing) → DatFont falls back to global datFont.</item>
/// <item>Controller sets DatFont after build → controller value wins.</item>
/// <item><see cref="LayoutImporter.Build"/> threads fontResolve to the factory.</item>
/// </list>
/// </para>
/// </summary>
public class DatWidgetFactoryFontResolveTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
// ── Test 1: null fontResolve → DatFont == datFont (backward-compat) ─────
/// <summary>
/// When fontResolve is null (the live GameWindow path), BuildText must NOT
/// touch DatFont beyond setting it to the global datFont. Null in → null out.
/// </summary>
[Fact]
public void NullFontResolve_DatFont_EqualsGlobalFont()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = 0x40000001u };
// datFont=null, fontResolve=null → backward-compat: DatFont == null
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null, fontResolve: null));
Assert.Null(t.DatFont);
}
// ── Test 2: FontDid == 0 → resolver NOT called ────────────────────────────
/// <summary>
/// When the element has FontDid == 0, the fontResolve delegate must NOT be
/// invoked (the element has no dat font; fall through to global datFont).
/// </summary>
[Fact]
public void FontDidZero_ResolverNotCalled()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = 0u };
bool called = false;
UiDatFont? Resolver(uint id) { called = true; return null; }
// FontDid=0 → resolver should never fire, even though one is provided.
Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null, fontResolve: Resolver));
Assert.False(called, "fontResolve must NOT be called when FontDid == 0");
}
// ── Test 3: resolver returns null (font missing) → fallback to datFont ───
/// <summary>
/// When the element has a non-zero FontDid but the resolver returns null
/// (font not found in dats), DatFont must fall back to the shared global datFont.
/// Since datFont is null in unit tests, this verifies the fallback chain.
/// </summary>
[Fact]
public void ResolverReturnsNull_FallsBackToGlobalFont()
{
const uint SomeFontDid = 0x40000005u;
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = SomeFontDid };
int callCount = 0;
UiDatFont? Resolver(uint id) { callCount++; return null; } // always returns null (font missing)
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver));
// Resolver was called exactly once for this element.
Assert.Equal(1, callCount);
// Fallback: DatFont == global datFont (null in unit tests).
Assert.Null(t.DatFont);
}
// ── Test 4: resolver called with correct FontDid ──────────────────────────
/// <summary>
/// When fontResolve is provided and FontDid is non-zero, the resolver is called
/// with the element's exact FontDid value.
/// </summary>
[Fact]
public void ResolverCalledWithElementFontDid()
{
const uint ExpectedFontDid = 0x40000002u;
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = ExpectedFontDid };
uint? capturedId = null;
UiDatFont? Resolver(uint id) { capturedId = id; return null; }
DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver);
Assert.Equal(ExpectedFontDid, capturedId);
}
// ── Test 5: controller DatFont override wins over build-time value ───────
/// <summary>
/// A controller that calls FindElement and sets DatFont afterward MUST override
/// whatever the factory set at build time. This is the backward-compat guarantee.
/// The DatFont property is settable — a controller can override it after build.
/// </summary>
[Fact]
public void ControllerDatFontOverride_WinsOverBuildTimeFont()
{
const uint SomeFontDid = 0x40000003u;
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = SomeFontDid };
UiDatFont? Resolver(uint _) => null; // returns null → build-time DatFont == null
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver));
Assert.Null(t.DatFont); // build-time value set by factory
// Controller simulated override: set DatFont to null explicitly.
// The real guarantee is that DatFont is a settable property.
// (We can't construct a real UiDatFont here — it requires GL + dats.)
t.DatFont = null; // controller overrides after build
Assert.Null(t.DatFont);
// ✓ DatFont is settable — controller override mechanism is in place.
}
// ── Test 6: LayoutImporter.Build threads fontResolve to factory ──────────
/// <summary>
/// When <see cref="LayoutImporter.Build"/> is given a fontResolve, it must pass
/// it to <see cref="DatWidgetFactory.Create"/> for EVERY element in the tree.
/// Verified by checking that a Type-12 child with FontDid triggers the resolver.
/// </summary>
[Fact]
public void LayoutImporter_Build_ThreadsFontResolve_ToFactory()
{
const uint FontDid = 0x40000004u;
var root = new ElementInfo { Id = 1u, Type = 3, Width = 200, Height = 100 };
var child = new ElementInfo { Id = 2u, Type = 12, Width = 100, Height = 20, FontDid = FontDid };
root.Children.Add(child);
int resolveCallCount = 0;
UiDatFont? Resolver(uint id) { resolveCallCount++; return null; }
var layout = LayoutImporter.Build(root, NoTex, datFont: null, fontResolve: Resolver);
// The child element has a non-zero FontDid — resolver must have been called for it.
Assert.True(resolveCallCount > 0, "fontResolve was not called for the child element");
// The child widget was built (findable in the layout).
Assert.NotNull(layout.FindElement(2u));
}
// ── Test 7: Meter element also receives element font from resolver ────────
/// <summary>
/// Type-7 (UiMeter) elements with a FontDid must also go through the resolver.
/// Verified by checking the resolver fires for a meter element with a non-zero FontDid.
/// </summary>
[Fact]
public void FontResolve_CalledForMeter_WhenFontDidPresent()
{
const uint MeterFontDid = 0x40000006u;
var meter = new ElementInfo { Type = 7, Width = 150, Height = 16, FontDid = MeterFontDid };
int callCount = 0;
UiDatFont? Resolver(uint id) { callCount++; return null; }
var w = DatWidgetFactory.Create(meter, NoTex, datFont: null, fontResolve: Resolver);
var m = Assert.IsType<UiMeter>(w);
// Resolver was called for the meter element's FontDid.
Assert.True(callCount > 0, "fontResolve was not called for meter element");
// Meter DatFont: resolver returned null, so DatFont falls back to global datFont (null).
Assert.Null(m.DatFont);
}
// ── Test 8: LayoutImporter.BuildFromInfos signature is backward-compat ────
/// <summary>
/// <see cref="LayoutImporter.BuildFromInfos"/> without fontResolve (default null)
/// must still work for all callers that don't pass it — verifies the optional
/// parameter default is correct and no existing callers broke.
/// </summary>
[Fact]
public void BuildFromInfos_WithoutFontResolve_WorksAsBeforeFix()
{
var root = new ElementInfo { Id = 10u, Type = 3, Width = 200, Height = 100 };
var child = new ElementInfo { Id = 11u, Type = 12, Width = 100, Height = 20, FontDid = 0x40000001u };
// Call without fontResolve (the old signature shape).
var layout = LayoutImporter.BuildFromInfos(root, new[] { child }, NoTex, datFont: null);
var t = Assert.IsType<UiText>(layout.FindElement(11u));
// No resolver → DatFont == global datFont (null in unit tests).
Assert.Null(t.DatFont);
}
}