acdream/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
Erik a13cff884f ci(render): Campaign V slice V9 - the Vulkan gate runs on lavapipe
The first CI job in this project's history that renders a frame.

The whole row rests on a decision V6g already made and paid for. When
section 5.5.8 cut set 0 from ten dynamic storage descriptors to four, four
was not merely under the RX 9070 XT's eight - it is Vulkan's guaranteed
minimum, so no conformant device can fail the layout. That is what makes a
software-device row possible at all. Every other requirement was then
checked against Mesa's lvp_device.c rather than assumed, and all seventeen
features the gate demands are true on lavapipe - including
samplerAnisotropy, which V7 made load-bearing eight commits ago and which a
software rasterizer would have been entirely within its rights to decline.

Three things had to exist before the job could:

1. The harness could not stop. VulkanBringUpHost presents until its window
   closes, which is right at a desk and impossible in CI, where nothing ever
   closes a window. ACDREAM_VULKAN_PROBE_FRAMES gives it a budget; unset or
   malformed is zero, which keeps the interactive behaviour, so no existing
   invocation changes. The budget never cuts the capture short - the loop
   stays open until the screenshot has been attempted - because a run whose
   entire product is a PNG must not be able to exit green with an empty
   artifact directory. The decision is a pure static method, tested without
   a window or a driver.

2. tools/compile-shaders.ps1 was Windows-only and nobody had noticed,
   because nothing had ever run it anywhere else. It built its paths from
   embedded 'src\AcDream.App\...' literals; a backslash is a separator on
   Windows and an ordinary filename character everywhere else, so on Linux
   that is one long nonexistent file name.

3. The report's jq paths were invisible to the compiler. Renaming a record
   property or swapping the enum converter would have left every test green
   and turned CI red on someone else's branch days later, with a failure
   that reads like a driver problem. VulkanCapabilityReportContractTests
   pins the exact strings the job greps and pins its packed-version
   arithmetic against VulkanApiVersion's own unpacking.

The job, eleven steps: install lavapipe and Xvfb; record vulkaninfo as
evidence; publish linux-x64; run the Gpu.Vk tests on a second operating
system; probe the gate under a 24-bit Xvfb screen (the default is 8-bit,
which leaves the X11 WSI without a usable visual) and assert an accepting
verdict on a Cpu device at API >= 1.3 with a clean active probe; assert the
captured PNG is a real frame by IHDR dimensions and byte count; re-run with
ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore and assert exit 4 with an
actionable refusal; recompile the shaders and compare. Artifacts upload on
always(), so a red run ships its own diagnosis.

The .spv step is what ties the committed binaries to their sources. The
existing App test hashes GLSL against the manifest, which catches "edited a
shader, forgot to recompile"; nothing caught a stale or hand-edited .spv.
Verified on Windows before shipping: 19/19 artifacts byte-identical to a
fresh compile, zero drift.

No GL-versus-Vulkan pixel compare, for two independent reasons recorded in
section 5.5.20: linux-graphical asserts exit 4, so there is no left-hand
side, and the probe renders synthetic scenes rather than the DAT world CI
cannot have. The two jobs now say something sharper than a pixel diff would
have - on the same software Mesa stack, GL is refused and Vulkan is accepted
and draws. Physical Linux GPU and Wayland rows stay deferred on the Slice L
precedent; no hosted runner offers either.

Gates: Release build green, zero errors. App tests 4,152 / 3 skipped against
a 4,134 / 3 baseline at this branch's base (9b7f4343) - eighteen new, all
from this slice. Workflow validated by a real YAML parse plus an Actions
schema check and bash -n over all nine extracted run blocks; no actionlint
was available locally and none was downloaded. The job itself has not run:
its first execution is the CI run this commit triggers, and the V9 row stays
partial until that is green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:10:01 +02:00

609 lines
22 KiB
C#

