Applies the seven NITs from the CH2 re-review (verdict APPROVE-WITH-FIXES, following the REJECT->rework ate0e78883): 1. SpewBoxController's centered Left was captured once via AnchorEdges.Top and replayed forever on resize (UiElement.ApplyAnchor's Left/Right-both-false branch pins a fixed margin). Anchors is now AnchorEdges.None and Tick recomputes Left every frame against the current root width. 2. OneLine=false was defaulting to UiText's bottom-pinned transcript flow (VerticalJustify honored only via ConfigureDatState, which this synthesized element never calls). Added UiText.HonorVerticalJustification so a non-DAT controller can opt the scrollable path into VerticalJustify without a full LayoutDesc binding; SpewBoxController sets VerticalJustify=Top so lines flow from the top of the 450x72 box, matching newest-at-top insert semantics. Noted as invented-pending- measurement in AP-178's row (no new row). 3. Documented the deliberate inversion of UiText.LinesProvider's oldest-first contract in SpewBoxController.Tick (SpewBoxVM.Lines feeds newest-first, which is correct specifically because the box is now top-aligned) and added a test pinning the rendered order (newer message is the topmost line), driving root.Tick. 4. Fixed the stale "retail's code default, 1" comment in SpewBoxControllerTests — MaxConcurrentItems is the shipped LayoutDesc's AUTHORED value, 4. 5. Added the matching unmapped-id diagnostics line to LiveSessionRuntimeFactory's ShowWeenieError sink, matching the pattern GameEventWiring's WeenieError/WeenieErrorWithString handlers already use. 6. Corrected the "EXHAUSTIVE Portal sweep found ZERO" overclaim in SpewBoxLayoutDumpDiagnostic: the loop's id source was DatCollection's top-level aggregate GetAllIdsOfType<LayoutDesc>(), not dats.Portal's own (which reports a count of ZERO for this type), so querying those ids against dats.Portal.TryGet established nothing about Portal either way. Corrected the same overclaim echoed in SpewBoxState's MaxConcurrentItems doc comment and in AP-178's register text (both the table row and the section-header history line). What's actually established: dats.Local hosts the SpewBox layout at 0x21000011; whether Portal also carries a copy remains unestablished. 7. Added a test exercising the full ShowWeenieError -> AddText -> SpewBox path for id 0x0561 (the 50-friends-cap refusal) in LiveSessionCommandRouterTests, mirroring LiveSessionRuntimeFactory's ShowWeenieError closure exactly since every other LiveSessionRuntimeFactory test in this tree is a source-text conformance grep, not an instantiation. Ledger: CH2 ledger row's review column now reads REJECT -> reworkede0e78883-> re-review APPROVE-WITH-FIXES -> nits (this commit); Status header flips CH2 to code-complete/closed pending the user gate, CH3 next. Build green; touched-project tests green (19/19 new/changed, 4351/3354 App.Tests unaffected pass); full Release suite 11,916 passed / 4 skipped / 0 failed (baseline 11,914/4/0 plus the two new tests this commit adds). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
7.4 KiB
C#
178 lines
7.4 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions.Panels.SpewBox;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// CH2 REJECT-review rework, BLOCKER 1
|
|
/// (<c>docs/research/2026-08-09-ch2-review-findings.md</c>): these tests
|
|
/// drive the controller's frame hook (<c>UiRoot.Tick</c>, the global-message-3
|
|
/// broadcast) rather than calling <c>UiText.LinesProvider</c> directly. The
|
|
/// original tests invoked the provider straight through, bypassing the
|
|
/// <c>Visible</c> gate that <c>UiText.OnDraw</c> checks before ever polling
|
|
/// it — that is exactly why the original bug (start-invisible → provider
|
|
/// never called by drawing → no line ever visible, pending queue never
|
|
/// drains) was invisible to the original test suite.
|
|
/// </summary>
|
|
public sealed class SpewBoxControllerTests
|
|
{
|
|
[Fact]
|
|
public void Controller_IsClickThroughOverlayAndDisposesFromRetainedRoot()
|
|
{
|
|
var root = new UiRoot
|
|
{
|
|
Width = 1280f,
|
|
Height = 720f,
|
|
};
|
|
var state = new SpewBoxState();
|
|
|
|
using (var controller = new SpewBoxController(root, new SpewBoxVM(state)))
|
|
{
|
|
UiText text = Assert.IsType<UiText>(root.Children.OfType<UiText>().Single());
|
|
Assert.False(text.Visible);
|
|
Assert.True(text.ClickThrough);
|
|
Assert.Equal(int.MaxValue, text.ZOrder);
|
|
|
|
state.Enqueue("You can't jump while in the air");
|
|
|
|
// Drive the frame hook, NOT the provider — this is the tick
|
|
// that drains the pending queue in production, wired through
|
|
// UiRoot.Tick's IUiGlobalTimeListener broadcast.
|
|
root.Tick(dt: 0d, nowMs: 1000L);
|
|
|
|
UiText.Line line = Assert.Single(text.LinesProvider!());
|
|
Assert.Equal("You can't jump while in the air", line.Text);
|
|
Assert.True(text.Visible);
|
|
}
|
|
|
|
Assert.Empty(root.Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void Controller_NoContent_LeavesTextHiddenWithEmptyLines()
|
|
{
|
|
var root = new UiRoot { Width = 1280f, Height = 720f };
|
|
using var controller = new SpewBoxController(root, new SpewBoxVM(new SpewBoxState()));
|
|
|
|
root.Tick(dt: 0d, nowMs: 1000L);
|
|
|
|
UiText text = Assert.IsType<UiText>(root.Children.OfType<UiText>().Single());
|
|
Assert.Empty(text.LinesProvider!());
|
|
Assert.False(text.Visible);
|
|
}
|
|
|
|
[Fact]
|
|
public void Controller_BecomesVisible_WithoutAnyDrawHavingHappenedFirst()
|
|
{
|
|
// BLOCKER 1 acceptance (a): the original bug was start-invisible →
|
|
// LinesProvider only reachable through UiElement.DrawSelfAndChildren
|
|
// (which returns early on !Visible) → provider never called → never
|
|
// visible. This test never calls anything draw-shaped — no OnDraw,
|
|
// no DrawSelfAndChildren, no UiRenderContext — only the tick.
|
|
var root = new UiRoot { Width = 1280f, Height = 720f };
|
|
var state = new SpewBoxState();
|
|
using var controller = new SpewBoxController(root, new SpewBoxVM(state));
|
|
|
|
UiText text = Assert.IsType<UiText>(root.Children.OfType<UiText>().Single());
|
|
Assert.False(text.Visible);
|
|
|
|
state.Enqueue("Out of Range!");
|
|
root.Tick(dt: 0d, nowMs: 500L);
|
|
|
|
Assert.True(text.Visible);
|
|
Assert.Equal("Out of Range!", Assert.Single(text.LinesProvider!()).Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Controller_PendingQueueDrains_WithoutAnyDrawPass()
|
|
{
|
|
// BLOCKER 1 acceptance (b): SpewBoxState.Tick is the only caller
|
|
// that drains _pending (SpewBoxState.cs's Queue<string>). Before the
|
|
// fix, that call only happened inside LinesProvider, which drawing
|
|
// gated behind Visible — so the pending queue was an unbounded
|
|
// per-session leak. Enqueue, tick the frame hook, and assert the
|
|
// underlying state actually drained (Count reflects the visible
|
|
// set, not the still-pending queue) — never touching drawing.
|
|
var root = new UiRoot { Width = 1280f, Height = 720f };
|
|
var state = new SpewBoxState();
|
|
using var controller = new SpewBoxController(root, new SpewBoxVM(state));
|
|
|
|
state.Enqueue("first");
|
|
Assert.Equal(0, state.Count); // still pending, not yet drained
|
|
|
|
root.Tick(dt: 0d, nowMs: 100L);
|
|
|
|
Assert.Equal(1, state.Count); // drained into the visible list
|
|
Assert.Equal("first", state.Snapshot()[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Controller_QueueStaysBounded_AcrossManyTicksWithoutADrawPass()
|
|
{
|
|
// BLOCKER 1 acceptance (c): repeated enqueue+tick cycles must never
|
|
// let the visible set grow past SpewBoxState.MaxConcurrentItems.
|
|
// CH2 re-review nit 4 (docs/plans/2026-08-09-chat-parity-campaign.md):
|
|
// corrected — MaxConcurrentItems is the shipped LayoutDesc's
|
|
// AUTHORED value, 4, not retail's code-default of 1 (the fallback
|
|
// gmSpewBoxUI::PostInit uses only when the property is absent; see
|
|
// SpewBoxState.MaxConcurrentItems). This is what "unbounded
|
|
// per-session leak" would have looked like if the drain never ran
|
|
// at all.
|
|
var root = new UiRoot { Width = 1280f, Height = 720f };
|
|
var state = new SpewBoxState();
|
|
using var controller = new SpewBoxController(root, new SpewBoxVM(state));
|
|
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
state.Enqueue($"line {i}");
|
|
root.Tick(dt: 0d, nowMs: 1000L + i);
|
|
Assert.True(state.Count <= SpewBoxState.MaxConcurrentItems);
|
|
}
|
|
|
|
UiText text = Assert.IsType<UiText>(root.Children.OfType<UiText>().Single());
|
|
Assert.True(text.LinesProvider!().Count <= SpewBoxState.MaxConcurrentItems);
|
|
// Newest-at-top, matching retail's InsertItem(item, 0).
|
|
Assert.Equal("line 49", text.LinesProvider!()[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Controller_RenderedOrder_NewerMessageIsTheTopmostLine()
|
|
{
|
|
// CH2 re-review nit 3 (docs/plans/2026-08-09-chat-parity-campaign.md):
|
|
// pins the RENDERED order, not UiText.LinesProvider's documented
|
|
// contract (oldest-first) — SpewBoxController deliberately inverts
|
|
// that (see Tick's ordering comment) because the box is now
|
|
// top-aligned and retail shows the newest interface-text line on
|
|
// top. Drives root.Tick, not the provider, matching every other
|
|
// test in this file.
|
|
var root = new UiRoot { Width = 1280f, Height = 720f };
|
|
var state = new SpewBoxState();
|
|
using var controller = new SpewBoxController(root, new SpewBoxVM(state));
|
|
|
|
state.Enqueue("older message");
|
|
root.Tick(dt: 0d, nowMs: 1000L);
|
|
|
|
state.Enqueue("newer message");
|
|
root.Tick(dt: 0d, nowMs: 1001L);
|
|
|
|
UiText text = Assert.IsType<UiText>(root.Children.OfType<UiText>().Single());
|
|
IReadOnlyList<UiText.Line> rendered = text.LinesProvider!();
|
|
Assert.Equal(2, rendered.Count);
|
|
Assert.Equal("newer message", rendered[0].Text);
|
|
Assert.Equal("older message", rendered[1].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_RemovesBothTheTextAndTheGlobalTimeSinkFromTheRoot()
|
|
{
|
|
var root = new UiRoot { Width = 1280f, Height = 720f };
|
|
var controller = new SpewBoxController(root, new SpewBoxVM(new SpewBoxState()));
|
|
|
|
Assert.Equal(2, root.Children.Count); // UiText + the tick sink
|
|
|
|
controller.Dispose();
|
|
|
|
Assert.Empty(root.Children);
|
|
}
|
|
}
|