acdream/tests/AcDream.App.Tests/UI/UiFieldTests.cs
Erik 9f6d79b7e0 feat(chat): CT-C2 Escape leaves the entry; CT-C3 the timestamp is grey
Campaign CT slices C2 and C3.

**C2 — Escape in the chat input did nothing at all.** Not "did the wrong
thing": nothing. Two independent facts had to hold for that. UiField has no
Escape case, AND a focused field reports IsEditControl, which makes UiRoot skip
its own fallback and the input dispatcher withhold game actions — so the player
had no way out of the bar except the mouse.

Retail maps Escape to input action 0x0B, which runs
ChatInterface::DeactivateChatEntry @0x004F2FC0: RelinquishFocus, then
Deactivate. It does NOT clear the field. That is worth stating because the
obvious guess — "Escape clears the input" — is wrong and would have looked
perfectly reasonable; a half-written message survives stepping away from the
bar, and the test pins that rather than just pinning "handled".

**C3 — the timestamp took the message's colour.** Retail appends it as its own
run at a FIXED colour index (0x0C, which BuildChatColorLookupTable @0x004F31C0
fills with colorGrey) rather than the line's, so it stays grey whether the
message is red combat text or white speech.

Most of C3 was already done and stayed untouched: the DisplayTimeStamps option
is polled, and FormatTimestampPrefix already matches retail's "%#H:%M:%S ".
Only the colour was wrong, and it was only fixable now because A1/A4 made a
line able to carry more than one colour.

The stamp is a span ROLE rather than a second tag type: it is not clickable and
carries no payload, so modelling it as a tag would have made it hit-testable
for no reason. Its colour comes from the same runtime table every message
colour comes from, unlike the tagged-name colour, which is authored per element
(0x1D) and deliberately lives elsewhere.

One consequence worth naming: a timestamped line now needs runs even when its
sender is not tagged, because the stamp alone is reason enough. Before this,
only tagged lines got runs.

Also verified and NOT changed, having checked rather than assumed: C1's
auto-scroll half is already retail-faithful — UiScrollable.SetExtents samples
"was at the end" BEFORE applying new extents and only re-sticks if so, which is
exactly retail's IsAtVerticalEnd rule, and chat gets it by default. C1 reduces
to the unread indicator, which does not exist yet.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:28:45 +02:00

290 lines
9 KiB
C#

