Files
riojoy/tests/RioJoy.Core.Tests/Overlay/OverlayLayoutEngineTests.cs
T
CydandClaude Opus 4.8 8d2d0b71aa Phase 7: cockpit overlay generator, region extraction, and profile editor
Overlay generator (pure, tested) — RioJoy.Core/Overlay:
- FontFitter ports the legacy calc-fontsize auto-fit search (validated against a
  brute-force oracle); OverlayLayoutEngine ports create-data-layer's fit + h/v
  justification and adds per-region 90 deg CCW rotation; OverlayTemplate/Region +
  OverlayTemplateStore hold cell geometry/colour/rotation (regions.json).
- GoobieDataImporter parses the legacy .data label sheet into label rows.
- src/RioJoy.Overlay: SkiaSharp rasterizer (SkiaTextMeasurer shared with the engine
  so measured layout == drawn output; SkiaOverlayRenderer -> PNG;
  ProfileWallpaperGenerator). Verified end-to-end on the real cockpit art
  (regions.json + riojoy.png + TEST.data) by OverlayRenderIntegrationTests.
- RioJoy.Tray/WallpaperApplier applies via SystemParametersInfo; RioCoordinator
  generates+applies on profile activation (opt-in via AppConfig.OverlayTemplatePath).

Region geometry — tools/XcfRegionExtract parses riojoy.xcf (a GIMP-format binary
reader, no GIMP needed) into the 119-cell regions.json: per-layer offsets/size/
font/colour, with 90 deg CCW rotation on a-00 + b-10..b-1F. Base image riojoy.png.
NOTE: the wallpaper positions are a VGA chroma-split display artifact (6 displays),
not the cockpit's logical layout.

Mapping editor (first cut) — RioJoy.Tray/Editor/ProfileEditorForm renders the
config sheet's logical grid (SheetLayout parses the sheet CSV export). The iRIO
word is edited via ButtonBinding <-> RioMapEntry.Create (no hex bit-twiddling), with
a context-sensitive picker: keyboard keys by name (KeyCatalog VK names), joystick
Button N, hat direction, mouse/RIO-command enum names; modifiers only for keyboard.
Opened from the tray ("Edit profile..."). The sheet-grid layout is a starting point
that needs rework from a better-formatted source.

~220 xUnit tests green. Docs (PLAN.md, README) updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:13:48 -05:00

144 lines
5.1 KiB
C#

