feat: add native runtime settings
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user