diff options
Diffstat (limited to 'src/Godosa.Core.Tests')
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("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"); + + 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); + } + } +} |
