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; /// /// Campaign FA slice FA4: hermetic (no DAT, no live runtime) tests for /// 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). /// 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 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; } /// 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 ). 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 Members = []; public readonly List Calls = []; public readonly Dictionary Options = new(); public SelectionState Selection = new(); public uint LocalPlayerGuid; public Func TemplateResolver = FakeRowResolver; public Func 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(UiElement.FindDescendant(row, RowNameTextId)); Assert.Equal("Alice", name.LinesProvider().Single().Text); UiText stats = Assert.IsType(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(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(UiElement.FindDescendant(onlyRow, RowHealthMeterId)); Assert.Equal(0.10f, health.Fill()!.Value, 3); } /// /// Fix-round SF-5: RENAMED from /// ..._ButPreservesScrollPosition — 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 /// (UiTemplateListBoxFlushPreservingScrollTests.FlushPreservingScroll_ClampsToTheNewShorterContent). /// This test's real job — proven below — is that a genuine roster /// CHANGE (Bob joins) goes through /// (not the scroll-resetting ) and /// produces the right row count. /// [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); } /// 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). [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(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:")); } /// 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 /// rebuild) on every subsequent pure-vitals revision bump. Without the /// fix, comparing against _rows.Count (which stays 0 forever /// here) makes every Tick see a "membership change" that never actually /// happened. [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); } /// 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. [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(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(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(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); } }