acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs
Erik df9f2fd3da fix(ui): wrap chat panel body in outer BeginChild so drag-trap covers it
The InvisibleButton drag-trap inside BeginChild only catches clicks
inside that specific child. Chat had widgets OUTSIDE the inner
##chattail child (the Copy-mode Checkbox + a Separator at top, the
footer Separator + InputTextSubmit at bottom) — empty space around
those widgets fell through directly to the parent window's
window-drag init.

Fix: wrap the entire chat panel body in a single outer ##chatbody
BeginChild before drawing any content. The renderer's drag-trap
fires inside this outer child too, absorbing every empty-space
click in the chat panel body. The inner ##chattail child is now
nested inside it, which doesn't change its scroll-tail semantics
but does mean it gets its own drag-trap as a bonus.

Test fixed: Render_BeginChild_ReservesNegativeFooterFromFrameHeight
was using Single(BeginChild) — there are now two BeginChild calls
(##chatbody outer + ##chattail inner). Switched to Single(... &&
Args[0] == "##chattail") so the test still pins the footer reserve
on the inner call where it lives.

dotnet build green; 1,309 / 1,309 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:10:01 +02:00

129 lines
5.4 KiB
C#

using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// Phase J Tier 3: <see cref="ChatPanel.Render"/> must reserve footer
/// space for the separator + input field so the input stays anchored
/// at the bottom across window resizes (the user reported the input
/// disappearing when the window shrank). The pattern is the standard
/// ImGui chat-window layout: a scrollable child filling
/// <c>(0, -footerHeight)</c>, then the separator + input below it.
/// </summary>
public sealed class ChatPanelLayoutTests
{
private sealed class NoBus : ICommandBus
{
public void Publish<T>(T command) where T : notnull { /* no-op */ }
}
[Fact]
public void Render_OrderIs_Begin_BeginChild_EndChild_Separator_InputTextSubmit_End()
{
var log = new ChatLog();
log.OnSystemMessage("seed", chatType: 0);
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var renderer = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
var methods = renderer.Calls.Select(c => c.Method).ToList();
int beginIdx = methods.IndexOf("Begin");
int beginChildIdx = methods.IndexOf("BeginChild");
int endChildIdx = methods.IndexOf("EndChild");
// L.0 follow-up: Copy-mode toggle adds a Separator above the
// chat tail, so multiple Separators now exist. The footer
// separator (the one we care about for input layout) is the
// LAST one — between EndChild and the input field.
int separatorIdx = methods.LastIndexOf("Separator");
int inputSubmitIdx = methods.IndexOf("InputTextSubmit");
int endIdx = methods.IndexOf("End");
// All present
Assert.True(beginIdx >= 0, "Begin missing");
Assert.True(beginChildIdx >= 0, "BeginChild missing");
Assert.True(endChildIdx >= 0, "EndChild missing");
Assert.True(separatorIdx >= 0, "Separator missing");
Assert.True(inputSubmitIdx >= 0, "InputTextSubmit missing");
Assert.True(endIdx >= 0, "End missing");
// Order: Begin < BeginChild < EndChild < Separator < InputTextSubmit < End
Assert.True(beginIdx < beginChildIdx);
Assert.True(beginChildIdx < endChildIdx);
Assert.True(endChildIdx < separatorIdx);
Assert.True(separatorIdx < inputSubmitIdx);
Assert.True(inputSubmitIdx < endIdx);
}
[Fact]
public void Render_BeginChild_ReservesNegativeFooterFromFrameHeight()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var renderer = new FakePanelRenderer { FrameHeightWithSpacingValue = 24f };
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
// L.0 follow-up: the chat panel now wraps its body in an outer
// ##chatbody BeginChild (so empty-space clicks can't drag the
// parent window). The inner ##chattail BeginChild is the one
// that reserves the footer; that's what this test asserts.
var chattailCall = renderer.Calls.Single(c => c.Method == "BeginChild"
&& (string)c.Args[0]! == "##chattail");
var size = (System.Numerics.Vector2)chattailCall.Args[1]!;
// Width 0 = fill available; height < 0 = "fill minus this".
// Reserved height should equal FrameHeightWithSpacing + a small
// separator pad (~6f) so the input never visually clips the
// last chat line.
Assert.Equal(0f, size.X);
Assert.True(size.Y < 0, $"expected negative reserve, got {size.Y}");
Assert.True(size.Y <= -24f, $"expected at least -24f reserve, got {size.Y}");
}
[Fact]
public void Render_NewEntries_ScrollsToBottom()
{
// First render establishes the baseline (no auto-scroll because
// _lastRenderedCount == lines.Count == 0). Then a second render
// after a new entry should fire SetScrollHereY(1.0f).
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var renderer = new FakePanelRenderer();
var ctx = new PanelContext(0.016f, new NoBus());
panel.Render(ctx, renderer);
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
// Append a new entry, render again — auto-scroll should fire.
log.OnLocalSpeech("Caith", "hello", senderGuid: 0xAA, isRanged: false);
renderer.Calls.Clear();
panel.Render(ctx, renderer);
var scrollCall = renderer.Calls.Single(c => c.Method == "SetScrollHereY");
Assert.Equal(1.0f, (float)scrollCall.Args[0]!);
}
[Fact]
public void Render_NoNewEntries_DoesNotForceScroll()
{
var log = new ChatLog();
log.OnSystemMessage("seed", chatType: 0);
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var renderer = new FakePanelRenderer();
var ctx = new PanelContext(0.016f, new NoBus());
// First render establishes count baseline (1 entry). The first
// render auto-scrolls because lines.Count (1) > _lastRenderedCount
// (0). Subsequent renders without new entries should NOT scroll.
panel.Render(ctx, renderer);
renderer.Calls.Clear();
panel.Render(ctx, renderer);
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
}
}