+ 매일경제TV
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css
index 8391bd1..fb9b9b9 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css
@@ -945,6 +945,23 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--ui-accent); }
background: #fff;
box-shadow: 0 0 0 1px rgba(15, 23, 42, .08), 0 1px 2px rgba(15, 23, 42, .08);
}
+.brand-wordmark-fallback,
+.brand-wordmark {
+ grid-area: 1 / 1;
+}
+.brand-wordmark-fallback {
+ visibility: hidden;
+ color: #172033;
+ font-size: 16px;
+ font-weight: 800;
+ letter-spacing: -.045em;
+}
+.brand-wordmark-plate.wordmark-fallback-active .brand-wordmark-fallback {
+ visibility: visible;
+}
+.brand-wordmark-plate.wordmark-fallback-active .brand-wordmark {
+ display: none;
+}
.brand-wordmark {
display: block;
width: 142px;
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js
index 3dcf19f..b16b1ad 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js
@@ -17,6 +17,19 @@
const shell = document.querySelector(".operator-shell");
const splitter = document.getElementById("workspace-splitter");
const playlistPanel = document.querySelector(".playlist-panel");
+ const brandWordmark = document.querySelector("img.brand-wordmark");
+ const brandWordmarkPlate = brandWordmark?.closest(".brand-wordmark-plate");
+
+ function showBrandWordmarkFallback() {
+ brandWordmarkPlate?.classList.add("wordmark-fallback-active");
+ }
+
+ if (brandWordmark) {
+ brandWordmark.addEventListener("error", showBrandWordmarkFallback, { once: true });
+ if (brandWordmark.complete && brandWordmark.naturalWidth === 0) {
+ showBrandWordmarkFallback();
+ }
+ }
if (!region || !navigation || !navigationItems || !toggle || !toggleLabel ||
!stockTab || !settingsTab || !stockWorkspace || !catalogWorkspace ||
diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs
index c8d4f3d..ec50386 100644
--- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs
+++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs
@@ -22,8 +22,17 @@ public static class PlayoutOptionsLoader
public static PlayoutOptions Load(
string? path = null,
string? operatorSceneDirectory = null,
- string? operatorBackgroundDirectory = null)
+ string? operatorBackgroundDirectory = null,
+ bool forceSafeDryRun = false)
{
+ // Source-only builds are for code/UI review on machines without the
+ // approved runtime tree. Do not consume a previous machine-local playout
+ // profile or process override in that build flavor.
+ if (forceSafeDryRun)
+ {
+ return new PlayoutOptions();
+ }
+
var options = LoadJson(path ?? DefaultPath);
ApplyOperatorAssetOverrides(
options,
diff --git a/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs b/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs
index b22a648..f8b838b 100644
--- a/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs
+++ b/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs
@@ -204,6 +204,12 @@ test("appearance controls and accessibility preferences retain clear feedback",
test("workspace header keeps one concise title and the live native connection state", () => {
assert.match(markup,
/class="brand"[\s\S]*?class="brand-wordmark-plate"[\s\S]*?

[\s\S]*?class="brand-divider" aria-hidden="true"[\s\S]*?class="brand-product">V-STOCK<\/span>/);
+ assert.match(markup,
+ /class="brand-wordmark-fallback">매일경제TV<\/span>/);
+ assert.match(styles,
+ /\.brand-wordmark-plate\.wordmark-fallback-active \.brand-wordmark-fallback\s*\{[^}]*visibility:\s*visible/);
+ assert.match(styles,
+ /\.brand-wordmark-plate\.wordmark-fallback-active \.brand-wordmark\s*\{[^}]*display:\s*none/);
assert.doesNotMatch(markup, /class="brand-symbol"|class="brand-lockup"|>VRi);
assert.match(markup,
/class="workspace-heading"[\s\S]*?class="workspace-heading-icon"[\s\S]*?
[\s\S]*?
[^<]+<\/h1>/);
diff --git a/tests/LegacyParityWeb/workspace-navigation.test.cjs b/tests/LegacyParityWeb/workspace-navigation.test.cjs
index ad45a5f..a7150e0 100644
--- a/tests/LegacyParityWeb/workspace-navigation.test.cjs
+++ b/tests/LegacyParityWeb/workspace-navigation.test.cjs
@@ -510,6 +510,15 @@ test("workspace navigation stores only the approved local presentation preferenc
assert.doesNotMatch(navigation, /postMessage|sessionStorage|send\s*\(/);
});
+test("missing external wordmark uses the source-only text fallback", () => {
+ assert.match(navigation,
+ /brandWordmark\.addEventListener\("error", showBrandWordmarkFallback, \{ once: true \}\)/);
+ assert.match(navigation,
+ /brandWordmark\.complete && brandWordmark\.naturalWidth === 0/);
+ assert.match(navigation,
+ /classList\.add\("wordmark-fallback-active"\)/);
+});
+
test("splitter clamps pointer and keyboard resizing and Escape restores the gesture", () => {
for (const declaration of [
/const scheduleMinimumPercent = 34/,
diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs
index 7e24976..4b282c9 100644
--- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs
@@ -88,6 +88,13 @@ public sealed class LegacyDevelopmentLiveStartupContractTests
Assert.Contains("#if DEBUG", app, StringComparison.Ordinal);
Assert.Contains("const bool isDebugBuild = true;", app, StringComparison.Ordinal);
Assert.Contains("const bool isDebugBuild = false;", app, StringComparison.Ordinal);
+ Assert.Contains("#if SOURCE_ONLY_RUNTIME", app, StringComparison.Ordinal);
+ Assert.Contains("const bool isSourceOnlyBuild = true;", app,
+ StringComparison.Ordinal);
+ Assert.Contains("const bool isSourceOnlyBuild = false;", app,
+ StringComparison.Ordinal);
+ Assert.Contains("isSourceOnlyBuild", app, StringComparison.Ordinal);
+ Assert.Contains("? null", app, StringComparison.Ordinal);
Assert.Contains("args.Arguments", app, StringComparison.Ordinal);
Assert.Contains("Environment.GetCommandLineArgs()", app, StringComparison.Ordinal);
Assert.Contains("ResolveLaunchArguments(", app, StringComparison.Ordinal);
diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs
index a60c978..00baef9 100644
--- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs
@@ -268,6 +268,154 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
Assert.Single(rejectionTarget.Elements("Error"));
}
+ [Fact]
+ public void FreshCloneUsesIsolatedSourceOnlyOutputWithoutWeakeningFullBuilds()
+ {
+ var project = XDocument.Load(ProjectPath);
+
+ var sourceRootWasSpecified = Assert.Single(
+ project.Descendants("LegacyRuntimeSourceRootWasSpecified"));
+ Assert.Equal(
+ "'$(LegacyRuntimeSourceRoot)' != ''",
+ sourceRootWasSpecified.Attribute("Condition")?.Value);
+
+ var modes = project.Descendants("LegacyRuntimeAssetsMode").ToArray();
+ Assert.Equal(2, modes.Length);
+ var requiredMode = Assert.Single(
+ modes,
+ mode => string.Equals(mode.Value, "Required", StringComparison.Ordinal));
+ var requiredCondition = requiredMode.Attribute("Condition")?.Value;
+ Assert.Contains(
+ "$(LegacyRuntimeSourceRootWasSpecified)",
+ requiredCondition,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "'$(Configuration)' == 'Release'",
+ requiredCondition,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "$(GenerateAppxPackageOnBuild)",
+ requiredCondition,
+ StringComparison.Ordinal);
+ Assert.Contains("$(PublishAppxPackage)", requiredCondition, StringComparison.Ordinal);
+
+ var autoMode = Assert.Single(
+ modes,
+ mode => string.Equals(mode.Value, "Auto", StringComparison.Ordinal));
+ Assert.Equal(
+ "'$(LegacyRuntimeAssetsMode)' == ''",
+ autoMode.Attribute("Condition")?.Value);
+
+ var assetsEnabled = Assert.Single(
+ project.Descendants("LegacyRuntimeAssetsEnabled"),
+ element => (element.Attribute("Condition")?.Value ?? string.Empty)
+ .Contains("Exists('$(LegacyRuntimeSourceRoot)')", StringComparison.Ordinal));
+ Assert.Equal("true", assetsEnabled.Value);
+
+ var sourceOnlyOutput = Assert.Single(project.Descendants("BaseOutputPath"));
+ Assert.Equal(@"bin\SourceOnly\", sourceOnlyOutput.Value);
+ Assert.Contains(
+ "$(LegacyRuntimeAssetsEnabled)",
+ sourceOnlyOutput.Attribute("Condition")?.Value,
+ StringComparison.Ordinal);
+ var sourceOnlyIntermediate = Assert.Single(
+ project.Descendants("IntermediateOutputPath"));
+ Assert.Equal(
+ @"obj\SourceOnly\$(Platform)\$(Configuration)\",
+ sourceOnlyIntermediate.Value);
+ Assert.Contains(
+ "$(LegacyRuntimeAssetsEnabled)",
+ sourceOnlyIntermediate.Attribute("Condition")?.Value,
+ StringComparison.Ordinal);
+ var sourceOnlyConstants = Assert.Single(project.Descendants("DefineConstants"));
+ Assert.Contains(
+ "SOURCE_ONLY_RUNTIME",
+ sourceOnlyConstants.Value,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "$(LegacyRuntimeAssetsEnabled)",
+ sourceOnlyConstants.Attribute("Condition")?.Value,
+ StringComparison.Ordinal);
+
+ var validationTarget = Assert.Single(
+ project.Descendants("Target"),
+ element => string.Equals(
+ element.Attribute("Name")?.Value,
+ "ValidateLegacyRuntimeAssets",
+ StringComparison.Ordinal));
+ var sourceOnlyMessage = Assert.Single(validationTarget.Elements("Message"));
+ Assert.Contains(
+ "$(LegacyRuntimeAssetsMode)",
+ sourceOnlyMessage.Attribute("Condition")?.Value,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "source-only DryRun",
+ sourceOnlyMessage.Attribute("Text")?.Value,
+ StringComparison.Ordinal);
+
+ var runtimeValidationErrors = validationTarget.Elements("Error")
+ .Where(error => (error.Attribute("Condition")?.Value ?? string.Empty)
+ .Contains("Exists(", StringComparison.Ordinal))
+ .ToArray();
+ Assert.NotEmpty(runtimeValidationErrors);
+ Assert.All(runtimeValidationErrors, error =>
+ Assert.Contains(
+ "$(LegacyRuntimeAssetsValidationEnabled)",
+ error.Attribute("Condition")?.Value,
+ StringComparison.Ordinal));
+
+ string[] externalContentIncludes =
+ [
+ @"$(LegacyCutsSourceRoot)\**\*",
+ "@(LegacyPackagedResAsset)",
+ "@(LegacyFinancialPreviewAsset)",
+ "@(LegacyBrandAsset)"
+ ];
+ Assert.All(externalContentIncludes, include =>
+ {
+ var content = Assert.Single(
+ project.Descendants("Content"),
+ element => string.Equals(
+ element.Attribute("Include")?.Value,
+ include,
+ StringComparison.Ordinal));
+ Assert.Contains(
+ "$(LegacyRuntimeAssetsEnabled)",
+ content.Attribute("Condition")?.Value,
+ StringComparison.Ordinal);
+ });
+
+ var databaseCopy = Assert.Single(
+ project.Descendants("Target"),
+ element => string.Equals(
+ element.Attribute("Name")?.Value,
+ "CopyLegacyDatabaseIniToLocalOutput",
+ StringComparison.Ordinal));
+ Assert.Contains(
+ "$(LegacyRuntimeAssetsEnabled)",
+ databaseCopy.Attribute("Condition")?.Value,
+ StringComparison.Ordinal);
+
+ var mainWindow = File.ReadAllText(Path.Combine(
+ RepositoryRoot,
+ "src",
+ "MBN_STOCK_WEBVIEW.LegacyParityApp",
+ "MainWindow.xaml.cs"));
+ Assert.Contains("#if SOURCE_ONLY_RUNTIME", mainWindow, StringComparison.Ordinal);
+ Assert.Contains(
+ "Source-only builds do not initialize a database runtime.",
+ mainWindow,
+ StringComparison.Ordinal);
+
+ var playout = File.ReadAllText(Path.Combine(
+ RepositoryRoot,
+ "src",
+ "MBN_STOCK_WEBVIEW.LegacyParityApp",
+ "MainWindow.Playout.cs"));
+ Assert.Contains("forceSafeDryRun: IsSourceOnlyBuild", playout,
+ StringComparison.Ordinal);
+ }
+
[Fact]
public void LegacyRuntimeSourceRootIsOverrideableAndValidatedBeforeBuild()
{
diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs
index 369ab5d..f2ade04 100644
--- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs
@@ -62,6 +62,32 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn);
}
+ [Fact]
+ public void Load_SourceOnlyIgnoresLocalProfileOperatorRootsAndEnvironment()
+ {
+ using var environment = ClearedEnvironment()
+ .Set("MBN_STOCK_PLAYOUT_MODE", "Live")
+ .Set("MBN_STOCK_PLAYOUT_HOST", "configured-live-host")
+ .Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\configured-live-scenes")
+ .Set(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
+ PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
+ using var file = TemporaryJsonFile.Create("{ intentionally-invalid-json");
+
+ var options = PlayoutOptionsLoader.Load(
+ file.Path,
+ operatorSceneDirectory: "C:\\operator-scenes",
+ operatorBackgroundDirectory: "C:\\operator-backgrounds",
+ forceSafeDryRun: true);
+
+ Assert.Equal(PlayoutMode.DryRun, options.Mode);
+ Assert.Equal("127.0.0.1", options.Host);
+ Assert.Null(options.SceneDirectory);
+ Assert.Null(options.LegacyBackgroundDirectory);
+ Assert.False(options.TrustedLiveOutputEnabled);
+ Assert.Empty(options.TestSceneAllowlist);
+ }
+
[Fact]
public void ApplyExecutableAssetDefaults_UsesCutsBesideExecutable()
{