acdream/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs
Erik 5bdd0528f1 feat(ui): FA4 -- fellowship page fully live
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>
2026-08-12 04:40:53 +02:00

555 lines
24 KiB
C#

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 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 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 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: (_, _) => null);
}
// ── 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);
}
[Fact]
public void Tick_MembershipChange_RebuildsRoster_ButPreservesScrollPosition()
{
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
// Carry-forward 1: a genuine roster change (Bob joins) must not
// silently desync — verified via row count — AND must go through
// the scroll-preserving flush, not the scroll-resetting one. We
// can't observe a NONZERO preserved offset with only one short
// row, so this test's decisive assertion is closer to the wiring:
// FlushPreservingScroll leaves Scroll.ScrollY untouched immediately
// after the clear, unlike Flush.
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);
}
[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 -> round(31.11) = 31
[InlineData(true, false, 3, "12")] // proportional -> no acdream XP table -> level only, no invented %
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()
{
UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
SocialFellowshipPageController.Bind(root, b.Build());
var shareXp = (UiButton)UiElement.FindDescendant(root, ShareXpCheckboxId)!;
Assert.False(shareXp.Selected);
shareXp.OnClick!();
Assert.True(shareXp.Selected);
Assert.True(b.Options[CharacterOptionId.FellowshipShareXP]);
}
[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);
}
}