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>
This commit is contained in:
parent
357d203202
commit
5bdd0528f1
16 changed files with 1941 additions and 114 deletions
|
|
@ -698,14 +698,19 @@ public sealed class CharacterOptionsPageControllerTests
|
|||
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
|
||||
|
||||
/// <summary>
|
||||
/// The exact 35 <see cref="CharacterOptionId"/> values this slice dims
|
||||
/// The exact 31 <see cref="CharacterOptionId"/> values this slice dims
|
||||
/// (<see cref="RowSpec.StoreOnly"/> == true), transcribed independently
|
||||
/// of <c>CharacterOptionsPageController.Groups</c> from the derivation
|
||||
/// table in that class's own doc comment. <b>Wiring a future consumer
|
||||
/// for any of these means removing it from this literal set AND
|
||||
/// flipping its <c>Groups</c> table entry from <c>StoreOnly</c> to
|
||||
/// <c>Live</c> consciously — leaving either one stale fails this
|
||||
/// test.</b>
|
||||
/// test.</b> Campaign FA slice FA4, D7 removed four ids from this set —
|
||||
/// <c>IgnoreFellowshipRequests</c>/<c>FellowshipAutoAcceptRequests</c>
|
||||
/// (the fellowship-invite auto-decline/auto-accept, D6) and
|
||||
/// <c>FellowshipShareXP</c>/<c>FellowshipShareLoot</c> (the fellowship
|
||||
/// page's Create flow / D5 display / second checkbox surface) all
|
||||
/// gained real acdream-side consumers.
|
||||
/// </summary>
|
||||
private static readonly HashSet<CharacterOptionId> ExpectedStoreOnlyIds =
|
||||
[
|
||||
|
|
@ -724,13 +729,9 @@ public sealed class CharacterOptionsPageControllerTests
|
|||
CharacterOptionId.FilterLanguage,
|
||||
CharacterOptionId.ShowHelm,
|
||||
CharacterOptionId.ShowCloak,
|
||||
// Group 3 (Grouping) — all 6
|
||||
// Group 3 (Grouping) — 2 of 6 (FA4, D7: the other four — see class doc)
|
||||
CharacterOptionId.IgnoreAllegianceRequests,
|
||||
CharacterOptionId.IgnoreFellowshipRequests,
|
||||
CharacterOptionId.DisplayAllegianceLogonNotifications,
|
||||
CharacterOptionId.FellowshipShareXP,
|
||||
CharacterOptionId.FellowshipShareLoot,
|
||||
CharacterOptionId.FellowshipAutoAcceptRequests,
|
||||
// Group 4 (Other Players) — 10 of 11
|
||||
CharacterOptionId.AcceptLootPermits,
|
||||
CharacterOptionId.UseDeception,
|
||||
|
|
@ -761,8 +762,8 @@ public sealed class CharacterOptionsPageControllerTests
|
|||
.ToHashSet();
|
||||
|
||||
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
|
||||
Assert.Equal(35, actualStoreOnly.Count);
|
||||
Assert.Equal(15, 50 - actualStoreOnly.Count); // the 15 live rows
|
||||
Assert.Equal(31, actualStoreOnly.Count);
|
||||
Assert.Equal(19, 50 - actualStoreOnly.Count); // the 19 live rows (FA4, D7: +4)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FA slice FA4, carry-forward 2 (the FA3 re-review's non-blocking
|
||||
/// finding: "add a production-resolver test for the new template cache").
|
||||
/// <see cref="RowTemplateResolver"/> is the extracted, hermetically-testable
|
||||
/// caching resolver <c>RetailUiRuntime.MountSocialPanel</c> actually uses in
|
||||
/// production (Friends/Squelch since FA3, Fellowship since FA4) — these
|
||||
/// tests exercise ITS class directly, with fake import/build delegates, no
|
||||
/// live DAT.
|
||||
/// </summary>
|
||||
public sealed class RowTemplateResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_CachesTheImport_ButBuildsAFreshWidgetEveryCall()
|
||||
{
|
||||
int importCalls = 0;
|
||||
int buildCalls = 0;
|
||||
var resolver = new RowTemplateResolver(
|
||||
importInfos: (layoutId, elementId) =>
|
||||
{
|
||||
importCalls++;
|
||||
return new ElementInfo { Id = elementId, Type = 0xCu };
|
||||
},
|
||||
build: info =>
|
||||
{
|
||||
buildCalls++;
|
||||
return new UiText();
|
||||
});
|
||||
|
||||
UiElement? first = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||
UiElement? second = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||
UiElement? third = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.NotNull(third);
|
||||
// The expensive DAT tree walk (ImportInfos) ran exactly once...
|
||||
Assert.Equal(1, importCalls);
|
||||
Assert.Equal(1, resolver.ImportCount);
|
||||
// ...but every row still gets its OWN built widget instance (a
|
||||
// shared instance would mean every fellow's row points at the same
|
||||
// UiElement, breaking per-row state like OnClick/LinesProvider).
|
||||
Assert.Equal(3, buildCalls);
|
||||
Assert.NotSame(first, second);
|
||||
Assert.NotSame(second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_DifferentTemplatePairs_ImportIndependently()
|
||||
{
|
||||
int importCalls = 0;
|
||||
var resolver = new RowTemplateResolver(
|
||||
importInfos: (layoutId, elementId) =>
|
||||
{
|
||||
importCalls++;
|
||||
return new ElementInfo { Id = elementId, Type = 0xCu };
|
||||
},
|
||||
build: _ => new UiText());
|
||||
|
||||
// Fellowship's own template pair, and a DIFFERENT one (Friends'),
|
||||
// through the SAME resolver instance — matches production, where
|
||||
// MountSocialPanel feeds all three row families through one
|
||||
// RowTemplateResolver.
|
||||
resolver.Resolve(0x21000030u, 0x10000281u);
|
||||
resolver.Resolve(0x2100005Du, 0x10000519u);
|
||||
resolver.Resolve(0x21000030u, 0x10000281u);
|
||||
resolver.Resolve(0x2100005Du, 0x10000519u);
|
||||
|
||||
Assert.Equal(2, importCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ImportMiss_CachesTheNull_AndNeverRetriesTheImport()
|
||||
{
|
||||
int importCalls = 0;
|
||||
var resolver = new RowTemplateResolver(
|
||||
importInfos: (_, _) => { importCalls++; return null; },
|
||||
build: _ => throw new InvalidOperationException("build must not run for a null import"));
|
||||
|
||||
UiElement? first = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||
UiElement? second = resolver.Resolve(0x21000030u, 0x10000281u);
|
||||
|
||||
Assert.Null(first);
|
||||
Assert.Null(second);
|
||||
Assert.Equal(1, importCalls);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,555 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
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;
|
||||
|
||||
|
|
@ -16,6 +17,45 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
/// </summary>
|
||||
public sealed class SocialPanelControllerTests
|
||||
{
|
||||
private static readonly RuntimeCommandResult InactiveResult =
|
||||
new(RuntimeCommandStatus.Inactive, 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)
|
||||
{
|
||||
calls ??= new List<string>();
|
||||
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 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,
|
||||
|
|
@ -26,7 +66,7 @@ public sealed class SocialPanelControllerTests
|
|||
calls ??= new List<string>();
|
||||
return new SocialPanelController.Callbacks(
|
||||
Toggle: () => calls.Add("toggle"),
|
||||
FellowshipSnapshot: () => fellowship,
|
||||
Fellowship: MakeFellowshipBindings(calls, fellowship),
|
||||
AllegianceSnapshot: () => allegiance,
|
||||
Friends: friends ?? new FriendsState(),
|
||||
Squelch: squelch ?? new SquelchState(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.IO;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Runtime;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
|
|
@ -109,6 +110,137 @@ public sealed class SocialPanelLiveMountProbeTests
|
|||
Assert.NotNull(el);
|
||||
}
|
||||
|
||||
// Campaign FA slice FA4, item 8: the fellowship page's own control
|
||||
// inventory against the PRODUCTION mount (not a fixture) — the
|
||||
// name-entry field must build as UiField (proving the DAT authors
|
||||
// Editable=1, SocialFellowshipPageController's load-bearing
|
||||
// assumption), and every button/checkbox must resolve as UiButton.
|
||||
UiElement? nameField = UiElement.FindDescendant(tabs, 0x1000026Fu);
|
||||
Console.WriteLine(
|
||||
$"[socialprobe] fellowship name-entry field 0x1000026F -> {(nameField is null ? "MISSING" : nameField.GetType().Name)}");
|
||||
Assert.IsType<UiField>(nameField);
|
||||
|
||||
foreach ((uint id, string name) in new[]
|
||||
{
|
||||
(0x10000274u, "CreateFellowshipButton"),
|
||||
(0x1000027Bu, "FellowLeaderButton"),
|
||||
(0x1000027Cu, "FellowQuitButton"),
|
||||
(0x1000027Du, "FellowOpenButton"),
|
||||
(0x1000027Eu, "FellowRecruitButton"),
|
||||
(0x1000027Fu, "FellowDismissButton"),
|
||||
(0x10000280u, "FellowDisbandButton"),
|
||||
(0x10000270u, "IgnoreFellowshipRequestsCheckbox"),
|
||||
(0x10000271u, "FellowshipAutoAcceptRequestsCheckbox"),
|
||||
(0x10000272u, "FellowshipShareXPCheckbox"),
|
||||
(0x10000273u, "FellowshipShareLootCheckbox"),
|
||||
})
|
||||
{
|
||||
UiElement? el = UiElement.FindDescendant(tabs, id);
|
||||
Console.WriteLine($"[socialprobe] fellowship control {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
|
||||
Assert.IsType<UiButton>(el);
|
||||
}
|
||||
|
||||
// The fellowship ListBox's own authored template pair (lane A U10 —
|
||||
// read P0x64 on 0x10000279) and its scrollbar (P0x72).
|
||||
UiElement? fellowshipListBoxEl = UiElement.FindDescendant(tabs, 0x10000279u);
|
||||
UiTemplateListBox fellowshipListBox = Assert.IsType<UiTemplateListBox>(fellowshipListBoxEl);
|
||||
Console.WriteLine(
|
||||
$"[socialprobe] fellowship ListBox 0x10000279 templates={fellowshipListBox.Templates.Count} "
|
||||
+ $"scrollbar=0x{fellowshipListBox.ScrollbarElementId:X8}");
|
||||
Assert.NotEmpty(fellowshipListBox.Templates);
|
||||
UiTemplateListEntry fellowRowTemplate = fellowshipListBox.Templates[0];
|
||||
|
||||
// Production-resolver build of the row template (FA4 carry-forward
|
||||
// 2): the SAME RowTemplateResolver shape MountSocialPanel uses in
|
||||
// production — Import then Build against the LIVE dats, not a
|
||||
// fixture — proving the 8-part row (lane A §3.1) actually resolves
|
||||
// end-to-end, not merely that the id pair is authored.
|
||||
var rowTemplates = new RowTemplateResolver(
|
||||
(layoutId, elementId) => LayoutImporter.ImportInfos(dats, layoutId, elementId),
|
||||
info => LayoutImporter.Build(info, _ => (1u, 8, 8), null, null, strings.Resolve).Root);
|
||||
UiElement? fellowRow = rowTemplates.Resolve(
|
||||
fellowRowTemplate.TemplateLayoutId, fellowRowTemplate.TemplateElementId);
|
||||
Console.WriteLine(
|
||||
$"[socialprobe] fellowship row template 0x{fellowRowTemplate.TemplateLayoutId:X8}/"
|
||||
+ $"0x{fellowRowTemplate.TemplateElementId:X8} -> {(fellowRow is null ? "IMPORT NULL" : fellowRow.GetType().Name)}");
|
||||
Assert.NotNull(fellowRow);
|
||||
|
||||
foreach ((uint id, string name, Type expectedType) in new (uint, string, Type)[]
|
||||
{
|
||||
(0x10000283u, "FellowName", typeof(UiText)),
|
||||
(0x10000284u, "FellowStats", typeof(UiText)),
|
||||
(0x10000285u, "HealthMeter", typeof(UiMeter)),
|
||||
(0x10000287u, "StaminaMeter", typeof(UiMeter)),
|
||||
(0x10000289u, "ManaMeter", typeof(UiMeter)),
|
||||
})
|
||||
{
|
||||
UiElement? el = UiElement.FindDescendant(fellowRow!, id);
|
||||
Console.WriteLine($"[socialprobe] fellowship row field {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
|
||||
Assert.IsType(expectedType, el);
|
||||
}
|
||||
|
||||
// Checkbox labels/tooltips (0x23000003) and the Open/Close caption
|
||||
// pair (0x23000001) — the two string tables SocialFellowshipPageController
|
||||
// resolves at Bind time (lane A §4.3).
|
||||
foreach (string retailName in new[]
|
||||
{
|
||||
"IgnoreFellowshipRequests", "FellowshipAutoAcceptRequests",
|
||||
"FellowshipShareXP", "FellowshipShareLoot",
|
||||
})
|
||||
{
|
||||
string? label = strings.Resolve(0x23000003u, DatStringResolver.ComputeHash($"ID_PlayerOption_{retailName}"));
|
||||
Console.WriteLine($"[socialprobe] checkbox label ID_PlayerOption_{retailName} -> '{label}'");
|
||||
}
|
||||
foreach (string key in new[]
|
||||
{
|
||||
"ID_Fellowship_OpenFellowshipButtonText", "ID_Fellowship_CloseFellowshipButtonText",
|
||||
})
|
||||
{
|
||||
string? label = strings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key));
|
||||
Console.WriteLine($"[socialprobe] fellowship caption {key} -> '{label}'");
|
||||
}
|
||||
|
||||
// Full end-to-end Bind against the PRODUCTION mount (the same
|
||||
// resolver/scrollbar/frame wiring MountSocialPanel does) — proves
|
||||
// Bind() finds every element it needs with zero "not found"
|
||||
// console warnings, not just that the individual ids resolve in
|
||||
// isolation above.
|
||||
UiElement? fellowshipPageForBind = UiElement.FindDescendant(tabs, 0x10000292u);
|
||||
Assert.NotNull(fellowshipPageForBind);
|
||||
var originalOut = Console.Out;
|
||||
var capture = new StringWriter();
|
||||
Console.SetOut(capture);
|
||||
SocialFellowshipPageController? fellowshipController;
|
||||
try
|
||||
{
|
||||
fellowshipController = SocialFellowshipPageController.Bind(
|
||||
fellowshipPageForBind!,
|
||||
new SocialFellowshipPageController.Bindings(
|
||||
Snapshot: () => new RuntimeFellowshipSnapshot(),
|
||||
Members: () => [],
|
||||
TemplateResolver: rowTemplates.Resolve,
|
||||
Create: (_, _) => default,
|
||||
Recruit: _ => default,
|
||||
Dismiss: _ => default,
|
||||
Quit: _ => default,
|
||||
AssignLeader: _ => default,
|
||||
SetOpen: _ => default,
|
||||
SetPanelOpen: _ => default,
|
||||
Selection: new AcDream.Core.Selection.SelectionState(),
|
||||
LocalPlayerGuid: () => 0u,
|
||||
CurrentCharacterOption: _ => false,
|
||||
SetCharacterOption: (_, _) => { },
|
||||
ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
string bindLog = capture.ToString();
|
||||
Console.WriteLine($"[socialprobe] fellowship Bind() console output:\n{bindLog}");
|
||||
Assert.NotNull(fellowshipController);
|
||||
Assert.DoesNotContain("not found", bindLog);
|
||||
|
||||
// Allegiance signature elements.
|
||||
foreach ((uint id, string name) in new[]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// FA3 carry-forward 1 (folded into Campaign FA slice FA4): unlike
|
||||
/// <see cref="UiTemplateListBox.Flush"/>, <see cref="UiTemplateListBox.FlushPreservingScroll"/>
|
||||
/// must not reset the scroll offset to 0 — it exists specifically so a
|
||||
/// roster rebuild triggered by a server-side change (not user action)
|
||||
/// doesn't yank a scrolled-down user back to the top.
|
||||
/// </summary>
|
||||
public sealed class UiTemplateListBoxFlushPreservingScrollTests
|
||||
{
|
||||
private static UiTemplateListBox MakeListBox(float width, float height)
|
||||
{
|
||||
var info = new ElementInfo { Id = 0x10000279u, Type = 5u };
|
||||
return new UiTemplateListBox(
|
||||
info,
|
||||
_ => (0u, 0, 0),
|
||||
new[] { new UiTemplateListEntry(0x21000030u, 0x10000281u) },
|
||||
scrollbarElementId: 0x1000027Au)
|
||||
{
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddRows(UiTemplateListBox box, int count, float rowHeight = 40f)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
box.AddPrebuiltRow(new UiText { Width = box.Width, Height = rowHeight });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_ResetsScrollToZero_TheOrdinaryContract()
|
||||
{
|
||||
var box = MakeListBox(200f, 100f);
|
||||
AddRows(box, 10); // 400px of content in a 100px viewport
|
||||
box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
|
||||
box.ViewportForTest!.LayoutScrollableChildren();
|
||||
box.Scroll.SetScrollY(150);
|
||||
Assert.Equal(150, box.Scroll.ScrollY);
|
||||
|
||||
box.Flush();
|
||||
|
||||
Assert.Equal(0, box.Scroll.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushPreservingScroll_KeepsTheScrollOffset()
|
||||
{
|
||||
var box = MakeListBox(200f, 100f);
|
||||
AddRows(box, 10); // 400px of content in a 100px viewport
|
||||
box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
|
||||
box.ViewportForTest!.LayoutScrollableChildren();
|
||||
box.Scroll.SetScrollY(150);
|
||||
Assert.Equal(150, box.Scroll.ScrollY);
|
||||
|
||||
box.FlushPreservingScroll();
|
||||
|
||||
Assert.Equal(150, box.Scroll.ScrollY);
|
||||
|
||||
// Rebuild the SAME row count — the offset should still be valid
|
||||
// (and stay put) once the next layout pass recomputes content
|
||||
// height against the rebuilt rows.
|
||||
AddRows(box, 10);
|
||||
box.ViewportForTest!.LayoutScrollableChildren();
|
||||
Assert.Equal(150, box.Scroll.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushPreservingScroll_ClampsToTheNewShorterContent()
|
||||
{
|
||||
var box = MakeListBox(200f, 100f);
|
||||
AddRows(box, 10); // 400px of content
|
||||
box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
|
||||
box.ViewportForTest!.LayoutScrollableChildren();
|
||||
box.Scroll.SetScrollY(300); // near the bottom of the 10-row content
|
||||
|
||||
box.FlushPreservingScroll();
|
||||
// Rebuild with far FEWER rows (e.g. most of the fellowship left).
|
||||
AddRows(box, 2); // 80px of content, less than the 100px viewport
|
||||
box.ViewportForTest!.LayoutScrollableChildren();
|
||||
|
||||
// Clamped to the new MaxScroll (0, since content < viewport) —
|
||||
// never left dangling past the real content, and never throws.
|
||||
Assert.Equal(0, box.Scroll.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushPreservingScroll_DormantBox_IsANoOp()
|
||||
{
|
||||
var box = MakeListBox(200f, 100f);
|
||||
// No rows ever added — the viewport was never created.
|
||||
box.FlushPreservingScroll();
|
||||
Assert.Null(box.ViewportForTest);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue