fix: retail text and golden-string tests must not follow the machine's locale
Run 170's Windows gate went red on 37 tests across four assemblies while the same commit passed 14,370/0 locally. The failures were all one family: Expected: "You have 1 500p" <- built with the machine's culture Actual: "You have 1,500p" <- production, correctly invariant The runner is Swedish; this dev box is not. These tests had been passing on CI only because that machine's registry locale had been pinned by hand — machine state, which came undone (almost certainly the reboot after today's hang). Re-pinning it would be a workaround on one machine for a defect in the repo, so this fixes the repo instead. Two genuinely different bugs were hiding in that one symptom. 1. TESTS that build an expected string with the ambient culture and compare it to invariant production output, and test-side recording sinks whose traces are compared against literal golden strings. Those only ever passed on a machine that happens to format like the invariant culture. Pinned to InvariantCulture: the vendor purse/cost expectations, and the motion-funnel, animation-sequencer, framebuffer-resize, resource-slot, and runtime-attack trace sinks. 2. PRODUCTION that formats player-visible retail text with the ambient culture. This one matters beyond CI: retail is a US client, so it shows "2.50", "1,500p" and "(-20)" to everyone. On a Swedish machine acdream was showing "2,50", "1 500p" and "(-20)" with U+2212 MINUS SIGN — the audience for this alpha is literally Swedish. Converted 76 sites to InvariantCulture across the item/creature appraisal formatters, the character stat panel's buff and vitae parentheticals, the appraisal and link-status controllers, the chat /framerate and /location output, the camera sensitivity toast, the time-override toast, the F3 dump, the sky diagnostics, and the world-frame invariant-failure message. DATES are deliberately left on the current culture (CharacterController's birth/login stamp, RuntimeHouseState's purchase expiry). Retail has no answer for a non-US player's date format, and forcing "08/19/2026 7:00:00 PM" on them is a UX decision, not a retail-fidelity one. Apparatus, so the next occurrence is reproducible instead of mysterious: tests/TestCultureInitializer.cs adds an opt-in ACDREAM_TEST_CULTURE knob to every test assembly, linked in through a new tests/Directory.Build.props. Unset — what CI and everyone runs — it changes nothing. ACDREAM_TEST_CULTURE=sv-SE dotnet test ... reproduced all 37 CI failures on this machine plus 6 more the runner's own locale does not surface (the Unicode-minus family), and drove the fix. Verified both ways on the full solution under the release-gate filter: default culture 14,370 passed / 0 failed, and ACDREAM_TEST_CULTURE=sv-SE 14,370 passed / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6e6e6f8de4
commit
6a15dd063c
20 changed files with 271 additions and 112 deletions
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
|
|
@ -81,7 +82,9 @@ public sealed class FramebufferResizeControllerTests
|
|||
|
||||
private sealed class Camera(List<string> calls) : IFramebufferCameraTarget
|
||||
{
|
||||
public void SetAspect(float aspect) => calls.Add($"camera:{aspect:F3}");
|
||||
public void SetAspect(float aspect) => calls.Add(
|
||||
// Invariant: compared against literal golden strings.
|
||||
string.Create(CultureInfo.InvariantCulture, $"camera:{aspect:F3}"));
|
||||
}
|
||||
|
||||
private sealed class DevTools(List<string> calls) : IFramebufferDevToolsTarget
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Update;
|
||||
|
||||
|
|
@ -278,7 +279,11 @@ public sealed class RuntimeResourceSlotTests
|
|||
|
||||
private sealed class RecordingUpdateRoot(List<string> calls) : IGameUpdateFrameRoot
|
||||
{
|
||||
public void Tick(UpdateFrameInput input) => calls.Add($"update:{input.HostDeltaSeconds}");
|
||||
public void Tick(UpdateFrameInput input) => calls.Add(
|
||||
// Invariant: compared against literal golden strings.
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"update:{input.HostDeltaSeconds}"));
|
||||
}
|
||||
|
||||
private sealed class RecordingRenderRoot(List<string> calls) : IGameRenderFrameRoot
|
||||
|
|
@ -286,7 +291,9 @@ public sealed class RuntimeResourceSlotTests
|
|||
public RenderFrameOutcome Render(RenderFrameInput input)
|
||||
{
|
||||
calls.Add(
|
||||
$"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}");
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}"));
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
|
|
@ -511,7 +512,9 @@ public sealed class VendorUiControllerTests
|
|||
// to the singular name UNCHANGED (not an invented "Arrows" + "s").
|
||||
Assert.Equal("100 Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"),
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
|
|
@ -539,7 +542,9 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
Assert.Equal("Bread", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"),
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
|
|
@ -587,7 +592,9 @@ public sealed class VendorUiControllerTests
|
|||
// selection.
|
||||
Assert.Equal("Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"),
|
||||
GetText(h.ItemCostText));
|
||||
|
||||
// Player drags the slider to 40 AFTER selecting -- no re-click, no
|
||||
|
|
@ -597,7 +604,9 @@ public sealed class VendorUiControllerTests
|
|||
// SellPrice = ceil(2.0*10*40 - 0.1) = 800.
|
||||
Assert.Equal("40 Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"),
|
||||
GetText(h.ItemCostText));
|
||||
|
||||
h.BuyButton.OnClick!.Invoke();
|
||||
|
|
@ -2540,14 +2549,22 @@ public sealed class VendorUiControllerTests
|
|||
// Before staging: zero staged count/value, still grammatically
|
||||
// plural ("0 items"), purse text is live regardless of staging.
|
||||
Assert.Equal("Buying 0 items worth 0p", GetText(h.BuyListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.BuyPurseText));
|
||||
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
// ComputeBuyTransactionValue: sellRate 1.5 * value 500 * quantity 1
|
||||
// = 750, ceil(750 - 0.1) = 750.
|
||||
Assert.Equal("Buying 1 item worth 750p", GetText(h.BuyListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.BuyPurseText));
|
||||
|
||||
h.BuyClearListButton.OnClick!.Invoke();
|
||||
|
||||
|
|
@ -2565,7 +2582,11 @@ public sealed class VendorUiControllerTests
|
|||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.BuyPurseText));
|
||||
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[(uint)PropertyInt.CoinValue] = 42;
|
||||
|
|
@ -2584,7 +2605,11 @@ public sealed class VendorUiControllerTests
|
|||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
|
||||
Assert.Equal("Selling 0 items worth 0p", GetText(h.SellListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.SellPurseText));
|
||||
|
||||
h.Controller.HandleDropRelease(
|
||||
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
|
@ -2592,7 +2617,11 @@ public sealed class VendorUiControllerTests
|
|||
// ComputeSellTransactionValue: SellProfile's BuyPrice 1.0 * value
|
||||
// 100 * quantity 1 = 100, floor(100 + 0.1) = 100.
|
||||
Assert.Equal("Selling 1 item worth 100p", GetText(h.SellListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.SellPurseText));
|
||||
|
||||
h.SellClearListButton.OnClick!.Invoke();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
|
|
@ -184,16 +185,20 @@ public sealed class AnimationSequencerCutoverTraceTests
|
|||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
uint id = n.Value.Anim is null ? 0u : loader.IdOf(n.Value.Anim);
|
||||
sb.Append($"{id:X}@{n.Value.Framerate:F1}");
|
||||
// Invariant throughout: this trace is compared against literal
|
||||
// golden strings, so it must not follow the machine's locale.
|
||||
sb.Append(CultureInfo.InvariantCulture, $"{id:X}@{n.Value.Framerate:F1}");
|
||||
if (ReferenceEquals(n, core.FirstCyclicNode)) sb.Append('*');
|
||||
if (ReferenceEquals(n, core.CurrAnimNode)) sb.Append('^');
|
||||
}
|
||||
var v = core.Velocity;
|
||||
var o = core.Omega;
|
||||
sb.Append($" | frame={core.FrameNumber:F1}");
|
||||
sb.Append($" vel=({v.X:F2},{v.Y:F2},{v.Z:F2})");
|
||||
sb.Append($" om=({o.X:F2},{o.Y:F2},{o.Z:F2})");
|
||||
sb.Append($" style={seq.CurrentStyle:X8} motion={seq.CurrentMotion:X8} mod={seq.CurrentSpeedMod:F2}");
|
||||
sb.Append(CultureInfo.InvariantCulture, $" | frame={core.FrameNumber:F1}");
|
||||
sb.Append(CultureInfo.InvariantCulture, $" vel=({v.X:F2},{v.Y:F2},{v.Z:F2})");
|
||||
sb.Append(CultureInfo.InvariantCulture, $" om=({o.X:F2},{o.Y:F2},{o.Z:F2})");
|
||||
sb.Append(
|
||||
CultureInfo.InvariantCulture,
|
||||
$" style={seq.CurrentStyle:X8} motion={seq.CurrentMotion:X8} mod={seq.CurrentSpeedMod:F2}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -21,7 +22,12 @@ public class MotionInterpreterFunnelTests
|
|||
public readonly List<string> Calls = new();
|
||||
public bool ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
Calls.Add($"DIM {motion:x8}@{speed:F2}");
|
||||
// Invariant: this trace is compared against literal golden
|
||||
// strings, so it must not follow the machine's locale (sv-SE
|
||||
// renders 1.00 as "1,00" and -1.20 with a Unicode minus).
|
||||
Calls.Add(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"DIM {motion:x8}@{speed:F2}"));
|
||||
// R3-W5: a style/stance id (>= 0x80000000, i.e. negative as
|
||||
// int32) has no locomotion MotionData entry in the dat — retail's
|
||||
// real CMotionTable::DoObjectMotion genuinely fails for it
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
|
@ -251,7 +252,10 @@ public sealed class RuntimeSimulationFixtureHostTests
|
|||
|
||||
public bool SendAttack(AttackHeight height, float power)
|
||||
{
|
||||
Trace.Add($"attack:{height}:{power:0.0}");
|
||||
// Invariant: compared against literal golden traces.
|
||||
Trace.Add(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"attack:{height}:{power:0.0}"));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Combat;
|
||||
|
|
@ -748,7 +749,10 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
|
|||
Trace.Add("attack:prepare");
|
||||
public bool SendAttack(AttackHeight height, float power)
|
||||
{
|
||||
Trace.Add($"attack:{height}:{power:0.0}");
|
||||
// Invariant: compared against literal golden traces.
|
||||
Trace.Add(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"attack:{height}:{power:0.0}"));
|
||||
return true;
|
||||
}
|
||||
public void SendCancelAttack() { }
|
||||
|
|
|
|||
15
tests/Directory.Build.props
Normal file
15
tests/Directory.Build.props
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
|
||||
<!-- Test assemblies only. The fixture projects under tests/ are ordinary
|
||||
libraries and executables (plugins, child processes), and a
|
||||
ModuleInitializer in a library is exactly what CA2255 exists to flag. -->
|
||||
<ItemGroup Condition="$(MSBuildProjectName.EndsWith('.Tests'))">
|
||||
<!-- One opt-in culture knob for every test assembly. See
|
||||
TestCultureInitializer for why: a locale-dependent expectation should
|
||||
be reproducible on any machine, not only on one whose Windows locale
|
||||
happens to match. Does nothing unless ACDREAM_TEST_CULTURE is set. -->
|
||||
<Compile Include="$(MSBuildThisFileDirectory)TestCultureInitializer.cs"
|
||||
Link="TestCultureInitializer.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
54
tests/TestCultureInitializer.cs
Normal file
54
tests/TestCultureInitializer.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AcDream.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Lets a test run pin the ambient culture, so a locale-dependent failure can
|
||||
/// be reproduced on any machine instead of only on one with the right Windows
|
||||
/// locale.
|
||||
///
|
||||
/// <para><b>Why this exists.</b> On 2026-08-19 the Windows CI runner went red on
|
||||
/// 37 tests across four assemblies with failures like
|
||||
/// <c>Expected: "You have 1 500p" / Actual: "You have 1,500p"</c>. The
|
||||
/// production code was right — it formats retail text with
|
||||
/// <see cref="CultureInfo.InvariantCulture"/>, so every player sees retail's
|
||||
/// comma. The TESTS were wrong: they built their expected strings with
|
||||
/// <c>$"{value:N0}"</c>, which uses the machine's current culture, so they only
|
||||
/// passed on a machine that happens to format like the invariant culture. The
|
||||
/// runner is Swedish (space as the group separator), and the tests had been
|
||||
/// passing there only because its registry locale had been pinned by hand —
|
||||
/// a machine-state fix that silently came undone.</para>
|
||||
///
|
||||
/// <para>The expectations are fixed to be invariant. This knob is the
|
||||
/// apparatus that makes such a break reproducible next time:
|
||||
/// <c>ACDREAM_TEST_CULTURE=sv-SE dotnet test ...</c> runs the suite as the
|
||||
/// Swedish runner sees it. Unset (the default, and what CI runs) changes
|
||||
/// nothing at all.</para>
|
||||
/// </summary>
|
||||
internal static class TestCultureInitializer
|
||||
{
|
||||
internal const string CultureVariable = "ACDREAM_TEST_CULTURE";
|
||||
|
||||
[ModuleInitializer]
|
||||
internal static void Initialize()
|
||||
{
|
||||
string? requested = Environment.GetEnvironmentVariable(CultureVariable);
|
||||
if (string.IsNullOrWhiteSpace(requested))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var culture = CultureInfo.GetCultureInfo(requested);
|
||||
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||
}
|
||||
catch (CultureNotFoundException)
|
||||
{
|
||||
// A typo in an opt-in diagnostic must not fail an unrelated suite;
|
||||
// the run simply keeps the machine's own culture.
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue