Add the MBN-branded startup intro, semantic sidebar icons, and refined card-based operator visuals. Strengthen packaged UI and input smoke coverage for the updated experience.
1055 lines
44 KiB
C#
1055 lines
44 KiB
C#
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||
|
||
public sealed class LegacyPackageInputSmokeContractTests
|
||
{
|
||
private static readonly string Runner = File.ReadAllText(Path.Combine(
|
||
FindRepositoryRoot(),
|
||
"scripts",
|
||
"Invoke-LegacyPackageInputSmoke.ps1"));
|
||
private static readonly string Harness = File.ReadAllText(Path.Combine(
|
||
FindRepositoryRoot(),
|
||
"scripts",
|
||
"Test-LegacyPackageInput.mjs"));
|
||
|
||
[Fact]
|
||
public void WindowsInputEvidenceIsMaterializedBeforePowerShellArrayWrapping()
|
||
{
|
||
Assert.Contains(
|
||
"WindowsInput = @($windowsInputEvidence.ToArray())",
|
||
Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain(
|
||
"WindowsInput = @($windowsInputEvidence)",
|
||
Runner,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void NamedPlaylistDoubleClickSealUsesAsciiSafeUtf8Constants()
|
||
{
|
||
Assert.Contains("$ExpectedSavedFixtureGraphicType = $Utf8.GetString(", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("'MeyXtO2MkOq4sOuzuA=='", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("'7ZiE7J6s6rCA'", Runner, StringComparison.Ordinal);
|
||
Assert.Contains("'7Iuc6rCE7Jm464uo7J286rCA'", Runner,
|
||
StringComparison.Ordinal);
|
||
|
||
var doubleClick = Slice(
|
||
Runner,
|
||
"function Invoke-ExactNamedPlaylistDoubleClickRequest",
|
||
"function Get-BackgroundPickerWindowHandles");
|
||
Assert.Contains("$ExpectedSavedFixtureGraphicType", doubleClick,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("$ExpectedSavedFixtureCurrentSubtype", doubleClick,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("$ExpectedSavedFixtureAfterHoursSubtype", doubleClick,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("'1열판기본'", doubleClick,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void PhysicalPointerInputsCaptureGeometryAfterForegroundAcquisition()
|
||
{
|
||
foreach (var (start, end) in new[]
|
||
{
|
||
("public static void SendRowHeaderDragAfterThenHome(",
|
||
"public static void SendShiftClickRangeThenDeleteAndRestore("),
|
||
("public static void SendShiftClickRangeThenDeleteAndRestore(",
|
||
"public static void SendApplicationClick("),
|
||
("public static void SendApplicationClick(",
|
||
"public static void SendApplicationDoubleClick("),
|
||
("public static void SendApplicationDoubleClick(",
|
||
"public static IntPtr ReadForegroundWindow()")
|
||
})
|
||
{
|
||
var input = Slice(Runner, start, end);
|
||
AssertInOrder(
|
||
input,
|
||
"AcquireForeground(window, processId, forbiddenPort);",
|
||
"ClientGeometry baseline = ReadAndAssertClientGeometry(",
|
||
"SleepWithWindowGuard(");
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void PhysicalPointerCoordinatesSupportDpiAwareAndVirtualizedWindowsHosts()
|
||
{
|
||
var geometry = Slice(
|
||
Runner,
|
||
"private static ClientGeometry ReadAndAssertClientGeometry(",
|
||
"private static void AssertStableClientGeometry(");
|
||
foreach (var token in new[]
|
||
{
|
||
"bool deviceMatches =",
|
||
"bool logicalMatches =",
|
||
"if (!deviceMatches && !logicalMatches)",
|
||
"double cssScaleX = client.Right / viewportCssWidth;",
|
||
"double cssScaleY = client.Bottom / viewportCssHeight;",
|
||
"CssScaleX = cssScaleX",
|
||
"CssScaleY = cssScaleY"
|
||
})
|
||
{
|
||
Assert.Contains(token, geometry, StringComparison.Ordinal);
|
||
}
|
||
|
||
var conversion = Slice(
|
||
Runner,
|
||
"private static Point CssPointToScreen(",
|
||
"private static void SendMousePath(");
|
||
Assert.Contains("cssX * geometry.CssScaleX", conversion,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("cssY * geometry.CssScaleY", conversion,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("cssX * devicePixelRatio", conversion,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("cssY * devicePixelRatio", conversion,
|
||
StringComparison.Ordinal);
|
||
|
||
var drag = Slice(
|
||
Runner,
|
||
"public static void SendRowHeaderDragAfterThenHome(",
|
||
"public static void SendShiftClickRangeThenDeleteAndRestore(");
|
||
Assert.Contains("12.0 * baseline.CssScaleX", drag,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("12.0 * devicePixelRatio", drag,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void WorkspaceSplitterUsesThePointerPipelinePersistsAndRestoresItsWidth()
|
||
{
|
||
var splitter = Slice(
|
||
Harness,
|
||
"function assertSplitterResizeResult(",
|
||
"async function ensureWorkspace(");
|
||
AssertInOrder(
|
||
splitter,
|
||
"await pressMouse(start, 0);",
|
||
"await moveMouse(target, 0, 1);",
|
||
"await releaseMouse(target, 0);",
|
||
"async function exerciseWorkspaceSplitter(baseline)",
|
||
"dragWorkspaceSplitterToPercent(",
|
||
"pressKey(keyboardKey, \"resize workspace splitter from keyboard\")",
|
||
"const restoredLayout = await dragWorkspaceSplitterToPercent(",
|
||
"assertFixedSchedule(baseline, restoredLayout",
|
||
"storedPercent: Number.parseFloat(restoredLayout.storedScheduleWidth)");
|
||
Assert.Contains("splitterState?.valueNow", splitter,
|
||
StringComparison.Ordinal);
|
||
Assert.Matches(
|
||
@"Math\.abs\(layout\.splitterState\.valueNow -\s*" +
|
||
@"Number\(layout\.scheduleWidthPercent\.toFixed\(1\)\)\) > 0\.001",
|
||
splitter);
|
||
Assert.DoesNotMatch(
|
||
@"splitterState\??\.valueNow\s*!==\s*" +
|
||
@"Math\.round\(layout\.scheduleWidthPercent\)",
|
||
splitter);
|
||
Assert.Contains("outboundMessages.length", splitter,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("Input.dispatchMouseEvent", splitter,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain(".click(", splitter, StringComparison.Ordinal);
|
||
Assert.DoesNotContain("localStorage.setItem", splitter,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("style.setProperty", splitter,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"ArrowLeft: { key: \"ArrowLeft\", code: \"ArrowLeft\", virtualKey: 37 }",
|
||
Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"ArrowRight: { key: \"ArrowRight\", code: \"ArrowRight\", virtualKey: 39 }",
|
||
Harness,
|
||
StringComparison.Ordinal);
|
||
|
||
var sequence = Slice(
|
||
Harness,
|
||
"async function verifyWorkspaceNavigationAndLayout(preflight)",
|
||
"async function playlistDropGeometry(label)");
|
||
AssertInOrder(
|
||
sequence,
|
||
"const splitterResize = await exerciseWorkspaceSplitter(baseline);",
|
||
"collapse workspace menu",
|
||
"splitterResize,");
|
||
}
|
||
|
||
[Fact]
|
||
public void DryRunFinalSealKeepsWindowsDragOrderAndRequiresTheSafeTailAppend()
|
||
{
|
||
var seal = Slice(
|
||
Runner,
|
||
"$expectedAfterOrder = @(",
|
||
"$expectedFullUiDbPlaylistCount =");
|
||
|
||
Assert.Contains("$expectedFinalOrder = @($expectedAfterOrder)", seal,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("if ($HarnessProfile -ceq 'DryRunPlayout')", seal,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("[string]$evidence.dryRunPlayout.tailAppendedEntryId", seal,
|
||
StringComparison.Ordinal);
|
||
Assert.Equal(
|
||
2,
|
||
seal.Split(
|
||
"Test-ExactStringSequence $statePlaylistIds $expectedFinalOrder",
|
||
StringSplitOptions.None).Length - 1 +
|
||
seal.Split(
|
||
"Test-ExactStringSequence $domPlaylistIds $expectedFinalOrder",
|
||
StringSplitOptions.None).Length - 1);
|
||
Assert.DoesNotContain(
|
||
"Test-ExactStringSequence $statePlaylistIds $expectedAfterOrder",
|
||
seal,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain(
|
||
"Test-ExactStringSequence $domPlaylistIds $expectedAfterOrder",
|
||
seal,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void ReadOnlyPhysicalPlaylistButtonsAndCheckboxUseTheClosedLocalAllowlist()
|
||
{
|
||
var harnessAllowlist = Slice(
|
||
Harness,
|
||
"const readOnlyAllowedOutboundMessageTypes",
|
||
"const fullUiDbAllowedOutboundMessageTypes");
|
||
Assert.Contains("\"move-selected-playlist-rows\"", harnessAllowlist,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("\"set-playlist-enabled\"", harnessAllowlist,
|
||
StringComparison.Ordinal);
|
||
|
||
var runnerAllowlist = Slice(
|
||
Runner,
|
||
"$allowedOutbound = @(",
|
||
"if ($HarnessProfile -ceq 'DryRunPlayout')");
|
||
Assert.Contains("'move-selected-playlist-rows'", runnerAllowlist,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("'set-playlist-enabled'", runnerAllowlist,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void DryRunSealPinsProgramEnableAndShortcutEvidenceExactly()
|
||
{
|
||
var seal = Slice(
|
||
Runner,
|
||
"$playoutIntentContractOk =",
|
||
"$screenshotPathOk =");
|
||
foreach (var token in new[]
|
||
{
|
||
"programEnableParity.currentEntryId",
|
||
"programEnableParity.skippedEntryId",
|
||
"programEnableParity.nextEntryId",
|
||
"programEnableParity.setAllIntentCount -eq 2",
|
||
"programEnableParity.toggleActiveIntentCount -eq 1",
|
||
"programEnableParity.endOfPlaylistObserved -eq $true",
|
||
"programDragParity.appendIntentCount -eq 1",
|
||
"programDragParity.cleanupDeleteIntentCount -eq 1",
|
||
"programDragParity.onAirEntryPreserved -eq $true",
|
||
"shortcutParity.f8IntentCount -eq 1",
|
||
"shortcutParity.escapeIntentCount -eq 1",
|
||
"shortcutParity.finalPhase -ceq 'idle'",
|
||
"[string]$_ -ceq 'set-all-playlist-enabled'",
|
||
"[string]$_ -ceq 'toggle-active-playlist-enabled'"
|
||
})
|
||
{
|
||
Assert.Contains(token, seal, StringComparison.Ordinal);
|
||
}
|
||
|
||
var allowlist = Slice(
|
||
Runner,
|
||
"$allowedOutbound = @(",
|
||
"foreach ($messageType");
|
||
Assert.Contains("'set-all-playlist-enabled'", allowlist,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("'toggle-active-playlist-enabled'", allowlist,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void DryRunPhysicallyCancelsF2PickerAndFailClosesMissingF3Default()
|
||
{
|
||
var nativeInput = Slice(
|
||
Runner,
|
||
"public static void SendBackgroundFunctionKey(",
|
||
"public static void SendEscapeToForegroundWindow");
|
||
Assert.Contains("key != VirtualKeyF2 && key != VirtualKeyF3", nativeInput,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("AssertNoForbiddenTcp(processId, forbiddenPort)", nativeInput,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("allowForegroundTransitionAfterKey", nativeInput,
|
||
StringComparison.Ordinal);
|
||
|
||
var fixture = Slice(
|
||
Runner,
|
||
"function Initialize-BackgroundShortcutFixture",
|
||
"function Assert-NoUnsafeEnvironment");
|
||
Assert.Contains("$Profile -cne 'DryRunPlayout'", fixture,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("$ExpectedInstallLocation", fixture,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("[IO.FileAttributes]::ReparsePoint", fixture,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("The F3 fail-closed test requires the external default background asset to be absent.",
|
||
fixture, StringComparison.Ordinal);
|
||
Assert.Contains("@(Get-ChildItem -LiteralPath $root -Force).Count -ne 0", fixture,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("[IO.Directory]::Delete($root, $false)", fixture,
|
||
StringComparison.Ordinal);
|
||
|
||
var picker = Slice(
|
||
Runner,
|
||
"function Get-BackgroundPickerWindowHandles",
|
||
"function Invoke-ExactWindowsInputRequest");
|
||
foreach (var token in new[]
|
||
{
|
||
"WindowClassName($handle) -cne '#32770'",
|
||
"$BackgroundPickerTitle",
|
||
"WindowProcessId($handle) -ne $Application.Id",
|
||
"OwnerChainContains(",
|
||
"The exact app-owned background picker is not modal.",
|
||
"SendEscapeToForegroundWindow($picker.Handle)",
|
||
"The exact background picker was not destroyed after the one Escape input.",
|
||
"The external default background appeared; the one F3 fail-closed input was not sent."
|
||
})
|
||
{
|
||
Assert.Contains(token, picker, StringComparison.Ordinal);
|
||
}
|
||
|
||
var harness = Slice(
|
||
Harness,
|
||
"async function requestPhysicalBackgroundShortcuts()",
|
||
"async function installProbe()");
|
||
AssertInOrder(
|
||
harness,
|
||
"operation: \"background-f2-picker-cancel\"",
|
||
"[\"choose-background\"]",
|
||
"operation: \"background-f3-none\"",
|
||
"snapshot.state.statusKind === \"error\"",
|
||
"[\"toggle-background\"]",
|
||
"defaultAssetAbsentFailClosed: true");
|
||
|
||
Assert.Contains("return 3", Runner, StringComparison.Ordinal);
|
||
Assert.Contains("backgroundShortcutParity.f2KeyCalls -eq 1", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("backgroundShortcutParity.f3KeyCalls -eq 1", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("backgroundShortcutParity.finalBackgroundEnabled -eq $false", Runner,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void NamedPlaylistRecoveryIsStrictlyScopedForwardedAndSealed()
|
||
{
|
||
Assert.Contains("[string]$RecoverNamedPlaylistTitle", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("'^CDX_P_[0-9]{13}_[0-9a-f]{6}$'", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("$RecoverNamedPlaylistTitle.Length -ne 26", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("$Profile -cne 'FullUiDb'", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("$FullUiDbScope -cne 'PList'", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("$FullUiDbScope -cne 'All'", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("'--recover-named-playlist-title'", Runner,
|
||
StringComparison.Ordinal);
|
||
|
||
var seal = Slice(
|
||
Runner,
|
||
"$expectedRecoveryRequested =",
|
||
"$databaseWriteContractOk =");
|
||
AssertInOrder(
|
||
seal,
|
||
"$recoveryEvidence.targetTitle -cne $ExpectedRecoveryTitle",
|
||
"$recoveryEvidence.freshListVerified -ne $true",
|
||
"$recoveryEvidence.exactAbsenceVerified -ne $true",
|
||
"$sealedRecoveryMatchCount -ne 0",
|
||
"$recoveryEvidence.result -ceq 'alreadyAbsent'",
|
||
"$recoveryEvidence.writeCount -eq 0",
|
||
"$namedPlaylistDeleteWriteCount -eq 1",
|
||
"$recoveryEvidence.result -ceq 'deleted'",
|
||
"$recoveryEvidence.writeCount -eq 1",
|
||
"@('committedFresh', 'committedOptimistic')",
|
||
"$namedPlaylistDeleteWriteCount -eq 2");
|
||
Assert.Contains(
|
||
"namedPlaylistRecovery = [bool]$namedPlaylistRecoveryContractOk",
|
||
Runner,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void NamedPlaylistRecoveryUsesOneConfirmedDeleteWithoutRetryThenRunsCrud()
|
||
{
|
||
Assert.Contains("\"recover-named-playlist-title\"", Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("/^CDX_P_[0-9]{13}_[0-9a-f]{6}$/", Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("recoverNamedPlaylistTitle.length !== 26", Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("new Set([\"all\", \"plist\"])", Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"completedNamedPlaylistRefreshAfter(snapshot, recoveryRefreshBaseline)",
|
||
Harness,
|
||
StringComparison.Ordinal);
|
||
|
||
var recovery = Slice(
|
||
Harness,
|
||
"async function recoverNamedPlaylistFixture",
|
||
"async function executeNamedPlaylistCrud");
|
||
AssertInOrder(
|
||
recovery,
|
||
"if (initialWriteCount !== 0)",
|
||
"PList recovery fresh list open",
|
||
"completedNamedPlaylistRefreshAfter(snapshot, recoveryRefreshBaseline)",
|
||
"current.state.namedPlaylist.isListTruncated !== false",
|
||
"recovery.freshListVerified = true",
|
||
"const exactMatches =",
|
||
"if (exactMatches.length > 1)",
|
||
"if (exactMatches.length === 0)",
|
||
"recovery.result = \"alreadyAbsent\"",
|
||
"PList recovery exact target select",
|
||
"snapshot.state.namedPlaylist.selectedDefinitionId === target.definitionId",
|
||
"PList recovery open existing delete confirmation",
|
||
"snapshot.dom.namedConfirmationHidden === false",
|
||
"PList recovery confirm exact delete once",
|
||
"writeCount === 1",
|
||
"outcome === \"committedFresh\" || outcome === \"committedOptimistic\"",
|
||
"recovery.result = \"deleted\"",
|
||
"recovery.writeCount = recoveryWriteCount",
|
||
"recovery.exactAbsenceVerified = true");
|
||
Assert.Equal(
|
||
1,
|
||
recovery.Split(
|
||
"document.getElementById(\"named-playlist-confirmation-yes\")",
|
||
StringSplitOptions.None).Length - 1);
|
||
Assert.Contains("failUnknown(`Timed out waiting for ${label}; no action was retried.",
|
||
Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("named.isWriteQuarantined === true", Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("named.lastMutationOutcome === \"outcomeUnknown\"", Harness,
|
||
StringComparison.Ordinal);
|
||
|
||
var sequence = Slice(
|
||
Harness,
|
||
"async function executeFullUiDbSequence",
|
||
"try {");
|
||
AssertInOrder(
|
||
sequence,
|
||
"await recoverNamedPlaylistFixture();",
|
||
"await executeNamedPlaylistCrud();");
|
||
}
|
||
|
||
[Fact]
|
||
public void FullUiDbSafetyGateAllowsAndClassifiesEveryManualFinancialWriteIntent()
|
||
{
|
||
var allowed = Slice(
|
||
Harness,
|
||
"const fullUiDbAllowedOutboundMessageTypes",
|
||
"const playoutIntentMessageTypes");
|
||
var databaseWrites = Slice(
|
||
Harness,
|
||
"const databaseWriteIntentMessageTypes",
|
||
"const forbiddenCdpMethods");
|
||
|
||
foreach (var intent in new[]
|
||
{
|
||
"save-manual-financial",
|
||
"save-manual-financial-raw",
|
||
"delete-manual-financial",
|
||
"delete-all-manual-financial"
|
||
})
|
||
{
|
||
Assert.Contains($"\"{intent}\"", allowed, StringComparison.Ordinal);
|
||
Assert.Contains($"\"{intent}\"", databaseWrites, StringComparison.Ordinal);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void FullUiDbCatalogChecksUseTheOriginalDirectInputGestures()
|
||
{
|
||
Assert.Contains("async function addOperatorCatalogStockByDoubleClick", Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("interClickDelayMilliseconds", Harness, StringComparison.Ordinal);
|
||
Assert.Contains("market === \"krx\" ? 350 : 50", Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("await pressKey(\"Delete\"", Harness, StringComparison.Ordinal);
|
||
Assert.Contains("async function dragOperatorCatalogRow", Harness,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("text/x-mbn-operator-catalog-row", Harness,
|
||
StringComparison.Ordinal);
|
||
|
||
var theme = Slice(
|
||
Harness,
|
||
"async function createReadEditDeleteTheme",
|
||
"async function searchAndSelectMainExpert");
|
||
AssertInOrder(
|
||
theme,
|
||
"addOperatorCatalogStockByDoubleClick(",
|
||
"third row Up",
|
||
"third row Down",
|
||
"focus third row for Delete key",
|
||
"pressKey(\"Delete\"");
|
||
|
||
var expert = Slice(
|
||
Harness,
|
||
"async function createReadEditDeleteExpert",
|
||
"async function executeOperatorCatalogCrud");
|
||
AssertInOrder(
|
||
expert,
|
||
"addOperatorCatalogStockByDoubleClick(",
|
||
"second row Up",
|
||
"second row Down",
|
||
"dragOperatorCatalogRow(",
|
||
"focus second row for Delete key",
|
||
"pressKey(\"Delete\"");
|
||
|
||
var fixedCatalog = Slice(
|
||
Harness,
|
||
"async function verifyFixedAndIndustryScreens",
|
||
"async function verifyComparisonScreen");
|
||
Assert.Contains("UC1 fixed parent batch", fixedCatalog,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("fixedSection.actions.length", fixedCatalog,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("beforeSection.safety.outboundMessages.length + 1", fixedCatalog,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("original no-op", fixedCatalog,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void FullUiDbKrxThemeCreatePhysicallyRoundTripsTheMarketWithoutLosingTheName()
|
||
{
|
||
var allowed = Slice(
|
||
Harness,
|
||
"const fullUiDbAllowedOutboundMessageTypes",
|
||
"const playoutIntentMessageTypes");
|
||
Assert.Contains("\"change-create-theme-market\"", allowed,
|
||
StringComparison.Ordinal);
|
||
|
||
var roundTrip = Slice(
|
||
Harness,
|
||
"async function verifyKrxThemeCreateMarketRoundTrip",
|
||
"async function createReadEditDeleteTheme");
|
||
AssertInOrder(
|
||
roundTrip,
|
||
"operatorCatalogEditorMarketValue !== \"krx\"",
|
||
"draftRows?.some(row => row.rowId === draftRowId)",
|
||
"switch editor market to NXT",
|
||
"newThemeMarket === \"nxt\"",
|
||
"editorName === title",
|
||
"draftRows?.length === 0",
|
||
"statusKind === \"warning\"",
|
||
"operatorCatalogNameValue === title",
|
||
"change-create-theme-market",
|
||
"switch editor market back to KRX",
|
||
"newThemeMarket === \"krx\"",
|
||
"operatorCatalogNameValue === title",
|
||
"removedDraftRowId: draftRowId");
|
||
|
||
var create = Slice(
|
||
Harness,
|
||
"async function createReadEditDeleteTheme",
|
||
"async function searchAndSelectMainExpert");
|
||
AssertInOrder(
|
||
create,
|
||
"let added = await addOperatorCatalogStockByDoubleClick(",
|
||
"await verifyKrxThemeCreateMarketRoundTrip(",
|
||
"duplicate recheck after market round-trip",
|
||
"name available after market round-trip",
|
||
"stock re-add after market round-trip",
|
||
"let expectedSavedRows = 1");
|
||
}
|
||
|
||
[Fact]
|
||
public void DryRunProgramParityUsesActualKeysAndTheOriginalEnabledStateSplit()
|
||
{
|
||
var dryRunAllowed = Slice(
|
||
Harness,
|
||
": dryRunPlayoutProfile",
|
||
": readOnlyAllowedOutboundMessageTypes;");
|
||
Assert.Contains("\"set-all-playlist-enabled\"", dryRunAllowed,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("\"toggle-active-playlist-enabled\"", dryRunAllowed,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("\"set-playlist-enabled\"", dryRunAllowed,
|
||
StringComparison.Ordinal);
|
||
|
||
var authorization = Slice(
|
||
Harness,
|
||
"function authorizeDryRunPlayoutShortcut",
|
||
"async function setSearchText");
|
||
AssertInOrder(
|
||
authorization,
|
||
"new Set([\"F8\", \"Escape\"])",
|
||
"const expectedPhase = definitionName === \"F8\" ? \"idle\" : \"program\"",
|
||
"playout.isCommandAvailable !== true",
|
||
"playout.outcomeUnknown === true",
|
||
"playout.isPlayCompletionPending === true",
|
||
"playout.isTakeOutCompletionPending === true",
|
||
"authorization.consumed === true",
|
||
"Date.now() - authorization.issuedAt > 5_000",
|
||
"authorization.consumed = true");
|
||
|
||
var sequence = Slice(
|
||
Harness,
|
||
"async function executeDryRunPlayoutSequence",
|
||
"function recordFullUiDbCheck");
|
||
AssertInOrder(
|
||
sequence,
|
||
"const f8Authorization = authorizeDryRunPlayoutShortcut(",
|
||
"\"rapid DryRun F8 after row selection\"",
|
||
"PROGRAM captured-pointer safe-tail drag",
|
||
"PROGRAM captured-pointer tail append",
|
||
"delete PROGRAM drag-appended future tail",
|
||
"PROGRAM drag-appended tail cleanup",
|
||
"PROGRAM safe-tail double-click append",
|
||
"PROGRAM clear every enabled row",
|
||
"snapshot.state.playlist.every(row => row.isEnabled === false)",
|
||
"snapshot.state.playout.nextKind === \"endOfPlaylist\"",
|
||
"PROGRAM restore every enabled row",
|
||
"snapshot.state.playlist.every(row => row.isEnabled === true)",
|
||
"snapshot.state.playout.nextKind === \"playlistNext\"",
|
||
"pointerClickDisabledPlaylistCheckbox(",
|
||
"checkboxAriaDisabled === true",
|
||
"pressKey(\"Space\"",
|
||
"[\"toggle-active-playlist-enabled\"]",
|
||
"DryRun NEXT skips the Space-disabled future row",
|
||
"currentEntryId === nextEntryId",
|
||
"const escapeAuthorization = authorizeDryRunPlayoutShortcut(",
|
||
"\"DryRun Escape TAKE OUT after NEXT\"",
|
||
"snapshot.state.playout.phase === \"idle\"");
|
||
Assert.Contains("[\"set-all-playlist-enabled\"]", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("mouseCheckboxDisabled: true", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("setAllIntentCount: 2", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("toggleActiveIntentCount: 1", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("programDragParity =", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("onAirEntryPreserved: true", sequence,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void ProgramNamedPlaylistProfilePhysicallyCreatesSavesReadsAndDeletesWhileOnAir()
|
||
{
|
||
Assert.Contains("'ProgramPlaylistDb'", Runner, StringComparison.Ordinal);
|
||
Assert.Contains("'program-playlist-db'", Runner, StringComparison.Ordinal);
|
||
Assert.Contains("\"program-playlist-db\"", Harness, StringComparison.Ordinal);
|
||
|
||
var profile = Slice(
|
||
Harness,
|
||
"const programPlaylistDbProfile",
|
||
"const hardDeadlineEpochMilliseconds");
|
||
AssertInOrder(
|
||
profile,
|
||
"playout-background-file",
|
||
"playout-background-none",
|
||
"playout-prepare",
|
||
"playout-next",
|
||
"db-load-button");
|
||
foreach (var allowed in new[]
|
||
{
|
||
"create-named-playlist",
|
||
"save-current-named-playlist-to",
|
||
"delete-selected-named-playlist",
|
||
"dismiss-dialog",
|
||
"take-in",
|
||
"take-out"
|
||
})
|
||
{
|
||
Assert.Contains($"\"{allowed}\"", profile, StringComparison.Ordinal);
|
||
}
|
||
|
||
var sequence = Slice(
|
||
Harness,
|
||
"async function executeProgramNamedPlaylistCrud",
|
||
"const manualFinancialScreens");
|
||
AssertInOrder(
|
||
sequence,
|
||
"enter DryRun PROGRAM for named-playlist persistence",
|
||
"PROGRAM open named-playlist save dialog",
|
||
"PROGRAM create named-playlist definition",
|
||
"PROGRAM confirm named-playlist row save",
|
||
"PROGRAM reopen save dialog for fresh readback",
|
||
"PROGRAM named-playlist fresh readback",
|
||
"PROGRAM confirm named-playlist delete",
|
||
"PROGRAM named-playlist delete and absence",
|
||
"leave DryRun PROGRAM after named-playlist CRUD",
|
||
"PROGRAM named-playlist final IDLE");
|
||
Assert.DoesNotContain(
|
||
"document.getElementById(\"db-load-button\")",
|
||
sequence,
|
||
StringComparison.Ordinal);
|
||
var safeSequence = Slice(
|
||
Harness,
|
||
"async function executeSafeSequence",
|
||
"async function executeDryRunPlayoutSequence");
|
||
Assert.Contains("if (!programPlaylistDbProfile)", safeSequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("document.getElementById(\"db-load-button\")", safeSequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("writes.length !== 3", sequence, StringComparison.Ordinal);
|
||
Assert.Contains("\"저장되었습니다\"", sequence, StringComparison.Ordinal);
|
||
Assert.DoesNotContain("??λ릺?덉뒿?덈떎", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("definitionId: readback.definitionId", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("saveDefinitionId: created.definitionId", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"snapshot.state.namedPlaylist.documentFreshness === \"fresh\"",
|
||
sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"snapshot.state.namedPlaylist.listFreshness === \"fresh\"",
|
||
sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"snapshot.state.namedPlaylist.documentFreshness === \"none\"",
|
||
sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("snapshot.state.namedPlaylist.rowCount === 0", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("rowCount: savedRowCount", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("freshDefinitionReadbackVerified: true", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("exactAbsenceVerified: true", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("beforeProgramDeleteWriteCount", sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("waitForNamedPlaylistDeleteDispatch(", sequence,
|
||
StringComparison.Ordinal);
|
||
var finalIdle = Slice(
|
||
sequence,
|
||
"PROGRAM named-playlist final IDLE",
|
||
"restore deletion selection after PROGRAM named-playlist CRUD");
|
||
Assert.Contains("allowActiveDryRun: true", finalIdle,
|
||
StringComparison.Ordinal);
|
||
|
||
var deleteDispatch = Slice(
|
||
Harness,
|
||
"async function waitForNamedPlaylistDeleteDispatch",
|
||
"function assertNamedPlaylistRecoverySafe");
|
||
Assert.Contains("Date.now() + 5_000", deleteDispatch,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("writeCount === 1", deleteDispatch,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("produced no delete intent", deleteDispatch,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("failKnown", deleteDispatch, StringComparison.Ordinal);
|
||
Assert.Contains("failUnknown", deleteDispatch, StringComparison.Ordinal);
|
||
|
||
var seal = Slice(
|
||
Runner,
|
||
"elseif ($HarnessProfile -ceq 'ProgramPlaylistDb')",
|
||
"$playoutIntentContractOk =");
|
||
foreach (var token in new[]
|
||
{
|
||
"approvedDatabaseWriteMessages).Count -eq 3",
|
||
"create-named-playlist",
|
||
"save-current-named-playlist-to",
|
||
"delete-selected-named-playlist",
|
||
"saveDefinitionId",
|
||
"definitionId",
|
||
"requestId",
|
||
"freshDefinitionReadbackVerified -eq $true",
|
||
"exactAbsenceVerified -eq $true",
|
||
"finalPhase -ceq 'idle'"
|
||
})
|
||
{
|
||
Assert.Contains(token, seal, StringComparison.Ordinal);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void CatalogBlankStockSearchUsesButtonAndEnterAndSealsTheBoundedDisplay()
|
||
{
|
||
var boundedSearch = Slice(
|
||
Harness,
|
||
"async function verifyBlankOperatorCatalogStockSearch",
|
||
"async function addOperatorCatalogStock");
|
||
AssertInOrder(
|
||
boundedSearch,
|
||
"replaceText(",
|
||
"`${label} blank query`",
|
||
"trigger === \"button\"",
|
||
"operator-catalog-stock-search",
|
||
"pressKey(\"Enter\"",
|
||
"currentCatalog?.stockQuery === \"\"",
|
||
"resultCount > 1 && resultCount <= 10_000",
|
||
"statusMessage === \"종목 검색을 완료했습니다.\"",
|
||
"operatorCatalogStockResultCount === resultCount",
|
||
"[\"search-operator-catalog-stocks\"]",
|
||
"messages[0].emptyStockQuery !== true",
|
||
"resultCount !== 10_000 || !truncationVisible",
|
||
"draftRowsPreserved: true");
|
||
|
||
var theme = Slice(
|
||
Harness,
|
||
"async function createReadEditDeleteTheme",
|
||
"async function searchAndSelectMainExpert");
|
||
AssertInOrder(
|
||
theme,
|
||
"`${label} ThemeA editor`",
|
||
"addOperatorCatalogStockByDoubleClick(");
|
||
|
||
var expert = Slice(
|
||
Harness,
|
||
"async function createReadEditDeleteExpert",
|
||
"async function executeOperatorCatalogCrud");
|
||
AssertInOrder(
|
||
expert,
|
||
"snapshot.state.operatorCatalog?.mode === \"edit\"",
|
||
"`${label} existing UC6 editor`",
|
||
"addOperatorCatalogStockByDoubleClick(");
|
||
}
|
||
|
||
[Fact]
|
||
public void PhysicalPlaylistExchangeShiftSelectsDeletesAndRestoresTheExactThreeRows()
|
||
{
|
||
var request = Slice(
|
||
Harness,
|
||
"async function requestWindowsInput(",
|
||
"async function installProbe");
|
||
AssertInOrder(
|
||
request,
|
||
"beforeOrder.length !== 5",
|
||
"reorderedOrder.at(-2) !== rangeStartRowId",
|
||
"const afterOrder = reorderedOrder.filter",
|
||
"row-header-drag-home-shift-range-delete-restore",
|
||
"expectedRangeSelectedRowIds: [rangeStartRowId, rangeEndRowId]",
|
||
"mouseDownCalls: 4",
|
||
"shiftKeyDownCalls: 1",
|
||
"shiftKeyUpCalls: 1",
|
||
"deleteKeyCalls: 1",
|
||
"delete-selected-playlist-rows",
|
||
"selections[1].shift !== true",
|
||
"shiftRangeDeletedRowIds = [rangeStartRowId, rangeEndRowId]");
|
||
Assert.Contains("neutral-pointer guard", request, StringComparison.Ordinal);
|
||
Assert.Contains("await moveMouse(neutralPoint)", request,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("neutralSignature !== guardSignature", request,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("Input.dispatchMouseEvent", request,
|
||
StringComparison.Ordinal);
|
||
|
||
var native = Slice(
|
||
Runner,
|
||
"public static void SendShiftClickRangeThenDeleteAndRestore(",
|
||
"public static void SendApplicationClick(");
|
||
AssertInOrder(
|
||
native,
|
||
"SendKeyboard(VirtualKeyShift, false)",
|
||
"SendMouseButton(MouseLeftDown)",
|
||
"SendKeyboard(VirtualKeyShift, true)",
|
||
"SendKeyboard(VirtualKeyDelete, false)",
|
||
"SendKeyboard(VirtualKeyDelete, true)",
|
||
"SendMouseMove(restore.X, restore.Y)");
|
||
}
|
||
|
||
[Fact]
|
||
public void ReadOnlyPhysicalSequenceDirectlyClicksPlaylistButtonsAndIndividualCheckbox()
|
||
{
|
||
var sequence = Slice(
|
||
Harness,
|
||
"async function executeSafeSequence",
|
||
"async function executeDryRunPlayoutSequence");
|
||
|
||
AssertInOrder(
|
||
sequence,
|
||
"move selected playlist row with Up button",
|
||
"playlist Up button reorder",
|
||
"restore selected playlist row with Down button",
|
||
"playlist Down button reorder",
|
||
"disable playlist row with its checkbox",
|
||
"individual playlist checkbox disabled row",
|
||
"restore playlist row with its checkbox",
|
||
"individual playlist checkbox restored row",
|
||
"stage direct selected-delete button row",
|
||
"select direct button delete fixture",
|
||
"delete selected playlist row with button",
|
||
"selected-delete button removed exact fixture");
|
||
Assert.Contains(
|
||
"document.getElementById(\"playlist-move-up\")",
|
||
sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"document.getElementById(\"playlist-move-down\")",
|
||
sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"document.getElementById(\"playlist-delete-selected\")",
|
||
sequence,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"input[type=\"checkbox\"]",
|
||
sequence,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void FullUiDbPListUsesASecondPhysicalDoubleClickExchangeWithContentRestoration()
|
||
{
|
||
var request = Slice(
|
||
Harness,
|
||
"async function requestNamedPlaylistLoadDoubleClick",
|
||
"function namedPlaylistDeleteWriteCount");
|
||
AssertInOrder(
|
||
request,
|
||
"configuration.windowsInputExchangeCount !== 2",
|
||
"DB 재생목록 불러오기",
|
||
"const beforeContent = playlistContentProjection(current)",
|
||
"named-playlist-load-double-click",
|
||
"mouseDownCalls: 2",
|
||
"doubleClickIntervalMilliseconds: 100",
|
||
"load-named-playlist-by-id",
|
||
"JSON.stringify(playlistContentProjection(snapshot))",
|
||
"loads.length !== 1",
|
||
"approvedDatabaseWriteMessages.length !== beforeApprovedWriteCount",
|
||
"contentRestored: true");
|
||
Assert.DoesNotContain("pointerDoubleClick", request,
|
||
StringComparison.Ordinal);
|
||
Assert.DoesNotContain("Input.dispatchMouseEvent", request,
|
||
StringComparison.Ordinal);
|
||
|
||
var runnerExchange = Slice(
|
||
Runner,
|
||
"function Get-ExpectedWindowsInputExchangeCount",
|
||
"function Invoke-NodeHarnessUnderTcpWatch");
|
||
AssertInOrder(
|
||
runnerExchange,
|
||
"return 2",
|
||
"named-playlist-load-double-click",
|
||
"Invoke-ExactNamedPlaylistDoubleClickRequest",
|
||
"Write-WindowsInputAcknowledgement");
|
||
Assert.Contains("public static void SendApplicationDoubleClick(", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("for (int click = 0; click < 2; click++)", Runner,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("PList physical double-click fresh readback", Runner,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public void StartupIntroMustBeDismissedBeforeThePhysicalInputHarnessStarts()
|
||
{
|
||
var wait = Slice(
|
||
Runner,
|
||
"function Wait-StartupIntroDismissed",
|
||
"function Assert-NoExistingContentDialog");
|
||
Assert.Contains("[datetime]::UtcNow.AddSeconds(4)", wait,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("매일경제TV V-STOCK 시작 화면", wait,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("[Windows.Automation.PropertyCondition]::new(", wait,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("$intro.Current.IsOffscreen", wait,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("Assert-ExactApplicationStillRunning", wait,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("Assert-NoTornadoConnection", wait,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("remained visible beyond its four-second bound", wait,
|
||
StringComparison.Ordinal);
|
||
|
||
var launch = Slice(
|
||
Runner,
|
||
"$script:Phase = 'package launch'",
|
||
"$harnessRun = Invoke-NodeHarnessUnderTcpWatch");
|
||
AssertInOrder(
|
||
launch,
|
||
"Wait-ExactLoopbackListener",
|
||
"Wait-StartupIntroDismissed",
|
||
"$script:Phase = 'real input harness'");
|
||
}
|
||
|
||
[Fact]
|
||
public void DebugAppXMustMatchTheLatestMsixAndCurrentWebSourceBeforeLaunch()
|
||
{
|
||
var guard = Slice(
|
||
Runner,
|
||
"function Assert-DebugAppXMatchesLatestPackage",
|
||
"function Get-ExactDevelopmentPackage");
|
||
|
||
Assert.Contains("$BuildFlavor -cne 'DebugAppX'", guard,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("[IO.Compression.ZipFile]::OpenRead($msix)", guard,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("'AppxManifest.xml'", guard, StringComparison.Ordinal);
|
||
Assert.Contains("'Assets/StartupHero.png'", guard, StringComparison.Ordinal);
|
||
Assert.Contains("'Web/app.js'", guard, StringComparison.Ordinal);
|
||
Assert.Contains("'Web/Brand/logo.png'", guard, StringComparison.Ordinal);
|
||
Assert.Contains("'Web/index.html'", guard, StringComparison.Ordinal);
|
||
Assert.Contains("'Web/playout-ui.js'", guard, StringComparison.Ordinal);
|
||
Assert.Contains("'Web/styles.css'", guard, StringComparison.Ordinal);
|
||
Assert.Contains("'Web/workspace-navigation.js'", guard,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("Get-StreamSha256Hex $entryStream", guard,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("registered Debug AppX is stale", guard,
|
||
StringComparison.Ordinal);
|
||
foreach (var relativeSource in new[]
|
||
{
|
||
"Web\\app.js",
|
||
"Web\\index.html",
|
||
"Web\\styles.css",
|
||
"Web\\workspace-navigation.js"
|
||
})
|
||
{
|
||
Assert.Contains(relativeSource, guard, StringComparison.Ordinal);
|
||
}
|
||
Assert.Contains("$sourcePath = Join-Path $projectRoot $relativeSource", guard,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains("$installedPath = Join-Path $InstallLocation $relativeSource", guard,
|
||
StringComparison.Ordinal);
|
||
Assert.Contains(
|
||
"Assert-DebugAppXMatchesLatestPackage ([string]$package.InstallLocation)",
|
||
Runner,
|
||
StringComparison.Ordinal);
|
||
}
|
||
|
||
private static string Slice(string source, string start, string end)
|
||
{
|
||
var startIndex = source.IndexOf(start, StringComparison.Ordinal);
|
||
var endIndex = source.IndexOf(end, startIndex, StringComparison.Ordinal);
|
||
Assert.True(startIndex >= 0 && endIndex > startIndex);
|
||
return source[startIndex..endIndex];
|
||
}
|
||
|
||
private static void AssertInOrder(string source, params string[] markers)
|
||
{
|
||
var cursor = 0;
|
||
foreach (var marker in markers)
|
||
{
|
||
var index = source.IndexOf(marker, cursor, StringComparison.Ordinal);
|
||
Assert.True(index >= cursor, $"Marker was missing or out of order: {marker}");
|
||
cursor = index + marker.Length;
|
||
}
|
||
}
|
||
|
||
private static string FindRepositoryRoot()
|
||
{
|
||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||
while (directory is not null)
|
||
{
|
||
if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
||
{
|
||
return directory.FullName;
|
||
}
|
||
|
||
directory = directory.Parent;
|
||
}
|
||
|
||
throw new DirectoryNotFoundException("Repository root was not found.");
|
||
}
|
||
}
|