feat: add native runtime settings

This commit is contained in:
2026-07-21 12:07:18 +09:00
parent b6208c0971
commit fc4007d676
25 changed files with 3543 additions and 63 deletions

View File

@@ -147,6 +147,37 @@ public sealed class LegacyIniDatabaseOptionsLoaderTests
Assert.Equal("maria.dev.invalid", runtime.Options.MariaDb.Host);
}
[Fact]
public void Runtime_UserSelectedResCanExplicitlyPrecedeExecutableCredentials()
{
using var directory = TemporaryDirectory.Create();
var executableRes = Path.Combine(directory.Path, "Res");
Directory.CreateDirectory(executableRes);
File.WriteAllBytes(
Path.Combine(executableRes, "MmoneyCoder.ini"),
TemporaryLegacyIni.ValidUtf8Bytes());
using var selected = TemporaryLegacyIni.Create(Encoding.UTF8.GetBytes(
"""
[Oracle]
ConnectionName=selected.oracle.invalid:1521/DEVORA
ID=oracle-user
Pass=oracle-password
[Maria]
ConnectionName=selected.maria.invalid:3306/dev_stock
ID=maria-user
Pass=maria-password
"""));
var runtime = DatabaseRuntime.CreateLegacyExecutableDefault(
directory.Path,
Path.Combine(directory.Path, "missing.json"),
selected.Path,
preferLegacyOverride: true);
Assert.Equal("selected.oracle.invalid", runtime.Options.Oracle.Host);
Assert.Equal("selected.maria.invalid", runtime.Options.MariaDb.Host);
}
[Fact]
public void Load_OptionalRealLegacyIni_IsConfiguredWithoutPrintingValues()
{

View File

@@ -0,0 +1,493 @@
using System.Text;
using System.Text.Json;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOperatorSettingsTests
{
private static readonly Encoding Cp949 = CreateCp949Encoding();
[Fact]
public void DefaultPath_IsTheBoundedLocalApplicationConfigurationPath()
{
var expected = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"Config",
"runtime-folders.local.json");
Assert.Equal(expected, LegacyOperatorSettingsStore.DefaultPath);
Assert.True(Path.IsPathFullyQualified(LegacyOperatorSettingsStore.DefaultPath));
}
[Fact]
public void SceneValidation_RequiresOnlyTheBaselineSceneAndReturnsAnAbbreviation()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("Cuts");
File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline");
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
sceneDirectory + Path.DirectorySeparatorChar);
Assert.True(result.IsValid);
Assert.Equal(Path.GetFullPath(sceneDirectory), result.Folder!.CanonicalPath);
Assert.Equal("baseline", File.ReadAllText(
Path.Combine(result.Folder.CanonicalPath, "5001.t2s")));
Assert.Contains("Cuts", result.Folder.AbbreviatedPath);
Assert.InRange(
result.Folder.AbbreviatedPath.Length,
1,
LegacyOperatorFolderValidator.DefaultDisplayCharacters);
Assert.False(result.Folder.DatabaseIniDetected);
Assert.Null(result.WarningMessage);
}
[Fact]
public void SceneValidation_MissingBaselineFailsWithoutLeakingThePath()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("sensitive-design-folder");
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
sceneDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.RequiredFileMissing,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, sceneDirectory);
}
[Fact]
public void BackgroundValidation_AcceptsAnEmptyFixedDriveDirectory()
{
using var directory = new TemporaryDirectory();
var backgroundDirectory = directory.CreateDirectory("Background");
Assert.True(LegacyOperatorFolderValidator.TryValidate(
LegacyOperatorSettingsFolderKind.Background,
backgroundDirectory,
out var folder,
out var warning));
Assert.Equal(Path.GetFullPath(backgroundDirectory), folder!.CanonicalPath);
Assert.Null(warning);
}
[Fact]
public void ResourceValidation_ParsesEveryClosedUiIniAndOnlyFlagsDatabaseIni()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
File.WriteAllText(
Path.Combine(resourceDirectory, "MmoneyCoder.ini"),
"secret-content-that-must-not-be-returned");
var stockMenu = LegacyRuntimeStockCutMenuLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.False(stockMenu.UsesBuiltInCatalog);
var uiCatalogs = LegacyRuntimeUiCatalogLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.Empty(uiCatalogs.Warnings);
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.True(result.IsValid, $"{result.Failure}: {result.WarningMessage}");
Assert.True(result.Folder!.DatabaseIniDetected);
Assert.DoesNotContain("secret", result.Folder.AbbreviatedPath,
StringComparison.OrdinalIgnoreCase);
Assert.Null(result.WarningMessage);
}
[Fact]
public void ResourceValidation_InvalidClosedIniFailsWithoutPartialAcceptance()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("private-runtime-resources");
WriteValidResourceFiles(resourceDirectory);
WriteCp949(
Path.Combine(resourceDirectory, "종목.ini"),
["[종목]", "_않은_컷"]);
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, resourceDirectory);
}
[Fact]
public void ResourceValidation_RejectsAnUnsafeDatabaseIniWithoutReadingIt()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
Directory.CreateDirectory(Path.Combine(resourceDirectory, "MmoneyCoder.ini"));
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.RequiredFileUnsafe,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, resourceDirectory);
}
[Fact]
public void Validation_RejectsRelativeMissingRootAndControlContainingPaths()
{
using var directory = new TemporaryDirectory();
var missing = Path.Combine(directory.Path, "missing-private-folder");
string?[] candidates =
[
null,
string.Empty,
"relative-folder",
missing,
Path.GetPathRoot(directory.Path),
directory.Path + "\nprivate"
];
foreach (var candidate in candidates)
{
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Background,
candidate);
Assert.False(result.IsValid);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, missing);
}
}
[Fact]
public void Validation_RejectsAReparsePointInTheDirectoryAncestry_WhenSupported()
{
using var directory = new TemporaryDirectory();
var target = directory.CreateDirectory("target");
var link = Path.Combine(directory.Path, "link");
try
{
Directory.CreateSymbolicLink(link, target);
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or
PlatformNotSupportedException or NotSupportedException)
{
return;
}
try
{
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Background,
link);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.ReparsePoint,
result.Failure);
}
finally
{
Directory.Delete(link);
}
}
[Fact]
public void Load_MissingFileUsesDefaultsWithoutWarning()
{
using var directory = new TemporaryDirectory();
var store = new LegacyOperatorSettingsStore(
Path.Combine(directory.Path, "Config", "runtime-folders.local.json"));
var result = store.Load();
Assert.Equal(LegacyOperatorSettings.Default, result.Settings);
Assert.False(result.LoadedFromDisk);
Assert.Equal(LegacyOperatorSettingsStoreFailure.None, result.Failure);
Assert.Null(result.WarningMessage);
}
[Fact]
public void SaveAndLoad_RoundTripsOnlyTheVersionedClosedSchemaAtomically()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("Cuts");
File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline");
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
var backgroundDirectory = directory.CreateDirectory("Background");
var configurationPath = Path.Combine(
directory.Path,
"Config",
"runtime-folders.local.json");
var store = new LegacyOperatorSettingsStore(configurationPath);
var settings = new LegacyOperatorSettings(
sceneDirectory,
resourceDirectory,
backgroundDirectory,
NavigationExpanded: false);
var firstSave = store.Save(settings);
var secondSave = store.Save(settings with { NavigationExpanded = true });
var loaded = store.Load();
Assert.True(firstSave.Succeeded);
Assert.True(secondSave.Succeeded);
Assert.NotNull(secondSave.SavedSettings);
Assert.Null(secondSave.WarningMessage);
Assert.True(loaded.LoadedFromDisk);
Assert.True(loaded.Settings.NavigationExpanded);
Assert.Equal(Path.GetFullPath(sceneDirectory), loaded.Settings.SceneDirectory);
Assert.Equal(Path.GetFullPath(resourceDirectory), loaded.Settings.ResourceDirectory);
Assert.Equal(
Path.GetFullPath(backgroundDirectory),
loaded.Settings.BackgroundDirectory);
Assert.Empty(Directory.EnumerateFiles(
Path.GetDirectoryName(configurationPath)!,
"*.tmp"));
using var document = JsonDocument.Parse(File.ReadAllBytes(configurationPath));
var names = document.RootElement.EnumerateObject()
.Select(property => property.Name)
.ToArray();
Assert.Equal(
[
"schemaVersion",
"sceneDirectory",
"resourceDirectory",
"backgroundDirectory",
"navigationExpanded"
],
names);
Assert.DoesNotContain(
document.RootElement.EnumerateObject(),
property => property.Name.Contains("password", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("target", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("live", StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("{}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"schemaVersion\":2,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.UnsupportedSchema)]
[InlineData("{\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true,\"unexpected\":1}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"schemaVersion\":1,\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"SchemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"schemaVersion\":1,\"sceneDirectory\":\"relative\",\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidSettings)]
public void Load_StrictMalformedOrUnsafeJsonFailsClosed(
string json,
LegacyOperatorSettingsStoreFailure expectedFailure)
{
using var directory = new TemporaryDirectory();
var configurationPath = Path.Combine(directory.Path, "settings.json");
File.WriteAllText(configurationPath, json, new UTF8Encoding(false));
var store = new LegacyOperatorSettingsStore(configurationPath);
var result = store.Load();
Assert.Equal(LegacyOperatorSettings.Default, result.Settings);
Assert.False(result.LoadedFromDisk);
Assert.Equal(expectedFailure, result.Failure);
AssertPathFree(result.WarningMessage, directory.Path);
}
[Fact]
public void Load_OversizedJsonFailsBeforeParsing()
{
using var directory = new TemporaryDirectory();
var configurationPath = Path.Combine(directory.Path, "settings.json");
using (var stream = new FileStream(
configurationPath,
FileMode.CreateNew,
FileAccess.Write))
{
stream.SetLength(LegacyOperatorSettingsStore.MaximumFileBytes + 1L);
}
var result = new LegacyOperatorSettingsStore(configurationPath).Load();
Assert.Equal(LegacyOperatorSettingsStoreFailure.FileTooLarge, result.Failure);
Assert.Equal(LegacyOperatorSettings.Default, result.Settings);
AssertPathFree(result.WarningMessage, configurationPath);
}
[Fact]
public void Save_RejectsUnvalidatedPathsWithoutCreatingAConfigurationFile()
{
using var directory = new TemporaryDirectory();
var configurationPath = Path.Combine(directory.Path, "settings.json");
var store = new LegacyOperatorSettingsStore(configurationPath);
var settings = LegacyOperatorSettings.Default with
{
SceneDirectory = Path.Combine(directory.Path, "not-present-private-cuts")
};
var result = store.Save(settings);
Assert.False(result.Succeeded);
Assert.Equal(LegacyOperatorSettingsStoreFailure.InvalidSettings, result.Failure);
Assert.Null(result.SavedSettings);
Assert.False(File.Exists(configurationPath));
AssertPathFree(result.WarningMessage, settings.SceneDirectory!);
}
[Fact]
public void AbbreviateForDisplay_IsBoundedAndNeverReturnsInvalidInput()
{
using var directory = new TemporaryDirectory();
var longLeaf = new string('a', 80);
var path = directory.CreateDirectory(longLeaf);
var abbreviated = LegacyOperatorFolderValidator.AbbreviateForDisplay(path, 20);
Assert.InRange(abbreviated.Length, 1, 20);
Assert.Contains('…', abbreviated);
Assert.Equal(
LegacyOperatorFolderValidator.UnconfiguredDisplay,
LegacyOperatorFolderValidator.AbbreviateForDisplay("relative\\path"));
}
private static void WriteValidResourceFiles(string resourceDirectory)
{
var stockLabel = LegacyStockCutCatalog.Rows
.First(row => !row.IsSeparator)
.RawLabel;
WriteCp949(
Path.Combine(resourceDirectory, "종목.ini"),
["[종목]", stockLabel]);
WriteFixedFile(
resourceDirectory,
"해외.ini",
LegacyFixedMarket.Overseas);
WriteFixedFile(
resourceDirectory,
"환율.ini",
LegacyFixedMarket.Exchange);
WriteFixedFile(
resourceDirectory,
"지수.ini",
LegacyFixedMarket.Index);
WriteIndustryFile(
resourceDirectory,
"업종_코스피.ini",
IndustryMarket.Kospi);
WriteIndustryFile(
resourceDirectory,
"업종_코스닥.ini",
IndustryMarket.Kosdaq);
WriteComparisonFile(resourceDirectory);
}
private static void WriteFixedFile(
string resourceDirectory,
string fileName,
LegacyFixedMarket market)
{
var lines = new List<string>();
foreach (var section in LegacyFixedActionCatalog.Default
.GetActions(market)
.GroupBy(action => action.Section, StringComparer.Ordinal))
{
lines.Add($"[{section.Key}]");
lines.AddRange(section.Select(action => action.Label));
}
WriteCp949(Path.Combine(resourceDirectory, fileName), lines);
}
private static void WriteIndustryFile(
string resourceDirectory,
string fileName,
IndustryMarket market)
{
var actions = LegacyIndustryActionCatalog.GetActions(market);
var section = market == IndustryMarket.Kospi ? "코스피" : "코스닥";
WriteCp949(
Path.Combine(resourceDirectory, fileName),
[$"[{section}]", .. actions.Select(action => action.Label)]);
}
private static void WriteComparisonFile(string resourceDirectory)
{
var actions = LegacyComparisonWorkflowCatalog.Actions;
var directActions = actions
.Where(action => action.Kind != LegacyComparisonActionKind.Return)
.ToArray();
var returnActions = actions
.Where(action => action.Kind == LegacyComparisonActionKind.Return)
.ToArray();
var lines = new List<string> { "[지수]" };
lines.AddRange(LegacyComparisonWorkflowCatalog.FixedTargets
.Select(target => target.DisplayName));
lines.AddRange(directActions.Select(action => $"[{action.Label}]"));
lines.Add($"[{returnActions[0].Section}]");
lines.AddRange(returnActions.Select(action => action.Label));
WriteCp949(Path.Combine(resourceDirectory, "종목비교.ini"), lines);
}
private static void WriteCp949(string path, IEnumerable<string> lines) =>
File.WriteAllText(path, string.Join("\r\n", lines) + "\r\n", Cp949);
private static void AssertPathFree(string? warningMessage, string sensitivePath)
{
var warning = Assert.IsType<string>(warningMessage);
Assert.InRange(warning.Length, 1, 100);
Assert.DoesNotContain(sensitivePath, warning, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain('\\', warning);
Assert.DoesNotContain('/', warning);
}
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"MBN_STOCK_WEBVIEW_SETTINGS_{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public string CreateDirectory(string name)
{
var path = System.IO.Path.Combine(Path, name);
Directory.CreateDirectory(path);
return path;
}
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}

View File

@@ -255,6 +255,33 @@ public sealed class LegacyRuntimeStockCutMenuLoaderTests
}
}
[Fact]
public void ResolveFromResourceDirectory_ReadsTheDirectNativeSelectedDirectory()
{
var directory = Path.Combine(
Path.GetTempPath(),
$"mbn-stock-cut-direct-resource-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
WriteMenu(Path.Combine(directory, "종목.ini"), "1열판기본_현재가");
var composition = LegacyRuntimeStockCutMenuLoader
.ResolveFromResourceDirectory(directory);
Assert.Equal(
LegacyRuntimeStockCutMenuSource.ConfiguredResourceDirectory,
composition.Source);
Assert.Equal(
"1열판기본_현재가",
Assert.Single(composition.Rows).RawLabel);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ResolveFromCandidates_ExternalOverrideTakesPrecedenceOverPackagedMenu()
{

View File

@@ -141,6 +141,25 @@ public sealed class LegacyRuntimeUiCatalogLoaderTests
comparisonWorkflow.Current.Actions.Select(action => action.ActionId));
}
[Fact]
public void ResolveFromResourceDirectory_ReadsTheDirectNativeSelectedDirectory()
{
using var directory = new TemporaryDirectory();
WriteAllValidFiles(directory.Path);
var resourceDirectory = System.IO.Path.Combine(directory.Path, "Res");
var composition = LegacyRuntimeUiCatalogLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.Empty(composition.Warnings);
AssertRuntimeFixedFile(
directory.Path,
"해외.ini",
LegacyFixedMarket.Overseas,
LegacyFixedActionCatalog.ExpectedOverseasCount,
composition.FixedCatalog);
}
[Fact]
public void UnknownFixedRow_FallsBackWithoutRegisteringAnAction()
{

View File

@@ -0,0 +1,143 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyOperatorSettingsBridgeTests
{
[Theory]
[InlineData("design", LegacyOperatorFolderKind.Design)]
[InlineData("resource", LegacyOperatorFolderKind.Resource)]
[InlineData("background", LegacyOperatorFolderKind.Background)]
public void ParseChooseFolder_AcceptsOnlyClosedFolderKinds(
string wireKind,
LegacyOperatorFolderKind expectedKind)
{
var json =
$"{{\"type\":\"choose-operator-folder\",\"payload\":{{\"kind\":\"{wireKind}\"}}}}";
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error));
Assert.Null(error);
var intent = Assert.IsType<LegacyChooseOperatorFolderIntent>(parsed);
Assert.Equal(expectedKind, intent.Kind);
}
[Theory]
[InlineData("design", LegacyOperatorFolderKind.Design)]
[InlineData("resource", LegacyOperatorFolderKind.Resource)]
[InlineData("background", LegacyOperatorFolderKind.Background)]
public void ParseResetFolder_AcceptsOnlyClosedFolderKinds(
string wireKind,
LegacyOperatorFolderKind expectedKind)
{
var json =
$"{{\"type\":\"reset-operator-folder\",\"payload\":{{\"kind\":\"{wireKind}\"}}}}";
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error));
Assert.Null(error);
var intent = Assert.IsType<LegacyResetOperatorFolderIntent>(parsed);
Assert.Equal(expectedKind, intent.Kind);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ParseNavigationExpanded_RequiresAnExactBoolean(bool expanded)
{
var json =
$"{{\"type\":\"set-operator-navigation-expanded\",\"payload\":{{\"expanded\":{expanded.ToString().ToLowerInvariant()}}}}}";
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error));
Assert.Null(error);
var intent = Assert.IsType<LegacySetOperatorNavigationExpandedIntent>(parsed);
Assert.Equal(expanded, intent.Expanded);
}
[Theory]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{}}")]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"settings\"}}")]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"Design\"}}")]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":1}}")]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"design\",\"path\":\"C:\\\\Cuts\"}}")]
[InlineData("{\"type\":\"reset-operator-folder\",\"payload\":{\"kind\":\"resource\",\"extra\":false}}")]
[InlineData("{\"type\":\"reset-operator-folder\",\"payload\":{\"kind\":\"resource\",\"kind\":\"design\"}}")]
[InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{}}")]
[InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{\"expanded\":\"true\"}}")]
[InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{\"expanded\":true,\"path\":\"C:\\\\Config\"}}")]
public void ParseSettingsIntents_RejectsMissingUnknownAndExtraAuthority(string json)
{
Assert.False(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
Assert.Null(parsed);
}
[Fact]
public void SerializeState_ProjectsOperatorSettingsWithClosedWireNames()
{
var settings = new LegacyOperatorSettingsSnapshot(
17,
new LegacyOperatorFolderSnapshot("D:\\Design", true, true, "사용 가능"),
new LegacyOperatorFolderSnapshot("D:\\Resource", true, false, "Res 폴더 없음"),
new LegacyOperatorFolderSnapshot("앱 기본 배경", false, true, "기본값"),
NavigationExpanded: false,
RestartRequired: true,
Message: "재시작 후 적용됩니다.",
MessageKind: LegacyOperatorSettingsMessageKind.Warning,
AppVersion: "0.1.0.0",
OracleState: "연결됨",
MariaState: "연결 끊김",
DatabasePollingSeconds: 10);
var json = LegacyBridgeProtocol.SerializeState(
CreateSnapshot(),
operatorSettings: settings);
using var document = JsonDocument.Parse(json);
var projected = document.RootElement
.GetProperty("payload")
.GetProperty("operatorSettings");
Assert.Equal(17, projected.GetProperty("revision").GetInt64());
Assert.Equal("D:\\Design", projected.GetProperty("design")
.GetProperty("displayName").GetString());
Assert.True(projected.GetProperty("design").GetProperty("isCustom").GetBoolean());
Assert.True(projected.GetProperty("design").GetProperty("isValid").GetBoolean());
Assert.Equal("사용 가능", projected.GetProperty("design")
.GetProperty("status").GetString());
Assert.False(projected.GetProperty("resource").GetProperty("isValid").GetBoolean());
Assert.False(projected.GetProperty("background").GetProperty("isCustom").GetBoolean());
Assert.False(projected.GetProperty("navigationExpanded").GetBoolean());
Assert.True(projected.GetProperty("restartRequired").GetBoolean());
Assert.Equal("재시작 후 적용됩니다.", projected.GetProperty("message").GetString());
Assert.Equal("warning", projected.GetProperty("messageKind").GetString());
Assert.Equal("0.1.0.0", projected.GetProperty("appVersion").GetString());
Assert.Equal("연결됨", projected.GetProperty("oracleState").GetString());
Assert.Equal("연결 끊김", projected.GetProperty("mariaState").GetString());
Assert.Equal(10, projected.GetProperty("databasePollingSeconds").GetInt32());
Assert.False(projected.TryGetProperty("Design", out _));
Assert.False(projected.GetProperty("design").TryGetProperty("DisplayName", out _));
}
[Fact]
public void SerializeState_UsesNullOperatorSettingsWhenNotProvided()
{
using var document = JsonDocument.Parse(
LegacyBridgeProtocol.SerializeState(CreateSnapshot()));
Assert.Equal(
JsonValueKind.Null,
document.RootElement.GetProperty("payload")
.GetProperty("operatorSettings").ValueKind);
}
private static LegacyOperatorSnapshot CreateSnapshot() => new(
1,
string.Empty,
[],
null,
[],
[],
null,
string.Empty,
LegacyOperatorStatusKind.Neutral);
}

