Files
riojoy/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs
T
CydandClaude Opus 5 46e108de89 feedback: plasma box, explicit fonts, and per-position text coalescing
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>
2026-08-02 11:45:59 -05:00

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
}
}