using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Net.Messages; namespace AcDream.App.Tests.UI.Layout; public sealed class CreatureAppraisalRowsTests { [Fact] public void FailedAssessmentShowsOnlyHealthPercentAndUnknownOtherValues() { var profile = Profile( health: 25u, healthMax: 100u, highlights: (ushort)0, colors: (ushort)0); IReadOnlyList rows = CreatureAppraisalRows.Build(profile, success: false); Assert.All( rows, row => Assert.Equal( CreatureAppraisalValueStyle.Incomplete, row.Style)); Assert.Equal("25 %", rows[6].Value); Assert.Equal("???", rows[0].Value); Assert.Equal("???", rows[7].Value); Assert.Equal("???", rows[8].Value); } [Fact] public void EnchantmentBitsSelectRetailPositiveAndNegativeStyles() { var profile = Profile( health: 100u, healthMax: 100u, highlights: (ushort)((1 << 0) | (1 << 6)), colors: (ushort)(1 << 0)); IReadOnlyList rows = CreatureAppraisalRows.Build(profile, success: true); Assert.Equal(CreatureAppraisalValueStyle.Positive, rows[0].Style); Assert.Equal(CreatureAppraisalValueStyle.Negative, rows[6].Style); Assert.Equal(CreatureAppraisalValueStyle.Normal, rows[1].Style); } [Fact] public void ExtraRatingsFollowRetailGroupingFormattingAndSeparators() { var properties = new PropertyBundle(); properties.Ints[0x133u] = 35; properties.Ints[0x139u] = 7; properties.Ints[0x13Au] = 4; properties.Ints[0x134u] = 12; properties.Ints[0x13Bu] = 3; properties.Ints[0x13Cu] = 2; properties.Ints[0x15Eu] = 9; properties.Ints[0x15Fu] = 6; IReadOnlyList rows = CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false); Assert.Equal(5, rows.Count); Assert.Equal(("", ""), (rows[0].Label, rows[0].Value)); Assert.Equal( ("Dmg/CritDmg", "Rating: 35/4"), (rows[1].Label, rows[1].Value)); Assert.Equal( ("Dmg/CritDmg", "Resist: 12/2"), (rows[2].Label, rows[2].Value)); Assert.Equal( ("DoT/Life:", "Resist: 9/6"), (rows[3].Label, rows[3].Value)); Assert.Equal(("", ""), (rows[4].Label, rows[4].Value)); } [Fact] public void CritOnlyTriggersRatingRowWhileHealingBoostAloneDoesNot() { var critOnly = new PropertyBundle(); critOnly.Ints[0x139u] = 8; IReadOnlyList rows = CreatureAppraisalRows.BuildExtra(critOnly, armorLevels: null, character: false); Assert.Equal(3, rows.Count); Assert.Equal("Rating: 0/0", rows[1].Value); var healingOnly = new PropertyBundle(); healingOnly.Ints[0x143u] = 20; Assert.Empty( CreatureAppraisalRows.BuildExtra( healingOnly, armorLevels: null, character: false)); } // ── Campaign AS slice AS3: armor-level trio + extras-list ordering ──── // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md // §2b rows 3-7 + 15, rulings R3 (legend unconditional) and R4 (spacer // discipline). Decomp anchors: CharExamineUI::SetAppraiseInfo // @0x004B45F0 (armor-level trio @0x004B4FD1-@0x004B5410, legend // @0x004B5D7D-@0x004B5DED) and CreatureExamineUI::SetAppraiseInfo // @0x004B3FF0 (ratings-only; no trio, no legend). [Fact] public void ArmorLevelTrioUsesRetailGroupingLabelsAndFormatPrecedingRatings() { var levels = new AppraiseInfoParser.ArmorLevel( Head: 100, Chest: 110, Abdomen: 120, UpperArm: 130, LowerArm: 140, Hand: 150, UpperLeg: 160, LowerLeg: 170, Foot: 180); var properties = new PropertyBundle(); properties.Ints[0x133u] = 35; // DamageRating -> also triggers ratings block IReadOnlyList rows = CreatureAppraisalRows.BuildExtra(properties, levels, character: true); // [0] spacer, [1..3] AL trio, [4] spacer, [5] rating, [6] spacer, // [7] legend. Assert.Equal(8, rows.Count); Assert.Equal(("", ""), (rows[0].Label, rows[0].Value)); Assert.Equal( ("Head/Chest/Groin", "AL: 100/110/120"), (rows[1].Label, rows[1].Value)); Assert.Equal( ("Bicep/Wrist/Hand", "AL: 130/140/150"), (rows[2].Label, rows[2].Value)); Assert.Equal( ("Thigh/Shin/Foot", "AL: 160/170/180"), (rows[3].Label, rows[3].Value)); Assert.Equal(("", ""), (rows[4].Label, rows[4].Value)); Assert.Equal( ("Dmg/CritDmg", "Rating: 35/0"), (rows[5].Label, rows[5].Value)); Assert.Equal(("", ""), (rows[6].Label, rows[6].Value)); Assert.Equal( ("* = Unenchantable", string.Empty), (rows[7].Label, rows[7].Value)); } [Theory] [InlineData(9998, "9998")] [InlineData(9999, "*0")] [InlineData(10123, "*124")] public void ArmorLevelPartRendersUnenchantableSentinelAtOrAbove9999( int value, string expected) { var levels = new AppraiseInfoParser.ArmorLevel( Head: value, Chest: 0, Abdomen: 0, UpperArm: 0, LowerArm: 0, Hand: 0, UpperLeg: 0, LowerLeg: 0, Foot: 0); IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( new PropertyBundle(), levels, character: true); CreatureAppraisalRow row = Assert.Single( rows, r => r.Label == "Head/Chest/Groin"); Assert.Equal($"AL: {expected}/0/0", row.Value); } [Fact] public void ArmorLevelRowMixesStarredAndPlainPartsIndependently() { var levels = new AppraiseInfoParser.ArmorLevel( Head: 50, Chest: 9999, Abdomen: 20000, UpperArm: 0, LowerArm: 0, Hand: 0, UpperLeg: 0, LowerLeg: 0, Foot: 0); IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( new PropertyBundle(), levels, character: true); CreatureAppraisalRow row = Assert.Single( rows, r => r.Label == "Head/Chest/Groin"); Assert.Equal("AL: 50/*0/*10001", row.Value); } [Fact] public void AllNineArmorLevelsZeroOrNegativeEmitsNoTrioAndNoSpacer() { var levels = new AppraiseInfoParser.ArmorLevel( Head: 0, Chest: 0, Abdomen: -5, UpperArm: 0, LowerArm: 0, Hand: 0, UpperLeg: 0, LowerLeg: 0, Foot: 0); IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( new PropertyBundle(), levels, character: true); Assert.DoesNotContain(rows, r => r.Label.Contains("Groin")); Assert.DoesNotContain(rows, r => r.Label.Contains("Hand")); Assert.DoesNotContain(rows, r => r.Label.Contains("Foot")); // Legend is still unconditional on the char path. Assert.Equal("* = Unenchantable", rows[^1].Label); } [Fact] public void ArmorLevelTrioAbsentWhenArmorLevelsIsNull() { var properties = new PropertyBundle(); properties.Ints[0x133u] = 35; IReadOnlyList rows = CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: true); Assert.DoesNotContain(rows, r => r.Value.StartsWith("AL:", StringComparison.Ordinal)); } [Fact] public void EachRatingRowGatesIndependently() { // Crit (313) alone gates the Rating row but never displays. var critOnly = new PropertyBundle(); critOnly.Ints[0x139u] = 1; IReadOnlyList critRows = CreatureAppraisalRows.BuildExtra(critOnly, armorLevels: null, character: false); Assert.Equal(3, critRows.Count); Assert.Equal(("Dmg/CritDmg", "Rating: 0/0"), (critRows[1].Label, critRows[1].Value)); // CritResist (315) alone gates the Resist row but never displays. var critResistOnly = new PropertyBundle(); critResistOnly.Ints[0x13Bu] = 1; IReadOnlyList critResistRows = CreatureAppraisalRows.BuildExtra(critResistOnly, armorLevels: null, character: false); Assert.Equal(3, critResistRows.Count); Assert.Equal( ("Dmg/CritDmg", "Resist: 0/0"), (critResistRows[1].Label, critResistRows[1].Value)); // 350/351 (DoT/Life) alone, independent of the other two families. var dotLifeOnly = new PropertyBundle(); dotLifeOnly.Ints[0x15Eu] = 4; IReadOnlyList dotLifeRows = CreatureAppraisalRows.BuildExtra(dotLifeOnly, armorLevels: null, character: false); Assert.Equal(3, dotLifeRows.Count); Assert.Equal( ("DoT/Life:", "Resist: 4/0"), (dotLifeRows[1].Label, dotLifeRows[1].Value)); } [Fact] public void LegendIsAbsentOnMonsterPathEvenWithRatingsShown() { var properties = new PropertyBundle(); properties.Ints[0x133u] = 35; IReadOnlyList rows = CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false); Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable"); } [Fact] public void LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras() { IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( new PropertyBundle(), armorLevels: null, character: true); CreatureAppraisalRow only = Assert.Single(rows); Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value)); } [Fact] public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry() { ElementInfo template = FixtureLoader.LoadExaminationRowTemplateInfos(); Assert.Equal(CreatureAppraisalRowTemplateFactory.TemplateId, template.Id); Assert.Equal((292f, 20f), (template.Width, template.Height)); ElementInfo label = Assert.Single( template.Children, child => child.Id == CreatureAppraisalRowTemplateFactory.LabelId); ElementInfo value = Assert.Single( template.Children, child => child.Id == CreatureAppraisalRowTemplateFactory.ValueId); Assert.Equal((0f, 128f), (label.X, label.Width)); Assert.Equal((27f, 256f), (value.X, value.Width)); Assert.Equal(0x40000001u, label.FontDid); Assert.Equal(0x40000001u, value.FontDid); } [Fact] public void CreatureNameResolverUsesLoadedRetailMappingAndSafeFallback() { var resolver = new CreatureDisplayNameResolver( new Dictionary { [77u] = "Ghost" }); Assert.Equal("Ghost", resolver.Resolve(77)); Assert.Equal(string.Empty, resolver.Resolve(0)); Assert.Equal(string.Empty, resolver.Resolve(999)); } [Fact] public void LayeredTemplateSeparatesChromeFromForegroundText() { var templates = new CreatureAppraisalRowTemplateFactory( FixtureLoader.LoadExaminationRowTemplateInfos(), _ => (0u, 0, 0), defaultFont: null); var row = new CreatureAppraisalRow( "Strength", "120", CreatureAppraisalValueStyle.Normal); UiTemplateListSlot background = templates.Create( row, CreatureAppraisalRowLayer.Background); UiTemplateListSlot foreground = templates.Create( row, CreatureAppraisalRowLayer.Foreground); Assert.Empty(((UiText)background.Content.FindElement( CreatureAppraisalRowTemplateFactory.LabelId)!).LinesProvider()); Assert.Empty(((UiText)background.Content.FindElement( CreatureAppraisalRowTemplateFactory.ValueId)!).LinesProvider()); Assert.False( Assert.IsType(foreground.Content.Root).MediaVisible); Assert.Equal( "Strength", Assert.Single(((UiText)foreground.Content.FindElement( CreatureAppraisalRowTemplateFactory.LabelId)!) .LinesProvider()).Text); Assert.Equal( "120", Assert.Single(((UiText)foreground.Content.FindElement( CreatureAppraisalRowTemplateFactory.ValueId)!) .LinesProvider()).Text); } private static AppraiseInfoParser.CreatureProfile Profile( uint health, uint healthMax, ushort highlights, ushort colors) => new( Flags: 0x09u, Health: health, HealthMax: healthMax, Strength: null, Endurance: null, Quickness: null, Coordination: null, Focus: null, Self: null, Stamina: 50u, Mana: 75u, StaminaMax: 100u, ManaMax: 100u, AttributeHighlights: highlights, AttributeColors: colors); }