acdream/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs
Erik e77dd7c413 docs: launch-options reference + the test that keeps it honest
The client reads 161 ACDREAM_* environment variables across 79 files. Only
about 25 were written down, and the audit found the documentation drifting
in both directions: CLAUDE.md still advertised ACDREAM_RUN_SKILL /
ACDREAM_JUMP_SKILL (deleted; skills are server-authoritative now, and the
jump fallback is 300, not the documented 200), while flags with real
side effects had no description at all.

docs/launch-options.md documents every one by lifecycle — production,
command line, measurement, automation, permanent diagnostics, temporary
probes, deprecated, retired — with a mandatory side-effects column. That
column is the point: #432 cost three days of taxed measurements because
ACDREAM_AUTOMATION_ARTIFACT_DIR reads like an output path and also builds
a per-frame diagnostics referee, and ACDREAM_STREAM_RADIUS silently
measures a streaming window production never uses. Rows now say so. Other
surprises the audit surfaced and recorded: ACDREAM_DUMP_SCENERY_Z swaps in
a duplicate scenery-placement path rather than only logging,
ACDREAM_PROBE_VIS silently also enables ACDREAM_PROBE_ENVCELL, and
ACDREAM_DUMP_ENTITY's id list doubles as an unrelated probe's watchlist.

LaunchOptionsDocumentationTests enforces it, because a hand-maintained list
of 161 flags is stale within a week: an undocumented flag fails, and so
does a documented row whose read site was deleted. It scans string literals
rather than GetEnvironmentVariable call shapes — the startup path reads
through an injected delegate, so a call-shaped pattern silently missed
ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. A third
test freezes per-file direct-read debt by exact count (20 files outside the
owner classes), so structure rules 4 and 5 can be paid down but not
regressed.

CLAUDE.md's 94-line env-var section becomes a 16-line pointer, and its
stale test-character paragraph is corrected.

Also fixed, all doc-vs-code mismatches the audit proved:
- RenderingDiagnostics.FrameProfEnabled described a GPU-query self-disable
  that Campaign V slice V11 deleted.
- Two comments named ACDREAM_RENDER_BACKEND as a live co-requisite; it died
  with the OpenGL backend.
- EnvCellRenderer.CollectCellAuditLines and its ACDREAM_A8_AUDIT doc: the
  method had no caller anywhere and its documented caller never existed.

Filed rather than fixed, to keep this a documentation change: #434 (the
DebugPanel/DebugVM surface is never constructed, so ~40 "runtime-toggleable"
comments are false and 35 env reads are unreachable) and #435 (17 temporary
probes outlived their closed investigations; 14 more name no owner).

Full hermetic suite 12,202 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 10:28:59 +02:00

245 lines
11 KiB
C#

