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)); } [Fact] public void PlasmaRow_ParsesRowAndHexData() { FeedbackCommand cmd = Parse("plasma row 5 80000000000000000000000000000001"); Assert.Equal(FeedbackCommandKind.PlasmaRow, cmd.Kind); Assert.Equal(5, cmd.Y); Assert.NotNull(cmd.Data); Assert.Equal(16, cmd.Data!.Length); Assert.Equal(0x80, cmd.Data[0]); // leftmost pixel lit (MSB-first) Assert.Equal(0x01, cmd.Data[15]); Assert.All(cmd.Data.Skip(1).Take(14), b => Assert.Equal(0, b)); } [Fact] public void PlasmaRow_HexRowNumber_AndMixedCaseHex() { FeedbackCommand cmd = Parse("PLASMA ROW 0x1F AaBbCcDdEeFf00112233445566778899"); Assert.Equal(31, cmd.Y); Assert.Equal(0xAA, cmd.Data![0]); Assert.Equal(0x99, cmd.Data[15]); } [Theory] [InlineData("plasma row")] // no row [InlineData("plasma row 5")] // no data [InlineData("plasma row 32 80000000000000000000000000000001")] // row out of range [InlineData("plasma row 5 8000")] // too short [InlineData("plasma row 5 800000000000000000000000000000010A")] // too long [InlineData("plasma row 5 8000000000000000000000000000000G")] // non-hex char [InlineData("plasma row 5 80000000000000000000000000000001 x")] // trailing token public void PlasmaRow_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 } }