View File

@@ -0,0 +1,164 @@
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyOperatorSettingsNativeContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string AppRoot = Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp");
private static readonly string Window = File.ReadAllText(
Path.Combine(AppRoot, "MainWindow.xaml.cs"));
private static readonly string Settings = File.ReadAllText(
Path.Combine(AppRoot, "MainWindow.Settings.cs"));
private static readonly string Playout = File.ReadAllText(
Path.Combine(AppRoot, "MainWindow.Playout.cs"));
[Fact]
public void FolderPathsComeOnlyFromTheWindowOwnedNativePicker()
{
Assert.Contains("var picker = new FolderPicker", Settings, StringComparison.Ordinal);
Assert.Contains("picker.FileTypeFilter.Add(\"*\")", Settings,
StringComparison.Ordinal);
Assert.Contains("InitializeWithWindow.Initialize(picker, owner);", Settings,
StringComparison.Ordinal);
Assert.Contains("await picker.PickSingleFolderAsync()", Settings,
StringComparison.Ordinal);
Assert.Contains("selected.Path", Settings, StringComparison.Ordinal);
Assert.Contains("LegacyOperatorFolderValidator.TryValidate(", Settings,
StringComparison.Ordinal);
Assert.DoesNotContain("intent.Path", Settings, StringComparison.Ordinal);
Assert.DoesNotContain("choose.Path", Settings, StringComparison.Ordinal);
}
[Fact]
public void SelectedFoldersAreLoadedAtStartupAndAppliedOnlyAtCompositionBoundaries()
{
Assert.Contains("_operatorSettingsStore.Load()", Window, StringComparison.Ordinal);
Assert.Contains("_appliedOperatorSettings = operatorSettingsLoad.Settings", Window,
StringComparison.Ordinal);
Assert.Contains("ResolveFromResourceDirectory(", Window, StringComparison.Ordinal);
Assert.Contains("preferLegacyOverride: selectedLegacyDatabaseIni is not null", Window,
StringComparison.Ordinal);
Assert.Contains(
"operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory",
Playout,
StringComparison.Ordinal);
Assert.Contains(
"operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory",
Playout,
StringComparison.Ordinal);
Assert.DoesNotContain("InitializePlayoutRuntime();", Settings, StringComparison.Ordinal);
Assert.DoesNotContain("CreateLegacyExecutableDefault(", Settings,
StringComparison.Ordinal);
}
[Fact]
public void GateAUsesOnlyThePinnedJsonDatabaseProfile()
{
var databaseComposition = Slice(
Window,
"string? initializationError = null;",
"DataQueryExecutor.Configure(_databaseRuntime.Executor);");
var gateA = RequiredIndex(
databaseComposition,
"_playoutLaunchAuthorization.IsGateA");
var pinnedJson = RequiredIndex(
databaseComposition,
"DatabaseRuntime.CreateDefault()",
gateA);
var legacyIni = RequiredIndex(
databaseComposition,
"DatabaseRuntime.CreateLegacyExecutableDefault(",
pinnedJson);
Assert.True(gateA < pinnedJson);
Assert.True(pinnedJson < legacyIni);
}
[Fact]
public void SettingsWritesAreGateABlockedAndDoNotWaitForDatabaseHealthActivity()
{
var process = Slice(
Window,
"private async Task ProcessIntentAsync(LegacyUiIntent intent)",
"private static bool IsOrderedPlayoutIntent");
var gateABlock = RequiredIndex(process, "IsPersistentWriteIntent(intent)");
var settingsDispatch = RequiredIndex(
process,
"await HandleOperatorSettingsIntentAsync(",
gateABlock);
var databaseWait = RequiredIndex(
process,
"await AcquirePriorityDatabaseActivityAsync(_lifetimeCancellation.Token)");
Assert.True(gateABlock < settingsDispatch);
Assert.True(settingsDispatch < databaseWait);
Assert.Contains("HandleOperatorSettingsIntentAsync(", process,
StringComparison.Ordinal);
Assert.Contains("LegacyChooseOperatorFolderIntent", Playout,
StringComparison.Ordinal);
Assert.Contains("LegacyResetOperatorFolderIntent", Playout,
StringComparison.Ordinal);
Assert.Contains("LegacySetOperatorNavigationExpandedIntent", Playout,
StringComparison.Ordinal);
Assert.Contains("ReconcileRejectedOperatorSettingsIntent(", process,
StringComparison.Ordinal);
}
[Fact]
public void SettingsStateIsProjectedWithoutPublishingFullNativePaths()
{
Assert.Contains("operatorSettings: CreateOperatorSettingsSnapshot()", Window,
StringComparison.Ordinal);
Assert.Contains("AbbreviatedPath", Settings, StringComparison.Ordinal);
Assert.DoesNotContain("CanonicalPath,\n _operatorSettingsMessage", Settings,
StringComparison.Ordinal);
Assert.Contains("DatabasePollingSeconds: (int)DatabaseHealthPollingInterval.TotalSeconds",
Settings,
StringComparison.Ordinal);
}
[Fact]
public void FailedSettingsSaveRevisionsTheSnapshotToUndoOptimisticNavigation()
{
Assert.Contains(
"if (!result.Succeeded || result.SavedSettings is null)",
Settings,
StringComparison.Ordinal);
Assert.Contains(
"Interlocked.Increment(ref _operatorSettingsRevision);",
Settings,
StringComparison.Ordinal);
}
private static string Slice(string value, string start, string end)
{
var startIndex = RequiredIndex(value, start);
var endIndex = RequiredIndex(value, end, startIndex + start.Length);
return value[startIndex..endIndex];
}
private static int RequiredIndex(string value, string needle, int startIndex = 0)
{
var index = value.IndexOf(needle, startIndex, StringComparison.Ordinal);
Assert.True(index >= 0, $"Required marker was not found: {needle}");
return index;
}
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.");
}
}

