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>
This commit is contained in:
Cyd
2026-06-27 17:13:48 -05:00
co-authored by Claude Opus 4.8
parent 2deeb374ae
commit 8d2d0b71aa
47 changed files with 3866 additions and 21 deletions
@@ -0,0 +1,78 @@
using RioJoy.Core.Editing;
using Xunit;
namespace RioJoy.Core.Tests.Editing;
public class SheetLayoutTests
{
[Theory]
[InlineData("0F", 0x0F)]
[InlineData("50", 0x50)]
[InlineData("6F", 0x6F)]
[InlineData("00", 0x00)]
public void AddressFromText_ParsesHexCells(string text, int expected)
{
Assert.Equal(expected, SheetLayout.AddressFromText(text));
}
[Theory]
[InlineData("HOME")]
[InlineData("RIO_0")]
[InlineData("70")] // > 0x6F
[InlineData("M U")]
[InlineData("0")] // single digit
public void AddressFromText_RejectsNonAddresses(string text)
{
Assert.Null(SheetLayout.AddressFromText(text));
}
[Theory]
[InlineData(0x00, SheetSection.Contacts)]
[InlineData(0x10, SheetSection.Secondary)]
[InlineData(0x18, SheetSection.Screen)]
[InlineData(0x2F, SheetSection.Letters)]
[InlineData(0x38, SheetSection.Throttle)]
[InlineData(0x40, SheetSection.Joystick)]
[InlineData(0x50, SheetSection.Keypad)]
[InlineData(0x6F, SheetSection.ExtKeypad)]
public void SectionForAddress_Classifies(int address, SheetSection expected)
{
Assert.Equal(expected, SheetLayout.SectionForAddress(address));
}
[Fact]
public void Parse_BuildsCellsWithGridPositions()
{
const string csv =
"\"\",\"IsLit\",\"\",\"\",\"0F\"\n" +
"\"\",\"\",\"\",\"\",\"HOME\"";
IReadOnlyList<SheetCell> cells = SheetLayout.Parse(csv);
SheetCell addr = Assert.Single(cells, c => c.Text == "0F");
Assert.Equal(0x0F, addr.Address);
Assert.Equal(SheetSection.Contacts, addr.Section);
Assert.Equal(0, addr.Row);
Assert.Equal(4, addr.Col);
SheetCell key = Assert.Single(cells, c => c.Text == "HOME");
Assert.Null(key.Address);
Assert.Equal(1, key.Row);
Assert.Equal(4, key.Col);
}
[Fact]
public void Parse_RealSheet_HasFullAddressMap()
{
IReadOnlyList<SheetCell> cells = SheetLayout.Load(
TestRepo.CustomBackground("config-sheet.csv"));
// Every RIO address 0x00..0x6F that exists should appear exactly once as a cell.
var addresses = cells.Where(c => c.Address is not null).Select(c => c.Address!.Value).ToHashSet();
Assert.Contains(0x00, addresses);
Assert.Contains(0x2F, addresses); // a letter button
Assert.Contains(0x50, addresses); // keypad
Assert.Contains(0x6F, addresses); // ext keypad
Assert.True(addresses.Count >= 100, $"expected most of the address map, got {addresses.Count}");
}
}
@@ -0,0 +1,84 @@
using RioJoy.Core.Mapping;
using Xunit;
namespace RioJoy.Core.Tests.Mapping;
public class ButtonBindingTests
{
[Theory]
[InlineData(RioRouteKind.Keyboard, 0x47, 0x0047)]
[InlineData(RioRouteKind.Joystick, 0x58, 0x1058)]
[InlineData(RioRouteKind.Hat, 0x01, 0x2001)]
[InlineData(RioRouteKind.Mouse, 0x10, 0x4010)]
[InlineData(RioRouteKind.RioCommand, 0x60, 0x7060)]
public void Create_PacksRoutingFlagsAndValue(RioRouteKind kind, byte value, ushort expected)
{
RioMapEntry e = RioMapEntry.Create(kind, value);
Assert.Equal(expected, e.Raw);
Assert.Equal(kind, e.Kind);
Assert.Equal(value, e.Value);
}
[Fact]
public void Create_SetsHighByteBits()
{
RioMapEntry e = RioMapEntry.Create(
RioRouteKind.Keyboard, 0x41, lit: true, shift: true, ctrl: true, alt: true, extended: true);
Assert.True(e.HasLamp);
Assert.True(e.Shift);
Assert.True(e.Ctrl);
Assert.True(e.Alt);
Assert.True(e.Extended);
Assert.Equal(0x41, e.Value);
// 0x8000|0x0800|0x0400|0x0200|0x0100|0x41
Assert.Equal(0x8F41, e.Raw);
}
[Theory]
[InlineData(RioRouteKind.Keyboard)]
[InlineData(RioRouteKind.Joystick)]
[InlineData(RioRouteKind.Hat)]
[InlineData(RioRouteKind.Mouse)]
[InlineData(RioRouteKind.RioCommand)]
public void Binding_RoundTripsThroughWord(RioRouteKind kind)
{
var b = new ButtonBinding
{
Kind = kind,
Value = 0x2A,
IsLit = true,
Shift = true,
Alt = true,
};
ushort word = b.ToWord();
ButtonBinding back = ButtonBinding.FromWord(word);
Assert.Equal(b.Kind, back.Kind);
Assert.Equal(b.Value, back.Value);
Assert.Equal(b.IsLit, back.IsLit);
Assert.Equal(b.Shift, back.Shift);
Assert.Equal(b.Ctrl, back.Ctrl);
Assert.Equal(b.Alt, back.Alt);
Assert.Equal(b.Extended, back.Extended);
Assert.Equal(word, back.ToWord());
}
[Fact]
public void Binding_FromWord_DecodesLegacyValue()
{
// 0x8F41 from the encode test -> lit keyboard 'A' with all modifiers.
ButtonBinding b = ButtonBinding.FromWord(0x8F41);
Assert.Equal(RioRouteKind.Keyboard, b.Kind);
Assert.Equal(0x41, b.Value);
Assert.True(b.IsLit && b.Shift && b.Ctrl && b.Alt && b.Extended);
}
[Fact]
public void Binding_Default_IsUnmapped()
{
Assert.True(new ButtonBinding().IsUnmapped);
Assert.False(new ButtonBinding { Value = 0x41 }.IsUnmapped);
}
}
@@ -0,0 +1,42 @@
using RioJoy.Core.Mapping;
using Xunit;
namespace RioJoy.Core.Tests.Mapping;
public class KeyCatalogTests
{
[Theory]
[InlineData("A", 0x41)]
[InlineData("Z", 0x5A)]
[InlineData("0", 0x30)]
[InlineData("F1", 0x70)]
[InlineData("F12", 0x7B)]
[InlineData("Enter", 0x0D)]
[InlineData("Space", 0x20)]
[InlineData("Left", 0x25)]
[InlineData("NumPad 5", 0x65)]
public void NameAndValue_RoundTrip(string name, int vk)
{
Assert.True(KeyCatalog.TryGetValue(name, out byte v));
Assert.Equal((byte)vk, v);
Assert.Equal(name, KeyCatalog.NameFor((byte)vk));
}
[Fact]
public void NameFor_UnknownVk_ReturnsHex()
{
Assert.Equal("0xFF", KeyCatalog.NameFor(0xFF));
}
[Fact]
public void TryGetValue_Unknown_ReturnsFalse()
{
Assert.False(KeyCatalog.TryGetValue("NotAKey", out _));
}
[Fact]
public void Entries_AreUniqueByName()
{
Assert.Equal(KeyCatalog.Entries.Count, KeyCatalog.Entries.Select(e => e.Name).Distinct().Count());
}
}
@@ -0,0 +1,68 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
/// <summary>
/// Regression tests over the committed cockpit <c>regions.json</c> extracted from
/// riojoy.xcf by tools/XcfRegionExtract. Anchors the extracted geometry and proves
/// the asset loads through <see cref="OverlayTemplateStore"/>.
/// </summary>
public class CockpitRegionsTests
{
private static OverlayTemplate Load() =>
OverlayTemplateStore.Load(TestRepo.CustomBackground("regions.json"));
[Fact]
public void Loads_WithExpectedCanvasAndCount()
{
OverlayTemplate t = Load();
Assert.Equal(2720, t.Width);
Assert.Equal(600, t.Height);
Assert.Equal(119, t.Regions.Count);
}
[Fact]
public void HasExpectedButtonCell()
{
OverlayRegion? b00 = Load().FindRegion("b-00");
Assert.NotNull(b00);
Assert.Equal(2558, b00!.X, 6);
Assert.Equal(428, b00.Y, 6);
Assert.Equal(153, b00.Width, 6);
Assert.Equal(53, b00.Height, 6);
Assert.Equal(45, b00.FontSizePx, 6);
Assert.Equal("Sans", b00.FontFamily);
}
[Fact]
public void IncludesNamedControlLabels()
{
OverlayTemplate t = Load();
Assert.NotNull(t.FindRegion("Throttle"));
Assert.NotNull(t.FindRegion("Trigger"));
Assert.NotNull(t.FindRegion("HatUp"));
}
[Fact]
public void VerticalCells_AreTaggedCcw90()
{
OverlayTemplate t = Load();
Assert.Equal(90.0, t.FindRegion("a-00")!.RotationDegrees);
Assert.Equal(90.0, t.FindRegion("b-10")!.RotationDegrees);
Assert.Equal(90.0, t.FindRegion("b-1F")!.RotationDegrees);
Assert.Null(t.FindRegion("b-00")!.RotationDegrees); // a normal upright cell
}
[Fact]
public void AllRegionsLieWithinCanvas()
{
OverlayTemplate t = Load();
Assert.All(t.Regions, r =>
{
Assert.True(r.X >= 0 && r.Y >= 0, $"{r.Name} has negative offset");
Assert.True(r.X + r.Width <= t.Width, $"{r.Name} overflows width");
Assert.True(r.Y + r.Height <= t.Height, $"{r.Name} overflows height");
});
}
}
@@ -0,0 +1,75 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class FontFitterTests
{
// Brute-force oracle: the largest integer size whose text fits, mirroring the
// routine's truncate-to-integer behavior and MinFontSize floor.
private static double BruteForceBestFit(
string text, ITextMeasurer m, double width, double height, double cap = 1000)
{
double best = FontFitter.MinFontSize;
for (double s = FontFitter.MinFontSize; s <= cap; s++)
{
TextExtent e = m.Measure(text, "f", s);
if (e.Width <= width && e.Height <= height)
best = s;
}
return best;
}
[Theory]
[InlineData("FIRE", 100, 20)]
[InlineData("GEAR UP", 120, 24)]
[InlineData("X", 50, 50)]
[InlineData("LONGER LABEL TEXT", 80, 16)]
[InlineData("WW", 10, 40)] // width-bound, tiny cell
public void BestFitSize_MatchesBruteForceOracle(string text, double w, double h)
{
var m = new LinearTextMeasurer();
double fit = FontFitter.BestFitSize(text, "f", w, h, m);
double oracle = BruteForceBestFit(text, m, w, h);
Assert.Equal(oracle, fit);
}
[Fact]
public void BestFitSize_ResultActuallyFits()
{
var m = new LinearTextMeasurer();
double size = FontFitter.BestFitSize("THROTTLE", "f", 90, 18, m);
TextExtent e = m.Measure("THROTTLE", "f", size);
Assert.True(e.Width <= 90 && e.Height <= 18, $"size {size} -> {e.Width}x{e.Height} should fit 90x18");
}
[Fact]
public void BestFitSize_NeverBelowMinimum()
{
var m = new LinearTextMeasurer();
// A cell far too small for any text still yields the floor, not 0/negative.
double size = FontFitter.BestFitSize("IMPOSSIBLE", "f", 1, 1, m);
Assert.Equal(FontFitter.MinFontSize, size);
}
[Fact]
public void BestFitSize_EmptyText_Terminates()
{
// Empty text has zero width; the loop must still converge (via repeated
// extents) rather than grow forever.
double size = FontFitter.BestFitSize("", "f", 100, 100, new LinearTextMeasurer());
Assert.True(size >= FontFitter.MinFontSize);
}
[Fact]
public void BestFitSize_LargerCell_AllowsLargerFont()
{
var m = new LinearTextMeasurer();
double small = FontFitter.BestFitSize("ABC", "f", 60, 12, m);
double big = FontFitter.BestFitSize("ABC", "f", 600, 120, m);
Assert.True(big > small);
}
}
@@ -0,0 +1,57 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class GoobieDataImporterTests
{
private const string Sample = """
; a comment, ignored
( a-00 b-00 b-01 b-02 )
( "tag" "FIRE" "" "GEAR UP" )
""";
[Fact]
public void Parse_ReadsFieldsAndRow()
{
GoobieDataImporter.Sheet sheet = GoobieDataImporter.Parse(Sample);
Assert.Equal(new[] { "a-00", "b-00", "b-01", "b-02" }, sheet.Fields);
Assert.Single(sheet.Rows);
Assert.Equal("tag", sheet.Rows[0]["a-00"]);
Assert.Equal("FIRE", sheet.Rows[0]["b-00"]);
Assert.Equal("GEAR UP", sheet.Rows[0]["b-02"]); // quoted strings keep spaces
}
[Fact]
public void LabelsForRow_DropsEmptyValues()
{
IReadOnlyDictionary<string, string> labels =
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Parse(Sample));
Assert.True(labels.ContainsKey("b-00"));
Assert.False(labels.ContainsKey("b-01")); // empty string dropped
Assert.Equal(3, labels.Count); // a-00, b-00, b-02
}
[Fact]
public void Parse_Field0_IsTheRowFileTag()
{
GoobieDataImporter.Sheet sheet = GoobieDataImporter.Parse(Sample);
Assert.Equal("a-00", sheet.Fields[0]);
Assert.Equal("tag", sheet.Rows[0][sheet.Fields[0]]);
}
[Fact]
public void Loads_RealTestDataFile()
{
GoobieDataImporter.Sheet sheet = GoobieDataImporter.Load(TestRepo.CustomBackground("TEST.data"));
Assert.Equal("a-00", sheet.Fields[0]);
Assert.NotEmpty(sheet.Rows);
// Header: ( a-00 b-00 b-01 ... ) Row0: ( "defalt" "LSDI" "ZOOM" ... )
Assert.Equal("defalt", sheet.Rows[0]["a-00"]);
Assert.Equal("LSDI", sheet.Rows[0]["b-00"]);
Assert.Equal("ZOOM", sheet.Rows[0]["b-01"]);
}
}
@@ -0,0 +1,23 @@
using RioJoy.Core.Overlay;
namespace RioJoy.Core.Tests.Overlay;
/// <summary>
/// Deterministic fake <see cref="ITextMeasurer"/> for layout tests: width grows
/// linearly with character count and font size, height with font size. Lets the
/// font-fit and placement math be asserted exactly without a real font/rasterizer.
/// </summary>
internal sealed class LinearTextMeasurer : ITextMeasurer
{
private readonly double _charWidthPerPx;
private readonly double _heightPerPx;
public LinearTextMeasurer(double charWidthPerPx = 0.6, double heightPerPx = 1.2)
{
_charWidthPerPx = charWidthPerPx;
_heightPerPx = heightPerPx;
}
public TextExtent Measure(string text, string fontFamily, double fontSizePx) =>
new(text.Length * fontSizePx * _charWidthPerPx, fontSizePx * _heightPerPx);
}
@@ -0,0 +1,143 @@
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);
}
}
@@ -0,0 +1,50 @@
using RioJoy.Core.Overlay;
using RioJoy.Overlay;
using SkiaSharp;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
/// <summary>
/// Full Phase-7 pipeline on the real cockpit assets: extracted regions.json + the
/// exported riojoy.png + the legacy TEST.data labels → a rendered wallpaper. Proves
/// the extract → import → lay out → rasterize chain end to end (headless).
/// </summary>
public class OverlayRenderIntegrationTests
{
private static bool RegionDiffers(SKBitmap a, SKBitmap b, OverlayRegion r)
{
int x0 = (int)r.X, y0 = (int)r.Y;
int x1 = Math.Min(a.Width, (int)(r.X + r.Width));
int y1 = Math.Min(a.Height, (int)(r.Y + r.Height));
for (int y = y0; y < y1; y++)
for (int x = x0; x < x1; x++)
if (a.GetPixel(x, y) != b.GetPixel(x, y))
return true;
return false;
}
[Fact]
public void RendersCockpitWallpaper_FromRealAssets()
{
OverlayTemplate template = OverlayTemplateStore.Load(TestRepo.CustomBackground("regions.json"));
Assert.False(string.IsNullOrWhiteSpace(template.BaseImagePath));
using SKBitmap baseImg = SKBitmap.Decode(TestRepo.CustomBackground(template.BaseImagePath!));
Assert.NotNull(baseImg);
Assert.Equal(template.Width, baseImg.Width);
Assert.Equal(template.Height, baseImg.Height);
IReadOnlyDictionary<string, string> labels =
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Load(TestRepo.CustomBackground("TEST.data")));
using SKBitmap rendered = new SkiaOverlayRenderer().Render(template, labels, baseImg);
Assert.Equal(2720, rendered.Width);
Assert.Equal(600, rendered.Height);
// A labeled cell ("b-00" -> "LSDI") must have changed from the base.
OverlayRegion b00 = template.FindRegion("b-00")!;
Assert.True(RegionDiffers(baseImg, rendered, b00), "labelled region b-00 should differ from the base image");
}
}
@@ -0,0 +1,53 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class OverlayTemplateStoreTests
{
[Fact]
public void RoundTrips_GeometryAndOverrides()
{
var template = new OverlayTemplate
{
BaseImagePath = "cockpit.png",
Width = 1920,
Height = 1080,
Regions =
{
new OverlayRegion { Name = "b-00", X = 10, Y = 20, Width = 80, Height = 24, FontFamily = "Consolas", FontSizePx = 18 },
new OverlayRegion { Name = "b-41", X = 5, Y = 7, Width = 60, Height = 30, HJust = OverlayHJust.Right, VJust = OverlayVJust.Bottom },
},
};
OverlayTemplate back = OverlayTemplateStore.Deserialize(OverlayTemplateStore.Serialize(template));
Assert.Equal(1920, back.Width);
Assert.Equal(2, back.Regions.Count);
OverlayRegion? r0 = back.FindRegion("b-00");
Assert.NotNull(r0);
Assert.Equal(80, r0!.Width, 6);
Assert.Equal("Consolas", r0.FontFamily);
Assert.Null(r0.HJust);
OverlayRegion? r1 = back.FindRegion("B-41"); // case-insensitive lookup
Assert.NotNull(r1);
Assert.Equal(OverlayHJust.Right, r1!.HJust);
Assert.Equal(OverlayVJust.Bottom, r1.VJust);
}
[Fact]
public void Serialize_WritesEnumsAsStrings()
{
var template = new OverlayTemplate
{
Regions = { new OverlayRegion { Name = "x", HJust = OverlayHJust.Center } },
};
string json = OverlayTemplateStore.Serialize(template);
Assert.Contains("\"Center\"", json);
Assert.DoesNotContain("\"HJust\": 1", json);
}
}
@@ -0,0 +1,46 @@
using RioJoy.Core.Overlay;
using RioJoy.Overlay;
using SkiaSharp;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class ProfileWallpaperGeneratorTests
{
[Fact]
public void Generate_WritesWallpaperPng_AtCanvasSize()
{
string templatePath = TestRepo.CustomBackground("regions.json");
OverlayTemplate template = OverlayTemplateStore.Load(templatePath);
string templateDir = Path.GetDirectoryName(templatePath)!;
var labels = new Dictionary<string, string> { ["a-00"] = "DEMO", ["b-00"] = "FIRE" };
string outPath = Path.Combine(Path.GetTempPath(), $"riojoy-wp-{Guid.NewGuid():N}.png");
try
{
string written = new ProfileWallpaperGenerator().Generate(template, templateDir, labels, outPath);
Assert.Equal(outPath, written);
Assert.True(File.Exists(outPath));
using SKBitmap bmp = SKBitmap.Decode(outPath);
Assert.Equal(template.Width, bmp.Width);
Assert.Equal(template.Height, bmp.Height);
}
finally
{
if (File.Exists(outPath)) File.Delete(outPath);
}
}
[Fact]
public void Generate_WithoutBaseImagePath_Throws()
{
var template = new OverlayTemplate { Width = 10, Height = 10 }; // BaseImagePath null
Assert.Throws<InvalidOperationException>(() =>
new ProfileWallpaperGenerator().Generate(
template, ".", new Dictionary<string, string>(),
Path.Combine(Path.GetTempPath(), $"x-{Guid.NewGuid():N}.png")));
}
}
@@ -0,0 +1,82 @@
using RioJoy.Core.Overlay;
using RioJoy.Overlay;
using SkiaSharp;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class SkiaOverlayRendererTests
{
private static SKBitmap SolidBase(int w, int h, SKColor color)
{
var bmp = new SKBitmap(w, h);
using var canvas = new SKCanvas(bmp);
canvas.Clear(color);
canvas.Flush();
return bmp;
}
private static bool HasLightPixel(SKBitmap bmp, int x, int y, int w, int h)
{
for (int yy = y; yy < y + h; yy++)
for (int xx = x; xx < x + w; xx++)
{
SKColor p = bmp.GetPixel(xx, yy);
if (p.Red > 40 || p.Green > 40 || p.Blue > 40)
return true;
}
return false;
}
private static OverlayRegion Region(string name, int x, int y, int w, int h) =>
new() { Name = name, X = x, Y = y, Width = w, Height = h, FontFamily = "Arial", FontSizePx = 20, ColorHex = "#FFFFFF" };
[Fact]
public void Render_DrawsLabel_AndPreservesDimensions()
{
using SKBitmap baseImg = SolidBase(200, 100, SKColors.Black);
var template = new OverlayTemplate { Width = 200, Height = 100, Regions = { Region("b-00", 10, 10, 150, 50) } };
var labels = new Dictionary<string, string> { ["b-00"] = "FIRE" };
using SKBitmap output = new SkiaOverlayRenderer().Render(template, labels, baseImg);
Assert.Equal(200, output.Width);
Assert.Equal(100, output.Height);
Assert.True(HasLightPixel(output, 10, 10, 150, 50), "white label text should appear inside the region");
}
[Fact]
public void Render_DoesNotMutateBaseImage()
{
using SKBitmap baseImg = SolidBase(120, 60, SKColors.Black);
var template = new OverlayTemplate { Regions = { Region("x", 5, 5, 100, 40) } };
using SKBitmap _ = new SkiaOverlayRenderer().Render(template, new Dictionary<string, string> { ["x"] = "HI" }, baseImg);
Assert.False(HasLightPixel(baseImg, 0, 0, 120, 60), "the source bitmap must be left untouched");
}
[Fact]
public void Render_RegionWithoutLabel_StaysBackground()
{
using SKBitmap baseImg = SolidBase(120, 60, SKColors.Black);
var template = new OverlayTemplate { Regions = { Region("x", 5, 5, 100, 40) } };
// No entry for "x" -> nothing drawn.
using SKBitmap output = new SkiaOverlayRenderer().Render(template, new Dictionary<string, string>(), baseImg);
Assert.False(HasLightPixel(output, 0, 0, 120, 60));
}
[Fact]
public void RenderToPng_ProducesValidPngSignature()
{
using SKBitmap baseImg = SolidBase(64, 64, SKColors.Black);
var template = new OverlayTemplate { Regions = { Region("x", 2, 2, 60, 30) } };
byte[] png = new SkiaOverlayRenderer().RenderToPng(template, new Dictionary<string, string> { ["x"] = "HI" }, baseImg);
Assert.True(png.Length > 8);
Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, png[..8]);
}
}
@@ -0,0 +1,16 @@
namespace RioJoy.Core.Tests;
/// <summary>Locates committed reference assets relative to the repo root.</summary>
internal static class TestRepo
{
public static string Root()
{
DirectoryInfo? dir = new(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "RioJoy.sln")))
dir = dir.Parent;
return dir?.FullName ?? throw new DirectoryNotFoundException("Could not locate repo root (RioJoy.sln).");
}
public static string CustomBackground(string file) =>
Path.Combine(Root(), "docs", "reference", "customBackground", file);
}
@@ -17,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\RioJoy.Core\RioJoy.Core.csproj" />
<ProjectReference Include="..\..\src\RioJoy.Overlay\RioJoy.Overlay.csproj" />
</ItemGroup>
</Project>