acdream/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs
Erik 72ceddce2e fix: social panel completion batch (user gate 2026-08-13, "fix all")
One user-ordered batch across the FA social panel + world selection.
Every root cause was probe-proven before the fix (new
ProbeSocialClickRouting in SocialPanelLiveMountProbeTests - production
window mount + real UiRoot hit-tests + a synthetic click):

1. STUCK CHECKBOXES (fellowship x4, allegiance x1, "always checked /
   can't change any options"): the authored checkboxes carry DAT
   ToggleBehavior, so UiButton SELF-FLIPS Selected at MouseUp - the old
   handlers read the flipped value and wrote the ORIGINAL back, snapping
   every click to where it started (the probe recorded (id, oldValue)).
   Fix: SuppressSelfToggle (the CH6a/b mirror discipline) + derive the
   next value from the STORE; the per-tick seeding mirrors it back.
2. UNCLICKABLE ROSTER ROWS ("only get the move window cursor"): the row
   name text is display-text ClickThrough=true, which the hit-test walk
   skips regardless of HandlesClick - the wired OnClick was unreachable.
   Fix: UiText.OnClick assignment now clears ClickThrough (central,
   documented); the stats text gains the same select handler so most of
   the row's width selects the fellow.
3. TRUNCATED EMPTY-STATE ("You do not belong... To create MISSING"):
   the authored string resolves COMPLETE (three sentences) but embedded
   '\n's rendered as one clipped line. DatWidgetFactory now splits
   authored strings into one Line per newline, with the provider still
   re-reading DefaultColor live (the state-color contract - caught by
   BuildText_AuthoredLineTracksStateFontColor).
4. FELLOW NAMES WHITE (user-directed): the AD-82 invented leader-gold +
   selection-blue tints are deleted; names always white (register row
   narrowed).
5. ALLEGIANCE HEADER LABELS: bare "0"/"0" -> "Followers: N" / "Rank: [N]"
   (user-specified format; the full retail StringInfo composition stays
   AD-85's gap), monarch block matching.
6. FRIENDS/SQUELCH LIVE (AD-79 mostly retired): Add friend (name box ->
   0x0018, retail clears the box - Request_AddFriend @0x0048D240),
   Remove (row-click selection -> 0x0017), Appear Offline (CharacterOption
   0x27 via the immediate 0x0005 auto-save, ACE pushes FriendStatusChanged
   to your friend-of list), Squelch Character/Account add-by-name
   (0x0058 guid0/type AllChannels + 0x0059) and Remove for the selected
   row. The wire beneath (builders, WorldSession sends, Runtime commands,
   parsers) existed end-to-end since J4.1/FA1 - this is panel wiring only
   (docs/research/2026-08-13-social-wire-completion.md, committed here).
   Send Tell stays inert (not in the order; AD-79's remainder).
7. WORLD SELF-SELECTION ("clicking my own char should select myself"):
   retail has NO self-exclusion (CPhysicsPart::Draw @0x0050D823 arms
   every physobj; RecvNotice_SmartBoxObjectFound @0x004E5BAE selects
   unconditionally) - the includeSelf gate was an unregistered
   divergence, now removed on both the left-click and right-click paths.

Element roles were probe-measured, never guessed (Add 0x10000514 /
Remove 0x10000515 / Send Tell 0x10000516 / Appear Offline 0x1000052C /
name field 0x1000051B; Squelch: field 0x10000540, Remove 0x10000547,
Squelch Character 0x1000054B, Squelch Account 0x1000054C).

Register: AD-79 mostly retired, AD-82 narrowed. Known remainder, filed
not hidden: the fellowship page's authored 600px content vs the 362px
viewport leaves Dismiss/Assign-Leader below the fold until the window is
resized taller (probe-measured; candidate follow-up).

Tests: Checkbox_Click fact rewritten to the mirror contract (both
directions), monarch-followers label updated, includeSelf expectation
updated, probe extended (click routing, synthetic click, action-widget
role dump). App suite 4,976/3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:30:51 +02:00

807 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Runtime;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign FA slice FA4: hermetic (no DAT, no live runtime) tests for
/// <see cref="SocialFellowshipPageController"/> against a hand-built page
/// tree carrying every element id lane A's structural inventory names
/// (docs/research/2026-08-11-fa-panel-structure.md §3.1).
/// </summary>
public sealed class SocialFellowshipPageControllerTests
{
private const uint NotInFellowshipFrameId = 0x1000026Bu;
private const uint InFellowshipFrameId = 0x10000275u;
private const uint NameEntryBoxId = 0x1000026Fu;
private const uint CreateButtonId = 0x10000274u;
private const uint FellowshipNameTextId = 0x10000276u;
private const uint ListBoxId = 0x10000279u;
private const uint ScrollbarId = 0x1000027Au;
private const uint LeaderButtonId = 0x1000027Bu;
private const uint QuitButtonId = 0x1000027Cu;
private const uint OpenButtonId = 0x1000027Du;
private const uint RecruitButtonId = 0x1000027Eu;
private const uint DismissButtonId = 0x1000027Fu;
private const uint DisbandButtonId = 0x10000280u;
private const uint IgnoreCheckboxId = 0x10000270u;
private const uint AutoAcceptCheckboxId = 0x10000271u;
private const uint ShareXpCheckboxId = 0x10000272u;
private const uint ShareLootCheckboxId = 0x10000273u;
private const uint RowNameTextId = 0x10000283u;
private const uint RowStatsTextId = 0x10000284u;
private const uint RowHealthMeterId = 0x10000285u;
private const uint RowStaminaMeterId = 0x10000287u;
private const uint RowManaMeterId = 0x10000289u;
private static readonly Func<uint, (uint, int, int)> NoTex = static _ => (0u, 0, 0);
private static UiButton MakeButton(uint id)
{
var button = new UiButton(new ElementInfo { Id = id, Type = 1u }, NoTex);
button.DatElementId = id;
return button;
}
private static UiElement BuildPageRoot(out UiTemplateListBox listBox, out UiField nameField)
{
var root = new UiPanel();
void AddPlain(UiElement child, uint id)
{
child.DatElementId = id;
root.AddChild(child);
}
AddPlain(new UiPanel(), NotInFellowshipFrameId);
AddPlain(new UiPanel(), InFellowshipFrameId);
nameField = new UiField();
AddPlain(nameField, NameEntryBoxId);
AddPlain(MakeButton(CreateButtonId), CreateButtonId);
AddPlain(new UiText(), FellowshipNameTextId);
listBox = new UiTemplateListBox(
new ElementInfo { Id = ListBoxId, Type = 5u },
NoTex,
new[] { new UiTemplateListEntry(0x21000030u, 0x10000281u) },
scrollbarElementId: ScrollbarId);
AddPlain(listBox, ListBoxId);
AddPlain(new UiScrollbar(), ScrollbarId);
AddPlain(MakeButton(LeaderButtonId), LeaderButtonId);
AddPlain(MakeButton(QuitButtonId), QuitButtonId);
AddPlain(MakeButton(OpenButtonId), OpenButtonId);
AddPlain(MakeButton(RecruitButtonId), RecruitButtonId);
AddPlain(MakeButton(DismissButtonId), DismissButtonId);
AddPlain(MakeButton(DisbandButtonId), DisbandButtonId);
AddPlain(MakeButton(IgnoreCheckboxId), IgnoreCheckboxId);
AddPlain(MakeButton(AutoAcceptCheckboxId), AutoAcceptCheckboxId);
AddPlain(MakeButton(ShareXpCheckboxId), ShareXpCheckboxId);
AddPlain(MakeButton(ShareLootCheckboxId), ShareLootCheckboxId);
return root;
}
/// <summary>Mirrors lane A's 8-child fellow row shape (name, stats, three
/// meter+text-consumed-by-meter pairs — the meter text is NOT a separate
/// resolved child, per <see cref="UiMeter.ConsumesDatChildren"/>).</summary>
private static UiElement BuildFakeFellowRow()
{
var row = new UiPanel();
void Add(UiElement child, uint id)
{
child.DatElementId = id;
row.AddChild(child);
}
Add(new UiText(), RowNameTextId);
Add(new UiText(), RowStatsTextId);
Add(new UiMeter(), RowHealthMeterId);
Add(new UiMeter(), RowStaminaMeterId);
Add(new UiMeter(), RowManaMeterId);
return row;
}
private static int _rowBuildCount;
private static UiElement? FakeRowResolver(uint layoutId, uint elementId)
{
_rowBuildCount++;
return BuildFakeFellowRow();
}
private static readonly RuntimeCommandResult InactiveResult = new(RuntimeCommandStatus.Inactive, default);
private static readonly RuntimeCommandResult AcceptedResult = new(RuntimeCommandStatus.Accepted, default);
private sealed class FellowshipBindingsBuilder
{
public RuntimeFellowshipSnapshot Snapshot;
public List<RuntimeFellowMemberSnapshot> Members = [];
public readonly List<string> Calls = [];
public readonly Dictionary<CharacterOptionId, bool> Options = new();
public SelectionState Selection = new();
public uint LocalPlayerGuid;
public Func<uint, uint, UiElement?> TemplateResolver = FakeRowResolver;
public Func<uint, uint, string?> ResolveString = static (_, _) => null;
// SetPanelOpen (0x00A6) is the one command whose CONTROLLER behavior
// depends on the result status (MUST-FIX 3 re-fix: the latch advances
// only on Accepted). Default in-world so the edge-trigger tests model
// production; a test flips this false to simulate a pre-world reset.
public bool PanelOpenInWorld = true;
public SocialFellowshipPageController.Bindings Build() => new(
Snapshot: () => Snapshot,
Members: () => Members,
TemplateResolver: TemplateResolver,
Create: (name, shareXp) => { Calls.Add($"create:{name}:{shareXp}"); return InactiveResult; },
Recruit: guid => { Calls.Add($"recruit:{guid:X8}"); return InactiveResult; },
Dismiss: guid => { Calls.Add($"dismiss:{guid:X8}"); return InactiveResult; },
Quit: disband => { Calls.Add($"quit:{disband}"); return InactiveResult; },
AssignLeader: guid => { Calls.Add($"assign-leader:{guid:X8}"); return InactiveResult; },
SetOpen: isOpen => { Calls.Add($"set-open:{isOpen}"); return InactiveResult; },
SetPanelOpen: panelOpen => { Calls.Add($"set-panel-open:{panelOpen}"); return PanelOpenInWorld ? AcceptedResult : InactiveResult; },
Selection: Selection,
LocalPlayerGuid: () => LocalPlayerGuid,
CurrentCharacterOption: id => Options.TryGetValue(id, out bool v) && v,
SetCharacterOption: (id, value) => { Options[id] = value; Calls.Add($"set-option:{id}:{value}"); },
ResolveString: ResolveString);
}
// ── Empty/full frame swap (still correct after the FA4 rewrite) ────────
[Fact]
public void Bind_NotInFellowship_ShowsEmptyFrame()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
SocialFellowshipPageController? controller = SocialFellowshipPageController.Bind(root, b.Build());
Assert.NotNull(controller);
Assert.True(UiElement.FindDescendant(root, NotInFellowshipFrameId)!.Visible);
Assert.False(UiElement.FindDescendant(root, InFellowshipFrameId)!.Visible);
}
[Fact]
public void Bind_InFellowship_ShowsPopulatedFrame()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
SocialFellowshipPageController.Bind(root, b.Build());
Assert.False(UiElement.FindDescendant(root, NotInFellowshipFrameId)!.Visible);
Assert.True(UiElement.FindDescendant(root, InFellowshipFrameId)!.Visible);
}
// ── Roster building ──────────────────────────────────────────────────
[Fact]
public void Tick_BuildsOneRowPerMember_WithNameLevelAndVitals()
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot
{
IsInFellowship = true,
Revision = 1,
ShareXp = true,
EvenXpSplit = true,
MemberCount = 1,
},
Members =
[
new RuntimeFellowMemberSnapshot(
Guid: 0x50000001u, Name: "Alice", Level: 12,
MaxHealth: 100, MaxStamina: 80, MaxMana: 60,
CurrentHealth: 55, CurrentStamina: 80, CurrentMana: 10,
ShareLoot: true),
],
};
SocialFellowshipPageController.Bind(root, b.Build());
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
UiText name = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowNameTextId));
Assert.Equal("Alice", name.LinesProvider().Single().Text);
UiText stats = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowStatsTextId));
// 1-member even split -> 1.0 -> "100%" (lane B §7.2 table index 0).
Assert.Equal("12 100%", stats.LinesProvider().Single().Text);
var health = Assert.IsType<UiMeter>(UiElement.FindDescendant(row, RowHealthMeterId));
Assert.Equal(0.55f, health.Fill()!.Value, 3);
Assert.Equal("55/100", health.Label());
}
[Fact]
public void Tick_SameMemberSet_UpdatesRowsInPlace_NoRebuild()
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var member = new RuntimeFellowMemberSnapshot(
0x50000001u, "Alice", 12, 100, 80, 60, 55, 80, 10, false);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
Members = [member],
};
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
UiElement firstRow = Assert.Single(listBox.ViewportForTest!.Children);
// A vitals-only change: SAME guid, new health, revision bumped (the
// exact shape ApplyUpdateFellow produces on a 0x02C0 vitals tick).
b.Snapshot = b.Snapshot with { Revision = 2 };
b.Members = [member with { CurrentHealth = 10 }];
controller.Tick();
UiElement onlyRow = Assert.Single(listBox.ViewportForTest!.Children);
Assert.Same(firstRow, onlyRow); // NOT rebuilt — same row instance
var health = Assert.IsType<UiMeter>(UiElement.FindDescendant(onlyRow, RowHealthMeterId));
Assert.Equal(0.10f, health.Fill()!.Value, 3);
}
/// <summary>
/// Fix-round SF-5: RENAMED from
/// <c>..._ButPreservesScrollPosition</c> — with only one short row this
/// test cannot observe a NONZERO preserved offset (its own prior
/// comment admitted as much), so the "preserves scroll position" half
/// of the old name was aspirational, not verified here. The actual
/// shrink/clamp math IS covered, correctly, at the widget level
/// (<c>UiTemplateListBoxFlushPreservingScrollTests.FlushPreservingScroll_ClampsToTheNewShorterContent</c>).
/// This test's real job — proven below — is that a genuine roster
/// CHANGE (Bob joins) goes through <see cref="UiTemplateListBox.FlushPreservingScroll"/>
/// (not the scroll-resetting <see cref="UiTemplateListBox.Flush"/>) and
/// produces the right row count.
/// </summary>
[Fact]
public void Tick_MembershipChange_RebuildsRoster()
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
Members = [alice],
};
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
listBox.ViewportForTest!.ApplyAnchor(listBox.Width, listBox.Height);
listBox.ViewportForTest!.LayoutScrollableChildren();
listBox.Scroll.SetScrollY(0); // only one short row — nothing to scroll, but exercise the path
var bob = new RuntimeFellowMemberSnapshot(0x50000002u, "Bob", 10, 90, 70, 50, 90, 70, 50, false);
b.Snapshot = b.Snapshot with { Revision = 2, MemberCount = 2 };
b.Members = [alice, bob];
controller.Tick();
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
}
/// <summary>Fix-round SF-5: the shrink counterpart of the join test
/// above — a member LEAVING must also rebuild (not silently desync),
/// and a departed fellow's panel selection must clear rather than stay
/// stale (so a subsequent Dismiss/Leader click can't re-target a guid
/// that is no longer on the roster).</summary>
[Fact]
public void Tick_MemberLeaves_ShrinksRoster_AndClearsSelectionIfTheyWereSelected()
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
var bob = new RuntimeFellowMemberSnapshot(0x50000002u, "Bob", 10, 90, 70, 50, 90, 70, 50, false);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot
{
IsInFellowship = true, Revision = 1, MemberCount = 2, LeaderGuid = 0x50000001u,
},
Members = [alice, bob],
LocalPlayerGuid = 0x50000001u, // Alice, the leader
};
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
UiElement bobRow = listBox.ViewportForTest!.Children[1];
UiText bobName = Assert.IsType<UiText>(UiElement.FindDescendant(bobRow, RowNameTextId));
bobName.OnClick!(); // selects Bob
((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!();
Assert.Contains("dismiss:50000002", b.Calls);
b.Calls.Clear();
// Bob leaves (independently of the click above — e.g. he quit).
b.Snapshot = b.Snapshot with { Revision = 2, MemberCount = 1 };
b.Members = [alice];
controller.Tick();
Assert.Single(listBox.ViewportForTest!.Children);
// The stale selection must be gone — Dismiss must not re-send
// Bob's guid.
((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!();
Assert.DoesNotContain(b.Calls, c => c.StartsWith("dismiss:"));
}
/// <summary>Fix-round SF-2: a permanently-unbuildable row template must
/// be attempted once, at the real membership change, not re-attempted
/// (a full DAT-locked <see cref="UiTemplateListBox.FlushPreservingScroll"/>
/// rebuild) on every subsequent pure-vitals revision bump. Without the
/// fix, comparing against <c>_rows.Count</c> (which stays 0 forever
/// here) makes every Tick see a "membership change" that never actually
/// happened.</summary>
[Fact]
public void Tick_RowTemplatePermanentlyFailsToBuild_DoesNotRetryOnEveryVitalsTick()
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
int resolverCalls = 0;
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
Members = [member],
TemplateResolver = (_, _) => { resolverCalls++; return null; }, // permanently unbuildable
};
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
Assert.Empty(listBox.ViewportForTest?.Children ?? []);
int callsAfterFirstAttempt = resolverCalls;
Assert.True(callsAfterFirstAttempt >= 1);
// Two pure vitals ticks: SAME member set, revision bumps (the exact
// shape a 0x02C0 vitals refresh produces).
b.Snapshot = b.Snapshot with { Revision = 2 };
controller.Tick();
b.Snapshot = b.Snapshot with { Revision = 3 };
controller.Tick();
Assert.Equal(callsAfterFirstAttempt, resolverCalls);
}
/// <summary>Fix-round SF-3: Recruit's "already a fellow" check must read
/// TRUE membership, not which rows happen to have finished building —
/// otherwise a permanently-unbuildable row lets Recruit light up for
/// someone who IS already a fellow.</summary>
[Fact]
public void RecruitButton_TargetIsAnExistingFellowWhoseRowFailedToBuild_StaysDisabled()
{
UiElement root = BuildPageRoot(out _, out _);
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World); // targeting the (already-a-fellow) guid
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 1u },
Members = [member],
LocalPlayerGuid = 1u,
Selection = selection,
TemplateResolver = (_, _) => null, // Alice's row never builds -- but she IS still a fellow
};
SocialFellowshipPageController.Bind(root, b.Build());
Assert.False(((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).Enabled);
}
// ── MUST-FIX 4: world→panel selection sync ──────────────────────────
[Fact]
public void WorldSelectionOfAFellow_EnablesDismissAndLeader_WithoutClickingTheirRow()
{
UiElement root = BuildPageRoot(out _, out _);
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
var selection = new SelectionState();
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu },
Members = [member],
LocalPlayerGuid = 0xFFu, // leader
Selection = selection,
};
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
Assert.False(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
Assert.False(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled);
// MUST-FIX 4 — selecting Alice in the WORLD (not clicking her panel
// row) must still enable Dismiss/Leader, matching retail's
// gmFellowshipUI::UpdateFellowSelection reverse arm.
selection.Select(0x50000001u, SelectionChangeSource.World);
controller.Tick();
Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
Assert.True(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled);
}
[Fact]
public void WorldSelectionOfANonFellow_DoesNotClearAnExistingPanelSelection()
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
var selection = new SelectionState();
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu },
Members = [member],
LocalPlayerGuid = 0xFFu,
Selection = selection,
};
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
UiText name = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowNameTextId));
name.OnClick!(); // selects Alice via her row
controller.Tick(); // Enabled is refreshed by RefreshButtonStates, which only runs in Tick
Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
// Retail's fallback arm: a world selection that is NOT a fellow
// must not clear the panel's own selection while that fellow is
// still on the roster.
selection.Select(0x99999999u, SelectionChangeSource.World);
controller.Tick();
Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
}
// ── N-0: optimistic Open/Close caption ──────────────────────────────
[Fact]
public void OpenButton_Click_FlipsCaptionImmediately_NotWaitingForTheNextTick()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, IsOpen = false, LeaderGuid = 1u },
LocalPlayerGuid = 1u,
ResolveString = (_, hash) =>
hash == DatStringResolver.ComputeHash("ID_Fellowship_OpenFellowshipButtonText") ? "Open"
: hash == DatStringResolver.ComputeHash("ID_Fellowship_CloseFellowshipButtonText") ? "Close"
: null,
};
SocialFellowshipPageController.Bind(root, b.Build());
var openButton = (UiButton)UiElement.FindDescendant(root, OpenButtonId)!;
Assert.Equal("Open", openButton.Label);
openButton.OnClick!();
// N-0 (fix round): retail's Open button handler pre-toggles its own
// state before the server echo (0x02BE) lands (lane B feature 11)
// -- the caption flips on click, not on the next Tick.
Assert.Equal("Close", openButton.Label);
}
[Fact]
public void Tick_NotInFellowship_ClearsAnyStaleRoster()
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
Members = [member],
};
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
Assert.Single(listBox.ViewportForTest!.Children);
b.Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false };
controller.Tick();
Assert.Empty(listBox.ViewportForTest!.Children);
}
// ── D5 display ───────────────────────────────────────────────────────
[Theory]
[InlineData(false, true, 1, "12 0%")] // ShareXp off -> retail's literal 0.0
[InlineData(true, true, 9, "12 31%")] // even split, 9 fellows -> 0.3111111 -> truncate(31.11) = 31
[InlineData(true, false, 3, "12")] // proportional -> no acdream XP table -> level only, no invented %
// MUST-FIX 1 (fix round): retail TRUNCATES (_ftol2 @0x0048ECC9), it
// does not round. The stored float constants for 6 and 8 fellows are
// 0.44999998807907104 and 0.3499999940395355 (byte-read from the
// PDB-paired binary) -- ×100 = 44.999998.../34.999999..., which
// truncate to 44/34, NOT 45/35 (what MathF.Round -- or even a naive
// single-precision (int)(pct*100f) cast, since 0.45f*100f already
// rounds UP to exactly 45.0f in single precision -- would produce).
[InlineData(true, true, 6, "12 44%")]
[InlineData(true, true, 8, "12 34%")]
public void FormatStatsText_MatchesD5Rules(bool shareXp, bool evenSplit, int memberCount, string expected)
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot
{
IsInFellowship = true, Revision = 1, ShareXp = shareXp,
EvenXpSplit = evenSplit, MemberCount = memberCount,
},
Members = [member],
};
SocialFellowshipPageController.Bind(root, b.Build());
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
UiText stats = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowStatsTextId));
Assert.Equal(expected, stats.LinesProvider().Single().Text);
}
// ── Create flow ──────────────────────────────────────────────────────
[Fact]
public void CreateButton_DisabledWhileNameFieldEmpty_EnabledOnceTyped()
{
UiElement root = BuildPageRoot(out _, out UiField nameField);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
var createButton = (UiButton)UiElement.FindDescendant(root, CreateButtonId)!;
Assert.False(createButton.Enabled);
nameField.SetText("The Wanderers");
controller.Tick();
Assert.True(createButton.Enabled);
nameField.SetText(" ");
controller.Tick();
Assert.False(createButton.Enabled);
}
[Fact]
public void CreateButton_Click_SendsTypedName_AndTheShareXpOptionValue()
{
UiElement root = BuildPageRoot(out _, out UiField nameField);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
b.Options[CharacterOptionId.FellowshipShareXP] = true;
SocialFellowshipPageController.Bind(root, b.Build());
var createButton = (UiButton)UiElement.FindDescendant(root, CreateButtonId)!;
nameField.SetText("The Wanderers");
createButton.OnClick!();
Assert.Contains("create:The Wanderers:True", b.Calls);
}
[Fact]
public void CreateButton_Click_WithEmptyName_DoesNotSend()
{
UiElement root = BuildPageRoot(out _, out UiField nameField);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
SocialFellowshipPageController.Bind(root, b.Build());
var createButton = (UiButton)UiElement.FindDescendant(root, CreateButtonId)!;
createButton.OnClick!();
Assert.DoesNotContain(b.Calls, c => c.StartsWith("create:"));
}
// ── Member action buttons ───────────────────────────────────────────
[Fact]
public void QuitButton_Click_SendsQuit_WithDisbandFalse()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
SocialFellowshipPageController.Bind(root, b.Build());
((UiButton)UiElement.FindDescendant(root, QuitButtonId)!).OnClick!();
Assert.Contains("quit:False", b.Calls);
}
[Fact]
public void DisbandButton_Click_SendsQuit_WithDisbandTrue()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
SocialFellowshipPageController.Bind(root, b.Build());
((UiButton)UiElement.FindDescendant(root, DisbandButtonId)!).OnClick!();
Assert.Contains("quit:True", b.Calls);
}
[Fact]
public void OpenButton_Click_TogglesTheCurrentOpenState()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, IsOpen = false, LeaderGuid = 1u },
LocalPlayerGuid = 1u,
};
SocialFellowshipPageController.Bind(root, b.Build());
((UiButton)UiElement.FindDescendant(root, OpenButtonId)!).OnClick!();
Assert.Contains("set-open:True", b.Calls);
}
[Fact]
public void RecruitButton_Click_SendsTheCurrentWorldSelection()
{
UiElement root = BuildPageRoot(out _, out _);
var selection = new SelectionState();
selection.Select(0x60000001u, SelectionChangeSource.World);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true },
Selection = selection,
};
SocialFellowshipPageController.Bind(root, b.Build());
((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).OnClick!();
Assert.Contains("recruit:60000001", b.Calls);
}
[Fact]
public void DismissAndLeaderButtons_Click_TargetTheSelectedFellow()
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu },
Members = [member],
LocalPlayerGuid = 0xFFu, // leader, so Dismiss/Leader are enabled once a fellow is selected
};
SocialFellowshipPageController.Bind(root, b.Build());
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
UiText name = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowNameTextId));
name.OnClick!(); // selects Alice, per SelectFellow
((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!();
((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).OnClick!();
Assert.Contains("dismiss:50000001", b.Calls);
Assert.Contains("assign-leader:50000001", b.Calls);
Assert.Equal(0x50000001u, b.Selection.SelectedObjectId);
}
// ── Button enable rules (lane B §2.8) ───────────────────────────────
[Fact]
public void ButtonStates_NonLeader_DisbandOpenLeaderDismiss_AreDisabled_QuitStaysEnabled()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, LeaderGuid = 0x1u },
LocalPlayerGuid = 0x2u, // not the leader
};
SocialFellowshipPageController.Bind(root, b.Build());
Assert.True(((UiButton)UiElement.FindDescendant(root, QuitButtonId)!).Enabled);
Assert.False(((UiButton)UiElement.FindDescendant(root, DisbandButtonId)!).Enabled);
Assert.False(((UiButton)UiElement.FindDescendant(root, OpenButtonId)!).Enabled);
Assert.False(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled);
Assert.False(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
}
[Fact]
public void ButtonStates_Leader_DisbandAndOpen_AreEnabled()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, LeaderGuid = 0x1u },
LocalPlayerGuid = 0x1u,
};
SocialFellowshipPageController.Bind(root, b.Build());
Assert.True(((UiButton)UiElement.FindDescendant(root, DisbandButtonId)!).Enabled);
Assert.True(((UiButton)UiElement.FindDescendant(root, OpenButtonId)!).Enabled);
}
[Fact]
public void RecruitButton_DisabledWhenFellowshipIsFull()
{
UiElement root = BuildPageRoot(out _, out _);
var selection = new SelectionState();
selection.Select(0x60000001u, SelectionChangeSource.World);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, MemberCount = 9, LeaderGuid = 1u },
LocalPlayerGuid = 1u,
Selection = selection,
};
SocialFellowshipPageController.Bind(root, b.Build());
Assert.False(((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).Enabled);
}
// ── Checkboxes ───────────────────────────────────────────────────────
[Fact]
public void Checkbox_Click_TogglesAndWritesTheCharacterOption()
{
// 2026-08-13 gate fix: these authored checkboxes carry ToggleBehavior
// (the button SELF-FLIPS Selected at MouseUp), so the handler derives
// the next value from the STORE and the per-tick seeding mirrors it
// back — the old handler read the already-flipped widget state and
// wrote the ORIGINAL value forever (the stuck-checkbox gate report).
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
var shareXp = (UiButton)UiElement.FindDescendant(root, ShareXpCheckboxId)!;
Assert.True(shareXp.SuppressSelfToggle);
Assert.False(shareXp.Selected);
shareXp.OnClick!();
Assert.True(b.Options[CharacterOptionId.FellowshipShareXP]);
controller.Tick(); // the seeding mirrors the store
Assert.True(shareXp.Selected);
shareXp.OnClick!(); // and the toggle works BOTH ways
Assert.False(b.Options[CharacterOptionId.FellowshipShareXP]);
controller.Tick();
Assert.False(shareXp.Selected);
}
[Fact]
public void Checkbox_RefreshesFromLiveState_WhenTheOtherSurfaceWrites()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
var ignore = (UiButton)UiElement.FindDescendant(root, IgnoreCheckboxId)!;
Assert.False(ignore.Selected);
// Simulate the Character tab's OWN checkbox writing the same option.
b.Options[CharacterOptionId.IgnoreFellowshipRequests] = true;
controller.Tick();
Assert.True(ignore.Selected);
}
// ── D4: 0x00A6 panel-open declaration ───────────────────────────────
[Fact]
public void SetPageVisible_SendsPanelOpen_OnlyOnATransition()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
b.Calls.Clear(); // discard any Bind-time noise
controller.SetPageVisible(true);
Assert.Contains("set-panel-open:True", b.Calls);
b.Calls.Clear();
controller.SetPageVisible(true); // no transition -> no second send
Assert.Empty(b.Calls);
controller.SetPageVisible(false);
Assert.Contains("set-panel-open:False", b.Calls);
}
[Fact]
public void SetPageVisible_DoesNotLatch_WhenDeclarationDropped_SoItRetriesInWorld()
{
// MUST-FIX 3 re-fix (FA4 re-review REOPEN): a declaration attempted
// before world entry is dropped (Inactive). The latch must NOT advance
// on a dropped publish — otherwise the in-world retry is deduplicated
// away and the server never learns the panel is open (fellow vitals
// freeze). This is the widget-level root of the reconnect bug.
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder
{
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true },
PanelOpenInWorld = false, // pre-world: SetPanelOpen returns Inactive
};
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
b.Calls.Clear();
controller.SetPageVisible(true);
Assert.Contains("set-panel-open:True", b.Calls); // attempted, dropped
b.Calls.Clear();
// In world now: the SAME visible=true re-attempts (not deduplicated,
// because the dropped attempt never latched) and this time it sticks.
b.PanelOpenInWorld = true;
controller.SetPageVisible(true);
Assert.Contains("set-panel-open:True", b.Calls);
}
}