Phase 9: FeedbackPipeServer (\\.\pipe\riojoy-feedback) + loopback UDP share a forgiving text line protocol into FeedbackRouter; CoalescingLampScheduler rate- governs the 9600-baud link; plasma finally wired into activation (greeting, teardown blank, PlasmaDisplay write lock); ViGEm FeedbackReceived drives RumbleLampAdapter. Per-profile Feedback config, docs/FEEDBACK.md, 425 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
155 lines
5.6 KiB
C#
155 lines
5.6 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 };
|
|
|
|
[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 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());
|
|
}
|
|
}
|