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>
285 lines
11 KiB
C#
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());
|
|
}
|
|
}
|