using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class OverlayLayoutEngineTests
{
// LinearTextMeasurer(0.6, 1.2): width = len*size*0.6, height = size*1.2.
private static readonly OverlayLayoutEngine Engine = new(new LinearTextMeasurer());
private static OverlayRegion Region(
string name = "b-00", double x = 0, double y = 0, double w = 200, double h = 40, double size = 10) =>
new() { Name = name, X = x, Y = y, Width = w, Height = h, FontSizePx = size, FontFamily = "Sans" };
[Fact]
public void Place_TextFits_KeepsBaseSize_AndCenters()
{
// "FIRE" at size 10 -> 24 x 12, well inside 200 x 40.
PlacedLabel p = Engine.Place(Region(x: 10, y: 20), "FIRE");
Assert.Equal(10, p.FontSizePx, 6);
Assert.Equal(24, p.Width, 6);
Assert.Equal(12, p.Height, 6);
Assert.Equal(10 + (200 - 24) / 2, p.X, 6); // 98
Assert.Equal(20 + (40 - 12) / 2, p.Y, 6); // 34
}
[Fact]
public void Place_Overflow_Fit_ShrinksFont()
{
// Narrow cell: "FIRE" at base 10 is 24 wide > 20 -> auto-fit shrinks it.
PlacedLabel p = Engine.Place(Region(w: 20, h: 40), "FIRE");
Assert.True(p.FontSizePx < 10);
Assert.True(p.Width <= 20, $"fitted width {p.Width} must be <= 20");
}
[Fact]
public void Place_Overflow_Crop_KeepsBaseSize()
{
var opts = new OverlayLayoutOptions { SizeMode = OverlaySizeMode.Crop };
PlacedLabel p = Engine.Place(Region(w: 20, h: 40), "FIRE", opts);
Assert.Equal(10, p.FontSizePx, 6); // not shrunk
Assert.Equal(24, p.Width, 6); // overflows the 20-wide cell
}
[Theory]
[InlineData(OverlayHJust.Left, 0)]
[InlineData(OverlayHJust.Center, 44)] // (100-12)/2
[InlineData(OverlayHJust.Right, 88)] // 100-12
public void Place_HorizontalJustification(OverlayHJust hjust, double expectedX)
{
// "AB" at size 10 -> 12 x 12 inside a 100 x 20 cell at origin.
var opts = new OverlayLayoutOptions { HJust = hjust };
PlacedLabel p = Engine.Place(Region(w: 100, h: 20), "AB", opts);
Assert.Equal(expectedX, p.X, 6);
}
[Theory]
[InlineData(OverlayVJust.Top, 0)]
[InlineData(OverlayVJust.Center, 4)] // (20-12)/2
[InlineData(OverlayVJust.Bottom, 8)] // 20-12
public void Place_VerticalJustification(OverlayVJust vjust, double expectedY)
{
var opts = new OverlayLayoutOptions { VJust = vjust };
PlacedLabel p = Engine.Place(Region(w: 100, h: 20), "AB", opts);
Assert.Equal(expectedY, p.Y, 6);
}
[Fact]
public void Place_RegionOverride_BeatsEngineDefault()
{
OverlayRegion region = Region(w: 100, h: 20);
region.HJust = OverlayHJust.Right;
region.VJust = OverlayVJust.Top;
var opts = new OverlayLayoutOptions { HJust = OverlayHJust.Left, VJust = OverlayVJust.Bottom };
PlacedLabel p = Engine.Place(region, "AB", opts);
Assert.Equal(88, p.X, 6); // Right wins over Left
Assert.Equal(0, p.Y, 6); // Top wins over Bottom
}
[Fact]
public void Place_Rotated_CentersInCell_AndCarriesAngle()
{
// "HELLO" at size 10 -> 30 x 12; fits the swapped 200 x 50 envelope, so size holds.
OverlayRegion region = Region(w: 50, h: 200, size: 10);
region.RotationDegrees = 90;
PlacedLabel p = Engine.Place(region, "HELLO");
Assert.Equal(90, p.RotationDegrees, 6);
Assert.Equal(10, p.FontSizePx, 6);
Assert.Equal((50 - 30) / 2.0, p.X, 6); // centered horizontally -> 10
Assert.Equal((200 - 12) / 2.0, p.Y, 6); // centered vertically -> 94
}
[Fact]
public void Place_Rotated_FitsAgainstSwappedDimensions()
{
// A wide, short cell: upright the text fits height-bound; rotated it must fit
// the (swapped) narrow height, forcing a smaller font.
OverlayRegion upright = Region(w: 200, h: 20, size: 30);
OverlayRegion rotated = Region(w: 200, h: 20, size: 30);
rotated.RotationDegrees = 90;
double uprightSize = Engine.Place(upright, "HELLO").FontSizePx;
double rotatedSize = Engine.Place(rotated, "HELLO").FontSizePx;
Assert.True(rotatedSize < uprightSize,
$"rotated {rotatedSize} should be smaller than upright {uprightSize} (fit uses swapped axes)");
}
[Fact]
public void Layout_SkipsEmptyAndMissingLabels_PreservesRegionOrder()
{
var template = new OverlayTemplate
{
Regions =
{
Region("b-00"),
Region("b-01"),
Region("b-02"),
},
};
var labels = new Dictionary<string, string>
{
["b-00"] = "FIRE",
["b-01"] = "", // empty -> skipped
["b-02"] = "GEAR",
["b-99"] = "ORPHAN", // no such region -> ignored
};
IReadOnlyList<PlacedLabel> placed = Engine.Layout(template, labels);
Assert.Equal(2, placed.Count);
Assert.Equal("b-00", placed[0].RegionName);
Assert.Equal("b-02", placed[1].RegionName);
Assert.Equal("GEAR", placed[1].Text);
}
}