Campaign FA slice FA3 dual-lens review fix round. Blast MUST-FIX 1: SocialFriendsPageController/SocialSquelchPageController never wired their ListBox's own sibling scrollbar (Friends 0x10000517->0x10000518, Squelch 0x1000053E->0x10000543) — the two lists had NO scroll driver at all (no wheel fallback exists on UiScrollablePanel), making any roster past the visible extent completely unreachable, not just awkward to scroll. Both controllers now wire `scrollbar.Model = listBox.Scroll` scoped to their own page root, exactly like the four existing UiTemplateListBox consumers (Character/Chat/Config/KeyboardConfig). New tests prove the wiring AND that a >panel-height roster is actually reachable through it. Blast SHOULD-FIX 2/3: the Friends/Squelch resolver re-ran LayoutImporter.ImportInfos (a full DAT tree walk) under the shared DAT lock on EVERY row, every revision, even while the panel was closed — RetailUiRuntime.MountSocialPanel's TemplateResolver now caches each row template's ElementInfo the first time it is resolved and never re-Imports for that template id again. SocialPanelController additionally gates the Friends/Squelch Tick-driven rebuild on the panel's own visibility via the (previously unused) IRetainedPanelController OnShown/OnHidden hooks, so no DAT-locked rebuild work runs at all while the panel is closed. SocialFriendsPageController/SocialSquelchPageController also now only advance _lastRevision after every row resolves — a transient resolver miss no longer latches an empty roster until the next server-side change; it retries on the next Tick instead. Mechanism SHOULD-FIX 8: SocialPanelController.Tick() now returns early once disposed, matching every other J-slice teardown discipline (it was being ticked unconditionally forever since RetailUiRuntime never nulls the field). Mechanism SHOULD-FIX 1: IsShowingAllegiance's doc claimed the F3/F4 close-on-second-press semantics were "retail's Toggle-action semantics" — re-derived and confirmed NO retail OnAction consumer exists for either action anywhere in the binary. Relabeled as acdream's own OpenSpellbook-precedent convention; no register row added, following the same no-row precedent OpenSpellbook and every other non-toolbar Toggle panel already sets. Unknown filed as U11 in the panel-structure research doc's §8 table. Tests: 5 new (2 scrollbar-wiring pins, 2 long-roster-reachable-via- scrollbar, 1 hidden-panel-does-not-rebuild/shown-panel-catches-up); 2 existing tests updated for the new visibility gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
460 lines
20 KiB
C#
460 lines
20 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Net.Messages;
|
|
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 SocialPanelController.Callbacks MakeCallbacks(
|
|
List<string>? calls = null,
|
|
RuntimeFellowshipSnapshot fellowship = default,
|
|
RuntimeAllegianceSnapshot allegiance = default,
|
|
FriendsState? friends = null,
|
|
SquelchState? squelch = null)
|
|
{
|
|
calls ??= new List<string>();
|
|
return new SocialPanelController.Callbacks(
|
|
Toggle: () => calls.Add("toggle"),
|
|
FellowshipSnapshot: () => fellowship,
|
|
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);
|
|
}
|
|
}
|