diff --git a/src/Forma.Testing/UiMatrix.cs b/src/Forma.Testing/UiMatrix.cs new file mode 100644 index 00000000..6dbee8c9 --- /dev/null +++ b/src/Forma.Testing/UiMatrix.cs @@ -0,0 +1,129 @@ +// 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 + { + /// Creates a case. + 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; + } + + /// 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; } + /// 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; } + /// 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}"; + } + + /// + /// 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); + } + } +}