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>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.Text;
|
||||
using RioJoy.Core.Feedback;
|
||||
using RioJoy.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Feedback;
|
||||
|
||||
public class FeedbackLineParserTests
|
||||
{
|
||||
private static FeedbackCommand Parse(string line)
|
||||
{
|
||||
bool ok = FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error);
|
||||
Assert.True(ok, $"expected '{line}' to parse, got error: {error}");
|
||||
return command!;
|
||||
}
|
||||
|
||||
private static string ParseError(string line)
|
||||
{
|
||||
bool ok = FeedbackLineParser.TryParse(line, out _, out string? error);
|
||||
Assert.False(ok);
|
||||
Assert.NotNull(error);
|
||||
return error!;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("lamp 0x12 bright", 0x12, 0x3C)] // solid implied; = SolidBright
|
||||
[InlineData("lamp 18 bright", 18, 0x3C)] // decimal address
|
||||
[InlineData("lamp 0x12 dim", 0x12, 0x14)] // = SolidDim
|
||||
[InlineData("lamp 0x12 off", 0x12, 0x00)] // = SolidOff
|
||||
[InlineData("lamp 0x12 fast bright", 0x12, 0x3F)]
|
||||
[InlineData("lamp 0x12 slow dim", 0x12, 0x15)]
|
||||
[InlineData("lamp 0x12 med bright", 0x12, 0x3E)]
|
||||
[InlineData("lamp 0x12 solid bright", 0x12, 0x3C)]
|
||||
[InlineData("LAMP 0x12 FAST BRIGHT", 0x12, 0x3F)] // keywords case-insensitive
|
||||
[InlineData("lamp 0x12 0x36", 0x12, 0x36)] // raw state byte
|
||||
[InlineData("lamp 0x12 20", 0x12, 20)] // raw state, decimal
|
||||
public void Lamp_Forms_ComposeTheDocumentedStateByte(string line, int address, int state)
|
||||
{
|
||||
FeedbackCommand cmd = Parse(line);
|
||||
Assert.Equal(FeedbackCommandKind.Lamp, cmd.Kind);
|
||||
Assert.Equal(address, cmd.Address);
|
||||
Assert.Equal((byte)state, cmd.LampState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lamp_WordStates_MatchRioLampStateCompose()
|
||||
{
|
||||
Assert.Equal(RioLampState.SolidBright, Parse("lamp 0 bright").LampState);
|
||||
Assert.Equal(RioLampState.SolidDim, Parse("lamp 0 dim").LampState);
|
||||
Assert.Equal(RioLampState.SolidOff, Parse("lamp 0 off").LampState);
|
||||
Assert.Equal(
|
||||
RioLampState.Compose(LampFlash.FlashFast, LampField1.Bright, LampField2.Bright),
|
||||
Parse("lamp 0 fast bright").LampState);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00)]
|
||||
[InlineData(0x47)]
|
||||
[InlineData(0x50)]
|
||||
[InlineData(0x5F)]
|
||||
[InlineData(0x60)]
|
||||
[InlineData(0x6F)]
|
||||
public void Lamp_AddressRangeEdges_Accepted(int address)
|
||||
{
|
||||
Assert.Equal(address, Parse($"lamp 0x{address:X2} dim").Address);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("lamp 0x48 dim")] // gap between buttons and keypad 0
|
||||
[InlineData("lamp 0x4F dim")]
|
||||
[InlineData("lamp 0x70 dim")] // beyond MaxAddress
|
||||
[InlineData("lamp 200 dim")]
|
||||
public void Lamp_AddressOutOfRange_Rejected(string line)
|
||||
{
|
||||
Assert.Contains("out of range", ParseError(line));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("lamp 0x12 0x40")] // raw state above 6 lamp-state bits
|
||||
[InlineData("lamp 0x12 64")]
|
||||
public void Lamp_RawStateAboveSixBits_Rejected(string line)
|
||||
{
|
||||
Assert.Contains("0x00-0x3F", ParseError(line));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("lamp")]
|
||||
[InlineData("lamp 0x12")]
|
||||
[InlineData("lamp 0x12 blinky")]
|
||||
[InlineData("lamp 0x12 fast blinky")]
|
||||
[InlineData("lamp 0x12 fast bright extra")]
|
||||
[InlineData("lamp banana dim")]
|
||||
[InlineData("bogus 1 2")]
|
||||
public void Lamp_Malformed_ReturnsErrorText(string line)
|
||||
{
|
||||
Assert.NotEmpty(ParseError(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LampAll_ParsesStateWithoutAddress()
|
||||
{
|
||||
FeedbackCommand cmd = Parse("lamp-all off");
|
||||
Assert.Equal(FeedbackCommandKind.LampAll, cmd.Kind);
|
||||
Assert.Equal(RioLampState.SolidOff, cmd.LampState);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("# a comment")]
|
||||
[InlineData("; also a comment")]
|
||||
[InlineData(" # indented comment")]
|
||||
public void BlankAndComment_SkippedWithoutError(string line)
|
||||
{
|
||||
bool ok = FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error);
|
||||
Assert.False(ok);
|
||||
Assert.Null(command);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlasmaClear_Parses()
|
||||
{
|
||||
Assert.Equal(FeedbackCommandKind.PlasmaClear, Parse("plasma clear").Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlasmaText_Quoted_StripsQuotes()
|
||||
{
|
||||
FeedbackCommand cmd = Parse("plasma text \"VIPER 1-1\"");
|
||||
Assert.Equal(FeedbackCommandKind.PlasmaText, cmd.Kind);
|
||||
Assert.Equal("VIPER 1-1", cmd.Text);
|
||||
Assert.Equal(0, cmd.X); // (0,0) = auto-center
|
||||
Assert.Equal(0, cmd.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlasmaText_Unquoted_TakesRestOfLine()
|
||||
{
|
||||
Assert.Equal("VIPER 1-1", Parse("plasma text VIPER 1-1").Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlasmaText_TwoLeadingNumbers_ArePosition()
|
||||
{
|
||||
FeedbackCommand cmd = Parse("plasma text 12 3 \"FUEL LOW\"");
|
||||
Assert.Equal(12, cmd.X);
|
||||
Assert.Equal(3, cmd.Y);
|
||||
Assert.Equal("FUEL LOW", cmd.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlasmaText_OneLeadingNumber_IsText()
|
||||
{
|
||||
// Only two consecutive numeric tokens form a position; a single one is text.
|
||||
FeedbackCommand cmd = Parse("plasma text 42 kills");
|
||||
Assert.Equal(0, cmd.X);
|
||||
Assert.Equal(0, cmd.Y);
|
||||
Assert.Equal("42 kills", cmd.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlasmaText_QuotedNumber_IsText()
|
||||
{
|
||||
Assert.Equal("42", Parse("plasma text \"42\"").Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlasmaText_Latin1Chars_Preserved()
|
||||
{
|
||||
// Latin-1 range survives the parser untouched (plasma wire encoding).
|
||||
Assert.Equal("CAFÉ ÜBER", Parse("plasma text \"CAFÉ ÜBER\"").Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("plasma")]
|
||||
[InlineData("plasma bogus")]
|
||||
[InlineData("plasma clear extra")]
|
||||
[InlineData("plasma text")]
|
||||
[InlineData("plasma text \"unterminated")]
|
||||
[InlineData("plasma text \"done\" trailing")]
|
||||
[InlineData("plasma text 300 1 \"X\"")] // position out of byte range
|
||||
public void Plasma_Malformed_ReturnsErrorText(string line)
|
||||
{
|
||||
Assert.NotEmpty(ParseError(line));
|
||||
}
|
||||
}
|
||||
|
||||
public class FeedbackLineBufferTests
|
||||
{
|
||||
private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s);
|
||||
|
||||
[Fact]
|
||||
public void Feed_SplitsOnLf_AndStripsCr()
|
||||
{
|
||||
var buffer = new FeedbackLineBuffer();
|
||||
byte[] data = Latin1("lamp 1 dim\r\nplasma clear\n");
|
||||
Assert.Equal(
|
||||
new[] { "lamp 1 dim", "plasma clear" },
|
||||
buffer.Feed(data, data.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Feed_ReassemblesLinesSplitAcrossChunks()
|
||||
{
|
||||
var buffer = new FeedbackLineBuffer();
|
||||
byte[] a = Latin1("lamp 0x12 fa");
|
||||
byte[] b = Latin1("st bright\n");
|
||||
Assert.Empty(buffer.Feed(a, a.Length));
|
||||
Assert.Equal(new[] { "lamp 0x12 fast bright" }, buffer.Feed(b, b.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Feed_DiscardsOverlongLine_ThenRecovers()
|
||||
{
|
||||
var buffer = new FeedbackLineBuffer();
|
||||
byte[] junk = Latin1(new string('x', FeedbackLineBuffer.MaxLineLength + 50) + "\nlamp 1 dim\n");
|
||||
Assert.Equal(new[] { "lamp 1 dim" }, buffer.Feed(junk, junk.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Feed_DecodesLatin1Bytes()
|
||||
{
|
||||
var buffer = new FeedbackLineBuffer();
|
||||
byte[] data = Latin1("plasma text \"CAFÉ\"\n");
|
||||
Assert.Equal(new[] { "plasma text \"CAFÉ\"" }, buffer.Feed(data, data.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_ReturnsTrailingLineWithoutLf()
|
||||
{
|
||||
// UDP datagrams may omit the final LF; end-of-datagram is a terminator.
|
||||
var buffer = new FeedbackLineBuffer();
|
||||
byte[] data = Latin1("lamp 1 dim\nlamp 2 off");
|
||||
Assert.Equal(new[] { "lamp 1 dim" }, buffer.Feed(data, data.Length));
|
||||
Assert.Equal("lamp 2 off", buffer.Flush());
|
||||
Assert.Null(buffer.Flush()); // flushed state is consumed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_EmptyOrDiscarding_ReturnsNull()
|
||||
{
|
||||
var buffer = new FeedbackLineBuffer();
|
||||
Assert.Null(buffer.Flush());
|
||||
|
||||
byte[] junk = Latin1(new string('x', FeedbackLineBuffer.MaxLineLength + 50));
|
||||
Assert.Empty(buffer.Feed(junk, junk.Length));
|
||||
Assert.Null(buffer.Flush()); // overlong tail is discarded, not returned
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using RioJoy.Core.Feedback;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Feedback;
|
||||
|
||||
public class FeedbackPipeServerTests
|
||||
{
|
||||
private static string UniqueName() => $"riojoy-fb-test-{Guid.NewGuid():N}";
|
||||
|
||||
private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s);
|
||||
|
||||
/// <summary>Collector whose Count/Snapshot are safe against the reader threads.</summary>
|
||||
private sealed class Lines
|
||||
{
|
||||
private readonly List<string> _lines = new();
|
||||
|
||||
public void Add(string line)
|
||||
{
|
||||
lock (_lines) _lines.Add(line);
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lines) return _lines.Count; }
|
||||
}
|
||||
|
||||
public string[] Snapshot()
|
||||
{
|
||||
lock (_lines) return _lines.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// The accept loop arms asynchronously after Start; retry until it listens.
|
||||
private static NamedPipeClientStream Connect(string name, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (true)
|
||||
{
|
||||
var client = new NamedPipeClientStream(".", name, PipeDirection.Out);
|
||||
try
|
||||
{
|
||||
client.Connect(200);
|
||||
return client;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or TimeoutException)
|
||||
{
|
||||
client.Dispose();
|
||||
Assert.True(DateTime.UtcNow < deadline, $"could not connect to {name}: {ex.Message}");
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Send(NamedPipeClientStream client, string text)
|
||||
{
|
||||
byte[] data = Latin1(text);
|
||||
client.Write(data, 0, data.Length);
|
||||
client.Flush();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lines_AreDeliveredInOrder()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var lines = new Lines();
|
||||
using var server = new FeedbackPipeServer(name, lines.Add);
|
||||
server.Start();
|
||||
|
||||
using NamedPipeClientStream client = Connect(name);
|
||||
Send(client, "lamp 1 dim\r\nplasma clear\n");
|
||||
|
||||
await FeedbackWait.For(() => lines.Count >= 2);
|
||||
Assert.Equal(new[] { "lamp 1 dim", "plasma clear" }, lines.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Line_SplitAcrossWrites_Reassembles()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var lines = new Lines();
|
||||
using var server = new FeedbackPipeServer(name, lines.Add);
|
||||
server.Start();
|
||||
|
||||
using NamedPipeClientStream client = Connect(name);
|
||||
Send(client, "lamp 0x12 fa");
|
||||
Send(client, "st bright\n");
|
||||
|
||||
await FeedbackWait.For(() => lines.Count >= 1);
|
||||
Assert.Equal("lamp 0x12 fast bright", Assert.Single(lines.Snapshot()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoConcurrentClients_BothDeliver()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var lines = new Lines();
|
||||
using var server = new FeedbackPipeServer(name, lines.Add);
|
||||
server.Start();
|
||||
|
||||
using NamedPipeClientStream a = Connect(name);
|
||||
using NamedPipeClientStream b = Connect(name); // second instance while A stays connected
|
||||
Send(a, "lamp 1 dim\n");
|
||||
Send(b, "lamp 2 off\n");
|
||||
|
||||
await FeedbackWait.For(() => lines.Count >= 2);
|
||||
Assert.Equal(new[] { "lamp 1 dim", "lamp 2 off" }, lines.Snapshot().OrderBy(l => l));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientDisconnect_ThenReconnect_Works()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var lines = new Lines();
|
||||
using var server = new FeedbackPipeServer(name, lines.Add);
|
||||
server.Start();
|
||||
|
||||
using (NamedPipeClientStream first = Connect(name))
|
||||
Send(first, "lamp 1 dim\n");
|
||||
await FeedbackWait.For(() => lines.Count >= 1);
|
||||
|
||||
using NamedPipeClientStream second = Connect(name); // server re-arms after the EOF
|
||||
Send(second, "lamp 2 off\n");
|
||||
await FeedbackWait.For(() => lines.Count >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThrowingLineHandler_DoesNotKillTheConnection()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var lines = new Lines();
|
||||
using var server = new FeedbackPipeServer(name, line =>
|
||||
{
|
||||
if (line.Contains("boom"))
|
||||
throw new InvalidOperationException("handler bug");
|
||||
lines.Add(line);
|
||||
});
|
||||
server.Start();
|
||||
|
||||
using NamedPipeClientStream client = Connect(name);
|
||||
Send(client, "boom\nlamp 1 dim\n");
|
||||
|
||||
await FeedbackWait.For(() => lines.Count >= 1); // the good line still lands
|
||||
Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnblocksThePendingAccept()
|
||||
{
|
||||
var server = new FeedbackPipeServer(UniqueName(), _ => { });
|
||||
server.Start();
|
||||
Thread.Sleep(100); // let the accept loop park in WaitForConnection
|
||||
server.Dispose(); // must not hang on the pending accept (poke-connect)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using RioJoy.Core.Feedback;
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Plasma;
|
||||
using RioJoy.Core.Tests.Mapping;
|
||||
using RioJoy.Core.Tests.Serial;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end: pipe client → line assembly → parse → router → lamp scheduler /
|
||||
/// plasma, the exact path a sim export script exercises.
|
||||
/// </summary>
|
||||
public class FeedbackServiceTests
|
||||
{
|
||||
private static string UniqueName() => $"riojoy-fb-svc-{Guid.NewGuid():N}";
|
||||
|
||||
private static NamedPipeClientStream Connect(string name, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (true)
|
||||
{
|
||||
var client = new NamedPipeClientStream(".", name, PipeDirection.Out);
|
||||
try
|
||||
{
|
||||
client.Connect(200);
|
||||
return client;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or TimeoutException)
|
||||
{
|
||||
client.Dispose();
|
||||
Assert.True(DateTime.UtcNow < deadline, $"could not connect: {ex.Message}");
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Send(NamedPipeClientStream client, string text)
|
||||
{
|
||||
byte[] data = Encoding.GetEncoding(28591).GetBytes(text);
|
||||
client.Write(data, 0, data.Length);
|
||||
client.Flush();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PipeClient_DrivesLampsAndPlasma_MalformedLinesSurvive()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var lamps = new RecordingSink();
|
||||
var plasmaTransport = new FakeTransport();
|
||||
using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name });
|
||||
service.Start();
|
||||
service.Attach(lamps, new RioInputMap(), new PlasmaDisplay(plasmaTransport),
|
||||
new ProfileFeedbackConfig());
|
||||
|
||||
using NamedPipeClientStream client = Connect(name);
|
||||
Send(client, "# cockpit warmup\nlamp 0x11 fast bright\nbogus nonsense\nplasma clear\n");
|
||||
|
||||
await FeedbackWait.For(() => lamps.Snapshot().Length >= 1);
|
||||
Assert.Equal("Lamp(0x11,0x3F)", Assert.Single(lamps.Snapshot()));
|
||||
Assert.Equal(PlasmaCommands.Clear(), await plasmaTransport.NextWriteAsync());
|
||||
Assert.Equal(1, service.MalformedLines); // the bogus line, not the comment
|
||||
|
||||
Send(client, "lamp 0x11 off\n"); // the connection survived the bad line
|
||||
await FeedbackWait.For(() => lamps.Snapshot().Length >= 2);
|
||||
Assert.Equal("Lamp(0x11,0x00)", lamps.Snapshot()[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detach_DropsCommands_ReattachAppliesAgain()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var lamps = new RecordingSink();
|
||||
using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name });
|
||||
service.Start();
|
||||
using NamedPipeClientStream client = Connect(name);
|
||||
|
||||
// Dormant (never attached): commands drop, the client stays connected.
|
||||
Send(client, "lamp 0x01 bright\n");
|
||||
await FeedbackWait.For(() => service.DroppedCommands >= 1);
|
||||
Assert.Empty(lamps.Snapshot());
|
||||
|
||||
// Profile activates: the same client now drives lamps.
|
||||
service.Attach(lamps, new RioInputMap(), null, new ProfileFeedbackConfig());
|
||||
Send(client, "lamp 0x01 bright\n");
|
||||
await FeedbackWait.For(() => lamps.Snapshot().Length >= 1);
|
||||
|
||||
// Yield to a native game: back to dropping, still connected.
|
||||
service.Detach();
|
||||
Send(client, "lamp 0x01 off\n");
|
||||
await FeedbackWait.For(() => service.DroppedCommands >= 2);
|
||||
Assert.Single(lamps.Snapshot());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Feedback;
|
||||
|
||||
internal static class FeedbackWait
|
||||
{
|
||||
/// <summary>Poll until <paramref name="condition"/> holds, failing at the deadline.</summary>
|
||||
public static async Task For(Func<bool> condition, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition())
|
||||
{
|
||||
Assert.True(DateTime.UtcNow < deadline, "condition not reached in time");
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using RioJoy.Core.Feedback;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Feedback;
|
||||
|
||||
public class FeedbackUdpListenerTests
|
||||
{
|
||||
private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s);
|
||||
|
||||
private sealed class Lines
|
||||
{
|
||||
private readonly List<string> _lines = new();
|
||||
|
||||
public void Add(string line)
|
||||
{
|
||||
lock (_lines) _lines.Add(line);
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lines) return _lines.Count; }
|
||||
}
|
||||
|
||||
public string[] Snapshot()
|
||||
{
|
||||
lock (_lines) return _lines.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Send(int port, byte[] datagram)
|
||||
{
|
||||
using var udp = new UdpClient();
|
||||
udp.Send(datagram, datagram.Length, new IPEndPoint(IPAddress.Loopback, port));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Datagram_WithoutTrailingLf_IsOneLine()
|
||||
{
|
||||
var lines = new Lines();
|
||||
using var listener = new FeedbackUdpListener(0, lines.Add); // 0 → ephemeral
|
||||
Assert.NotEqual(0, listener.Port);
|
||||
listener.Start();
|
||||
|
||||
Send(listener.Port, Latin1("lamp 1 dim")); // datagram end terminates the line
|
||||
|
||||
await FeedbackWait.For(() => lines.Count >= 1);
|
||||
Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Datagram_WithMultipleLines_DeliversEach()
|
||||
{
|
||||
var lines = new Lines();
|
||||
using var listener = new FeedbackUdpListener(0, lines.Add);
|
||||
listener.Start();
|
||||
|
||||
Send(listener.Port, Latin1("lamp 1 dim\nlamp 2 off\nplasma clear"));
|
||||
|
||||
await FeedbackWait.For(() => lines.Count >= 3);
|
||||
Assert.Equal(new[] { "lamp 1 dim", "lamp 2 off", "plasma clear" }, lines.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThrowingLineHandler_DoesNotKillTheListener()
|
||||
{
|
||||
var lines = new Lines();
|
||||
using var listener = new FeedbackUdpListener(0, line =>
|
||||
{
|
||||
if (line.Contains("boom"))
|
||||
throw new InvalidOperationException("handler bug");
|
||||
lines.Add(line);
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
Send(listener.Port, Latin1("boom\n"));
|
||||
Send(listener.Port, Latin1("lamp 1 dim\n"));
|
||||
|
||||
await FeedbackWait.For(() => lines.Count >= 1);
|
||||
Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnblocksThePendingReceive()
|
||||
{
|
||||
var listener = new FeedbackUdpListener(0, _ => { });
|
||||
listener.Start();
|
||||
Thread.Sleep(50); // let the loop park in Receive
|
||||
listener.Dispose(); // Close must unblock it without hanging
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortInUse_ThrowsSocketException()
|
||||
{
|
||||
using var first = new FeedbackUdpListener(0, _ => { });
|
||||
Assert.Throws<SocketException>(() => new FeedbackUdpListener(first.Port, _ => { }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using RioJoy.Core.Plasma;
|
||||
using RioJoy.Core.Tests.Serial;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Plasma;
|
||||
|
||||
public class PlasmaDisplayTests
|
||||
{
|
||||
private static byte[][] PosTextChunks(string text, byte x = 0, byte y = 0, byte attr = 0, byte font = 0)
|
||||
{
|
||||
(byte rx, byte ry, byte rfont, int len) = PlasmaCommands.ResolvePosText(text, x, y, font);
|
||||
return new[]
|
||||
{
|
||||
PlasmaCommands.CursorX(rx),
|
||||
PlasmaCommands.CursorY(ry),
|
||||
PlasmaCommands.FontAttr(attr),
|
||||
PlasmaCommands.Font(rfont),
|
||||
PlasmaCommands.Text(text[..len]),
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PosTextAsync_EmitsThePosTextSequenceInOrder()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var display = new PlasmaDisplay(transport);
|
||||
|
||||
await display.PosTextAsync("VIPER 1-1").WithTimeout();
|
||||
|
||||
foreach (byte[] expected in PosTextChunks("VIPER 1-1"))
|
||||
Assert.Equal(expected, await transport.NextWriteAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PosTextAsync_EmptyText_WritesNothing()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var display = new PlasmaDisplay(transport);
|
||||
|
||||
await display.PosTextAsync("").WithTimeout();
|
||||
|
||||
Assert.False(transport.Writes.TryRead(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_WritesTheClearCommand()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var display = new PlasmaDisplay(transport);
|
||||
|
||||
await display.ClearAsync().WithTimeout();
|
||||
|
||||
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PosTextAsync_ConcurrentCalls_DoNotInterleave()
|
||||
{
|
||||
// Without the write lock, B's cursor/font fragments land between A's five
|
||||
// writes and corrupt the ESC stream. Gate A's first write so B has every
|
||||
// chance to sneak in, then assert the ten writes arrive as A's five
|
||||
// followed by B's five.
|
||||
var transport = new GatedTransport();
|
||||
var display = new PlasmaDisplay(transport);
|
||||
|
||||
Task a = display.PosTextAsync("AAAA");
|
||||
Task b = display.PosTextAsync("BBBB");
|
||||
transport.Open();
|
||||
await Task.WhenAll(a, b).WithTimeout();
|
||||
|
||||
var writes = new List<byte[]>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
writes.Add(await transport.NextWriteAsync());
|
||||
|
||||
byte[][] expected = PosTextChunks("AAAA").Concat(PosTextChunks("BBBB")).ToArray();
|
||||
for (int i = 0; i < expected.Length; i++)
|
||||
Assert.Equal(expected[i], writes[i]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
using RioJoy.Core.Feedback;
|
||||
using RioJoy.Core.Output;
|
||||
using RioJoy.Core.Profiles;
|
||||
using Xunit;
|
||||
@@ -86,6 +87,69 @@ public class ConfigStoreTests
|
||||
Assert.Null(Assert.Single(ConfigStore.Deserialize(json).Profiles).AxisRouting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrips_FeedbackSections()
|
||||
{
|
||||
var config = new AppConfig
|
||||
{
|
||||
Feedback = new FeedbackEndpointConfig { PipeName = "riojoy-fb-test", UdpPort = 19900 },
|
||||
Profiles =
|
||||
{
|
||||
new RioProfile
|
||||
{
|
||||
Name = "DCS",
|
||||
Feedback = new ProfileFeedbackConfig
|
||||
{
|
||||
AllowPlasmaText = false,
|
||||
Rumble = new RumbleLampConfig
|
||||
{
|
||||
LargeMotorLamps = { 0x12, 0x13 },
|
||||
SmallMotorLamps = { 0x60 },
|
||||
Threshold = 32,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
AppConfig back = ConfigStore.Deserialize(ConfigStore.Serialize(config));
|
||||
|
||||
Assert.NotNull(back.Feedback);
|
||||
Assert.True(back.Feedback!.PipeEnabled);
|
||||
Assert.Equal("riojoy-fb-test", back.Feedback.PipeName);
|
||||
Assert.Equal(19900, back.Feedback.UdpPort);
|
||||
|
||||
// These records hold List<int>, so no record value equality — per-property.
|
||||
ProfileFeedbackConfig fb = Assert.Single(back.Profiles).Feedback!;
|
||||
Assert.True(fb.AllowLampCommands);
|
||||
Assert.False(fb.AllowPlasmaText);
|
||||
Assert.NotNull(fb.Rumble);
|
||||
Assert.Equal(new[] { 0x12, 0x13 }, fb.Rumble!.LargeMotorLamps);
|
||||
Assert.Equal(new[] { 0x60 }, fb.Rumble.SmallMotorLamps);
|
||||
Assert.Equal(32, fb.Rumble.Threshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Feedback_Unset_StaysNull_AndOffJson()
|
||||
{
|
||||
// null = feedback off / endpoint defaults; NullValueHandling.Ignore keeps
|
||||
// both sections out of the JSON, so pre-Phase-9 files stay byte-compatible.
|
||||
string json = ConfigStore.Serialize(new AppConfig { Profiles = { new RioProfile { Name = "P" } } });
|
||||
Assert.DoesNotContain("Feedback", json);
|
||||
|
||||
AppConfig back = ConfigStore.Deserialize(json);
|
||||
Assert.Null(back.Feedback);
|
||||
Assert.Null(Assert.Single(back.Profiles).Feedback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShippedDescentProfile_ParsesWithFeedbackOff()
|
||||
{
|
||||
string json = File.ReadAllText(Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json"));
|
||||
RioProfile p = Assert.Single(ConfigStore.Deserialize($"{{\"Profiles\":[{json}]}}").Profiles);
|
||||
Assert.Null(p.Feedback); // pre-Phase-9 profile documents deserialize with feedback off
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShippedDescentProfile_ParsesWithDescentRouting_NoTriggerTargets()
|
||||
{
|
||||
|
||||
@@ -48,6 +48,30 @@ public class RioRuntimeTests
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lamps_SetLamp_SendsALampRequestOverTheLink()
|
||||
{
|
||||
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);
|
||||
|
||||
// The accessor the feedback endpoint's lamp scheduler drives (Phase 9).
|
||||
runtime.Lamps.SetLamp(0x12, RioLampState.SolidBright);
|
||||
|
||||
Assert.Equal(
|
||||
PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x12, RioLampState.SolidBright }),
|
||||
await fake.NextWriteAsync());
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalogReply_DrivesAllSixAxes()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Threading.Channels;
|
||||
using RioJoy.Core.Serial;
|
||||
|
||||
namespace RioJoy.Core.Tests.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IRioTransport"/> whose first write blocks until <see cref="Open"/>
|
||||
/// — lets a test park one writer mid-sequence while another tries to cut in
|
||||
/// (write-lock and latest-wins assertions).
|
||||
/// </summary>
|
||||
internal sealed class GatedTransport : IRioTransport
|
||||
{
|
||||
private readonly Channel<byte[]> _writes = Channel.CreateUnbounded<byte[]>();
|
||||
private readonly SemaphoreSlim _gate = new(0, 1);
|
||||
private readonly object _armLock = new();
|
||||
private bool _gateArmed = true;
|
||||
|
||||
public string Description => "gated";
|
||||
|
||||
/// <summary>Release the parked first write.</summary>
|
||||
public void Open() => _gate.Release();
|
||||
|
||||
public Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public async Task WriteAsync(byte[] data, CancellationToken cancellationToken)
|
||||
{
|
||||
bool wait;
|
||||
lock (_armLock)
|
||||
{
|
||||
wait = _gateArmed;
|
||||
_gateArmed = false;
|
||||
}
|
||||
if (wait)
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
_writes.Writer.TryWrite((byte[])data.Clone());
|
||||
}
|
||||
|
||||
/// <summary>Read the next write, failing if none arrives in time.</summary>
|
||||
public async Task<byte[]> NextWriteAsync(TimeSpan? timeout = null)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(5));
|
||||
return await _writes.Reader.ReadAsync(cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>True when no further write has arrived.</summary>
|
||||
public bool NoMoreWrites => !_writes.Reader.TryPeek(out _);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
Reference in New Issue
Block a user