Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1549 lines
60 KiB
C#
1549 lines
60 KiB
C#
using AcDream.App.Studio;
|
||
using AcDream.App.UI;
|
||
using AcDream.App.UI.Layout;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.Tests.UI.Layout;
|
||
|
||
/// <summary>
|
||
/// Unit tests for <see cref="CharacterStatController"/> — the Attributes-tab controller that
|
||
/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay).
|
||
/// Pure data wiring against fake layouts; no dats, no GL.
|
||
///
|
||
/// <para>Pass 1 tests verify header/XP meter/attribute list/footer State-A binding.
|
||
/// Pass 2 tests verify: (a) row click → selection toggle; (b) footer State-B content;
|
||
/// (c) raise-button affordability; (d) tab button states.</para>
|
||
/// </summary>
|
||
public class CharacterStatControllerTests
|
||
{
|
||
// ── Header labels bind to the sheet ──────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void Bind_SetsNameLabel()
|
||
{
|
||
var name = new UiText();
|
||
var layout = Fake((CharacterStatController.NameId, name));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.Equal("Studio Player", name.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_SetsLevelLabel()
|
||
{
|
||
var level = new UiText();
|
||
var layout = Fake((CharacterStatController.LevelId, level));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.Equal("126", level.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_SetsHeritageAndPkLabels()
|
||
{
|
||
var heritage = new UiText();
|
||
var pk = new UiText();
|
||
var layout = Fake((CharacterStatController.HeritageId, heritage),
|
||
(CharacterStatController.PkStatusId, pk));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.Equal("Female Aluvian Adventurer", heritage.LinesProvider()[0].Text);
|
||
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_WindowChromeButton_InvokesCloseCallback()
|
||
{
|
||
var close = MakeButton(WindowChromeController.CharacterCloseButtonId);
|
||
var layout = Fake((WindowChromeController.CharacterCloseButtonId, close));
|
||
int closes = 0;
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++);
|
||
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
||
|
||
Assert.Equal(1, closes);
|
||
}
|
||
|
||
[Fact]
|
||
public void CharacterIdentityText_StatHeaderLine_ComposesRetailGenderHeritageTitle()
|
||
{
|
||
var sheet = new CharacterSheet
|
||
{
|
||
Gender = "Female",
|
||
Heritage = "Aluvian",
|
||
Title = "the Adventurer",
|
||
};
|
||
|
||
Assert.Equal("Female Aluvian Adventurer", CharacterIdentityText.StatHeaderLine(sheet));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(1, "Male")]
|
||
[InlineData(2, "Female")]
|
||
[InlineData(0, null)]
|
||
public void CharacterIdentityText_GenderDisplayName_UsesRetailEnum(int value, string? expected)
|
||
=> Assert.Equal(expected, CharacterIdentityText.GenderDisplayName(value));
|
||
|
||
[Theory]
|
||
[InlineData(1, "Aluvian")]
|
||
[InlineData(2, "Gharu'ndim")]
|
||
[InlineData(5, "Umbraen")]
|
||
[InlineData(13, "Olthoi")]
|
||
[InlineData(99, null)]
|
||
public void CharacterIdentityText_HeritageGroupDisplayName_UsesRetailEnum(int value, string? expected)
|
||
=> Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName(value));
|
||
|
||
[Fact]
|
||
public void Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated()
|
||
{
|
||
var root = new UiPanel { Width = 300, Height = 600 };
|
||
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
|
||
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
|
||
|
||
var visibleName = new UiText { ElementId = CharacterStatController.NameId };
|
||
var hiddenName = new UiText { ElementId = CharacterStatController.NameId };
|
||
var visibleHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
|
||
var hiddenHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
|
||
var visibleLevel = new UiText { ElementId = CharacterStatController.LevelId };
|
||
var hiddenLevel = new UiText { ElementId = CharacterStatController.LevelId };
|
||
var visibleTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
|
||
var hiddenTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
|
||
var visibleTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
|
||
var hiddenTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
|
||
var visibleMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
|
||
var hiddenMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
|
||
var visibleXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
|
||
var hiddenXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
|
||
visibleMeter.AddChild(visibleXpNext);
|
||
hiddenMeter.AddChild(hiddenXpNext);
|
||
|
||
attrPage.AddChild(visibleName);
|
||
attrPage.AddChild(visibleHeritage);
|
||
attrPage.AddChild(visibleLevel);
|
||
attrPage.AddChild(visibleTotalXpLabel);
|
||
attrPage.AddChild(visibleTotalXp);
|
||
attrPage.AddChild(visibleMeter);
|
||
hiddenPage.AddChild(hiddenName);
|
||
hiddenPage.AddChild(hiddenHeritage);
|
||
hiddenPage.AddChild(hiddenLevel);
|
||
hiddenPage.AddChild(hiddenTotalXpLabel);
|
||
hiddenPage.AddChild(hiddenTotalXp);
|
||
hiddenPage.AddChild(hiddenMeter);
|
||
root.AddChild(attrPage);
|
||
root.AddChild(hiddenPage);
|
||
|
||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||
{
|
||
[CharacterStatController.NameId] = hiddenName,
|
||
[CharacterStatController.HeritageId] = hiddenHeritage,
|
||
[CharacterStatController.LevelId] = hiddenLevel,
|
||
[CharacterStatController.TotalXpLabelId] = hiddenTotalXpLabel,
|
||
[CharacterStatController.TotalXpId] = hiddenTotalXp,
|
||
[CharacterStatController.XpMeterId] = hiddenMeter,
|
||
[CharacterStatController.XpNextValueId] = hiddenXpNext,
|
||
});
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.Equal("Studio Player", visibleName.LinesProvider()[0].Text);
|
||
Assert.Equal("Female Aluvian Adventurer", visibleHeritage.LinesProvider()[0].Text);
|
||
Assert.Equal("126", visibleLevel.LinesProvider()[0].Text);
|
||
Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text);
|
||
Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text);
|
||
Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text);
|
||
Assert.Empty(hiddenName.LinesProvider());
|
||
Assert.Empty(hiddenXpNext.LinesProvider());
|
||
}
|
||
|
||
// ── XP meter fill ────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void Bind_SetsXpMeterFill()
|
||
{
|
||
var meter = new UiMeter();
|
||
var layout = Fake((CharacterStatController.XpMeterId, meter));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var fill = meter.Fill();
|
||
Assert.NotNull(fill);
|
||
Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}");
|
||
}
|
||
|
||
// ── Attribute list — 9 rows in list box 0x1000023D ───────────────────────
|
||
|
||
[Fact]
|
||
public void Bind_AttributeList_Has9Rows()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
// Rows are UiClickablePanel which inherits UiPanel, so OfType<UiPanel> matches.
|
||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||
Assert.Equal(9, rows.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_AttributeList_RowsAreClickablePanels()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
Assert.Equal(9, rows.Count);
|
||
// All rows must have an OnClick wired (not null) and ClickThrough = false.
|
||
foreach (var row in rows)
|
||
{
|
||
Assert.NotNull(row.OnClick);
|
||
Assert.False(row.ClickThrough, "clickable row must accept pointer hits");
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_AttributeList_EachRowHasRightAlignedValueLabel()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||
Assert.Equal(9, rows.Count);
|
||
foreach (var row in rows)
|
||
{
|
||
var texts = row.Children.OfType<UiText>().ToList();
|
||
Assert.True(texts.Count >= 2, "each row must have at least name + value UiText");
|
||
var valueEl = texts[^1];
|
||
Assert.True(valueEl.RightAligned, "value label must be RightAligned");
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_AttributeList_RowNamesInRetailOrder()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||
Assert.Equal(9, rows.Count);
|
||
|
||
string[] expectedNames =
|
||
{
|
||
"Strength", "Endurance", "Coordination", "Quickness", "Focus", "Self",
|
||
"Health", "Stamina", "Mana",
|
||
};
|
||
|
||
for (int i = 0; i < 9; i++)
|
||
{
|
||
var texts = rows[i].Children.OfType<UiText>().ToList();
|
||
Assert.True(texts.Count >= 2, $"row {i} must have name + value");
|
||
string rowName = texts[1].LinesProvider()[0].Text;
|
||
Assert.Equal(expectedNames[i], rowName);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_AttributeList_RowValues_AttributeIntegersAndVitalsCurMax()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||
|
||
string ValueOf(UiPanel row) => row.Children.OfType<UiText>().ToList()[^1].LinesProvider()[0].Text;
|
||
|
||
Assert.Equal("200", ValueOf(rows[0])); // Strength
|
||
Assert.Equal("10", ValueOf(rows[1])); // Endurance
|
||
Assert.Equal("10", ValueOf(rows[2])); // Coordination
|
||
Assert.Equal("200", ValueOf(rows[3])); // Quickness
|
||
Assert.Equal("10", ValueOf(rows[4])); // Focus
|
||
Assert.Equal("10", ValueOf(rows[5])); // Self
|
||
Assert.Equal("5/5", ValueOf(rows[6])); // Health
|
||
Assert.Equal("10/10", ValueOf(rows[7])); // Stamina
|
||
Assert.Equal("10/10", ValueOf(rows[8])); // Mana
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_AttributeList_IconHasBackgroundSpriteWhenResolverProvided()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||
Assert.Equal(9, rows.Count);
|
||
|
||
var iconEl = rows[0].Children.OfType<UiText>().First();
|
||
Assert.Equal(0x060002C8u, iconEl.BackgroundSprite);
|
||
|
||
var healthIcon = rows[6].Children.OfType<UiText>().First();
|
||
Assert.Equal(0x06004C3Bu, healthIcon.BackgroundSprite);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_AttributeList_IconBackgroundSprite_ZeroWhenNoResolver()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
|
||
|
||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||
Assert.Equal(9, rows.Count);
|
||
foreach (var row in rows)
|
||
{
|
||
var iconEl = row.Children.OfType<UiText>().First();
|
||
Assert.Equal(0u, iconEl.BackgroundSprite);
|
||
}
|
||
}
|
||
|
||
// ── Footer State A ────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void Bind_FooterStateA_TitleIsSelectPrompt()
|
||
{
|
||
var title = new UiText();
|
||
var layout = Fake((CharacterStatController.FooterTitleId, title));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
// Initial state (nothing selected): State-A title.
|
||
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_FooterStateA_Line1LabelIsSkillCreditsAvailable()
|
||
{
|
||
var lbl = new UiText();
|
||
var layout = Fake((CharacterStatController.FooterLine1Label, lbl));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.Equal("Skill Credits Available:", lbl.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_FooterStateA_Line1ValueIsSkillCredits()
|
||
{
|
||
var val = new UiText();
|
||
var layout = Fake((CharacterStatController.FooterLine1Value, val));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.Equal("96", val.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_FooterStateA_Line2LabelIsUnassignedExperience()
|
||
{
|
||
var lbl = new UiText();
|
||
var layout = Fake((CharacterStatController.FooterLine2Label, lbl));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_FooterStateA_Line2ValueIsUnassignedXp()
|
||
{
|
||
var val = new UiText();
|
||
var layout = Fake((CharacterStatController.FooterLine2Value, val));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var expected = (87_757_321_741L).ToString("N0");
|
||
Assert.Equal(expected, val.LinesProvider()[0].Text);
|
||
}
|
||
|
||
// ── Pass 2: Row selection → Footer State B ───────────────────────────────
|
||
|
||
[Fact]
|
||
public void RowClick_SelectRow4Focus_FooterStateBShowsFocusTitle()
|
||
{
|
||
// Focus is index 4 in AttrRows. SampleData Focus = 10. Cost = 110.
|
||
var title = new UiText();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.FooterTitleId, title),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
// Simulate a click on row 4 (Focus).
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
Assert.Equal(9, rows.Count);
|
||
rows[4].OnClick!();
|
||
|
||
// Footer title should now be "Focus: 10".
|
||
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_SelectRow4Focus_FooterLine1LabelIsExperienceToRaise()
|
||
{
|
||
var lbl = new UiText();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.FooterLine1Label, lbl),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||
|
||
Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_SelectRow4Focus_FooterLine1ValueIsRaiseCost()
|
||
{
|
||
var val = new UiText();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.FooterLine1Value, val),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||
|
||
// Focus raise cost = 110 (SampleData fixture).
|
||
Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_SelectRow4Focus_FooterLine2LabelIsUnassignedExperience()
|
||
{
|
||
var lbl = new UiText();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.FooterLine2Label, lbl),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||
|
||
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_SelectRow4Focus_FooterLine2ValueIsUnassignedXp()
|
||
{
|
||
var val = new UiText();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.FooterLine2Value, val),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||
|
||
// UnassignedXp = 87_757_321_741L
|
||
var expected = (87_757_321_741L).ToString("N0");
|
||
Assert.Equal(expected, val.LinesProvider()[0].Text);
|
||
}
|
||
|
||
// ── Pass 2: Toggle deselects ──────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void RowClick_ToggleSameRow_ReturnsToFooterStateA()
|
||
{
|
||
var title = new UiText();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.FooterTitleId, title),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
|
||
// Select Focus (row 4).
|
||
rows[4].OnClick!();
|
||
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
|
||
|
||
// Click the same row again → deselect.
|
||
rows[4].OnClick!();
|
||
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_SwitchRow_UpdatesToNewRow()
|
||
{
|
||
var title = new UiText();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.FooterTitleId, title),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
|
||
// Select Endurance (row 1, value=10).
|
||
rows[1].OnClick!();
|
||
Assert.Equal("Endurance: 10", title.LinesProvider()[0].Text);
|
||
|
||
// Select Self (row 5, value=10).
|
||
rows[5].OnClick!();
|
||
Assert.Equal("Self: 10", title.LinesProvider()[0].Text);
|
||
}
|
||
|
||
// ── Pass 2: Row highlight ─────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void RowClick_SelectRow_HighlightsSelectedAndClearsOthers()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
|
||
// All rows start transparent.
|
||
Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W));
|
||
|
||
// Select row 2 (Coordination).
|
||
rows[2].OnClick!();
|
||
Assert.NotEqual(0f, rows[2].BackgroundColor.W); // highlighted
|
||
Assert.Equal(0f, rows[0].BackgroundColor.W); // others cleared
|
||
Assert.Equal(0f, rows[1].BackgroundColor.W);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_Deselect_ClearsHighlight()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
rows[2].OnClick!(); // select
|
||
rows[2].OnClick!(); // deselect
|
||
|
||
Assert.Equal(0f, rows[2].BackgroundColor.W);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite()
|
||
{
|
||
// When spriteResolve is provided, the selected row must use sprite 0x06001397
|
||
// (retail Button-state-6 dark bar) instead of the translucent gold BackgroundColor.
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
// Minimal sprite resolver — returns a fake non-zero handle so UiPanel draws it.
|
||
static (uint, int, int) FakeResolve(uint id) => (1u, 32, 8);
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: FakeResolve);
|
||
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
rows[2].OnClick!();
|
||
|
||
Assert.Equal(0x06001397u, rows[2].BackgroundSprite); // selected → sprite
|
||
Assert.Equal(0f, rows[2].BackgroundColor.W); // no tint
|
||
Assert.Equal(0u, rows[0].BackgroundSprite); // others cleared
|
||
Assert.Equal(0u, rows[1].BackgroundSprite);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_WithSpriteResolve_Deselect_ClearsSprite()
|
||
{
|
||
var list = new UiPanel();
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
static (uint, int, int) FakeResolve(uint id) => (1u, 32, 8);
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: FakeResolve);
|
||
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
rows[2].OnClick!(); // select
|
||
rows[2].OnClick!(); // deselect
|
||
|
||
Assert.Equal(0u, rows[2].BackgroundSprite);
|
||
}
|
||
|
||
// ── Pass 2: Raise button affordability ───────────────────────────────────
|
||
|
||
[Fact]
|
||
public void RaiseButtons_InitiallyHidden()
|
||
{
|
||
var btn1 = MakeButton();
|
||
var btn10 = MakeButton();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.RaiseOneId, btn1),
|
||
(CharacterStatController.RaiseTenId, btn10),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.False(btn1.Visible, "raise×1 must start hidden");
|
||
Assert.False(btn10.Visible, "raise×10 must start hidden");
|
||
}
|
||
|
||
[Fact]
|
||
public void RaiseButtons_AffordableRow_ShowsNormalState()
|
||
{
|
||
// Focus row (index 4) cost=110, UnassignedXp=87_757_321_741 → affordable.
|
||
var btn1 = MakeButton();
|
||
var btn10 = MakeButton();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.RaiseOneId, btn1),
|
||
(CharacterStatController.RaiseTenId, btn10),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // select Focus
|
||
|
||
Assert.True(btn1.Visible, "raise×1 visible on selection");
|
||
Assert.True(btn10.Visible, "raise×10 visible on selection");
|
||
Assert.Equal("Normal", btn1.ActiveState);
|
||
Assert.Equal("Normal", btn10.ActiveState);
|
||
}
|
||
|
||
[Fact]
|
||
public void RaiseButtons_OnlyOneAffordable_SplitsOneAndTenStates()
|
||
{
|
||
var btn1 = MakeButton();
|
||
var btn10 = MakeButton();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.RaiseOneId, btn1),
|
||
(CharacterStatController.RaiseTenId, btn10),
|
||
(CharacterStatController.ListBoxId, list));
|
||
var sheet = new CharacterSheet
|
||
{
|
||
UnassignedXp = 500L,
|
||
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
|
||
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
|
||
};
|
||
|
||
CharacterStatController.Bind(layout, () => sheet);
|
||
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||
|
||
Assert.Equal("Normal", btn1.ActiveState);
|
||
Assert.Equal("Ghosted", btn10.ActiveState);
|
||
}
|
||
|
||
[Fact]
|
||
public void RaiseButtons_MaxedRow_ShowsGhostedState()
|
||
{
|
||
// Strength row (index 0) cost=0 → disabled. UnassignedXp is irrelevant.
|
||
var btn1 = MakeButton();
|
||
var btn10 = MakeButton();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.RaiseOneId, btn1),
|
||
(CharacterStatController.RaiseTenId, btn10),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // select Strength (cost=0)
|
||
|
||
Assert.True(btn1.Visible, "raise button visible even when disabled");
|
||
Assert.Equal("Ghosted", btn1.ActiveState);
|
||
Assert.Equal("Ghosted", btn10.ActiveState);
|
||
}
|
||
|
||
[Fact]
|
||
public void RaiseButtons_ClickAffordableAttribute_EmitsRaiseRequest()
|
||
{
|
||
var btn1 = MakeButton();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.RaiseOneId, btn1),
|
||
(CharacterStatController.ListBoxId, list));
|
||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||
btn1.OnClick!();
|
||
|
||
var request = Assert.Single(requests);
|
||
Assert.Equal(CharacterStatController.RaiseTargetKind.Attribute, request.Kind);
|
||
Assert.Equal(5u, request.StatId);
|
||
Assert.Equal(110L, request.Cost);
|
||
Assert.Equal(1, request.Amount);
|
||
}
|
||
|
||
[Fact]
|
||
public void RaiseButtons_ClickAffordableAttribute_KeepsNormalStateUntilCostsRefresh()
|
||
{
|
||
var btn1 = MakeButton();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.RaiseOneId, btn1),
|
||
(CharacterStatController.ListBoxId, list));
|
||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||
Assert.Equal("Normal", btn1.ActiveState);
|
||
|
||
btn1.OnClick!();
|
||
|
||
Assert.Single(requests);
|
||
Assert.Equal("Normal", btn1.ActiveState);
|
||
}
|
||
|
||
[Fact]
|
||
public void RaiseButtons_ClickAffordableVital_EmitsMaxVitalId()
|
||
{
|
||
var btn1 = MakeButton();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.RaiseOneId, btn1),
|
||
(CharacterStatController.ListBoxId, list));
|
||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||
|
||
list.Children.OfType<UiClickablePanel>().ToList()[6].OnClick!();
|
||
btn1.OnClick!();
|
||
|
||
var request = Assert.Single(requests);
|
||
Assert.Equal(CharacterStatController.RaiseTargetKind.Vital, request.Kind);
|
||
Assert.Equal(1u, request.StatId);
|
||
Assert.Equal(90L, request.Cost);
|
||
Assert.Equal(1, request.Amount);
|
||
}
|
||
|
||
[Fact]
|
||
public void RaiseButtons_ClickUnaffordableTen_DoesNotEmitRequest()
|
||
{
|
||
var btn10 = MakeButton();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.RaiseTenId, btn10),
|
||
(CharacterStatController.ListBoxId, list));
|
||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||
var sheet = new CharacterSheet
|
||
{
|
||
UnassignedXp = 500L,
|
||
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
|
||
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
|
||
};
|
||
|
||
CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: requests.Add);
|
||
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||
btn10.OnClick!();
|
||
|
||
Assert.Empty(requests);
|
||
}
|
||
|
||
[Fact]
|
||
public void RaiseButtons_Deselect_HidesButtons()
|
||
{
|
||
var btn1 = MakeButton();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.RaiseOneId, btn1),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
rows[4].OnClick!(); // select
|
||
Assert.True(btn1.Visible);
|
||
rows[4].OnClick!(); // deselect
|
||
Assert.False(btn1.Visible, "raise button hidden after deselect");
|
||
}
|
||
|
||
// ── Pass 2: Tab button states ─────────────────────────────────────────────
|
||
// Tab sprites are added to layout.Root (NOT to the tab group elements), so
|
||
// the tab groups keep Children.Count==0 and survive the page-visibility pass.
|
||
// AddTabSpritesToRoot() adds 3 sprite UiTexts + 1 label UiText per tab to the
|
||
// root when a spriteResolve is provided; nothing is added when spriteResolve=null.
|
||
|
||
[Fact]
|
||
public void TabButtons_NoSpriteResolve_AddsNoSpriteChildrenToRoot()
|
||
{
|
||
// When spriteResolve is null, AddTabSpritesToRoot is not called — no sprites added.
|
||
var layout = Fake(); // empty fake layout with just the root UiPanel
|
||
int rootChildCountBefore = layout.Root.Children.Count;
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
|
||
|
||
// No extra children added to root when spriteResolve is null.
|
||
Assert.Equal(rootChildCountBefore, layout.Root.Children.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void TabButtons_AttributesGroup_AddsOpenSpriteIdsToRoot()
|
||
{
|
||
// Attributes tab (isOpen=true): 3 sprite children with Open sprite ids on ROOT.
|
||
// The tab group element itself has 0 children (sprites go to root, not the group).
|
||
var layout = Fake(); // minimal fake
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
// Root should contain the Open sprite ids among all tab sprite children.
|
||
var sprites = layout.Root.Children.OfType<UiText>()
|
||
.Where(t => t.BackgroundSprite != 0u).ToList();
|
||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D92u); // Open left-cap
|
||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D94u); // Open center
|
||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D96u); // Open right-cap
|
||
}
|
||
|
||
[Fact]
|
||
public void TabButtons_SkillsAndTitles_AddClosedSpriteIdsToRoot()
|
||
{
|
||
// Skills + Titles tabs (isOpen=false): Closed sprite ids appear on root.
|
||
var layout = Fake();
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
var sprites = layout.Root.Children.OfType<UiText>()
|
||
.Where(t => t.BackgroundSprite != 0u).ToList();
|
||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D93u); // Closed left-cap
|
||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D95u); // Closed center
|
||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D97u); // Closed right-cap
|
||
}
|
||
|
||
[Fact]
|
||
public void TabButtons_WithSpriteResolve_AddsAllThreeTabsToRoot()
|
||
{
|
||
// With spriteResolve: all 3 tabs inject 4 children each (3 sprites + 1 label)
|
||
// = 12 sprite children total across 3 tabs on the root.
|
||
var layout = Fake();
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
// 3 tabs × 3 sprites = 9 sprite UiTexts on root.
|
||
var sprites = layout.Root.Children.OfType<UiText>()
|
||
.Where(t => t.BackgroundSprite != 0u).ToList();
|
||
Assert.Equal(9, sprites.Count);
|
||
}
|
||
|
||
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
|
||
|
||
[Fact]
|
||
public void SkillsTab_Click_RebuildsListWithRetailBucketsAndRows()
|
||
{
|
||
var list = new UiPanel { Width = 300 };
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
ClickTab(layout, left: 92f);
|
||
|
||
var headerPanels = SkillHeaders(list);
|
||
var headers = headerPanels
|
||
.Select(c => c.Children.OfType<UiText>().First().LinesProvider()[0].Text)
|
||
.ToList();
|
||
Assert.Equal(new[]
|
||
{
|
||
"Specialized Skills",
|
||
"Trained Skills",
|
||
"Untrained Skills",
|
||
"Unusable Skills",
|
||
}, headers);
|
||
Assert.Equal(new[] { 0x06000F90u, 0x06000F86u, 0x06000F98u, 0x06000F89u },
|
||
headerPanels.Select(h => h.BackgroundSprite).ToArray());
|
||
Assert.All(headerPanels, h =>
|
||
Assert.Equal(Vector4.One, h.Children.OfType<UiText>().First().LinesProvider()[0].Color));
|
||
|
||
var rows = SkillRows(list);
|
||
Assert.Equal(12, rows.Count);
|
||
Assert.All(rows, row =>
|
||
{
|
||
Assert.Equal(Vector4.Zero, row.BackgroundColor);
|
||
Assert.Equal(0u, row.BackgroundSprite);
|
||
Assert.True(row.UseSelectionBars);
|
||
});
|
||
var rowNames = rows
|
||
.Select(r => r.Children.OfType<UiText>().ToList()[1].LinesProvider()[0].Text)
|
||
.ToList();
|
||
Assert.Equal(new[]
|
||
{
|
||
"Melee Defense", "War Magic",
|
||
"Arcane Lore", "Life Magic", "Missile Weapons",
|
||
"Healing", "Jump", "Loyalty", "Run",
|
||
"Alchemy", "Cooking", "Fletching",
|
||
}, rowNames);
|
||
|
||
var meleeTexts = rows[0].Children.OfType<UiText>().ToList();
|
||
Assert.Equal(Vector4.One, meleeTexts[1].LinesProvider()[0].Color);
|
||
Assert.Equal(new Vector4(0.55f, 1f, 0.55f, 1f), meleeTexts[2].LinesProvider()[0].Color);
|
||
|
||
var healingTexts = rows[5].Children.OfType<UiText>().ToList();
|
||
Assert.Equal(Vector4.One, healingTexts[1].LinesProvider()[0].Color);
|
||
Assert.Equal(Vector4.One, healingTexts[2].LinesProvider()[0].Color);
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillsTab_ClickThenAttributesTab_RestoresAttributeRows()
|
||
{
|
||
var list = new UiPanel { Width = 300 };
|
||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
ClickTab(layout, left: 92f);
|
||
Assert.Equal(12, SkillRows(list).Count);
|
||
|
||
ClickTab(layout, left: 0f);
|
||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||
Assert.Equal(9, rows.Count);
|
||
Assert.Equal("Strength", rows[0].Children.OfType<UiText>().ToList()[1].LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillsTab_MouseClick_RebuildsVisibleListWhenListIdIsDuplicated()
|
||
{
|
||
var root = new UiPanel { Width = 300, Height = 600 };
|
||
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
|
||
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
|
||
var name = new UiText();
|
||
var visibleList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
|
||
var hiddenDuplicateList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
|
||
|
||
attrPage.AddChild(name);
|
||
attrPage.AddChild(visibleList);
|
||
hiddenPage.AddChild(hiddenDuplicateList);
|
||
root.AddChild(attrPage);
|
||
root.AddChild(hiddenPage);
|
||
|
||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||
{
|
||
[CharacterStatController.NameId] = name,
|
||
// Mirrors the real import: the id dictionary can point at a hidden duplicate.
|
||
[CharacterStatController.ListBoxId] = hiddenDuplicateList,
|
||
});
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
Assert.Equal("Strength", FirstRowName(visibleList));
|
||
Assert.Equal("<no row>", FirstRowName(hiddenDuplicateList));
|
||
|
||
var ui = new UiRoot { Width = 300, Height = 600 };
|
||
ui.AddChild(root);
|
||
Assert.IsType<UiClickablePanel>(ui.Pick(132, 12));
|
||
|
||
ui.OnMouseDown(UiMouseButton.Left, 132, 12);
|
||
ui.OnMouseUp(UiMouseButton.Left, 132, 12);
|
||
|
||
Assert.Equal("Melee Defense", FirstRowName(visibleList));
|
||
Assert.Equal("<no row>", FirstRowName(hiddenDuplicateList));
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillsTab_SelectWarMagic_ShowsTrainedSkillFooter()
|
||
{
|
||
var list = new UiPanel { Width = 300 };
|
||
var title = new UiText();
|
||
var l1Label = new UiText();
|
||
var l1Value = new UiText();
|
||
var l2Label = new UiText();
|
||
var l2Value = new UiText();
|
||
var layout = Fake(
|
||
(CharacterStatController.ListBoxId, list),
|
||
(CharacterStatController.FooterTitleId, title),
|
||
(CharacterStatController.FooterLine1Label, l1Label),
|
||
(CharacterStatController.FooterLine1Value, l1Value),
|
||
(CharacterStatController.FooterLine2Label, l2Label),
|
||
(CharacterStatController.FooterLine2Value, l2Value));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
ClickTab(layout, left: 92f);
|
||
var rows = SkillRows(list);
|
||
rows[1].OnClick!();
|
||
|
||
Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text);
|
||
Assert.Equal("Experience To Raise:", l1Label.LinesProvider()[0].Text);
|
||
Assert.Equal((11_100_000L).ToString("N0"), l1Value.LinesProvider()[0].Text);
|
||
Assert.Equal("Unassigned Experience:", l2Label.LinesProvider()[0].Text);
|
||
Assert.Equal((87_757_321_741L).ToString("N0"), l2Value.LinesProvider()[0].Text);
|
||
Assert.Equal(0x06001397u, rows[1].BackgroundSprite);
|
||
Assert.True(rows[1].UseSelectionBars);
|
||
Assert.Equal(Vector4.Zero, rows[1].BackgroundColor);
|
||
Assert.Equal(0u, rows[0].BackgroundSprite);
|
||
Assert.True(rows[0].UseSelectionBars);
|
||
Assert.Equal(Vector4.Zero, rows[0].BackgroundColor);
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillsTab_ClickRaiseTen_EmitsSkillRaiseRequest()
|
||
{
|
||
var list = new UiPanel { Width = 300 };
|
||
var btn10 = MakeButton();
|
||
var layout = Fake(
|
||
(CharacterStatController.ListBoxId, list),
|
||
(CharacterStatController.RaiseTenId, btn10));
|
||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16),
|
||
onRaiseRequest: requests.Add);
|
||
|
||
ClickTab(layout, left: 92f);
|
||
SkillRows(list)[1].OnClick!();
|
||
btn10.OnClick!();
|
||
|
||
var request = Assert.Single(requests);
|
||
Assert.Equal(CharacterStatController.RaiseTargetKind.Skill, request.Kind);
|
||
Assert.Equal(34u, request.StatId);
|
||
Assert.Equal(111_000_000L, request.Cost);
|
||
Assert.Equal(10, request.Amount);
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillsTab_SelectHealing_ShowsUntrainedSkillFooter()
|
||
{
|
||
var list = new UiPanel { Width = 300 };
|
||
var title = new UiText();
|
||
var l1Label = new UiText();
|
||
var l1Value = new UiText();
|
||
var l2Label = new UiText();
|
||
var l2Value = new UiText();
|
||
var btn1 = MakeButton();
|
||
var layout = Fake(
|
||
(CharacterStatController.ListBoxId, list),
|
||
(CharacterStatController.FooterTitleId, title),
|
||
(CharacterStatController.FooterLine1Label, l1Label),
|
||
(CharacterStatController.FooterLine1Value, l1Value),
|
||
(CharacterStatController.FooterLine2Label, l2Label),
|
||
(CharacterStatController.FooterLine2Value, l2Value),
|
||
(CharacterStatController.RaiseOneId, btn1));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
ClickTab(layout, left: 92f);
|
||
SkillRows(list)[5].OnClick!();
|
||
|
||
Assert.Equal("Healing", title.LinesProvider()[0].Text);
|
||
Assert.Equal("Skill Credits To Raise:", l1Label.LinesProvider()[0].Text);
|
||
Assert.Equal("6", l1Value.LinesProvider()[0].Text);
|
||
Assert.Equal("Skill Credits Available:", l2Label.LinesProvider()[0].Text);
|
||
Assert.Equal("96", l2Value.LinesProvider()[0].Text);
|
||
Assert.True(btn1.Visible);
|
||
Assert.Equal("Normal", btn1.ActiveState);
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillsTab_ClickUntrainedSkill_EmitsTrainSkillRequest()
|
||
{
|
||
var list = new UiPanel { Width = 300 };
|
||
var btn1 = MakeButton();
|
||
var layout = Fake(
|
||
(CharacterStatController.ListBoxId, list),
|
||
(CharacterStatController.RaiseOneId, btn1));
|
||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16),
|
||
onRaiseRequest: requests.Add);
|
||
|
||
ClickTab(layout, left: 92f);
|
||
SkillRows(list)[5].OnClick!();
|
||
btn1.OnClick!();
|
||
|
||
var request = Assert.Single(requests);
|
||
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
|
||
Assert.Equal(21u, request.StatId);
|
||
Assert.Equal(6L, request.Cost);
|
||
Assert.Equal(1, request.Amount);
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillsTab_ClickTrain_RebuildsSelectedSkillAsTrained()
|
||
{
|
||
var list = new UiPanel { Width = 300 };
|
||
var btn1 = MakeButton();
|
||
var btn10 = MakeButton();
|
||
CharacterSheet sheet = new()
|
||
{
|
||
SkillCredits = 10,
|
||
UnassignedXp = 1_000,
|
||
Skills = new[]
|
||
{
|
||
new CharacterSkill(100u, "Train Me", 0x06000001u,
|
||
CharacterSkillAdvancementClass.Untrained,
|
||
BaseLevel: 5,
|
||
CurrentLevel: 5,
|
||
UsableUntrained: true,
|
||
TrainedCost: 4,
|
||
SpecializedCost: 0,
|
||
RaiseCost: 0,
|
||
Raise10Cost: 0),
|
||
},
|
||
};
|
||
var layout = Fake(
|
||
(CharacterStatController.ListBoxId, list),
|
||
(CharacterStatController.RaiseOneId, btn1),
|
||
(CharacterStatController.RaiseTenId, btn10));
|
||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||
|
||
CharacterStatController.Bind(layout, () => sheet,
|
||
spriteResolve: id => (id, 16, 16),
|
||
onRaiseRequest: request =>
|
||
{
|
||
requests.Add(request);
|
||
sheet = new CharacterSheet
|
||
{
|
||
SkillCredits = 6,
|
||
UnassignedXp = 1_000,
|
||
Skills = new[]
|
||
{
|
||
new CharacterSkill(100u, "Train Me", 0x06000001u,
|
||
CharacterSkillAdvancementClass.Trained,
|
||
BaseLevel: 5,
|
||
CurrentLevel: 5,
|
||
UsableUntrained: true,
|
||
TrainedCost: 4,
|
||
SpecializedCost: 0,
|
||
RaiseCost: 10,
|
||
Raise10Cost: 100),
|
||
},
|
||
};
|
||
});
|
||
|
||
ClickTab(layout, left: 92f);
|
||
SkillRows(list).Single().OnClick!();
|
||
Assert.False(btn10.Visible);
|
||
|
||
btn1.OnClick!();
|
||
|
||
var request = Assert.Single(requests);
|
||
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
|
||
Assert.Equal("Train Me", SkillRows(list).Single().Children.OfType<UiText>().ToList()[1].LinesProvider()[0].Text);
|
||
Assert.True(btn1.Visible);
|
||
Assert.True(btn10.Visible);
|
||
Assert.Equal("Normal", btn1.ActiveState);
|
||
Assert.Equal("Normal", btn10.ActiveState);
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillsTab_BindsCharacterScrollbarToScrollableViewport()
|
||
{
|
||
var root = new UiPanel { Width = 300, Height = 600 };
|
||
var page = new UiPanel { Width = 300, Height = 600 };
|
||
var name = new UiText();
|
||
var list = MakeDatElement(CharacterStatController.ListBoxId, top: 137, width: 300, height: 80);
|
||
var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 137, width: 16, height: 80);
|
||
scrollbarShell.Left = 281;
|
||
|
||
page.AddChild(name);
|
||
page.AddChild(list);
|
||
page.AddChild(scrollbarShell);
|
||
root.AddChild(page);
|
||
|
||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||
{
|
||
[CharacterStatController.NameId] = name,
|
||
[CharacterStatController.ListBoxId] = list,
|
||
[CharacterStatController.ListScrollbarId] = scrollbarShell,
|
||
});
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||
spriteResolve: id => (id, 16, 16));
|
||
|
||
ClickTab(layout, left: 92f);
|
||
|
||
var bar = page.Children.OfType<UiScrollbar>().Single();
|
||
Assert.True(bar.Visible);
|
||
Assert.NotNull(bar.Model);
|
||
Assert.Contains(list.Children, c => c is UiScrollablePanel);
|
||
Assert.False(scrollbarShell.Visible);
|
||
}
|
||
|
||
[Fact]
|
||
public void GetRaiseCost_Index4Focus_Returns110()
|
||
{
|
||
var sheet = SampleData.SampleCharacter();
|
||
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
|
||
}
|
||
|
||
[Fact]
|
||
public void GetRaiseCost_Amount10Index4Focus_Returns1100()
|
||
{
|
||
var sheet = SampleData.SampleCharacter();
|
||
Assert.Equal(1_100L, CharacterStatController.GetRaiseCost(sheet, 4, amount: 10));
|
||
}
|
||
|
||
[Fact]
|
||
public void GetRaiseCost_Index0Strength_Returns0()
|
||
{
|
||
var sheet = SampleData.SampleCharacter();
|
||
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 0));
|
||
}
|
||
|
||
[Fact]
|
||
public void GetRaiseCost_OutOfRange_Returns0()
|
||
{
|
||
var sheet = SampleData.SampleCharacter();
|
||
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 99));
|
||
}
|
||
|
||
// ── GetRowName helper ─────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void GetRowName_Index0_ReturnsStrength()
|
||
=> Assert.Equal("Strength", CharacterStatController.GetRowName(0));
|
||
|
||
[Fact]
|
||
public void GetRowName_Index4_ReturnsFocus()
|
||
=> Assert.Equal("Focus", CharacterStatController.GetRowName(4));
|
||
|
||
[Fact]
|
||
public void GetRowName_Index6_ReturnsHealth()
|
||
=> Assert.Equal("Health", CharacterStatController.GetRowName(6));
|
||
|
||
[Fact]
|
||
public void GetRowName_NegativeIndex_ReturnsEmpty()
|
||
=> Assert.Equal(string.Empty, CharacterStatController.GetRowName(-1));
|
||
|
||
// ── SampleData sanity ─────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void SampleCharacter_SkillCredits_Is96()
|
||
=> Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
|
||
|
||
[Fact]
|
||
public void SampleCharacter_UnassignedXp_IsSet()
|
||
=> Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp);
|
||
|
||
[Fact]
|
||
public void SampleCharacter_AttributeRaiseCosts_HasNineEntries()
|
||
{
|
||
var costs = SampleData.SampleCharacter().AttributeRaiseCosts;
|
||
Assert.NotNull(costs);
|
||
Assert.Equal(9, costs.Length);
|
||
}
|
||
|
||
[Fact]
|
||
public void SampleCharacter_AttributeRaise10Costs_HasNineEntries()
|
||
{
|
||
var costs = SampleData.SampleCharacter().AttributeRaise10Costs;
|
||
Assert.NotNull(costs);
|
||
Assert.Equal(9, costs.Length);
|
||
}
|
||
|
||
[Fact]
|
||
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
|
||
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
|
||
|
||
[Fact]
|
||
public void SampleCharacter_AttributeRaise10Costs_FocusAt1100()
|
||
=> Assert.Equal(1_100L, SampleData.SampleCharacter().AttributeRaise10Costs[4]);
|
||
|
||
[Fact]
|
||
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
|
||
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
|
||
|
||
// ── UiText flag sanity ────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void UiText_RightAligned_DefaultFalse()
|
||
{
|
||
var t = new UiText();
|
||
Assert.False(t.RightAligned);
|
||
}
|
||
|
||
[Fact]
|
||
public void UiText_RightAligned_CanBeSetTrue()
|
||
{
|
||
var t = new UiText { RightAligned = true };
|
||
Assert.True(t.RightAligned);
|
||
}
|
||
|
||
// ── Header captions (new — LevelCaptionId + TotalXpLabelId) ─────────────
|
||
|
||
[Fact]
|
||
public void Bind_LevelCaptionId_SetsCharacterLevelText()
|
||
{
|
||
var caption = new UiText();
|
||
var layout = Fake((CharacterStatController.LevelCaptionId, caption));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
// 2-line caption: "Character" (top) / "Level" (bottom) so it fits the 65px element.
|
||
var lines = caption.LinesProvider();
|
||
Assert.True(lines.Count >= 1, "LevelCaption must provide at least one line");
|
||
Assert.Equal("Character", lines[0].Text);
|
||
Assert.False(caption.Centered, "LevelCaption must be left-justified (Centered=false)");
|
||
Assert.False(caption.RightAligned, "LevelCaption must be left-justified (RightAligned=false)");
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_TotalXpLabelId_SetsTotalExperienceXpText()
|
||
{
|
||
var lbl = new UiText();
|
||
var layout = Fake((CharacterStatController.TotalXpLabelId, lbl));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.Equal("Total Experience (XP):", lbl.LinesProvider()[0].Text);
|
||
Assert.False(lbl.Centered, "TotalXpLabel must be left-justified (Centered=false)");
|
||
Assert.False(lbl.RightAligned, "TotalXpLabel must be left-justified (RightAligned=false)");
|
||
}
|
||
|
||
// ── Polish Commit 1: name white, Infinity!, white footer title ─────────────
|
||
|
||
[Fact]
|
||
public void Bind_NameColor_IsWhite()
|
||
{
|
||
// Retail: name "Horan" is WHITE, not gold. (2026-06-26 ref)
|
||
var name = new UiText();
|
||
var layout = Fake((CharacterStatController.NameId, name));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var color = name.LinesProvider()[0].Color;
|
||
Assert.Equal(1f, color.X, precision: 3);
|
||
Assert.Equal(1f, color.Y, precision: 3);
|
||
Assert.Equal(1f, color.Z, precision: 3);
|
||
Assert.Equal(1f, color.W, precision: 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_MaxedRow_FooterLine1ValueIsInfinity()
|
||
{
|
||
// Strength (index 0) cost=0 → "Infinity!" per retail spec.
|
||
var val = new UiText();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.FooterLine1Value, val),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // Strength
|
||
|
||
Assert.Equal("Infinity!", val.LinesProvider()[0].Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void RowClick_SelectedFooterTitle_IsWhite()
|
||
{
|
||
// State B footer title must be WHITE (retail 2026-06-26 ref).
|
||
var title = new UiText();
|
||
var list = new UiPanel();
|
||
var layout = Fake(
|
||
(CharacterStatController.FooterTitleId, title),
|
||
(CharacterStatController.ListBoxId, list));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // Focus
|
||
|
||
var color = title.LinesProvider()[0].Color;
|
||
Assert.Equal(1f, color.X, precision: 3);
|
||
Assert.Equal(1f, color.Y, precision: 3);
|
||
Assert.Equal(1f, color.Z, precision: 3);
|
||
Assert.Equal(1f, color.W, precision: 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_FooterStateA_TitleColor_IsBodyNotWhite()
|
||
{
|
||
// State A title is body (parchment), not white.
|
||
var title = new UiText();
|
||
var layout = Fake((CharacterStatController.FooterTitleId, title));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
// Body = (0.92, 0.90, 0.82, 1.0) — check it's not pure white.
|
||
var color = title.LinesProvider()[0].Color;
|
||
Assert.True(color.X < 1f || color.Y < 1f || color.Z < 1f,
|
||
"State-A title should be body/parchment color, not pure white");
|
||
}
|
||
|
||
[Fact]
|
||
public void Bind_LevelCaptionId_SetsTwoLines()
|
||
{
|
||
// Retail: level caption is "Character" / "Level" on two lines (not truncated).
|
||
var caption = new UiText();
|
||
var layout = Fake((CharacterStatController.LevelCaptionId, caption));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
var lines = caption.LinesProvider();
|
||
Assert.Equal(2, lines.Count);
|
||
Assert.Equal("Character", lines[0].Text);
|
||
Assert.Equal("Level", lines[1].Text);
|
||
}
|
||
|
||
// ── Robustness ────────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void Bind_MissingElements_DoesNotThrow()
|
||
=> CharacterStatController.Bind(Fake(), SampleData.SampleCharacter);
|
||
|
||
// ── Fix 5: XP meter text children bound via FindElement ──────────────────
|
||
|
||
/// <summary>
|
||
/// Fix 5: the XP label (0x10000237) is a dat-origin UiText child of the XP meter
|
||
/// (now built by the importer). After Bind(), FindElement must return a UiText with
|
||
/// a LinesProvider that emits "XP for next level:".
|
||
/// </summary>
|
||
[Fact]
|
||
public void Bind_XpMeter_XpNextLabel_IsBoundViaFindElement()
|
||
{
|
||
var meter = new UiMeter();
|
||
var xpLabel = new UiText();
|
||
// Attach the label as a child of the meter so it matches the real importer layout.
|
||
meter.AddChild(xpLabel);
|
||
var layout = Fake(
|
||
(CharacterStatController.XpMeterId, meter),
|
||
(CharacterStatController.XpNextLabelId, xpLabel));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.NotNull(xpLabel.LinesProvider);
|
||
var lines = xpLabel.LinesProvider();
|
||
Assert.Single(lines);
|
||
Assert.Equal("XP for next level:", lines[0].Text);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fix 5: the XP value (0x10000238) is bound to the XpToNextLevel string.
|
||
/// ClickThrough=true and RightAligned=true must be set by the controller.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Bind_XpMeter_XpNextValue_IsBoundViaFindElement()
|
||
{
|
||
var meter = new UiMeter();
|
||
var xpValue = new UiText();
|
||
meter.AddChild(xpValue);
|
||
var layout = Fake(
|
||
(CharacterStatController.XpMeterId, meter),
|
||
(CharacterStatController.XpNextValueId, xpValue));
|
||
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||
|
||
Assert.NotNull(xpValue.LinesProvider);
|
||
Assert.True(xpValue.ClickThrough, "XP value overlay must be ClickThrough");
|
||
Assert.True(xpValue.RightAligned, "XP value overlay must be RightAligned");
|
||
|
||
var lines = xpValue.LinesProvider();
|
||
Assert.Single(lines);
|
||
// XpToNextLevel from SampleData = 42_000_000L formatted as "42,000,000"
|
||
Assert.Equal((42_000_000L).ToString("N0"), lines[0].Text);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fix 5: if XpNextLabelId / XpNextValueId are absent from the layout (the old
|
||
/// ConsumesDatChildren path, or a test layout that doesn't include them), Bind()
|
||
/// must not throw — the meter Fill is still bound.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Bind_XpMeter_MissingTextChildren_DoesNotThrow()
|
||
{
|
||
var meter = new UiMeter();
|
||
var layout = Fake((CharacterStatController.XpMeterId, meter));
|
||
|
||
// No XpNextLabelId or XpNextValueId in the layout.
|
||
var ex = Record.Exception(() =>
|
||
CharacterStatController.Bind(layout, SampleData.SampleCharacter));
|
||
|
||
Assert.Null(ex);
|
||
// Fill must still be bound.
|
||
Assert.NotNull(meter.Fill());
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
private static void ClickTab(ImportedLayout layout, float left)
|
||
{
|
||
var tab = layout.Root.Children.OfType<UiClickablePanel>()
|
||
.Single(p => System.Math.Abs(p.Left - left) < 0.5f
|
||
&& System.Math.Abs(p.Top) < 0.5f
|
||
&& System.Math.Abs(p.Height - 25f) < 0.5f);
|
||
tab.OnClick!();
|
||
}
|
||
|
||
private static string FirstRowName(UiElement list)
|
||
{
|
||
var row = Descendants(list).OfType<UiClickablePanel>().FirstOrDefault();
|
||
if (row is null) return "<no row>";
|
||
var texts = row.Children.OfType<UiText>().ToList();
|
||
return texts.Count > 1 ? texts[1].LinesProvider()[0].Text : "<no text>";
|
||
}
|
||
|
||
private static List<UiClickablePanel> SkillRows(UiElement list)
|
||
=> Descendants(list).OfType<UiClickablePanel>().ToList();
|
||
|
||
private static List<UiPanel> SkillHeaders(UiElement list)
|
||
=> Descendants(list)
|
||
.Where(c => c is UiPanel and not UiClickablePanel
|
||
&& c.Children.OfType<UiText>().Any())
|
||
.Cast<UiPanel>()
|
||
.ToList();
|
||
|
||
private static IEnumerable<UiElement> Descendants(UiElement root)
|
||
{
|
||
foreach (var child in root.Children)
|
||
{
|
||
yield return child;
|
||
foreach (var nested in Descendants(child))
|
||
yield return nested;
|
||
}
|
||
}
|
||
|
||
private static UiDatElement MakeDatElement(uint id, float top, float width, float height)
|
||
{
|
||
var info = new ElementInfo
|
||
{
|
||
Id = id,
|
||
Type = 3,
|
||
Y = top,
|
||
Width = width,
|
||
Height = height,
|
||
};
|
||
return new UiDatElement(info, static _ => (0u, 0, 0))
|
||
{
|
||
Top = top,
|
||
Width = width,
|
||
Height = height,
|
||
};
|
||
}
|
||
|
||
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
|
||
private static UiButton MakeButton(uint id = 0u)
|
||
{
|
||
var info = new ElementInfo { Id = id, Type = 1 };
|
||
return new UiButton(info, static _ => (0u, 0, 0));
|
||
}
|
||
|
||
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
|
||
{
|
||
var dict = new Dictionary<uint, UiElement>();
|
||
var root = new UiPanel();
|
||
foreach (var (id, e) in items)
|
||
{
|
||
root.AddChild(e);
|
||
dict[id] = e;
|
||
}
|
||
return new ImportedLayout(root, dict);
|
||
}
|
||
}
|