using System.Text.RegularExpressions;
namespace AcDream.App.Tests.Diagnostics;
/// <summary>
/// Keeps <c>docs/launch-options.md</c> honest: every environment variable the
/// shipped client reads must have a documented row, and every documented row
/// must name a variable something actually reads.
/// </summary>
/// <remarks>
/// <para>
/// A hand-maintained list of ~170 flags goes stale within a week. #432 is what
/// that costs: <c>ACDREAM_AUTOMATION_ARTIFACT_DIR</c> read like an output-path
/// setting but also constructed a per-frame diagnostics referee worth ~6 MB and
/// ~14 ms every frame, and three days of measurements were taxed before anyone
/// noticed. The same audit found CLAUDE.md still advertising
/// <c>ACDREAM_RUN_SKILL</c> / <c>ACDREAM_JUMP_SKILL</c> after their read sites
/// were deleted. Both directions of drift are failures, so both fail here.
/// </para>
/// <para>
/// Scope is <c>src/</c> only. Test-owned variables, shader-compiler macro
/// tokens under <c>tools/</c>, and historical mentions in dated research
/// documents are deliberately out of scope — the doc describes what a launched
/// client reads today.
/// </para>
/// </remarks>
public sealed class LaunchOptionsDocumentationTests
{
private const string DocRelativePath = "docs/launch-options.md";
/// <summary>
/// Structure rule 5 wants runtime flags behind diagnostic owner classes and
/// rule 4 wants startup configuration in <c>RuntimeOptions</c>. These files
/// still read the environment directly, with the exact number of distinct
/// flags each one reads today.
/// </summary>
/// <remarks>
/// The counts are FROZEN, not merely the file names: promoting a stray to
/// its subsystem's owner lowers a number (update it here), and adding a new
/// direct read raises one and fails. A file that reaches zero leaves the
/// table entirely.
/// </remarks>
private static readonly IReadOnlyDictionary<string, int> DirectReadDebt =
new Dictionary<string, int>(StringComparer.Ordinal)
{
["src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs"] = 2,
["src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs"] = 1,
["src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs"] = 1,
["src/AcDream.App/Rendering/GameWindow.cs"] = 1,
["src/AcDream.App/Rendering/PortalVisibilityBuilder.cs"] = 1,
["src/AcDream.App/Rendering/Sky/SkyRenderer.cs"] = 1,
["src/AcDream.App/Rendering/TextureCache.cs"] = 1,
["src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs"] = 2,
["src/AcDream.Core/Physics/PhysicsEngine.cs"] = 1,
["src/AcDream.Core/Physics/TransitionTypes.cs"] = 3,
["src/AcDream.Core/Vfx/PhysicsScriptRunner.cs"] = 1,
["src/AcDream.Core/World/SkyDescLoader.cs"] = 2,
["src/AcDream.Core.Net/GameEventWiring.cs"] = 1,
["src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs"] = 1,
["src/AcDream.Core.Net/Messages/UpdateMotion.cs"] = 1,
["src/AcDream.Core.Net/WorldSession.cs"] = 3,
["src/AcDream.Platform/ApplicationPathSet.cs"] = 3,
["src/AcDream.Platform/BakePublicationGuardPaths.cs"] = 1,
["src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs"] = 1,
["src/AcDream.UI.Abstractions/Settings/QualityPreset.cs"] = 6,
};
/// <summary>
/// Any <c>ACDREAM_*</c> string literal in <c>src/</c>. Matching the literal
/// rather than a <c>GetEnvironmentVariable</c> call is deliberate: the
/// startup path reads through an injected <c>env</c> delegate
/// (<c>RuntimeOptions.Parse</c>) so a call-shaped pattern silently missed
/// ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. Comment
/// mentions (shader macros such as ACDREAM_SAMPLE_2D, prefix fragments)
/// carry no quotes and stay out.
/// </summary>
private static readonly Regex EnvironmentRead = new(
@"""(ACDREAM_[A-Z0-9_]+)""",
RegexOptions.Compiled);
private static readonly Regex DocumentedRow = new(
@"^\|\s*`(ACDREAM_[A-Z0-9_]+)`",
RegexOptions.Compiled | RegexOptions.Multiline);
[Fact]
public void EveryEnvironmentVariableTheClientReadsIsDocumented()
{
IReadOnlySet<string> read = ReadFlags();
IReadOnlySet<string> documented = DocumentedFlags();
List<string> undocumented = read.Except(documented).Order(StringComparer.Ordinal).ToList();
Assert.True(
undocumented.Count == 0,
$"{DocRelativePath} is missing a row for flags the client reads: "
+ string.Join(", ", undocumented)
+ ". Add the row in the same commit that adds the read site.");
}
[Fact]
public void EveryDocumentedEnvironmentVariableStillExists()
{
IReadOnlySet<string> read = ReadFlags();
IReadOnlySet<string> documented = DocumentedFlags();
List<string> phantom = documented.Except(read).Order(StringComparer.Ordinal).ToList();
Assert.True(
phantom.Count == 0,
$"{DocRelativePath} documents flags nothing in src/ reads: "
+ string.Join(", ", phantom)
+ ". Delete the row (or move it to the retired section with its "
+ "removal commit) in the same commit that deletes the read site.");
}
[Fact]
public void DirectEnvironmentReadsOutsideOwnerClassesDoNotGrow()
{
Dictionary<string, int> actual = SourceFiles()
.Where(file => !IsOwnerClass(file.RelativePath))
.Select(file => (
file.RelativePath,
Count: DistinctFlagsRead(File.ReadAllText(file.Path))))
.Where(file => file.Count > 0)
.ToDictionary(file => file.RelativePath, file => file.Count, StringComparer.Ordinal);
List<string> problems = [];
foreach ((string path, int count) in actual.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
if (!DirectReadDebt.TryGetValue(path, out int frozen))
{
problems.Add(
$"{path} reads {count} ACDREAM_* variable(s) directly but is "
+ "not an owner class. Add the property to the subsystem's "
+ "diagnostics owner (or RuntimeOptions) and read it there.");
}
else if (count > frozen)
{
problems.Add(
$"{path} grew from {frozen} to {count} direct reads. New "
+ "flags belong in an owner class, not here.");
}
}
foreach ((string path, int frozen) in DirectReadDebt.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
int count = actual.GetValueOrDefault(path, 0);
if (count < frozen)
{
problems.Add(
$"{path} is down to {count} direct read(s) from {frozen}. "
+ "Lower the frozen count (or drop the entry at zero) so the "
+ "debt cannot silently grow back.");
}
}
Assert.True(problems.Count == 0, string.Join("\n", problems));
}
private static bool IsOwnerClass(string relativePath)
{
// The intended homes for environment reads: one static diagnostics
// class per subsystem, the typed startup options objects, the
// executables' own entry points, and the single-purpose probe/capture
// owners (a file that exists only to own one probe already satisfies
// the rule the *Diagnostics.cs suffix encodes).
string name = Path.GetFileName(relativePath);
return name.EndsWith("Diagnostics.cs", StringComparison.Ordinal)
|| name.EndsWith("Options.cs", StringComparison.Ordinal)
|| name.EndsWith("Probe.cs", StringComparison.Ordinal)
|| name.EndsWith("Capture.cs", StringComparison.Ordinal)
|| name == "Program.cs";
}
private static int DistinctFlagsRead(string source)
{
var flags = new HashSet<string>(StringComparer.Ordinal);
foreach (Match match in EnvironmentRead.Matches(source))
flags.Add(match.Groups[1].Value);
return flags.Count;
}
private static IReadOnlySet<string> ReadFlags()
{
var flags = new HashSet<string>(StringComparer.Ordinal);
foreach ((string path, _) in SourceFiles())
{
foreach (Match match in EnvironmentRead.Matches(File.ReadAllText(path)))
flags.Add(match.Groups[1].Value);
}
Assert.True(
flags.Count > 100,
$"Only found {flags.Count} environment reads in src/; the scanner "
+ "is probably broken rather than the codebase suddenly clean.");
return flags;
}
private static IReadOnlySet<string> DocumentedFlags()
{
string doc = Path.Combine(FindRepoRoot(), DocRelativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(doc), $"{DocRelativePath} is missing.");
string text = File.ReadAllText(doc);
// Rows below the retired marker describe flags that are deliberately
// gone; they document history and must not resurrect the read-site
// requirement.
int retired = text.IndexOf("<!-- retired -->", StringComparison.Ordinal);
if (retired >= 0)
text = text[..retired];
var flags = new HashSet<string>(StringComparer.Ordinal);
foreach (Match match in DocumentedRow.Matches(text))
flags.Add(match.Groups[1].Value);
return flags;
}
private static IEnumerable<(string Path, string RelativePath)> SourceFiles()
{
string root = FindRepoRoot();
string src = Path.Combine(root, "src");
foreach (string path in Directory.EnumerateFiles(src, "*.cs", SearchOption.AllDirectories))
{
string relative = Path.GetRelativePath(root, path).Replace('\\', '/');
if (relative.Contains("/bin/", StringComparison.Ordinal)
|| relative.Contains("/obj/", StringComparison.Ordinal))
{
continue;
}
yield return (path, relative);
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}