View File

@@ -0,0 +1,147 @@
using System.Text.RegularExpressions;
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyOperatorSettingsWebContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string WebRoot = Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"Web");
private static readonly string Markup = File.ReadAllText(Path.Combine(WebRoot, "index.html"));
private static readonly string Styles = File.ReadAllText(Path.Combine(WebRoot, "styles.css"));
private static readonly string App = File.ReadAllText(Path.Combine(WebRoot, "app.js"));
[Fact]
public void SettingsScreenOffersThreeNativeFolderPickersAndRestartFeedback()
{
var expectedKinds = new[] { "design", "resource", "background" };
var actualKinds = Regex.Matches(Markup, "data-operator-folder=\"([^\"]+)\"")
.Select(match => match.Groups[1].Value)
.ToArray();
Assert.Equal(expectedKinds, actualKinds);
foreach (var kind in expectedKinds)
{
Assert.Contains($"id=\"operator-{kind}-folder-name\"", Markup,
StringComparison.Ordinal);
Assert.Contains($"id=\"operator-{kind}-folder-status\"", Markup,
StringComparison.Ordinal);
Assert.Contains($"id=\"operator-{kind}-folder-select\"", Markup,
StringComparison.Ordinal);
Assert.Contains($"id=\"operator-{kind}-folder-reset\"", Markup,
StringComparison.Ordinal);
}
Assert.Matches(
"id=\"operator-settings-message\"[^>]*role=\"status\"[^>]*aria-live=\"polite\"",
Markup);
Assert.Matches(
"id=\"operator-settings-restart\"[^>]*role=\"status\"[^>]*aria-live=\"polite\"",
Markup);
Assert.Contains("앱을 다시 시작하면 새 폴더가 적용됩니다.", Markup,
StringComparison.Ordinal);
Assert.Contains("settings-status-badge.valid", Styles, StringComparison.Ordinal);
Assert.Contains("settings-status-badge.invalid", Styles, StringComparison.Ordinal);
}
[Fact]
public void SettingsDispatchesOnlyClosedFolderKindsAndNeverWebSuppliedPaths()
{
var dispatch = Slice(
App,
"Object.keys(operatorFolderControls).forEach(function (kind)",
"searchButton.addEventListener");
Assert.Contains("send(\"choose-operator-folder\", { kind: kind })", dispatch,
StringComparison.Ordinal);
Assert.Contains("send(\"reset-operator-folder\", { kind: kind })", dispatch,
StringComparison.Ordinal);
Assert.Contains(
"send(\"set-operator-navigation-expanded\", { expanded: expanded })",
dispatch,
StringComparison.Ordinal);
Assert.DoesNotContain("path", dispatch, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("displayName", dispatch, StringComparison.Ordinal);
}
[Fact]
public void RepeatedSettingsStateKeepsStaticControlsAndAppliesNavigationByRevision()
{
var folderRender = Slice(App, "function renderOperatorFolder", "function render(state)");
var settingsRender = Slice(
App,
"function renderOperatorSettings",
"function render(state)");
Assert.DoesNotContain("innerHTML", folderRender, StringComparison.Ordinal);
Assert.DoesNotContain("replaceChildren", folderRender, StringComparison.Ordinal);
Assert.Contains("operatorSettingsRevisionInitialized", settingsRender,
StringComparison.Ordinal);
Assert.Contains("revision !== operatorSettingsRenderRevision", settingsRender,
StringComparison.Ordinal);
Assert.Matches(
new Regex(
@"if \(revisionChanged\) \{[\s\S]*?LegacyWorkspaceNavigation\.setExpanded\(expanded\);[\s\S]*?operatorSettingsRenderRevision = revision;",
RegexOptions.CultureInvariant),
settingsRender);
Assert.Single(
Regex.Matches(settingsRender, "LegacyWorkspaceNavigation.setExpanded").Cast<Match>());
Assert.Contains("pendingOperatorSettingsFocusId", settingsRender,
StringComparison.Ordinal);
Assert.Contains("target.focus({ preventScroll: true });", settingsRender,
StringComparison.Ordinal);
}
[Fact]
public void SettingsShowsSafeDiagnosticsAndNamesProtectedValues()
{
foreach (var id in new[]
{
"operator-settings-app-version",
"operator-settings-playout-mode",
"operator-settings-playout-connection",
"operator-settings-oracle-state",
"operator-settings-maria-state",
"operator-settings-db-monitor"
})
{
Assert.Contains($"id=\"{id}\"", Markup, StringComparison.Ordinal);
}
Assert.Contains("id=\"operator-navigation-expanded\"", Markup,
StringComparison.Ordinal);
Assert.Contains("databasePollingSeconds", App, StringComparison.Ordinal);
Assert.Contains("DB 자격 증명", Markup, StringComparison.Ordinal);
Assert.Contains("Tornado2 송출 대상", Markup, StringComparison.Ordinal);
Assert.Contains("Live/DryRun 권한", Markup, StringComparison.Ordinal);
Assert.Contains("장면 허용 목록", Markup, StringComparison.Ordinal);
}
private static string Slice(string source, string startMarker, string endMarker)
{
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.True(start >= 0, $"Missing start marker: {startMarker}");
var end = source.IndexOf(endMarker, start, StringComparison.Ordinal);
Assert.True(end > start, $"Missing end marker: {endMarker}");
return source[start..end];
}
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 could not be located.");
}
}

