aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core.Tests
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Core.Tests
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Core.Tests')
-rw-r--r--src/Godosa.Core.Tests/Audio/AudioMixTests.cs22
-rw-r--r--src/Godosa.Core.Tests/Audio/Mp3Tests.cs90
-rw-r--r--src/Godosa.Core.Tests/Audio/SynthTests.cs122
-rw-r--r--src/Godosa.Core.Tests/Audio/WavReaderTests.cs127
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs59
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs97
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs53
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs38
-rw-r--r--src/Godosa.Core.Tests/Godosa.Core.Tests.csproj13
-rw-r--r--src/Godosa.Core.Tests/Input/GamepadScriptTests.cs56
-rw-r--r--src/Godosa.Core.Tests/Input/InputScriptTests.cs73
-rw-r--r--src/Godosa.Core.Tests/Input/MenuCursorTests.cs42
-rw-r--r--src/Godosa.Core.Tests/Runs/RouteLegsTests.cs116
-rw-r--r--src/Godosa.Core.Tests/Runs/RunsTests.cs149
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs47
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs185
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs63
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs100
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs157
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs83
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs103
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs111
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs54
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs85
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs224
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs63
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs58
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs84
-rw-r--r--src/Godosa.Core.Tests/SettingsFileTests.cs90
29 files changed, 2564 insertions, 0 deletions
diff --git a/src/Godosa.Core.Tests/Audio/AudioMixTests.cs b/src/Godosa.Core.Tests/Audio/AudioMixTests.cs
new file mode 100644
index 0000000..93a0bed
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/AudioMixTests.cs
@@ -0,0 +1,22 @@
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+public class AudioMixTests
+{
+ private sealed class Plain : IAudioMix
+ {
+ public HrtfMode Hrtf => default;
+ public float ChannelGain(int channel, AudioPauseState state) => 1f;
+ public bool Pausable(int channel) => true;
+ public bool WorldPaused(AudioPauseState state) => false;
+ }
+
+ [Fact]
+ public void ChannelName_DefaultsToTheNumber()
+ {
+ IAudioMix mix = new Plain();
+ Assert.Equal("2", mix.ChannelName(2));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/Mp3Tests.cs b/src/Godosa.Core.Tests/Audio/Mp3Tests.cs
new file mode 100644
index 0000000..9cdca1e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/Mp3Tests.cs
@@ -0,0 +1,90 @@
+using System;
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+/// <summary>Fixtures: our own sine tones (ffmpeg lavfi sine, amplitude 1/8, 0.25 s) encoded with lame 3.100: mono
+/// 440 Hz at 22050 Hz (64 kbit/s, --resample 22.05); stereo 440 Hz left / 880 Hz right at 44100 Hz (128 kbit/s,
+/// --resample 44.1). Oracle: a sine of f Hz crosses zero 2·f times a second; counted in a 0.1 s window inside the tone
+/// (lame's encoder delay, ≈ 1105 samples, and frame padding are near-silent and cross zero at random).</summary>
+public class Mp3Tests
+{
+ private static readonly byte[] Mono = Convert.FromBase64String("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");
+ private static readonly byte[] Stereo = Convert.FromBase64String("//uQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAALAAATlgAXFxcXFxcXFxcuLi4uLi4uLi5FRUVFRUVFRUVdXV1dXV1dXV10dHR0dHR0dHSLi4uLi4uLi4uioqKioqKioqK6urq6urq6urrR0dHR0dHR0dHo6Ojo6Ojo6Oj///////////8AAAA5TEFNRTMuMTAwAaoAAAAALCcAABSAJAQvRgAAgAAAE5awSG/GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQBAAAAp0NzJ1kwABUofnTrCQAC+ytNznFAAF8lWeLONAAAUbbkNVU00TNFTPCwxqPHNQczxpCF61h0h0i1TsTa+/8vCCAAAAAIEEIB8HwfB+UBA5icH+CDpzp85y/nOX93T7uXB8HwfD4IBiUAYP6QQOZf3dIBjbcip1TqnVOqdicDvouxCWARmdZteb2mkZa9dbvhgUCgUCgUIEEJzmjB8H3lAQDGXP8EHTnT5zl/Ocv5zp93Lg+Hy4PggCDhAAwfPw+CBzLh/u6QAMwAEMEMIIADDZGMQ+pIwxfgF6GPz8buKIVAB0oOPieWBDZTH4cMNhxzuutsA0AT4AUQQN34CwgQbQbf8QQFYCkKoNv/j0iHw+NN//FUiHw+IR6Pf6gaEoSBr/iU6DUCECGAAXARgcBaRdydEx2PDCEhYYZeHBMIDGlmNbhIwiEg7TJMGVwszkEdAvfA9RPhGvxGiRHqOH/HcMMJcSI9f/HqZF4vGJd//LpkXi8iXS7/EQVBURHv9YKiIKgqIkATSIoYA4AdmAPAHZgTgFyYAgBPGAj//uSBAsIAswVRDd8QABPQWjt7zwBCqhVES/sRoFqFWEV34kQgcBltKwGZHsKjmFcA3JgpAHOYEmBeGBUgUhgXIEUYBiAVQNRw7jk36ez9+Vw4i3iRxijh78q3/rXb9NWjq/enG///vAAAAEjcRBBP3YdlKqBgIgKGAqGkYIITRhRgZGDqJ8Y6oxR1bn2mPQLeTCSAIAowGwIBIAsuKLcf6E+qvG2Ws7OS/7Ff6/6ftq/Tq/3K/+kCGhZUtmYA8ARGAmgK5gTwGMYMSFEmUyJ7plEoUWYMgBvHZx5q7GacrGm0JlZKlZD4AchH7fmRPndAYYHYt/JUd+H7voIuPKsbeiz9y/0J/XMPd1o6IAABvLdyZUeMdtBCwDl/jAkADAQgDE52gyWMC6ABRIA2QtaFB0XDoptNO2v3s6+nRV+I7V+351mFiuL0Z0UULgezzrSufWRWCCrQIlBawvMIqW66at9bMVTNvUwEkBSMBQAcTAcwLgwMgFvMHdEgDMuIzgzGMRmMG0BOzZUoyQxNzXjlsk2A1DBZpsus9VNvpCr6cijaP/7kgQdjwLNFcKD+xGwPQPo7RfCKAxoVwgOfqJBhJVgwf2JSJnztYA3zHeINXvTkFfeitiWs5KPbfrXVsfv6N3NbUQgIBmzWyCSStUCzARDMfZrKmpbY6lzyRoDpoMihM9YRdK76dtHtfvr365Bgx0au//9n/rd1/eQ+P5hv/95g4EGSySaYTxyeqmFbCFho+cB8aMIIYGFhgv4HQoABqReAaTVYGkJUBlYqAFA8UARcuF9Nn+3OfT9Z7SLsfmGV17C5JjxzbG98otcZS7Ze+5zb8sbsIJjGJvI9mUIUZFLpgdd+sa0RTuMC0DqQQAFlnjADwAgwE4BPOw2R4D1BEIRhoFWc+UjDglel9G1Y7UTjakpQrXyOidN96W2W1gY+qTXIx1hvqtHllOnFnYxrGhTAYTgqxpkUnCTBAgrxq1TTFFNCgDLKjRkxeZaiGgO5vPoYJYGKmQfsKpkAgYGYIiBpAbJeBlSQHGZgd/wBuywWZHcav0vb0/f3nrJUOE3xfKPUjTOuR6e7/f/s7L5NoyYL2pD5Vv06CwAqB7j+5pbJlb/+5IELwgCpxXEM3+gkF5hWEV3+0ALKFcMr+xGwOsP5DQ/CKTlQNAYuCYFAOYCCAlHM4okZ24cGGqETNYVLKl3TgCLMJUB9cyXZAM/Uo+8jffgIcD1ZVNSLBqZVHCkzuJMq1LdF2oX2HTShUqLrINa4VSwoHm8BOKGBMYBgBMwDYAsMBzAbzA4APQwh0MRM4YX1zNwwxUwiQEWP1vTd4s2Z+Nj5TOT0uw/8op7BH1ZPkVfsrxnsKX3p6n96Mo7675JJpG3G3q9orR8/11sQdVvv/m6wGBFbJZCiXzlBGAgEjDLWlBi/pvzHnDQDrzTsDTttU9O2/9n/erenxdVPtEMl/vfr/+3/1f9y932+pUAACOLMYjgxoVTJaONDSEwUsOYMkAcRDIww4QwSoD9M1QDIBM1k6NyhzSB0WBn5nrKVo1F2aihDpT7KvpfVZ/b2Kcvu/ZyKHs9NG5LrXWN7umAQAd/VWaWSZm4wBQGLYmBQEmAfgLByyae6YFiALA4AxRGZLA04IDTMjrdu+mrdL30Y9Fuucelu837brhiu9q1IJ57//uSBEoIMogIw8Of2JBXBWhFd+VEi5hXCg5+okFSFWEB34kQ7GudW5d91W4QRdNo+oolz1r/84kFCAyKMzRR6OFw0wnYNYNBhZ+jP/A14wn8E3A442ANFKQDOqXAzdDAMfEoA4Bi5CLlwzTQ9Tea/+cbVYxViqkeUV9HXeV73XsQOzO8lail+xExqcn11q2l0XXmlmv1u5MrKM2sNAAClsjAsCjAPgGE5RJUmMCuAGQcAZIis2hM+DMX2ojdDtr1qzd/0ef396r323GxHbmYtFkswKIG6mpOWcUtYxeVsN7MCdy17mNl9IGVAEyiqmJheYpIxjo+mbYAYIgGamPEsopjqQZGYHmBwAaB6BnQwHMVgeXYBvyQWkjuKr/7+ky9+k5kyeaujtbvwl+j5bX3/7P7vRRJnPv9QA9/WM0zEzJqACgAXdMBgHMAzAVjgEU6c5MUCCFJJtYtYFX25PYlB7TS/JV00ajYw7s8X8Z7yt7IpFbMelCZxGvfKKqkK3IWFRinRRFrgXDJGnImv8DDDPlTmRjDRENNdCK01rRETDSBWP/7kgRcCEJzFcQzn6CQTeEoUXf7QAiQHxute0JBQY/hVd+JEOm5M6nMoiMnPMGJdynBMMPW7WzmWDK7bk0Vu9Ov/t0L/v1s/9/+3/+mAgN63qZZ8ZrUqBABLvGA4EGAYALpvxilgYEgALg4AiSRbaK3wZlKnr+vdH36U1WvxEd+xCBGL7K86fk16uyu8kvelyRRtWuNQxHff1WeVRSqAAAjizEouMTEkxufjMkFMEBDQjHJmbAxvUMyMDiA5zMwDQATopjzZTfERoFAs9jYrq11B30q7a/o12f6NK6u/9naUt/rsv33fQyAAAY/+PqtUCpgriMMOclQEwBwHD4nnjAwMSsL9S21xO/9emj/3stdNdBnv3/T/ahf/exeS02fu/0//5cS6f62tuSBdZawwVA8xQGQ6n7Q6UGYxTBc7IAggaEKUUwp8DkPZ/f192yMYd+UZ6az+ytTKX0fp6/9/bd/T//V2JgGUIg/38qrpGsPGgFTGAIAMAcASTXakXQ8g8MFLVfqdHnqqmV8jMUdKtlPjKdFHjNKdLXan2f9q9H0aGv/+5IEgggCSwjEQ5/QkDyj+LwfwigIMFcnruBGoQaEYdnP6QA6dzdTtNUAACNAMWDsxaWDHyXM3zIwRYPLMe3dtzHgA7cwPMEQAylUAK0B4lYH73Ac0oCISNIqn1K/V5g6H+kfu7koU2sjT/+/Spbdn/1f/2c0AABBA5f+6zcTNeHi2yDpgEA5gEoC4bRCpanzLA4irpxYte0da5zbqFIqRBCKJqf/rQTT/Z/cQ6tjN++qUv6e5Xc1NrGp1rqBi+fmTZAQEhgZgOmDgCwYkIn5voULm8+KGYkANx9Z5rXpnnBm+ZhyrXKchyX9fq363OO+Wbv+/0ZJ5bd9mr3/t/9/+z7v6sQAAVLpnC1HzX63NGkYrkv8w4CAKnRtEoDgNWTQ7LrPVTT+xdV2fZvbnv+PTm/r/6Nrf/X9tK8hr/s3f+8wEMBUMBAAfTAUwM4wI4GdMFZFBDJcJxoyS0TsMEqBczE34FSJxZwdZHm3D4sPNrIrPVbfrSM/p0OPoUR7WVL8uyp5Gv9dQkW/pcuWvazRGWejVZdPO0bmJ7xhIAQAd3rG//uSBLwIAl8WQ8OfoJBHYThod/pACNhXFy9oRoD0D6N1vwjYaZCaa1MYDgEWZMCgTMA1AijgW3N8wJUAqMASAAUNmWw9ZQBN7300Rqt3pdKOv2yiiuvz6d79Ri38iPYvqL2kC68XvqptcguZhlWemm0/02ZQWuNCqaViIVMtCTXDI7eFMbwcQ7v+BDt2HMMb8Kk7p+NOZjOWMzC4MULFTxCvUB92yl98gH3abd7KgzQ+qxDH2/OV6qu9X23810fZxiFJAo9VBEJA7yx1MsiNQKVMCABLNGBYJGAZASJvwTuYYEgAWmAIgACKLLodvlMV+hFdn6yU7UdqKlT7GeD+/6druiFEbXP29VPpbuv9D/2NsC98mkCKKnU0KQuKnFoODU1Bk1MTLVE1KULKAAACdUshZJQEAgkYAEpj7hghCmmSnVqZIAo5gagpAk8HCAPCB7cA1IaJsavV+v2v610Tpt963CT5j+ds93jnfR/q//tphUAACIgy/LGmZCal0AYEgABgBMCgVMAxAlDen3q8wI0AuMAQAA0UmWw9ZQpmZk+j7v/7kgTviILcFcKD+xGwWOVYRXfiRIn8IxDN+2JBkKGhFd+JEIq2JT3r6p6jWW910VvUGlBi666sLi8uhwni7bwAp7WFq+4g4oOs2pQUuuaL3nrx51tClx7nIpyWRAAAwVwVTBLBZMHEBkwyhcjGyM8PICt07f06TKfHTMTkFUwwxITENDjMNcM0wIwQTAaA0MDACYFAKRl4DBDAQ5eNk6ehiGGggBlBkc5JjgSRIQllo2hpyKWKrmMQaRQQJDJdcwRQEGle3BAOpvKmcPxBa52XtwLZoA2nxxrDOHEhzcs5K4b1KIYlk25DuTleV09/+4YYfvOnz1T04EOJEYPqBAaCC4YEgYLg++ifEYfq6jgkOJ1ROHy71YP0EPghWAQAA5rrYyADABAFMA0BMwEQC0DjAsBaMGoGIwvAfzCAAFMLcM8xXBkjEEFDOri44www/DFsEMMJAGAwVAIACAWp0YCJmUjYGA0KxEGGQGBnR0ZgKBc3OEjDfloIWDCyExcPIgMGAgODBGCmRExnBUYwDLCmBh5ioyYuKgobR0MJCQUFu0n/+5IE+ggCMhVH616YmGTlWEh34kQYcLkTteyAA++iYva9sAAItRhZZ9AeloYaHmGgqSb0JCIOKaM0oXcnXbcemcBUjTHTLuIoPJDblu/f+njEYjF6vDblxeYZ279+kllivbwp6e3+FSksbpLGOeesMN0lJz888//PusPzzzwp7Y4MOWuCA0EHNlAxB8+D6qS59QAczMFAwn1iegAFGIKEIAAAADKkF7NLl4YwGQRAcDd/m2ecwYz44BgQAfmAMBH/mD2HqYWAUg8CCWi/wPADA/KYAEYF1eB4F4HSzgnMEsJo34GbggYUOFLoUjDmiEpFBafAx44AwaCQoM6AaBmKDmS/gKFAAhIAIACgEBwELfCkZJqX/EFxkhOouYQmGfFmGKKl6v5UGkO0XKOodZTJAk0quuv/jnEBJQjifIITpFiZIaTH1f/+Rc+TpeIsWSYQLZaIqUimdNF6/q///yuXi6ZAAAEaMKQQAAAGfxSmkKmAEEgcAf+St2ZhiyYIAuYDA7/nA0AmaRoBgGBADf4GcAiBoEUAYLEQCgLwNcGMDTi7//uSBIOABjtoRs56gADWrRkJztQAAAABpBwAACAAADSDgAAEAijAgAA5RX4GGR2BgMChQohQdC5RHJABIfAwuFgBAqCAIBCAgGAWTJo5NJ/AOEAGCgGBgMDAUAoCwFBvhSMk1I/wbNhgoGxIYqBt4MfCzhiigvV/IgLcO0VqM8M+Ux4HtJauuv/jNDkj8RxFyCERIMTJDSYX6lf/8i5bIiTxAiVJhAtloipNFM6VFrr+r///J8niZKX/60xBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg==");
+
+ private static (float[] Left, float[] Right) Render(Mp3Stream s, int frames)
+ {
+ var buf = new float[2 * frames];
+ s.Render(buf, frames);
+ var (l, r) = (new float[frames], new float[frames]);
+ for (var i = 0; i < frames; i++)
+ (l[i], r[i]) = (buf[2 * i], buf[2 * i + 1]);
+ return (l, r);
+ }
+
+ [Fact]
+ public void Reader_decodes_mono_at_the_file_rate()
+ {
+ var clip = Mp3Reader.Read(Mono)!.Value;
+ Assert.Equal(22050, clip.SampleRate);
+ Assert.InRange(clip.Samples.Length, 5512, 5512 + 3 * 1152);
+ var f = Array.ConvertAll(clip.Samples, s => s / 32767f);
+ Assert.InRange(SignalStats.ZeroCrossings(f, 2400, 2400 + 2205), 86, 90); // 0.1 s of 440 Hz → 88
+ }
+
+ [Fact]
+ public void Reader_downmixes_stereo_to_mono()
+ {
+ var clip = Mp3Reader.Read(Stereo)!.Value;
+ Assert.Equal(44100, clip.SampleRate);
+ Assert.InRange(clip.Samples.Length, 11025, 11025 + 3 * 1152);
+ }
+
+ [Fact]
+ public void Reader_rejects_garbage() => Assert.Null(Mp3Reader.Read(new byte[300]));
+
+ /// <summary>Oracle: both tones are 0.25 s; the LAME tag's encoder delay and padding are not counted (the Info header's
+ /// 12 and 11 whole frames would be 0.313 and 0.287 s). Within 1 sample of rounding.</summary>
+ [Fact]
+ public void Seconds_come_from_the_headers_without_encoder_delay()
+ {
+ Assert.InRange(Mp3Reader.Seconds(Mono)!.Value, 0.25, 0.25 + 1 / 22050.0);
+ Assert.InRange(Mp3Reader.Seconds(Stereo)!.Value, 0.25, 0.25 + 1 / 44100.0);
+ Assert.Null(Mp3Reader.Seconds(new byte[300]));
+ }
+
+ [Fact]
+ public void Stream_keeps_channels_apart()
+ {
+ using var s = new Mp3Stream(Stereo, loop: false);
+ var (l, r) = Render(s, 11025);
+ Assert.InRange(SignalStats.ZeroCrossings(l, 4000, 4000 + 4410), 86, 90); // 440 Hz → 88 in 0.1 s
+ Assert.InRange(SignalStats.ZeroCrossings(r, 4000, 4000 + 4410), 174, 178); // 880 Hz → 176
+ }
+
+ [Fact]
+ public void Stream_resamples_mono_to_44100_and_duplicates_it()
+ {
+ using var s = new Mp3Stream(Mono, loop: false);
+ var (l, r) = Render(s, 11025);
+ Assert.InRange(SignalStats.ZeroCrossings(l, 4000, 4000 + 4410), 86, 90); // still 440 Hz (176 if played as 44.1 kHz)
+ Assert.Equal(l, r);
+ }
+
+ [Fact]
+ public void Once_only_goes_silent_and_finishes_looping_does_not()
+ {
+ using var once = new Mp3Stream(Mono, loop: false);
+ Render(once, 44100); // 1 s, past the 0.25 s file
+ Assert.True(once.Finished);
+ var tail = new float[200];
+ Array.Fill(tail, 1f); // the stream must write the silence, not rely on a cleared buffer
+ once.Render(tail, 100);
+ Assert.All(tail, x => Assert.Equal(0f, x));
+ using var loop = new Mp3Stream(Mono, loop: true);
+ var (l, _) = Render(loop, 44100);
+ Assert.False(loop.Finished);
+ Assert.InRange(SignalStats.Rms(l, 22050), 0.05f, 0.1f); // second half still sounding (sine 1/8: RMS 0.088 less gaps)
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/SynthTests.cs b/src/Godosa.Core.Tests/Audio/SynthTests.cs
new file mode 100644
index 0000000..7c2c29b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/SynthTests.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Linq;
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+public class SynthTests
+{
+ [Fact]
+ public void Random_IsDeterministicAndInUnitRange()
+ {
+ var a = new SynthRandom(42);
+ var b = new SynthRandom(42);
+ var values = Enumerable.Range(0, 1000).Select(_ => a.Next01()).ToArray();
+
+ Assert.Equal(values, Enumerable.Range(0, 1000).Select(_ => b.Next01()).ToArray());
+ Assert.All(values, v => Assert.InRange(v, 0f, 0.99999f));
+ Assert.InRange(values.Average(), 0.45f, 0.55f);
+ }
+
+ [Fact]
+ public void Random_IsXorshift32OfTheMixedSeed() // pinned: seeded songs must sound the same in every run
+ {
+ // oracle: independent Python murmur3 fmix32 + xorshift32 — fmix32(1) = 0x514E28B7, first state 0x1F48D1FB
+ Assert.Equal(0x514E28B7u, SynthRandom.Mix(1));
+ Assert.Equal(2050257f / 16777216f, new SynthRandom(1).Next01());
+ }
+
+ [Fact]
+ public void Random_NearbySeeds_StartFarApart()
+ {
+ var firsts = Enumerable.Range(0, 20).Select(s => new SynthRandom((uint)s).Next01()).ToList();
+
+ Assert.True(firsts.Max() - firsts.Min() > 0.5f);
+ }
+
+ [Fact]
+ public void Loop_CrossfadesTheTailIntoTheHead()
+ {
+ var x = new float[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
+
+ var y = Synth.Loop(x, overlapSamples: 2);
+
+ Assert.Equal(new float[] { 8, 5, 2, 3, 4, 5, 6, 7 }, y); // y[^1] = 7 wraps to y[0] = 8, like x[7] → x[8]
+ }
+
+ [Fact]
+ public void Hash_IsFnv1a() // stable across runs, unlike string.GetHashCode
+ {
+ Assert.Equal(0xE40C292Cu, SynthRandom.Hash("a"));
+ Assert.Equal(0x1A47E90Bu, SynthRandom.Hash("abc"));
+ }
+
+ [Fact]
+ public void Sine_SteadyPitch()
+ {
+ var x = Synth.Sine(1f, 100f);
+
+ Assert.Equal(44100, x.Length);
+ Assert.InRange(SignalStats.ZeroCrossings(x), 198, 201);
+ }
+
+ [Fact]
+ public void Sine_SweepIsExponential()
+ {
+ var x = Synth.Sine(1f, 100f, 400f); // f = 100·4^t: 144 crossings in the first half, 289 in the second
+
+ Assert.InRange(SignalStats.ZeroCrossings(x, 0, 22050), 141, 148);
+ Assert.InRange(SignalStats.ZeroCrossings(x, 22050), 285, 292);
+ }
+
+ [Fact]
+ public void LowPass_PassesLowCutsHigh()
+ {
+ float low = SignalStats.Rms(Synth.Sine(0.5f, 200f).LowPass(1000f), 2000) / SignalStats.Rms(Synth.Sine(0.5f, 200f), 2000);
+ float high = SignalStats.Rms(Synth.Sine(0.5f, 8000f).LowPass(1000f), 2000) / SignalStats.Rms(Synth.Sine(0.5f, 8000f), 2000);
+
+ Assert.InRange(low, 0.89f, 1.05f); // within 1 dB
+ Assert.InRange(high, 0f, 0.1f); // −20 dB or more (2nd order: ≈ −36 dB at 3 octaves)
+ }
+
+ [Fact]
+ public void HighPass_PassesHighCutsLow()
+ {
+ float low = SignalStats.Rms(Synth.Sine(0.5f, 100f).HighPass(1000f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, 100f), 4000);
+ float high = SignalStats.Rms(Synth.Sine(0.5f, 6000f).HighPass(1000f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, 6000f), 4000);
+
+ Assert.InRange(low, 0f, 0.05f);
+ Assert.InRange(high, 0.89f, 1.05f);
+ }
+
+ [Fact]
+ public void BandPass_PassesCentreOnly()
+ {
+ float Ratio(float hz) => SignalStats.Rms(Synth.Sine(0.5f, hz).BandPass(1000f, 2f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, hz), 4000);
+
+ Assert.InRange(Ratio(1000f), 0.85f, 1.05f);
+ Assert.InRange(Ratio(100f), 0f, 0.1f);
+ Assert.InRange(Ratio(8000f), 0f, 0.1f);
+ }
+
+ [Fact]
+ public void Perc_RisesThenDecaysByTimeConstant()
+ {
+ var x = Enumerable.Repeat(1f, 44100).ToArray().Perc(attack: 0.01f, decay: 0.1f);
+
+ Assert.Equal(0f, x[0]);
+ Assert.InRange(x[441], 0.99f, 1f); // top at the end of the attack
+ Assert.InRange(x[441 + 4410], 0.36f, 0.375f); // e^-1 one time constant later
+ }
+
+ [Fact]
+ public void ToPcm_NormalisesToLevel_AndFadesEnds()
+ {
+ var pcm = Synth.ToPcm(Enumerable.Repeat(0.3f, 4410).ToArray(), level: 0.5f);
+
+ Assert.Equal((short)16384, pcm.Max());
+ Assert.Equal(0, pcm[0]);
+ Assert.Equal(0, pcm[^1]);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/WavReaderTests.cs b/src/Godosa.Core.Tests/Audio/WavReaderTests.cs
new file mode 100644
index 0000000..6ed5485
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/WavReaderTests.cs
@@ -0,0 +1,127 @@
+using System;
+using System.IO;
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+public class WavReaderTests
+{
+ private static byte[] Wav(short format, short channels, int rate, short bits, byte[] data, bool extensible = false)
+ {
+ using var ms = new MemoryStream();
+ using var w = new BinaryWriter(ms);
+ int fmtSize = extensible ? 40 : 16;
+ w.Write("RIFF"u8); w.Write(4 + 8 + fmtSize + 8 + data.Length); w.Write("WAVE"u8);
+ w.Write("fmt "u8); w.Write(fmtSize);
+ w.Write(extensible ? (short)-2 : format); w.Write(channels); w.Write(rate);
+ w.Write(rate * channels * bits / 8); w.Write((short)(channels * bits / 8)); w.Write(bits);
+ if (extensible)
+ {
+ w.Write((short)22); w.Write(bits); w.Write(0);
+ w.Write((int)format); w.Write(new byte[12]); // sub-format GUID: first 4 bytes = format tag
+ }
+ w.Write("data"u8); w.Write(data.Length); w.Write(data);
+ return ms.ToArray();
+ }
+
+ private static byte[] Shorts(params short[] s)
+ {
+ var b = new byte[s.Length * 2];
+ Buffer.BlockCopy(s, 0, b, 0, b.Length);
+ return b;
+ }
+
+ [Fact]
+ public void Pcm16Stereo_DownmixesToMono()
+ {
+ var clip = WavReader.Read(Wav(1, 2, 22050, 16, Shorts(1000, 3000, -2000, -4000)));
+
+ Assert.NotNull(clip);
+ Assert.Equal(22050, clip!.Value.SampleRate);
+ Assert.Equal(new short[] { 2000, -3000 }, clip.Value.Samples);
+ }
+
+ [Fact]
+ public void Pcm24Mono()
+ {
+ // 0x400000 = +0.5 FS, 0xC00000 = −0.5 FS (little-endian 3-byte)
+ var clip = WavReader.Read(Wav(1, 1, 44100, 24, new byte[] { 0, 0, 0x40, 0, 0, 0xC0 }));
+
+ Assert.Equal(new short[] { 16384, -16384 }, clip!.Value.Samples);
+ }
+
+ [Fact]
+ public void Float32_Extensible()
+ {
+ var data = new byte[8];
+ BitConverter.GetBytes(0.5f).CopyTo(data, 0);
+ BitConverter.GetBytes(-1f).CopyTo(data, 4);
+
+ var clip = WavReader.Read(Wav(3, 1, 48000, 32, data, extensible: true));
+
+ Assert.Equal(new short[] { 16384, -32767 }, clip!.Value.Samples);
+ Assert.Equal(48000, clip.Value.SampleRate);
+ }
+
+ [Theory]
+ [InlineData(new byte[0])]
+ [InlineData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 })]
+ public void Garbage_IsNull(byte[] bytes) => Assert.Null(WavReader.Read(bytes));
+
+ [Fact]
+ public void Pcm8_UnsignedCentredOn128()
+ {
+ var clip = WavReader.Read(Wav(1, 1, 8000, 8, new byte[] { 128, 192, 0 }));
+ Assert.Equal(new short[] { 0, 16384, -32768 }, clip!.Value.Samples);
+ }
+
+ // MS ADPCM fixtures: our own tones encoded by ffmpeg 7 (adpcm_ms, block 256, standard 7 coefficient pairs). Oracle:
+ // ffmpeg's own adpcm_ms decode (pcm_s16le), stereo averaged with truncation, cut to the fact chunk's frame count.
+ private static readonly byte[] AdpcmMono = Convert.FromBase64String("UklGRnQEAABXQVZFZm10IDIAAAACAAEAIlYAAIA+AAAAAQQAIAD0AQcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAACAYAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAQAAAAQAPwHAAB3d3MhAQAP/dzNvd3M7d3d3u8BNFNTRDNSMzQjIyEQ/tvM293M3dzd7d7vASREUzUzQ0MzNCIiAf7b283b3c3dzd7e7wEjVEQ1JDM0MzMzIhH+zL3MzdzN3dzt7f/xE1Q1NEM0M0MzMjIQDtrczM3czd3c7d7+ACNFQ0RDNDNDMzMiEQ7bzMzc3N3Nzt7e/gATRTRENDNDQyMzIhEO6829zN3M7c7t7u/xEkREQ0NEJCMjMiIgD+vb3M3Nzdzd3d7f8BNTVDRDQ0MzNCMiEQ7trb3b3c3c3d3d7/8iU1Q0Q0M0NCMjIiAf3bzM3M3czt3d7e7wEkU0REMASgLk/uj2QkIyMyIRH+zMzb3N3Nze3d3u8BI2Q0RDQzQzQyMiIQ/tvM292+3O3d3d7vADNjU0QzUjNCMyMiEP/Nrc3M3Nztzt7u7wETRTRENDNDQjMyIhH/zbzb3czd3N3d3e/xI1REQ0NDQzNCMjEgDtzL3M3Nzdze3d3+8RNUQ1NDQzQzNCIxEQ/czMzczdzc7d3t7/AjRFNTNDQ0MzMzMhEP28zNzM7N7O3e3u/wE0RERDQ0M0IzMzIhDuvbzc293c3dzt7v8BM1RENDRDM0IzMiIQ/sy9zNzc3N3d3d7vASRUNTQ0NDM0MjIiEP7a3Mzczd3N3O3u/wAjVDU0AEkC1PUA7jRCMzMzIhD+zMzM3M3dzc7e7u8AI1NTUzQ0MzQzIyIR/tvMzczc3c3dzu7vARNEU0NEM0M0MjIiEf/b283Nzc3N7c7u//ESREQ1M0M0M0IyIhAP3Mvc2+ztzt3e3u4AIlQ1NEM0NDMzMzIh/9vMzNzczs7e3e7uABNEU0Q0NDNDMzMjEQ7rzbzdvd3N3O3t/vESRERDRDNDQzIzIxEP3Mvczb7c7d3O3v4PEkQ1Q0NDQzQjMjIRD+2tvc3NzO3c7e7v8BFDVDRDQ0M0MjMiIQ/trczNzN3Ozt7t7/ACNUNTQ0NDNDIzIxIP7a3Mzczd3Nzt7e/wAjRTUwDmAfTseOVEMzQzMzIiAOzMzM3M3c3d3O3u8AJ3cQDx8fHx8fHx8fAQ8BDx8B8Q8fAfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8="); // 440 Hz ×4 gain, 22050 Hz, fact 1544
+ private static readonly byte[] AdpcmStereo = Convert.FromBase64String("UklGRnQFAABXQVZFZm10IDIAAAACAAIAIlYAAIA+AAAAAQQAIAD0AAcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAATwQAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAUAAAAAEAAQAP8Bs/gAAJLxeHd4RyATGycbEQ8EDeTs0sHAz87RwsLa0NPN39TP4NDi+eDxAh8uQ0JOTj5QPUcwMUtELjIwMT4wPSQgHQ7w5820ztC/38Pew9DN1d7SzN/U793k4v4BESpPQ14+Q08wQT5GPUMxMj9OLz4vJy8PEf/j27LMwt6y38Pa08Dez9Pf1dzU3+zj7g8VIE5RPl80TUAxQjtDPzNOMC8wMDUtLBUOAOHAwsC93cbdxM/S2sHQwu3Q1O3Q4uHwDR8TQExETEQwXzI6Qz8wTjNNITMpMSAQIwD+/dKw0rrB38XRzcLd0dDD3NTcwe7i/vTxHBQ8QUAAAEoACA649VUMRPRlAm4/NlAsQS8zPzExSyEvMR8THv700c2w37LSztHKwt7T38zW39Lf3dLg4w4CKDFjMF4/QUI9RE8/PjZNMDM+Mz8oMREd8fPMws3DzcTC28/R0MHO5N7U3t/e4e/n8RsSMVBNQ0A9UDU/TzJAOjBCPiQ9MiAhHxIN/NKz0rjP0rHhzNDS0s3SzuTd4tvg4uACCxMxQEpQNE5BPkNNPzQ/TjI/QiwhLiUgDgDx48/DysDO1sDO3tDB38PQ3e/X0NHu8vvyIitRP1NAMEBNPkc/MU8/QC4yMC4vIiUQDf/nwMyw0cLN0MLez9XP0tzS0M7j4eHvAABTAH8GBvDJ2Q3wlegXEDFMRE9OQTFCP04+TzEyPT8xQSAnER0MBe3Gz87RsMHS0LDo0cDi3t/U393Q4eXtDxQwTk9SPl81TTNAP0I+Pk0kPDMwJDsSLw7w4sXAvdHCydKx7cDg0dTcz9Tg0ODg7fT8ES80bzFPQUExTE89P0AwN04iPi42ICEeAfL5wMDDwM3UzsLfwdvB0NDS3dDD4uDp8QEQET1RPFM9UjIwTzdMMUM7IUAhIi0gIw8f/vviwr3exMLQuuPfwN/d0cHX7t7V7+7w8BUtM19ATTJUPkAyTTFPMTAwQTwlLz0fJgAA7OK+w9K4083A0t3BwOTd3wAAhACACpH0huoO9tQc1O/g4P31HR8yQ1k0XTBDPUBDPTFOIEMlLD8hIBUtDwTw3dOg0bzfw9LN3cHQ3s3Q19DV39zg8fIPIj5bREBPTUUwTTIwTUIvMTQ9NTwiPh8UAu/hsM3NxtC91N3D3MTc08Df7dLQ7uPqBQ4fMENcQTJRO0ExQTFPPj4wRD8jPCMvHBEA8uvAtt+w0sDQysLQ0M3S0NLQzuHu5O0DCREhMm0+UD9HPk9BMzwzQT5BLT4kLCERHgUP0dG+wMHD2LPf393Ewe/f09Dd0NDb9f3/ICRNUUM8VDBNQzwzQDE/TCMwPDIyLhArBPDdw8Kw2rHTy9IAAIUA0RBwAFYCbwKB6s7SzeHU0dzR3uPs8fAUME9DWjFQPjRRPjE+T0QgPTQ7IjAgIB4O8+zToNW70cDC3s7V3s7Swu/e0OXR7ODwAwskTk5CQ0swUTA/RD1PMz0wNU0jLDQeHxT/7dTAvcLO1MDcv+PQz+Hd1N7UzeHi/fwDBxA7QVAwUT4wUiBBHwAAAAEPAQ8BDwEAH/AR8B/wEf8R8A8AAQAPAQ8AAQ8BAA8BDwEPAQ8AAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8B"); // 440 Hz left, noise right, fact 1103
+
+ private static readonly byte[] AdpcmQuiet = Convert.FromBase64String("UklGRnQBAABXQVZFZm10IDIAAAACAAEAESsAAIA+AAAAAQQAIAD0AQcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAASwEAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAEAAAAQAAMAAAAAAQAAAADwAAAPAAAAAAAAAQAAAQAAAAAPAAAPAAAAAAAAABAAABAAAAAA8AAA8AAAAAAAAAEAAAEAAAAADwAADwAAAAAAAAEAAAAQAAAADwAAAPAAAAAAAAAQAAAQAAAAAPAAAPAAAAAAAAABAAABAAAAAA8AAA8AAAAAAAAAEAAAEAAAAADwAADwAAAAAAAAEAAAAQAAAADwAAAPAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); // 300 Hz ×0.004, 11025 Hz, fact 331
+
+ [Fact]
+ public void MsAdpcm_QuietKeepsTheStepFloorOf16()
+ {
+ var s = WavReader.Read(AdpcmQuiet)!.Value.Samples; // ffmpeg: steps stay at the floor, samples move by 16
+ Assert.Equal(331, s.Length);
+ Assert.Equal(new short[] { 0, 3, 3, 3, 3, 19, 19, 19, 19, 19, 19, 19 }, s[..12]);
+ Assert.Equal((302, 3682), (Sum(s), AbsSum(s)));
+ }
+
+ private static int Sum(short[] s) { int n = 0; foreach (var x in s) n += x; return n; }
+ private static int AbsSum(short[] s) { int n = 0; foreach (var x in s) n += Math.Abs(x); return n; }
+
+ [Fact]
+ public void MsAdpcm_Mono_MatchesFfmpeg()
+ {
+ var s = WavReader.Read(AdpcmMono)!.Value.Samples;
+ Assert.Equal(1544, s.Length);
+ Assert.Equal(new short[] { 0, 2044, 2156, 2422, 3059, 4585 }, s[..6]);
+ Assert.Equal(new short[] { -6016, -4057, -2328, -284, 2060, 3464 }, s[498..504]); // across the block edge at 500
+ Assert.Equal(new short[] { -16364, -16364, -15876 }, s[^3..]);
+ Assert.Equal((74278, 16090432), (Sum(s), AbsSum(s)));
+ }
+
+ [Fact]
+ public void MsAdpcm_Stereo_MatchesFfmpeg()
+ {
+ var clip = WavReader.Read(AdpcmStereo)!.Value;
+ Assert.Equal(22050, clip.SampleRate);
+ var s = clip.Samples;
+ Assert.Equal(1103, s.Length);
+ Assert.Equal(new short[] { -1847, -679, -687, -386, -527, 1116 }, s[..6]);
+ Assert.Equal(new short[] { 277, 3542, 2416, 255, -1687, -2380 }, s[498..504]);
+ Assert.Equal(new short[] { -1115, -937, 606 }, s[^3..]);
+ Assert.Equal((-183764, 2966742), (Sum(s), AbsSum(s)));
+ }
+
+ [Fact]
+ public void Downmix_InterleavedFloat()
+ {
+ Assert.Equal(new short[] { 16384, 0 }, Pcm.Downmix(new[] { 0.25f, 0.75f, 1f, -1f }, 2));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs b/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs
new file mode 100644
index 0000000..c67042e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs
@@ -0,0 +1,59 @@
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class DebugFlagsTests
+{
+ [Fact]
+ public void Categories_AreCommaSeparated_Trimmed_CaseInsensitive()
+ {
+ var flags = new DebugFlags(" input , AUDIO");
+ Assert.True(flags.On("input"));
+ Assert.True(flags.On("audio"));
+ Assert.False(flags.On("frames"));
+ }
+
+ [Fact]
+ public void All_TurnsOnEveryCategory_ButNotExactSwitches()
+ {
+ var flags = new DebugFlags("all");
+ Assert.True(flags.On("script"));
+ Assert.False(flags.Exact("nohud"));
+ }
+
+ [Fact]
+ public void Exact_MatchesOnlyTheNamedSwitch()
+ {
+ var flags = new DebugFlags("frames,NoHud");
+ Assert.True(flags.Exact("nohud"));
+ Assert.False(flags.Exact("input"));
+ }
+
+ [Fact]
+ public void MissingOrEmpty_TurnsNothingOn()
+ {
+ Assert.False(new DebugFlags(null).On("input"));
+ Assert.False(new DebugFlags(" , ,").On("input"));
+ }
+
+ [Fact]
+ public void EngineDebug_Apply_SetsEachLibCategory()
+ {
+ try
+ {
+ EngineDebug.Apply(new DebugFlags("script"));
+ Assert.True(EngineDebug.Script);
+ Assert.False(EngineDebug.Input);
+ Assert.False(EngineDebug.Audio);
+ EngineDebug.Apply(new DebugFlags("input,audio"));
+ Assert.True(EngineDebug.Input);
+ Assert.True(EngineDebug.Audio);
+ Assert.False(EngineDebug.Script);
+ }
+ finally
+ {
+ EngineDebug.Apply(new DebugFlags(null));
+ }
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs b/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs
new file mode 100644
index 0000000..44b6758
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs
@@ -0,0 +1,97 @@
+using System;
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class FrameTimeStatsTests
+{
+ /// <summary>99 smooth 10 ms frames plus one 100 ms hitch.</summary>
+ private static FrameTimeStats OneHitch()
+ {
+ var stats = new FrameTimeStats(capacity: 100);
+ for (int i = 0; i < 99; i++)
+ stats.Record(0.010, workSeconds: 0.002, simTicks: 0, simSeconds: 0);
+ stats.Record(0.100, workSeconds: 0.002, simTicks: 0, simSeconds: 0);
+ return stats;
+ }
+
+ [Fact]
+ public void Fps_IsFramesOverTotalTime_NotMeanOfInstantaneousFps()
+ {
+ var s = OneHitch().Summarize();
+
+ Assert.Equal(100, s.Frames);
+ // 100 frames / 1.09 s. (Mean of per-frame FPS would be (99*100 + 10)/100 = 99.1.)
+ Assert.Equal(91.743, s.Fps, 3);
+ Assert.Equal(10.9, s.AvgMs, 6);
+ }
+
+ [Fact]
+ public void Percentiles_UseNearestRank_AndMaxCatchesTheHitch()
+ {
+ var s = OneHitch().Summarize();
+
+ Assert.Equal(10.0, s.P50Ms, 6);
+ Assert.Equal(10.0, s.P95Ms, 6);
+ Assert.Equal(10.0, s.P99Ms, 6); // rank ceil(0.99*100) = 99th of 100
+ Assert.Equal(100.0, s.MaxMs, 6);
+ }
+
+ [Fact]
+ public void OnePercentLow_AveragesTheSlowestOnePercentOfFrames()
+ {
+ var s = OneHitch().Summarize();
+
+ Assert.Equal(10.0, s.OnePercentLowFps, 6); // worst 1 of 100 frames = 100 ms
+ }
+
+ [Fact]
+ public void Hitches_CountFramesOverTwiceTheMedian()
+ {
+ var stats = new FrameTimeStats(capacity: 10);
+ foreach (var ms in new[] { 10, 10, 10, 10, 10, 20, 21, 35 })
+ stats.Record(ms / 1000.0, 0, 0, 0);
+
+ Assert.Equal(2, stats.Summarize().Hitches); // median 10 → 21 and 35 (20 is not > 20)
+ }
+
+ [Fact]
+ public void Window_DropsOldestFrames()
+ {
+ var stats = new FrameTimeStats(capacity: 4);
+ foreach (var ms in new[] { 100, 100, 3, 4, 5, 6 })
+ stats.Record(ms / 1000.0, 0, 0, 0);
+
+ Span<float> intervals = stackalloc float[4];
+ int n = stats.CopyIntervalsMs(intervals);
+
+ Assert.Equal(4, n);
+ Assert.Equal(new float[] { 3, 4, 5, 6 }, intervals.ToArray());
+ Assert.Equal(6.0, stats.Summarize().MaxMs, 4);
+ }
+
+ [Fact]
+ public void Sim_ReportsAchievedTickRateAndCostPerTick()
+ {
+ var stats = new FrameTimeStats(capacity: 60);
+ for (int i = 0; i < 60; i++) // 1 s at 60 fps; a tick every other frame, 1 ms each
+ stats.Record(1.0 / 60.0, workSeconds: 0.004, simTicks: i % 2, simSeconds: (i % 2) * 0.001);
+
+ var s = stats.Summarize();
+
+ Assert.Equal(30.0, s.SimTickRate, 3);
+ Assert.Equal(1.0, s.AvgTickMs, 6);
+ Assert.Equal(4.0, s.AvgWorkMs, 6);
+ }
+
+ [Fact]
+ public void Empty_SummarizesToZerosWithoutDividingByZero()
+ {
+ var s = new FrameTimeStats().Summarize();
+
+ Assert.Equal(0, s.Frames);
+ Assert.Equal(0.0, s.Fps);
+ Assert.Equal(0.0, s.AvgTickMs);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs b/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs
new file mode 100644
index 0000000..46cf5ed
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs
@@ -0,0 +1,53 @@
+using System.Globalization;
+using System.Threading;
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class GpuAndCsvTests
+{
+ [Fact]
+ public void GpuSamples_AverageSeparatelyFromFrames()
+ {
+ var stats = new FrameTimeStats(capacity: 4);
+ stats.Record(0.016, 0.002, 1, 0.0001);
+ Assert.Equal(0, stats.Summarize().GpuSamples);
+ Assert.Equal(0.0, stats.Summarize().AvgGpuMs);
+
+ stats.RecordGpu(0.001);
+ stats.RecordGpu(0.003);
+
+ var s = stats.Summarize();
+ Assert.Equal(2, s.GpuSamples);
+ Assert.Equal(2.0, s.AvgGpuMs, precision: 9);
+ }
+
+ [Fact]
+ public void GpuSamples_RollOverTheWindow()
+ {
+ var stats = new FrameTimeStats(capacity: 2);
+ stats.RecordGpu(0.010);
+ stats.RecordGpu(0.001);
+ stats.RecordGpu(0.003);
+
+ Assert.Equal(2.0, stats.Summarize().AvgGpuMs, precision: 9);
+ }
+
+ [Fact]
+ public void CsvRow_IsInvariantCulture_MsWithThreeDecimals_GpuBlankWhenUnknown()
+ {
+ var previous = Thread.CurrentThread.CurrentCulture;
+ Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE"); // comma decimals must not leak in
+ try
+ {
+ Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms", PerfCsv.Header());
+ Assert.Equal("12.500,16.667,2.000,1,0.100,1.250", PerfCsv.Row(12.5, 0.0166667, 0.002, 1, 0.0001, 0.00125));
+ Assert.Equal("0.000,16.667,2.000,0,0.000,", PerfCsv.Row(0, 0.0166667, 0.002, 0, 0, null));
+ }
+ finally
+ {
+ Thread.CurrentThread.CurrentCulture = previous;
+ }
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs b/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs
new file mode 100644
index 0000000..fd64778
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs
@@ -0,0 +1,38 @@
+using System.Globalization;
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class PerfCsvTests
+{
+ [Fact]
+ public void Header_AppendsCallerColumnsInOrder()
+ {
+ Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms", PerfCsv.Header());
+ Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms,draw_calls,triangles",
+ PerfCsv.Header("draw_calls", "triangles"));
+ }
+
+ [Fact]
+ public void Row_AppendsCallerValuesInOrder()
+ {
+ Assert.Equal("1.500,16.000,4.000,2,1.000,,12,2.500,",
+ PerfCsv.Row(1.5, 0.016, 0.004, 2, 0.001, null, "12", PerfCsv.Ms(0.0025), PerfCsv.Ms(null)));
+ }
+
+ [Fact]
+ public void Ms_IsInvariant_ThreeDecimals()
+ {
+ var previous = Thread.CurrentThread.CurrentCulture;
+ try
+ {
+ Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
+ Assert.Equal("12.500", PerfCsv.Ms(0.0125));
+ }
+ finally
+ {
+ Thread.CurrentThread.CurrentCulture = previous;
+ }
+ }
+}
diff --git a/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj b/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj
new file mode 100644
index 0000000..76a07ab
--- /dev/null
+++ b/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj
@@ -0,0 +1,13 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <IsPackable>false</IsPackable>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" />
+ <PackageReference Include="xunit" />
+ <PackageReference Include="xunit.runner.visualstudio" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs b/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs
new file mode 100644
index 0000000..9c0c9dc
--- /dev/null
+++ b/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs
@@ -0,0 +1,56 @@
+using System.Numerics;
+using Godosa.Core.Input;
+using Xunit;
+
+namespace Godosa.Core.Tests.Input;
+
+public class GamepadScriptTests
+{
+ [Theory]
+ [InlineData("PadA", PadButtons.A)]
+ [InlineData("PadB", PadButtons.B)]
+ [InlineData("PadX", PadButtons.X)]
+ [InlineData("PadY", PadButtons.Y)]
+ [InlineData("PadBack", PadButtons.Back)]
+ [InlineData("PadStart", PadButtons.Start)]
+ [InlineData("PadUp", PadButtons.DpadUp)]
+ [InlineData("PadDown", PadButtons.DpadDown)]
+ [InlineData("PadLeft", PadButtons.DpadLeft)]
+ [InlineData("PadRight", PadButtons.DpadRight)]
+ [InlineData("PadLB", PadButtons.LeftShoulder)]
+ [InlineData("PadRB", PadButtons.RightShoulder)]
+ public void Button_DownHolds_UpReleases(string name, PadButtons button)
+ {
+ var down = GamepadScript.Apply(default, name, true)!.Value;
+ Assert.Equal(button, down.Buttons);
+ Assert.True(down.Held(button));
+ Assert.Equal(PadButtons.None, GamepadScript.Apply(down, name, false)!.Value.Buttons);
+ }
+
+ [Fact]
+ public void Button_LeavesOtherButtonsAndSticksAlone()
+ {
+ var start = new GamepadState(new Vector2(0.5f, 0f), Vector2.Zero, 0f, 0f, PadButtons.B);
+ var next = GamepadScript.Apply(start, "PadA", true)!.Value;
+ Assert.Equal(PadButtons.A | PadButtons.B, next.Buttons);
+ Assert.Equal(new Vector2(0.5f, 0f), next.LeftStick);
+ Assert.Equal(PadButtons.A, GamepadScript.Apply(next, "PadB", false)!.Value.Buttons);
+ }
+
+ [Fact]
+ public void Triggers_GoFullOnAndOff()
+ {
+ var lt = GamepadScript.Apply(default, "PadLT", true)!.Value;
+ Assert.Equal(1f, lt.LeftTrigger);
+ Assert.Equal(0f, lt.RightTrigger);
+ Assert.Equal(0f, GamepadScript.Apply(lt, "PadLT", false)!.Value.LeftTrigger);
+ Assert.Equal(1f, GamepadScript.Apply(default, "PadRT", true)!.Value.RightTrigger);
+ }
+
+ [Fact]
+ public void Names_AreCaseInsensitive_UnknownIsNull()
+ {
+ Assert.True(GamepadScript.Apply(default, "padstart", true)!.Value.Held(PadButtons.Start));
+ Assert.Null(GamepadScript.Apply(default, "PadZ", true));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Input/InputScriptTests.cs b/src/Godosa.Core.Tests/Input/InputScriptTests.cs
new file mode 100644
index 0000000..d208240
--- /dev/null
+++ b/src/Godosa.Core.Tests/Input/InputScriptTests.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Godosa.Core.Input;
+using Xunit;
+
+namespace Godosa.Core.Tests.Input;
+
+public class InputScriptTests
+{
+ [Fact]
+ public void Parse_AbsoluteRelativeHoldPressClick_SortedByTime()
+ {
+ var script = InputScript.Parse("""
+ # walk to the Elder
+ 1.0 hold W 2.5
+ +0.5 press E
+ 5 click Accept
+ 4.0 down Space
+ 4.2 up Space
+ """);
+
+ Assert.Equal(new[]
+ {
+ new ScriptEvent(1.0, ScriptAction.Down, "W"),
+ new ScriptEvent(1.5, ScriptAction.Down, "E"),
+ new ScriptEvent(1.5 + InputScript.PressSeconds, ScriptAction.Up, "E"),
+ new ScriptEvent(3.5, ScriptAction.Up, "W"),
+ new ScriptEvent(4.0, ScriptAction.Down, "Space"),
+ new ScriptEvent(4.2, ScriptAction.Up, "Space"),
+ new ScriptEvent(5.0, ScriptAction.Click, "Accept"),
+ }, script.Events);
+ }
+
+ [Fact]
+ public void Type_KeepsTheWholeText() =>
+ Assert.Equal(new ScriptEvent(1.0, ScriptAction.Type, "Ada Lovelace"), InputScript.Parse("1 type Ada Lovelace").Events.Single());
+
+ [Fact]
+ public void Click_KeepsTheWholeLabel() =>
+ Assert.Equal("Take all", InputScript.Parse("2 click Take all").Events.Single().Arg);
+
+ [Theory]
+ [InlineData("1.0 jump W")]
+ [InlineData("abc down W")]
+ [InlineData("1.0 down")]
+ [InlineData("1.0 hold W")]
+ [InlineData("+0.5 down W")]
+ public void Parse_BadLine_ReportsItsLineNumber(string bad)
+ {
+ var ex = Assert.Throws<FormatException>(() => InputScript.Parse("# ok\n" + bad));
+ Assert.Contains("line 2", ex.Message);
+ }
+
+ [Fact]
+ public void Due_ReturnsEachEventOnceWhenItsTimeComes()
+ {
+ var script = InputScript.Parse("1 down W\n2 up W\n2 press E");
+ var got = new List<ScriptEvent>();
+
+ script.Due(0.99, got);
+ Assert.Empty(got);
+ script.Due(1.0, got);
+ Assert.Single(got);
+ script.Due(2.0, got);
+ Assert.Equal(3, got.Count);
+ script.Due(10.0, got);
+ Assert.Equal(4, got.Count);
+ script.Due(20.0, got);
+ Assert.Equal(4, got.Count);
+ Assert.True(script.Finished);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Input/MenuCursorTests.cs b/src/Godosa.Core.Tests/Input/MenuCursorTests.cs
new file mode 100644
index 0000000..79a1184
--- /dev/null
+++ b/src/Godosa.Core.Tests/Input/MenuCursorTests.cs
@@ -0,0 +1,42 @@
+using Godosa.Core.Input;
+using Xunit;
+
+namespace Godosa.Core.Tests.Input;
+
+public class MenuCursorTests
+{
+ [Fact]
+ public void Move_WrapsAround()
+ {
+ var c = new MenuCursor();
+ c.Clamp(3);
+
+ c.Move(-1, 3);
+ Assert.Equal(2, c.Index);
+ c.Move(+1, 3);
+ Assert.Equal(0, c.Index);
+ c.Move(+1, 3);
+ Assert.Equal(1, c.Index);
+ }
+
+ [Fact]
+ public void Clamp_KeepsIndexInsideAShrinkingList_AndEmptyMeansNone()
+ {
+ var c = new MenuCursor();
+ c.Clamp(5);
+ c.Move(+1, 5);
+ c.Move(+1, 5);
+ c.Move(+1, 5);
+
+ c.Clamp(2);
+ Assert.Equal(1, c.Index);
+
+ c.Clamp(0);
+ Assert.Equal(-1, c.Index);
+ c.Move(+1, 0);
+ Assert.Equal(-1, c.Index);
+
+ c.Clamp(4);
+ Assert.Equal(0, c.Index);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs
new file mode 100644
index 0000000..31295ab
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs
@@ -0,0 +1,116 @@
+using Godosa.Core.Runs;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs;
+
+public class RouteLegsTests
+{
+ // 5×5 grid, rows top to bottom; '#' blocked, 's' soft (sealed by the game's scripts, opened later).
+ private static Func<long, long, bool> Grid(params string[] rows) => (x, y) => rows[y][(int)x] is '#' or 's';
+ private static Func<long, long, bool> Soft(params string[] rows) => (x, y) => rows[y][(int)x] == 's';
+
+ [Fact]
+ public void Path_GoesRoundAWall_StartExcluded()
+ {
+ var blocked = Grid(
+ ".#...",
+ ".#...",
+ ".#...",
+ ".....",
+ ".....");
+ // From 0,0 the only way past the wall is under it: down the left column to row 3, then up-right diagonally.
+ var path = RouteLegs.Path((0, 0), (2, 0), 5, 5, blocked);
+ Assert.NotNull(path);
+ Assert.Equal((2, 0), path![^1]);
+ Assert.DoesNotContain((0L, 0L), path);
+ Assert.Equal(6, path.Count); // 0,1 0,2 1,3 2,2 2,1 2,0
+ Assert.All(path, c => Assert.False(blocked(c.X, c.Y)));
+ }
+
+ [Fact]
+ public void Path_NoWay_Null()
+ {
+ var blocked = Grid(
+ "..#..",
+ "..#..",
+ "..#..",
+ "..#..",
+ "..#..");
+ Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, blocked));
+ }
+
+ // The game's router takes a leg only within 2 cells (Chebyshev): from each stop the farthest accepted path cell.
+ [Fact]
+ public void Legs_FarthestAcceptedStopEachTime()
+ {
+ var path = new List<(long X, long Y)> { (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), (7, 0) };
+ var legs = RouteLegs.Legs((0, 0), path, (a, b) => Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)) <= 2);
+ Assert.Equal([(2L, 0L), (4L, 0L), (6L, 0L), (7L, 0L)], legs);
+ }
+
+ [Fact]
+ public void Legs_RouterAcceptsNothing_NullWithTheStuckStop()
+ {
+ var path = new List<(long X, long Y)> { (1, 0), (2, 0) };
+ Assert.Null(RouteLegs.Legs((0, 0), path, (a, b) => b == (1, 0), out var stuck));
+ Assert.Equal((1, 0), stuck);
+ }
+
+ // Closed off only by a sealed (soft) cell: the seals a way would cross once open, start to goal.
+ [Fact]
+ public void Seals_ListedInOrder()
+ {
+ string[] rows =
+ [
+ "..s..",
+ "#####",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, Grid(rows)));
+ Assert.Equal([(2L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(rows), Soft(rows)));
+ Assert.Empty(RouteLegs.Seals((0, 0), (4, 4), 5, 5, Grid(rows), Soft(rows))); // walled, not sealed
+ }
+
+ [Fact]
+ public void Seals_CappedAndNoneWhenOpenAlready()
+ {
+ string[] twice =
+ [
+ ".s.s.",
+ "#####",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Equal([(1L, 0L), (3L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice)));
+ Assert.Equal([(1L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice), max: 1));
+ // Open the long way round (row 2): a seal on the short way is no reason to wait.
+ string[] detour =
+ [
+ "..s..",
+ "#.#.#",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Empty(RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(detour), Soft(detour)));
+ }
+
+ // Hops: walk to a portal, click it, arrive at its exit; BFS over portal exits.
+ [Fact]
+ public void Hops_ThroughTwoPortals()
+ {
+ // Three rooms on a line, x 0–9 / 10–19 / 20–29 (walking stays inside a room); portal A in room 1 at 5 → 12, B in
+ // room 2 at 18 → 25; a dead-end portal C in room 1 at 2 → 40.
+ static int Room(long x) => (int)(x / 10);
+ bool Reaches(long from, long to) => Room(from) == Room(to);
+ var portals = new[] { ("A", 5L, 12L), ("B", 18L, 25L), ("C", 2L, 40L) };
+ var hops = RouteLegs.Hops(1L, 27L, portals, p => p.Item2, p => p.Item3, Reaches);
+ Assert.NotNull(hops);
+ Assert.Equal(["A", "B"], hops!.Select(p => p.Item1));
+ Assert.Null(RouteLegs.Hops(1L, 35L, portals, p => p.Item2, p => p.Item3, Reaches));
+ Assert.Empty(RouteLegs.Hops(1L, 7L, portals, p => p.Item2, p => p.Item3, Reaches)!);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/RunsTests.cs b/src/Godosa.Core.Tests/Runs/RunsTests.cs
new file mode 100644
index 0000000..1b6d6d4
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/RunsTests.cs
@@ -0,0 +1,149 @@
+using Godosa.Core.Runs;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs;
+
+public class RunsTests
+{
+ private static RunFlags Parse(params string[] args)
+ {
+ var flags = new RunFlags();
+ for (var i = 0; i < args.Length; i++)
+ Assert.True(RunFlags.TryTake(args, ref i, ref flags), args[i]);
+ return flags;
+ }
+
+ [Fact]
+ public void Flags_DeterministicWithAndWithoutSeed()
+ {
+ Assert.Equal(new RunFlags(Deterministic: true, Seed: 7), Parse("--deterministic", "7"));
+ Assert.Equal(new RunFlags(Deterministic: true), Parse("--deterministic"));
+ Assert.Equal(1UL, Parse("--deterministic").SeedOr(1));
+ Assert.Equal(7UL, Parse("--deterministic", "7").SeedOr(1));
+ }
+
+ [Fact]
+ public void Flags_SeedNotTakenWhenNextIsAnOption()
+ {
+ string[] args = ["--deterministic", "--mute"];
+ var flags = new RunFlags();
+ var i = 0;
+ Assert.True(RunFlags.TryTake(args, ref i, ref flags));
+ Assert.Equal(0, i);
+ i = 1;
+ Assert.False(RunFlags.TryTake(args, ref i, ref flags));
+ }
+
+ [Fact]
+ public void Flags_DigestEveryAndHeadless()
+ {
+ Assert.Equal(new RunFlags(Digest: true), Parse("--digest"));
+ Assert.Equal(new RunFlags(Digest: true, DigestEvery: 60), Parse("--digest", "every=60"));
+ Assert.Equal(new RunFlags(Deterministic: true, Headless: true), Parse("--headless"));
+ Assert.Equal(new RunFlags(Deterministic: true, Seed: 3, Headless: true), Parse("--headless", "--deterministic", "3"));
+ }
+
+ [Fact]
+ public void Flags_BadDigestEvery_Throws() =>
+ Assert.Throws<ArgumentException>(() => Parse("--digest", "every=0"));
+
+ [Fact]
+ public void Clock_DeterministicFixedStep()
+ {
+ var clock = new FrameClock(true);
+ Assert.Equal(1.0 / 60, clock.Next(0.5));
+ Assert.Equal(1.0 / 60, clock.Next(0.001));
+ Assert.Equal(2, clock.Frame);
+ Assert.Equal(2.0 / 60, clock.Seconds, 12);
+ }
+
+ [Fact]
+ public void Clock_WallClamped()
+ {
+ var clock = new FrameClock(false);
+ Assert.Equal(0.1, clock.Next(0.5));
+ Assert.Equal(0.02, clock.Next(0.02));
+ Assert.Equal(0, clock.Next(-1));
+ Assert.Equal(3, clock.Frame);
+ }
+
+ [Fact]
+ public void Clock_Held_NoTimeNoFrame()
+ {
+ var clock = new FrameClock(true) { Held = true };
+ Assert.Equal(0, clock.Next(1.0 / 60));
+ Assert.Equal(0, clock.Frame);
+ Assert.Equal(0, clock.Seconds);
+ clock.Held = false;
+ clock.Next(0);
+ Assert.Equal(1, clock.Frame);
+ }
+
+ // Published FNV-1a 64 vectors (isthe.com/chongo/tech/comp/fnv).
+ [Theory]
+ [InlineData("", 0xcbf29ce484222325UL)]
+ [InlineData("a", 0xaf63dc4c8601ec8cUL)]
+ [InlineData("foobar", 0x85944171f73967e8UL)]
+ public void Digest_FnvVectors(string text, ulong expected)
+ {
+ var d = new StateDigest();
+ d.Add(text);
+ Assert.Equal(expected, d.Value);
+ Assert.Equal(expected, StateDigest.Of(System.Text.Encoding.UTF8.GetBytes(text)));
+ }
+
+ // Hand-checked: FNV-1a 64 of the little-endian bytes 01 00 00 00 and of 1.5 as an IEEE double.
+ [Fact]
+ public void Digest_NumbersLittleEndian()
+ {
+ var u = new StateDigest();
+ u.Add(1u);
+ Assert.Equal(0xad2aca7747985764UL, u.Value);
+ var d = new StateDigest();
+ d.Add(1.5);
+ Assert.Equal(0xaa95e93229a27c80UL, d.Value);
+ Assert.Equal("aa95e93229a27c80", d.Hex);
+ }
+
+ [Fact]
+ public void Line_Format() =>
+ Assert.Equal("digest 120 3.5 000000000000beef", DigestLine.Format(120, 3.5, 0xbeef));
+
+ [Fact]
+ public void Line_FormatRoundsGameTime() =>
+ Assert.Equal("digest 1 0.017 0000000000000001", DigestLine.Format(1, 1.0 / 60, 1));
+
+ [Fact]
+ public void Line_Due()
+ {
+ Assert.True(DigestLine.Due(60, 60));
+ Assert.False(DigestLine.Due(59, 60));
+ Assert.False(DigestLine.Due(0, 60));
+ Assert.False(DigestLine.Due(60, 0));
+ }
+
+ // SplitMix64 seeded 0: outputs e220a8397b1dcdaf, 6e789e6aa1b965f4 (published reference sequence).
+ [Fact]
+ public void Seeds_SplitMixReference()
+ {
+ Assert.Equal(0xe220a8397b1dcdafUL, SeedStreams.Of(0, 0));
+ Assert.Equal(0x6e789e6aa1b965f4UL, SeedStreams.Of(0, 1));
+ Assert.Equal(0x63cbe1e459320dd7UL, SeedStreams.Of(7, 0));
+ }
+
+ [Fact]
+ public void Loop_StopsWhenFrameReturnsFalse()
+ {
+ var seen = new List<long>();
+ var (frames, capped) = HeadlessLoop.Run(n => { seen.Add(n); return n < 3; }, null);
+ Assert.Equal([1L, 2, 3], seen);
+ Assert.Equal(3, frames);
+ Assert.False(capped);
+ }
+
+ [Fact]
+ public void Loop_FramesLimit() => Assert.Equal((5L, false), HeadlessLoop.Run(_ => true, 5));
+
+ [Fact]
+ public void Loop_Cap() => Assert.Equal((10L, true), HeadlessLoop.Run(_ => true, null, cap: 10));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs
new file mode 100644
index 0000000..1b53910
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs
@@ -0,0 +1,47 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Multi-click verbs (walk-path, fight, travel-to): a poll may hand the runner more input; the plan plays from
+/// the next frame (its frame 0), the action is not polled until it is sent.</summary>
+public sealed class ActionInputTests
+{
+ [Fact]
+ public void PollInput_PlaysFromTheNextFrame_ThenPollsAgain()
+ {
+ var g = new FakeGame();
+ var polls = new List<int>();
+ var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(2, new InputEvent(InputKind.KeyUp, Key: "B"))]);
+ g.ActionTable["walk"] = new FakeAction(VerbKind.Player,
+ _ => new ActionStart(new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "A"))])),
+ n =>
+ {
+ polls.Add(n);
+ return n == 1 ? StepStatus.Input(follow) : StepStatus.Done;
+ });
+ var (r, output) = Run("format 1\nmode player\nwalk\nlog done", g);
+
+ Assert.True(r.Outcome == RunOutcome.Passed, string.Join("\n", output));
+ Assert.Equal(["A", "B", "B"], g.Sent.Select(s => s.Event.Key));
+ var a = g.Sent[0].Frame;
+ Assert.Equal(a + 1, g.Sent[1].Frame); // polled after frame a → B on the next frame
+ Assert.Equal(a + 3, g.Sent[2].Frame); // its frame 2
+ Assert.Equal([1, 2], polls); // no poll while the follow-up plan is unsent
+ Assert.Contains("done", output);
+ }
+
+ [Fact]
+ public void PollInput_HalfSent_ReleasedOnFailure()
+ {
+ var g = new FakeGame();
+ var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(50, new InputEvent(InputKind.KeyUp, Key: "B"))]);
+ g.ActionTable["walk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), n => StepStatus.Input(follow));
+ var (r, _) = Run("format 1\nmode player\nwalk", g, new ScriptRunOptions { StepCap = 10 });
+
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Equal(InputKind.KeyUp, g.Sent[^1].Event.Kind); // the held key is let go
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
new file mode 100644
index 0000000..a84cf94
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
@@ -0,0 +1,185 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public sealed class CheckpointCacheTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private (ScriptRunner Runner, RunResult Result, List<string> Output) Run(string main, FakeGame g, ScriptRunOptions? o = null, Dictionary<string, string>? files = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary<string, string>()), g,
+ o ?? new ScriptRunOptions { CacheDir = _dir }, output.Add);
+ return (runner, runner.Run(10_000), output);
+ }
+
+ private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C";
+
+ // --no-snapshots: checkpoints are markers only — no digest (a whole-world serialization: seconds late in a big game,
+ // the window froze), nothing cached; a later run resumes from nothing.
+ [Fact]
+ public void NoSnapshots_CheckpointsNeitherDigestNorCache()
+ {
+ var g = new FakeGame();
+ var (r, res, output) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Snapshots = false });
+ Assert.Equal(RunOutcome.Passed, res.Outcome);
+ Assert.Equal(0, g.Digests);
+ Assert.Equal(["marker", "marker"], r.Report!.Checkpoints.Select(c => c.Source));
+ Assert.Contains("checkpoint two f=4", output);
+ Assert.False(Directory.Exists(_dir) && Directory.EnumerateFiles(_dir, "*", SearchOption.AllDirectories).Any());
+ var (_, stopped, _) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Snapshots = false, Until = "one" });
+ Assert.Equal((RunOutcome.Stopped, 2L), (stopped.Outcome, stopped.Frames));
+ }
+
+ [Fact]
+ public void SecondRun_ResumesAtTheLastCachedCheckpoint()
+ {
+ var first = new FakeGame();
+ first.OnAdvance = f => first.State += 1;
+ var (r1, _, _) = Run(Script, first);
+ Assert.Equal(6, r1.Report!.Frames);
+ Assert.Equal(["replayed", "replayed"], r1.Report.Checkpoints.Select(c => c.Source));
+
+ var second = new FakeGame();
+ second.OnAdvance = f => second.State += 1;
+ var (r2, res, output) = Run(Script, second);
+ Assert.Equal(RunOutcome.Passed, res.Outcome);
+ Assert.Contains("resumed at two (cache) f=4", output);
+ Assert.Equal(["C"], second.Sent.Where(s => s.Event.Kind == InputKind.KeyDown).Select(s => s.Event.Key));
+ Assert.Equal((6L, 6), (res.Frames, second.State)); // restored frame 4 / state 4, key C = 2 frames → 6 / 6
+ Assert.Equal(1, r2.Report!.CacheHits);
+ }
+
+ // A window restore rebuilds the game view after the frame: the runner pauses (Restoring) for that frame, no game frame
+ // passes, and the run ends as an immediate restore would.
+ [Fact]
+ public void DeferredRestore_PausesOneFrame_SameResult()
+ {
+ var first = new FakeGame();
+ first.OnAdvance = f => first.State += 1;
+ Run(Script, first);
+
+ var second = new FakeGame { RestoreDeferred = true };
+ second.OnAdvance = f => second.State += 1;
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = Script }),
+ new Dictionary<string, string>()), second, new ScriptRunOptions { CacheDir = _dir }, _ => { });
+ runner.BeforeFrame();
+ Assert.True(runner.Restoring);
+ Assert.Empty(second.Sent);
+
+ var third = new FakeGame { RestoreDeferred = true };
+ third.OnAdvance = f => third.State += 1;
+ var (r3, res, _) = Run(Script, third);
+ Assert.False(r3.Restoring);
+ Assert.Equal((6L, 6), (res.Frames, third.State)); // the paused frame advanced nothing
+ }
+
+ [Fact]
+ public void ChangedStepBeforeACheckpoint_InvalidatesIt_LaterStepsDoNot()
+ {
+ Run(Script, new FakeGame());
+ var (r, _, output) = Run(Script.Replace("key B", "key X"), new FakeGame());
+ Assert.Contains("resumed at one (cache) f=2", output);
+ var (r3, _, output3) = Run(Script.Replace("key C", "key Y"), new FakeGame());
+ Assert.Contains("resumed at two (cache) f=4", output3);
+ }
+
+ [Fact]
+ public void CommentEdits_KeepTheCache()
+ {
+ Run(Script, new FakeGame());
+ var (_, _, output) = Run("# new comment\n" + Script.Replace("key A", "key A # aim"), new FakeGame());
+ Assert.Contains("resumed at two (cache) f=4", output);
+ }
+
+ [Fact]
+ public void SeedContentOrVars_InvalidateEverything()
+ {
+ Run(Script, new FakeGame());
+ var (_, _, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Seed = 5 });
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ var (_, _, output2) = Run(Script, new FakeGame { ContentVersion = "c2" });
+ Assert.DoesNotContain(output2, l => l.StartsWith("resumed"));
+ }
+
+ [Fact]
+ public void NoCache_ReplaysAndWarnsOnDigestMismatch()
+ {
+ Run(Script, new FakeGame());
+ var drift = new FakeGame { State = 100 };
+ var (r, _, output) = Run(Script, drift, new ScriptRunOptions { CacheDir = _dir, UseCache = false });
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ Assert.Contains(r.Report!.Warnings, w => w.StartsWith("checkpoint one digest differs from cache"));
+ // Cache replaced: a cached run now resumes from the new state.
+ var after = new FakeGame();
+ Run(Script, after);
+ Assert.Equal(100, after.State);
+ }
+
+ [Fact]
+ public void From_RequiresACachedCheckpoint_Until_StopsEarly()
+ {
+ var (_, missing, out1) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "two" });
+ Assert.Equal((RunOutcome.Invalid, 2), (missing.Outcome, missing.ExitCode));
+ Assert.Contains("checkpoint two not cached", out1);
+ var (_, unknown, out2) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "nope" });
+ Assert.Contains("unknown checkpoint nope", out2);
+ var g = new FakeGame();
+ var (_, stopped, _) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Until = "one" });
+ Assert.Equal((RunOutcome.Stopped, 0, 2L), (stopped.Outcome, stopped.ExitCode, stopped.Frames));
+ var (_, from, outFrom) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "one" });
+ Assert.Equal(RunOutcome.Passed, from.Outcome);
+ Assert.Contains("resumed at one (cache) f=2", outFrom);
+ }
+
+ // Review focus 3: a checkpoint inside an include + def call restores vars, output level and labels.
+ [Fact]
+ public void Resume_InsideIncludeAndCall_RestoresScopes()
+ {
+ var files = new Dictionary<string, string>
+ {
+ ["ch.steps"] = "format 1\noutput steps\ndef d(who):\n set inner = ${who}-x\n checkpoint deep\n log \"${who} ${inner} ${top}\"\nstep s:\n call d(gil)\n key Q",
+ };
+ const string main = "format 1\nmode player\nset top = T\ninclude ch.steps\nkey Z";
+ var (_, _, replay) = Run(main, new FakeGame(), files: files);
+ var (_, _, resumed) = Run(main, new FakeGame(), files: files);
+ Assert.Contains("resumed at deep (cache) f=0", resumed);
+ string[] Tail(List<string> o) => [.. o.Where(l => !l.StartsWith("resumed") && !l.StartsWith("checkpoint") && !l.StartsWith("digest"))];
+ Assert.Equal(Tail(replay), Tail(resumed));
+ Assert.Contains("gil gil-x T", resumed);
+ Assert.Contains(resumed, l => l.StartsWith("[ch.steps:9 s] key Q done")); // output level steps + label restored
+ }
+
+ [Fact]
+ public void Eviction_KeepsTotalUnderTheCap()
+ {
+ var cache = new CheckpointCache(_dir, capBytes: 300);
+ var state = new RunnerState(0, 0, [], []);
+ for (var i = 0; i < 5; i++)
+ {
+ cache.Save($"k{i}", new CachedCheckpoint(state, new byte[100]));
+ File.SetLastWriteTimeUtc(Path.Combine(_dir, $"k{i}.snap"), new DateTime(2026, 1, 1, 0, 0, i, DateTimeKind.Utc));
+ }
+ var left = Directory.GetFiles(_dir, "*.snap").Select(Path.GetFileNameWithoutExtension).Order().ToList();
+ Assert.True(Directory.GetFiles(_dir, "*.snap").Sum(f => new FileInfo(f).Length) <= 300);
+ Assert.Contains("k4", left);
+ Assert.DoesNotContain("k0", left);
+ }
+
+ [Fact]
+ public void NoSnapshotSupport_NoCache()
+ {
+ var (r, _, _) = Run(Script, new FakeGame { SupportsSnapshot = false });
+ Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir).Length > 0);
+ Assert.Equal(0, r.Report!.CacheMisses);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs
new file mode 100644
index 0000000..d14ea6a
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs
@@ -0,0 +1,63 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ConditionTests
+{
+ private sealed class Ctx : IConditionContext
+ {
+ public Dictionary<string, RunValue> Values { get; } = new()
+ {
+ ["quest(1)"] = RunValue.Of(2), ["map"] = RunValue.Of("tarant"), ["dialog-open"] = RunValue.Of(true),
+ ["global(gold)"] = RunValue.Of(500), ["alive"] = RunValue.Of(false),
+ };
+
+ public bool HasProbe(string name) => name is "quest" or "map" or "dialog-open" or "global" or "alive";
+
+ public RunValue Probe(string name, IReadOnlyList<string> args) =>
+ Values[args.Count == 0 ? name : $"{name}({string.Join(",", args)})"];
+
+ public long? Constant(string name) => name switch { "accepted" => 2, "completed" => 4, _ => null };
+ }
+
+ private static bool Test(string text) => ConditionParser.Parse(text).Test(new Ctx());
+
+ [Theory]
+ [InlineData("quest(1) >= accepted", true)]
+ [InlineData("quest(1) = completed", false)]
+ [InlineData("map = \"tarant\"", true)]
+ [InlineData("map != \"ashbury\"", true)]
+ [InlineData("dialog-open", true)]
+ [InlineData("alive", false)]
+ [InlineData("not alive", true)]
+ [InlineData("dialog-open and global(gold) > 499", true)]
+ [InlineData("alive or global(gold) < 100", false)]
+ [InlineData("not (alive or dialog-open)", false)]
+ [InlineData("alive or dialog-open and quest(1) = 2", true)] // and binds tighter than or
+ [InlineData("global(gold) <= 500", true)]
+ public void Evaluates(string text, bool expected) => Assert.Equal(expected, Test(text));
+
+ [Fact]
+ public void MismatchedTypes_Fault() =>
+ Assert.Equal("cannot compare tarant with 3", Assert.Throws<ScriptFault>(() => Test("map > 3")).Message);
+
+ [Theory]
+ [InlineData("quest(1) >=")]
+ [InlineData("(alive")]
+ [InlineData("alive alive")]
+ [InlineData("\"open")]
+ public void BadSyntax_Throws(string text) => Assert.Throws<FormatException>(() => ConditionParser.Parse(text));
+
+ [Fact]
+ public void ListsProbesAndBareNames()
+ {
+ var c = ConditionParser.Parse("quest(1) >= accepted and not dialog-open");
+ Assert.Equal(["quest(1)", "accepted", "dialog-open"], c.Probes().Select(p => p.Args.Count == 0 ? p.Name : $"{p.Name}({string.Join(",", p.Args)})"));
+ Assert.Equal(["accepted", "dialog-open"], c.Bare());
+ }
+
+ [Fact]
+ public void Describe_ListsProbeValues() =>
+ Assert.Equal("quest(1)=2 map=tarant", Cond.Describe(ConditionParser.Parse("quest(1) > 3 and map = \"x\""), new Ctx()));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs
new file mode 100644
index 0000000..8318148
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs
@@ -0,0 +1,100 @@
+using Godosa.Core.Runs.Scripts;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Scriptable game for runner tests: probes are a dictionary, actions are lambdas, input is recorded with the
+/// frame it arrived on.</summary>
+public sealed class FakeGame : IRunGame, IInputRouter
+{
+ public long Frame { get; private set; }
+ public List<(long Frame, InputEvent Event)> Sent { get; } = [];
+ public Dictionary<string, Func<IReadOnlyList<string>, RunValue>> ProbeFns { get; } = new();
+ public Dictionary<string, IRunAction> ActionTable { get; } = new();
+ public Dictionary<string, (int X, int Y)> Objects { get; } = new();
+ public Dictionary<string, long> ConstantTable { get; } = new() { ["accepted"] = 2 };
+ public Action<long>? OnAdvance { get; set; }
+ public int State { get; set; }
+ public FakeRng? RngImpl { get; set; } = new();
+
+ public string Name => "fake";
+ public string ContentVersion { get; set; } = "c1";
+ public IInputRouter Input => this;
+ public IReadOnlyDictionary<string, IProbe> Probes => ProbeFns.ToDictionary(p => p.Key, p => (IProbe)new FnProbe(p.Value));
+ public IReadOnlyDictionary<string, long> Constants => ConstantTable;
+ public IReadOnlyDictionary<string, ILocator> Locators => new Dictionary<string, ILocator> { ["obj"] = new ObjLocator(this) };
+ public IReadOnlyDictionary<string, IRunAction> Actions => ActionTable;
+ public IRunRng? Rng => RngImpl;
+ public bool SupportsSnapshot { get; set; } = true;
+ public bool RestoreDeferred { get; set; }
+
+ public Action<InputEvent>? OnSend { get; set; }
+ public bool RestoreThrows { get; set; }
+ public string? Refusal { get; set; }
+ public string? SnapshotRefusal() => Refusal;
+
+ public void Send(InputEvent e)
+ {
+ OnSend?.Invoke(e);
+ Sent.Add((Frame, e));
+ }
+
+ public byte[] SnapshotSave() => BitConverter.GetBytes(State).Concat(BitConverter.GetBytes(Frame)).ToArray();
+
+ public void SnapshotRestore(byte[] data)
+ {
+ if (RestoreThrows)
+ throw new IOException("restore broke");
+ State = BitConverter.ToInt32(data, 0);
+ Frame = BitConverter.ToInt64(data, 4);
+ }
+
+ public int Digests { get; private set; }
+ public ulong Digest() { Digests++; return (ulong)State * 1_000_003UL + (ulong)Frame; }
+ public string Dump() => $"state={State} frame={Frame}";
+
+ public void Advance()
+ {
+ Frame++;
+ OnAdvance?.Invoke(Frame);
+ }
+
+ private sealed class FnProbe(Func<IReadOnlyList<string>, RunValue> fn) : IProbe
+ {
+ public int MinArgs => 0;
+ public int MaxArgs => 4;
+ public RunValue Eval(IReadOnlyList<string> args) => fn(args);
+ }
+
+ private sealed class ObjLocator(FakeGame g) : ILocator
+ {
+ public LocatorResult Locate(IReadOnlyList<string> args) =>
+ g.Objects.TryGetValue(args[0], out var p) ? LocatorResult.Ok(p.X, p.Y) : LocatorResult.Fail($"no object {args[0]} on screen");
+ }
+}
+
+public sealed class FakeRng : IRunRng
+{
+ public List<string> Calls { get; } = [];
+ public void Seed(long seed) => Calls.Add($"seed {seed}");
+ public void ForceNext(string kind, long value) => Calls.Add($"next {kind} {value}");
+}
+
+/// <summary>An action: optional input plan, then Poll() answers from a function of the frames polled so far.</summary>
+public sealed class FakeAction(VerbKind kind, Func<IReadOnlyList<string>, ActionStart> begin, Func<int, StepStatus>? poll = null,
+ int min = 0, int max = 4) : IRunAction
+{
+ private int _polls;
+ public VerbKind Kind => kind;
+ public int MinArgs => min;
+ public int MaxArgs => max;
+ public List<IReadOnlyList<string>> Begun { get; } = [];
+
+ public ActionStart Begin(IReadOnlyList<string> args)
+ {
+ Begun.Add(args);
+ _polls = 0;
+ return begin(args);
+ }
+
+ public StepStatus Poll() => (poll ?? (_ => StepStatus.Done))(++_polls);
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs
new file mode 100644
index 0000000..1b7257b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs
@@ -0,0 +1,157 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Final-review findings (lib core): each test failed before its fix.</summary>
+public sealed class ReviewFixTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C";
+
+ [Fact]
+ public void NoArgVerb_AndEmptySet_Run()
+ {
+ var g = new FakeGame();
+ g.ActionTable["close-panel"] = new FakeAction(VerbKind.Player, _ => new ActionStart(Status: StepStatus.Done), min: 0, max: 0);
+ var (r, output) = Run("format 1\nmode player\nset x =\nclose-panel\nlog \"[${x}]\"", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains("[]", output);
+ }
+
+ // A probe value (dialog text) with quotes inside a quoted argument stays text: the line is split into tokens first.
+ [Fact]
+ public void QuotedArgument_ValueWithQuotes_StaysOneToken()
+ {
+ var (r, output) = Run("format 1\nmode player\nset x = \"\\\"Hi,\\\" he said\"\nlog \"R: ${x}\"", new FakeGame());
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains("R: \"Hi,\" he said", output);
+ }
+
+ [Fact]
+ public void SubstitutedValues_AreNotRescanned()
+ {
+ var values = new Dictionary<string, string> { ["d"] = "$", ["e"] = "{x}", ["raw"] = "${y}" };
+ Assert.Equal("${x}", VarExpander.Expand("${d}${e}", n => values.GetValueOrDefault(n), null));
+ Assert.Equal("say ${y}!", VarExpander.Expand("say ${raw}!", n => values.GetValueOrDefault(n), null));
+ }
+
+ [Theory]
+ [InlineData("log Done:", "log", "Done:")]
+ [InlineData("type name:", "type", "name:")]
+ public void LineEndingInColon_IsAVerbUnlessABlockKeyword(string line, string verb, string args)
+ {
+ var f = ScriptParser.Parse("main.steps", $"format 1\nmode debug\n{line}");
+ Assert.Empty(f.Errors);
+ var v = Assert.IsType<VerbNode>(f.Body.Single());
+ Assert.Equal((verb, args), (v.Verb, v.Args));
+ }
+
+ [Fact]
+ public void ThrowingInputRouter_FailsTheRun()
+ {
+ var g = new FakeGame { OnSend = _ => throw new InvalidOperationException("router down") };
+ var (r, output) = Run("format 1\nmode player\nkey R", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 key R: InvalidOperationException: router down");
+ }
+
+ [Fact]
+ public void CorruptCacheEntry_IsAMiss()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ foreach (var f in Directory.GetFiles(_dir, "*.snap"))
+ File.WriteAllBytes(f, [.. "GRS1"u8, 3, 0, 0, 0, (byte)'{', (byte)'x', (byte)'!']);
+ var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ }
+
+ // A game state the snapshot can't hold (an open dialog) → checkpoint not cached, run unharmed.
+ [Fact]
+ public void RefusedSnapshot_CheckpointNotCached()
+ {
+ var g = new FakeGame { Refusal = "dialog open" };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string>
+ {
+ ["main.steps"] = Script,
+ }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = _dir }, output.Add);
+ Assert.Equal(RunOutcome.Passed, runner.Run(1000).Outcome);
+ Assert.Equal(["checkpoint one not cached: dialog open", "checkpoint two not cached: dialog open"], runner.Report!.Warnings);
+ Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir, "*.snap", SearchOption.AllDirectories).Length > 0);
+ }
+
+ [Fact]
+ public void FailingRestore_FailsTheRun()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ var (r, output) = Run(Script, new FakeGame { RestoreThrows = true }, new ScriptRunOptions { CacheDir = _dir });
+ Assert.Equal((RunOutcome.Failed, 1), (r.Outcome, r.ExitCode));
+ Assert.Contains(output, l => l.Contains("IOException: restore broke"));
+ }
+
+ [Fact]
+ public void UnwritableCacheAndReportDirs_WarnInsteadOfThrowing()
+ {
+ Directory.CreateDirectory(_dir);
+ var file = Path.Combine(_dir, "file");
+ File.WriteAllText(file, "x");
+ var g = new FakeGame();
+ g.ProbeFns["no"] = _ => RunValue.Of(false);
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string>
+ {
+ ["main.steps"] = "format 1\nmode player\ncheckpoint one\nexpect no",
+ }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = Path.Combine(file, "cache"), ReportDir = Path.Combine(file, "report") }, output.Add);
+ var r = runner.Run(1000);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(runner.Report!.Warnings, w => w.StartsWith("checkpoint cache not written:"));
+ Assert.Contains(output, l => l.StartsWith("warning: report not written:"));
+ }
+
+ [Fact]
+ public void Until_UnknownName_Invalid()
+ {
+ var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { Until = "nowhere" });
+ Assert.Equal((RunOutcome.Invalid, 2), (r.Outcome, r.ExitCode));
+ Assert.Contains("unknown label or checkpoint nowhere", output);
+ }
+
+ [Fact]
+ public void Until_ResumesOnlyBeforeIt()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); // caches one and two
+ var output = new List<string>();
+ var r = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = Script }),
+ new Dictionary<string, string>()), new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Until = "two" }, output.Add).Run(1000);
+ Assert.Contains("resumed at one (cache) f=2", output);
+ Assert.Equal((RunOutcome.Stopped, 4L), (r.Outcome, r.Frames));
+ }
+
+ [Fact]
+ public void ScriptSeedHeader_SeedsTheGame_CommandLineWins()
+ {
+ var g = new FakeGame();
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }),
+ new Dictionary<string, string>()), g, new ScriptRunOptions(), output.Add);
+ runner.Run(1000);
+ Assert.Equal(["seed 7"], g.RngImpl!.Calls);
+ Assert.Equal(7UL, runner.Report!.Seed);
+ var g2 = new FakeGame();
+ var runner2 = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }),
+ new Dictionary<string, string>()), g2, new ScriptRunOptions { Seed = 9 }, output.Add);
+ runner2.Run(1000);
+ Assert.Equal(["seed 9"], g2.RngImpl!.Calls);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs
new file mode 100644
index 0000000..00ee4b0
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs
@@ -0,0 +1,83 @@
+using System.Text.Json;
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public sealed class RunReportTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private static (ScriptRunner Runner, RunResult Result, List<string> Output) Run(string main, FakeGame g, ScriptRunOptions o, Dictionary<string, string>? files = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary<string, string>()), g, o, output.Add);
+ return (runner, runner.Run(10_000), output);
+ }
+
+ [Fact]
+ public void Failure_ReportHoldsChainStepReasonRecentDumpVars()
+ {
+ var g = new FakeGame { State = 7 };
+ g.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (runner, _, _) = Run("format 1\nmode player\nset who = Gilbert\nkey A\ninclude act2.steps", g, new ScriptRunOptions { ReportDir = _dir },
+ new() { ["act2.steps"] = "format 1\nstep talk-gilbert:\n key B\n expect quest(5) >= accepted" });
+ var f = runner.Report!.Failure!;
+ Assert.Equal("act2.steps:4", f.At);
+ Assert.Equal(["main.steps:5"], f.Chain);
+ Assert.Equal(("expect quest(5) >= accepted", "talk-gilbert"), (f.Step, f.Label));
+ Assert.Equal("expect failed: quest(5) >= accepted (quest(5)=1)", f.Reason);
+ Assert.Equal(["main.steps:4 key A f=2", "act2.steps:3 key B f=4"], f.Recent);
+ Assert.Equal("state=7 frame=4", f.Dump);
+ Assert.Equal("Gilbert", f.Vars["who"]);
+ Assert.True(File.Exists(Path.Combine(_dir, "report.txt")));
+ Assert.True(File.Exists(Path.Combine(_dir, "fail.snap")));
+ var json = JsonDocument.Parse(File.ReadAllText(Path.Combine(_dir, "report.json")));
+ Assert.Equal("Failed", json.RootElement.GetProperty("Outcome").GetString());
+ var text = File.ReadAllText(Path.Combine(_dir, "report.txt"));
+ Assert.Contains("main.steps:5 → act2.steps:4", text);
+ Assert.Contains("state=7 frame=4", text);
+ }
+
+ [Fact]
+ public void Recent_KeepsLast20()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["no"] = _ => RunValue.Of(false);
+ var steps = string.Join("\n", Enumerable.Range(1, 25).Select(n => $"key K{n}"));
+ var (runner, _, _) = Run($"format 1\nmode player\n{steps}\nexpect no", g, new ScriptRunOptions());
+ var recent = runner.Report!.Failure!.Recent;
+ Assert.Equal(20, recent.Count);
+ Assert.StartsWith("main.steps:8 key K6", recent[0]);
+ }
+
+ [Fact]
+ public void Summary_StepsFramesCheckpointsDigests()
+ {
+ var g = new FakeGame { State = 3 };
+ var (runner, r, output) = Run("format 1\nmode player\nkey A\ncheckpoint one\nstep s:\n key B", g, new ScriptRunOptions { Seed = 9 });
+ var rep = runner.Report!;
+ Assert.Equal((RunOutcome.Passed, 4L, 2, 9UL), (rep.Outcome, rep.Frames, rep.Steps, rep.Seed));
+ // Digest = State * 1_000_003 + Frame (FakeGame), at frame 2: 3 * 1000003 + 2 = 3000011.
+ Assert.Equal([new CheckpointRecord("one", 2, 3000011UL.ToString("x16"), "replayed")], rep.Checkpoints);
+ Assert.Contains(DigestLine.Format(2, 2 / 60.0, 3000011UL), output);
+ Assert.Contains(DigestLine.Format(4, 4 / 60.0, 3000013UL), output); // after the labelled step
+ }
+
+ [Fact]
+ public void NonDeterministic_NoDigestLines_FlagInReport()
+ {
+ var (runner, _, output) = Run("format 1\nmode player\ncheckpoint one", new FakeGame(), new ScriptRunOptions { Deterministic = false });
+ Assert.DoesNotContain(output, l => l.StartsWith("digest "));
+ Assert.False(runner.Report!.Deterministic);
+ Assert.Contains("deterministic: no", ReportWriter.Text(runner.Report));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs
new file mode 100644
index 0000000..f28f330
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs
@@ -0,0 +1,103 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptLoaderTests
+{
+ private static ScriptProgram Load(Dictionary<string, string> files, Dictionary<string, string>? vars = null) =>
+ ScriptLoader.Load("main.steps", new MemoryScriptSource(files), vars ?? []);
+
+ private static string Errors(ScriptProgram p) => string.Join(" | ", p.Errors.Select(e => e.ToString()));
+
+ private static string Ops(ScriptProgram p) => string.Join(" ", p.Code.Select(i => i.Op switch
+ {
+ Op.Verb => $"{i.A}", Op.ScopeBegin => i.LocalVars ? "{L" : "{", Op.ScopeEnd => "}", Op.LabelBegin => $"[{i.A}",
+ Op.LabelEnd => "]", Op.TryBegin => "try(", Op.TryEnd => ")", Op.SetupBegin => "setup(", Op.SetupEnd => ")",
+ _ => i.Op.ToString().ToLowerInvariant(),
+ }));
+
+ [Fact]
+ public void Flattens_IncludesDefsBlocks()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode player\nsetup:\n cheat gold\ndef rest(h):\n key R\nstep a:\n include ch/one.steps\n call rest(8)\ncheckpoint done",
+ ["ch/one.steps"] = "format 1\nclick 1,1\ninclude ../common.steps",
+ ["common.steps"] = "format 1\ntry:\n key Q",
+ });
+ Assert.Equal("", Errors(p));
+ Assert.Equal("setup( cheat ) [a { click { try( key ) } } {L key } ] checkpoint", Ops(p));
+ var tryBegin = p.Code.Single(i => i.Op == Op.TryBegin);
+ Assert.Equal(Op.TryEnd, p.Code[tryBegin.Jump].Op);
+ var q = p.Code.Single(i => i.Op == Op.Verb && i.A == "key" && i.B == "Q");
+ Assert.Equal("common.steps:3", q.At.ToString());
+ Assert.Equal(["main.steps:8", "ch/one.steps:3"], q.Chain.Select(c => c.ToString()));
+ var bind = p.Code.Single(i => i.Op == Op.ScopeBegin && i.LocalVars);
+ Assert.Equal([new KeyValuePair<string, string>("h", "8")], bind.Bindings);
+ }
+
+ [Fact]
+ public void VarsFiles_PathExpandedWithCommandLine_FileDoesNotOverrideIt()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode player\nvars ui/${res}.vars\nclick ${ui.ok}",
+ ["ui/hd.vars"] = "ui.ok = 10,20\nres = sd",
+ }, new() { ["res"] = "hd" });
+ Assert.Equal("", Errors(p));
+ Assert.Equal(("10,20", "hd"), (p.Vars["ui.ok"], p.Vars["res"]));
+ Assert.Contains("res", p.Locked);
+ }
+
+ [Theory]
+ [InlineData("format 1\nclick 1,1", "main.steps:1: top-level file needs 'mode'")]
+ [InlineData("format 1\nmode player\ninclude x.steps", "main.steps:3: include not found: x.steps")]
+ [InlineData("format 1\nmode player\ncall nope()", "main.steps:3: unknown def nope")]
+ [InlineData("format 1\nmode player\ndef d(a):\n key R\ncall d()", "main.steps:5: def d takes 1 arguments, got 0")]
+ [InlineData("format 1\nmode player\ndef d():\n call d()\ncall d()", "main.steps:4: recursive call d")]
+ [InlineData("format 1\nmode player\ndef d():\n key R\ndef d():\n key Q", "main.steps:5: def d already defined")]
+ [InlineData("format 1\nmode player\ncheckpoint a\ncheckpoint a", "main.steps:4: duplicate checkpoint a")]
+ [InlineData("format 1\nmode player\nclick ${nowhere}", "main.steps:3: unresolved variable ${nowhere}")]
+ [InlineData("format 1\nmode player\nkey R\nsetup:\n cheat x", "main.steps:4: setup must be the first block")]
+ [InlineData("format 1\nmode player\ntry:\n checkpoint c", "main.steps:4: checkpoint inside try")]
+ public void Reports(string main, string error) => Assert.Contains(error, Errors(Load(new() { ["main.steps"] = main })));
+
+ [Fact]
+ public void IncludeCycle_NamesThePath() =>
+ Assert.Contains("b.steps:2: include cycle: main.steps → a.steps → b.steps → a.steps", Errors(Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\ninclude a.steps",
+ ["a.steps"] = "format 1\ninclude b.steps",
+ ["b.steps"] = "format 1\ninclude a.steps",
+ })));
+
+ [Fact]
+ public void IncludedFile_ModeOrSetup_Rejected()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\ninclude a.steps",
+ ["a.steps"] = "format 1\nmode player\nsetup:\n cheat x",
+ });
+ Assert.Contains("a.steps:2: mode only in the top-level file", Errors(p));
+ Assert.Contains("a.steps:3: setup only in the top-level file", Errors(p));
+ }
+
+ [Fact]
+ public void LockedVariable_SetIsAnError() =>
+ Assert.Contains("main.steps:3: variable res is set on the command line",
+ Errors(Load(new() { ["main.steps"] = "format 1\nmode debug\nset res = x" }, new() { ["res"] = "hd" })));
+
+ [Fact]
+ public void DefParamsAndEarlierSets_AreKnownVariables() =>
+ Assert.Equal("", Errors(Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\nset n = 3\ndef d(a):\n key ${a}\n wait-frames ${n}\ncall d(R)",
+ })));
+
+ [Fact]
+ public void IncludedFileKeepsItsOwnFormat() =>
+ Assert.Equal(1, Load(new() { ["main.steps"] = "format 1\nmode debug\ninclude a.steps", ["a.steps"] = "format 1\nkey R" })
+ .Code.Single(i => i.Op == Op.Verb).Format);
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs
new file mode 100644
index 0000000..352ff03
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs
@@ -0,0 +1,111 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptParserTests
+{
+ private static ScriptFile Parse(string text) => ScriptParser.Parse("main.steps", text);
+
+ private static string Errors(ScriptFile f) => string.Join(" | ", f.Errors.Select(e => e.ToString()));
+
+ [Fact]
+ public void Header_AndStatements()
+ {
+ var f = Parse("""
+ # comment
+ format 1
+ mode player
+ seed 7
+ vars ui/${res}.vars
+ click 10,20
+ wait dialog-open 2s
+ expect quest(1) >= accepted
+ set x = "a b"
+ include ch/one.steps
+ call rest(8, inn)
+ checkpoint done
+ """);
+ Assert.Equal("", Errors(f));
+ Assert.Equal((1, RunMode.Player, 7UL), (f.Format, f.Mode, f.Seed!.Value));
+ Assert.Equal(["ui/${res}.vars"], f.VarFiles);
+ var v = Assert.IsType<VerbNode>(f.Body[0]);
+ Assert.Equal(("click", "10,20", 6), (v.Verb, v.Args, v.At.Line));
+ var w = Assert.IsType<WaitNode>(f.Body[1]);
+ Assert.Equal(("dialog-open", 120), (w.Condition, w.Timeout));
+ Assert.Equal("quest(1) >= accepted", Assert.IsType<ExpectNode>(f.Body[2]).Condition);
+ var s = Assert.IsType<SetNode>(f.Body[3]);
+ Assert.Equal(("x", "a b"), (s.Name, s.Value));
+ Assert.Equal("ch/one.steps", Assert.IsType<IncludeNode>(f.Body[4]).Path);
+ var c = Assert.IsType<CallNode>(f.Body[5]);
+ Assert.Equal("rest", c.Name);
+ Assert.Equal(["8", "inn"], c.Args);
+ Assert.Equal("done", Assert.IsType<CheckpointNode>(f.Body[6]).Name);
+ }
+
+ [Fact]
+ public void Blocks_NestByIndent()
+ {
+ var f = Parse("""
+ format 1
+ mode debug
+ setup:
+ cheat gold 5
+ step talk-virgil:
+ click 1,2
+ try:
+ key R
+ def rest(hours):
+ key R
+ wait-frames ${hours}
+ key Q
+ """);
+ Assert.Equal("", Errors(f));
+ var setup = Assert.IsType<BlockNode>(f.Body[0]);
+ Assert.Equal((BlockKind.Setup, 1), (setup.Kind, setup.Body.Count));
+ var step = Assert.IsType<BlockNode>(f.Body[1]);
+ Assert.Equal((BlockKind.Step, "talk-virgil", 2), (step.Kind, step.Label, step.Body.Count));
+ Assert.Equal(BlockKind.Try, Assert.IsType<BlockNode>(step.Body[1]).Kind);
+ var def = Assert.IsType<DefNode>(f.Body[2]);
+ Assert.Equal(("rest", 2), (def.Name, def.Body.Count));
+ Assert.Equal(["hours"], def.Params);
+ Assert.Equal("Q", Assert.IsType<VerbNode>(f.Body[3]).Args);
+ }
+
+ [Theory]
+ [InlineData("mode player\nclick 1,1", "main.steps:1: first line must be 'format N'")]
+ [InlineData("format 9\nmode player", "main.steps:1: unknown format 9 (latest 1)")]
+ [InlineData("format 1\nmode sneaky", "main.steps:2: mode must be player or debug")]
+ [InlineData("format 1\nmode debug\nclick 1,1\nseed 3", "main.steps:4: header line after steps")]
+ [InlineData("format 1\nmode debug\nwait dialog-open 0.01s", "main.steps:3: bad timeout '0.01s'")]
+ [InlineData("format 1\nmode debug\nwait 2s", "main.steps:3: wait needs a condition and a timeout")]
+ [InlineData("format 1\nmode debug\nstep a:\n click 1,1", "main.steps:4: indent not a multiple of 2")]
+ [InlineData("format 1\nmode debug\nstep a:\n\tclick 1,1", "main.steps:4: tab in indentation")]
+ [InlineData("format 1\nmode debug\nstep a:\nclick 1,1", "main.steps:3: empty block")]
+ [InlineData("format 1\nmode debug\nloop:\n click 1,1", "main.steps:3: unknown block 'loop:'")]
+ [InlineData("format 1\nmode debug\nclick 1,1\n key R", "main.steps:4: unexpected indent")]
+ [InlineData("format 1\nmode debug\nlog \"abc", "main.steps:3: unterminated quote")]
+ [InlineData("format 1\nmode debug\nset 9x = 1", "main.steps:3: bad set (set name = value)")]
+ public void Errors_NameTheLine(string text, string error) => Assert.Contains(error, Errors(Parse(text)));
+
+ // Review focus 5: CRLF, BOM and trailing spaces parse as LF.
+ [Fact]
+ public void CrlfBomTrailingSpaces_SameAsLf()
+ {
+ var lf = Parse("format 1\nmode debug\nstep a:\n click 1,2\n");
+ var crlf = Parse("\uFEFFformat 1 \r\nmode debug\r\nstep a: \r\n click 1,2 \r\n");
+ Assert.Equal("", Errors(crlf));
+ var a = Assert.IsType<BlockNode>(lf.Body[0]);
+ var b = Assert.IsType<BlockNode>(crlf.Body[0]);
+ Assert.Equal(a.Text, b.Text);
+ Assert.Equal(((VerbNode)a.Body[0]).Text, ((VerbNode)b.Body[0]).Text);
+ Assert.Equal(((VerbNode)a.Body[0]).At, ((VerbNode)b.Body[0]).At);
+ }
+
+ [Fact]
+ public void ReportsEveryError_NotOnlyTheFirst()
+ {
+ var f = Parse("format 1\nmode debug\nwait x 2m\nset = 1\nloop:\n key R");
+ Assert.Equal(3, f.Errors.Count);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs
new file mode 100644
index 0000000..6f3be4e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs
@@ -0,0 +1,54 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunFlagsTests
+{
+ private static ScriptRunFlags Parse(params string[] args)
+ {
+ var f = new ScriptRunFlags();
+ for (var i = 0; i < args.Length; i++)
+ Assert.True(ScriptRunFlags.TryTake(args, ref i, f), args[i]);
+ return f;
+ }
+
+ [Fact]
+ public void ParsesEveryOption()
+ {
+ var f = Parse("--script", "main.steps", "--var", "res=1920x1080", "--var", "who=gil", "--output", "trace",
+ "--no-cache", "--no-snapshots", "--from", "one", "--until", "two", "--step-cap", "500", "--report-dir", "out/runs/a");
+ Assert.True(f.NoSnapshots);
+ Assert.False(f.ToOptions(new RunFlags(), null).Snapshots);
+ Assert.Equal("main.steps", f.Script);
+ Assert.Equal(new Dictionary<string, string> { ["res"] = "1920x1080", ["who"] = "gil" }, f.Vars);
+ Assert.Equal((OutputLevel.Trace, true, "one", "two", 500, "out/runs/a"), (f.Output!.Value, f.NoCache, f.From, f.Until, f.StepCap!.Value, f.ReportDir));
+ }
+
+ [Fact]
+ public void NotOurs_ReturnsFalse_IndexUnchanged()
+ {
+ var i = 0;
+ Assert.False(ScriptRunFlags.TryTake(["--mute"], ref i, new ScriptRunFlags()));
+ Assert.Equal(0, i);
+ }
+
+ [Theory]
+ [InlineData("--var", "novalue")]
+ [InlineData("--output", "loud")]
+ [InlineData("--step-cap", "x")]
+ public void BadValues_Throw(string option, string value)
+ {
+ var i = 0;
+ Assert.Throws<ArgumentException>(() => ScriptRunFlags.TryTake([option, value], ref i, new ScriptRunFlags()));
+ }
+
+ [Fact]
+ public void ToOptions_DeterministicSeedOutput()
+ {
+ var o = Parse("--output", "steps", "--no-cache").ToOptions(new RunFlags(Deterministic: true, Seed: 7), "out/runs/cache");
+ Assert.Equal((true, (ulong?)7UL, OutputLevel.Steps, OutputLevel.Steps, false, "out/runs/cache"), (o.Deterministic, o.Seed, o.Output, o.Floor, o.UseCache, o.CacheDir));
+ Assert.False(Parse("--nondeterministic").ToOptions(new RunFlags(), null).Deterministic);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs
new file mode 100644
index 0000000..9129456
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs
@@ -0,0 +1,85 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunnerStructureTests
+{
+ [Fact]
+ public void Output_DefaultLabels_StepsLevelShowsEveryStep()
+ {
+ var (_, labels) = Run("format 1\nmode player\nstep a:\n key R\n log \"hi\"\nkey Q", new FakeGame());
+ Assert.Equal(["hi", "[main.steps:3 a] done f=2 (+2)", "digest 2 0.033 0000000000000002"], labels);
+ var (_, steps) = Run("format 1\nmode player\noutput steps\nstep a:\n key R\nkey Q", new FakeGame());
+ Assert.Equal(["[main.steps:5 a] key R done f=2 (+2)", "[main.steps:4 a] done f=2 (+2)", "digest 2 0.033 0000000000000002", "[main.steps:6] key Q done f=4 (+2)"], steps);
+ }
+
+ [Fact]
+ public void Output_ScopedToIncludedFile()
+ {
+ var (_, output) = Run("format 1\nmode player\ninclude loud.steps\nkey Q", new FakeGame(),
+ files: new() { ["loud.steps"] = "format 1\noutput steps\nkey R" });
+ Assert.Equal(["[loud.steps:3] key R done f=2 (+2)"], output); // key Q at labels level: silent
+ }
+
+ [Fact]
+ public void Output_FloorCannotBeLowered()
+ {
+ var (_, output) = Run("format 1\nmode player\noutput quiet\nkey R", new FakeGame(), new ScriptRunOptions { Floor = OutputLevel.Steps });
+ Assert.Equal(["[main.steps:4] key R done f=2 (+2)"], output);
+ }
+
+ [Fact]
+ public void DefCall_BindsParams_ParamUnknownOutside()
+ {
+ var g = new FakeGame();
+ var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3);
+ g.ActionTable["cheat"] = a;
+ var (ok, _) = Run("format 1\nmode debug\ndef d(x):\n set local = in\n cheat ${x} ${local}\ncall d(gold)", g);
+ Assert.Equal(RunOutcome.Passed, ok.Outcome);
+ Assert.Equal(["gold", "in"], a.Begun[0]);
+ var (bad, output) = Run("format 1\nmode debug\ndef d(x):\n cheat ${x}\ncall d(gold)\ncheat ${x}", g);
+ Assert.Equal(RunOutcome.Invalid, bad.Outcome);
+ Assert.Contains(output, l => l == "main.steps:6: unresolved variable ${x}");
+ }
+
+ [Fact]
+ public void Try_FaultInsideContinuesAfterTheBlock_NonDeterministic()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["never"] = _ => RunValue.Of(false);
+ var (r, output) = Run("format 1\nmode player\ntry:\n wait never 3\n key A\nkey B", g, new ScriptRunOptions { Deterministic = false });
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains(output, l => l == "try failed at main.steps:4: timeout after 3 frames: never (never=0)");
+ Assert.DoesNotContain(g.Sent, s => s.Event.Key == "A");
+ Assert.Contains(g.Sent, s => s.Event.Key == "B");
+ }
+
+ // Review focus 1: a fault while a button is down releases it.
+ [Fact]
+ public void Fault_ReleasesHeldButtonAndKey()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["never"] = _ => RunValue.Of(false);
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player,
+ _ => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.KeyDown, Key: "Shift")), new PlannedInput(0, new InputEvent(InputKind.Down, 9, 9)),
+ new PlannedInput(5, new InputEvent(InputKind.Up, 9, 9)), new PlannedInput(5, new InputEvent(InputKind.KeyUp, Key: "Shift"))])),
+ polls => StepStatus.Running, 1, 1);
+ g.OnAdvance = f => { if (f == 2) throw new InvalidOperationException("crash"); };
+ var (r, _) = Run("format 1\nmode player\nwalk-to 9,9", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.Up, 9, 9));
+ Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.KeyUp, Key: "Shift"));
+ }
+
+ [Fact]
+ public void Setup_RunsFirstAtFrameZero()
+ {
+ var g = new FakeGame();
+ var order = new List<string>();
+ g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, a => { order.Add($"cheat@{g.Frame}"); return new ActionStart(Status: StepStatus.Done); }, min: 1, max: 3);
+ Run("format 1\nmode player\nsetup:\n cheat stats max\nkey R", g);
+ Assert.Equal(["cheat@0"], order);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs
new file mode 100644
index 0000000..0738f7b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs
@@ -0,0 +1,224 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunnerTests
+{
+ internal static (RunResult Result, List<string> Output) Run(string main, FakeGame game, ScriptRunOptions? options = null,
+ Dictionary<string, string>? files = null, Dictionary<string, string>? vars = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(all), vars ?? []);
+ var output = new List<string>();
+ var runner = new ScriptRunner(program, game, options ?? new ScriptRunOptions(), output.Add);
+ return (runner.Run(cap: 10_000), output);
+ }
+
+ [Fact]
+ public void Click_DownAtStepFrameUpNextFrame()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nclick 100,200\nkey R", g);
+ Assert.Equal((RunOutcome.Passed, 0), (r.Outcome, r.ExitCode));
+ // Hand-derived: frame 0 (game.Frame before first Advance) move+down, frame 1 up; key starts on frame 2: down, frame 3 up.
+ Assert.Equal(
+ [
+ (0L, new InputEvent(InputKind.Move, 100, 200)), (0L, new InputEvent(InputKind.Down, 100, 200)),
+ (1L, new InputEvent(InputKind.Up, 100, 200)),
+ (2L, new InputEvent(InputKind.KeyDown, Key: "R")), (3L, new InputEvent(InputKind.KeyUp, Key: "R")),
+ ], g.Sent);
+ Assert.Equal(4, r.Frames);
+ }
+
+ // A window host ends its batch of game frames when a step completes, so the next step resolves on a fresh draw.
+ [Fact]
+ public void StepsDone_CountsCompletedSteps()
+ {
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nclick 100,200\nkey R" }), new Dictionary<string, string>());
+ var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { });
+ Assert.Equal(0, runner.StepsDone);
+ runner.Run(cap: 10_000);
+ Assert.Equal(2, runner.StepsDone);
+ }
+
+ // A window host skips the poll of a frame drawn later (a batch frame that closed a panel: a click resolved then
+ // would aim on the draw from before): the frame counts, the step is polled on the next one.
+ [Fact]
+ public void AfterFrame_NoPoll_CountsTheFrameOnly()
+ {
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nwait-frames 1" }), new Dictionary<string, string>());
+ var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { });
+ runner.BeforeFrame();
+ runner.AfterFrame(poll: false);
+ Assert.Equal((1, 0), (runner.FrameCount, runner.StepsDone));
+ runner.BeforeFrame();
+ runner.AfterFrame();
+ Assert.Equal((2, 1), (runner.FrameCount, runner.StepsDone));
+ }
+
+ [Fact]
+ public void Locator_AimsTheClick_OffScreenFaults()
+ {
+ var g = new FakeGame();
+ g.Objects["Virgil"] = (640, 360);
+ var (ok, _) = Run("format 1\nmode player\nclick @obj(Virgil)", g);
+ Assert.Equal(RunOutcome.Passed, ok.Outcome);
+ Assert.Contains((0L, new InputEvent(InputKind.Down, 640, 360)), g.Sent);
+ var (bad, output) = Run("format 1\nmode player\nclick @obj(Bates)", new FakeGame());
+ Assert.Equal((RunOutcome.Failed, 1), (bad.Outcome, bad.ExitCode));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 click @obj(Bates): locator @obj(Bates): no object Bates on screen");
+ }
+
+ [Fact]
+ public void Wait_HoldsUntilConditionOrTimesOut()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["ready"] = _ => RunValue.Of(g.Frame >= 5);
+ var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Equal(5L, g.Sent[0].Frame); // condition true after the 5th advance → key starts at frame 5
+
+ var g2 = new FakeGame();
+ g2.ProbeFns["ready"] = _ => RunValue.Of(false);
+ g2.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (t, output) = Run("format 1\nmode player\nwait ready and quest(3) > 1 4", g2);
+ Assert.Equal(RunOutcome.Failed, t.Outcome);
+ Assert.Equal(4, t.Frames);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 wait ready and quest(3) > 1 4: timeout after 4 frames: ready and quest(3) > 1 (ready=0 quest(3)=1)");
+ }
+
+ [Fact]
+ public void Wait_AlreadyTrue_TakesNoFrame()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["ready"] = _ => RunValue.Of(true);
+ var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g);
+ Assert.Equal(0L, g.Sent[0].Frame);
+ Assert.Equal(2, r.Frames);
+ }
+
+ [Fact]
+ public void Expect_FailsWithValues()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (r, output) = Run("format 1\nmode player\nexpect quest(1) >= accepted", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 expect quest(1) >= accepted: expect failed: quest(1) >= accepted (quest(1)=1)");
+ }
+
+ [Fact]
+ public void Action_PlanThenPollUntilDone()
+ {
+ var g = new FakeGame();
+ var talk = new FakeAction(VerbKind.Player, args => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.Down, 5, 6)), new PlannedInput(1, new InputEvent(InputKind.Up, 5, 6))])),
+ polls => polls >= 3 ? StepStatus.Done : StepStatus.Running, 1, 1);
+ g.ActionTable["talk"] = talk;
+ var (r, _) = Run("format 1\nmode player\ntalk @obj(x)\nkey Z", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Equal(["@obj(x)"], talk.Begun.Single());
+ // Down sent f0, Up f1; polls run after advances to 2, 3, 4 (Done on the 3rd) → key starts at frame 4.
+ Assert.Equal(4L, g.Sent.Single(s => s.Event.Kind == InputKind.KeyDown).Frame);
+ }
+
+ [Fact]
+ public void Action_ArgsGetVariablesExpanded_LocatorsLeftToTheGame()
+ {
+ var g = new FakeGame();
+ var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3);
+ g.ActionTable["cheat"] = a;
+ Run("format 1\nmode debug\nset n = 50\ncheat gold ${n}", g);
+ Assert.Equal(["gold", "50"], a.Begun.Single());
+ }
+
+ [Fact]
+ public void Action_FaultStatusFailsTheRun()
+ {
+ var g = new FakeGame();
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), polls => polls == 2 ? StepStatus.Fault("stuck at 3,4") : StepStatus.Running, 1, 1);
+ var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g);
+ Assert.Equal((RunOutcome.Failed, 2L), (r.Outcome, r.Frames));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: stuck at 3,4");
+ }
+
+ // Review focus 2: a hook that never finishes hits the step cap.
+ [Fact]
+ public void StepCap_StopsAHangingAction()
+ {
+ var g = new FakeGame();
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), _ => StepStatus.Running, 1, 1);
+ var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g, new ScriptRunOptions { StepCap = 50 });
+ Assert.Equal((RunOutcome.Failed, 50L), (r.Outcome, r.Frames));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: step exceeded 50 frames");
+ }
+
+ [Fact]
+ public void GameHookException_IsAFailureNotACrash()
+ {
+ var g = new FakeGame();
+ g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => throw new InvalidOperationException("boom"), min: 1, max: 1);
+ var (r, output) = Run("format 1\nmode player\ntalk x", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 talk x: InvalidOperationException: boom");
+ }
+
+ [Fact]
+ public void InvalidScript_Exit2_NoFrames()
+ {
+ var g = new FakeGame();
+ var (r, output) = Run("format 1\nmode player\ndance", g);
+ Assert.Equal((RunOutcome.Invalid, 2, 0L), (r.Outcome, r.ExitCode, r.Frames));
+ Assert.Contains(output, l => l == "main.steps:3: unknown verb dance");
+ Assert.Equal(0, g.Frame);
+ }
+
+ [Fact]
+ public void Rng_CallsTheGameHook_NoFrame()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nrng seed 9\nrng next hit 1", g);
+ Assert.Equal((RunOutcome.Passed, 0L), (r.Outcome, r.Frames));
+ Assert.Equal(["seed 9", "next hit 1"], g.RngImpl!.Calls);
+ }
+
+ [Fact]
+ public void WaitFrames_CountsFrames()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nwait-frames 7\nwait-frames 0\nwait-frames 0.5s", g);
+ Assert.Equal(37, r.Frames);
+ }
+
+ [Theory]
+ [InlineData("dclick 1,2", "Move@0 Down@0 Up@1 Down@2 Up@3")]
+ [InlineData("rclick 1,2", "Move@0 Down(R)@0 Up(R)@1")]
+ [InlineData("drag 0,0 30,60", "Move(0,0)@0 Down@0 Move(10,20)@1 Move(20,40)@2 Move(30,60)@3 Up@4")]
+ [InlineData("hold W 3", "KeyDown(W)@0 KeyUp(W)@3")]
+ [InlineData("chord Ctrl+S", "KeyDown(Ctrl)@0 KeyDown(S)@0 KeyUp(S)@1 KeyUp(Ctrl)@1")]
+ [InlineData("type \"hi there\"", "Text(hi there)@0")]
+ [InlineData("wheel -2 5,5", "Move@0 Wheel(-2)@0")]
+ public void InputPlans_Shapes(string line, string expected)
+ {
+ var g = new FakeGame();
+ Run($"format 1\nmode player\n{line}", g);
+ string Show((long Frame, InputEvent E) s) => s.E.Kind switch
+ {
+ InputKind.Move when line.StartsWith("drag") => $"Move({s.E.X},{s.E.Y})@{s.Frame}",
+ InputKind.Down or InputKind.Up when s.E.Button == MouseButton.Right => $"{s.E.Kind}(R)@{s.Frame}",
+ InputKind.KeyDown or InputKind.KeyUp => $"{s.E.Kind}({s.E.Key})@{s.Frame}",
+ InputKind.Text => $"Text({s.E.Text})@{s.Frame}",
+ InputKind.Wheel => $"Wheel({s.E.Delta})@{s.Frame}",
+ _ => $"{s.E.Kind}@{s.Frame}",
+ };
+ Assert.Equal(expected, string.Join(" ", g.Sent.Select(Show)));
+ }
+
+ [Fact]
+ public void BadPosition_Faults()
+ {
+ var (r, output) = Run("format 1\nmode player\nclick 10;20", new FakeGame());
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 click 10;20: bad position '10;20'");
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs
new file mode 100644
index 0000000..98dcc73
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs
@@ -0,0 +1,63 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptValidatorTests
+{
+ private static FakeGame Game()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["quest"] = _ => RunValue.Of(0);
+ g.ProbeFns["dialog-open"] = _ => RunValue.Of(false);
+ g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), min: 1, max: 1);
+ g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 20);
+ return g;
+ }
+
+ private static string Validate(string main, bool deterministic = true, FakeGame? game = null) =>
+ string.Join(" | ", ScriptValidator.Validate(
+ ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = main }), new Dictionary<string, string>()),
+ game ?? Game(), deterministic).Select(e => e.ToString()));
+
+ [Fact]
+ public void ValidPlayerScript_NoErrors() =>
+ Assert.Equal("", Validate("format 1\nmode player\nsetup:\n cheat gold 5\ntalk @obj(Virgil)\nwait dialog-open 2s\nexpect quest(1) >= accepted\nclick 1,2\nkey R\nlog \"hi\""));
+
+ [Theory]
+ [InlineData("format 1\nmode player\ndance", "main.steps:3: unknown verb dance")]
+ [InlineData("format 1\nmode player\nwalk-to 3,4", "main.steps:3: verb walk-to not supported by game fake")]
+ [InlineData("format 1\nmode player\nclick", "main.steps:3: verb click takes 1..1 arguments, got 0")]
+ [InlineData("format 1\nmode player\ncheat gold 5", "main.steps:3: debug verb cheat outside setup in player mode")]
+ [InlineData("format 1\nmode player\nwait nope 10", "main.steps:3: unknown probe or constant nope")]
+ [InlineData("format 1\nmode player\nexpect quest(1) >=", "main.steps:3: condition ends early")]
+ [InlineData("format 1\nmode player\ntalk @npc(Virgil)", "main.steps:3: unknown locator @npc")]
+ [InlineData("format 1\nmode player\ntry:\n key R", "main.steps:3: try needs a non-deterministic run")]
+ [InlineData("format 1\nmode player\noutput loud", "main.steps:3: output level must be quiet|checkpoints|labels|steps|trace")]
+ public void Errors(string main, string error) => Assert.Contains(error, Validate(main));
+
+ [Fact]
+ public void DebugMode_DebugVerbAnywhere() => Assert.Equal("", Validate("format 1\nmode debug\ncheat gold 5\nkey R"));
+
+ [Fact]
+ public void Try_AllowedNonDeterministic() => Assert.Equal("", Validate("format 1\nmode player\ntry:\n key R", deterministic: false));
+
+ [Fact]
+ public void Rng_NeedsTheGameHook()
+ {
+ var g = Game();
+ g.RngImpl = null;
+ Assert.Contains("main.steps:3: verb rng not supported by game fake", Validate("format 1\nmode player\nrng seed 4", game: g));
+ }
+
+ [Fact]
+ public void GameVerb_ShadowingALibVerbWithAnotherKind_Rejected()
+ {
+ var g = Game();
+ g.ActionTable["click"] = new FakeAction(VerbKind.Debug, _ => new ActionStart());
+ Assert.Contains("game verb click shadows a lib verb", Validate("format 1\nmode debug\nkey R", game: g));
+ }
+
+ [Fact]
+ public void LoaderErrorsComeThrough() => Assert.Contains("main.steps:3: unknown def x", Validate("format 1\nmode debug\ncall x()"));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs
new file mode 100644
index 0000000..0f787dc
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs
@@ -0,0 +1,58 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class StepLexerTests
+{
+ [Theory]
+ [InlineData("click 10,20 # aim", "click 10,20 ")]
+ [InlineData("log \"a # b\" # c", "log \"a # b\" ")]
+ [InlineData("# only", "")]
+ [InlineData("type \"say \\\"#1\\\"\"", "type \"say \\\"#1\\\"\"")]
+ public void StripComment_OutsideQuotesOnly(string line, string expected) =>
+ Assert.Equal(expected, StepLexer.StripComment(line));
+
+ [Fact]
+ public void Tokens_SplitOnSpaceQuotedStaysWhole()
+ {
+ var t = StepLexer.Tokens("use @inv(Lockpicks) on \"Big Door\"");
+ Assert.Equal(["use", "@inv(Lockpicks)", "on", "Big Door"], t.Select(x => x.Text));
+ Assert.Equal([false, false, false, true], t.Select(x => x.Quoted));
+ }
+
+ [Fact]
+ public void Tokens_EscapesInQuotes()
+ {
+ var t = StepLexer.Tokens("type \"a \\\"b\\\" \\\\ c\"");
+ Assert.Equal("a \"b\" \\ c", t[1].Text);
+ }
+
+ [Fact]
+ public void Tokens_ParenthesesKeepSpacesInOneToken()
+ {
+ // locator args may contain spaces: @npc(Gilbert Bates)
+ var t = StepLexer.Tokens("talk @npc(Gilbert Bates)");
+ Assert.Equal(["talk", "@npc(Gilbert Bates)"], t.Select(x => x.Text));
+ }
+
+ [Fact]
+ public void Tokens_UnterminatedQuote_Throws() =>
+ Assert.Equal("unterminated quote", Assert.Throws<FormatException>(() => StepLexer.Tokens("log \"abc")).Message);
+
+ [Theory]
+ [InlineData("120", 120)]
+ [InlineData("2s", 120)]
+ [InlineData("0.5s", 30)]
+ [InlineData("0", 0)]
+ [InlineData("1.25s", 75)]
+ public void Durations_FramesAndSeconds(string text, int frames) => Assert.Equal(frames, Durations.Frames(text));
+
+ [Theory]
+ [InlineData("0.01s")] // 0.6 frames: not whole
+ [InlineData("-5")]
+ [InlineData("2m")]
+ [InlineData("")]
+ [InlineData("1e3")]
+ public void Durations_Invalid_Null(string text) => Assert.Null(Durations.Frames(text));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs
new file mode 100644
index 0000000..c6d3286
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs
@@ -0,0 +1,84 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class VarsTests
+{
+ private static readonly Dictionary<string, string> Values = new()
+ {
+ ["res"] = "1920x1080", ["hours"] = "8", ["ui.rest.8"] = "400,300", ["a"] = "b", ["b"] = "deep",
+ };
+
+ private static string Expand(string text) => VarExpander.Expand(text, n => Values.GetValueOrDefault(n),
+ p => p == "global(gold)" ? RunValue.Of(1250) : throw new ScriptFault($"no probe {p}"));
+
+ [Theory]
+ [InlineData("ui/${res}.vars", "ui/1920x1080.vars")]
+ [InlineData("click ${ui.rest.${hours}}", "click 400,300")]
+ [InlineData("${${a}}", "deep")]
+ [InlineData("gold ${probe:global(gold)}", "gold 1250")]
+ [InlineData("cost $5 and $", "cost $5 and $")] // review focus 4: lone $ is literal
+ [InlineData("no vars", "no vars")]
+ public void Expand_Values(string text, string expected) => Assert.Equal(expected, Expand(text));
+
+ [Theory]
+ [InlineData("${missing}", "unresolved variable ${missing}")]
+ [InlineData("x ${a", "unclosed ${ in 'x ${a'")]
+ [InlineData("${}", "empty ${}")]
+ public void Expand_Errors_AreFaults(string text, string message) =>
+ Assert.Equal(message, Assert.Throws<ScriptFault>(() => Expand(text)).Message);
+
+ [Fact]
+ public void Names_InnermostPlainOnly() =>
+ Assert.Equal(["hours", "res"], VarExpander.Names("${ui.${hours}} ${res} ${probe:quest(1)}").Order());
+
+ [Fact]
+ public void VarsFile_ParsesAndReportsBadLines()
+ {
+ var (values, errors) = VarsFile.Parse("ui/hd.vars", "# hd\nui.answer1 = 412,520\n\nui.rest.8=400,300\nbroken line\n");
+ Assert.Equal("412,520", values["ui.answer1"]);
+ Assert.Equal("400,300", values["ui.rest.8"]);
+ Assert.Equal(["ui/hd.vars:5: bad vars line (name = value)"], errors.Select(e => e.ToString()));
+ }
+
+ [Fact]
+ public void Scopes_SetGoesToLocalOrRoot()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Set("g", "1");
+ s.Push(local: false, OutputLevel.Labels, []); // include
+ s.Set("inc", "2"); // global: root
+ s.Push(local: true, OutputLevel.Labels, [new("p", "x")]); // def call
+ s.Set("loc", "3");
+ Assert.Equal(("1", "2", "3", "x"), (s.Get("g"), s.Get("inc"), s.Get("loc"), s.Get("p")));
+ s.Pop();
+ Assert.Null(s.Get("loc"));
+ Assert.Null(s.Get("p"));
+ s.Pop();
+ Assert.Equal("2", s.Get("inc"));
+ }
+
+ [Fact]
+ public void Scopes_OutputLevelRestoredOnPop()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Push(local: false, s.Level, []);
+ s.Level = OutputLevel.Trace;
+ s.Pop();
+ Assert.Equal(OutputLevel.Labels, s.Level);
+ }
+
+ [Fact]
+ public void Scopes_SnapshotRestore_RoundTrips()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Set("g", "1");
+ s.Push(local: true, OutputLevel.Steps, [new("p", "x")]);
+ var copy = new VarScopes(OutputLevel.Quiet);
+ copy.Restore(s.Snapshot());
+ Assert.Equal(("1", "x", OutputLevel.Steps), (copy.Get("g"), copy.Get("p"), copy.Level));
+ s.Set("p", "changed");
+ Assert.Equal("x", copy.Get("p")); // deep copy
+ }
+}
diff --git a/src/Godosa.Core.Tests/SettingsFileTests.cs b/src/Godosa.Core.Tests/SettingsFileTests.cs
new file mode 100644
index 0000000..7308c2b
--- /dev/null
+++ b/src/Godosa.Core.Tests/SettingsFileTests.cs
@@ -0,0 +1,90 @@
+using Xunit;
+
+namespace Godosa.Core.Tests;
+
+public sealed class SettingsFileTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(Path.GetTempPath(), "godosa-settings-" + Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);
+ }
+
+ private readonly List<string> _reports = new();
+
+ private SettingsFile<string> Store(string path, Func<string, string>? parse = null) =>
+ new(path, parse ?? (text => "parsed:" + text), value => value, () => "defaults", _reports.Add);
+
+ [Fact]
+ public void Missing_GivesDefaults()
+ {
+ var file = Store(Path.Combine(_dir, "settings.json"), _ => throw new InvalidOperationException("parsed"));
+ Assert.Equal("defaults", file.Load());
+ Assert.Empty(_reports); // a first run is not a problem
+ }
+
+ [Fact]
+ public void Save_CreatesFolder_ThenLoadParsesWhatWasWritten_NoTempLeft()
+ {
+ string path = Path.Combine(_dir, "sub", "settings.json");
+ var file = Store(path);
+ file.Save("{\"a\":1}");
+ Assert.Equal("{\"a\":1}", System.IO.File.ReadAllText(path));
+ Assert.Equal("parsed:{\"a\":1}", file.Load());
+ Assert.False(System.IO.File.Exists(path + ".tmp"));
+ }
+
+ [Fact]
+ public void ParseFailure_FallsBackToDefaults()
+ {
+ string path = Path.Combine(_dir, "settings.json");
+ Directory.CreateDirectory(_dir);
+ System.IO.File.WriteAllText(path, "\u0000junk");
+ var file = Store(path, _ => throw new FormatException("bad"));
+ Assert.Equal("defaults", file.Load());
+ Assert.Contains("load failed", Assert.Single(_reports));
+ }
+
+ [Fact]
+ public void SaveFailure_DoesNotThrow()
+ {
+ Directory.CreateDirectory(Path.Combine(_dir, "settings.json")); // a folder where the file should go
+ Store(Path.Combine(_dir, "settings.json")).Save("x");
+ Assert.Contains("save failed", Assert.Single(_reports));
+ Assert.False(System.IO.File.Exists(Path.Combine(_dir, "settings.json.tmp")), "temp file cleaned up");
+ }
+
+ [Fact]
+ public void BareRelativePath_SavesInTheWorkingFolder()
+ {
+ string name = "godosa-settings-" + Guid.NewGuid().ToString("N") + ".json";
+ try
+ {
+ Store(name).Save("x");
+ Assert.Empty(_reports);
+ Assert.Equal("x", System.IO.File.ReadAllText(Path.GetFullPath(name)));
+ }
+ finally
+ {
+ System.IO.File.Delete(Path.GetFullPath(name));
+ }
+ }
+
+ [Fact]
+ public void DefaultPath_UsesOverrideWhenSet_ElseAppDataFolder()
+ {
+ string variable = "GODOSA_TEST_SETTINGS_" + Guid.NewGuid().ToString("N");
+ string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ Assert.Equal(Path.Combine(appData, "some-game", "settings.json"), SettingsFile<string>.DefaultPath(variable, "some-game"));
+ Environment.SetEnvironmentVariable(variable, "/tmp/x.json");
+ try
+ {
+ Assert.Equal("/tmp/x.json", SettingsFile<string>.DefaultPath(variable, "some-game"));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, null);
+ }
+ }
+}