acdream/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs
Erik 04161defd8 fix(ui): FA4 re-review REOPEN — re-declare 0x00A6 from the post-world seam, not the pre-world reset
The FA4 fix round's MUST-FIX 3 placed the 0x00A6 reconnect re-arm at the
wrong lifecycle point (re-review 8bbceff5): ResetSessionTransientUi runs
via the SessionDialogs reset stage BEFORE _inWorld=true, so SetPanelOpen
(world-gated, Validate requireWorld:true) returned Inactive and published
nothing — yet _pageVisible was latched true anyway, so no later hook
re-declared and fellow vitals stayed frozen for the whole new session.
The unit test passed only because the fake recorded unconditionally.

Two-part fix, both retail-faithful mechanisms not suppressions:
- SocialFellowshipPageController.SetPageVisible advances the edge-trigger
  latch ONLY when the declaration is Accepted (published), so a dropped
  pre-world send leaves the latch clear and a later attempt retries.
- ResetSessionDeclaration (pre-world) now ONLY clears the latch; the new
  RedeclareAfterWorldEntry fires from the LiveSession EnteredWorld seam
  (wired via RestoreLayout, idempotent if a persisted layout already
  re-showed the page) so a still-open Fellowship page re-declares 0x00A6
  in world and vitals resume.

Regression pins that actually catch it (the prior test could not):
- SetPageVisible_DoesNotLatch_WhenDeclarationDropped_SoItRetriesInWorld
  (widget-level root, world-gated fake);
- Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset +
  Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen (panel-level,
  world-gated). RED-verified: reintroducing the pre-world declaration
  fails the reconnect test.

Full Release suite: 13,286 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:59:16 +02:00

795 lines
36 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()
{
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);
}
[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);
}
}