138 lines
6.7 KiB
C#
138 lines
6.7 KiB
C#
|
|
using System;
|
||
|
|
using System.Collections.Generic;
|
||
|
|
using System.IO;
|
||
|
|
using System.Linq;
|
||
|
|
using BadDay.Core;
|
||
|
|
|
||
|
|
namespace BadDay.Tests;
|
||
|
|
|
||
|
|
public static class Program
|
||
|
|
{
|
||
|
|
private static readonly PainBoxParameters Default = new();
|
||
|
|
|
||
|
|
private static readonly (string Name, Action Body)[] Tests =
|
||
|
|
{
|
||
|
|
("same seed and parameters render byte-identical timelines", () =>
|
||
|
|
{
|
||
|
|
var a = PainBoxSimulation.Run(Default, 42);
|
||
|
|
var b = PainBoxSimulation.Run(Default, 42);
|
||
|
|
Check(a.Render() == b.Render(), "rendered timelines differ");
|
||
|
|
Check(a.TimeToBreakMs == b.TimeToBreakMs && a.Flinches == b.Flinches, "results differ");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("the same seed gives the same result no matter what ran before it", () =>
|
||
|
|
{
|
||
|
|
var alone = PainBoxSimulation.Run(Default, 7).Render();
|
||
|
|
for (ulong s = 100; s < 105; s++) PainBoxSimulation.Run(Default, s);
|
||
|
|
Check(PainBoxSimulation.Run(Default, 7).Render() == alone, "state leaked between runs");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("different seeds give different characters: break times spread", () =>
|
||
|
|
{
|
||
|
|
var sweep = SweepSummary.Run(Default, 1, 30);
|
||
|
|
var distinct = sweep.Results.Select(r => r.TimeToBreakMs).Distinct().Count();
|
||
|
|
Check(distinct > 10, $"only {distinct} distinct break times in 30 seeds");
|
||
|
|
Check(sweep.MinBreakMs < sweep.MaxBreakMs, "no spread");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("golden: default parameters, seed 1 breaks at 14730 ms and the hand is out 100 ms later", () =>
|
||
|
|
{
|
||
|
|
// Pins the placeholder model; a deliberate retune changes this number and this line together.
|
||
|
|
var r = PainBoxSimulation.Run(Default, 1);
|
||
|
|
Check(r.Outcome == Outcome.WillBroke, $"outcome {r.Outcome}");
|
||
|
|
Check(r.TimeToBreakMs == 14730, $"break at {r.TimeToBreakMs}");
|
||
|
|
Check(r.EndMs == 14830, $"end at {r.EndMs}");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("break time is the WillBroke entry and the hand leaves after the withdrawal latency", () =>
|
||
|
|
{
|
||
|
|
var r = PainBoxSimulation.Run(Default, 3);
|
||
|
|
var broke = r.Timeline.Single(e => e.Kind == EntryKind.WillBroke);
|
||
|
|
var out_ = r.Timeline.Single(e => e.Kind == EntryKind.Withdrew);
|
||
|
|
Check(broke.TimeMs == r.TimeToBreakMs, "TimeToBreakMs disagrees with the timeline");
|
||
|
|
Check(broke.Resolve == 0.0 && broke.Goal == Goal.Withdraw, "break entry should show resolve 0, goal Withdraw");
|
||
|
|
Check(out_.TimeMs - broke.TimeMs == 100, $"latency {out_.TimeMs - broke.TimeMs} ms");
|
||
|
|
Check(r.Timeline.Where(e => e.TimeMs < broke.TimeMs).All(e => e.Resolve > 0.0 && e.Goal == Goal.HoldHand),
|
||
|
|
"resolve hit zero or goal changed before the recorded break");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("a stronger box breaks the will sooner", () =>
|
||
|
|
{
|
||
|
|
long Break(double intensity) =>
|
||
|
|
PainBoxSimulation.Run(Default with { BoxIntensity = intensity }, 5).TimeToBreakMs ?? long.MaxValue;
|
||
|
|
Check(Break(1.0) < Break(0.8) && Break(0.8) < Break(0.6), "break times not ordered by intensity");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("a stronger objective pull holds the will longer", () =>
|
||
|
|
{
|
||
|
|
long Break(double pull) =>
|
||
|
|
PainBoxSimulation.Run(Default with { ObjectivePull = pull }, 5).TimeToBreakMs ?? long.MaxValue;
|
||
|
|
Check(Break(0.0) < Break(0.5) && Break(0.5) < Break(0.8), "break times not ordered by pull");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("disabling the box before the break ends the run with the will intact", () =>
|
||
|
|
{
|
||
|
|
var r = PainBoxSimulation.Run(Default with { BoxDisableAtSeconds = 5.0 }, 1);
|
||
|
|
Check(r.Outcome == Outcome.BoxDisabled, $"outcome {r.Outcome}");
|
||
|
|
Check(r.TimeToBreakMs is null && r.EndMs == 5000, $"break {r.TimeToBreakMs}, end {r.EndMs}");
|
||
|
|
Check(r.Timeline.Last().Resolve > 0.0, "resolve should be above zero");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("disabling the box after the break changes nothing", () =>
|
||
|
|
{
|
||
|
|
var plain = PainBoxSimulation.Run(Default, 1);
|
||
|
|
var late = PainBoxSimulation.Run(Default with { BoxDisableAtSeconds = 60.0 }, 1);
|
||
|
|
Check(plain.Render() == late.Render(), "a box disabled after the break altered the run");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("no stimulus: nothing flinches and the run endures", () =>
|
||
|
|
{
|
||
|
|
var r = PainBoxSimulation.Run(Default with { BoxIntensity = 0.0, MaxSeconds = 10.0 }, 1);
|
||
|
|
Check(r.Outcome == Outcome.Endured && r.Flinches == 0 && r.TimeToBreakMs is null, r.Summary());
|
||
|
|
Check(r.Timeline.All(e => e.Pain == 0.0 && e.Strain == 0.0 && e.Resolve == 1.0), "state moved without a stimulus");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("holding a reflex down costs strain before resolve gives out", () =>
|
||
|
|
{
|
||
|
|
var r = PainBoxSimulation.Run(Default, 1);
|
||
|
|
Check(r.Timeline.Any(e => e.Goal == Goal.HoldHand && e.Strain > 0.5), "no visible strain while holding");
|
||
|
|
Check(r.Flinches > 0, "no reflex leaked through before the break");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("sweep summary adds up and orders min <= median <= max", () =>
|
||
|
|
{
|
||
|
|
var s = SweepSummary.Run(Default, 1, 25);
|
||
|
|
Check(s.Broke + s.BoxDisabled + s.Endured == s.Runs, "outcome counts do not sum");
|
||
|
|
Check(s.MinBreakMs <= s.MedianBreakMs && s.MedianBreakMs <= s.MaxBreakMs, "order");
|
||
|
|
Check(s.Results[0].Seed == 1 && s.Results[24].Seed == 25, "seeds not consecutive");
|
||
|
|
}),
|
||
|
|
|
||
|
|
("CLI run is deterministic and sweep reports a break-time line", () =>
|
||
|
|
{
|
||
|
|
string Run(params string[] a) { var w = new StringWriter(); BadDay.Cli.Program.Execute(a, w); return w.ToString(); }
|
||
|
|
Check(Run("run", "--seed", "9") == Run("run", "--seed", "9"), "CLI output differs between runs");
|
||
|
|
Check(Run("run", "--seed", "1").Contains("will broke at 14730 ms"), "CLI summary missing break time");
|
||
|
|
Check(Run("sweep", "--seed", "1", "--count", "5").Contains("time to break min/median/max ms:"), "no sweep summary");
|
||
|
|
bool threw = false;
|
||
|
|
try { Run("run"); } catch (ArgumentException) { threw = true; }
|
||
|
|
Check(threw, "missing --seed should be rejected");
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
|
||
|
|
public static int Main()
|
||
|
|
{
|
||
|
|
int failed = 0;
|
||
|
|
foreach (var (name, body) in Tests)
|
||
|
|
{
|
||
|
|
try { body(); Console.WriteLine($"PASS {name}"); }
|
||
|
|
catch (Exception e) { failed++; Console.WriteLine($"FAIL {name}: {e.Message}"); }
|
||
|
|
}
|
||
|
|
Console.WriteLine($"{Tests.Length - failed}/{Tests.Length} passed");
|
||
|
|
return failed == 0 ? 0 : 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void Check(bool ok, string message)
|
||
|
|
{
|
||
|
|
if (!ok) throw new InvalidOperationException(message);
|
||
|
|
}
|
||
|
|
}
|