using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiFieldTests
{
[Fact]
public void InsertChar_AdvancesCaret()
{
var input = new UiField();
input.InsertChar('h'); input.InsertChar('i');
Assert.Equal("hi", input.Text);
Assert.Equal(2, input.CaretPos);
}
[Fact]
public void Backspace_DeletesBeforeCaret()
{
var input = new UiField();
foreach (var c in "abc") input.InsertChar(c);
input.MoveCaret(-1);
input.Backspace();
Assert.Equal("ac", input.Text);
Assert.Equal(1, input.CaretPos);
}
[Fact]
public void Submit_FiresCallback_ClearsText_PushesHistory()
{
string? sent = null;
var input = new UiField { OnSubmit = t => sent = t };
foreach (var c in "hello") input.InsertChar(c);
input.Submit();
Assert.Equal("hello", sent);
Assert.Equal("", input.Text);
Assert.Equal(0, input.CaretPos);
}
[Fact]
public void EmptySubmit_DoesNotFire()
{
int n = 0;
var input = new UiField { OnSubmit = _ => n++ };
input.Submit();
Assert.Equal(0, n);
}
[Fact]
public void History_UpDownBrowsesPreviousSubmissions()
{
var input = new UiField { OnSubmit = _ => {} };
foreach (var c in "first") input.InsertChar(c); input.Submit();
foreach (var c in "second") input.InsertChar(c); input.Submit();
input.HistoryPrev();
Assert.Equal("second", input.Text);
input.HistoryPrev();
Assert.Equal("first", input.Text);
input.HistoryNext();
Assert.Equal("second", input.Text);
input.HistoryNext();
Assert.Equal("", input.Text);
}
[Fact]
public void History_CapsAt100()
{
var input = new UiField { OnSubmit = _ => {} };
for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
Assert.True(input.HistoryCount <= 100);
}
[Fact]
public void MultilineClick_AfterTextShrankSinceLastWrap_DoesNotThrowAndPlacesCaretInNewText()
{
// The 2026-07-29 inscription-field crash: the wrapped-line cache is
// rebuilt at DRAW time, but mouse events are pumped BEFORE the
// frame's draw — a backspace/SetText followed by a click in the same
// frame handed HitChar wrap lines describing the OLD, longer text,
// and MeasureRange threw ArgumentOutOfRangeException from
// String.Substring. HitChar must prove wrap coherence itself.
var input = new UiField
{
OneLine = false,
Selectable = true,
Width = 120,
Height = 80,
};
input.SetText(
"a long inscription that wraps across multiple lines when it "
+ "is measured with the fallback eight pixel glyph width");
// Simulate the draw-time cache build for the CURRENT (long) text.
input.EnsureWrappedLinesCurrent();
// Text shrinks with no draw in between — the cached lines now
// describe ranges far beyond the live string.
input.SetText("hi");
// Click low and to the right, where a stale line would demand a
// substring past the end of "hi". Pre-fix: throws. Post-fix: the
// cache rebuilds and the caret lands inside the new text.
var exception = Record.Exception(() => input.OnEvent(
new UiEvent(0u, input, UiEventType.MouseDown, Data1: 90, Data2: 60)));
Assert.Null(exception);
Assert.InRange(input.CaretPos, 0, input.Text.Length);
}
[Fact]
public void MultilineClick_AfterBackspacesSinceLastWrap_DoesNotThrow()
{
var input = new UiField
{
OneLine = false,
Selectable = true,
Width = 96,
Height = 60,
};
input.SetText("wrapped inscription text under edit right now");
input.EnsureWrappedLinesCurrent();
for (int i = 0; i < 30; i++)
input.Backspace();
var exception = Record.Exception(() => input.OnEvent(
new UiEvent(0u, input, UiEventType.MouseDown, Data1: 80, Data2: 40)));
Assert.Null(exception);
Assert.InRange(input.CaretPos, 0, input.Text.Length);
}
[Fact]
public void CharacterFilter_rejectsDisallowedInput()
{
var input = new UiField { CharacterFilter = static c => char.IsAsciiDigit(c) };
input.InsertChar('4');
input.InsertChar('x');
input.InsertChar('2');
Assert.Equal("42", input.Text);
}
[Fact]
public void SelectAllOnFocus_survivesInitiatingMouseDown_andTypingReplacesValue()
{
var input = new UiField { SelectAllOnFocus = true, Selectable = true };
input.SetText("17");
input.OnEvent(new UiEvent(0u, input, UiEventType.FocusGained));
input.OnEvent(new UiEvent(0u, input, UiEventType.MouseDown, Data1: 2));
input.InsertChar('5');
Assert.Equal("5", input.Text);
}
[Fact]
public void ReadOnlyField_RejectsMutationsButReportsClick()
{
int clicks = 0;
var input = new UiField
{
Editable = false,
OnReadOnlyClick = () => clicks++,
};
input.SetText("fixed");
input.InsertChar('!');
input.Backspace();
input.OnEvent(new UiEvent(0u, input, UiEventType.Click));
Assert.Equal("fixed", input.Text);
Assert.Equal(1, clicks);
Assert.False(input.AcceptsFocus);
Assert.False(input.IsEditControl);
}
[Fact]
public void MultiLineField_EnterAddsNewlineInsteadOfSubmitting()
{
int submissions = 0;
var input = new UiField
{
OneLine = false,
OnSubmit = _ => submissions++,
};
input.InsertChar('a');
input.OnEvent(new UiEvent(
0u,
input,
UiEventType.KeyDown,
Data0: (int)Silk.NET.Input.Key.Enter));
input.OnEvent(new UiEvent(
0u,
input,
UiEventType.Char,
Data0: '\r'));
input.InsertChar('b');
Assert.Equal("a\nb", input.Text);
Assert.Equal(0, submissions);
}
// ── CT-B2: typed-abbreviation expansion ─────────────────────────────
[Fact]
public void TypingASpaceOffersTheTextToTheReplacer()
{
var input = new UiField();
input.TextReplacer = text => text == "/r " ? "@tell Dww, " : null;
foreach (char c in "/r ")
input.InsertChar(c);
Assert.Equal("@tell Dww, ", input.Text);
Assert.Equal("@tell Dww, ".Length, input.CaretPos);
}
[Fact]
public void ANonSpaceCharacterNeverTriggersTheReplacer()
{
// Retail keys the expansion on 0x20 specifically; running it on every
// keystroke would rewrite text out from under someone mid-word.
int calls = 0;
var input = new UiField();
input.TextReplacer = _ => { calls++; return null; };
foreach (char c in "/reply")
input.InsertChar(c);
Assert.Equal(0, calls);
}
[Fact]
public void EditingInTheMiddleOfALineIsNotRewritten()
{
// The caret is not at the end, so the player is editing existing text
// rather than typing an abbreviation — expanding here would corrupt a
// sentence they are part way through fixing.
var input = new UiField();
input.SetText("/r hello");
input.MoveCaret(-5); // caret sits just after "/r"
input.TextReplacer = _ => "@tell Dww, ";
input.InsertChar(' ');
Assert.Equal("/r hello", input.Text);
}
[Fact]
public void AReplacerReturningNullLeavesTheTextExactlyAsTyped()
{
var input = new UiField();
input.TextReplacer = _ => null;
foreach (char c in "hi ")
input.InsertChar(c);
Assert.Equal("hi ", input.Text);
}
// ── CT-C2: Escape leaves the chat entry ─────────────────────────────
[Fact]
public void EscapeIsHandledAndKeepsWhatWasTyped()
{
// Retail's DeactivateChatEntry @0x004F2FC0 relinquishes focus and
// deactivates; it does NOT clear the field, so a half-written message
// survives stepping away from the bar.
var input = new UiField();
input.SetText("half written");
bool handled = input.OnEvent(new UiEvent(
0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape));
Assert.True(handled);
Assert.Equal("half written", input.Text);
}
[Fact]
public void EscapeIsIgnoredWhenTheFieldIsNotEditable()
{
// A read-only field returns early before the key switch, so Escape
// must not acquire behaviour there.
var input = new UiField { Editable = false };
Assert.True(input.OnEvent(new UiEvent(
0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape)));
}
}