Merge HW-acceleration detection (default lava off on software renderer) into streaming-overhaul
This commit is contained in:
@@ -265,6 +265,24 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply the hardware-capability default ONCE per session before seeding the controls: if the
|
||||||
|
// browser has no WebGL hardware acceleration, the lava subsystem (which software-renders on
|
||||||
|
// the main thread and starves audio decode) defaults off while the waveform stays on. The
|
||||||
|
// probe lives in JS (it needs a real WebGL context); the scoped state guards the one-time
|
||||||
|
// application, so a remounted visualizer never re-applies and a later explicit toggle is
|
||||||
|
// never clobbered. Sequenced before PushControlsAsync so the seed already carries the
|
||||||
|
// corrected enables; ApplyCapabilityDefault also raises Changed for the controls UI.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hardwareAccelerated = await _module.InvokeAsync<bool>("detectHardwareAcceleration");
|
||||||
|
ControlState.ApplyCapabilityDefault(hardwareAccelerated);
|
||||||
|
}
|
||||||
|
catch (JSException ex)
|
||||||
|
{
|
||||||
|
// A probe failure must not regress the HW-accel majority: leave the defaults (lava on).
|
||||||
|
Logger.LogWarning(ex, "WaveformVisualizer: hardware-acceleration probe failed; leaving lava at its default.");
|
||||||
|
}
|
||||||
|
|
||||||
// Seed the module with the current state now that it exists. All control values (the eight
|
// Seed the module with the current state now that it exists. All control values (the eight
|
||||||
// dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
|
// dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
|
||||||
// so a mix opened mid-session seeds the module with the knob/toggle positions the listener
|
// so a mix opened mid-session seeds the module with the knob/toggle positions the listener
|
||||||
|
|||||||
@@ -160,6 +160,47 @@ public sealed class WaveformVisualizerControlState
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? Changed;
|
public event Action? Changed;
|
||||||
|
|
||||||
|
// Whether the one-time, capability-driven default has been applied this session. The default-set
|
||||||
|
// (lava off when the browser has no WebGL hardware acceleration) must run exactly once — on the
|
||||||
|
// first interactive render, before the listener has touched a toggle — so it sets the *initial
|
||||||
|
// default* and never clobbers a later explicit in-session toggle. Scoped with the rest of this
|
||||||
|
// state, so it survives SPA navigation (a remounted visualizer does not re-apply) and resets on a
|
||||||
|
// fresh page load (F5 re-probes).
|
||||||
|
private bool _capabilityDefaultApplied;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the hardware-capability default exactly once per session: when the browser reports no
|
||||||
|
/// WebGL hardware acceleration, the lava subsystem (the expensive, main-thread software-rendered
|
||||||
|
/// part that starves audio decode) defaults <c>off</c> while the waveform stays <c>on</c>. With
|
||||||
|
/// acceleration present this is a no-op — lava keeps its <see cref="DefaultLavaEnabled"/> on-state.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Idempotent and guarded: only the FIRST call this session has any effect, so it sets the initial
|
||||||
|
/// default and never overrides a listener's explicit toggle (the control remains fully functional —
|
||||||
|
/// a user on a software renderer may re-enable lava at their own risk). Mutates, coerces
|
||||||
|
/// Theater Mode, then raises <see cref="Changed"/> once so the controls UI, the visualizer bridge,
|
||||||
|
/// and the Theater observers all reflect the default in a single cycle. Called by the visualizer
|
||||||
|
/// bridge on first interactive render, once JS interop (the probe) is available.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hardwareAccelerated">
|
||||||
|
/// The probe result — <c>true</c> when WebGL hardware acceleration is present (or the renderer is
|
||||||
|
/// unknown/masked, favoring the common case), <c>false</c> only on a positive software-renderer
|
||||||
|
/// match or total WebGL failure.
|
||||||
|
/// </param>
|
||||||
|
public void ApplyCapabilityDefault(bool hardwareAccelerated)
|
||||||
|
{
|
||||||
|
if (_capabilityDefaultApplied) return;
|
||||||
|
_capabilityDefaultApplied = true;
|
||||||
|
|
||||||
|
// Accelerated (or unknown): keep the as-shipped defaults — no observer churn.
|
||||||
|
if (hardwareAccelerated) return;
|
||||||
|
|
||||||
|
LavaEnabled = false; // the expensive subsystem off; WaveformEnabled stays at its default (true).
|
||||||
|
CoerceTheaterMode();
|
||||||
|
NotifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer
|
/// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer
|
||||||
/// subsystems are off (there is nothing to go to theater FOR). Call this after mutating
|
/// subsystems are off (there is nothing to go to theater FOR). Call this after mutating
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
|
|
||||||
import { decodePressure } from '../audio/decodePressure.js';
|
import { decodePressure } from '../audio/decodePressure.js';
|
||||||
|
|
||||||
|
// Re-exported so the Blazor bridge (WaveformVisualizer.razor.cs) reaches the HW-accel probe through
|
||||||
|
// the same module reference it already imports for create() — one JS import surface, no second handle.
|
||||||
|
// The probe itself (and its unit-tested pure classifier) lives in hwAccel.ts.
|
||||||
|
export { detectHardwareAcceleration } from './hwAccel.js';
|
||||||
|
|
||||||
// ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────
|
// ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* hwAccel classifier tests — the pure software-renderer signature matching and the
|
||||||
|
* uncertainty/failure policy that drives the lava default-off decision.
|
||||||
|
*
|
||||||
|
* These cover the code-PROVABLE half of the feature: given a renderer string (or its absence, or a
|
||||||
|
* total WebGL failure), is the browser classified "accelerated" (lava on) or not (lava off)? The
|
||||||
|
* impure probe (detectHardwareAcceleration → real getContext) is browser-confirmed, not unit-tested.
|
||||||
|
*
|
||||||
|
* Same harness convention as decodePressure.test.ts — no test runner in this repo; Node 22+ strips TS
|
||||||
|
* types natively. Run a copy from the COMPILED output so the `./hwAccel.js` import specifier resolves:
|
||||||
|
*
|
||||||
|
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
|
||||||
|
* cp DeepDrftPublic/Interop/visualizer/hwAccel.test.ts DeepDrftPublic/wwwroot/js/visualizer/
|
||||||
|
* node DeepDrftPublic/wwwroot/js/visualizer/hwAccel.test.ts
|
||||||
|
*
|
||||||
|
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
|
||||||
|
* Excluded from the production tsc build via tsconfig `exclude: Interop/ ** /*.test.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
classifyHardwareAcceleration,
|
||||||
|
isSoftwareRenderer,
|
||||||
|
SOFTWARE_RENDERER_SIGNATURES,
|
||||||
|
} from './hwAccel.js';
|
||||||
|
|
||||||
|
// --- tiny inline harness (no dependencies) ---------------------------------------------------
|
||||||
|
let passed = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
function test(name: string, fn: () => void): void {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
passed++;
|
||||||
|
} catch (e) {
|
||||||
|
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function assertTrue(actual: boolean, msg?: string): void {
|
||||||
|
if (actual !== true) throw new Error(`${msg ?? 'assertTrue'}: expected true, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
function assertFalse(actual: boolean, msg?: string): void {
|
||||||
|
if (actual !== false) throw new Error(`${msg ?? 'assertFalse'}: expected false, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isSoftwareRenderer: positive matches -----------------------------------------------------
|
||||||
|
|
||||||
|
// Real-world software renderer strings, as reported by UNMASKED_RENDERER_WEBGL on accel-off configs.
|
||||||
|
const SOFTWARE_STRINGS = [
|
||||||
|
'Google SwiftShader',
|
||||||
|
'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)',
|
||||||
|
'llvmpipe (LLVM 12.0.0, 256 bits)',
|
||||||
|
'Gallium 0.4 on llvmpipe (LLVM 17.0.6, 256 bits)',
|
||||||
|
'softpipe',
|
||||||
|
'Microsoft Basic Render Driver',
|
||||||
|
'Mesa OffScreen',
|
||||||
|
'Software Rasterizer',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const s of SOFTWARE_STRINGS) {
|
||||||
|
test(`isSoftwareRenderer matches software string: "${s}"`, () => {
|
||||||
|
assertTrue(isSoftwareRenderer(s), `"${s}" should match a software signature`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isSoftwareRenderer: hardware (GPU) strings must NOT match ---------------------------------
|
||||||
|
|
||||||
|
const HARDWARE_STRINGS = [
|
||||||
|
'ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)',
|
||||||
|
'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)',
|
||||||
|
'ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0, D3D11)',
|
||||||
|
'Apple GPU',
|
||||||
|
'Mali-G78',
|
||||||
|
'Adreno (TM) 650',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const s of HARDWARE_STRINGS) {
|
||||||
|
test(`isSoftwareRenderer rejects hardware string: "${s}"`, () => {
|
||||||
|
assertFalse(isSoftwareRenderer(s), `"${s}" should NOT match any software signature`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- case-insensitivity -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('isSoftwareRenderer is case-insensitive', () => {
|
||||||
|
assertTrue(isSoftwareRenderer('SWIFTSHADER'), 'upper-case must still match');
|
||||||
|
assertTrue(isSoftwareRenderer('LlVmPiPe'), 'mixed-case must still match');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every declared signature self-matches (sanity on the list)', () => {
|
||||||
|
for (const sig of SOFTWARE_RENDERER_SIGNATURES) {
|
||||||
|
assertTrue(isSoftwareRenderer(sig), `signature "${sig}" must match itself`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- classifyHardwareAcceleration: the full policy --------------------------------------------
|
||||||
|
|
||||||
|
test('positive software match → NOT accelerated (lava off)', () => {
|
||||||
|
assertFalse(
|
||||||
|
classifyHardwareAcceleration(true, 'Google SwiftShader'),
|
||||||
|
'a working context with a software renderer must classify as not accelerated',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real GPU renderer → accelerated (lava on)', () => {
|
||||||
|
assertTrue(
|
||||||
|
classifyHardwareAcceleration(true, 'ANGLE (NVIDIA GeForce RTX 3080, D3D11)'),
|
||||||
|
'a working context with a GPU renderer must classify as accelerated',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uncertainty / default-on case: context works but the renderer string is masked or absent.
|
||||||
|
test('masked renderer (null) with a working context → accelerated (default on)', () => {
|
||||||
|
assertTrue(
|
||||||
|
classifyHardwareAcceleration(true, null),
|
||||||
|
'an unknown renderer must favor the HW-accel majority',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty/whitespace renderer with a working context → accelerated (default on)', () => {
|
||||||
|
assertTrue(classifyHardwareAcceleration(true, ''), 'empty string is unknown, not software');
|
||||||
|
assertTrue(classifyHardwareAcceleration(true, ' '), 'whitespace is unknown, not software');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total-WebGL-failure case: no context at all → lava can't run → not accelerated.
|
||||||
|
test('no WebGL context at all → NOT accelerated (lava off), regardless of renderer arg', () => {
|
||||||
|
assertFalse(classifyHardwareAcceleration(false, null), 'no context → lava off');
|
||||||
|
assertFalse(
|
||||||
|
classifyHardwareAcceleration(false, 'ANGLE (NVIDIA GeForce RTX 3080, D3D11)'),
|
||||||
|
'no context dominates even a GPU-looking string',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- report ----------------------------------------------------------------------------------
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(failures.join('\n'));
|
||||||
|
throw new Error(`${failures.length} test(s) failed, ${passed} passed`);
|
||||||
|
}
|
||||||
|
console.log(`ALL ${passed} TESTS PASSED`);
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Hardware-acceleration probe for the lava-lamp visualizer.
|
||||||
|
*
|
||||||
|
* WHY: with hardware acceleration OFF the WebGL2 lava field software-renders on the main thread and
|
||||||
|
* starves WebCodecs Opus decode → playback struggles. The decodePressure auto-throttle alone is not
|
||||||
|
* enough — even throttled, software-rendered lava is too expensive. So when there is no HW-accel
|
||||||
|
* support we default the LAVA subsystem OFF (the expensive part) while keeping the WAVEFORM ON. With
|
||||||
|
* HW accel present (the common case) nothing changes — lava defaults on, full quality.
|
||||||
|
*
|
||||||
|
* The probe creates a throwaway WebGL context, reads the unmasked renderer string via
|
||||||
|
* WEBGL_debug_renderer_info, and matches it against known software-renderer signatures.
|
||||||
|
*
|
||||||
|
* UNCERTAINTY POLICY (favor the HW-accel majority): lava is disabled ONLY on a positive
|
||||||
|
* software-renderer match or a total failure to obtain any WebGL context (lava can't run at all). If
|
||||||
|
* the renderer string is unavailable/masked (some privacy configs strip
|
||||||
|
* WEBGL_debug_renderer_info) but a context otherwise succeeds, we default to "accelerated" — we do not
|
||||||
|
* disable lava on absence of evidence, only on positive evidence of software rendering.
|
||||||
|
*
|
||||||
|
* LIMIT (browser-confirmed, not code-provable): UNMASKED_RENDERER_WEBGL can be masked, and a given
|
||||||
|
* browser running with HW accel OFF may report a string none of these signatures match — in which
|
||||||
|
* case this probe reports "accelerated" and lava stays on. The signature list below is the only
|
||||||
|
* tunable; if a real software-renderer string slips through, add it here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case-insensitive substrings that positively identify a software (non-GPU) WebGL renderer. Matching
|
||||||
|
* any one of these means the browser is software-rendering WebGL → lava off. Order is irrelevant.
|
||||||
|
*/
|
||||||
|
export const SOFTWARE_RENDERER_SIGNATURES: readonly string[] = [
|
||||||
|
'swiftshader', // Chrome's software GL fallback (also "Google SwiftShader")
|
||||||
|
'llvmpipe', // Mesa software rasterizer (Linux)
|
||||||
|
'softpipe', // Mesa software rasterizer (older/gallium)
|
||||||
|
'microsoft basic render', // Windows "Microsoft Basic Render Driver"
|
||||||
|
'mesa offscreen', // Mesa headless/offscreen software path
|
||||||
|
'software', // generic catch-all ("... Software ...")
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure predicate: does this renderer string positively identify a software renderer? Case-insensitive
|
||||||
|
* substring match against {@link SOFTWARE_RENDERER_SIGNATURES}. Empty/whitespace is NOT a match — a
|
||||||
|
* masked/absent string is "unknown", not "software" (see {@link classifyHardwareAcceleration}).
|
||||||
|
*/
|
||||||
|
export function isSoftwareRenderer(renderer: string): boolean {
|
||||||
|
const r = renderer.toLowerCase();
|
||||||
|
return SOFTWARE_RENDERER_SIGNATURES.some((sig) => r.includes(sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure classifier mapping probe observations to "is hardware accelerated?". Split out from the
|
||||||
|
* DOM-touching {@link detectHardwareAcceleration} so the policy is unit-testable without a browser.
|
||||||
|
*
|
||||||
|
* • no WebGL context at all → false (lava can't run — total failure)
|
||||||
|
* • renderer masked/absent → true (favor the HW-accel majority — absence of evidence)
|
||||||
|
* • positive software match → false (positive evidence of software rendering)
|
||||||
|
* • otherwise → true (a real GPU renderer string)
|
||||||
|
*/
|
||||||
|
export function classifyHardwareAcceleration(hasWebglContext: boolean, renderer: string | null): boolean {
|
||||||
|
if (!hasWebglContext) return false;
|
||||||
|
if (renderer === null || renderer.trim() === '') return true;
|
||||||
|
return !isSoftwareRenderer(renderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the unmasked renderer string, or null when the debug extension is unavailable/masked. */
|
||||||
|
function readUnmaskedRenderer(gl: WebGLRenderingContext | WebGL2RenderingContext): string | null {
|
||||||
|
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
if (!ext) return null;
|
||||||
|
const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
|
||||||
|
return typeof renderer === 'string' ? renderer : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe once per page — the renderer is a constant for the lifetime of the document. Cached so the
|
||||||
|
// scoped C# control-state's one-time default-set never pays for a second throwaway context.
|
||||||
|
let cached: boolean | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe the browser for WebGL hardware acceleration. Returns true when the lava subsystem should
|
||||||
|
* default ON (HW accel present or renderer unknown), false when it should default OFF (positive
|
||||||
|
* software-renderer match or no WebGL context at all). Cached after the first call; never throws.
|
||||||
|
*/
|
||||||
|
export function detectHardwareAcceleration(): boolean {
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
cached = probe();
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
function probe(): boolean {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const gl = (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) as
|
||||||
|
| WebGLRenderingContext
|
||||||
|
| WebGL2RenderingContext
|
||||||
|
| null;
|
||||||
|
if (!gl) return classifyHardwareAcceleration(false, null);
|
||||||
|
const result = classifyHardwareAcceleration(true, readUnmaskedRenderer(gl));
|
||||||
|
// Release the throwaway context — WebGL contexts are a scarce per-page resource (~16 in
|
||||||
|
// Chrome before force-eviction). The renderer string is already captured in `result` above
|
||||||
|
// so this is safe to call before returning. Inner try/catch ensures a rogue loseContext
|
||||||
|
// implementation (or a browser that surfaces it incorrectly) cannot silently swallow the
|
||||||
|
// result or re-throw out of probe() and trigger the defensive `return true` fallback.
|
||||||
|
try { gl.getExtension('WEBGL_lose_context')?.loseContext(); } catch { /* defensive */ }
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
// getContext/createElement do not throw in practice; this guard is purely defensive. An
|
||||||
|
// unexpected probe failure should NOT regress the HW-accel majority, so default to
|
||||||
|
// accelerated (lava on) — only the clean "no context" path above disables lava.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,4 +88,93 @@ public class WaveformVisualizerControlStateTests
|
|||||||
|
|
||||||
Assert.That(observedTheaterMode, Is.False);
|
Assert.That(observedTheaterMode, Is.False);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ApplyCapabilityDefault: the one-time HW-accel default-set ──
|
||||||
|
|
||||||
|
// No HW accel → lava defaults off, waveform stays on (the whole point of the feature).
|
||||||
|
[Test]
|
||||||
|
public void ApplyCapabilityDefault_NoHardwareAccel_LavaOffWaveformOn()
|
||||||
|
{
|
||||||
|
_state.ApplyCapabilityDefault(hardwareAccelerated: false);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_state.LavaEnabled, Is.False);
|
||||||
|
Assert.That(_state.WaveformEnabled, Is.True);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HW accel present → no change; lava keeps its shipped on-default (the common case is untouched).
|
||||||
|
[Test]
|
||||||
|
public void ApplyCapabilityDefault_HardwareAccelerated_LavaStaysOn()
|
||||||
|
{
|
||||||
|
_state.ApplyCapabilityDefault(hardwareAccelerated: true);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_state.LavaEnabled, Is.True);
|
||||||
|
Assert.That(_state.WaveformEnabled, Is.True);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// No HW accel raises Changed once so the controls UI / bridge / theater observers all react.
|
||||||
|
[Test]
|
||||||
|
public void ApplyCapabilityDefault_NoHardwareAccel_RaisesChanged()
|
||||||
|
{
|
||||||
|
var raised = 0;
|
||||||
|
_state.Changed += () => raised++;
|
||||||
|
|
||||||
|
_state.ApplyCapabilityDefault(hardwareAccelerated: false);
|
||||||
|
|
||||||
|
Assert.That(raised, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// HW accel present must not churn observers — no Changed when nothing changed.
|
||||||
|
[Test]
|
||||||
|
public void ApplyCapabilityDefault_HardwareAccelerated_DoesNotRaiseChanged()
|
||||||
|
{
|
||||||
|
var raised = 0;
|
||||||
|
_state.Changed += () => raised++;
|
||||||
|
|
||||||
|
_state.ApplyCapabilityDefault(hardwareAccelerated: true);
|
||||||
|
|
||||||
|
Assert.That(raised, Is.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarded to once per session: a second call cannot re-clobber, even with a different verdict.
|
||||||
|
[Test]
|
||||||
|
public void ApplyCapabilityDefault_SecondCall_IsNoOp()
|
||||||
|
{
|
||||||
|
_state.ApplyCapabilityDefault(hardwareAccelerated: true); // first call wins (accelerated → on)
|
||||||
|
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // must be ignored
|
||||||
|
|
||||||
|
Assert.That(_state.LavaEnabled, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not override an explicit in-session toggle: once applied, a user re-enabling lava on a
|
||||||
|
// software renderer sticks — a later (guarded) call never reverts it.
|
||||||
|
[Test]
|
||||||
|
public void ApplyCapabilityDefault_DoesNotOverrideExplicitUserToggle()
|
||||||
|
{
|
||||||
|
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // default: lava off
|
||||||
|
_state.LavaEnabled = true; // user re-enables at their own risk
|
||||||
|
|
||||||
|
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // guarded → no-op
|
||||||
|
|
||||||
|
Assert.That(_state.LavaEnabled, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No HW accel while Theater Mode is on with only lava active → coercion exits theater in the same
|
||||||
|
// cycle (lava off + waveform off would strand theater; here waveform stays on, but verify the
|
||||||
|
// coercion path is exercised when it WOULD strand).
|
||||||
|
[Test]
|
||||||
|
public void ApplyCapabilityDefault_NoHardwareAccel_CoercesStrandedTheater()
|
||||||
|
{
|
||||||
|
_state.WaveformEnabled = false; // only lava active
|
||||||
|
_state.TheaterMode = true;
|
||||||
|
|
||||||
|
_state.ApplyCapabilityDefault(hardwareAccelerated: false); // lava off → both off → strand
|
||||||
|
|
||||||
|
Assert.That(_state.TheaterMode, Is.False);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user