PlasmaCommands.GraphicsWrite/GraphicsRow port the display firmware graphics command (ESC P s y x w h, MSB-left); PlasmaDisplay.RowAsync writes a locked whole-row update; the line protocol gains `plasma row <y> <hex32>`. The router plasma slot becomes a bounded FIFO queue: rows stream in order (a frame must not tear), texts still coalesce to the newest, clear flushes, cap 128 with counted drops. Docs updated; 442 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
225 lines
8.4 KiB
C#
225 lines
8.4 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 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());
|
|
}
|
|
}
|