독립 검증 도구 추가와 앱 상태 책임 분리

This commit is contained in:
2026-09-06 18:09:11 +09:00
parent 0f0431de5a
commit 002a922e1b
62 changed files with 3454 additions and 2008 deletions
@@ -0,0 +1,88 @@
using System.Text.Json;
using ProjectQuint.Probes;
var json = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
try
{
var action = args.Length == 0 ? "list" : args[0];
var options = new Dictionary<string, int>(StringComparer.Ordinal);
string? output = null;
for (var index = 1; index < args.Length; index += 2)
{
if (index + 1 >= args.Length)
throw new ArgumentException("Every option requires a value.");
if (args[index] == "--output" && output == null)
output = args[index + 1];
else if (args[index] is "--notes" or "--seed" or "--fps" or "--iterations" &&
int.TryParse(args[index + 1], out var value) && options.TryAdd(args[index], value))
continue;
else
throw new ArgumentException("Unknown, duplicated or invalid option.");
}
var notes = options.GetValueOrDefault("--notes", 10_000);
var seed = options.GetValueOrDefault("--seed", 42);
var fps = options.GetValueOrDefault("--fps", 240);
var iterations = options.GetValueOrDefault("--iterations", 20);
if (seed < 0)
throw new ArgumentException("Seed must be nonnegative.");
object report;
switch (action)
{
case "list":
if (options.Count != 0 || output != null)
throw new ArgumentException("The list command accepts no options.");
report = new
{
schemaVersion = 1,
component = "headless-core",
commands = new[] { "list", "replay", "benchmark", "test (wrapper)" },
input = "seeded synthetic four-lane notes; no music or external library",
options = new[] { "--notes", "--seed", "--fps", "--iterations (benchmark)", "--output" },
};
break;
case "replay":
if (options.ContainsKey("--iterations"))
throw new ArgumentException("Iterations applies only to benchmarks.");
var workload = SyntheticWorkload.Create(notes, (uint)seed);
report = new
{
schemaVersion = 1, complete = true, notes, seed, frameRate = fps,
eventCount = workload.Events.Length,
result = ReplayRunner.Run(workload, fps),
};
break;
case "benchmark":
report = CoreBenchmark.Run(notes, (uint)seed, fps, iterations, checkpoint =>
{
Console.Error.WriteLine($"benchmark {checkpoint.Samples.Length}/{iterations}");
if (output != null)
Save(output, checkpoint);
});
break;
default:
throw new ArgumentException("Unknown command. Use list, replay, benchmark or test.");
}
if (output != null)
Save(output, report);
Console.WriteLine(JsonSerializer.Serialize(report, json));
return 0;
}
catch (Exception error)
{
// Paths in I/O exceptions can identify the local machine; keep structured failures generic.
Console.Error.WriteLine(JsonSerializer.Serialize(new
{
error = error.GetType().Name,
detail = error is ArgumentException ? error.Message : "The probe failed; no complete result was produced.",
}));
return 1;
}
void Save(string path, object report)
{
var destination = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
var temporary = destination + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(report, json) + "\n");
File.Move(temporary, destination, overwrite: true);
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ProjectQuint.Probes/ProjectQuint.Probes.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- Compile the shipping sources, never a separate implementation of the rules. -->
<Compile Include="../../Assets/ProjectQuint/Runtime/Core/**/*.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,42 @@
using System.Runtime.InteropServices;
namespace ProjectQuint.Probes;
public sealed record CoreBenchmarkReport(int SchemaVersion, bool Complete, DateTimeOffset MeasuredAtUtc,
string Runtime, string OperatingSystem, string Architecture, int ProcessorCount,
int Notes, int Events, uint Seed, int FrameRate, int WarmupIterations, int RequestedIterations,
ReplayObservation[] Samples);
public static class CoreBenchmark
{
public static CoreBenchmarkReport Run(int notes, uint seed, int frameRate, int iterations,
Action<CoreBenchmarkReport> checkpoint)
{
if (iterations < 1 || iterations > 100)
throw new ArgumentOutOfRangeException(nameof(iterations), "Use 1 to 100 iterations.");
var workload = SyntheticWorkload.Create(notes, seed);
const int warmups = 3;
JudgementSnapshot expected = default;
for (var index = 0; index < warmups; index++)
expected = ReplayRunner.Run(workload, frameRate).Judgement;
var samples = new List<ReplayObservation>(iterations);
var measuredAt = DateTimeOffset.UtcNow;
for (var index = 0; index < iterations; index++)
{
var sample = ReplayRunner.Run(workload, frameRate);
if (sample.Judgement != expected || !sample.Judgement.Finished)
throw new InvalidOperationException("The benchmark replay diverged from its warmup result.");
samples.Add(sample);
checkpoint(Report(false));
}
return Report(true);
CoreBenchmarkReport Report(bool complete) => new(1, complete, measuredAt,
RuntimeInformation.FrameworkDescription,
System.OperatingSystem.IsMacOS() ? "macOS" :
System.OperatingSystem.IsLinux() ? "Linux" :
System.OperatingSystem.IsWindows() ? "Windows" : "Other",
RuntimeInformation.ProcessArchitecture.ToString(), Environment.ProcessorCount,
notes, workload.Events.Length, seed, frameRate, warmups, iterations, samples.ToArray());
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ProjectQuint.Core/ProjectQuint.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
using System.Diagnostics;
using ProjectQuint.Core.Judgement;
namespace ProjectQuint.Probes;
public readonly record struct JudgementSnapshot(int Score, int Combo, int MaxCombo,
int MissCount, int KoolCount, int CoolCount, int GoodCount, int JudgedUnits, bool Finished);
public sealed record ReplayObservation(JudgementSnapshot Judgement, double SetupMilliseconds,
double ReplayMilliseconds, long SetupAllocatedBytes, long ReplayAllocatedBytes);
public static class ReplayRunner
{
public static ReplayObservation Run(SyntheticWorkload workload, int frameRate)
{
if (frameRate < 1 || frameRate > 10_000)
throw new ArgumentOutOfRangeException(nameof(frameRate), "Use a frame rate from 1 to 10000.");
var allocationStart = GC.GetAllocatedBytesForCurrentThread();
var setupStart = Stopwatch.GetTimestamp();
var engine = new RhythmJudgementEngine(workload.Notes, laneCount: 4);
var setupEnd = Stopwatch.GetTimestamp();
var replayAllocationStart = GC.GetAllocatedBytesForCurrentThread();
long frame = 0;
foreach (var input in workload.Events)
{
// Merge two timestamped streams; no sleeps or real-time waiting affect this trace.
while ((double)frame / frameRate <= input.Time)
engine.Update((double)frame++ / frameRate);
if (input.Pressed)
engine.Press(input.Lane, input.Time);
else
engine.Release(input.Lane, input.Time);
}
engine.Update(workload.Notes[^1].EndTime + 1);
var replayEnd = Stopwatch.GetTimestamp();
var allocationEnd = GC.GetAllocatedBytesForCurrentThread();
return new ReplayObservation(new JudgementSnapshot(engine.Score, engine.Combo, engine.MaxCombo,
engine.MissCount, engine.KoolCount, engine.CoolCount, engine.GoodCount,
engine.JudgedNoteCount, engine.IsFinished),
Milliseconds(setupStart, setupEnd), Milliseconds(setupEnd, replayEnd),
replayAllocationStart - allocationStart, allocationEnd - replayAllocationStart);
}
private static double Milliseconds(long start, long end) =>
(end - start) * 1000.0 / Stopwatch.Frequency;
}
@@ -0,0 +1,42 @@
using ProjectQuint.Core.Charts;
namespace ProjectQuint.Probes;
public readonly record struct ReplayInput(double Time, int Lane, bool Pressed);
public sealed record SyntheticWorkload(PlayableNote[] Notes, ReplayInput[] Events, uint Seed)
{
public static SyntheticWorkload Create(int noteCount, uint seed)
{
if (noteCount < 1 || noteCount > 100_000)
throw new ArgumentOutOfRangeException(nameof(noteCount), "Use 1 to 100000 notes.");
var notes = new PlayableNote[noteCount];
var events = new List<ReplayInput>(noteCount * 2);
var state = seed;
for (var index = 0; index < noteCount; index++)
{
// Explicit integer PRNG makes the workload independent of System.Random versions.
state = unchecked(state * 1664525u + 1013904223u);
var time = 1.0 + index * 0.125;
var longNote = index % 11 == 0;
var note = new PlayableNote
{
Id = index + 1,
Lane = index % 4,
StartTime = time,
EndTime = time + (longNote ? 0.375 : 0),
};
notes[index] = note;
// Deliberate misses and early releases exercise failure as well as perfect play.
if (index % 17 == 0)
continue;
var pressTime = time + ((int)(state % 7) - 3) * 0.01;
events.Add(new ReplayInput(pressTime, note.Lane, true));
events.Add(new ReplayInput(longNote
? note.EndTime - (index % 23 == 0 ? 0.2 : 0)
: pressTime + 0.02, note.Lane, false));
}
return new SyntheticWorkload(notes, events.OrderBy(e => e.Time)
.ThenBy(e => e.Pressed).ThenBy(e => e.Lane).ToArray(), seed);
}
}
@@ -0,0 +1,31 @@
using NUnit.Framework;
using ProjectQuint.Probes;
namespace ProjectQuint.Tests
{
public sealed class CoreBenchmarkTests
{
[Test]
public void Benchmark_CheckpointsAreIncompleteUntilEverySampleFinishes()
{
var checkpoints = 0;
var report = CoreBenchmark.Run(100, 42, 60, 3, checkpoint =>
{
++checkpoints;
Assert.That(checkpoint.Complete, Is.False);
Assert.That(checkpoint.Samples.Length, Is.EqualTo(checkpoints));
Assert.That(checkpoint.Samples[0].Judgement.Finished, Is.True);
});
Assert.That(report.Complete, Is.True);
Assert.That(checkpoints, Is.EqualTo(3));
Assert.That(report.Samples.Length, Is.EqualTo(3));
Assert.That(report.WarmupIterations, Is.EqualTo(3));
}
[TestCase(0)]
[TestCase(101)]
public void Benchmark_RejectsInvalidIterationCount(int iterations) =>
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
CoreBenchmark.Run(100, 42, 60, iterations, _ => { }));
}
}
@@ -0,0 +1,44 @@
using System.Linq;
using NUnit.Framework;
using ProjectQuint.Probes;
namespace ProjectQuint.Tests
{
public sealed class CoreReplayTests
{
[Test]
public void SyntheticWorkload_IsReproducibleWithoutMedia()
{
var first = SyntheticWorkload.Create(500, 42);
var second = SyntheticWorkload.Create(500, 42);
Assert.That(first.Events, Is.EqualTo(second.Events));
Assert.That(first.Notes.Select(n => (n.Id, n.Lane, n.StartTime, n.EndTime)),
Is.EqualTo(second.Notes.Select(n => (n.Id, n.Lane, n.StartTime, n.EndTime))));
}
[TestCase(30)]
[TestCase(60)]
[TestCase(144)]
[TestCase(240)]
public void Replay_KeepsTheSameJudgementsAcrossFrameSchedules(int frameRate)
{
var workload = SyntheticWorkload.Create(1000, 42);
var expected = ReplayRunner.Run(workload, 1000).Judgement;
var actual = ReplayRunner.Run(workload, frameRate).Judgement;
Assert.That(actual, Is.EqualTo(expected));
Assert.That(actual.Finished, Is.True);
Assert.That(actual.MissCount, Is.GreaterThan(0));
Assert.That(actual.JudgedUnits, Is.GreaterThan(workload.Notes.Length));
}
[TestCase(0)]
[TestCase(-1)]
public void InvalidWorkloadSize_IsRejected(int notes) =>
Assert.Throws<System.ArgumentOutOfRangeException>(() => SyntheticWorkload.Create(notes, 42));
[Test]
public void InvalidFrameRate_IsRejected() =>
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
ReplayRunner.Run(SyntheticWorkload.Create(10, 42), 0));
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ProjectQuint.Core/ProjectQuint.Core.csproj" />
<ProjectReference Include="../ProjectQuint.Probes/ProjectQuint.Probes.csproj" />
<!-- NUnit 3 matches the assertions supported by the Unity test runner. -->
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<Compile Include="../../Assets/ProjectQuint/Tests/Core/**/*.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs" />
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/HomeNavigationTests.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Updates/*.cs" />
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileSessionController.cs" />
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs" />
<Compile Include="CoreReplayTests.cs" />
<Compile Include="CoreBenchmarkTests.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,128 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.9.0, )",
"resolved": "18.9.0",
"contentHash": "xIzVXa/VpKXqDWzyC/5Hw8JbFY5u9k3Jc53CpcjBiOXvkQGio484LD9FNwOiGUSMDcYL3Rcw9sNgGi5WrswlPQ==",
"dependencies": {
"Microsoft.CodeCoverage": "18.9.0",
"Microsoft.TestPlatform.TestHost": "18.9.0"
}
},
"NUnit": {
"type": "Direct",
"requested": "[3.14.0, )",
"resolved": "3.14.0",
"contentHash": "R7iPwD7kbOaP3o2zldWJbWeMQAvDKD0uld27QvA3PAALl1unl7x0v2J7eGiJOYjimV/BuGT4VJmr45RjS7z4LA==",
"dependencies": {
"NETStandard.Library": "2.0.0"
}
},
"NUnit3TestAdapter": {
"type": "Direct",
"requested": "[6.3.0, )",
"resolved": "6.3.0",
"contentHash": "8ZD+0dIfDHsL4w4Gl/LPxWH0O4ky3hFXqTZjY+ry0ThI3cMVErr9Tjxal5O5wJxGXh50a9qkDEAWVca8KQYCEw==",
"dependencies": {
"Microsoft.Extensions.DependencyModel": "8.0.2",
"Microsoft.Testing.Extensions.VSTestBridge": "2.3.3",
"Microsoft.Testing.Platform.MSBuild": "2.3.3"
}
},
"Microsoft.ApplicationInsights": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.9.0",
"contentHash": "MtegCIKGuG0r/LCdIjoR1HgzCGIUBhR3aNdbtQpts5vMXQuqBDZ2Jsd2uFKoHFM2XrQV6ZrDf3F5oLi3SLTp8w=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "mUBDZZRgZrSyFOsJ2qJJ9fXfqd/kXJwf3AiDoqLD9m6TjY5OO/vLNOb9fb4juC0487eq4hcGN/M2Rh/CKS7QYw=="
},
"Microsoft.NETCore.Platforms": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
},
"Microsoft.Testing.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "nY8ceQyPWB9TRE1WE5Oe/sks2e10SxgPv61vBHsFYpgCeDtNwhpMZwmL29GklO3/etTdOsLdrCmNr4zJWaR2fg==",
"dependencies": {
"Microsoft.ApplicationInsights": "2.23.0",
"Microsoft.Testing.Platform": "2.3.3"
}
},
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "dceVNxnfTEjKnrIreLcMfkgrzzBY8kgCCJX5NAv7Lq/6vPlTP4VB5zxKeuYy0yfCoBtWuPkLjUG6qtOBMfpypA==",
"dependencies": {
"Microsoft.Testing.Platform": "2.3.3"
}
},
"Microsoft.Testing.Extensions.VSTestBridge": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "JLovZjH6K10YiHO/BcidsaSHo/M2DfOe9FDjxjFdV6aoHO6VYTTYlLBNcyLJSo20q5gDTmNvmxYVH39A942aJA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.4.0",
"Microsoft.Testing.Extensions.Telemetry": "2.3.3",
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.3.3",
"Microsoft.Testing.Platform": "2.3.3"
}
},
"Microsoft.Testing.Platform": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "ENbH4BQh9riXtOKc25KKITfiGGWhWMBJA7pZuNaF9zxzzSaVkp5AMeZxGQ6sXYaR6xb724NLz985Is6XIYqLSg=="
},
"Microsoft.Testing.Platform.MSBuild": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "iVAvNbZ5JPDZSrTcIrCDoCsAkzjDxwDEt2mDuMd8ng1P6e2P2NCd0g0BsoBVG+nJLSmK//V+yeEvjCa3E2fxHw==",
"dependencies": {
"Microsoft.Testing.Platform": "2.3.3"
}
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.9.0",
"contentHash": "Fz/qXC52VXXopzHj7v2reCKcCqSxkTDCkEDfkNAH0vni/7qK/KLMiEYtRmnUanxO2F2g2zZZ60qCpxF0AF7URA=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.9.0",
"contentHash": "Oq+Pma/J86aZL72t71bcESvDszDsTjUfl6PIsuDdPoKTHZfNMhKamCfLD5fKx51W/u0KozXtJo2/3fnt0sgPxg==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.9.0"
}
},
"NETStandard.Library": {
"type": "Transitive",
"resolved": "2.0.0",
"contentHash": "7jnbRU+L08FXKMxqUflxEXtVymWvNOrS8yHgu9s6EM8Anr6T/wIX4nZ08j/u3Asz+tCufp3YVwFSEvFTPYmBPA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0"
}
},
"projectquint.core": {
"type": "Project"
},
"projectquint.probes": {
"type": "Project",
"dependencies": {
"ProjectQuint.Core": "[1.0.0, )"
}
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}