Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Fixes the crash the user hit twice today (captured in artifacts/coldeve-acceptance-20260729/crash-hunt.log): clicking into a multiline UiField - the examination window's inscription field - after the text had changed since the last draw threw an unhandled ArgumentOutOfRangeException from String.Substring and took the whole client down (UiField.MeasureRange <- HitChar <- OnEvent MouseDown). Root cause: _wrappedLines is a DRAW-side cache (rebuilt only in DrawMultiLine) consumed by the INPUT side (HitChar on MouseDown and drag-select MouseMove). Input events are pumped before the frame's draw, so a mutation (backspace, SetText, paste) followed by a click in the same pumped frame handed HitChar wrap lines describing the OLD, longer text; measuring those stale ranges ran past the end of the live string. Fix: text mutations now bump a version (the _text field became a private property so every existing mutation site participates without churn), the draw records which version its wrap lines describe, and HitChar proves coherence via EnsureWrappedLinesCurrent() - rebuilding with the last draw width when stale. Rebuilding rather than clamping keeps caret placement CORRECT against the live text, not merely non-throwing. Two inversion-sensitive regression tests reproduce the exact crash sequence (wrap long text, shrink without a draw, click); they throw without the HitChar coherence call. App tests 3,962 passed / 3 skipped (3,960 + 2 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
6.2 KiB
C#
203 lines
6.2 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);
|
|
}
|
|
}
|