Files
riojoy/tests/RioJoy.Core.Tests/Feedback/CoalescingLampSchedulerTests.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

142 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}