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() .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()) { 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 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() { 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 { "[지수]" }; 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? fixedOrders = null, IReadOnlyList? kospi = null, IReadOnlyList? kosdaq = null, IReadOnlyList? targets = null, IReadOnlyList? directActions = null, IReadOnlyList? returnActions = null, bool returnSectionFirst = false) { Directory.CreateDirectory(System.IO.Path.Combine(baseDirectory, "Res")); foreach (var market in Enum.GetValues()) { 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 actions, Func? labelSelector = null, bool appendDuplicate = false) { var lines = new List(); 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 FlattenForIni( IEnumerable actions) => actions.GroupBy(action => action.Section, StringComparer.Ordinal) .SelectMany(group => group); private static void WriteIndustryFile( string baseDirectory, IndustryMarket market, IReadOnlyList 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 targets, IReadOnlyList directActions, IReadOnlyList returnActions, bool returnSectionFirst) { var lines = new List { "[지수]" }; 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 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 ReadTestIni( string baseDirectory, string fileName) { var sections = new List(); List? 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> GetAsync( IndustryMarket market, CancellationToken cancellationToken = default) => Task.FromResult>( Array.Empty()); } private sealed class EmptyComparisonDataService : ILegacyComparisonDataService { public Task> SearchDomesticAsync( string query, int maximumResults, CancellationToken cancellationToken = default) => Task.FromResult>( Array.Empty()); public Task> SearchWorldAsync( string query, int maximumResults, CancellationToken cancellationToken = default) => Task.FromResult>( Array.Empty()); public Task ResolveStoredTargetAsync( LegacyComparisonStoredTargetReference target, CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class EmptyComparisonPairStore : ILegacyComparisonPairStore { public Task ReadCp949Async( CancellationToken cancellationToken = default) => Task.FromResult(null); public Task WriteCp949Async( IReadOnlyList 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 Rows); }