feat: harden isolated Tornado test workflow

This commit is contained in:
2026-07-10 11:27:40 +09:00
parent 5a8c7028dc
commit fc932b27f6
36 changed files with 3919 additions and 226 deletions

View File

@@ -0,0 +1,491 @@
using System.Collections;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
namespace MBN_STOCK_WEBVIEW.PlayoutSmoke;
internal sealed record IsolatedTestCommandPlan(
SmokeCommandKind Kind,
PlayoutOptions Options,
PlayoutCue? PrepareCue,
PlayoutCue? NextCue,
TimeSpan ObservationDuration);
internal sealed class IsolatedTestPreflightException : Exception
{
public IsolatedTestPreflightException(string errorCode)
: base("The isolated Test command was rejected before engine creation.")
{
ErrorCode = errorCode;
}
public string ErrorCode { get; }
}
internal static class IsolatedTestCommandPreflight
{
internal const string EnvironmentVariablePrefix = "MBN_STOCK_PLAYOUT_";
public static IsolatedTestCommandPlan Create(SmokeCommandInvocation invocation)
{
ArgumentNullException.ThrowIfNull(invocation);
if (invocation.Kind is not (
SmokeCommandKind.TestPlan or
SmokeCommandKind.TestConnect or
SmokeCommandKind.TestSequence))
{
throw Rejected("invalid-test-command");
}
if (HasPlayoutEnvironmentOverride(Environment.GetEnvironmentVariables()))
{
throw Rejected("environment-override-present");
}
var configPath = ValidateConfigPath(invocation.ConfigPath);
PlayoutOptions options;
try
{
options = PlayoutOptionsLoader.LoadFileOnly(configPath);
}
catch (Exception exception) when (
exception is PlayoutConfigurationException or
ArgumentException or
IOException or
UnauthorizedAccessException)
{
throw Rejected("configuration-rejected");
}
if (options.Mode != PlayoutMode.Test || options.TrustedLiveOutputEnabled)
{
throw Rejected("test-mode-required");
}
// This one-shot diagnostic never reconnects or replays a command. The runtime
// still checks process eligibility before every operation.
options.ReconnectEnabled = false;
options.MaximumReconnectAttempts = 0;
PlayoutCue? prepareCue = null;
PlayoutCue? nextCue = null;
if (invocation.Kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence)
{
if (invocation.PrepareSceneCode is null || invocation.NextSceneCode is null ||
invocation.ObservationMilliseconds is not (>= 1_000 and <= 30_000) ||
!SmokeCommandLine.IsSafeSceneCode(invocation.PrepareSceneCode) ||
!SmokeCommandLine.IsSafeSceneCode(invocation.NextSceneCode))
{
throw Rejected("scene-code-rejected");
}
prepareCue = CreateCue(invocation.PrepareSceneCode);
nextCue = CreateCue(invocation.NextSceneCode);
}
try
{
PlayoutEngineFactory.ValidateIsolatedTestCommand(
options,
prepareCue is null ? [] : [prepareCue, nextCue!]);
}
catch (Exception exception) when (
exception is PlayoutConfigurationException or ArgumentException)
{
throw Rejected("safety-gate-rejected");
}
return new IsolatedTestCommandPlan(
invocation.Kind,
options,
prepareCue,
nextCue,
TimeSpan.FromMilliseconds(invocation.ObservationMilliseconds ?? 0));
}
internal static bool HasPlayoutEnvironmentOverride(IDictionary environment)
{
ArgumentNullException.ThrowIfNull(environment);
foreach (var key in environment.Keys)
{
if (key is string name &&
name.StartsWith(EnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string ValidateConfigPath(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !Path.IsPathFullyQualified(value))
{
throw Rejected("config-file-rejected");
}
string fullPath;
FileAttributes attributes;
try
{
fullPath = Path.GetFullPath(value);
var root = Path.GetPathRoot(fullPath);
if (string.IsNullOrEmpty(root) ||
fullPath.StartsWith("\\\\", StringComparison.Ordinal) ||
new DriveInfo(root).DriveType != DriveType.Fixed)
{
throw Rejected("config-file-rejected");
}
if (!File.Exists(fullPath))
{
throw Rejected("config-file-rejected");
}
attributes = File.GetAttributes(fullPath);
}
catch (IsolatedTestPreflightException)
{
throw;
}
catch (Exception exception) when (
exception is ArgumentException or
IOException or
NotSupportedException or
System.Security.SecurityException or
UnauthorizedAccessException)
{
throw Rejected("config-file-rejected");
}
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw Rejected("config-file-rejected");
}
return fullPath;
}
private static PlayoutCue CreateCue(string sceneCode) =>
new($"{sceneCode}.t2s", sceneCode);
private static IsolatedTestPreflightException Rejected(string errorCode) => new(errorCode);
}
internal sealed record SafeCommandStep(
string Phase,
string Operation,
string Code);
internal sealed record SafeCleanupReport(
bool DisconnectAttempted,
string? DisconnectCode,
bool QuarantineAttempted,
bool QuarantineCompleted,
bool AdapterDisposed);
internal sealed record SafeTestCommandReport(
string Command,
string Mode,
bool ConnectRequestIssued,
bool? ComActivationAttempted,
bool Completed,
bool OutcomeUnknown,
bool OutputMayBeActive,
string? StoppedAfter,
IReadOnlyList<SafeCommandStep> Steps,
SafeCleanupReport Cleanup);
internal static class IsolatedTestCommandExecutor
{
public static Task<SafeTestCommandReport> ExecuteAsync(
IsolatedTestCommandPlan plan,
CancellationToken cancellationToken = default) =>
ExecuteAsync(
plan,
PlayoutEngineFactory.Create,
static (delay, token) => Task.Delay(delay, token),
cancellationToken);
internal static async Task<SafeTestCommandReport> ExecuteAsync(
IsolatedTestCommandPlan plan,
Func<PlayoutOptions, IPlayoutEngine> engineFactory,
Func<TimeSpan, CancellationToken, Task> observationDelay,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(engineFactory);
ArgumentNullException.ThrowIfNull(observationDelay);
if (plan.Kind is not (SmokeCommandKind.TestConnect or SmokeCommandKind.TestSequence))
{
throw new ArgumentException("The plan is not executable.", nameof(plan));
}
var steps = new List<SafeCommandStep>();
IPlayoutEngine? engine = null;
var connectRequestIssued = false;
var connectResultReceived = false;
bool? comActivationAttempted = false;
var completed = false;
var outcomeUnknown = false;
string? stoppedAfter = null;
var disconnectAttempted = false;
string? disconnectCode = null;
var adapterDisposed = false;
var quarantineAttempted = false;
var quarantineCompleted = false;
var normalDisconnectCompleted = false;
var outputMayBeActive = false;
var takeOutAttempted = false;
try
{
engine = engineFactory(plan.Options);
do
{
connectRequestIssued = true;
var connect = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false);
connectResultReceived = true;
AddStep(steps, "command", connect);
comActivationAttempted = InferComActivationAttempt(connect.Code);
if (!connect.IsSuccess)
{
stoppedAfter = connect.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(connect);
break;
}
if (plan.Kind == SmokeCommandKind.TestSequence)
{
var prepare = await engine.PrepareAsync(plan.PrepareCue!, cancellationToken)
.ConfigureAwait(false);
AddStep(steps, "command", prepare);
if (!prepare.IsSuccess)
{
stoppedAfter = prepare.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(prepare);
break;
}
// Once TAKE IN is issued, an exception or ambiguous result can mean
// the Test output became active even though no success was observed.
outputMayBeActive = true;
var takeIn = await engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
AddStep(steps, "command", takeIn);
if (!takeIn.IsSuccess)
{
stoppedAfter = takeIn.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(takeIn);
if (!outcomeUnknown)
{
outputMayBeActive = false;
}
break;
}
if (!await ObserveAsync().ConfigureAwait(false))
{
stoppedAfter = "Observation";
break;
}
var next = await engine.NextAsync(plan.NextCue!, cancellationToken)
.ConfigureAwait(false);
AddStep(steps, "command", next);
if (!next.IsSuccess)
{
stoppedAfter = next.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(next);
break;
}
if (!await ObserveAsync().ConfigureAwait(false))
{
stoppedAfter = "Observation";
break;
}
takeOutAttempted = true;
var takeOut = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
cancellationToken).ConfigureAwait(false);
AddStep(steps, "command", takeOut);
if (!takeOut.IsSuccess)
{
stoppedAfter = takeOut.Operation.ToString();
// TAKE IN succeeded, so every non-successful TAKE OUT leaves the
// physical Test output state unsafe to infer from its result code.
outcomeUnknown = true;
break;
}
outputMayBeActive = false;
}
disconnectAttempted = true;
var disconnect = await engine.DisconnectAsync(cancellationToken).ConfigureAwait(false);
disconnectCode = disconnect.Code.ToString();
AddStep(steps, "command", disconnect);
normalDisconnectCompleted = true;
if (!disconnect.IsSuccess)
{
stoppedAfter = disconnect.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(disconnect);
break;
}
completed = true;
}
while (false);
}
catch
{
stoppedAfter ??= connectRequestIssued ? "Internal" : "EngineCreation";
if (!connectResultReceived)
{
comActivationAttempted = connectRequestIssued ? null : false;
}
outcomeUnknown = connectRequestIssued;
}
finally
{
if (engine is not null)
{
if (outputMayBeActive && !takeOutAttempted && !outcomeUnknown)
{
takeOutAttempted = true;
try
{
var cleanupTakeOut = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None).ConfigureAwait(false);
AddStep(steps, "cleanup", cleanupTakeOut);
if (cleanupTakeOut.IsSuccess)
{
outputMayBeActive = false;
}
else
{
outcomeUnknown = true;
}
}
catch
{
outcomeUnknown = true;
}
}
if (!normalDisconnectCompleted && !outcomeUnknown)
{
disconnectAttempted = true;
try
{
var cleanup = await engine.DisconnectAsync(CancellationToken.None)
.ConfigureAwait(false);
disconnectCode = cleanup.Code.ToString();
AddStep(steps, "cleanup", cleanup);
outcomeUnknown |= IsOutcomeUnknown(cleanup);
}
catch
{
disconnectCode = "Failed";
outcomeUnknown = true;
}
}
var safeToDispose = true;
if (outcomeUnknown)
{
quarantineAttempted = true;
try
{
await engine.QuarantineAsync().ConfigureAwait(false);
quarantineCompleted = true;
}
catch
{
// A normal Dispose may issue SDK Disconnect. If quarantine could
// not first detach the session, leaking locally is safer than an
// unrequested control call against an unknown physical output.
safeToDispose = false;
}
}
if (safeToDispose)
{
try
{
await engine.DisposeAsync().ConfigureAwait(false);
adapterDisposed = true;
}
catch
{
outcomeUnknown = true;
}
}
}
}
return BuildReport();
async Task<bool> ObserveAsync()
{
try
{
await observationDelay(plan.ObservationDuration, cancellationToken)
.ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch
{
// Observation is local timing only. Its failure does not make a COM
// command ambiguous, so retain the bounded TAKE OUT cleanup path.
return false;
}
}
SafeTestCommandReport BuildReport() => new(
plan.Kind == SmokeCommandKind.TestConnect ? "test-connect" : "test-sequence",
"Test",
connectRequestIssued,
comActivationAttempted,
completed,
outcomeUnknown,
outputMayBeActive,
stoppedAfter,
steps.AsReadOnly(),
new SafeCleanupReport(
disconnectAttempted,
disconnectCode,
quarantineAttempted,
quarantineCompleted,
adapterDisposed));
}
private static void AddStep(
ICollection<SafeCommandStep> steps,
string phase,
PlayoutResult result) =>
steps.Add(new SafeCommandStep(
phase,
result.Operation.ToString(),
result.Code.ToString()));
private static bool IsOutcomeUnknown(PlayoutResult result) =>
result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut;
private static bool? InferComActivationAttempt(PlayoutResultCode code) => code switch
{
PlayoutResultCode.Success or PlayoutResultCode.Failed => true,
PlayoutResultCode.Rejected => false,
_ => null
};
}

