Files
riojoy/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs
T
CydandClaude Fable 5 ad7ac19ab2 feedback: game-to-cockpit endpoint (pipe/UDP lamps+plasma), rumble lamp flash
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>
2026-07-31 19:31:03 -05:00

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());
}
}