Roster: SocialFellowshipPageController now builds one row per fellow
from the authored template (0x21000030/0x10000281, live-DAT verified),
diffing the member GUID set on each revision-gated Tick -- an unchanged
set updates every row's bound widgets in place (no ListBox mutation, so
scroll position is untouched by construction); only a real join/leave/
disband triggers a rebuild, via UiTemplateListBox.FlushPreservingScroll
(FA3 carry-forward 1, both the widget-level fix and the controller-level
diff). Health/stamina/mana meters bind Fill+Label; the leader's name
tints gold (lane A's row template has no dedicated leader marker, so
this is a flagged adaptation, not a ported mechanism). Row-click
selection (SelectFellow) drives Dismiss/Leader targeting and the world
selection (SelectionChangeSource.Social).
D4: SocialPanelController now tracks "is the social window shown AND is
Fellowship the active tab" via UiTabPanel.ActivePageChanged +
OnShown/OnHidden, and calls SetPageVisible on every transition, which
sends 0x00A6 (idempotent, no-op while disconnected) -- the prerequisite
ACE gates its 0x02C0 vitals stream on.
Create flow: the inline name field (0x1000026F, an authored Editable
UiField -- live-DAT verified) gates the Create button's enabled state
exactly like retail (empty name = disabled = the whole refusal
mechanism, no separate error text); FellowshipShareXP's live value is
read at click time.
Actions + confirmations: Recruit/Dismiss/Quit/Disband/AssignLeader/
SetOpen all route through DeferredGameRuntimeStateCommands (new
Fellowship* methods) rather than a raw WorldSession send, so Quit
correctly picks up RuntimeFellowshipState's leader hand-off rule.
Button enable states port gmFellowshipUI::UpdateButtons verbatim. The
Open/Close button's caption swaps between the two DAT-resolved strings
cached once at Bind (never per-tick -- DatCollection is not safe to
touch unprotected from the render loop). RetailUiRuntime intercepts a
type-4 confirmation request before it reaches the generic
GameplayConfirmationController: IgnoreFellowshipRequests auto-declines,
FellowshipAutoAcceptRequests auto-accepts, neither set falls through to
the existing dialog machinery unchanged (D6).
D5 display: the per-fellow stats line uses retail's byte-decoded
even-split percentage table verbatim (1.0/.../.3111111/.28, default
0.0); the proportional branch omits the percentage rather than
inventing a formula (no acdream ExperienceToRaiseLevel table exists
yet). Both StringInfo variable substitution (row/stats/vitals text) and
ACCharGenData::FormatName (create-flow name canonicalization) are
unported prerequisites, so row text renders as plain numeric composites
-- register rows AD-80/AD-81 (docs commit).
D7: un-dims IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
(consumed by the D6 auto-decline/accept) and FellowshipShareXP/
FellowshipShareLoot (consumed by Create + the page's own second
checkbox surface) on the Character tab -- 4 of 35 store-only rows
promoted to Live (31 remain dimmed).
Carry-forwards from the FA3 re-review, folded into this slice's
contract:
- UiTemplateListBox.FlushPreservingScroll -- preserves scroll offset
across a rebuild instead of resetting to 0 (Flush's existing
contract, unchanged, for Friends/Squelch).
- RowTemplateResolver -- the FA3 caching row-template resolver
extracted from a MountSocialPanel local function into its own
hermetically-testable class; now shared by Friends/Squelch/
Fellowship's row families.
- Friends/Squelch scrollbars now resolve via the built
UiTemplateListBox.ScrollbarElementId (DAT property 0x72) instead of
a hardcoded literal, matching ConfigOptionsPageController's own OP6
precedent.
- The Fellowship roster path never advances its revision latch on a
partial resolver failure until the NEXT real membership change --
never a per-frame retry loop.
Live-DAT verified (ACDREAM_PROBE_LIVE_MOUNT=1, extended
SocialPanelLiveMountProbeTests): the name field builds as UiField, all
11 buttons/checkboxes resolve, the row template's 5 checked fields
resolve to the right widget types, every checkbox label/tooltip and the
Open/Close captions resolve to real retail strings ("Open"/"Close"),
and a full production-path Bind() against live DATs produces zero
"not found" warnings.
App tests: +30 (7 UiTemplateListBox/RowTemplateResolver unit tests, 23
SocialFellowshipPageControllerTests covering roster diff/rebuild,
button enable rules, checkbox wiring, create-flow gating, D4
idempotency, and D5 formatting) plus 2 CharacterOptionsPageController
counts updated for the D7 un-dim (35->31 dimmed, 15->19 live).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
99 lines
3.5 KiB
C#
99 lines
3.5 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// FA3 carry-forward 1 (folded into Campaign FA slice FA4): unlike
|
|
/// <see cref="UiTemplateListBox.Flush"/>, <see cref="UiTemplateListBox.FlushPreservingScroll"/>
|
|
/// must not reset the scroll offset to 0 — it exists specifically so a
|
|
/// roster rebuild triggered by a server-side change (not user action)
|
|
/// doesn't yank a scrolled-down user back to the top.
|
|
/// </summary>
|
|
public sealed class UiTemplateListBoxFlushPreservingScrollTests
|
|
{
|
|
private static UiTemplateListBox MakeListBox(float width, float height)
|
|
{
|
|
var info = new ElementInfo { Id = 0x10000279u, Type = 5u };
|
|
return new UiTemplateListBox(
|
|
info,
|
|
_ => (0u, 0, 0),
|
|
new[] { new UiTemplateListEntry(0x21000030u, 0x10000281u) },
|
|
scrollbarElementId: 0x1000027Au)
|
|
{
|
|
Width = width,
|
|
Height = height,
|
|
};
|
|
}
|
|
|
|
private static void AddRows(UiTemplateListBox box, int count, float rowHeight = 40f)
|
|
{
|
|
for (int i = 0; i < count; i++)
|
|
box.AddPrebuiltRow(new UiText { Width = box.Width, Height = rowHeight });
|
|
}
|
|
|
|
[Fact]
|
|
public void Flush_ResetsScrollToZero_TheOrdinaryContract()
|
|
{
|
|
var box = MakeListBox(200f, 100f);
|
|
AddRows(box, 10); // 400px of content in a 100px viewport
|
|
box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
|
|
box.ViewportForTest!.LayoutScrollableChildren();
|
|
box.Scroll.SetScrollY(150);
|
|
Assert.Equal(150, box.Scroll.ScrollY);
|
|
|
|
box.Flush();
|
|
|
|
Assert.Equal(0, box.Scroll.ScrollY);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlushPreservingScroll_KeepsTheScrollOffset()
|
|
{
|
|
var box = MakeListBox(200f, 100f);
|
|
AddRows(box, 10); // 400px of content in a 100px viewport
|
|
box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
|
|
box.ViewportForTest!.LayoutScrollableChildren();
|
|
box.Scroll.SetScrollY(150);
|
|
Assert.Equal(150, box.Scroll.ScrollY);
|
|
|
|
box.FlushPreservingScroll();
|
|
|
|
Assert.Equal(150, box.Scroll.ScrollY);
|
|
|
|
// Rebuild the SAME row count — the offset should still be valid
|
|
// (and stay put) once the next layout pass recomputes content
|
|
// height against the rebuilt rows.
|
|
AddRows(box, 10);
|
|
box.ViewportForTest!.LayoutScrollableChildren();
|
|
Assert.Equal(150, box.Scroll.ScrollY);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlushPreservingScroll_ClampsToTheNewShorterContent()
|
|
{
|
|
var box = MakeListBox(200f, 100f);
|
|
AddRows(box, 10); // 400px of content
|
|
box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
|
|
box.ViewportForTest!.LayoutScrollableChildren();
|
|
box.Scroll.SetScrollY(300); // near the bottom of the 10-row content
|
|
|
|
box.FlushPreservingScroll();
|
|
// Rebuild with far FEWER rows (e.g. most of the fellowship left).
|
|
AddRows(box, 2); // 80px of content, less than the 100px viewport
|
|
box.ViewportForTest!.LayoutScrollableChildren();
|
|
|
|
// Clamped to the new MaxScroll (0, since content < viewport) —
|
|
// never left dangling past the real content, and never throws.
|
|
Assert.Equal(0, box.Scroll.ScrollY);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlushPreservingScroll_DormantBox_IsANoOp()
|
|
{
|
|
var box = MakeListBox(200f, 100f);
|
|
// No rows ever added — the viewport was never created.
|
|
box.FlushPreservingScroll();
|
|
Assert.Null(box.ViewportForTest);
|
|
}
|
|
}
|