Files
riojoy/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs
T
CydandClaude Opus 5 46e108de89 feedback: plasma box, explicit fonts, and per-position text coalescing
Three endpoint gaps, found building the Descent 3 score overlay - the boxed
place|score field the original games drew over the callsign:

plasma box <x> <y> <w> <h> draws an outlined box with a blanked interior, the
overlay chrome, as one ESC P graphics write. The wire addresses whole bytes
horizontally, so the write covers the byte-aligned span containing the box and
clears span pixels outside it; documented, with 8-px alignment the advice.
Boxes queue FIFO with rows.

plasma text gains an explicit font: a third numeric token after the position,
with text still following, so `plasma text 2 2 7` still displays "7". Auto-fit
picks the font by LENGTH - short text always rendered large, and a "1" that
must fit a 12-px box simply could not be sent before. ResolvePosText
generalizes the legacy Score-font special case: 0 = auto, nonzero honored.

Text coalescing is now per position. The global rule - any queued text
superseded every other queued text - meant the documented two-field layout
(callsign top, score bottom) could not survive its own send burst: the second
field silently ate the first whenever both were queued. A newer text now
replaces only a queued text at the same (x,y).

15 new tests; 472 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 11:45:59 -05:00

285 lines
11 KiB
C#

using RioJoy.Core.Feedback;
using RioJoy.Core.Mapping;
using RioJoy.Core.Plasma;
using RioJoy.Core.Protocol;
using RioJoy.Core.Tests.Mapping;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class FeedbackRouterTests : IDisposable
{
private readonly RecordingSink _sink = new();
private readonly CoalescingLampScheduler _scheduler;
private readonly CancellationTokenSource _cts = new();
private readonly Task _pump;
public FeedbackRouterTests()
{
_scheduler = new CoalescingLampScheduler(_sink, TimeSpan.FromMilliseconds(1));
_pump = _scheduler.RunAsync(_cts.Token);
}
public void Dispose()
{
_cts.Cancel();
_pump.Wait(TimeSpan.FromSeconds(5));
_cts.Dispose();
}
private static FeedbackCommand Lamp(int address, byte state) =>
new() { Kind = FeedbackCommandKind.Lamp, Address = address, LampState = state };
private static FeedbackCommand Text(string text) =>
new() { Kind = FeedbackCommandKind.PlasmaText, Text = text };
private static FeedbackCommand Row(byte y)
{
var data = new byte[16];
data[0] = y; // distinguishable payload per row
return new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaRow, Y = y, Data = data };
}
[Fact]
public async Task Lamp_ProfileOwnedAddressDropped_UnownedApplied()
{
var map = new RioInputMap();
map[0x10] = RioMapEntry.Create(RioRouteKind.Keyboard, 0x41, lit: true); // InputRouter owns this lamp
var router = new FeedbackRouter();
var logged = new List<string>();
router.Logged += logged.Add;
router.Attach(_scheduler, map, plasma: null, new ProfileFeedbackConfig());
router.Dispatch(Lamp(0x11, RioLampState.SolidBright)); // unowned → applied
await FeedbackWait.For(() => _sink.Snapshot().Length >= 1);
Assert.Equal("Lamp(0x11,0x3C)", Assert.Single(_sink.Snapshot()));
router.Dispatch(Lamp(0x10, RioLampState.SolidBright)); // owned → dropped
router.Dispatch(Lamp(0x10, RioLampState.SolidOff));
await Task.Delay(50);
Assert.Single(_sink.Snapshot());
Assert.Equal(2, router.DroppedCommands);
Assert.Single(logged); // logged once per address per attach, not per drop
}
[Fact]
public async Task Detached_CommandsDroppedAndCounted()
{
var router = new FeedbackRouter();
router.Dispatch(Lamp(0x01, RioLampState.SolidBright)); // never attached
Assert.Equal(1, router.DroppedCommands);
router.Attach(_scheduler, new RioInputMap(), null, new ProfileFeedbackConfig());
router.Detach();
router.Dispatch(Lamp(0x01, RioLampState.SolidBright));
Assert.Equal(2, router.DroppedCommands);
await Task.Delay(50);
Assert.Empty(_sink.Snapshot());
}
[Fact]
public async Task AllowFlags_GateLampAndPlasma()
{
var transport = new FakeTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig { AllowLampCommands = false, AllowPlasmaText = false });
router.Dispatch(Lamp(0x01, RioLampState.SolidBright));
router.Dispatch(Text("NOPE"));
await Task.Delay(50);
Assert.Empty(_sink.Snapshot());
Assert.False(transport.Writes.TryRead(out _));
Assert.Equal(2, router.DroppedCommands);
}
[Fact]
public async Task LampAll_SkipsProfileOwnedLamps()
{
var map = new RioInputMap();
map[0x00] = RioMapEntry.Create(RioRouteKind.Joystick, 1, lit: true);
var router = new FeedbackRouter();
router.Attach(_scheduler, map, null, new ProfileFeedbackConfig());
router.Dispatch(new FeedbackCommand
{
Kind = FeedbackCommandKind.LampAll,
LampState = RioLampState.SolidDim,
});
int expected = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid) - 1;
await FeedbackWait.For(() => _sink.Snapshot().Length >= expected);
await Task.Delay(50);
string[] sent = _sink.Snapshot();
Assert.Equal(expected, sent.Length);
Assert.DoesNotContain("Lamp(0x00,0x14)", sent); // the profile-owned lamp is untouched
}
[Fact]
public async Task Plasma_FloodCoalesces_FirstAndLatestOnly()
{
// Park the first text mid-write; everything dispatched meanwhile collapses
// to the single latest pending command.
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Text("FIRST")); // goes busy, parked on the gate
router.Dispatch(Text("MID-1")); // pending
router.Dispatch(Text("MID-2")); // supersedes MID-1
router.Dispatch(Text("LAST")); // supersedes MID-2
transport.Open();
var writes = new List<byte[]>();
for (int i = 0; i < 10; i++)
writes.Add(await transport.NextWriteAsync());
await Task.Delay(50);
Assert.Equal(PlasmaCommands.Text("FIRST"), writes[4]); // FIRST's text chunk
Assert.Equal(PlasmaCommands.Text("LAST"), writes[9]); // then only LAST's
Assert.True(transport.NoMoreWrites);
Assert.Equal(2, router.DroppedCommands); // the two superseded middles
}
[Fact]
public async Task PlasmaText_CoalescesPerPosition_OtherFieldsSurvive()
{
// The documented multi-field layout sends several positioned texts in a
// burst (callsign top, score bottom). Coalescing is per (x,y): a newer
// score supersedes the queued score, never the queued callsign.
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Text("PARK")); // goes busy, parked on the gate
router.Dispatch(new FeedbackCommand
{ Kind = FeedbackCommandKind.PlasmaText, Text = "VIPER", X = 2, Y = 2 });
router.Dispatch(new FeedbackCommand
{ Kind = FeedbackCommandKind.PlasmaText, Text = "SCORE 1", X = 2, Y = 18 });
router.Dispatch(new FeedbackCommand
{ Kind = FeedbackCommandKind.PlasmaText, Text = "SCORE 2", X = 2, Y = 18 }); // supersedes SCORE 1 only
transport.Open();
var texts = new List<string>();
for (int i = 0; i < 15; i++)
{
byte[] w = await transport.NextWriteAsync();
string s = System.Text.Encoding.GetEncoding(28591).GetString(w);
if (w.Length > 0 && w[0] != 0x1B)
texts.Add(s); // the text chunks, minus ESC command prefixes
if (texts.Count == 3)
break;
}
Assert.Equal(new[] { "PARK", "VIPER", "SCORE 2" }, texts);
Assert.Equal(1, router.DroppedCommands); // only SCORE 1 superseded
}
[Fact]
public async Task PlasmaBox_WritesOutlineWithBlankedInterior()
{
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
// 16px-wide box at a byte boundary, 4 rows: bytes are exact.
router.Dispatch(new FeedbackCommand
{ Kind = FeedbackCommandKind.PlasmaBox, X = 32, Y = 10, Width = 16, Height = 4 });
transport.Open();
byte[] w = await transport.NextWriteAsync();
// ESC P s=0 y=10 x=4 w=2 h=4, then 8 data bytes.
Assert.Equal(new byte[] { 0x1B, (byte)'P', 0, 10, 4, 2, 4 }, w.Take(7).ToArray());
byte[] data = w.Skip(7).ToArray();
Assert.Equal(new byte[]
{
0xFF, 0xFF, // top edge: all lit
0x80, 0x01, // interior row: only the side walls
0x80, 0x01,
0xFF, 0xFF, // bottom edge
}, data);
}
[Fact]
public async Task PlasmaRows_StreamInFifoOrder_NotCoalesced()
{
// A bitmap frame is many rows — unlike text, rows must all land, in order.
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Row(0)); // goes busy, parked on the gate
router.Dispatch(Row(1));
router.Dispatch(Row(2));
transport.Open();
Assert.Equal(PlasmaCommands.GraphicsRow(0, Row(0).Data!), await transport.NextWriteAsync());
Assert.Equal(PlasmaCommands.GraphicsRow(1, Row(1).Data!), await transport.NextWriteAsync());
Assert.Equal(PlasmaCommands.GraphicsRow(2, Row(2).Data!), await transport.NextWriteAsync());
Assert.Equal(0, router.DroppedCommands);
}
[Fact]
public async Task PlasmaClear_FlushesQueuedRowsAndTexts()
{
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Row(0)); // in flight, parked
router.Dispatch(Row(1)); // queued…
router.Dispatch(Text("STALE"));
router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear }); // …flushed
transport.Open();
Assert.Equal(PlasmaCommands.GraphicsRow(0, Row(0).Data!), await transport.NextWriteAsync());
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
await Task.Delay(50);
Assert.True(transport.NoMoreWrites);
Assert.Equal(2, router.DroppedCommands); // the flushed row + text
}
[Fact]
public async Task PlasmaRowQueue_IsBounded()
{
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Row(0)); // parked in flight; everything below queues
for (int i = 0; i < 140; i++)
router.Dispatch(Row(1));
Assert.Equal(140 - 128, router.DroppedCommands); // over the 128-entry bound
transport.Open();
// Drain: the parked row + the 128 queued ones.
for (int i = 0; i < 129; i++)
await transport.NextWriteAsync();
await Task.Delay(50);
Assert.True(transport.NoMoreWrites);
}
[Fact]
public async Task PlasmaClear_WritesTheClearCommand()
{
var transport = new FakeTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear });
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
}
}