feat: load legacy Cuts and Res runtime assets

This commit is contained in:
2026-07-18 14:32:04 +09:00
parent da8da06fac
commit 5eb4054120
32 changed files with 2893 additions and 120 deletions

View File

@@ -0,0 +1,226 @@
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class LegacyIniDatabaseOptionsLoaderTests
{
private const string IntegrationPathEnvironmentVariable =
"MBN_STOCK_LEGACY_INI_INTEGRATION_PATH";
[Fact]
public void Load_Utf8LegacyFile_MapsBothProvidersAndKeepsLastValue()
{
using var file = TemporaryLegacyIni.Create(
Encoding.UTF8.GetBytes(
"""
[Oracle]
//ConnectionName=ignored.invalid:1/IGNORED
ConnectionName=oracle.dev.invalid:1522/DEVORA
ID=old-user
ID=oracle-user
Pass=oracle=password
[Maria]
ConnectionName=maria.dev.invalid:3307/dev_stock
ID=maria-user
Pass=maria=password
"""));
var loader = new LegacyIniDatabaseOptionsLoader(_ => null);
var options = loader.Load(file.Path);
Assert.True(options.IsConfigured(DataSourceKind.Oracle));
Assert.True(options.IsConfigured(DataSourceKind.MariaDb));
Assert.Equal("oracle.dev.invalid", options.Oracle.Host);
Assert.Equal(1522, options.Oracle.Port);
Assert.Equal("DEVORA", options.Oracle.Sid);
Assert.Null(options.Oracle.ServiceName);
Assert.Equal("oracle-user", options.Oracle.UserName);
Assert.Equal("oracle=password", options.Oracle.Password);
Assert.Equal("maria.dev.invalid", options.MariaDb.Host);
Assert.Equal(3307, options.MariaDb.Port);
Assert.Equal("dev_stock", options.MariaDb.Database);
Assert.Equal("maria-user", options.MariaDb.UserName);
Assert.Equal("maria=password", options.MariaDb.Password);
Assert.Equal(MariaDbTlsMode.Disabled, options.MariaDb.TlsMode);
}
[Fact]
public void Load_Cp949LegacyFile_PreservesKoreanValues()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp949 = Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
using var file = TemporaryLegacyIni.Create(cp949.GetBytes(
"""
[Oracle]
ConnectionName=oracle.dev.invalid:1521/
ID=
Pass=
[Maria]
ConnectionName=maria.dev.invalid:3306/
ID=
Pass=2
"""));
var options = new LegacyIniDatabaseOptionsLoader(_ => null).Load(file.Path);
Assert.Equal("개발", options.Oracle.Sid);
Assert.Equal("오라클사용자", options.Oracle.UserName);
Assert.Equal("주식", options.MariaDb.Database);
Assert.Equal("마리아사용자", options.MariaDb.UserName);
}
[Fact]
public void Load_EnvironmentStillOverridesLegacyValues()
{
using var file = TemporaryLegacyIni.CreateValidUtf8();
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MBN_STOCK_ORACLE_HOST"] = "override-oracle.invalid",
["MBN_STOCK_MARIADB_TLS_MODE"] = "Required"
};
var loader = new LegacyIniDatabaseOptionsLoader(
name => environment.TryGetValue(name, out var value) ? value : null);
var options = loader.Load(file.Path);
Assert.Equal("override-oracle.invalid", options.Oracle.Host);
Assert.Equal(MariaDbTlsMode.Required, options.MariaDb.TlsMode);
}
[Fact]
public void Load_InvalidFile_DoesNotExposeFileValues()
{
const string secret = "sensitive-password-sentinel";
using var file = TemporaryLegacyIni.Create(Encoding.UTF8.GetBytes(
$"[Oracle]\nConnectionName=invalid\nID=user\nPass={secret}"));
var exception = Assert.Throws<DatabaseConfigurationException>(
() => new LegacyIniDatabaseOptionsLoader(_ => null).Load(file.Path));
Assert.DoesNotContain(secret, exception.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain(file.Path, exception.ToString(), StringComparison.Ordinal);
}
[Fact]
public void Runtime_PrefersExecutableResIni_AndFallsBackWhenAbsent()
{
using var directory = TemporaryDirectory.Create();
var res = Path.Combine(directory.Path, "Res");
Directory.CreateDirectory(res);
File.WriteAllBytes(
Path.Combine(res, "MmoneyCoder.ini"),
TemporaryLegacyIni.ValidUtf8Bytes());
var runtime = DatabaseRuntime.CreateLegacyExecutableDefault(
directory.Path,
Path.Combine(directory.Path, "missing.json"));
Assert.Equal("oracle.dev.invalid", runtime.Options.Oracle.Host);
File.Delete(Path.Combine(res, "MmoneyCoder.ini"));
var fallback = DatabaseRuntime.CreateLegacyExecutableDefault(
directory.Path,
Path.Combine(directory.Path, "missing.json"),
Path.Combine(directory.Path, "missing-legacy.ini"));
Assert.False(fallback.Options.IsConfigured(DataSourceKind.Oracle));
Assert.False(fallback.Options.IsConfigured(DataSourceKind.MariaDb));
}
[Fact]
public void Runtime_UsesLocalLegacyOverrideWhenPackageDoesNotContainCredentials()
{
using var directory = TemporaryDirectory.Create();
using var overrideFile = TemporaryLegacyIni.CreateValidUtf8();
var runtime = DatabaseRuntime.CreateLegacyExecutableDefault(
directory.Path,
Path.Combine(directory.Path, "missing.json"),
overrideFile.Path);
Assert.Equal("oracle.dev.invalid", runtime.Options.Oracle.Host);
Assert.Equal("maria.dev.invalid", runtime.Options.MariaDb.Host);
}
[Fact]
public void Load_OptionalRealLegacyIni_IsConfiguredWithoutPrintingValues()
{
var path = Environment.GetEnvironmentVariable(
IntegrationPathEnvironmentVariable);
if (string.IsNullOrWhiteSpace(path))
{
return;
}
var options = new LegacyIniDatabaseOptionsLoader().Load(path);
Assert.True(options.IsConfigured(DataSourceKind.Oracle));
Assert.True(options.IsConfigured(DataSourceKind.MariaDb));
}
private sealed class TemporaryLegacyIni : IDisposable
{
private TemporaryLegacyIni(string directory, string path)
{
Directory = directory;
Path = path;
}
public string Directory { get; }
public string Path { get; }
public static TemporaryLegacyIni CreateValidUtf8() =>
Create(ValidUtf8Bytes());
public static byte[] ValidUtf8Bytes() => Encoding.UTF8.GetBytes(
"""
[Oracle]
ConnectionName=oracle.dev.invalid:1521/DEVORA
ID=oracle-user
Pass=oracle-password
[Maria]
ConnectionName=maria.dev.invalid:3306/dev_stock
ID=maria-user
Pass=maria-password
""");
public static TemporaryLegacyIni Create(byte[] bytes)
{
var directory = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(directory);
var path = System.IO.Path.Combine(directory, "MmoneyCoder.ini");
File.WriteAllBytes(path, bytes);
return new TemporaryLegacyIni(directory, path);
}
public void Dispose() =>
System.IO.Directory.Delete(Directory, recursive: true);
}
private sealed class TemporaryDirectory : IDisposable
{
private TemporaryDirectory(string path) => Path = path;
public string Path { get; }
public static TemporaryDirectory Create()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(path);
return new TemporaryDirectory(path);
}
public void Dispose() =>
System.IO.Directory.Delete(Path, recursive: true);
}
}

