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>
261 lines
12 KiB
C#
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";
|
|
}
|
|
}
|