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>
142 lines
5.2 KiB
C#
142 lines
5.2 KiB
C#
using System.Globalization;
|
||
using RioJoy.Core.Feedback;
|
||
using RioJoy.Core.Mapping;
|
||
using RioJoy.Core.Protocol;
|
||
using RioJoy.Core.Tests.Mapping;
|
||
using RioJoy.Core.Tests.Serial;
|
||
using Xunit;
|
||
|
||
namespace RioJoy.Core.Tests.Feedback;
|
||
|
||
public class CoalescingLampSchedulerTests
|
||
{
|
||
private static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(1); // pump as fast as timers allow
|
||
|
||
private static Task WaitFor(Func<bool> condition, int timeoutMs = 5000) =>
|
||
FeedbackWait.For(condition, timeoutMs);
|
||
|
||
// "Lamp(0x12,0x3C)" → (0x12, 0x3C)
|
||
private static (int Address, byte State) ParseLamp(string entry)
|
||
{
|
||
string[] parts = entry["Lamp(0x".Length..^1].Split(new[] { ",0x" }, StringSplitOptions.None);
|
||
return (int.Parse(parts[0], NumberStyles.HexNumber, CultureInfo.InvariantCulture),
|
||
byte.Parse(parts[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Post_SameAddressRepeatedly_SendsOnlyTheLatestState()
|
||
{
|
||
var sink = new RecordingSink();
|
||
var scheduler = new CoalescingLampScheduler(sink, Fast);
|
||
for (byte s = 0; s <= 0x30; s++)
|
||
scheduler.Post(0x12, s); // a burst of updates while nothing pumps
|
||
|
||
using var cts = new CancellationTokenSource();
|
||
Task pump = scheduler.RunAsync(cts.Token);
|
||
await WaitFor(() => sink.Snapshot().Length >= 1);
|
||
await Task.Delay(50); // give a buggy scheduler time to send the rest
|
||
cts.Cancel();
|
||
await pump.WithTimeout();
|
||
|
||
string entry = Assert.Single(sink.Snapshot());
|
||
Assert.Equal((0x12, (byte)0x30), ParseLamp(entry)); // burst collapsed to the last state
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Post_UnchangedState_IsNotResent()
|
||
{
|
||
var sink = new RecordingSink();
|
||
var scheduler = new CoalescingLampScheduler(sink, Fast);
|
||
using var cts = new CancellationTokenSource();
|
||
Task pump = scheduler.RunAsync(cts.Token);
|
||
|
||
scheduler.Post(0x05, RioLampState.SolidBright);
|
||
await WaitFor(() => sink.Snapshot().Length >= 1);
|
||
|
||
scheduler.Post(0x05, RioLampState.SolidBright); // same state again
|
||
await Task.Delay(50);
|
||
cts.Cancel();
|
||
await pump.WithTimeout();
|
||
|
||
Assert.Single(sink.Snapshot()); // no resend for an unchanged lamp
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Pump_SendsAtMostOneLampPerTick()
|
||
{
|
||
var sink = new RecordingSink();
|
||
var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(200));
|
||
scheduler.Post(0x01, RioLampState.SolidBright);
|
||
scheduler.Post(0x02, RioLampState.SolidBright);
|
||
scheduler.Post(0x03, RioLampState.SolidBright);
|
||
|
||
using var cts = new CancellationTokenSource();
|
||
Task pump = scheduler.RunAsync(cts.Token);
|
||
await WaitFor(() => sink.Snapshot().Length >= 1);
|
||
|
||
// The next tick is ~200 ms out; three pending lamps must not burst.
|
||
Assert.Single(sink.Snapshot());
|
||
cts.Cancel();
|
||
await pump.WithTimeout();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task PostAll_CoversExactlyTheValidAddressSet()
|
||
{
|
||
var sink = new RecordingSink();
|
||
var scheduler = new CoalescingLampScheduler(sink, Fast);
|
||
scheduler.PostAll(RioLampState.SolidOff);
|
||
|
||
int validCount = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid);
|
||
Assert.Equal(104, validCount); // 72 buttons + 2×16 keypad keys; 0x48-0x4F is a gap
|
||
|
||
using var cts = new CancellationTokenSource();
|
||
Task pump = scheduler.RunAsync(cts.Token);
|
||
await WaitFor(() => sink.Snapshot().Length >= validCount);
|
||
await Task.Delay(50);
|
||
cts.Cancel();
|
||
await pump.WithTimeout();
|
||
|
||
var sent = sink.Snapshot().Select(ParseLamp).ToArray();
|
||
Assert.Equal(validCount, sent.Length); // nothing sent twice, no gap addresses
|
||
Assert.All(sent, s => Assert.Equal(RioLampState.SolidOff, s.State));
|
||
Assert.Equal(
|
||
Enumerable.Range(0, RioAddress.TableSize).Where(RioAddress.IsValid),
|
||
sent.Select(s => s.Address).OrderBy(a => a));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Post_InvalidAddress_Ignored()
|
||
{
|
||
var sink = new RecordingSink();
|
||
var scheduler = new CoalescingLampScheduler(sink, Fast);
|
||
scheduler.Post(0x48, RioLampState.SolidBright); // gap address (rumble config is unvalidated)
|
||
scheduler.Post(0x70, RioLampState.SolidBright);
|
||
scheduler.Post(-1, RioLampState.SolidBright);
|
||
|
||
using var cts = new CancellationTokenSource();
|
||
Task pump = scheduler.RunAsync(cts.Token);
|
||
await Task.Delay(100);
|
||
cts.Cancel();
|
||
await pump.WithTimeout();
|
||
|
||
Assert.Empty(sink.Snapshot());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Cancel_StopsThePump()
|
||
{
|
||
var sink = new RecordingSink();
|
||
var scheduler = new CoalescingLampScheduler(sink, Fast);
|
||
using var cts = new CancellationTokenSource();
|
||
Task pump = scheduler.RunAsync(cts.Token);
|
||
|
||
cts.Cancel();
|
||
await pump.WithTimeout(); // exits cleanly, no OperationCanceledException
|
||
|
||
scheduler.Post(0x01, RioLampState.SolidBright);
|
||
await Task.Delay(50);
|
||
Assert.Empty(sink.Snapshot()); // a stopped pump sends nothing
|
||
}
|
||
}
|