View File

@@ -0,0 +1,578 @@
using System.Text;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyRuntimeUiCatalogLoaderTests
{
private static readonly Encoding Cp949 = CreateCp949Encoding();
[Fact]
public void ConfiguredLegacyBinDebugFiles_MatchEveryRuntimeProjection()
{
var legacyBinDebug = Environment.GetEnvironmentVariable(
"MBN_STOCK_N_LEGACY_BIN_DEBUG");
if (string.IsNullOrWhiteSpace(legacyBinDebug))
{
return;
}
var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
legacyBinDebug);
Assert.Empty(composition.Warnings);
AssertRuntimeFixedFile(
legacyBinDebug,
"해외.ini",
LegacyFixedMarket.Overseas,
LegacyFixedActionCatalog.ExpectedOverseasCount,
composition.FixedCatalog);
AssertRuntimeFixedFile(
legacyBinDebug,
"환율.ini",
LegacyFixedMarket.Exchange,
LegacyFixedActionCatalog.ExpectedExchangeCount,
composition.FixedCatalog);
AssertRuntimeFixedFile(
legacyBinDebug,
"지수.ini",
LegacyFixedMarket.Index,
LegacyFixedActionCatalog.ExpectedIndexCount,
composition.FixedCatalog);
AssertRuntimeIndustryFile(
legacyBinDebug,
"업종_코스피.ini",
IndustryMarket.Kospi,
composition.IndustryLayout);
AssertRuntimeIndustryFile(
legacyBinDebug,
"업종_코스닥.ini",
IndustryMarket.Kosdaq,
composition.IndustryLayout);
AssertRuntimeComparisonFile(
legacyBinDebug,
composition.ComparisonLayout);
}
[Fact]
public void RuntimeFiles_ControlOnlyTrustedDisplayOrder()
{
using var directory = new TemporaryDirectory();
var fixedOrders = Enum.GetValues<LegacyFixedMarket>()
.ToDictionary(
market => market,
market => LegacyFixedActionCatalog.Default.GetActions(market)
.Reverse()
.ToArray());
var kospi = LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kospi)
.Reverse()
.ToArray();
var kosdaq = LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq)
.Reverse()
.ToArray();
var targets = LegacyComparisonWorkflowCatalog.FixedTargets
.Reverse()
.ToArray();
var directActions = LegacyComparisonWorkflowCatalog.Actions
.Where(action => action.Kind != LegacyComparisonActionKind.Return)
.Reverse()
.ToArray();
var returnActions = LegacyComparisonWorkflowCatalog.Actions
.Where(action => action.Kind == LegacyComparisonActionKind.Return)
.Reverse()
.ToArray();
WriteAllValidFiles(
directory.Path,
fixedOrders,
kospi,
kosdaq,
targets,
directActions,
returnActions,
returnSectionFirst: true);
var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
directory.Path);
Assert.Empty(composition.Warnings);
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
{
Assert.Equal(
FlattenForIni(fixedOrders[market]).Select(action => action.Id),
composition.FixedCatalog.GetActions(market).Select(action => action.Id));
}
Assert.Equal(
kospi.Select(action => action.ActionId),
composition.IndustryLayout.GetActions(IndustryMarket.Kospi)
.Select(action => action.ActionId));
Assert.Equal(
kosdaq.Select(action => action.ActionId),
composition.IndustryLayout.GetActions(IndustryMarket.Kosdaq)
.Select(action => action.ActionId));
Assert.Equal(
targets.Select(target => target.TargetId),
composition.ComparisonLayout.FixedTargets.Select(target => target.TargetId));
Assert.Equal(
returnActions.Select(action => action.ActionId)
.Concat(directActions.Select(action => action.ActionId)),
composition.ComparisonLayout.CreateActionViews(null, null)
.Select(action => action.ActionId));
var industryWorkflow = new LegacyIndustrySelectionWorkflow(
new EmptyIndustrySelectionService(),
actionLayout: composition.IndustryLayout);
Assert.Equal(
kospi.Select(action => action.ActionId),
industryWorkflow.ResetForTabActivation(IndustryMarket.Kospi)
.Actions.Select(action => action.ActionId));
var comparisonWorkflow = new LegacyComparisonWorkflowController(
new EmptyComparisonDataService(),
new EmptyComparisonPairStore(),
composition.ComparisonLayout);
Assert.Equal(
targets.Select(target => target.DisplayName),
comparisonWorkflow.Current.FixedTargets.Select(target => target.DisplayName));
Assert.Equal(
returnActions.Select(action => action.ActionId)
.Concat(directActions.Select(action => action.ActionId)),
comparisonWorkflow.Current.Actions.Select(action => action.ActionId));
}
[Fact]
public void UnknownFixedRow_FallsBackWithoutRegisteringAnAction()
{
using var directory = new TemporaryDirectory();
WriteAllValidFiles(directory.Path);
WriteFixedFile(
directory.Path,
LegacyFixedMarket.Overseas,
LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Overseas),
(action, index) => index == 0 ? "untrusted-new-action" : action.Label);
var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
directory.Path);
var warning = Assert.Single(composition.Warnings);
Assert.Equal("해외.ini", warning.FileName);
Assert.Equal(LegacyRuntimeUiCatalogFailure.UnknownEntry, warning.Failure);
Assert.Equal(
LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Overseas)
.Select(action => action.Id),
composition.FixedCatalog.GetActions(LegacyFixedMarket.Overseas)
.Select(action => action.Id));
Assert.DoesNotContain(
composition.FixedCatalog.Actions,
action => string.Equals(
action.Label,
"untrusted-new-action",
StringComparison.Ordinal));
}
[Fact]
public void ExcessDuplicateRow_FallsBackWithDuplicateWarning()
{
using var directory = new TemporaryDirectory();
WriteAllValidFiles(directory.Path);
WriteFixedFile(
directory.Path,
LegacyFixedMarket.Exchange,
LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Exchange),
appendDuplicate: true);
var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
directory.Path);
var warning = Assert.Single(composition.Warnings);
Assert.Equal("환율.ini", warning.FileName);
Assert.Equal(LegacyRuntimeUiCatalogFailure.DuplicateEntry, warning.Failure);
Assert.Equal(
LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Exchange)
.Select(action => action.Id),
composition.FixedCatalog.GetActions(LegacyFixedMarket.Exchange)
.Select(action => action.Id));
}
[Fact]
public void InvalidCp949_FallsBackOnlyForThatFile()
{
using var directory = new TemporaryDirectory();
WriteAllValidFiles(directory.Path);
File.WriteAllBytes(
System.IO.Path.Combine(directory.Path, "Res", "지수.ini"),
[0x81]);
var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
directory.Path);
var warning = Assert.Single(composition.Warnings);
Assert.Equal("지수.ini", warning.FileName);
Assert.Equal(LegacyRuntimeUiCatalogFailure.InvalidEncoding, warning.Failure);
Assert.Equal(
LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Index)
.Select(action => action.Id),
composition.FixedCatalog.GetActions(LegacyFixedMarket.Index)
.Select(action => action.Id));
}
[Fact]
public void RowLimit_FailsClosedBeforeCatalogJoin()
{
using var directory = new TemporaryDirectory();
WriteAllValidFiles(directory.Path);
var section = LegacyFixedActionCatalog.Default
.GetActions(LegacyFixedMarket.Overseas)[0].Section;
WriteCp949(
System.IO.Path.Combine(directory.Path, "Res", "해외.ini"),
[$"[{section}]", .. Enumerable.Range(1, 51).Select(index => $"row-{index}")]);
var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
directory.Path);
var warning = Assert.Single(composition.Warnings);
Assert.Equal("해외.ini", warning.FileName);
Assert.Equal(LegacyRuntimeUiCatalogFailure.TooManyRows, warning.Failure);
}
[Fact]
public void ComparisonDirectActionCannotBeReinterpretedAsAChildRow()
{
using var directory = new TemporaryDirectory();
WriteAllValidFiles(directory.Path);
var directActions = LegacyComparisonWorkflowCatalog.Actions
.Where(action => action.Kind != LegacyComparisonActionKind.Return)
.ToArray();
var returnActions = LegacyComparisonWorkflowCatalog.Actions
.Where(action => action.Kind == LegacyComparisonActionKind.Return)
.ToArray();
var lines = new List<string> { "[지수]" };
lines.AddRange(LegacyComparisonWorkflowCatalog.FixedTargets
.Select(target => target.DisplayName));
foreach (var action in directActions)
{
lines.Add($"[{action.Label}]");
}
lines.Add($"[{returnActions[0].Section}]");
lines.Add(directActions[0].Label);
lines.AddRange(returnActions.Select(action => action.Label));
WriteCp949(
System.IO.Path.Combine(directory.Path, "Res", "종목비교.ini"),
lines);
var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
directory.Path);
var warning = Assert.Single(composition.Warnings);
Assert.Equal("종목비교.ini", warning.FileName);
Assert.Equal(LegacyRuntimeUiCatalogFailure.UnknownEntry, warning.Failure);
Assert.Equal(
LegacyComparisonWorkflowCatalog.Actions.Select(action => action.ActionId),
composition.ComparisonLayout.CreateActionViews(null, null)
.Select(action => action.ActionId));
}
private static void WriteAllValidFiles(
string baseDirectory,
IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedAction[]>? fixedOrders = null,
IReadOnlyList<LegacyIndustryActionDefinition>? kospi = null,
IReadOnlyList<LegacyIndustryActionDefinition>? kosdaq = null,
IReadOnlyList<LegacyComparisonTarget>? targets = null,
IReadOnlyList<LegacyComparisonActionDefinition>? directActions = null,
IReadOnlyList<LegacyComparisonActionDefinition>? returnActions = null,
bool returnSectionFirst = false)
{
Directory.CreateDirectory(System.IO.Path.Combine(baseDirectory, "Res"));
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
{
WriteFixedFile(
baseDirectory,
market,
fixedOrders is not null && fixedOrders.TryGetValue(market, out var ordered)
? ordered
: LegacyFixedActionCatalog.Default.GetActions(market));
}
WriteIndustryFile(
baseDirectory,
IndustryMarket.Kospi,
kospi ?? LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kospi));
WriteIndustryFile(
baseDirectory,
IndustryMarket.Kosdaq,
kosdaq ?? LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq));
WriteComparisonFile(
baseDirectory,
targets ?? LegacyComparisonWorkflowCatalog.FixedTargets,
directActions ?? LegacyComparisonWorkflowCatalog.Actions
.Where(action => action.Kind != LegacyComparisonActionKind.Return)
.ToArray(),
returnActions ?? LegacyComparisonWorkflowCatalog.Actions
.Where(action => action.Kind == LegacyComparisonActionKind.Return)
.ToArray(),
returnSectionFirst);
}
private static void WriteFixedFile(
string baseDirectory,
LegacyFixedMarket market,
IReadOnlyList<LegacyFixedAction> actions,
Func<LegacyFixedAction, int, string>? labelSelector = null,
bool appendDuplicate = false)
{
var lines = new List<string>();
var rowIndex = 0;
foreach (var section in actions.GroupBy(action => action.Section, StringComparer.Ordinal))
{
lines.Add($"[{section.Key}]");
foreach (var action in section)
{
lines.Add(labelSelector?.Invoke(action, rowIndex) ?? action.Label);
rowIndex++;
}
if (appendDuplicate)
{
lines.Add(section.First().Label);
appendDuplicate = false;
}
}
WriteCp949(
System.IO.Path.Combine(baseDirectory, "Res", FixedFileName(market)),
lines);
}
private static IEnumerable<LegacyFixedAction> FlattenForIni(
IEnumerable<LegacyFixedAction> actions) =>
actions.GroupBy(action => action.Section, StringComparer.Ordinal)
.SelectMany(group => group);
private static void WriteIndustryFile(
string baseDirectory,
IndustryMarket market,
IReadOnlyList<LegacyIndustryActionDefinition> actions)
{
var section = market == IndustryMarket.Kospi ? "코스피" : "코스닥";
WriteCp949(
System.IO.Path.Combine(baseDirectory, "Res", IndustryFileName(market)),
[$"[{section}]", .. actions.Select(action => action.Label)]);
}
private static void WriteComparisonFile(
string baseDirectory,
IReadOnlyList<LegacyComparisonTarget> targets,
IReadOnlyList<LegacyComparisonActionDefinition> directActions,
IReadOnlyList<LegacyComparisonActionDefinition> returnActions,
bool returnSectionFirst)
{
var lines = new List<string> { "[지수]" };
lines.AddRange(targets.Select(target => target.DisplayName));
void AddDirectSections()
{
foreach (var action in directActions)
{
lines.Add($"[{action.Label}]");
}
}
void AddReturnSection()
{
lines.Add($"[{returnActions[0].Section}]");
lines.AddRange(returnActions.Select(action => action.Label));
}
if (returnSectionFirst)
{
AddReturnSection();
AddDirectSections();
}
else
{
AddDirectSections();
AddReturnSection();
}
WriteCp949(
System.IO.Path.Combine(baseDirectory, "Res", "종목비교.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 AssertRuntimeFixedFile(
string baseDirectory,
string fileName,
LegacyFixedMarket market,
int expectedCount,
LegacyFixedActionCatalog catalog)
{
var document = ReadTestIni(baseDirectory, fileName);
var actions = catalog.GetActions(market);
Assert.Equal(expectedCount, actions.Count);
Assert.Equal(
document.SelectMany(section => section.Rows),
actions.Select(action => action.Label));
Assert.Equal(
document.SelectMany(section => section.Rows.Select(_ => section.Name)),
actions.Select(action => action.Section));
}
private static void AssertRuntimeIndustryFile(
string baseDirectory,
string fileName,
IndustryMarket market,
LegacyIndustryActionLayout layout)
{
var section = Assert.Single(ReadTestIni(baseDirectory, fileName));
var actions = layout.GetActions(market);
Assert.Equal(LegacyIndustryActionCatalog.ActionsPerMarket, actions.Count);
Assert.Equal(section.Rows, actions.Select(action => action.Label));
Assert.All(actions, action => Assert.Equal(section.Name, action.Section));
}
private static void AssertRuntimeComparisonFile(
string baseDirectory,
LegacyComparisonUiLayout layout)
{
var document = ReadTestIni(baseDirectory, "종목비교.ini");
Assert.Equal(6, document.Count);
Assert.Equal(24, layout.FixedTargets.Count);
Assert.Equal(
document[0].Rows,
layout.FixedTargets.Select(target => target.DisplayName));
var expectedActions = document.Skip(1).SelectMany(section =>
section.Rows.Count == 0
? new[] { (Label: section.Name, Section: section.Name) }
: section.Rows.Select(row => (Label: row, Section: section.Name)));
var actualActions = layout.CreateActionViews(null, null);
Assert.Equal(9, actualActions.Count);
Assert.Equal(
expectedActions,
actualActions.Select(action => (action.Label, action.Section)));
}
private static IReadOnlyList<TestIniSection> ReadTestIni(
string baseDirectory,
string fileName)
{
var sections = new List<TestIniSection>();
List<string>? rows = null;
foreach (var source in File.ReadAllLines(
System.IO.Path.Combine(baseDirectory, "Res", fileName),
Cp949))
{
var line = source.Trim();
if (line.Length == 0)
{
continue;
}
if (line[0] == '[' && line[^1] == ']')
{
rows = [];
sections.Add(new TestIniSection(line[1..^1].Trim(), rows));
}
else
{
Assert.NotNull(rows);
rows.Add(line);
}
}
return sections;
}
private static string FixedFileName(LegacyFixedMarket market) => market switch
{
LegacyFixedMarket.Overseas => "해외.ini",
LegacyFixedMarket.Exchange => "환율.ini",
LegacyFixedMarket.Index => "지수.ini",
_ => throw new ArgumentOutOfRangeException(nameof(market), market, null)
};
private static string IndustryFileName(IndustryMarket market) => market switch
{
IndustryMarket.Kospi => "업종_코스피.ini",
IndustryMarket.Kosdaq => "업종_코스닥.ini",
_ => throw new ArgumentOutOfRangeException(nameof(market), market, null)
};
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private sealed class EmptyIndustrySelectionService : IIndustrySelectionService
{
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
IndustryMarket market,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<IndustrySelection>>(
Array.Empty<IndustrySelection>());
}
private sealed class EmptyComparisonDataService : ILegacyComparisonDataService
{
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<LegacyComparisonTarget>>(
Array.Empty<LegacyComparisonTarget>());
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<LegacyComparisonTarget>>(
Array.Empty<LegacyComparisonTarget>());
public Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
LegacyComparisonStoredTargetReference target,
CancellationToken cancellationToken = default) =>
Task.FromResult<LegacyComparisonTarget?>(null);
}
private sealed class EmptyComparisonPairStore : ILegacyComparisonPairStore
{
public Task<byte[]?> ReadCp949Async(
CancellationToken cancellationToken = default) =>
Task.FromResult<byte[]?>(null);
public Task WriteCp949Async(
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
CancellationToken cancellationToken = default) =>
Task.CompletedTask;
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"MBN_STOCK_WEBVIEW_UI_INI_{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
private sealed record TestIniSection(string Name, IReadOnlyList<string> Rows);
}

View File

@@ -0,0 +1,121 @@
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyDbSmokeRuntimeSelectionContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string Program = File.ReadAllText(Path.Combine(
RepositoryRoot,
"tools",
"MBN_STOCK_WEBVIEW.DbSmoke",
"Program.cs"));
private static readonly string NxtAudit = File.ReadAllText(Path.Combine(
RepositoryRoot,
"tools",
"MBN_STOCK_WEBVIEW.DbSmoke",
"NxtThemeRestoreDbAudit.cs"));
[Fact]
public void SelectedLegacyRuntimeEndpointIsInjectedIntoNxtAudit()
{
Assert.Contains(
"var iniPath = Path.Combine(root, \"Res\", \"MmoneyCoder.ini\");",
Program,
StringComparison.Ordinal);
Assert.Contains(
"new LegacyIniDatabaseOptionsLoader(_ => null).Load(iniPath)",
Program,
StringComparison.Ordinal);
Assert.Contains(
"MariaDbEndpointReference.From(selectedOptions.MariaDb)",
Program,
StringComparison.Ordinal);
Assert.Contains(
"selection.ReferenceMariaDb",
Program,
StringComparison.Ordinal);
Assert.Contains(
"MariaDbEndpointReference referenceOptions",
NxtAudit,
StringComparison.Ordinal);
Assert.Contains(
"referenceOptions.Host",
NxtAudit,
StringComparison.Ordinal);
Assert.Contains(
"referenceOptions.Port",
NxtAudit,
StringComparison.Ordinal);
Assert.Contains(
"referenceOptions.Database",
NxtAudit,
StringComparison.Ordinal);
}
[Fact]
public void JsonRuntimeEndpointIsCapturedBeforeEnvironmentOverrides()
{
Assert.Contains(
"new DatabaseOptionsLoader(_ => null).Load(configurationPath)",
Program,
StringComparison.Ordinal);
Assert.Contains(
"var runtime = DatabaseRuntime.CreateDefault(configurationPath);",
Program,
StringComparison.Ordinal);
Assert.Contains(
"HasCompleteEndpointIdentity(configuredMariaDb)",
Program,
StringComparison.Ordinal);
Assert.Contains(
"MariaDbEndpointReference.From(configuredMariaDb)",
Program,
StringComparison.Ordinal);
Assert.Contains(
"MariaDbEndpointReference.From(runtime.Options.MariaDb)",
Program,
StringComparison.Ordinal);
}
[Fact]
public void NxtAuditHasNoMachineSpecificOriginalConfigurationDependency()
{
Assert.DoesNotContain("OriginalConfigurationPath", NxtAudit, StringComparison.Ordinal);
Assert.DoesNotContain("ReadOriginalEndpoint", NxtAudit, StringComparison.Ordinal);
Assert.DoesNotContain("MBN_STOCK_N", NxtAudit, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("MmoneyCoder.ini", NxtAudit, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void InjectedReferenceContainsEndpointIdentityOnly()
{
Assert.Contains(
"internal sealed record MariaDbEndpointReference(",
NxtAudit,
StringComparison.Ordinal);
Assert.Contains("string? Host", NxtAudit, StringComparison.Ordinal);
Assert.Contains("int Port", NxtAudit, StringComparison.Ordinal);
Assert.Contains("string? Database", NxtAudit, StringComparison.Ordinal);
Assert.DoesNotContain("UserName", NxtAudit, StringComparison.Ordinal);
Assert.DoesNotContain("Password", NxtAudit, StringComparison.Ordinal);
Assert.Contains("originalSha256=", NxtAudit, StringComparison.Ordinal);
Assert.Contains("currentSha256=", NxtAudit, StringComparison.Ordinal);
Assert.Contains("configuredEqual=", NxtAudit, StringComparison.Ordinal);
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate the repository root.");
}
}

View File

@@ -0,0 +1,238 @@
using System.Xml.Linq;
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyRuntimeAssetDeploymentContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string ProjectPath = Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"MBN_STOCK_WEBVIEW.LegacyParityApp.csproj");
[Fact]
public void LegacyParityProjectLinksCutsAndResIntoOutputPublishAndMsix()
{
var project = XDocument.Load(ProjectPath);
var contents = project.Descendants("Content").ToArray();
AssertLinkedRuntimeTree(contents, "$(LegacyCutsSourceRoot)\\**\\*", "Cuts\\");
AssertLinkedRuntimeTree(contents, "@(LegacyPackagedResAsset)", "Res\\");
var packagedResNames = project.Descendants("LegacyPackagedResAsset")
.Select(element => Path.GetFileName(element.Attribute("Include")?.Value))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
string[] requiredUiMenus =
[
"setup.ini",
"기타.ini",
"업종.ini",
"업종_코스닥.ini",
"업종_코스피.ini",
"종목.ini",
"종목_0111.ini",
"종목비교.ini",
"지수.ini",
"지수_0318.ini",
"지수_250401.ini",
"해외.ini",
"환율.ini"
];
Assert.Equal(34, packagedResNames.Count);
Assert.All(requiredUiMenus, name => Assert.Contains(name, packagedResNames));
Assert.DoesNotContain("MmoneyCoder.ini", packagedResNames);
Assert.DoesNotContain("MmoneyCoder - 복사본.ini", packagedResNames);
Assert.DoesNotContain("MmoneyCoder.ini.bak", packagedResNames);
Assert.DoesNotContain("MmoneyCoder.zip", packagedResNames);
Assert.DoesNotContain("afiedt.buf.txt", packagedResNames);
}
[Fact]
public void ActiveDatabaseIniIsPostBuildOutputOnlyAndNeverAPackagingItem()
{
var project = XDocument.Load(ProjectPath);
Assert.DoesNotContain(
project.Descendants().Where(element =>
element.Name.LocalName is "Content" or "None"),
element => (element.Attribute("Include")?.Value ?? string.Empty)
.Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
var upToDateInput = Assert.Single(project.Descendants("UpToDateCheckInput"));
Assert.Equal(
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
upToDateInput.Attribute("Include")?.Value);
var upToDateBuilt = Assert.Single(project.Descendants("UpToDateCheckBuilt"));
Assert.Equal(
"$(TargetDir)Res\\MmoneyCoder.ini",
upToDateBuilt.Attribute("Include")?.Value);
var copyTarget = Assert.Single(
project.Descendants("Target"),
element => string.Equals(
element.Attribute("Name")?.Value,
"CopyLegacyDatabaseIniToLocalOutput",
StringComparison.Ordinal));
Assert.Equal("Build", copyTarget.Attribute("AfterTargets")?.Value);
Assert.Contains(
"GenerateAppxPackageOnBuild",
copyTarget.Attribute("Condition")?.Value,
StringComparison.Ordinal);
Assert.Contains(
"PublishAppxPackage",
copyTarget.Attribute("Condition")?.Value,
StringComparison.Ordinal);
Assert.Equal(
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
copyTarget.Attribute("Inputs")?.Value);
Assert.Equal(
"$(TargetDir)Res\\MmoneyCoder.ini",
copyTarget.Attribute("Outputs")?.Value);
var copy = Assert.Single(copyTarget.Elements("Copy"));
Assert.Equal(
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
copy.Attribute("SourceFiles")?.Value);
Assert.Equal(
"$(TargetDir)Res\\MmoneyCoder.ini",
copy.Attribute("DestinationFiles")?.Value);
var fileWrite = Assert.Single(copyTarget.Descendants("FileWrites"));
Assert.Equal(
"$(TargetDir)Res\\MmoneyCoder.ini",
fileWrite.Attribute("Include")?.Value);
var cleanTarget = Assert.Single(
project.Descendants("Target"),
element => string.Equals(
element.Attribute("Name")?.Value,
"RemoveLegacyDatabaseIniFromLocalOutput",
StringComparison.Ordinal));
Assert.Equal("Clean", cleanTarget.Attribute("BeforeTargets")?.Value);
var delete = Assert.Single(cleanTarget.Elements("Delete"));
Assert.Equal(
"$(TargetDir)Res\\MmoneyCoder.ini",
delete.Attribute("Files")?.Value);
var rejectionTarget = Assert.Single(
project.Descendants("Target"),
element => string.Equals(
element.Attribute("Name")?.Value,
"RejectLegacyDatabaseIniFromPackagePayload",
StringComparison.Ordinal));
Assert.Equal(
"_ComputeAppxPackagePayload",
rejectionTarget.Attribute("BeforeTargets")?.Value);
Assert.Single(rejectionTarget.Elements("Error"));
}
[Fact]
public void LegacyRuntimeSourceRootIsOverrideableAndValidatedBeforeBuild()
{
var project = XDocument.Load(ProjectPath);
var sourceRoot = Assert.Single(
project.Descendants("LegacyRuntimeSourceRoot"),
element => element.Attribute("Condition") is not null);
Assert.Equal("'$(LegacyRuntimeSourceRoot)' == ''", sourceRoot.Attribute("Condition")!.Value);
Assert.Contains("MBN_STOCK_N\\MBN_STOCK_N\\bin\\Debug", sourceRoot.Value,
StringComparison.Ordinal);
var target = Assert.Single(
project.Descendants("Target"),
element => string.Equals(
element.Attribute("Name")?.Value,
"ValidateLegacyRuntimeAssets",
StringComparison.Ordinal));
Assert.Equal("PrepareForBuild", target.Attribute("BeforeTargets")?.Value);
var conditions = target.Elements("Error")
.Select(error => error.Attribute("Condition")?.Value)
.Where(condition => condition is not null)
.ToArray();
string[] requiredSentinels =
[
"$(LegacyRuntimeSourceRoot)",
"$(LegacyCutsSourceRoot)",
"$(LegacyResSourceRoot)",
"$(LegacyCutsSourceRoot)\\5001.t2s",
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
"$(LegacyResSourceRoot)\\종목.ini",
"$(LegacyResSourceRoot)\\업종_코스피.ini",
"$(LegacyResSourceRoot)\\업종_코스닥.ini",
"$(LegacyResSourceRoot)\\해외.ini",
"$(LegacyResSourceRoot)\\환율.ini",
"$(LegacyResSourceRoot)\\지수.ini"
];
Assert.All(requiredSentinels, sentinel =>
Assert.Contains(conditions, condition =>
condition!.Contains(sentinel, StringComparison.Ordinal)));
var databaseIniError = Assert.Single(
target.Elements("Error"),
error => (error.Attribute("Condition")?.Value ?? string.Empty)
.Contains("$(LegacyResSourceRoot)\\MmoneyCoder.ini", StringComparison.Ordinal));
Assert.Contains(
"GenerateAppxPackageOnBuild",
databaseIniError.Attribute("Condition")?.Value,
StringComparison.Ordinal);
Assert.Contains(
"PublishAppxPackage",
databaseIniError.Attribute("Condition")?.Value,
StringComparison.Ordinal);
}
[Fact]
public void RepositoryDoesNotContainCopiedLegacyRuntimeTrees()
{
Assert.False(Directory.Exists(Path.Combine(RepositoryRoot, "Cuts")));
Assert.False(Directory.Exists(Path.Combine(RepositoryRoot, "Res")));
Assert.False(Directory.Exists(Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"Cuts")));
Assert.False(Directory.Exists(Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"Res")));
}
private static void AssertLinkedRuntimeTree(
IReadOnlyCollection<XElement> contents,
string include,
string targetPrefix)
{
var content = Assert.Single(
contents,
element => string.Equals(
element.Attribute("Include")?.Value,
include,
StringComparison.Ordinal));
Assert.StartsWith(targetPrefix, content.Element("Link")?.Value, StringComparison.Ordinal);
Assert.StartsWith(
targetPrefix,
content.Element("TargetPath")?.Value,
StringComparison.Ordinal);
Assert.Equal("PreserveNewest", content.Element("CopyToOutputDirectory")?.Value);
Assert.Equal("PreserveNewest", content.Element("CopyToPublishDirectory")?.Value);
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate the repository root.");
}
}

