using RioJoy.Core.Feedback; using RioJoy.Core.Protocol; using RioJoy.Core.Tests.Mapping; using Xunit; namespace RioJoy.Core.Tests.Feedback; public class RumbleLampAdapterTests { // Flash + Bright/Bright state bytes the bands resolve to. private const byte SlowBright = 0x3D; private const byte MedBright = 0x3E; private const byte FastBright = 0x3F; [Theory] [InlineData(0, 24, 0x00)] // below threshold → off [InlineData(23, 24, 0x00)] [InlineData(24, 24, SlowBright)] // first third of the remaining range [InlineData(100, 24, SlowBright)] [InlineData(101, 24, MedBright)] // second third [InlineData(177, 24, MedBright)] [InlineData(178, 24, FastBright)] // top third [InlineData(255, 24, FastBright)] [InlineData(0, 0, SlowBright)] // threshold 0 = never off public void MapMotor_BandsResolveToDocumentedStates(int value, int threshold, byte expected) { Assert.Equal(expected, RumbleLampAdapter.MapMotor((byte)value, (byte)threshold)); } [Fact] public void MapMotor_MatchesRioLampStateCompose() { Assert.Equal( RioLampState.Compose(LampFlash.FlashFast, LampField1.Bright, LampField2.Bright), RumbleLampAdapter.MapMotor(255, 24)); Assert.Equal(RioLampState.SolidOff, RumbleLampAdapter.MapMotor(0, 24)); } [Fact] public async Task OnRumble_DrivesEachMotorsConfiguredAddresses() { var sink = new RecordingSink(); var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1)); using var cts = new CancellationTokenSource(); Task pump = scheduler.RunAsync(cts.Token); var adapter = new RumbleLampAdapter(new RumbleLampConfig { LargeMotorLamps = { 0x20, 0x21 }, SmallMotorLamps = { 0x30 }, }, scheduler); adapter.OnRumble(255, 0); // big hit, no small motor await FeedbackWait.For(() => sink.Snapshot().Length >= 3); cts.Cancel(); await pump; string[] sent = sink.Snapshot(); Assert.Contains("Lamp(0x20,0x3F)", sent); // large motor lamps flash fast Assert.Contains("Lamp(0x21,0x3F)", sent); Assert.Contains("Lamp(0x30,0x00)", sent); // small motor lamps confirmed off } [Fact] public async Task OnRumble_RepeatedIdenticalValues_PostNothingNew() { var sink = new RecordingSink(); var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1)); using var cts = new CancellationTokenSource(); Task pump = scheduler.RunAsync(cts.Token); var adapter = new RumbleLampAdapter( new RumbleLampConfig { LargeMotorLamps = { 0x20 }, SmallMotorLamps = { 0x30 } }, scheduler); // XInput-style spam: same vibration reported over and over. for (int i = 0; i < 200; i++) adapter.OnRumble(200, 0); await FeedbackWait.For(() => sink.Snapshot().Length >= 2); await Task.Delay(50); cts.Cancel(); await pump; Assert.Equal(2, sink.Snapshot().Length); // one state per motor, ever } [Fact] public async Task OnRumble_ZeroAfterRumble_TurnsTheLampsOff() { var sink = new RecordingSink(); var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1)); using var cts = new CancellationTokenSource(); Task pump = scheduler.RunAsync(cts.Token); var adapter = new RumbleLampAdapter( new RumbleLampConfig { LargeMotorLamps = { 0x20 } }, scheduler); adapter.OnRumble(255, 0); await FeedbackWait.For(() => sink.Snapshot().Contains("Lamp(0x20,0x3F)")); adapter.OnRumble(0, 0); await FeedbackWait.For(() => sink.Snapshot().Contains("Lamp(0x20,0x00)")); cts.Cancel(); await pump; } }