Campaign CT slice B2, and the autocomplete the user asked about directly.
Typing "/r " now rewrites the chat entry to "@tell {LastTeller}, " the moment
the space lands, matching ChatInterface::HandleTextReplacements @0x004F50D0 ->
SetReplyTextInChatBox @0x004F4760.
This is display sugar rather than routing: "/r hello" already SENT correctly
through ChatInputParser's reply aliases. What was missing is that the player
could not SEE who they were about to reply to before pressing enter.
The trigger strings came out of the constant pool, not the decompiled listing —
Binary Ninja renders them as bare data_* references with no preview:
data_7C4C70 = "r " data_7C4C68 = "rp " data_7C4C58 = "reply "
Retail stores them WITHOUT the leading prefix and tests the first character
separately against '/' (0x2F) or '@' (0x40), which is why both prefixes work.
The research summary for this area listed the triggers as "/t ", "/tell " and
"reply " — reading the pool corrected that.
Three boundaries, each pinned by test because each is a way to get this subtly
wrong:
- The trailing space is PART of the trigger. "/r" alone must be left alone —
the player may still be typing "/roleplay", and expanding early would
hijack a different command mid-word.
- Only on space. Running the replacer per keystroke would rewrite text out
from under someone mid-word; retail keys on 0x20 specifically.
- Only with the caret at the end. Otherwise the player is editing existing
text, and expanding would corrupt a sentence they are part way through
fixing.
With nobody to reply to, nothing is rewritten — retail leaves the text alone
rather than producing a tell addressed to nobody, and the ordinary submit path
still reports "Someone must @tell you first!".
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
7.9 KiB
C#
261 lines
7.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);
|
|
}
|
|
}
|