feat(D.2b): Character Attributes tab — 9-row list (icons + values + vitals) + footer State-A
Pass 1 of the Attributes tab interactive controller. Replaces the placeholder UiText attribute list with 9 real manual-layout rows matching retail's gmAttributeUI::PostInit (0x0049db70) structure: - 6 attribute rows (Strength/Endurance/Coordination/Quickness/Focus/Self) in retail display order (Coord enum 4 before Quick enum 3 — spec §1) - 3 vital rows (Health/Stamina/Mana) with cur/max format per Attribute2ndInfoRegion::Update (0x004f19e0) - Each row: icon (UiText.BackgroundSprite = 0x06xxxxxx RenderSurface via spriteResolve), name (left-justified), value (new RightAligned mode) Icon DataIDs from SubMap 0x25000006/0x25000007 via spec §2: STR 0x060002C8, END 0x060002C4, COORD 0x060002C9, QUICK 0x060002C6, FOCUS 0x060002C5, SELF 0x060002C7, HP 0x06004C3B, SP 0x06004C3C, MP 0x06004C3D Footer State-A (DisplayDefaultFooter 0x0049cde0): 0x1000024e title = "", 0x10000243 line-1-value = "Select an Attribute to Improve", 0x10000245 line-2-value = SkillCredits (InqInt(0x18)) Other changes: - UiText: add RightAligned bool (single-line right-justified, mirrors Centered path) - CharacterSheet: add SkillCredits property (retail InqInt(0x18)) - SampleData: update to spec fixture values (Str/Quick=200, rest=10, SkillCredits=96, Health=5/5, Stamina=10/10, Mana=10/10) - FixtureProvider: pass stack.ResolveChrome as spriteResolve for icon rendering - Tests: 13 targeted tests (9-row count, row order, attr+vital values, icon DataIDs, RightAligned flag, footer State-A all 5 elements) Pass 2 (selection/raise buttons) is separate per spec. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
27819514af
commit
902160098a
6 changed files with 552 additions and 74 deletions
|
|
@ -6,8 +6,8 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CharacterStatController"/> — the Attributes-tab controller that
|
||||
/// binds the REAL importer-mounted header + list elements (no created overlay). Pure data
|
||||
/// wiring against fake layouts; no dats, no GL.
|
||||
/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay).
|
||||
/// Pure data wiring against fake layouts; no dats, no GL.
|
||||
/// </summary>
|
||||
public class CharacterStatControllerTests
|
||||
{
|
||||
|
|
@ -64,26 +64,231 @@ public class CharacterStatControllerTests
|
|||
Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}");
|
||||
}
|
||||
|
||||
// ── Attribute list ───────────────────────────────────────────────────────
|
||||
// ── Attribute list — 9 rows in list box 0x1000023D ───────────────────────
|
||||
|
||||
/// <summary>Bind adds exactly 9 UiPanel row containers to the list box.</summary>
|
||||
[Fact]
|
||||
public void Bind_PopulatesAttributeList()
|
||||
public void Bind_AttributeList_Has9Rows()
|
||||
{
|
||||
var list = new UiPanel();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiText>().First();
|
||||
var text = string.Join("\n", rows.LinesProvider().Select(l => l.Text));
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
}
|
||||
|
||||
Assert.Contains("Strength", text);
|
||||
Assert.Contains("Endurance", text);
|
||||
Assert.Contains("Coordination", text);
|
||||
Assert.Contains("Quickness", text);
|
||||
Assert.Contains("Focus", text);
|
||||
Assert.Contains("Self", text);
|
||||
Assert.Contains("100", text);
|
||||
/// <summary>
|
||||
/// Every row must have a right-aligned value UiText child (the last UiText in
|
||||
/// the row), confirming the RightAligned path was wired.
|
||||
/// </summary>
|
||||
[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)
|
||||
{
|
||||
// Last UiText in each row is the value (right-aligned).
|
||||
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]; // last = value
|
||||
Assert.True(valueEl.RightAligned, "value label must be RightAligned");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail display order: Strength, Endurance, Coordination, Quickness, Focus, Self,
|
||||
/// Health, Stamina, Mana (spec §1). Name is the SECOND UiText child of each row.
|
||||
/// </summary>
|
||||
[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++)
|
||||
{
|
||||
// Name UiText = second UiText in the row (after the icon UiText).
|
||||
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; // index 1 = name (0 = icon)
|
||||
Assert.Equal(expectedNames[i], rowName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attribute row values are plain integers; vital row values are "cur/max".
|
||||
/// Validates against SampleData fixture: Str=200, Quick=200, rest=10;
|
||||
/// Health=5/5, Stamina=10/10, Mana=10/10.
|
||||
/// </summary>
|
||||
[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();
|
||||
|
||||
// Helper: value text is the last UiText in the row.
|
||||
string ValueOf(UiPanel row) => row.Children.OfType<UiText>().ToList()[^1].LinesProvider()[0].Text;
|
||||
|
||||
// Attribute rows 0-5.
|
||||
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
|
||||
|
||||
// Vital rows 6-8: "cur/max" format.
|
||||
Assert.Equal("5/5", ValueOf(rows[6])); // Health
|
||||
Assert.Equal("10/10", ValueOf(rows[7])); // Stamina
|
||||
Assert.Equal("10/10", ValueOf(rows[8])); // Mana
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When spriteResolve is provided the icon UiText must have BackgroundSprite set
|
||||
/// to the correct DataID; when null the BackgroundSprite must be 0.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bind_AttributeList_IconHasBackgroundSpriteWhenResolverProvided()
|
||||
{
|
||||
var list = new UiPanel();
|
||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||
|
||||
// Non-null resolver — just returns a fake handle for any id.
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16)); // fake: handle == id
|
||||
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
|
||||
// First icon = Strength row; expected DataID 0x060002C8.
|
||||
var iconEl = rows[0].Children.OfType<UiText>().First();
|
||||
Assert.Equal(0x060002C8u, iconEl.BackgroundSprite);
|
||||
|
||||
// Vital (Health) = row 6; expected DataID 0x06004C3B.
|
||||
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_TitleEmpty()
|
||||
{
|
||||
var title = new UiText();
|
||||
var layout = Fake((CharacterStatController.FooterTitleId, title));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal(string.Empty, title.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_FooterStateA_Line1LabelEmpty()
|
||||
{
|
||||
var lbl = new UiText();
|
||||
var layout = Fake((CharacterStatController.FooterLine1Label, lbl));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_FooterStateA_Line1ValueIsSelectPrompt()
|
||||
{
|
||||
var val = new UiText();
|
||||
var layout = Fake((CharacterStatController.FooterLine1Value, val));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal("Select an Attribute to Improve", val.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_FooterStateA_Line2LabelEmpty()
|
||||
{
|
||||
var lbl = new UiText();
|
||||
var layout = Fake((CharacterStatController.FooterLine2Label, lbl));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_FooterStateA_Line2ValueIsSkillCredits()
|
||||
{
|
||||
var val = new UiText();
|
||||
var layout = Fake((CharacterStatController.FooterLine2Value, val));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
// SampleData.SampleCharacter().SkillCredits == 96
|
||||
Assert.Equal("96", val.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
// ── SkillCredits propagation through SampleData ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SampleCharacter_SkillCredits_Is96()
|
||||
{
|
||||
Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
|
||||
}
|
||||
|
||||
// ── UiText.RightAligned — unit-level draw mode flag ──────────────────────
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
// ── Robustness: a partial layout (missing ids) must not throw ────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue