Three endpoint gaps, found building the Descent 3 score overlay - the boxed place|score field the original games drew over the callsign: plasma box <x> <y> <w> <h> draws an outlined box with a blanked interior, the overlay chrome, as one ESC P graphics write. The wire addresses whole bytes horizontally, so the write covers the byte-aligned span containing the box and clears span pixels outside it; documented, with 8-px alignment the advice. Boxes queue FIFO with rows. plasma text gains an explicit font: a third numeric token after the position, with text still following, so `plasma text 2 2 7` still displays "7". Auto-fit picks the font by LENGTH - short text always rendered large, and a "1" that must fit a 12-px box simply could not be sent before. ResolvePosText generalizes the legacy Score-font special case: 0 = auto, nonzero honored. Text coalescing is now per position. The global rule - any queued text superseded every other queued text - meant the documented two-field layout (callsign top, score bottom) could not survive its own send burst: the second field silently ate the first whenever both were queued. A newer text now replaces only a queued text at the same (x,y). 15 new tests; 472 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
12 KiB
C#
345 lines
12 KiB
C#
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));
|
|
}
|
|
|
|
[Fact]
|
|
public void PlasmaText_ThirdNumberWithTextFollowing_IsFont()
|
|
{
|
|
FeedbackCommand cmd = Parse("plasma text 35 21 2 \"1\"");
|
|
Assert.Equal(35, cmd.X);
|
|
Assert.Equal(21, cmd.Y);
|
|
Assert.Equal(2, cmd.Font);
|
|
Assert.Equal("1", cmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlasmaText_ThirdNumberAsLastToken_IsTextNotFont()
|
|
{
|
|
// `plasma text 2 2 7` keeps displaying "7", as it always has.
|
|
FeedbackCommand cmd = Parse("plasma text 2 2 7");
|
|
Assert.Equal(2, cmd.X);
|
|
Assert.Equal(2, cmd.Y);
|
|
Assert.Equal(0, cmd.Font);
|
|
Assert.Equal("7", cmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlasmaText_NoFontGiven_IsAuto()
|
|
{
|
|
Assert.Equal(0, Parse("plasma text 12 3 \"FUEL LOW\"").Font);
|
|
Assert.Equal(0, Parse("plasma text \"VIPER 1-1\"").Font);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlasmaText_FontOutOfRange_Rejected()
|
|
{
|
|
Assert.Contains("font", ParseError("plasma text 2 2 99 \"X\""));
|
|
}
|
|
|
|
[Fact]
|
|
public void PlasmaBox_ParsesGeometry()
|
|
{
|
|
FeedbackCommand cmd = Parse("plasma box 32 19 64 12");
|
|
Assert.Equal(FeedbackCommandKind.PlasmaBox, cmd.Kind);
|
|
Assert.Equal(32, cmd.X);
|
|
Assert.Equal(19, cmd.Y);
|
|
Assert.Equal(64, cmd.Width);
|
|
Assert.Equal(12, cmd.Height);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("plasma box")] // nothing
|
|
[InlineData("plasma box 32 19 64")] // missing h
|
|
[InlineData("plasma box 32 19 64 12 x")] // trailing token
|
|
[InlineData("plasma box 128 0 1 1")] // x out of range
|
|
[InlineData("plasma box 0 32 1 1")] // y out of range
|
|
[InlineData("plasma box 100 0 40 1")] // spills off the right edge
|
|
[InlineData("plasma box 0 28 1 8")] // spills off the bottom
|
|
[InlineData("plasma box 0 0 0 5")] // zero width
|
|
public void PlasmaBox_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
|
|
}
|
|
}
|