fix(chat): Campaign CH user-gate round 1 — jump-in-air edge, portal cue cadence, wrap/prefix/color fixes
The user tested Campaign CH's CODE-COMPLETE build live and reported ten defects (docs/plans/2026-08-09-chat-parity-campaign.md, "User gate — round 1"). Items A-G are fixed here; the remaining three (extra chat windows on 1/2/3/4, resize working in only one corner, transparency/ artifacts) are out of scope for a fix and filed as slice CH6. A. Jump-in-air refusal never fired live: the jump block only ever evaluated input.Jump inside the grounded-charge or already-charging branches. PlayerMovementController now detects the press RISING EDGE while airborne and reports WeenieError.NotGrounded once per press, leaving the grounded charge/fire path untouched. B. ChatVM's invented "[System] " prefix is dropped — retail prints system text bare. [Popup] is unchanged (AP-175). C. SpewBoxController's color is now the user-pinned exact value (1, 1, 0.247, 1), the same bright yellow as an incoming Tell. Register row AP-178 updated: color CLOSES, size/position/font stay open per the user's live report that they still differ. D. Closes #329: PortalTunnelPresentation now emits the portal wait cue unconditionally on every rotation-segment boundary, matching gmSmartBoxUI::UseTime's decompiled else-arm exactly instead of gating on a 5-second hold local transits never reached. PortalWaitNotice Controller now renders it in the same pinned yellow as item C. Register row AP-150 retired. E. Closes #362: new ClientCommandResponses.cs parses and renders the four previously-unhandled inbound GameEvents (ChannelIndex, ChannelList, AvailableHouses, AllegianceInfoResponse), each ported line-for-line from the named-retail decomp's inbound handlers. Register row TS-70 retired. F. ChatWindowController.WrapText now splits on embedded '\n'/'\r\n' first, then word-wraps each segment independently — server text like /help's reply no longer collapses onto one line. G. The chat input field's right edge no longer holds a fixed absolute pixel position across a window resize; Bind now upgrades it to retail edge-mode 1 (UiLayoutPolicy) or the AnchorEdges.Right stretch fallback so it tracks the window's client width instead of overflowing past a narrower resize. Full Release suite: 12,247 passed / 4 skipped / 0 failed (baseline 12,221/4/0 + 26 new tests across items A, E, F, G). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d1c1368a5e
commit
47e40900f3
17 changed files with 1428 additions and 81 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Chat;
|
||||
|
|
@ -309,4 +310,181 @@ public class ChatWindowControllerTests
|
|||
|
||||
Assert.Null(ctrl);
|
||||
}
|
||||
|
||||
// ── Input field resize: Campaign CH user-gate round 1, item G ────────────
|
||||
// The input line overflowed the chat window's right edge on resize. Its
|
||||
// right edge held a FIXED absolute pixel position (either the imported
|
||||
// UiLayoutPolicy's mode-0 "frozen at current" fallback, or the
|
||||
// AnchorEdges default with no Right bit) — nothing re-ran the
|
||||
// Left/Width recompute on a plain window resize, only at bind time and
|
||||
// on channel change. Bind now leaves the input's right edge tracking
|
||||
// the live parent width either way.
|
||||
|
||||
[Fact]
|
||||
public void Bind_InputField_WithNoImportedLayoutPolicy_NeverOverflowsOnNarrowerResize()
|
||||
{
|
||||
// BuildTestTree's synthetic ElementInfo nodes never set
|
||||
// HasOriginalParentSize, so DatWidgetFactory.CreateLayoutPolicy
|
||||
// returns null for every widget here — this exercises the
|
||||
// AnchorEdges fallback branch of the fix.
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||
Assert.NotNull(ctrl);
|
||||
Assert.Null(ctrl!.Input.LayoutPolicy);
|
||||
Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, ctrl.Input.Anchors & (AnchorEdges.Left | AnchorEdges.Right));
|
||||
|
||||
// First frame after Bind(): the per-frame draw pass calls ApplyAnchor
|
||||
// against the LIVE (still-authored, 490px) inputBar width — this is
|
||||
// the lazy margin CAPTURE, matching UiElement.ApplyAnchor's
|
||||
// "!_anchorCaptured" first-call semantics. Only THEN does a resize
|
||||
// (a later frame, a smaller parent width) exercise the stretch.
|
||||
const float authoredParentWidth = 490f;
|
||||
ctrl.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height);
|
||||
|
||||
const float narrowerParentWidth = 300f;
|
||||
ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height);
|
||||
|
||||
Assert.True(
|
||||
ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth,
|
||||
$"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_InputField_WithNoImportedLayoutPolicy_GrowsWithWiderResize()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||
Assert.NotNull(ctrl);
|
||||
|
||||
const float authoredParentWidth = 490f;
|
||||
ctrl!.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height);
|
||||
float originalWidth = ctrl.Input.Width;
|
||||
|
||||
const float widerParentWidth = 800f;
|
||||
ctrl.Input.ApplyAnchor(widerParentWidth, ctrl.Input.Height);
|
||||
|
||||
Assert.True(ctrl.Input.Width > originalWidth, "the input should widen when the window grows");
|
||||
Assert.True(ctrl.Input.Left + ctrl.Input.Width <= widerParentWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_InputField_WithImportedLayoutPolicy_RightEdgeTracksParentDeltaInsteadOfFreezing()
|
||||
{
|
||||
// Mirror the REAL production import path: the input field carries an
|
||||
// authored UiLayoutPolicy (HasOriginalParentSize=true, as a real
|
||||
// ImportInfos-resolved LayoutDesc element would). Right=0 here is
|
||||
// retail's raw edge mode BEFORE the fix's upgrade — proving Bind
|
||||
// replaces it with mode 1 rather than leaving mode 0's "frozen at
|
||||
// current pixel position" behavior in place.
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var inputInfo = FindById(rootInfo, 0x10000016u)
|
||||
?? throw new System.InvalidOperationException("test fixture missing the input node");
|
||||
inputInfo.HasOriginalParentSize = true;
|
||||
inputInfo.OriginalParentWidth = 490f;
|
||||
inputInfo.OriginalParentHeight = 17f;
|
||||
inputInfo.Left = 1u; // near-edge: fixed to current (retail mode 1)
|
||||
inputInfo.Top = 1u;
|
||||
inputInfo.Right = 0u; // far-edge mode this fix must upgrade away from
|
||||
inputInfo.Bottom = 1u;
|
||||
|
||||
// Rebuild the widget tree now that the fixture carries the policy
|
||||
// inputs (BuildTestTree already built one without them).
|
||||
layout = LayoutImporter.Build(rootInfo, NoTex, null);
|
||||
var bus = new CaptureBus();
|
||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
Assert.NotNull(ctrl!.Input.LayoutPolicy);
|
||||
Assert.Equal(1u, ctrl.Input.LayoutPolicy!.RightMode);
|
||||
|
||||
const float narrowerParentWidth = 300f;
|
||||
ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height);
|
||||
|
||||
Assert.True(
|
||||
ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth,
|
||||
$"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})");
|
||||
}
|
||||
|
||||
private static ElementInfo? FindById(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id) return node;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
if (FindById(child, id) is { } found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── WrapText: Campaign CH user-gate round 1, item F ──────────────────────
|
||||
// /help (and "probably many places") never split on embedded '\n' — the
|
||||
// whole multi-line blob rode the single early-out as ONE line. Split on
|
||||
// '\n' first, then word-wrap each segment; a single-segment text keeps
|
||||
// the pre-existing early-out behavior exactly.
|
||||
|
||||
private static float MeasureByCharCount(string s) => s.Length;
|
||||
|
||||
[Fact]
|
||||
public void WrapText_EmbeddedNewlines_ProduceOneRenderedLinePerSegment()
|
||||
{
|
||||
string text = "line one\nline two\nline three";
|
||||
|
||||
// maxW is generous — every segment fits without word-wrapping, so
|
||||
// this isolates the newline-split behavior specifically.
|
||||
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
|
||||
|
||||
Assert.Equal(new[] { "line one", "line two", "line three" }, lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapText_CarriageReturnNewline_NormalizesTheSameAsBareNewline()
|
||||
{
|
||||
string text = "line one\r\nline two";
|
||||
|
||||
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
|
||||
|
||||
Assert.Equal(new[] { "line one", "line two" }, lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapText_SegmentLongerThanMaxWidth_StillWordWraps()
|
||||
{
|
||||
// Each segment is independently word-wrapped by the SAME algorithm
|
||||
// the single-line path always used — a multi-line server message
|
||||
// whose second line overflows the window still wraps that line.
|
||||
string text = "short\nthis segment is much too long to fit on one line";
|
||||
|
||||
var lines = new List<string>(ChatWindowController.WrapText(text, 10f, MeasureByCharCount));
|
||||
|
||||
Assert.Equal("short", lines[0]);
|
||||
Assert.True(lines.Count > 2, "the long second segment should have wrapped into multiple lines");
|
||||
Assert.All(lines, line => Assert.True(MeasureByCharCount(line) <= 10f));
|
||||
Assert.Equal(
|
||||
"this segment is much too long to fit on one line",
|
||||
string.Join(" ", lines.Skip(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapText_SingleSegmentText_KeepsTheEarlyOutBehavior()
|
||||
{
|
||||
// No '\n' at all — the pre-existing single-line early-out path
|
||||
// (whole text fits => returned verbatim as one fragment) is
|
||||
// unchanged.
|
||||
string text = "no newlines here";
|
||||
|
||||
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
|
||||
|
||||
Assert.Equal(new[] { text }, lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapText_ConsecutiveNewlines_ProduceABlankLine()
|
||||
{
|
||||
string text = "first\n\nthird";
|
||||
|
||||
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
|
||||
|
||||
Assert.Equal(new[] { "first", "", "third" }, lines);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue