acdream/tests/AcDream.Plugins.MossTank.Tests/VtankSettingsProfileSerializerTests.cs
Erik 8fd3999616 fix(vt): I widen tDouble-declared distance fields from float to double
Item I (slice-1 fix round, finishing item 1). CaptureMatchesDeclaredSettingTypeAndValue's
~1.5e-7 float-round-trip tolerance was masking a real cause, not a real
value change: every tDouble-declared distance setting whose live field
was actually a float (CombatSettings.MaximumRange/MinimumRange/
ApproachDistance/RingDistance/ArcRange/TargetSelectAngleRange/
PetCustomRange/CollisionProjectileRadius/CollisionStepDistance/
SpellRangeFudge, Looting.CorpseApproachRange/CorpseMinimumApproachRange,
VitalPlan.HelperHealthDistance/HelperStaminaDistance/HelperManaDistance)
lost precision below float's ~7-significant-digit guarantee on every
load, since a loaded .usd's real value is a double.

- All 15 fields widened from float to double, matching their declared
  tDouble type. Every physics/combat call site that genuinely needs a
  float (IProjectileAutomation.EvaluatePath/EvaluatePathWithDiagnostics,
  ICombatAutomation.CaptureHostileTargets/CaptureCorpses, the
  fellow-distance Lowest() helper) now casts explicitly at that one use
  site (CombatController.cs, Looting.cs, VitalRecharge.cs,
  MossTankCommands.cs, PetAutomation.cs) instead of the field itself
  being narrowed everywhere it's stored.
- MossTankProfileStore's JSON DTOs (InventoryProfileDocument.
  CorpseApproachRange, CombatProfileDocument.MaximumRange/
  ApproachDistance/TargetSelectAngleRange/ArcRange/RingDistance/
  PetCustomRange) widened to match, so MossTank's own persisted profiles
  keep full precision too — their Apply()-side Math.Clamp calls needed no
  changes (the float literal bounds like 2f/100f already widen to the
  double overload implicitly).
- VtankSettingsProfileSerializer.Apply's `(float)(cell.AsDouble() * 240d)`
  casts and `cell.AsFloat()` calls for these 15 settings are now plain
  `cell.AsDouble()` / `cell.AsDouble() * 240d` — no narrowing at all.
- ValuesEqual's "d"/"f" branch dropped DoublesEqual/FloatRoundTripTolerance
  entirely: `a.AsDouble() == b.AsDouble()`, exact, matching every other
  tag. CaptureMatchesDeclaredSettingTypeAndValue's own test-side tolerance
  (a second, independently-tolerant comparison) removed the same way —
  all 135 catalog names now pass under Assert.Equal(exact) with zero
  special-casing.

Full MossTank suite: 581/581 (no count change — this is a precision fix,
not new coverage; CaptureMatchesDeclaredSettingTypeAndValue's own 135
cases already existed and now pass exactly instead of within tolerance).
App.Tests (Plugin|LaunchOptions filter): 84/84.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:27:09 +02:00

261 lines
12 KiB
C#

using System.Globalization;
using System.Linq;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class VtankSettingsProfileSerializerTests
{
private static readonly string FixturesRoot = Path.Combine(
AppContext.BaseDirectory, "Fixtures", "vtank");
public static TheoryData<string> UsdFixtureData()
{
var data = new TheoryData<string>();
foreach (string name in new[] { "defaultsettings", "owner-a", "owner-b", "owner-c" })
data.Add(Path.Combine(FixturesRoot, name + ".usd"));
return data;
}
private static VtankSettingsProfileSerializer.AllSettings NewSettings() => new()
{
Combat = new CombatSettings(),
Buffs = new BuffSettings(),
Vitals = new VitalSettings(),
Inventory = new InventorySettings(),
Navigation = new NavigationSettings(),
};
[Theory]
[MemberData(nameof(UsdFixtureData))]
public void EveryUsdFixtureLoads(string path)
{
string text = File.ReadAllText(path);
VtankSettingsProfileSerializer.AllSettings settings = NewSettings();
VtankDatabase document = VtankSettingsProfileSerializer.Load(text, settings);
Assert.NotNull(document.Find("Settings"));
}
// An untouched load -> save round trip must reproduce the exact bytes
// VTank itself wrote for every setting whose model faithfully carries
// VTank's own value range (Save() only overwrites a Settings row's
// Value cell when the live value actually differs from what was
// parsed, using a tolerance that absorbs float<->double formatting
// noise but not a real value change). UseArcs is now modeled as
// VTank's real 3-way enum (CombatSettings.UseArcsMode), so no
// normalization is needed — every setting round-trips untouched.
[Theory]
[MemberData(nameof(UsdFixtureData))]
public void UntouchedRoundTripIsByteIdentical(string path)
{
string original = File.ReadAllText(path);
VtankSettingsProfileSerializer.AllSettings settings = NewSettings();
VtankDatabase document = VtankSettingsProfileSerializer.Load(original, settings);
string rewritten = VtankSettingsProfileSerializer.Save(document, settings);
Assert.Equal(original, rewritten);
}
[Fact]
public void DefaultSettingsUsdMatchesEveryCatalogDefault()
{
string text = File.ReadAllText(Path.Combine(FixturesRoot, "defaultsettings.usd"));
VtankSettingsProfileSerializer.AllSettings settings = NewSettings();
VtankSettingsProfileSerializer.Load(text, settings);
Assert.Equal(25, settings.Combat.HuntSkillExcessOverDifficulty);
Assert.True(settings.Combat.Enabled);
Assert.True(settings.Buffs.Enabled);
Assert.False(settings.Inventory.Loot.Enabled);
Assert.False(settings.Navigation.Enabled);
Assert.Equal(0.75, settings.Vitals.NormalHealth, 6);
Assert.Equal(0.50, settings.Vitals.NormalStamina, 6);
Assert.Equal(0.50, settings.Vitals.NormalMana, 6);
Assert.Equal(5f, settings.Combat.MaximumRange, 3);
Assert.Equal(2, (int)settings.Combat.AttackHeight);
Assert.Equal(TargetSelectionMethod.Both, settings.Combat.SelectionMethod);
Assert.Equal(1.9, settings.Vitals.StaminaToHealthMultiplier, 6);
Assert.Equal(2.8, settings.Vitals.ManaToHealthMultiplier, 6);
Assert.Equal("ALFCBPS", settings.Buffs.ProtectionElements);
Assert.Equal(2, settings.Buffs.ProtectionProfileMode);
Assert.Equal(30d, settings.Buffs.BuffCastRecastSeconds, 6);
Assert.Equal(80, settings.Buffs.BuffWithUntrainedItemSkill);
Assert.Equal(-50, settings.Navigation.DoorLockpickExcessThreshold);
Assert.Equal(34, settings.Vitals.DropToPeaceModeRetryCount);
// RechargeHandlerSet: the real 26-row nested table (KB doc 01 row
// 137 said "24 seed rows"; the live fixture has 26 — this is the
// corrected count).
Assert.Equal(26, settings.Vitals.RechargeHandlerRows.Count);
RechargeHandlerRow first = settings.Vitals.RechargeHandlerRows[0];
Assert.Equal(1, first.Vital); // Health
Assert.Equal("Stamina to Health", first.HandlerString);
Assert.Equal(0, first.MinPercent);
Assert.Equal(15, first.MaxPercent);
Assert.Equal(1, first.Stance); // magic
}
// Every one of the 137 catalog names must map onto exactly one field —
// proven by asserting Capture() never returns null for a known name
// (except the two names with no live write path — enablemeta, whose
// value is MetaEngine.Enabled, live session state not a stored setting;
// and rechargehandlerset, which VTank's own generic editor and
// /vt opt set both refuse to touch either, section 2 row 137).
[Fact]
public void Every137CatalogNameMapsToExactlyOneField()
{
VtankSettingsProfileSerializer.AllSettings settings = NewSettings();
var unmapped = new List<string>();
foreach (string name in VtankOptionCatalog.Names)
{
if (VtankSettingsProfileSerializer.Capture(name, settings) is null)
unmapped.Add(name);
}
Assert.Equal(["EnableMeta", "RechargeHandlerSet"], unmapped);
}
// Fidelity regression pin for the "Capture() writes the wrong type tag"
// defect: 17 tInt settings (Recharge-*, IdleBuffTopoffTimeSeconds, …)
// were written as VtankCell "d" and 3 tSingle settings
// (CollisionProjectileRadius, CollisionStepDistance, SpellRangeFudge)
// were written as "f", while VTank's shipped defaultsettings.usd
// declares every one of them "i"/"d" respectively via its own
// SettingType column. VTank's gy.e/gy.f unbox helpers
// (refs/vtank/decompiled/gy.cs) throw InvalidCastException on that
// mismatch. This theory drives the expectation straight off the real
// fixture row rather than off a second hand-written table, so it
// cannot silently agree with a wrong assumption baked into the
// production code twice.
public static TheoryData<string> CatalogNamesWithLiveWritePath()
{
var data = new TheoryData<string>();
foreach (string name in VtankOptionCatalog.Names)
{
if (name is "EnableMeta" or "RechargeHandlerSet")
continue; // no live write path (Every137CatalogNameMapsToExactlyOneField).
data.Add(name);
}
return data;
}
[Theory]
[MemberData(nameof(CatalogNamesWithLiveWritePath))]
public void CaptureMatchesDeclaredSettingTypeAndValue(string name)
{
string text = File.ReadAllText(Path.Combine(FixturesRoot, "defaultsettings.usd"));
VtankSettingsProfileSerializer.AllSettings settings = NewSettings();
VtankDatabase document = VtankSettingsProfileSerializer.Load(text, settings);
VtankTable table = document.Find("Settings")!;
int nameColumn = table.ColumnIndex("Setting");
int valueColumn = table.ColumnIndex("Value");
VtankRow row = table.Rows.Single(
r => r.Cells[nameColumn].AsString().Equals(name, StringComparison.Ordinal));
VtankCell fixtureCell = row.Cells[valueColumn];
VtankCell? captured = VtankSettingsProfileSerializer.Capture(name, settings);
Assert.NotNull(captured);
Assert.Equal(fixtureCell.Tag, captured!.Tag);
switch (fixtureCell.Tag)
{
case "b":
Assert.Equal(fixtureCell.AsBool(), captured.AsBool());
break;
case "s":
Assert.Equal(fixtureCell.AsString(), captured.AsString());
break;
case "i":
Assert.Equal(fixtureCell.AsInt(), captured.AsInt());
break;
case "u":
Assert.Equal(fixtureCell.AsUInt(), captured.AsUInt());
break;
case "d":
case "f":
// Item I (slice-1 fix round): every tDouble-declared
// distance setting's live field was widened from float to
// double (CombatSettings.MaximumRange etc.), so this is now
// a real exact compare — no tolerance, matching
// VtankSettingsProfileSerializer's own ValuesEqual.
Assert.Equal(
fixtureCell.AsDouble(),
captured.AsDouble());
break;
default:
Assert.Fail($"{name}: unexpected tag '{fixtureCell.Tag}'.");
break;
}
}
[Fact]
public void RechargeHandlerSetRoundTripsFromOwnerFixture()
{
string text = File.ReadAllText(Path.Combine(FixturesRoot, "owner-a.usd"));
VtankSettingsProfileSerializer.AllSettings settings = NewSettings();
VtankSettingsProfileSerializer.Load(text, settings);
Assert.NotEmpty(settings.Vitals.RechargeHandlerRows);
Assert.Contains(
settings.Vitals.RechargeHandlerRows,
static row => row.HandlerString == "Kit Recharge");
}
// Item D (slice-1 fix round): CreateNew used to build ONLY a 4-column
// Settings table (empty Description, hardcoded SettingType 1/Bool for
// every row) — a hand-typed replica missing the other nine tables VTank
// ships (MyMonsters, GemFoodItems, ExtraBuffSpells, AntiExtraBuffSpells,
// ItemUseSpecifiers, SettingsCategories, SettingsEnumInfo, AssistItems,
// BuffedItems). It now seeds from the embedded defaultsettings.usd, so
// its table set must match the real fixture's exactly.
[Fact]
public void CreateNewHasTheSameTableSetAsTheDefaultFixture()
{
string fixtureText = File.ReadAllText(
Path.Combine(FixturesRoot, "defaultsettings.usd"));
VtankDatabase fixture = VtankDatabase.Parse(fixtureText);
VtankDatabase created = VtankSettingsProfileSerializer.CreateNew(NewSettings());
string[] expectedTables = [.. fixture.Tables
.Select(static entry => entry.Name)
.OrderBy(static name => name, StringComparer.Ordinal)];
string[] actualTables = [.. created.Tables
.Select(static entry => entry.Name)
.OrderBy(static name => name, StringComparer.Ordinal)];
Assert.Equal(expectedTables, actualTables);
Assert.True(expectedTables.Length > 1, "the fixture itself must carry more than one table.");
// Every row's Description/SettingType survive from the embedded
// document (not the prior blanket Description="" / SettingType=1).
VtankTable? settings = created.Find("Settings");
Assert.NotNull(settings);
int descriptionColumn = settings!.ColumnIndex("Description");
int typeColumn = settings.ColumnIndex("SettingType");
Assert.True(descriptionColumn >= 0);
Assert.True(typeColumn >= 0);
Assert.Contains(
settings.Rows,
row => row.Cells[descriptionColumn].AsString().Length > 0);
Assert.Contains(
settings.Rows,
row => row.Cells[typeColumn].AsInt() != (int)VtankSettingValueType.Bool);
}
[Fact]
public void UnknownSettingsRowIsPreservedVerbatim()
{
string usd = SingleSettingUsd("SomeFutureSetting", "s", "future-value");
VtankSettingsProfileSerializer.AllSettings settings = NewSettings();
VtankDatabase document = VtankSettingsProfileSerializer.Load(usd, settings);
string rewritten = VtankSettingsProfileSerializer.Save(document, settings);
Assert.Equal(usd, rewritten);
}
private static string SingleSettingUsd(string name, string valueTag, string valueText)
{
var lines = new List<string>
{
"1", "Settings", "4", "Setting", "Value", "Description", "SettingType",
"y", "n", "n", "n", "1",
"s", name, valueTag, valueText, "s", string.Empty, "i", "1",
};
return string.Join("\r\n", lines) + "\r\n";
}
}