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>
645 lines
29 KiB
C#
645 lines
29 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Runtime;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Controller-level tests for <see cref="SocialPanelController"/> against the
|
|
/// committed <c>social_panel_2100006E_1000018F.json</c> fixture (Campaign FA
|
|
/// slice FA3) — the SAME <c>LayoutImporter.ImportInfos(dats, 0x2100006Eu,
|
|
/// 0x1000018Fu)</c> catalog import <see cref="RetailUiRuntime.MountSocialPanel"/>
|
|
/// performs against real DATs. No DAT access, no live runtime — dat-free per
|
|
/// the established <see cref="FixtureLoader"/> pattern.
|
|
/// </summary>
|
|
public sealed class SocialPanelControllerTests
|
|
{
|
|
private static readonly RuntimeCommandResult InactiveResult =
|
|
new(RuntimeCommandStatus.Inactive, default);
|
|
private static readonly RuntimeCommandResult AcceptedResult =
|
|
new(RuntimeCommandStatus.Accepted, default);
|
|
|
|
/// <summary>Campaign FA slice FA4: the Fellowship page's full read/write
|
|
/// seam. Every command records its call (by name) into
|
|
/// <paramref name="calls"/> and returns <see cref="InactiveResult"/> —
|
|
/// hermetic tests here exercise WIRING (does clicking X call the right
|
|
/// delegate with the right argument), not live command semantics
|
|
/// (covered by <c>RuntimeFellowshipStateTests</c>/
|
|
/// <c>DirectGameRuntimeCommandAdapterTests</c>).</summary>
|
|
private static SocialFellowshipPageController.Bindings MakeFellowshipBindings(
|
|
List<string>? calls = null,
|
|
RuntimeFellowshipSnapshot snapshot = default,
|
|
IEnumerable<RuntimeFellowMemberSnapshot>? members = null,
|
|
SelectionState? selection = null,
|
|
uint localPlayerGuid = 0u,
|
|
Func<CharacterOptionId, bool>? currentCharacterOption = null,
|
|
Func<uint, uint, UiElement?>? templateResolver = null,
|
|
Func<uint, uint, string?>? resolveString = null,
|
|
Func<bool>? panelOpenInWorld = null)
|
|
{
|
|
calls ??= new List<string>();
|
|
// SetPanelOpen (0x00A6) is world-gated in production; only its result
|
|
// status feeds the MUST-FIX 3 latch. Default in-world (Accepted) so
|
|
// the D4-conjunction tests model production; a reconnect test flips it.
|
|
Func<bool> inWorld = panelOpenInWorld ?? (static () => true);
|
|
return new SocialFellowshipPageController.Bindings(
|
|
Snapshot: () => snapshot,
|
|
Members: () => members ?? [],
|
|
TemplateResolver: templateResolver ?? FakeRowTemplateResolver,
|
|
Create: (name, shareXp) => { calls.Add($"fellowship-create:{name}:{shareXp}"); return InactiveResult; },
|
|
Recruit: guid => { calls.Add($"fellowship-recruit:{guid:X8}"); return InactiveResult; },
|
|
Dismiss: guid => { calls.Add($"fellowship-dismiss:{guid:X8}"); return InactiveResult; },
|
|
Quit: disband => { calls.Add($"fellowship-quit:{disband}"); return InactiveResult; },
|
|
AssignLeader: guid => { calls.Add($"fellowship-assign-leader:{guid:X8}"); return InactiveResult; },
|
|
SetOpen: isOpen => { calls.Add($"fellowship-set-open:{isOpen}"); return InactiveResult; },
|
|
SetPanelOpen: panelOpen => { calls.Add($"fellowship-set-panel-open:{panelOpen}"); return inWorld() ? AcceptedResult : InactiveResult; },
|
|
Selection: selection ?? new SelectionState(),
|
|
LocalPlayerGuid: () => localPlayerGuid,
|
|
CurrentCharacterOption: currentCharacterOption ?? (_ => false),
|
|
SetCharacterOption: (id, value) => calls.Add($"fellowship-set-option:{id}:{value}"),
|
|
ResolveString: resolveString ?? ((_, _) => null));
|
|
}
|
|
|
|
private static SocialPanelController.Callbacks MakeCallbacks(
|
|
List<string>? calls = null,
|
|
RuntimeFellowshipSnapshot fellowship = default,
|
|
RuntimeAllegianceSnapshot allegiance = default,
|
|
FriendsState? friends = null,
|
|
SquelchState? squelch = null,
|
|
Func<bool>? panelOpenInWorld = null)
|
|
{
|
|
calls ??= new List<string>();
|
|
return new SocialPanelController.Callbacks(
|
|
Toggle: () => calls.Add("toggle"),
|
|
Fellowship: MakeFellowshipBindings(calls, fellowship, panelOpenInWorld: panelOpenInWorld),
|
|
AllegianceSnapshot: () => allegiance,
|
|
Friends: friends ?? new FriendsState(),
|
|
Squelch: squelch ?? new SquelchState(),
|
|
TemplateResolver: FakeRowTemplateResolver);
|
|
}
|
|
|
|
/// <summary>Mirrors the real Friends/Squelch row templates' own two-deep
|
|
/// nested-text shape AND their own authored extent (270x24, mechanism
|
|
/// SF-9's live dump) — FA3 live-mount probe — so
|
|
/// <see cref="SocialPanelRowText.FindDeepest"/> has something realistic
|
|
/// to resolve and <see cref="UiTemplateListBox.ContentHeight"/> grows
|
|
/// realistically per row (blast MF-1's long-roster scroll tests),
|
|
/// without needing the templates' own (uncommitted, separate
|
|
/// LayoutDesc) fixtures.</summary>
|
|
private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId)
|
|
{
|
|
var outer = new UiText { Width = 270f, Height = 24f };
|
|
var inner = new UiText();
|
|
outer.AddChild(inner);
|
|
return outer;
|
|
}
|
|
|
|
// ── Mount conformance ────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Bind_RootBuildsAsUiTabPanel()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
|
|
Assert.IsType<UiTabPanel>(layout.Root);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the authored <c>0x2E</c> tab table exactly as read from the live
|
|
/// DATs (FA3 live-mount probe) — CORRECTING the coordinator addendum's
|
|
/// x-order guess (see <see cref="SocialPanelController"/>'s own class
|
|
/// doc). Four entries; Allegiance is the sole default.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TabTable_MatchesLiveDatPairing_AllegianceIsDefault()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
var tabs = Assert.IsType<UiTabPanel>(layout.Root);
|
|
|
|
Assert.Equal(4, tabs.Tabs.Count);
|
|
Assert.Contains(tabs.Tabs, e =>
|
|
e.ButtonElementId == 0x1000028Cu && e.PageElementId == 0x10000291u && e.IsDefault);
|
|
Assert.Contains(tabs.Tabs, e =>
|
|
e.ButtonElementId == 0x1000028Eu && e.PageElementId == 0x10000292u && !e.IsDefault);
|
|
Assert.Contains(tabs.Tabs, e =>
|
|
e.ButtonElementId == 0x10000512u && e.PageElementId == 0x10000513u && !e.IsDefault);
|
|
Assert.Contains(tabs.Tabs, e =>
|
|
e.ButtonElementId == 0x1000053Bu && e.PageElementId == 0x1000054Au && !e.IsDefault);
|
|
Assert.Single(tabs.Tabs, e => e.IsDefault);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_Succeeds_AndActivateTabs_SelectsTheAuthoredDefault()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
|
|
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
|
|
|
|
Assert.NotNull(controller);
|
|
controller!.ActivateTabs();
|
|
Assert.Empty(controller.TabPanel.UnresolvedEntries);
|
|
Assert.True(controller.IsShowingAllegiance);
|
|
Assert.False(controller.IsShowingFellowship);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShowFellowship_SwitchesTheActiveTab()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
|
|
Assert.NotNull(controller);
|
|
controller!.ActivateTabs();
|
|
|
|
controller.ShowFellowship();
|
|
|
|
Assert.True(controller.IsShowingFellowship);
|
|
Assert.False(controller.IsShowingAllegiance);
|
|
}
|
|
|
|
[Fact]
|
|
public void CloseButton_InvokesToggleCallback()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
var calls = new List<string>();
|
|
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks(calls));
|
|
Assert.NotNull(controller);
|
|
|
|
var close = Assert.IsType<UiButton>(layout.FindElement(0x10000290u));
|
|
close.OnEvent(new UiEvent(0, close, UiEventType.Click));
|
|
|
|
Assert.Contains("toggle", calls);
|
|
}
|
|
|
|
// ── Fellowship empty state (item 4) ─────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Fellowship_NoFellowship_ShowsNotInFellowshipFrame()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout,
|
|
MakeCallbacks(fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = false }));
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement notIn = UiElement.FindDescendant(controller!.TabPanel, 0x1000026Bu)!;
|
|
UiElement inFellowship = UiElement.FindDescendant(controller.TabPanel, 0x10000275u)!;
|
|
Assert.True(notIn.Visible);
|
|
Assert.False(inFellowship.Visible);
|
|
}
|
|
|
|
[Fact]
|
|
public void Fellowship_HasFellowship_ShowsInFellowshipFrame()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout,
|
|
MakeCallbacks(fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement notIn = UiElement.FindDescendant(controller!.TabPanel, 0x1000026Bu)!;
|
|
UiElement inFellowship = UiElement.FindDescendant(controller.TabPanel, 0x10000275u)!;
|
|
Assert.False(notIn.Visible);
|
|
Assert.True(inFellowship.Visible);
|
|
}
|
|
|
|
// ── Allegiance empty state (item 4) ─────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Allegiance_NoProfile_HidesBlocksAndBlanksNames()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout,
|
|
MakeCallbacks(allegiance: new RuntimeAllegianceSnapshot { HasProfile = false }));
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
|
|
UiElement patronField = UiElement.FindDescendant(controller.TabPanel, 0x1000025Au)!;
|
|
Assert.False(monarchField.Visible);
|
|
Assert.False(patronField.Visible);
|
|
|
|
var monarchName = Assert.IsType<UiText>(
|
|
UiElement.FindDescendant(monarchField, 0x10000257u));
|
|
var patronName = Assert.IsType<UiText>(
|
|
UiElement.FindDescendant(patronField, 0x1000025Cu));
|
|
Assert.Equal(" ", Assert.Single(monarchName.LinesProvider()).Text);
|
|
Assert.Equal(" ", Assert.Single(patronName.LinesProvider()).Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Allegiance_HasProfile_ShowsBothBlocks()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout,
|
|
MakeCallbacks(allegiance: new RuntimeAllegianceSnapshot { HasProfile = true }));
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
|
|
UiElement patronField = UiElement.FindDescendant(controller.TabPanel, 0x1000025Au)!;
|
|
Assert.True(monarchField.Visible);
|
|
Assert.True(patronField.Visible);
|
|
}
|
|
|
|
// ── Friends/Squelch read-only lists (item 5) ────────────────────────────
|
|
|
|
[Fact]
|
|
public void Friends_PopulatesOneRowPerEntry()
|
|
{
|
|
var friends = new FriendsState();
|
|
friends.Apply(new FriendsUpdate(
|
|
FriendsUpdateType.Full,
|
|
new List<FriendEntry>
|
|
{
|
|
new(1u, "Alice", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()),
|
|
new(2u, "Bob", false, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()),
|
|
}));
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout, MakeCallbacks(friends: friends));
|
|
Assert.NotNull(controller);
|
|
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
UiElement.FindDescendant(controller!.TabPanel, 0x10000513u) is { } friendsPage
|
|
? UiElement.FindDescendant(friendsPage, 0x10000517u)
|
|
: null);
|
|
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Friends_ReactsToRevisionChange_OnTick()
|
|
{
|
|
var friends = new FriendsState();
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout, MakeCallbacks(friends: friends));
|
|
Assert.NotNull(controller);
|
|
|
|
// Fix-round blast SF-2: Friends/Squelch rebuild is gated on the
|
|
// panel's own visibility — simulate the window being shown, the
|
|
// same IRetainedPanelController.OnShown() the window manager calls
|
|
// in production.
|
|
controller!.OnShown();
|
|
|
|
UiElement friendsPage = UiElement.FindDescendant(controller.TabPanel, 0x10000513u)!;
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
UiElement.FindDescendant(friendsPage, 0x10000517u));
|
|
Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0);
|
|
|
|
friends.Apply(new FriendsUpdate(
|
|
FriendsUpdateType.Add,
|
|
new List<FriendEntry>
|
|
{
|
|
new(1u, "Alice", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()),
|
|
}));
|
|
controller.Tick();
|
|
|
|
Assert.Single(listBox.ViewportForTest!.Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void Squelch_PopulatesCharacterAndAccountRows()
|
|
{
|
|
var squelch = new SquelchState();
|
|
squelch.Replace(new SquelchDatabase(
|
|
new Dictionary<string, uint>(System.StringComparer.OrdinalIgnoreCase) { ["BadAccount"] = 1u },
|
|
new Dictionary<uint, SquelchInfo>
|
|
{
|
|
[7u] = new SquelchInfo("Grief", false, new HashSet<uint>()),
|
|
},
|
|
new SquelchInfo(string.Empty, false, new HashSet<uint>())));
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout, MakeCallbacks(squelch: squelch));
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!;
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
UiElement.FindDescendant(squelchPage, 0x1000053Eu));
|
|
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
|
|
}
|
|
|
|
// ── D1: Friends/Squelch action buttons are honest INERT ────────────────
|
|
|
|
/// <summary>
|
|
/// Pins the INERT contract register row AD-79 enumerates. Fix-round
|
|
/// mechanism SF-5: AD-79 names SEVEN controls (three Friends buttons,
|
|
/// the Friends "Appear Offline"-shaped checkbox, three Squelch
|
|
/// buttons) but this test previously only covered the six buttons —
|
|
/// <c>0x1000052C</c> builds through <see cref="DatWidgetFactory.BuildCheckbox"/>
|
|
/// as a <see cref="UiButton"/> too, so it slots into the same loop.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
|
|
Assert.NotNull(controller);
|
|
|
|
foreach (uint buttonId in new[] { 0x10000514u, 0x10000515u, 0x10000516u, 0x1000052Cu })
|
|
{
|
|
var button = Assert.IsType<UiButton>(
|
|
UiElement.FindDescendant(controller!.TabPanel, buttonId));
|
|
Assert.Null(button.OnClick);
|
|
}
|
|
foreach (uint buttonId in new[] { 0x10000547u, 0x1000054Bu, 0x1000054Cu })
|
|
{
|
|
var button = Assert.IsType<UiButton>(
|
|
UiElement.FindDescendant(controller!.TabPanel, buttonId));
|
|
Assert.Null(button.OnClick);
|
|
}
|
|
}
|
|
|
|
// ── Scrollbar wiring (blast MF-1) ───────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Friends_ScrollbarModel_IsWiredToListBoxScroll()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
UiElement.FindDescendant(friendsPage, 0x10000517u));
|
|
var scrollbar = Assert.IsType<UiScrollbar>(
|
|
UiElement.FindDescendant(friendsPage, 0x10000518u));
|
|
|
|
Assert.Same(listBox.Scroll, scrollbar.Model);
|
|
}
|
|
|
|
[Fact]
|
|
public void Squelch_ScrollbarModel_IsWiredToListBoxScroll()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!;
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
UiElement.FindDescendant(squelchPage, 0x1000053Eu));
|
|
var scrollbar = Assert.IsType<UiScrollbar>(
|
|
UiElement.FindDescendant(squelchPage, 0x10000543u));
|
|
|
|
Assert.Same(listBox.Scroll, scrollbar.Model);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Blast MF-1: proves a roster taller than the panel is actually
|
|
/// REACHABLE through the scrollbar, not merely wired-but-unused. 40 rows
|
|
/// at the real Friends row template's own height (270x24, mechanism
|
|
/// SF-9's live dump) comfortably exceed the ListBox's authored extent
|
|
/// (270x400) — this roster genuinely needs scrolling.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Friends_LongRoster_IsReachableViaScrollbar()
|
|
{
|
|
var friends = new FriendsState();
|
|
var entries = new List<FriendEntry>();
|
|
for (uint i = 0; i < 40; i++)
|
|
entries.Add(new(i, $"Friend{i}", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()));
|
|
friends.Apply(new FriendsUpdate(FriendsUpdateType.Full, entries));
|
|
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout, MakeCallbacks(friends: friends));
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
UiElement.FindDescendant(friendsPage, 0x10000517u));
|
|
var scrollbar = Assert.IsType<UiScrollbar>(
|
|
UiElement.FindDescendant(friendsPage, 0x10000518u));
|
|
|
|
Assert.True(
|
|
listBox.ContentHeight > (int)listBox.Height,
|
|
$"40 rows (contentHeight={listBox.ContentHeight}) should exceed the box's own height ({listBox.Height})");
|
|
|
|
// The scrollbar's Model IS the ListBox's own Scroll — moving it
|
|
// moves the SAME state that positions the rows
|
|
// (UiScrollablePanel.LayoutScrollableChildren, driven by the real
|
|
// render loop; simulated directly here since this is a dat-free
|
|
// controller test with no Draw pass).
|
|
scrollbar.Model!.SetExtents(listBox.ContentHeight, (int)listBox.Height);
|
|
Assert.True(scrollbar.Model.HasOverflow);
|
|
int before = scrollbar.Model.ScrollY;
|
|
scrollbar.Model.ScrollByLines(4);
|
|
Assert.True(scrollbar.Model.ScrollY > before);
|
|
}
|
|
|
|
/// <summary>Squelch counterpart of <see cref="Friends_LongRoster_IsReachableViaScrollbar"/>
|
|
/// (270x430 authored extent).</summary>
|
|
[Fact]
|
|
public void Squelch_LongRoster_IsReachableViaScrollbar()
|
|
{
|
|
var characters = new Dictionary<uint, SquelchInfo>();
|
|
for (uint i = 0; i < 40; i++)
|
|
characters[i] = new SquelchInfo($"Grief{i}", false, new HashSet<uint>());
|
|
var squelch = new SquelchState();
|
|
squelch.Replace(new SquelchDatabase(
|
|
new Dictionary<string, uint>(System.StringComparer.OrdinalIgnoreCase),
|
|
characters,
|
|
new SquelchInfo(string.Empty, false, new HashSet<uint>())));
|
|
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout, MakeCallbacks(squelch: squelch));
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!;
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
UiElement.FindDescendant(squelchPage, 0x1000053Eu));
|
|
var scrollbar = Assert.IsType<UiScrollbar>(
|
|
UiElement.FindDescendant(squelchPage, 0x10000543u));
|
|
|
|
Assert.True(
|
|
listBox.ContentHeight > (int)listBox.Height,
|
|
$"40 rows (contentHeight={listBox.ContentHeight}) should exceed the box's own height ({listBox.Height})");
|
|
|
|
scrollbar.Model!.SetExtents(listBox.ContentHeight, (int)listBox.Height);
|
|
Assert.True(scrollbar.Model.HasOverflow);
|
|
int before = scrollbar.Model.ScrollY;
|
|
scrollbar.Model.ScrollByLines(4);
|
|
Assert.True(scrollbar.Model.ScrollY > before);
|
|
}
|
|
|
|
// ── Rebuild discipline (blast SF-2/SF-3) ────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Blast SF-2: the panel's Friends/Squelch rebuild must not run while
|
|
/// the window is closed. <see cref="SocialPanelController"/> starts
|
|
/// with no visibility notification (matches production: the window
|
|
/// mounts <c>Visible = false</c> and nothing calls <c>OnShown</c> until
|
|
/// F3/F4), so a revision bump with no <c>OnShown</c> call must NOT
|
|
/// rebuild the list; the SAME bump picked up on the next <c>Tick()</c>
|
|
/// after <c>OnShown()</c> proves the rebuild is deferred, not lost.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Friends_RevisionBumpWhileHidden_DoesNotRebuild_ButRebuildsOnShow()
|
|
{
|
|
var friends = new FriendsState();
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout, MakeCallbacks(friends: friends));
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
UiElement.FindDescendant(friendsPage, 0x10000517u));
|
|
|
|
friends.Apply(new FriendsUpdate(
|
|
FriendsUpdateType.Add,
|
|
new List<FriendEntry> { new(1u, "Alice", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()) }));
|
|
|
|
// Panel never shown — Tick() must not rebuild.
|
|
controller!.Tick();
|
|
Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0);
|
|
|
|
// Showing the panel and ticking again picks up the accumulated bump.
|
|
controller.OnShown();
|
|
controller.Tick();
|
|
Assert.Single(listBox.ViewportForTest!.Children);
|
|
}
|
|
|
|
// ── D4 conjunction + reconnect re-arm (mechanism SF-5, MUST-FIX 3) ─────
|
|
|
|
/// <summary>
|
|
/// Fix-round mechanism SF-5 — the only pre-existing D4 test
|
|
/// (<c>SocialFellowshipPageControllerTests.SetPageVisible_SendsPanelOpen_OnlyOnATransition</c>)
|
|
/// exercises the PAGE controller's edge-trigger directly; nothing
|
|
/// exercised the actual conjunction this slice is named for —
|
|
/// <c>SocialPanelController</c>'s own "window shown AND Fellowship
|
|
/// active" logic, its <c>ActivePageChanged</c> subscription, or
|
|
/// <see cref="SocialPanelController.OnShown"/>/<see cref="SocialPanelController.OnHidden"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FellowshipPageVisible_Declares0x00A6_OnlyWhenWindowShownANDFellowshipActive()
|
|
{
|
|
var calls = new List<string>();
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout,
|
|
MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
|
|
Assert.NotNull(controller);
|
|
controller!.ActivateTabs(); // authored default tab is Allegiance, not Fellowship
|
|
calls.Clear();
|
|
|
|
// Window not shown yet, and not on the Fellowship tab -> no send.
|
|
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
|
|
|
|
// Window shown, but STILL on Allegiance -> conjunction false, still no send.
|
|
controller.OnShown();
|
|
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
|
|
|
|
// Switch to Fellowship while shown -> conjunction true -> declares.
|
|
controller.ShowFellowship();
|
|
Assert.Contains("fellowship-set-panel-open:True", calls);
|
|
|
|
calls.Clear();
|
|
controller.OnHidden(); // window closes while on Fellowship -> declares false
|
|
Assert.Contains("fellowship-set-panel-open:False", calls);
|
|
}
|
|
|
|
/// <summary>
|
|
/// MUST-FIX 3 (FA4 fix round) + its re-review REOPEN re-fix: a reconnect
|
|
/// while the Fellowship page stays open must re-declare <c>0x00A6</c> on
|
|
/// the fresh session — but ONLY AFTER world entry. The generation reset
|
|
/// (<see cref="SocialPanelController.ResetSessionDeclaration"/>) runs
|
|
/// BEFORE the new session is in world, where <c>SetPanelOpen</c> is
|
|
/// dropped (Inactive); it must only clear the latch. The re-declaration
|
|
/// is <see cref="SocialPanelController.RedeclareAfterWorldEntry"/>, wired
|
|
/// to the post-world <c>EnteredWorld</c> seam. The original fix declared
|
|
/// from the pre-world reset — the fake recorded a send the real
|
|
/// world-gated command would have dropped, so the roster stayed frozen.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset()
|
|
{
|
|
var calls = new List<string>();
|
|
bool inWorld = true;
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout,
|
|
MakeCallbacks(
|
|
calls,
|
|
fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true },
|
|
panelOpenInWorld: () => inWorld));
|
|
Assert.NotNull(controller);
|
|
controller!.ActivateTabs();
|
|
controller.OnShown();
|
|
controller.ShowFellowship();
|
|
Assert.Contains("fellowship-set-panel-open:True", calls); // declared in world
|
|
calls.Clear();
|
|
|
|
// Reconnect: the generation reset runs BEFORE the new session is in
|
|
// world. The pre-world reset must leave NO published 0x00A6 (the
|
|
// REOPEN bug latched a dropped send here and never retried).
|
|
inWorld = false;
|
|
controller.ResetSessionDeclaration();
|
|
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
|
|
|
|
// World entry: the post-world seam re-declares, now Accepted.
|
|
inWorld = true;
|
|
controller.RedeclareAfterWorldEntry();
|
|
Assert.Contains("fellowship-set-panel-open:True", calls);
|
|
}
|
|
|
|
/// <summary>MUST-FIX 3's counterpart: a reconnect while the panel is
|
|
/// closed (or on a different tab) must NOT declare <c>0x00A6</c> true,
|
|
/// even after world entry.</summary>
|
|
[Fact]
|
|
public void Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen()
|
|
{
|
|
var calls = new List<string>();
|
|
bool inWorld = true;
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout,
|
|
MakeCallbacks(
|
|
calls,
|
|
fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true },
|
|
panelOpenInWorld: () => inWorld));
|
|
Assert.NotNull(controller);
|
|
controller!.ActivateTabs(); // default tab: Allegiance (Fellowship not active)
|
|
controller.OnShown();
|
|
calls.Clear();
|
|
|
|
inWorld = false;
|
|
controller.ResetSessionDeclaration();
|
|
inWorld = true;
|
|
controller.RedeclareAfterWorldEntry();
|
|
|
|
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
|
|
}
|
|
|
|
// ── SF-4: Dispose unsubscribes ActivePageChanged ────────────────────────
|
|
|
|
[Fact]
|
|
public void Dispose_UnsubscribesActivePageChanged_TabSwitchAfterDisposeSendsNoCommand()
|
|
{
|
|
var calls = new List<string>();
|
|
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
|
|
SocialPanelController? controller = SocialPanelController.Bind(
|
|
layout,
|
|
MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
|
|
Assert.NotNull(controller);
|
|
controller!.ActivateTabs();
|
|
controller.OnShown();
|
|
calls.Clear();
|
|
|
|
controller.Dispose();
|
|
|
|
// A tab switch after Dispose must not reach UpdateFellowshipPageVisibility
|
|
// (and therefore must not issue a Runtime command) -- the whole
|
|
// point of unsubscribing in Dispose rather than relying on Tick's
|
|
// own _disposed guard, since this path never goes through Tick.
|
|
controller.ShowFellowship();
|
|
|
|
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
|
|
}
|
|
}
|