View File

@@ -38,32 +38,37 @@ public sealed class LegacyRuntimeStockCutMenuNativeContractTests
}
[Fact]
public void DatabaseInitializationErrorWinsOverTheSingleCutMenuFallbackWarning()
public void DatabaseInitializationErrorWinsOverCombinedRuntimeCatalogWarnings()
{
var errorBranch = MainWindow.IndexOf(
"if (initializationError is not null)",
StringComparison.Ordinal);
var warningBranch = MainWindow.IndexOf(
"else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)",
"cutMenuComposition.WarningMessage,",
errorBranch,
StringComparison.Ordinal);
var runtimeUiWarning = MainWindow.IndexOf(
"runtimeUiCatalogs.WarningMessage",
warningBranch,
StringComparison.Ordinal);
var errorReport = MainWindow.IndexOf(
"_controller.ReportError(initializationError);",
errorBranch,
StringComparison.Ordinal);
var warningReport = MainWindow.IndexOf(
"_controller.ReportWarning(cutMenuWarning);",
"_controller.ReportWarning(string.Join(\" \", catalogWarnings));",
warningBranch,
StringComparison.Ordinal);
Assert.True(errorBranch >= 0);
Assert.True(errorReport > errorBranch);
Assert.True(warningBranch > errorReport);
Assert.True(runtimeUiWarning > warningBranch);
Assert.True(warningReport > warningBranch);
Assert.Equal(
warningReport,
MainWindow.LastIndexOf(
"_controller.ReportWarning(cutMenuWarning);",
"_controller.ReportWarning(string.Join(\" \", catalogWarnings));",
StringComparison.Ordinal));
}

View File

@@ -62,6 +62,39 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn);
}
[Fact]
public void ApplyExecutableAssetDefaults_UsesCutsBesideExecutable()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
var cuts = Path.Combine(directory, "Cuts");
Directory.CreateDirectory(cuts);
try
{
var options = new PlayoutOptions();
PlayoutOptionsLoader.ApplyExecutableAssetDefaults(options, directory);
Assert.Equal(Path.GetFullPath(cuts), options.SceneDirectory);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ApplyExecutableAssetDefaults_DoesNotReplaceExplicitSceneDirectory()
{
var options = new PlayoutOptions
{
SceneDirectory = "C:\\explicit-cuts"
};
PlayoutOptionsLoader.ApplyExecutableAssetDefaults(options, "C:\\runtime");
Assert.Equal("C:\\explicit-cuts", options.SceneDirectory);
}
[Fact]
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
{