View File

@@ -95,6 +95,53 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Equal("C:\\explicit-cuts", options.SceneDirectory);
}
[Fact]
public void ApplyOperatorAssetOverrides_ChangesOnlyNativeAssetRoots()
{
var options = new PlayoutOptions
{
Mode = PlayoutMode.Live,
Host = "protected-host",
SceneDirectory = "C:\\json-cuts",
LegacyBackgroundDirectory = "C:\\json-backgrounds",
TrustedLiveOutputEnabled = true,
TestSceneAllowlist = ["5001"]
};
PlayoutOptionsLoader.ApplyOperatorAssetOverrides(
options,
" C:\\operator-cuts ",
" C:\\operator-backgrounds ");
Assert.Equal("C:\\operator-cuts", options.SceneDirectory);
Assert.Equal("C:\\operator-backgrounds", options.LegacyBackgroundDirectory);
Assert.Equal(PlayoutMode.Live, options.Mode);
Assert.Equal("protected-host", options.Host);
Assert.True(options.TrustedLiveOutputEnabled);
Assert.Equal(["5001"], options.TestSceneAllowlist);
}
[Fact]
public void Load_EnvironmentAssetRootsOverrideNativeOperatorSelection()
{
using var environment = ClearedEnvironment()
.Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\environment-cuts")
.Set(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY",
"C:\\environment-backgrounds");
using var file = TemporaryJsonFile.Missing();
var options = PlayoutOptionsLoader.Load(
file.Path,
"C:\\operator-cuts",
"C:\\operator-backgrounds");
Assert.Equal("C:\\environment-cuts", options.SceneDirectory);
Assert.Equal("C:\\environment-backgrounds", options.LegacyBackgroundDirectory);
Assert.Equal(PlayoutMode.DryRun, options.Mode);
Assert.False(options.TrustedLiveOutputEnabled);
}
[Fact]
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
{