using System.Collections.Generic;
using AcDream.App;
using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
namespace AcDream.App.Tests;
/// <summary>
/// Unit tests for <see cref="RuntimeOptions"/> startup-time parsing.
/// Behavior-preservation only: every assertion locks in the exact
/// boolean / numeric / nullability semantics from the env reads that
/// previously lived in <c>GameWindow.cs</c>.
/// </summary>
public sealed class RuntimeOptionsTests
{
[Fact]
public void ResidencyBudgetsPreserveCurrentProductionDefaults()
{
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
_ => null);
Assert.Equal(ResidencyBudgetOptions.Default, options.ResidencyBudgets);
Assert.Equal(
StreamingWorkBudgetOptions.Default,
options.StreamingWorkBudgets);
}
[Fact]
public void StreamingWorkBudgetOverridesAreOneTypedProfile()
{
var values = new Dictionary<string, string?>
{
["ACDREAM_STREAM_WORK_MS"] = "1.75",
["ACDREAM_STREAM_WORK_COMPLETIONS"] = "31",
["ACDREAM_STREAM_WORK_CPU_MIB"] = "6",
["ACDREAM_STREAM_WORK_ENTITY_OPS"] = "144",
["ACDREAM_STREAM_WORK_GPU_MIB"] = "5",
["ACDREAM_STREAM_WORK_GL_RETIRE_OPS"] = "23",
["ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT"] = "60",
};
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => values.GetValueOrDefault(name));
Assert.Equal(1.75, options.StreamingWorkBudgets.MaxUpdateMilliseconds);
Assert.Equal(31, options.StreamingWorkBudgets.MaxCompletionAdmissions);
Assert.Equal(
6 * StreamingWorkBudgetOptions.MiB,
options.StreamingWorkBudgets.MaxAdoptedCpuBytes);
Assert.Equal(144, options.StreamingWorkBudgets.MaxEntityOperations);
Assert.Equal(
5 * StreamingWorkBudgetOptions.MiB,
options.StreamingWorkBudgets.MaxGpuUploadBytes);
Assert.Equal(23, options.StreamingWorkBudgets.MaxGlRetireOperations);
Assert.Equal(0.60f, options.StreamingWorkBudgets.DestinationReserveFraction);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("NaN")]
[InlineData("Infinity")]
[InlineData("bad")]
public void InvalidStreamingWorkValuesFallBackIndependently(string value)
{
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => name switch
{
"ACDREAM_STREAM_WORK_MS" => value,
"ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT" => value,
_ => null,
});
Assert.Equal(
StreamingWorkBudgetOptions.Default.MaxUpdateMilliseconds,
options.StreamingWorkBudgets.MaxUpdateMilliseconds);
Assert.Equal(
StreamingWorkBudgetOptions.Default.DestinationReserveFraction,
options.StreamingWorkBudgets.DestinationReserveFraction);
}
[Fact]
public void ResidencyBudgetOverridesAreTypedMebibytesAndCounts()
{
var values = new Dictionary<string, string?>
{
["ACDREAM_RESIDENCY_MESH_GPU_MIB"] = "768",
["ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES"] = "72",
["ACDREAM_RESIDENCY_ANIMATION_MIB"] = "48",
["ACDREAM_RESIDENCY_ANIMATION_ENTRIES"] = "300",
["ACDREAM_RESIDENCY_AUDIO_MIB"] = "24",
};
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => values.GetValueOrDefault(name));
Assert.Equal(
768 * ResidencyBudgetOptions.MiB,
options.ResidencyBudgets.ObjectMeshGpuBytes);
Assert.Equal(
72,
options.ResidencyBudgets.ObjectMeshUnownedEntries);
Assert.Equal(
48 * ResidencyBudgetOptions.MiB,
options.ResidencyBudgets.AnimationBytes);
Assert.Equal(300, options.ResidencyBudgets.AnimationEntries);
Assert.Equal(
24 * ResidencyBudgetOptions.MiB,
options.ResidencyBudgets.AudioBytes);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("bad")]
[InlineData("8796093022208")]
public void InvalidResidencyBudgetFallsBackToCurrentDefault(string value)
{
RuntimeOptions options = RuntimeOptions.Parse(
"D:\\dat",
name => name == "ACDREAM_RESIDENCY_MESH_GPU_MIB"
? value
: null);
Assert.Equal(
ResidencyBudgetOptions.Default.ObjectMeshGpuBytes,
options.ResidencyBudgets.ObjectMeshGpuBytes);
}
private const string AnyDatDir = "C:/Users/test/dats";
private static Func<string, string?> Env(Dictionary<string, string?> values)
=> name => values.TryGetValue(name, out var v) ? v : null;
private static Func<string, string?> EmptyEnv() => _ => null;
[Fact]
public void Defaults_AllSafeOff_WhenEnvironmentIsEmpty()
{
var opts = RuntimeOptions.Parse(AnyDatDir, EmptyEnv());
Assert.Equal(AnyDatDir, opts.DatDir);
Assert.Equal(
Path.Combine(AnyDatDir, "acdream.pak"),
opts.PreparedAssetPath);
Assert.False(opts.LiveMode);
Assert.Equal("127.0.0.1", opts.LiveHost);
Assert.Equal(9000, opts.LivePort);
Assert.Null(opts.LiveUser);
Assert.Null(opts.LivePass);
Assert.False(opts.DevTools);
Assert.False(opts.UncappedRendering);
Assert.False(opts.DumpMoveTruth);
Assert.False(opts.NoAudio);
Assert.False(opts.EnableSkyPesDebug);
Assert.Equal(-1, opts.HidePartIndex);
// Default-on: RetailCloseDegrades is true unless explicitly disabled.
Assert.True(opts.RetailCloseDegrades);
Assert.False(opts.DumpSceneryZ);
Assert.False(opts.DumpClothing);
Assert.Null(opts.LegacyStreamRadius);
Assert.False(opts.UiProbeDump);
Assert.Null(opts.UiProbeScript);
Assert.Null(opts.AutomationArtifactDirectory);
Assert.Equal(0.7f, opts.FogStartMultiplier);
Assert.Equal(0.95f, opts.FogEndMultiplier);
Assert.False(opts.UiProbeEnabled);
Assert.False(opts.HasLiveCredentials);
}
[Fact]
public void PreparedAssetPath_DefaultsBesideDats_AndAllowsOneOverride()
{
Assert.Equal(
Path.Combine(AnyDatDir, "acdream.pak"),
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).PreparedAssetPath);
var overridden = RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_PAK_PATH"] = "D:/prepared/acdream.pak" }));
Assert.Equal("D:/prepared/acdream.pak", overridden.PreparedAssetPath);
}
[Fact]
public void LiveMode_Set_ExactlyByValue1()
{
Assert.True(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "1" })).LiveMode);
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "0" })).LiveMode);
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "true" })).LiveMode);
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "" })).LiveMode);
}
[Fact]
public void LiveHostAndPort_FallBackToDefaults_WhenUnsetOrInvalid()
{
var withDefaults = RuntimeOptions.Parse(AnyDatDir, EmptyEnv());
Assert.Equal("127.0.0.1", withDefaults.LiveHost);
Assert.Equal(9000, withDefaults.LivePort);
var withOverrides = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_TEST_HOST"] = "play.example.com",
["ACDREAM_TEST_PORT"] = "9123",
}));
Assert.Equal("play.example.com", withOverrides.LiveHost);
Assert.Equal(9123, withOverrides.LivePort);
// Non-numeric port falls back to default; we don't throw at parse time.
var withBadPort = RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_TEST_PORT"] = "abc" }));
Assert.Equal(9000, withBadPort.LivePort);
}
[Fact]
public void LiveUserPass_NullWhenEmptyOrUnset()
{
var emptyValues = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_TEST_USER"] = "",
["ACDREAM_TEST_PASS"] = "",
}));
Assert.Null(emptyValues.LiveUser);
Assert.Null(emptyValues.LivePass);
var realValues = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_TEST_USER"] = "testaccount",
["ACDREAM_TEST_PASS"] = "testpassword",
}));
Assert.Equal("testaccount", realValues.LiveUser);
Assert.Equal("testpassword", realValues.LivePass);
}
[Fact]
public void HasLiveCredentials_RequiresLiveModeAndBothUserAndPass()
{
// Live mode off → no credentials regardless of user/pass.
var noLive = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_TEST_USER"] = "u",
["ACDREAM_TEST_PASS"] = "p",
}));
Assert.False(noLive.HasLiveCredentials);
// Live mode on but missing user → no credentials.
var missingUser = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_LIVE"] = "1",
["ACDREAM_TEST_PASS"] = "p",
}));
Assert.False(missingUser.HasLiveCredentials);
// Live mode on but missing pass → no credentials.
var missingPass = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_LIVE"] = "1",
["ACDREAM_TEST_USER"] = "u",
}));
Assert.False(missingPass.HasLiveCredentials);
// All three present → credentials available.
var ok = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_LIVE"] = "1",
["ACDREAM_TEST_USER"] = "u",
["ACDREAM_TEST_PASS"] = "p",
}));
Assert.True(ok.HasLiveCredentials);
}
[Fact]
public void HidePartIndex_MinusOneWhenUnset_ParsesIntegers()
{
Assert.Equal(-1, RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).HidePartIndex);
Assert.Equal(7, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_HIDE_PART"] = "7" })).HidePartIndex);
// Invalid → fall back to -1 (preserves the int.TryParse failure semantics).
Assert.Equal(-1, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_HIDE_PART"] = "abc" })).HidePartIndex);
}
[Fact]
public void RetailCloseDegrades_DefaultOn_ExceptWhenValueIsExactlyZero()
{
// Unset → on.
Assert.True(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RetailCloseDegrades);
// Exactly "0" → off. Matches the pre-refactor semantics:
// !string.Equals(env, "0", StringComparison.Ordinal)
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_RETAIL_CLOSE_DEGRADES"] = "0",
})).RetailCloseDegrades);
// Any other value → on (including "1", "false", "True"). The original
// code only checked for the literal "0"; preserve that.
Assert.True(RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_RETAIL_CLOSE_DEGRADES"] = "1",
})).RetailCloseDegrades);
Assert.True(RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_RETAIL_CLOSE_DEGRADES"] = "false",
})).RetailCloseDegrades);
}
[Fact]
public void LegacyStreamRadius_NullWhenUnsetOrInvalid_ParsesNonNegativeIntegers()
{
Assert.Null(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).LegacyStreamRadius);
Assert.Null(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "abc" })).LegacyStreamRadius);
// Negative values are filtered out by the pre-refactor `sr >= 0` guard.
Assert.Null(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "-3" })).LegacyStreamRadius);
Assert.Equal(0, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "0" })).LegacyStreamRadius);
Assert.Equal(5, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "5" })).LegacyStreamRadius);
Assert.Equal(12, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "12" })).LegacyStreamRadius);
}
[Fact]
public void FogMultipliers_ParseInvariantFloats_AndFallBackIndependently()
{
var parsed = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_FOG_START_MULT"] = "0.625",
["ACDREAM_FOG_END_MULT"] = "1.125",
}));
Assert.Equal(0.625f, parsed.FogStartMultiplier);
Assert.Equal(1.125f, parsed.FogEndMultiplier);
var invalid = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_FOG_START_MULT"] = "not-a-number",
["ACDREAM_FOG_END_MULT"] = "",
}));
Assert.Equal(0.7f, invalid.FogStartMultiplier);
Assert.Equal(0.95f, invalid.FogEndMultiplier);
}
[Fact]
public void DayGroupOverride_IsReadOnceIntoTypedOptions()
{
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
EmptyEnv()).ForcedDayGroupIndex);
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_DAY_GROUP"] = "-1" }))
.ForcedDayGroupIndex);
Assert.Equal(
7,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_DAY_GROUP"] = "7" }))
.ForcedDayGroupIndex);
}
/// <summary>
/// Campaign V slice V7. Pins the Dereth day fraction -- the sun, the
/// keyframe, and every lit surface. Range-checked rather than merely parsed:
/// a day fraction outside [0, 1) is not a clamp candidate, it is a typo, and
/// silently pinning the world at 12.5 would be worse than ignoring it.
/// </summary>
[Fact]
public void WorldTimeOverride_IsReadOnceIntoTypedOptions()
{
Assert.Null(
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).PinnedWorldDayFraction);
Assert.Equal(
0.5f,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_WORLD_TIME"] = "0.5" }))
.PinnedWorldDayFraction);
Assert.Equal(
0f,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_WORLD_TIME"] = "0" }))
.PinnedWorldDayFraction);
foreach (string rejected in new[] { "1", "1.5", "-0.1", "midnight", "" })
{
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_WORLD_TIME"] = rejected }))
.PinnedWorldDayFraction);
}
}
/// <summary>
/// Campaign V slice V7. The sky has two clocks and this pins the one
/// ACDREAM_DAY_GROUP cannot reach — the cloud sheet's UV scroll, which
/// accumulates against real elapsed time. Unset must stay unset: every
/// ordinary run keeps the wall clock, and only the backend differential gate
/// asks for a pin.
/// </summary>
[Fact]
public void SkyPhaseOverride_IsReadOnceIntoTypedOptions()
{
Assert.Null(
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).SkyAnimationPhaseSeconds);
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_SKY_PHASE_SECONDS"] = "not-a-number" }))
.SkyAnimationPhaseSeconds);
// Zero is a real request, not "unset": it is the cloud sheet's authored
// origin and the value the gate script pins by default.
Assert.Equal(
0f,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_SKY_PHASE_SECONDS"] = "0" }))
.SkyAnimationPhaseSeconds);
Assert.Equal(
12.5f,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_SKY_PHASE_SECONDS"] = "12.5" }))
.SkyAnimationPhaseSeconds);
}
[Fact]
public void DiagnosticFlags_RespectExactValueOne()
{
var allOn = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_DEVTOOLS"] = "1",
["ACDREAM_UNCAPPED_RENDER"] = "1",
["ACDREAM_DUMP_MOVE_TRUTH"] = "1",
["ACDREAM_DUMP_SKY"] = "1",
["ACDREAM_NO_AUDIO"] = "1",
["ACDREAM_ENABLE_SKY_PES"] = "1",
["ACDREAM_DUMP_SCENERY_Z"] = "1",
["ACDREAM_DUMP_CLOTHING"] = "1",
}));
Assert.True(allOn.DevTools);
Assert.True(allOn.UncappedRendering);
Assert.True(allOn.DumpMoveTruth);
Assert.True(allOn.DumpSky);
Assert.True(allOn.NoAudio);
Assert.True(allOn.EnableSkyPesDebug);
Assert.True(allOn.DumpSceneryZ);
Assert.True(allOn.DumpClothing);
// Any non-"1" value leaves them off, matching the
// string.Equals(env, "1", StringComparison.Ordinal) check.
var anyOther = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_DEVTOOLS"] = "true",
["ACDREAM_UNCAPPED_RENDER"] = "true",
["ACDREAM_DUMP_MOVE_TRUTH"] = "yes",
["ACDREAM_NO_AUDIO"] = "2",
["ACDREAM_ENABLE_SKY_PES"] = "on",
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
["ACDREAM_DUMP_CLOTHING"] = "true",
}));
Assert.False(anyOther.DevTools);
Assert.False(anyOther.UncappedRendering);
Assert.False(anyOther.DumpMoveTruth);
Assert.False(anyOther.NoAudio);
Assert.False(anyOther.EnableSkyPesDebug);
Assert.False(anyOther.DumpSceneryZ);
Assert.False(anyOther.DumpClothing);
}
[Fact]
public void Parse_RejectsNullDatDirOrEnv()
{
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(null!, EmptyEnv()));
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(AnyDatDir, null!));
}
/// <summary>
/// Campaign V slice V5. OpenGL is the shipping backend until slice V10, so
/// the default must be GL and a typo must never silently start the dark
/// Vulkan host.
/// </summary>
[Fact]
public void RenderBackend_DefaultsToGl()
{
Assert.Equal(
RenderBackendKind.Gl,
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RenderBackend);
}
[Theory]
[InlineData("vulkan")]
[InlineData("Vulkan")]
[InlineData("VULKAN")]
public void RenderBackend_SelectsVulkanCaseInsensitively(string value)
{
Assert.Equal(
RenderBackendKind.Vulkan,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
}
[Theory]
[InlineData("gl")]
[InlineData("opengl")]
[InlineData("")]
[InlineData("vulcan")]
[InlineData("vk")]
[InlineData(" vulkan")]
public void RenderBackend_AnythingElseStaysOnGl(string value)
{
Assert.Equal(
RenderBackendKind.Gl,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
}
[Fact]
public void VulkanDeviceOverride_IsNullWhenUnsetOrEmpty()
{
Assert.Null(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanDeviceOverride);
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_DEVICE"] = "" })).VulkanDeviceOverride);
}
/// <summary>
/// The override is carried verbatim — index or name substring — because the
/// capability report records exactly what the operator asked for, matched or
/// not.
/// </summary>
[Theory]
[InlineData("1")]
[InlineData("Radeon")]
public void VulkanDeviceOverride_IsCarriedVerbatim(string value)
{
Assert.Equal(
value,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_DEVICE"] = value })).VulkanDeviceOverride);
}
[Fact]
public void VulkanForcedUnsupportedFeature_DrivesTheExitFourGateKnob()
{
Assert.Null(
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanForcedUnsupportedFeature);
Assert.Equal(
"timelineSemaphore",
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_FORCE_UNSUPPORTED"] = "timelineSemaphore" }))
.VulkanForcedUnsupportedFeature);
}
/// <summary>
/// Campaign V slice V9. The frame budget is what lets the probe harness run
/// unattended in CI. Zero is the interactive default, so every invocation
/// that predates the slice keeps presenting until its window closes.
/// </summary>
[Fact]
public void VulkanCapabilityProbeFrames_DefaultsToUnbounded()
{
Assert.Equal(
0,
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanCapabilityProbeFrames);
}
[Theory]
[InlineData("1", 1)]
[InlineData("30", 30)]
[InlineData("0", 0)]
public void VulkanCapabilityProbeFrames_ParsesANonNegativeBudget(
string value,
int expected)
{
Assert.Equal(
expected,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_PROBE_FRAMES"] = value }))
.VulkanCapabilityProbeFrames);
}
/// <summary>
/// A malformed budget falls back to the interactive default rather than to
/// some invented number: a CI step that meant to bound the run and mistyped
/// it should hang and be noticed, not silently capture at a frame count
/// nobody asked for.
/// </summary>
[Theory]
[InlineData("")]
[InlineData("-1")]
[InlineData("many")]
[InlineData("30.5")]
public void VulkanCapabilityProbeFrames_RejectsMalformedValues(string value)
{
Assert.Equal(
0,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VULKAN_PROBE_FRAMES"] = value }))
.VulkanCapabilityProbeFrames);
}
}