Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/Forma.Testing/UiMatrix.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>One cell of a <see cref="UiMatrix"/> run: the view, locale, UI scale and viewport it was laid out with.</summary>
public sealed class UiMatrixCase
{
/// <summary>Creates a case.</summary>
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;
}

/// <summary>The name of the view.</summary>
public string View { get; }
/// <summary>The locale the view was built for.</summary>
public string Locale { get; }
/// <summary>The user's UI scale percent (100 is the default).</summary>
public int ScalePercent { get; }
/// <summary>The back buffer size in pixels.</summary>
public Vector2 Viewport { get; }
/// <summary>The physical-to-logical scale the case lays out with: back buffer height over the design height, times the scale percent.</summary>
public float DisplayScale { get; }
/// <summary>The viewport in logical UI units, which is what the controls are laid out in.</summary>
public Vector2 LogicalViewport { get; }
/// <summary>View, locale, scale and viewport as one line.</summary>
public override string ToString() => $"{View} | {Locale} | {ScalePercent}% | {Viewport.X:0}x{Viewport.Y:0}";
}

/// <summary>A named view a <see cref="UiMatrix"/> run lays out; the factory receives the case so it can build the view for that locale.</summary>
public sealed class UiMatrixView
{
/// <summary>Creates a view entry.</summary>
public UiMatrixView(string name, Func<UiMatrixCase, Control> create)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Create = create ?? throw new ArgumentNullException(nameof(create));
}

/// <summary>The name reported in failures.</summary>
public string Name { get; }
/// <summary>Builds the view for a case.</summary>
public Func<UiMatrixCase, Control> Create { get; }
}

/// <summary>A failure found in one cell of the matrix.</summary>
public sealed class UiMatrixFailure
{
/// <summary>Creates a failure.</summary>
public UiMatrixFailure(UiMatrixCase matrixCase, string message)
{
Case = matrixCase;
Message = message;
}

/// <summary>The cell that failed.</summary>
public UiMatrixCase Case { get; }
/// <summary>What the check reported.</summary>
public string Message { get; }
/// <summary>The case and the message as one line.</summary>
public override string ToString() => $"{Case}: {Message}";
}

/// <summary>
/// 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.
/// </summary>
public static class UiMatrix
{
/// <summary>The height the UI is authored for; the display scale is the back buffer height over this, times the user's scale.</summary>
public const float DefaultDesignHeight = 720f;

/// <summary>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.</summary>
public static IReadOnlyList<UiMatrixFailure> Run(
IEnumerable<UiMatrixView> views,
IEnumerable<string> locales,
IEnumerable<int> scalePercents,
IEnumerable<Vector2> viewports,
Func<UIContext, Control, UiMatrixCase, IEnumerable<string>> check,
float designHeight = DefaultDesignHeight,
Action<UIContext> 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<UiMatrixFailure>();
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;
}

/// <summary>A check that reports every <see cref="UIContext.FindLayoutProblems(Control, LayoutProblemOptions)"/> finding.</summary>
public static Func<UIContext, Control, UiMatrixCase, IEnumerable<string>> LayoutProblems(LayoutProblemOptions options = null) =>
(context, root, _) => context.FindLayoutProblems(root, options).Select(problem => $"{problem.Kind}: {problem.Message}");
}
}
81 changes: 81 additions & 0 deletions tests/Forma.Tests/UiMatrixTest.cs
Original file line number Diff line number Diff line change
@@ -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<UiMatrixFailure> 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<string>(); });

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);
}
}
}
Loading