From fddb8df3c5062feda67e4aa1746a60fac6adab74 Mon Sep 17 00:00:00 2001 From: zignd Date: Sat, 10 Oct 2026 19:36:31 -0300 Subject: [PATCH 1/2] feat(testing): UiMatrix lays views out per locale, scale and viewport Refs #11. --- src/Forma.Testing/UiMatrix.cs | 118 ++++++++++++++++++++++++++++++ tests/Forma.Tests/UiMatrixTest.cs | 81 ++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 src/Forma.Testing/UiMatrix.cs create mode 100644 tests/Forma.Tests/UiMatrixTest.cs diff --git a/src/Forma.Testing/UiMatrix.cs b/src/Forma.Testing/UiMatrix.cs new file mode 100644 index 00000000..13830f37 --- /dev/null +++ b/src/Forma.Testing/UiMatrix.cs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Igor Hipólito Vieira + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; + +namespace Forma +{ + /// One cell of a run: the view, locale, UI scale and viewport it was laid out with. + public sealed class UiMatrixCase + { + public UiMatrixCase(string view, string locale, int scalePercent, Vector2 viewport, float displayScale, Vector2 logicalViewport) + { + View = view; + Locale = locale; + ScalePercent = scalePercent; + Viewport = viewport; + DisplayScale = displayScale; + LogicalViewport = logicalViewport; + } + + public string View { get; } + public string Locale { get; } + /// The user's UI scale percent (100 is the default). + public int ScalePercent { get; } + /// The back buffer size in pixels. + public Vector2 Viewport { get; } + /// The physical-to-logical scale the case lays out with: back buffer height over the design height, times the scale percent. + public float DisplayScale { get; } + /// The viewport in logical UI units, which is what the controls are laid out in. + public Vector2 LogicalViewport { get; } + public override string ToString() => $"{View} | {Locale} | {ScalePercent}% | {Viewport.X:0}x{Viewport.Y:0}"; + } + + /// A named view a run lays out; the factory receives the case so it can build the view for that locale. + public sealed class UiMatrixView + { + public UiMatrixView(string name, Func create) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Create = create ?? throw new ArgumentNullException(nameof(create)); + } + + public string Name { get; } + public Func Create { get; } + } + + /// A failure found in one cell of the matrix. + public sealed class UiMatrixFailure + { + public UiMatrixFailure(UiMatrixCase matrixCase, string message) + { + Case = matrixCase; + Message = message; + } + + public UiMatrixCase Case { get; } + public string Message { get; } + public override string ToString() => $"{Case}: {Message}"; + } + + /// + /// Lays every view out for every locale, UI scale and viewport and runs a check on each combination, so a layout that only breaks in one + /// language at one scale on one window size is found, and named with all four coordinates. The helper owns the host and disposes it. + /// + public static class UiMatrix + { + /// The height the UI is authored for; the display scale is the back buffer height over this, times the user's scale. + public const float DefaultDesignHeight = 720f; + + /// Lays out each combination and returns the check's messages as failures. The check receives the laid-out context, the view's root and the case. + public static IReadOnlyList Run( + IEnumerable views, + IEnumerable locales, + IEnumerable scalePercents, + IEnumerable viewports, + Func> check, + float designHeight = DefaultDesignHeight, + Action configureContext = null) + { + if (views == null) throw new ArgumentNullException(nameof(views)); + if (locales == null) throw new ArgumentNullException(nameof(locales)); + if (scalePercents == null) throw new ArgumentNullException(nameof(scalePercents)); + if (viewports == null) throw new ArgumentNullException(nameof(viewports)); + if (check == null) throw new ArgumentNullException(nameof(check)); + var failures = new List(); + var localeList = locales.ToList(); + var scaleList = scalePercents.ToList(); + var viewportList = viewports.ToList(); + foreach (var view in views) + foreach (var locale in localeList) + foreach (var scale in scaleList) + foreach (var viewport in viewportList) + { + var displayScale = viewport.Y / designHeight * scale / 100f; + var logical = new Vector2(viewport.X / displayScale, viewport.Y / displayScale); + var matrixCase = new UiMatrixCase(view.Name, locale, scale, viewport, displayScale, logical); + using var context = new UIContext { DisplayScale = displayScale, ViewportSize = logical }; + configureContext?.Invoke(context); + var root = new Control { Size = logical }; + var control = view.Create(matrixCase); + control.Size = logical; + root.AddChild(control); + context.Add(root); + context.Layout(); + foreach (var message in check(context, root, matrixCase)) failures.Add(new UiMatrixFailure(matrixCase, message)); + } + + return failures; + } + + /// A check that reports every finding. + public static Func> LayoutProblems(LayoutProblemOptions options = null) => + (context, root, _) => context.FindLayoutProblems(root, options).Select(problem => $"{problem.Kind}: {problem.Message}"); + } +} diff --git a/tests/Forma.Tests/UiMatrixTest.cs b/tests/Forma.Tests/UiMatrixTest.cs new file mode 100644 index 00000000..b943d40f --- /dev/null +++ b/tests/Forma.Tests/UiMatrixTest.cs @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Igor Hipólito Vieira +// SPDX-License-Identifier: MIT + +using System.Linq; +using Microsoft.Xna.Framework; + +namespace Forma.Tests +{ + [TestFixture] + public sealed class UiMatrixTest + { + private static readonly Vector2[] Viewports = { new Vector2(1280, 720), new Vector2(1920, 1080) }; + + // A label a quarter of the logical width wide: it fits the short text everywhere and the long text only while the logical + // viewport is wide, so it breaks in exactly one language at one scale. + private static IReadOnlyList Run(UIFontFace face, out int cases) + { + var count = 0; + var views = new[] + { + new UiMatrixView("tight label", matrixCase => + { + var text = matrixCase.Locale == "long" ? "Auto-place Spawn Points now" : "Save"; + var label = new Label { Text = text, UIFont = new DynamicUIFont(face, 16), Padding = new Thickness(0), Position = new Vector2(4, 4), Size = new Vector2(matrixCase.LogicalViewport.X / 4, 30) }; + var host = new Control(); + host.AddChild(label); + return host; + }), + }; + + var failures = UiMatrix.Run(views, new[] { "short", "long" }, new[] { 100, 200 }, Viewports, + (context, root, matrixCase) => + { + count++; + return UiMatrix.LayoutProblems().Invoke(context, root, matrixCase); + }); + cases = count; + return failures; + } + + [Test] + public void ABrokenView_FailsInExactlyTheExpectedCombinations_AndNoOther() + { + using var face = UIFontFace.FromProjectFile(TestContext.CurrentContext.TestDirectory, "Fonts/Inter_Regular.ttf"); + + var failures = Run(face, out var cases); + + Assert.Multiple(() => + { + Assert.That(cases, Is.EqualTo(1 * 2 * 2 * 2)); + Assert.That(failures.Select(f => f.Case.Locale).Distinct(), Is.EqualTo(new[] { "long" })); + Assert.That(failures.Select(f => f.Case.ScalePercent).Distinct(), Is.EqualTo(new[] { 200 })); + Assert.That(failures.Select(f => f.Case.Viewport).OrderBy(v => v.X), Is.EqualTo(Viewports)); + Assert.That(failures[0].ToString(), Does.Contain("tight label").And.Contain("long").And.Contain("200%").And.Contain("TextClipped")); + }); + } + + [Test] + public void TheCase_CarriesTheDisplayScaleAndTheLogicalViewport() + { + UiMatrixCase seen = null; + + UiMatrix.Run(new[] { new UiMatrixView("v", c => new Control()) }, new[] { "en" }, new[] { 130 }, new[] { new Vector2(1920, 1080) }, + (context, root, matrixCase) => { seen = matrixCase; return System.Array.Empty(); }); + + Assert.Multiple(() => + { + Assert.That(seen.DisplayScale, Is.EqualTo(1.5f * 1.3f).Within(0.001f)); + Assert.That(seen.LogicalViewport.X, Is.EqualTo(1920 / seen.DisplayScale).Within(0.01f)); + }); + } + + [Test] + public void AViewThatFitsEverywhere_ProducesNoFailures() + { + var failures = UiMatrix.Run(new[] { new UiMatrixView("empty", c => new Control()) }, new[] { "en", "ja" }, new[] { 70, 100, 130 }, Viewports, UiMatrix.LayoutProblems()); + + Assert.That(failures, Is.Empty); + } + } +} From d59bfec407c279ab1ac5cd7f65bbf04b0fdb1e25 Mon Sep 17 00:00:00 2001 From: zignd Date: Sat, 10 Oct 2026 20:11:56 -0300 Subject: [PATCH 2/2] docs: XML documentation for UiMatrix --- src/Forma.Testing/UiMatrix.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Forma.Testing/UiMatrix.cs b/src/Forma.Testing/UiMatrix.cs index 13830f37..6dbee8c9 100644 --- a/src/Forma.Testing/UiMatrix.cs +++ b/src/Forma.Testing/UiMatrix.cs @@ -11,6 +11,7 @@ namespace Forma /// One cell of a run: the view, locale, UI scale and viewport it was laid out with. public sealed class UiMatrixCase { + /// Creates a case. public UiMatrixCase(string view, string locale, int scalePercent, Vector2 viewport, float displayScale, Vector2 logicalViewport) { View = view; @@ -21,7 +22,9 @@ public UiMatrixCase(string view, string locale, int scalePercent, Vector2 viewpo LogicalViewport = logicalViewport; } + /// The name of the view. public string View { get; } + /// The locale the view was built for. public string Locale { get; } /// The user's UI scale percent (100 is the default). public int ScalePercent { get; } @@ -31,33 +34,41 @@ public UiMatrixCase(string view, string locale, int scalePercent, Vector2 viewpo public float DisplayScale { get; } /// The viewport in logical UI units, which is what the controls are laid out in. public Vector2 LogicalViewport { get; } + /// View, locale, scale and viewport as one line. public override string ToString() => $"{View} | {Locale} | {ScalePercent}% | {Viewport.X:0}x{Viewport.Y:0}"; } /// A named view a run lays out; the factory receives the case so it can build the view for that locale. public sealed class UiMatrixView { + /// Creates a view entry. public UiMatrixView(string name, Func create) { Name = name ?? throw new ArgumentNullException(nameof(name)); Create = create ?? throw new ArgumentNullException(nameof(create)); } + /// The name reported in failures. public string Name { get; } + /// Builds the view for a case. public Func Create { get; } } /// A failure found in one cell of the matrix. public sealed class UiMatrixFailure { + /// Creates a failure. public UiMatrixFailure(UiMatrixCase matrixCase, string message) { Case = matrixCase; Message = message; } + /// The cell that failed. public UiMatrixCase Case { get; } + /// What the check reported. public string Message { get; } + /// The case and the message as one line. public override string ToString() => $"{Case}: {Message}"; }