View File

@@ -4,14 +4,29 @@ using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Registration;
using MBN_STOCK_WEBVIEW.PlayoutSmoke;
var command = args.Length == 0 ? "--probe" : args[0].ToLowerInvariant();
return command switch
var parsed = SmokeCommandLine.Parse(args);
if (!parsed.IsValid)
{
"--probe" => Probe(),
"--dry-run" => await DryRunAsync(),
_ => Usage()
return Usage(parsed.ErrorCode);
}
using var cancellation = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cancellation.Cancel();
};
return parsed.Invocation.Kind switch
{
SmokeCommandKind.Probe => Probe(),
SmokeCommandKind.DryRun => await DryRunAsync(),
SmokeCommandKind.TestPlan => TestPlan(parsed.Invocation),
SmokeCommandKind.TestConnect or SmokeCommandKind.TestSequence =>
await RunIsolatedTestCommandAsync(parsed.Invocation, cancellation.Token),
_ => Usage("unknown-command")
};
static int Probe()
@@ -42,8 +57,7 @@ static int Probe()
tornadoProcessCount++;
var title = process.MainWindowTitle;
if (title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase))
if (SmokeCommandLine.IsUnsafeTornadoWindowTitle(title))
{
programWindowDetected = true;
}
@@ -58,9 +72,12 @@ static int Probe()
}
}
Console.WriteLine(JsonSerializer.Serialize(new
WriteJson(new
{
command = "probe",
architecture = Environment.Is64BitProcess ? "x64" : "x86",
connectRequestIssued = false,
comActivationAttempted = false,
runtimeRegistration = new
{
ready = runtimeRegistration.IsReady,
@@ -76,9 +93,9 @@ static int Probe()
programWindowDetected
},
safety = programWindowDetected
? "PROGRAM window detected; no COM activation was attempted."
? "PROGRAM or ambiguous Tornado window detected; no COM activation was attempted."
: "Read-only probe complete; no COM activation was attempted."
}, new JsonSerializerOptions { WriteIndented = true }));
});
return runtimeRegistration.IsReady && Environment.Is64BitProcess
? 0
@@ -101,24 +118,124 @@ static async Task<int> DryRunAsync()
await engine.DisconnectAsync()
};
Console.WriteLine(JsonSerializer.Serialize(new
WriteJson(new
{
command = "dry-run",
mode = engine.Status.Mode.ToString(),
state = engine.Status.State.ToString(),
connectRequestIssued = false,
comActivationAttempted = false,
commands = results.Select(result => new
{
operation = result.Operation.ToString(),
code = result.Code.ToString(),
message = result.Message
})
}, new JsonSerializerOptions { WriteIndented = true }));
});
return results.All(result => result.IsSuccess) ? 0 : 3;
}
static int Usage()
static int TestPlan(SmokeCommandInvocation invocation)
{
Console.Error.WriteLine("Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run]");
Console.Error.WriteLine("This tool never enables Test or Live mode and never activates K3D COM in dry-run.");
try
{
var plan = IsolatedTestCommandPreflight.Create(invocation);
WriteJson(new
{
command = "test-plan",
mode = "Test",
preflightValidated = true,
runtimeProcessGateChecked = false,
connectRequestIssued = false,
comActivationAttempted = false,
sceneCount = plan.PrepareCue is null ? 0 : 2,
automaticReconnectEnabled = plan.Options.ReconnectEnabled,
safety = "Configuration and scene assets validated only; no engine or COM was created."
});
return 0;
}
catch (IsolatedTestPreflightException exception)
{
WritePreflightFailure("test-plan", exception.ErrorCode);
return 65;
}
catch
{
WritePreflightFailure("test-plan", "internal-preflight-failure");
return 70;
}
}
static async Task<int> RunIsolatedTestCommandAsync(
SmokeCommandInvocation invocation,
CancellationToken cancellationToken)
{
var command = invocation.Kind == SmokeCommandKind.TestConnect
? "test-connect"
: "test-sequence";
IsolatedTestCommandPlan plan;
try
{
plan = IsolatedTestCommandPreflight.Create(invocation);
}
catch (IsolatedTestPreflightException exception)
{
WritePreflightFailure(command, exception.ErrorCode);
return 65;
}
catch
{
WritePreflightFailure(command, "internal-preflight-failure");
return 70;
}
var report = await IsolatedTestCommandExecutor.ExecuteAsync(plan, cancellationToken);
WriteJson(report);
if (report.Completed && !report.OutcomeUnknown)
{
return 0;
}
return report.OutcomeUnknown ? 5 : 4;
}
static void WritePreflightFailure(string command, string errorCode) =>
WriteJson(new
{
command,
mode = "Test",
preflightValidated = false,
runtimeProcessGateChecked = false,
connectRequestIssued = false,
comActivationAttempted = false,
errorCode,
safety = "Rejected before engine creation; no COM activation was attempted."
});
static void WriteJson<T>(T value) =>
Console.WriteLine(JsonSerializer.Serialize(value, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
static int Usage(string? errorCode)
{
WriteJson(new
{
command = "invalid",
connectRequestIssued = false,
comActivationAttempted = false,
errorCode = errorCode ?? "invalid-command-line"
});
Console.Error.WriteLine(
"Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run|--test-plan|--test-connect|--test-sequence]");
Console.Error.WriteLine(
$"Test commands require {SmokeCommandLine.AcknowledgementFlag} and --config <absolute-path>.");
Console.Error.WriteLine(
"--test-plan and --test-sequence also require --prepare <scene-code> --next <scene-code> --observe-ms <1000..30000>.");
Console.Error.WriteLine(
"Only --test-connect and --test-sequence may request COM, after all Test safety gates pass.");
return 64;
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")]

View File

@@ -0,0 +1,257 @@
namespace MBN_STOCK_WEBVIEW.PlayoutSmoke;
internal enum SmokeCommandKind
{
Invalid,
Probe,
DryRun,
TestPlan,
TestConnect,
TestSequence
}
internal sealed record SmokeCommandInvocation(
SmokeCommandKind Kind,
string? ConfigPath = null,
string? PrepareSceneCode = null,
string? NextSceneCode = null,
int? ObservationMilliseconds = null);
internal sealed record SmokeCommandParseResult(
bool IsValid,
SmokeCommandInvocation Invocation,
string? ErrorCode = null);
internal static class SmokeCommandLine
{
internal const string AcknowledgementFlag =
"--acknowledge-isolated-non-program-test-output";
public static SmokeCommandParseResult Parse(IReadOnlyList<string> arguments)
{
ArgumentNullException.ThrowIfNull(arguments);
if (arguments.Count == 0)
{
return Valid(new SmokeCommandInvocation(SmokeCommandKind.Probe));
}
if (arguments.Count == 1)
{
return arguments[0].ToLowerInvariant() switch
{
"--probe" => Valid(new SmokeCommandInvocation(SmokeCommandKind.Probe)),
"--dry-run" => Valid(new SmokeCommandInvocation(SmokeCommandKind.DryRun)),
_ => Invalid("unknown-command")
};
}
var kind = arguments[0].ToLowerInvariant() switch
{
"--test-plan" => SmokeCommandKind.TestPlan,
"--test-connect" => SmokeCommandKind.TestConnect,
"--test-sequence" => SmokeCommandKind.TestSequence,
_ => SmokeCommandKind.Invalid
};
if (kind == SmokeCommandKind.Invalid)
{
return Invalid("unknown-command");
}
string? configPath = null;
string? prepareSceneCode = null;
string? nextSceneCode = null;
int? observationMilliseconds = null;
var acknowledged = false;
for (var index = 1; index < arguments.Count; index++)
{
var argument = arguments[index];
switch (argument)
{
case AcknowledgementFlag:
if (acknowledged)
{
return Invalid("duplicate-option");
}
acknowledged = true;
break;
case "--config":
if (configPath is not null)
{
return Invalid("duplicate-option");
}
if (!TryReadValue(arguments, ref index, out configPath))
{
return Invalid("missing-option-value");
}
break;
case "--prepare" when kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence:
if (prepareSceneCode is not null)
{
return Invalid("duplicate-option");
}
if (!TryReadValue(arguments, ref index, out prepareSceneCode))
{
return Invalid("missing-option-value");
}
break;
case "--next" when kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence:
if (nextSceneCode is not null)
{
return Invalid("duplicate-option");
}
if (!TryReadValue(arguments, ref index, out nextSceneCode))
{
return Invalid("missing-option-value");
}
break;
case "--observe-ms" when kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence:
if (observationMilliseconds is not null)
{
return Invalid("duplicate-option");
}
if (!TryReadValue(arguments, ref index, out var observationText) ||
!int.TryParse(
observationText,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out var observationValue) ||
observationValue is < 1_000 or > 30_000)
{
return Invalid("invalid-observation-duration");
}
observationMilliseconds = observationValue;
break;
default:
return Invalid("unknown-option");
}
}
if (!acknowledged)
{
return Invalid("missing-acknowledgement");
}
if (string.IsNullOrWhiteSpace(configPath))
{
return Invalid("missing-required-option");
}
if (kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence)
{
if (prepareSceneCode is null || nextSceneCode is null || observationMilliseconds is null)
{
return Invalid("missing-required-option");
}
if (!IsSafeSceneCode(prepareSceneCode) || !IsSafeSceneCode(nextSceneCode))
{
return Invalid("unsafe-scene-code");
}
if (string.Equals(
prepareSceneCode,
nextSceneCode,
StringComparison.OrdinalIgnoreCase))
{
return Invalid("duplicate-scene-code");
}
}
return Valid(new SmokeCommandInvocation(
kind,
configPath,
prepareSceneCode,
nextSceneCode,
observationMilliseconds));
}
internal static bool IsSafeSceneCode(string value)
{
if (value.Length is < 1 or > 64)
{
return false;
}
foreach (var character in value)
{
if (!((character >= 'A' && character <= 'Z') ||
(character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9') ||
character is '_' or '-'))
{
return false;
}
}
return true;
}
internal static bool IsUnsafeTornadoWindowTitle(string? title)
{
if (string.IsNullOrWhiteSpace(title) ||
title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase))
{
return true;
}
const string marker = "TEST";
for (var start = 0; start <= title.Length - marker.Length; start++)
{
if (!title.AsSpan(start, marker.Length).Equals(
marker.AsSpan(),
StringComparison.OrdinalIgnoreCase))
{
continue;
}
var beforeIsBoundary = start == 0 || !char.IsLetterOrDigit(title[start - 1]);
var after = start + marker.Length;
var afterIsBoundary = after == title.Length || !char.IsLetterOrDigit(title[after]);
if (beforeIsBoundary && afterIsBoundary)
{
return false;
}
}
return true;
}
private static bool TryReadValue(
IReadOnlyList<string> arguments,
ref int index,
out string? value)
{
if (index + 1 >= arguments.Count ||
string.IsNullOrWhiteSpace(arguments[index + 1]) ||
arguments[index + 1].StartsWith("--", StringComparison.Ordinal))
{
value = null;
return false;
}
value = arguments[++index];
return true;
}
private static SmokeCommandParseResult Valid(SmokeCommandInvocation invocation) =>
new(true, invocation);
private static SmokeCommandParseResult Invalid(string errorCode) =>
new(false, new SmokeCommandInvocation(SmokeCommandKind.Invalid), errorCode);
}