362 lines
16 KiB
C#
362 lines
16 KiB
C#
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
|
|
|
public sealed class LegacyGateAAppIntegrationContractTests
|
|
{
|
|
private static readonly string RepositoryRoot = FindRepositoryRoot();
|
|
private static readonly string AppRoot = Path.Combine(
|
|
RepositoryRoot,
|
|
"src",
|
|
"MBN_STOCK_WEBVIEW.LegacyParityApp");
|
|
private static readonly string Window = Read(AppRoot, "MainWindow.xaml.cs");
|
|
private static readonly string Playout = Read(AppRoot, "MainWindow.Playout.cs");
|
|
private static readonly string IntentParser = Read(
|
|
RepositoryRoot,
|
|
"src",
|
|
"MBN_STOCK_WEBVIEW.LegacyBridge",
|
|
"LegacyPlayoutUiIntents.cs");
|
|
private static readonly string Factory = Read(
|
|
RepositoryRoot,
|
|
"src",
|
|
"MBN_STOCK_WEBVIEW.Playout",
|
|
"PlayoutEngineFactory.cs");
|
|
private static readonly string LaunchAuthorization = Read(
|
|
RepositoryRoot,
|
|
"src",
|
|
"MBN_STOCK_WEBVIEW.Playout",
|
|
"Safety",
|
|
"PlayoutLaunchAuthorization.cs");
|
|
private static readonly string InfrastructureRoot = Path.Combine(
|
|
RepositoryRoot,
|
|
"src",
|
|
"MBN_STOCK_WEBVIEW.Infrastructure",
|
|
"Execution");
|
|
|
|
[Fact]
|
|
public void Launch_authorization_is_captured_before_WebView_creation_and_forwarded_unchanged()
|
|
{
|
|
var constructor = Slice(
|
|
Window,
|
|
" public MainWindow()",
|
|
" private async void OnRootLoaded(");
|
|
var capture = RequiredIndex(
|
|
constructor,
|
|
"PlayoutLaunchAuthorization.CaptureFromEnvironment()");
|
|
var initializeComponent = RequiredIndex(constructor, "InitializeComponent();");
|
|
|
|
Assert.True(capture < initializeComponent);
|
|
Assert.Contains(
|
|
"_playoutLaunchAuthorization =\n" +
|
|
" PlayoutLaunchAuthorization.CaptureFromEnvironment();",
|
|
Normalize(constructor),
|
|
StringComparison.Ordinal);
|
|
|
|
var initialization = Normalize(Slice(
|
|
Playout,
|
|
" private void InitializePlayoutRuntime()",
|
|
" private async Task ConnectPlayoutAsync("));
|
|
Assert.Contains(
|
|
"_playoutEngine = PlayoutEngineFactory.Create(\n" +
|
|
" _playoutOptions,\n" +
|
|
" _playoutLaunchAuthorization);",
|
|
initialization,
|
|
StringComparison.Ordinal);
|
|
|
|
var factoryCreate = Slice(
|
|
Normalize(Factory),
|
|
" public static IPlayoutEngine Create(\n" +
|
|
" PlayoutOptions options,\n" +
|
|
" PlayoutLaunchAuthorization launchAuthorization)",
|
|
" internal static void ValidateGateAOptions(");
|
|
Assert.Contains("dispatcher,\n launchAuthorization,", factoryCreate,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("launchAuthorization: launchAuthorization", factoryCreate,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate_A_has_a_dedicated_capability_intent_and_exact_Samsung_5001_prepare_handler()
|
|
{
|
|
Assert.Contains(
|
|
"public sealed record LegacyGateAPrepareIntent(string Capability)",
|
|
IntentParser,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("\"gate-a-prepare\" => ParseGateAPrepare(payload)", IntentParser,
|
|
StringComparison.Ordinal);
|
|
var parser = Slice(
|
|
IntentParser,
|
|
" private static LegacyUiIntent? ParseGateAPrepare(JsonElement payload)",
|
|
" private static LegacyUiIntent? ParseFadeDuration(JsonElement payload)");
|
|
Assert.Contains("payload.EnumerateObject().Count() != 1", parser,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("capability.Length != 64", parser, StringComparison.Ordinal);
|
|
Assert.Contains("not (>= 'A' and <= 'F')", parser, StringComparison.Ordinal);
|
|
|
|
var dispatch = Normalize(Slice(
|
|
Window,
|
|
" private async Task<LegacyOperatorSnapshot> HandleIntentAsync(",
|
|
" private async Task<LegacyOperatorSnapshot> RefreshNamedPlaylistsForDispatchAsync("));
|
|
Assert.Contains(
|
|
"LegacyGateAPrepareIntent prepare =>\n" +
|
|
" await ExecuteGateAPrepareAsync(\n" +
|
|
" prepare.Capability,",
|
|
dispatch,
|
|
StringComparison.Ordinal);
|
|
|
|
var handler = Slice(
|
|
Playout,
|
|
" private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(",
|
|
" private bool TryCreateExactGateAPrepareRequest(");
|
|
Assert.Contains("TryClaimGateAPrepare(capability)", handler,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("TryCreateExactGateAPrepareRequest(out var request)", handler,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("gateAPrepareClaimed: true", handler, StringComparison.Ordinal);
|
|
Assert.Contains("gateAPrepareRequest: request", handler, StringComparison.Ordinal);
|
|
Assert.Contains("CompleteGateAPrepare()", handler, StringComparison.Ordinal);
|
|
|
|
var exact = Slice(
|
|
Playout,
|
|
" private bool TryCreateExactGateAPrepareRequest(",
|
|
" private async Task<LegacyOperatorSnapshot> SetFadeDurationAsync(");
|
|
foreach (var required in new[]
|
|
{
|
|
"snapshot.Playlist.Count != 1",
|
|
"composition.FadeDuration != 6",
|
|
"composition.BackgroundKind != LegacySceneBackgroundKind.None",
|
|
"row.MarketText, \"코스피\"",
|
|
"row.StockName, \"삼성전자\"",
|
|
"row.PageText, \"1/1\"",
|
|
"row.StockCode, \"005930\"",
|
|
"_controller.CreatePlayoutRequest(6)",
|
|
"candidate.SelectedIndexZeroBased != 0",
|
|
"entry.CutCode, \"5001\"",
|
|
"entry.FadeDuration != 6",
|
|
"entry.PageNavigation?.IsEnabled == true",
|
|
"selection.GroupCode, \"코스피\"",
|
|
"selection.Subject, \"삼성전자\"",
|
|
"selection.DataCode, \"005930\""
|
|
})
|
|
{
|
|
Assert.Contains(required, exact, StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Ordinary_playout_intents_are_rejected_in_Gate_A_before_workflow_access()
|
|
{
|
|
var execute = Slice(
|
|
Playout,
|
|
" private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(",
|
|
" private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(");
|
|
var workflowRead = RequiredIndex(execute, "var workflow = _playoutWorkflow;");
|
|
var preWorkflowGuard = execute[..workflowRead];
|
|
|
|
Assert.Contains("_playoutLaunchAuthorization.IsGateA", preWorkflowGuard,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("!gateAPrepareClaimed", preWorkflowGuard, StringComparison.Ordinal);
|
|
Assert.Contains("command != LegacyOperatorPlayoutCommand.Prepare", preWorkflowGuard,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("gateAPrepareRequest is null", preWorkflowGuard,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("return _controller.ReportError(", preWorkflowGuard,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("_playoutWorkflow", preWorkflowGuard,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("CreatePlayoutRequest", preWorkflowGuard,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("_playoutCommandGate.WaitAsync", preWorkflowGuard,
|
|
StringComparison.Ordinal);
|
|
|
|
var dispatch = Slice(
|
|
Window,
|
|
" private async Task<LegacyOperatorSnapshot> HandleIntentAsync(",
|
|
" private async Task<LegacyOperatorSnapshot> RefreshNamedPlaylistsForDispatchAsync(");
|
|
Assert.Contains("LegacyExecutePlayoutIntent playout =>", dispatch,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("ExecuteOperatorPlayoutAsync(\n playout.Command,",
|
|
Normalize(dispatch), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void All_database_mutation_executors_receive_the_same_launch_denial_policy()
|
|
{
|
|
var constructor = Slice(
|
|
Window,
|
|
" public MainWindow()",
|
|
" private async void OnRootLoaded(");
|
|
Assert.Contains(
|
|
"new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization)",
|
|
constructor,
|
|
StringComparison.Ordinal);
|
|
Assert.True(
|
|
CountOccurrences(
|
|
constructor,
|
|
"mutationAuthorization: databaseMutationAuthorization") == 3,
|
|
"The same launch authorization must be injected into all three DB mutation executors.");
|
|
|
|
var adapter = Slice(
|
|
Window,
|
|
" private sealed class LaunchDatabaseMutationAuthorization",
|
|
" private void PostState(");
|
|
Assert.Contains(": IDatabaseMutationAuthorization", adapter,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("return _authorization.AllowsDatabaseWrites;", adapter,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("_authorization = authorization ??", adapter,
|
|
StringComparison.Ordinal);
|
|
|
|
foreach (var executorFile in new[]
|
|
{
|
|
"OracleManualFinancialMutationExecutor.cs",
|
|
"OracleNamedPlaylistMutationExecutor.cs",
|
|
"OperatorCatalogMutationExecutor.cs"
|
|
})
|
|
{
|
|
var executor = File.ReadAllText(Path.Combine(InfrastructureRoot, executorFile));
|
|
var demand = RequiredIndex(executor, "DatabaseMutationAuthorizationGuard.Demand(");
|
|
var open = RequiredIndex(executor, "await connection.OpenAsync(");
|
|
Assert.True(demand < open, $"{executorFile} must deny before opening a DB connection.");
|
|
Assert.Contains("_mutationAuthorization", executor, StringComparison.Ordinal);
|
|
Assert.Contains("IDatabaseMutationAuthorization mutationAuthorization", executor,
|
|
StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate_A_blocks_persistent_write_intents_before_dispatch()
|
|
{
|
|
var process = Normalize(Slice(
|
|
Window,
|
|
" private async Task ProcessIntentAsync(LegacyUiIntent intent)",
|
|
" private static bool IsNamedPlaylistModalIntent("));
|
|
var guard = RequiredIndex(
|
|
process,
|
|
"_playoutLaunchAuthorization.IsGateA &&\n IsPersistentWriteIntent(intent)");
|
|
var dispatchStarted = RequiredIndex(process, "dispatchStarted = true;");
|
|
var dispatch = RequiredIndex(process, "HandleIntentAsync(intent");
|
|
Assert.True(guard < dispatchStarted);
|
|
Assert.True(guard < dispatch);
|
|
|
|
var guardEnd = process.IndexOf("return;", guard, StringComparison.Ordinal);
|
|
Assert.True(guardEnd > guard);
|
|
Assert.Contains("ReportIntentFailure(", process[guard..guardEnd],
|
|
StringComparison.Ordinal);
|
|
|
|
var persistent = Slice(
|
|
Playout,
|
|
" private static bool IsPersistentWriteIntent(LegacyUiIntent intent)",
|
|
" private static bool IsFixedSectionBatchMutationIntent(");
|
|
foreach (var intent in new[]
|
|
{
|
|
"LegacyDeleteUc4SelectedThemeIntent",
|
|
"LegacyDeleteUc6SelectedExpertIntent",
|
|
"LegacySaveOperatorCatalogIntent",
|
|
"LegacyConfirmNativeDialogIntent",
|
|
"LegacyAddComparisonPairIntent",
|
|
"LegacySaveManualFinancialIntent",
|
|
"LegacySaveManualNetSellIntent",
|
|
"LegacySaveManualViIntent",
|
|
"LegacyImportManualListsIntent",
|
|
"LegacyCreateNamedPlaylistIntent",
|
|
"LegacySaveCurrentNamedPlaylistIntent",
|
|
"LegacyDeleteSelectedNamedPlaylistIntent"
|
|
})
|
|
{
|
|
Assert.Contains(intent, persistent, StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate_A_blocks_fade_and_background_mutation_before_any_command_gate()
|
|
{
|
|
Assert.Contains("public bool AllowsCompositionMutation => !IsGateA;",
|
|
LaunchAuthorization, StringComparison.Ordinal);
|
|
|
|
var fade = Slice(
|
|
Playout,
|
|
" private async Task<LegacyOperatorSnapshot> SetFadeDurationAsync(",
|
|
" private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh(");
|
|
AssertCompositionMutationGuard(
|
|
fade,
|
|
"Gate A 검증 회차에서는 Fade 설정을 변경할 수 없습니다.");
|
|
|
|
var toggle = Slice(
|
|
Playout,
|
|
" private async Task<LegacyOperatorSnapshot> ToggleBackgroundAsync(",
|
|
" private async Task<LegacyOperatorSnapshot> ChooseBackgroundAsync(");
|
|
AssertCompositionMutationGuard(
|
|
toggle,
|
|
"Gate A 검증 회차에서는 배경 설정을 변경할 수 없습니다.");
|
|
|
|
var choose = Slice(
|
|
Playout,
|
|
" private async Task<LegacyOperatorSnapshot> ChooseBackgroundAsync(",
|
|
" private bool CanChangeComposition()");
|
|
AssertCompositionMutationGuard(
|
|
choose,
|
|
"Gate A 검증 회차에서는 배경 파일을 선택할 수 없습니다.");
|
|
}
|
|
|
|
private static void AssertCompositionMutationGuard(string method, string message)
|
|
{
|
|
var authorization = RequiredIndex(
|
|
method,
|
|
"if (!_playoutLaunchAuthorization.AllowsCompositionMutation)");
|
|
var commandGate = RequiredIndex(method, "_playoutCommandGate.WaitAsync");
|
|
Assert.True(authorization < commandGate);
|
|
Assert.Contains(message, method[authorization..commandGate],
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("return _controller.ReportError(", method[authorization..commandGate],
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
private static int CountOccurrences(string source, string marker)
|
|
{
|
|
var count = 0;
|
|
var index = 0;
|
|
while ((index = source.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
|
|
{
|
|
count++;
|
|
index += marker.Length;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
private static string Slice(string source, string startMarker, string endMarker)
|
|
{
|
|
var start = RequiredIndex(source, startMarker);
|
|
var end = RequiredIndex(source, endMarker, start + startMarker.Length);
|
|
return source[start..end];
|
|
}
|
|
|
|
private static int RequiredIndex(string source, string marker, int startIndex = 0)
|
|
{
|
|
var index = source.IndexOf(marker, startIndex, StringComparison.Ordinal);
|
|
Assert.True(index >= 0, $"Required source marker was not found: {marker}");
|
|
return index;
|
|
}
|
|
|
|
private static string Normalize(string source) =>
|
|
source.Replace("\r\n", "\n", StringComparison.Ordinal);
|
|
|
|
private static string Read(params string[] parts) =>
|
|
File.ReadAllText(Path.Combine(parts));
|
|
|
|
private static string FindRepositoryRoot()
|
|
{
|
|
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (current is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
|
{
|
|
return current.FullName;
|
|
}
|
|
|
|
current = current.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Repository root was not found.");
|
|
}
|
|
}
|