The editor's L/R gauges read Rx/Ry, but EnableZR (on in real profiles) folds the pedals into Rz and pins Rx/Ry to center, so the bars never moved with the physical pedals. AxisCalibrator now exposes LeftPedalOutput/RightPedalOutput (the calibrated pre-mix positions) and RioRuntime.AxesUpdated carries an AxisReadout (the six virtual axes + both pedals); the strip draws L/R from the pedal readouts, Z/Rz/X/Y from the axes. With ZR off the readouts equal Rx/Ry, so nothing changes there. Verified by screenshot: Rx/Ry centered while L=75% and R=25% render correctly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
191 lines
6.8 KiB
C#
191 lines
6.8 KiB
C#
using System.Diagnostics;
|
|
using AxisOutputs = RioJoy.Core.Calibration.AxisOutputs;
|
|
using AxisReadout = RioJoy.Core.Calibration.AxisReadout;
|
|
using RioJoy.Core;
|
|
using RioJoy.Core.Mapping;
|
|
using RioJoy.Core.Protocol;
|
|
using RioJoy.Core.Serial;
|
|
using RioJoy.Core.Tests.Mapping;
|
|
using RioJoy.Core.Tests.Serial;
|
|
using Xunit;
|
|
|
|
namespace RioJoy.Core.Tests;
|
|
|
|
public class RioRuntimeTests
|
|
{
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> condition)
|
|
{
|
|
var sw = Stopwatch.StartNew();
|
|
while (!condition())
|
|
{
|
|
if (sw.Elapsed > Timeout)
|
|
throw new TimeoutException("Condition not met in time.");
|
|
await Task.Delay(10);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ButtonPressed_RoutesToJoystick()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
var recorder = new RecordingSink();
|
|
|
|
var map = new RioInputMap { [0x05] = new RioMapEntry(0x1009) }; // joystick button 9
|
|
using var runtime = new RioRuntime(link, map, recorder, recorder);
|
|
runtime.Start();
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
|
|
|
|
await WaitUntilAsync(() => recorder.Snapshot().Contains("Joy(9,True)"));
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnalogReply_DrivesAllSixAxes()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
var recorder = new RecordingSink();
|
|
|
|
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
|
|
runtime.Start();
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
// All axes at zero → joystick X/Y centered.
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
|
|
|
|
await WaitUntilAsync(() => recorder.Snapshot().Count(e => e.StartsWith("Axis(")) == 6);
|
|
|
|
string[] log = recorder.Snapshot();
|
|
Assert.Contains("Axis(X,16383)", log);
|
|
Assert.Contains("Axis(Y,16383)", log);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnalogReply_RaisesAxesUpdated()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
var recorder = new RecordingSink();
|
|
|
|
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
|
|
AxisReadout? seen = null;
|
|
runtime.AxesUpdated += axes => seen = axes;
|
|
runtime.Start();
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
// All raw axes at zero → joystick X/Y centered, same values the sink receives;
|
|
// both pedals at rest → pedal readouts empty.
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
|
|
|
|
await WaitUntilAsync(() => seen is not null);
|
|
Assert.Equal(AxisOutputs.Center, seen!.Value.Axes.X);
|
|
Assert.Equal(AxisOutputs.Center, seen.Value.Axes.Y);
|
|
Assert.Equal(0, seen.Value.LeftPedal);
|
|
Assert.Equal(0, seen.Value.RightPedal);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LitLamps_Dimmed_OnFirstBoardReply_NotOnStart()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
var recorder = new RecordingSink();
|
|
|
|
var map = new RioInputMap { [0x05] = new RioMapEntry(0x9001) }; // lit joystick button
|
|
using var runtime = new RioRuntime(link, map, recorder, recorder);
|
|
runtime.Start();
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
// Nothing is sent on Start: lamp commands would be dropped during the board's
|
|
// post-reset boot, so no write happens until the board first replies.
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(150)));
|
|
|
|
// A reply proves the board is alive → the lit lamp is dimmed.
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
|
|
|
|
bool dimmed = false;
|
|
for (int i = 0; i < 8 && !dimmed; i++)
|
|
{
|
|
byte[] w = await fake.NextWriteAsync(TimeSpan.FromSeconds(2));
|
|
dimmed = w.Length >= 3 && w[0] == (byte)RioCommand.LampRequest && w[1] == 0x05;
|
|
}
|
|
Assert.True(dimmed, "expected a LampRequest dimming address 0x05 after the first reply");
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ButtonActivity_FiresForPressAndRelease_EvenWhenUnmapped()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
var recorder = new RecordingSink();
|
|
|
|
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder); // empty map
|
|
var activity = new List<(int Address, bool Pressed)>();
|
|
runtime.ButtonActivity += (addr, pressed) => activity.Add((addr, pressed));
|
|
runtime.Start();
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x05 }));
|
|
|
|
await WaitUntilAsync(() => activity.Count == 2);
|
|
|
|
Assert.Equal((0x05, true), activity[0]);
|
|
Assert.Equal((0x05, false), activity[1]);
|
|
Assert.Empty(recorder.Snapshot()); // unmapped → no output routed, but activity still fired
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task KeypadKey_RoutesThroughOffsetAddress()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
var recorder = new RecordingSink();
|
|
|
|
// Keypad 1, index 2 → address 0x62; map a keyboard key there (VK 0x41 = 'A').
|
|
var map = new RioInputMap { [0x62] = new RioMapEntry(0x0041) };
|
|
using var runtime = new RioRuntime(link, map, recorder, recorder);
|
|
runtime.Start();
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.KeyPressed, new byte[] { 1, 2 }));
|
|
|
|
await WaitUntilAsync(() => recorder.Snapshot().Contains("KeyDown(0x41,ext=False)"));
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
}
|