acdream/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs
Erik 1e8596a440
All checks were successful
CI / linux-portable (push) Successful in 3m39s
CI / windows-gate (push) Successful in 6m41s
CI / release (push) Successful in 2m10s
fix(ui): retail tooltip rendering — formula-first compose and the two-pass sizing every tooltip was missing
Two corrections from the owner's retail-render oracle at the CA5 re-check,
both against readings the TS-85 register row had recorded as settled:

Compose (skill tooltips): retail is formula + newline + description —
GetTooltip @0x004f1fe0's operator+ has the InqSkillFormula output as the
LEFT operand; the old '"\n" + formula, no separator' reading had the
operand order backwards and produced a leading blank line with the formula
and description glued on one line. A formula-less skill (Salvaging) shows
the bare description, matching the failed-InqSkillFormula branch.

Sizing (ALL tooltips, per the owner's direction): retail sizes a tooltip
in TWO passes (StartTooltip @0x0045DE90) — measure-wrap at the max width,
resize the root through the authored ResizeTo clamps, then
RecalculateGlyphList RE-WRAPS the text at its final clamped width and a
second resize grows the root's HEIGHT for the extra lines. The branch the
register called 'a structural no-op' IS that second pass; without it a
description longer than the clamped popup stayed one clipped line, where
retail shows three. ApplyTooltipText now ports the full chain, so every
tooltip surface (items, options rows, character panel, world hover, map)
wraps and grows exactly as retail.

Pinned by BuildTooltip_FormulaFirstThenNewlineThenDescription,
BuildTooltip_FormulaLessSkillShowsBareDescription, and
LongTooltip_RewrapsAtTheClampedPopupWidth_AndGrowsHeightForTheExtraLines.
TS-85 carries both dated corrections. Owner visual re-check owed: skill
tooltip shows formula on line one, description below, long descriptions
wrapping to three-plus lines inside the parchment. Full hermetic suite
15,332 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:09:54 +02:00

295 lines
11 KiB
C#

using AcDream.App.Net;
using AcDream.Core.CharGen;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Net;
public sealed class RetailSkillFormulaTests
{
[Fact]
public void ZeroDivisorIsTheOnlyFormulaFailureGate()
{
SkillFormula invalid = Formula(w: 7, x: 1, y: 1, z: 0);
Assert.False(RetailSkillFormula.TryCalculate(invalid, 10u, 20u, out uint invalidResult));
Assert.Equal(0u, invalidResult);
SkillFormula zeroX = Formula(w: 7, x: 0, y: 2, z: 3);
Assert.True(RetailSkillFormula.TryCalculate(zeroX, 10u, 4u, out uint validResult));
Assert.Equal(5u, validResult);
}
[Theory]
[InlineData(9u, 4u, 2u)]
[InlineData(10u, 4u, 3u)]
[InlineData(11u, 4u, 3u)]
[InlineData(12u, 4u, 3u)]
public void DivisionRoundsToNearestWithExactHalvesUp(
uint numerator,
uint divisor,
uint expected)
{
SkillFormula formula = Formula(w: 0, x: 1, y: 0, z: divisor);
Assert.True(RetailSkillFormula.TryCalculate(
formula,
numerator,
0u,
out uint result));
Assert.Equal(expected, result);
}
[Fact]
public void AdditiveWordIsInsideTheNumerator()
{
SkillFormula formula = Formula(w: 3, x: 1, y: 1, z: 4);
Assert.True(RetailSkillFormula.TryCalculate(formula, 4u, 2u, out uint result));
Assert.Equal(2u, result);
}
[Fact]
public void NumeratorUsesUnsignedThirtyTwoBitWrap()
{
SkillFormula formula = Formula(w: 1, x: 2, y: 0, z: 2);
Assert.True(RetailSkillFormula.TryCalculate(
formula,
uint.MaxValue,
0u,
out uint result));
Assert.Equal(0x80000000u, result);
}
[Fact]
public void SignedDatStorageIsReinterpretedAsRetailUnsignedWords()
{
SkillFormula formula = Formula(w: -1, x: 0, y: 0, z: 1);
Assert.True(RetailSkillFormula.TryCalculate(formula, 0u, 0u, out uint result));
Assert.Equal(uint.MaxValue, result);
}
/// <summary>
/// F13 (night-round review): <see cref="RetailSkillFormula.FormatFormula"/>
/// previously read the raw signed <c>int</c> fields directly instead of
/// the SAME unsigned reinterpretation <see cref="RetailSkillFormula.TryCalculate"/>
/// uses. A multiplier whose stored bit pattern has the high bit set
/// would read as a small negative number here (failing the
/// <c>&gt;= 1</c> hasAttr gate, or printing a negative multiplier) instead
/// of the huge unsigned value <c>TryCalculate</c> actually computes
/// with for the SAME formula.
/// </summary>
[Fact]
public void FormatFormula_MultiplierIsReinterpretedAsRetailUnsignedWord()
{
SkillFormula formula = Formula(w: 0, x: -1, y: 0, z: 1);
formula.Attribute1 = AttributeId.Strength;
string? text = RetailSkillFormula.FormatFormula(formula);
Assert.NotNull(text);
Assert.Contains(uint.MaxValue.ToString(), text);
Assert.DoesNotContain("-1", text);
}
/// <summary>Same reinterpretation, but for the divisor and additive-bonus
/// suffixes rather than the multiplier — both must read unsigned too.</summary>
[Fact]
public void FormatFormula_DivisorAndAdditiveBonusAreReinterpretedAsRetailUnsignedWords()
{
SkillFormula formula = Formula(w: -1, x: 1, y: 0, z: unchecked((uint)-2));
formula.Attribute1 = AttributeId.Strength;
string? text = RetailSkillFormula.FormatFormula(formula);
Assert.NotNull(text);
Assert.Contains($"/ {unchecked((uint)-2)}", text);
Assert.Contains($"+{uint.MaxValue}", text);
Assert.DoesNotContain("-1", text);
Assert.DoesNotContain("-2", text);
}
[Fact]
public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero()
{
const uint skillId = 0x345u;
const uint firstAttributeId = 1u;
const uint secondAttributeId = 2u;
var skillTable = new SkillTable();
skillTable.Skills.Add((SkillId)skillId, new SkillBase
{
Formula = new SkillFormula
{
AdditiveBonus = 1,
Attribute1Multiplier = 1,
Attribute2Multiplier = 2,
Divisor = 3,
Attribute1 = (AttributeId)firstAttributeId,
Attribute2 = (AttributeId)secondAttributeId,
},
});
var resolver = new LiveSkillCreditResolver(skillTable);
uint result = resolver.Resolve(
skillId,
new Dictionary<uint, uint> { [firstAttributeId] = 8u });
Assert.Equal(3u, result);
Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary<uint, uint>()));
}
[Fact]
public void BuildTooltip_FormulaFirstThenNewlineThenDescription()
{
// CA5 gate correction (2026-08-24): retail renders the formula on
// its own FIRST line with the description below — GetTooltip
// @0x004f1fe0's operator+ has the InqSkillFormula output as the
// LEFT operand (formula + a newline), then appends the description;
// confirmed against the owner's retail-client render. The original
// reading (newline-first + formula, glued description) produced a leading
// blank line and a single glued line.
SkillFormula formula = Formula(w: 0, x: 1, y: 1, z: 2);
formula.Attribute1 = DatReaderWriter.Enums.AttributeId.Strength;
formula.Attribute2 = DatReaderWriter.Enums.AttributeId.Coordination;
var skillBase = new SkillBase
{
Formula = formula,
Description = { Value = "Description text." },
};
string? tooltip = RetailSkillFormula.BuildTooltip(skillBase);
Assert.NotNull(tooltip);
Assert.False(tooltip!.StartsWith('\n'));
int split = tooltip.IndexOf('\n');
Assert.True(split > 0);
Assert.Equal("Description text.", tooltip[(split + 1)..]);
}
[Fact]
public void BuildTooltip_FormulaLessSkillShowsBareDescription()
{
// Retail's failed-InqSkillFormula branch (z==0) appends the
// description to a still-empty string — no leading break.
var skillBase = new SkillBase
{
Formula = Formula(w: 7, x: 1, y: 1, z: 0),
Description = { Value = "Salvage things." },
};
Assert.Equal("Salvage things.", RetailSkillFormula.BuildTooltip(skillBase));
}
private static SkillFormula Formula(int w, int x, int y, uint z) => new()
{
AdditiveBonus = w,
Attribute1Multiplier = x,
Attribute2Multiplier = y,
Divisor = unchecked((int)z),
};
// ── CC5 re-review residual round, R2 (2026-08-16): RetailSkillFormula.
// ── CalculateChargenScore / ChargenSkillScoreResolver had zero direct
// ── coverage — the F12(d) test substitutes `skillId * 10` instead of
// ── exercising the real formula. ─────────────────────────────────────
/// <summary>
/// <see cref="RetailSkillFormula.CalculateChargenScore"/>'s level bonus:
/// Untrained adds nothing, Trained adds 5, Specialized adds 10, on top
/// of the SAME base <see cref="RetailSkillFormula.TryCalculate"/> result
/// (formula here: <c>w=0, x=1, y=0, z=1</c> against
/// <c>attribute1=5</c> -&gt; base 5).
/// </summary>
[Theory]
[InlineData(ChargenSkillAdvancementClass.Untrained, 5u)]
[InlineData(ChargenSkillAdvancementClass.Trained, 10u)]
[InlineData(ChargenSkillAdvancementClass.Specialized, 15u)]
public void CalculateChargenScore_AddsTheLevelBonusOnTopOfTheBaseFormula(
ChargenSkillAdvancementClass level,
uint expected)
{
var skillBase = new SkillBase { Formula = Formula(w: 0, x: 1, y: 0, z: 1) };
uint result = RetailSkillFormula.CalculateChargenScore(skillBase, attribute1: 5u, attribute2: 0u, level);
Assert.Equal(expected, result);
}
/// <summary>
/// The divisor-zero skip path: <see cref="RetailSkillFormula.TryCalculate"/>'s
/// own failure gate short-circuits <see cref="RetailSkillFormula.CalculateChargenScore"/>
/// to a flat 0 BEFORE the level bonus is ever added — even for
/// Specialized, which would otherwise add 10.
/// </summary>
[Fact]
public void CalculateChargenScore_ZeroDivisor_ReturnsZero_RegardlessOfLevel()
{
var skillBase = new SkillBase { Formula = Formula(w: 7, x: 1, y: 1, z: 0) };
Assert.Equal(0u, RetailSkillFormula.CalculateChargenScore(
skillBase, 10u, 20u, ChargenSkillAdvancementClass.Untrained));
Assert.Equal(0u, RetailSkillFormula.CalculateChargenScore(
skillBase, 10u, 20u, ChargenSkillAdvancementClass.Trained));
Assert.Equal(0u, RetailSkillFormula.CalculateChargenScore(
skillBase, 10u, 20u, ChargenSkillAdvancementClass.Specialized));
}
/// <summary>
/// <see cref="ChargenSkillScoreResolver.Resolve"/>'s six-way
/// <c>ResolveAttribute</c> switch — one case per
/// <see cref="AttributeId"/> (Strength=1..Self=6) — resolving through a
/// formula that reads ONLY <c>Attribute1</c> (<c>x=1, y=0</c>) so the
/// result unambiguously reports which of the six
/// <see cref="ChargenAttributeValues"/> fields the switch actually read.
/// </summary>
[Theory]
[InlineData(AttributeId.Strength)]
[InlineData(AttributeId.Endurance)]
[InlineData(AttributeId.Coordination)]
[InlineData(AttributeId.Quickness)]
[InlineData(AttributeId.Focus)]
[InlineData(AttributeId.Self)]
public void ChargenSkillScoreResolver_ResolvesEachAttributeIdThroughTheSixWaySwitch(
AttributeId attributeId)
{
const uint skillId = 0x10u;
var skillTable = new SkillTable();
skillTable.Skills.Add((SkillId)skillId, new SkillBase
{
Formula = new SkillFormula
{
AdditiveBonus = 0,
Attribute1Multiplier = 1,
Attribute2Multiplier = 0,
Divisor = 1,
Attribute1 = attributeId,
// Attribute2 deliberately left at its zero default (Strength) —
// Attribute2Multiplier=0 means whatever it reads contributes
// nothing, so it cannot mask a wrong Attribute1 case.
},
});
var resolver = new ChargenSkillScoreResolver(skillTable);
ChargenAttributeValues attributes = AttributeValuesWith(attributeId, 42);
uint result = resolver.Resolve(skillId, attributes, ChargenSkillAdvancementClass.Untrained);
Assert.Equal(42u, result);
}
private static ChargenAttributeValues AttributeValuesWith(AttributeId attributeId, int value) =>
attributeId switch
{
AttributeId.Strength => new ChargenAttributeValues(value, 0, 0, 0, 0, 0),
AttributeId.Endurance => new ChargenAttributeValues(0, value, 0, 0, 0, 0),
AttributeId.Coordination => new ChargenAttributeValues(0, 0, value, 0, 0, 0),
AttributeId.Quickness => new ChargenAttributeValues(0, 0, 0, value, 0, 0),
AttributeId.Focus => new ChargenAttributeValues(0, 0, 0, 0, value, 0),
AttributeId.Self => new ChargenAttributeValues(0, 0, 0, 0, 0, value),
_ => default,
};
}