acdream/tests/AcDream.App.Tests/UI/UiChatViewTests.cs
Erik 4e60c03a74 feat(D.2b): chat text selection + Ctrl-C copy
Windows-like selection in the retail chat window: left-click-drag selects
characters, Ctrl-C copies, Ctrl-A selects all. The selected span paints a
translucent highlight behind the text.

- UiElement.CapturesPointerDrag: a per-element opt-out so an interior drag is
  delivered to the widget (text selection) instead of moving/resizing the host
  window. UiRoot.OnMouseDown honours it AFTER edge-resize (a resizable window
  is still resizable from its frame) and BEFORE window-move.
- UiChatView: AcceptsFocus + IsEditControl + CapturesPointerDrag; caches the
  OnDraw layout so OnEvent hit-tests the same geometry; HitChar maps a local
  point to (line,col) with glyph-midpoint caret snapping; SelectedText joins a
  multi-line span with \n; Ctrl-C writes to IKeyboard.ClipboardText (only when
  non-empty, so an empty copy never clobbers the clipboard).
- UiHost exposes the wired IKeyboard (clipboard + Ctrl modifier state).

Adversarial-review fix (the 99 tests would have stayed green without it): a
coordinate-frame mismatch between MouseDown and MouseMove. UiRoot.OnMouseDown
dispatched HitTestTopDown's coords, which are relative to the TOP-LEVEL child,
while MouseMove/MouseUp use target.ScreenPosition. For the chat view inset at
(8,8) inside its window the anchor landed ~8px off the click. OnMouseDown now
delivers target-LOCAL coords like the other mouse events. Added a UiRoot
regression test asserting MouseDown and MouseMove share the target-local frame
for a nested child.

Decomp ref: SurfaceWindow text/selection model; clipboard via Silk.NET
IKeyboard.ClipboardText. Built with the chat-select-copy implement->review
workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:21:28 +02:00

143 lines
5.1 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public class UiChatViewTests
{
[Fact]
public void ClampScroll_PinsToZero_WhenContentFitsView()
{
// 5 lines of content in a taller view → nothing to scroll, pinned at 0.
Assert.Equal(0f, UiChatView.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f));
Assert.Equal(0f, UiChatView.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f));
}
[Fact]
public void ClampScroll_CapsAtContentMinusView_WhenOverflowing()
{
// Content 500, view 200 → max scrollback is 300px (oldest line at top).
Assert.Equal(300f, UiChatView.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f));
Assert.Equal(120f, UiChatView.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f));
}
[Fact]
public void ClampScroll_NeverNegative()
{
Assert.Equal(0f, UiChatView.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f));
}
// ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ──
private static readonly Func<char, float> Mono10 = static _ => 10f;
[Fact]
public void CharIndexAt_ZeroOrNegative_IsColumnZero()
{
Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 0f));
Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, -5f));
}
[Fact]
public void CharIndexAt_SnapsToGlyphMidpoint()
{
// glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ...
Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0
Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0
Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1
Assert.Equal(2, UiChatView.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1
}
[Fact]
public void CharIndexAt_PastEnd_IsLength()
{
Assert.Equal(5, UiChatView.CharIndexAt("hello", Mono10, 1000f));
}
[Fact]
public void CharIndexAt_EmptyString_IsZero()
{
Assert.Equal(0, UiChatView.CharIndexAt("", Mono10, 50f));
}
// ── SelectedText assembly ────────────────────────────────────────────
private static IReadOnlyList<UiChatView.Line> Lines(params string[] texts)
{
var list = new List<UiChatView.Line>(texts.Length);
foreach (var t in texts)
list.Add(new UiChatView.Line(t, new Vector4(1, 1, 1, 1)));
return list;
}
[Fact]
public void SelectedText_SingleLine_Substring()
{
var lines = Lines("hello world");
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(0, 11));
Assert.Equal("world", s);
}
[Fact]
public void SelectedText_SingleLine_ReversedAnchorCaret_IsNormalised()
{
var lines = Lines("hello world");
// caret BEFORE anchor — Order() must normalise.
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 11), new UiChatView.Pos(0, 6));
Assert.Equal("world", s);
}
[Fact]
public void SelectedText_SamePosition_IsEmpty()
{
var lines = Lines("hello");
Assert.Equal("", UiChatView.SelectedText(lines, new UiChatView.Pos(0, 3), new UiChatView.Pos(0, 3)));
}
[Fact]
public void SelectedText_MultiLine_JoinsWithNewline()
{
var lines = Lines("first line", "second line", "third line");
// from col 6 of line 0 ("line") through col 5 of line 2 ("third")
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(2, 5));
Assert.Equal("line\nsecond line\nthird", s);
}
[Fact]
public void SelectedText_MultiLine_TwoLines_NoMiddle()
{
var lines = Lines("alpha", "bravo");
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 2), new UiChatView.Pos(1, 3));
Assert.Equal("pha\nbra", s);
}
[Fact]
public void SelectedText_MultiLine_ReversedAnchorCaret_IsNormalised()
{
var lines = Lines("alpha", "bravo");
// end before start → Order() swaps them.
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(1, 3), new UiChatView.Pos(0, 2));
Assert.Equal("pha\nbra", s);
}
[Fact]
public void SelectedText_EmptyLineList_IsEmpty()
{
Assert.Equal("", UiChatView.SelectedText(Array.Empty<UiChatView.Line>(),
new UiChatView.Pos(0, 0), new UiChatView.Pos(0, 0)));
}
[Fact]
public void Order_SortsByLineThenColumn()
{
var (s1, e1) = UiChatView.Order(new UiChatView.Pos(2, 1), new UiChatView.Pos(0, 5));
Assert.Equal(new UiChatView.Pos(0, 5), s1);
Assert.Equal(new UiChatView.Pos(2, 1), e1);
var (s2, e2) = UiChatView.Order(new UiChatView.Pos(1, 8), new UiChatView.Pos(1, 2));
Assert.Equal(new UiChatView.Pos(1, 2), s2);
Assert.Equal(new UiChatView.Pos(1, 8), e2);
}
}