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>
194 lines
8.9 KiB
C#
194 lines
8.9 KiB
C#
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);
|
|
}
|
|
}
|