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>
290 lines
9 KiB
C#
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)));
|
|
}
|
|
}
|