using System.Text; using MBN_STOCK_WEBVIEW.LegacyApplication; namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; public sealed class LegacyRuntimeStockCutMenuLoaderTests { [Fact] public void TryParse_UsesCp949AndPreservesTrimmedPhysicalOrder() { const string first = "1열판기본_현재가"; Assert.Equal(first, LegacyStockCutCatalog.Rows[1].RawLabel); var second = LegacyStockCutCatalog.Rows[0].RawLabel; var bytes = EncodeCp949($" \r\n [종목] \r\n {first} \r\n\r\n - \r\n{second}\r\n{first}\r\n"); var loaded = LegacyRuntimeStockCutMenuLoader.TryParse( bytes, out var rows, out var failure); Assert.True(loaded); Assert.Equal(LegacyRuntimeStockCutMenuFailure.None, failure); Assert.Collection( rows, row => Assert.Equal((0, 1, first, false), Fields(row)), row => Assert.Equal((1, null, string.Empty, true), Fields(row)), row => Assert.Equal((2, 2, second, false), Fields(row)), row => Assert.Equal((3, 3, first, false), Fields(row))); } [Fact] public void TryLoad_ReadsAStandaloneRuntimeFileWithoutBundlingTheAsset() { var directory = Path.Combine( Path.GetTempPath(), $"mbn-stock-cut-menu-{Guid.NewGuid():N}"); var path = Path.Combine(directory, "종목.ini"); Directory.CreateDirectory(directory); try { var label = LegacyStockCutCatalog.Rows[0].RawLabel; File.WriteAllBytes(path, EncodeCp949($"[종목]\r\n{label}\r\n")); var loaded = LegacyRuntimeStockCutMenuLoader.TryLoad( path, out var rows, out var failure); Assert.True(loaded); Assert.Equal(LegacyRuntimeStockCutMenuFailure.None, failure); var row = Assert.Single(rows); Assert.Equal((0, 1, label, false), Fields(row)); } finally { Directory.Delete(directory, recursive: true); } } [Theory] [InlineData(50, true, LegacyRuntimeStockCutMenuFailure.None)] [InlineData(51, false, LegacyRuntimeStockCutMenuFailure.TooManyRows)] public void TryParse_EnforcesTheLegacyFiftyPhysicalRowLimit( int rowCount, bool expectedSuccess, LegacyRuntimeStockCutMenuFailure expectedFailure) { var label = LegacyStockCutCatalog.Rows[0].RawLabel; var text = LegacyRuntimeStockCutMenuLoader.StockSectionHeader + "\r\n" + string.Join("\r\n", Enumerable.Repeat(label, rowCount)); var loaded = LegacyRuntimeStockCutMenuLoader.TryParse( EncodeCp949(text), out var rows, out var failure); Assert.Equal(expectedSuccess, loaded); Assert.Equal(expectedFailure, failure); Assert.Equal(expectedSuccess ? rowCount : 0, rows.Count); } public static TheoryData MalformedMenus => new() { { string.Empty, LegacyRuntimeStockCutMenuFailure.InvalidFormat }, { "[종목", LegacyRuntimeStockCutMenuFailure.InvalidFormat }, { LegacyStockCutCatalog.Rows[0].RawLabel, LegacyRuntimeStockCutMenuFailure.InvalidFormat }, { "[다른메뉴]\r\n-", LegacyRuntimeStockCutMenuFailure.InvalidFormat }, { "[종목]\r\n[다른메뉴]", LegacyRuntimeStockCutMenuFailure.InvalidFormat }, { "[종목]", LegacyRuntimeStockCutMenuFailure.EmptyMenu }, { "[종목]\r\n-", LegacyRuntimeStockCutMenuFailure.EmptyMenu }, { "[종목]\r\n지원하지않는_컷", LegacyRuntimeStockCutMenuFailure.UnknownCut } }; [Theory] [MemberData(nameof(MalformedMenus))] public void TryParse_FailsClosedWithoutReturningPartialRows( string text, LegacyRuntimeStockCutMenuFailure expectedFailure) { var loaded = LegacyRuntimeStockCutMenuLoader.TryParse( EncodeCp949(text), out var rows, out var failure); Assert.False(loaded); Assert.Empty(rows); Assert.Equal(expectedFailure, failure); } [Fact] public void TryParse_RejectsAnIncompleteCp949Sequence() { var loaded = LegacyRuntimeStockCutMenuLoader.TryParse( new byte[] { 0x81 }, out var rows, out var failure); Assert.False(loaded); Assert.Empty(rows); Assert.Equal(LegacyRuntimeStockCutMenuFailure.InvalidEncoding, failure); } [Fact] public void TryLoad_MissingOrBlankPathLetsTheCallerUseTheBuiltInCatalog() { var missing = Path.Combine( Path.GetTempPath(), $"missing-cut-menu-{Guid.NewGuid():N}.ini"); Assert.False(LegacyRuntimeStockCutMenuLoader.TryLoad( missing, out var missingRows, out var missingFailure)); Assert.Empty(missingRows); Assert.Equal(LegacyRuntimeStockCutMenuFailure.FileNotFound, missingFailure); Assert.False(LegacyRuntimeStockCutMenuLoader.TryLoad( " ", out var blankRows, out var blankFailure)); Assert.Empty(blankRows); Assert.Equal(LegacyRuntimeStockCutMenuFailure.InvalidPath, blankFailure); Assert.NotEmpty(LegacyStockCutCatalog.Rows); } [Fact] public void TryLoad_RejectsAnOversizedFileBeforeAllocatingOrDecodingItsContent() { var path = Path.Combine( Path.GetTempPath(), $"oversized-cut-menu-{Guid.NewGuid():N}.ini"); try { using (var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write)) { stream.SetLength(LegacyRuntimeStockCutMenuLoader.MaximumFileBytes + 1L); } var loaded = LegacyRuntimeStockCutMenuLoader.TryLoad( path, out var rows, out var failure); Assert.False(loaded); Assert.Empty(rows); Assert.Equal(LegacyRuntimeStockCutMenuFailure.FileTooLarge, failure); } finally { File.Delete(path); } } [Fact] public void ResolveFromBaseDirectory_MissingFileReturnsBoundedPathFreeFallback() { var directory = Path.Combine( Path.GetTempPath(), $"mbn-stock-cut-composition-{Guid.NewGuid():N}"); Directory.CreateDirectory(directory); try { var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(directory); Assert.True(composition.UsesBuiltInCatalog); Assert.Equal(LegacyRuntimeStockCutMenuSource.BuiltIn, composition.Source); Assert.Same(LegacyStockCutCatalog.Rows, composition.Rows); Assert.Equal( LegacyRuntimeStockCutMenuFailure.FileNotFound, composition.Failure); Assert.Equal( LegacyRuntimeStockCutMenuLoader.FallbackWarningMessage, composition.WarningMessage); var warning = Assert.IsType(composition.WarningMessage); Assert.InRange(warning.Length, 1, 80); Assert.DoesNotContain(directory, warning, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("종목.ini", warning, StringComparison.Ordinal); Assert.DoesNotContain('\\', warning); Assert.DoesNotContain('/', warning); var controller = new LegacyOperatorController( new EmptyStockLookup(), cutRows: composition.Rows); var warningState = controller.ReportWarning(warning); Assert.Equal(LegacyOperatorStatusKind.Warning, warningState.StatusKind); Assert.Equal(composition.WarningMessage, warningState.StatusMessage); var resourceDirectory = Path.Combine(directory, "Res"); Directory.CreateDirectory(resourceDirectory); File.WriteAllBytes( Path.Combine(resourceDirectory, "종목.ini"), EncodeCp949("[종목]\r\n지원하지않는_컷\r\n")); var invalid = LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(directory); Assert.True(invalid.UsesBuiltInCatalog); Assert.Same(LegacyStockCutCatalog.Rows, invalid.Rows); Assert.Equal(LegacyRuntimeStockCutMenuFailure.UnknownCut, invalid.Failure); Assert.Equal(composition.WarningMessage, invalid.WarningMessage); } finally { Directory.Delete(directory, recursive: true); } } [Fact] public void ResolveFromBaseDirectory_ValidRuntimeFileReturnsOnlyItsRows() { var directory = Path.Combine( Path.GetTempPath(), $"mbn-stock-cut-composition-{Guid.NewGuid():N}"); var resourceDirectory = Path.Combine(directory, "Res"); Directory.CreateDirectory(resourceDirectory); try { const string label = "1열판기본_현재가"; File.WriteAllBytes( Path.Combine(resourceDirectory, "종목.ini"), EncodeCp949($"[종목]\r\n{label}\r\n")); var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(directory); Assert.False(composition.UsesBuiltInCatalog); Assert.Equal(LegacyRuntimeStockCutMenuSource.Packaged, composition.Source); Assert.Equal(LegacyRuntimeStockCutMenuFailure.None, composition.Failure); Assert.Null(composition.WarningMessage); Assert.Equal(label, Assert.Single(composition.Rows).RawLabel); } finally { Directory.Delete(directory, recursive: true); } } [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() { var paths = CreateCandidatePaths(); try { WriteMenu(paths.ExternalPath, "1열판기본_현재가"); WriteMenu(paths.PackagedPath, "1열판기본_예상체결가"); var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( paths.ExternalPath, paths.PackagedBaseDirectory); Assert.Equal( LegacyRuntimeStockCutMenuSource.ExternalOverride, composition.Source); Assert.False(composition.UsesBuiltInCatalog); Assert.Null(composition.WarningMessage); Assert.Equal( "1열판기본_현재가", Assert.Single(composition.Rows).RawLabel); } finally { Directory.Delete(paths.Root, recursive: true); } } [Fact] public void ResolveFromCandidates_MissingExternalContinuesToPackagedMenu() { var paths = CreateCandidatePaths(); try { WriteMenu(paths.PackagedPath, "1열판기본_예상체결가"); var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( paths.ExternalPath, paths.PackagedBaseDirectory); Assert.Equal(LegacyRuntimeStockCutMenuSource.Packaged, composition.Source); Assert.False(composition.UsesBuiltInCatalog); Assert.Null(composition.WarningMessage); Assert.Equal( "1열판기본_예상체결가", Assert.Single(composition.Rows).RawLabel); } finally { Directory.Delete(paths.Root, recursive: true); } } [Fact] public void ResolveFromCandidates_InvalidExternalFailsClosedWithoutPackagedFallback() { var paths = CreateCandidatePaths(); try { WriteMenu(paths.ExternalPath, "지원하지않는_컷"); WriteMenu(paths.PackagedPath, "1열판기본_예상체결가"); var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( paths.ExternalPath, paths.PackagedBaseDirectory); Assert.Equal(LegacyRuntimeStockCutMenuSource.BuiltIn, composition.Source); Assert.True(composition.UsesBuiltInCatalog); Assert.Same(LegacyStockCutCatalog.Rows, composition.Rows); Assert.Equal(LegacyRuntimeStockCutMenuFailure.UnknownCut, composition.Failure); Assert.Equal( LegacyRuntimeStockCutMenuLoader.FallbackWarningMessage, composition.WarningMessage); } finally { Directory.Delete(paths.Root, recursive: true); } } [Fact] public void ResolveFromBaseDirectory_RejectsInvalidOrControlContainingPathsWithoutLeakage() { string?[] invalidPaths = [ null, string.Empty, " ", "relative\\package", "C:\\sensitive-root\nprivate", "C:\\sensitive-root\0private" ]; foreach (var invalidPath in invalidPaths) { var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(invalidPath); Assert.True(composition.UsesBuiltInCatalog); Assert.Equal(LegacyRuntimeStockCutMenuSource.BuiltIn, composition.Source); Assert.Same(LegacyStockCutCatalog.Rows, composition.Rows); Assert.Equal( LegacyRuntimeStockCutMenuFailure.InvalidPath, composition.Failure); Assert.Equal( LegacyRuntimeStockCutMenuLoader.FallbackWarningMessage, composition.WarningMessage); Assert.DoesNotContain("sensitive-root", composition.WarningMessage!, StringComparison.OrdinalIgnoreCase); } } private static (int PhysicalIndex, int? DisplayOrdinal, string RawLabel, bool IsSeparator) Fields(LegacyCutCatalogRow row) => (row.PhysicalIndex, row.DisplayOrdinal, row.RawLabel, row.IsSeparator); private static byte[] EncodeCp949(string text) { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); var cp949 = Encoding.GetEncoding( 949, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); return cp949.GetBytes(text); } private static ( string Root, string ExternalPath, string PackagedBaseDirectory, string PackagedPath) CreateCandidatePaths() { var root = Path.Combine( Path.GetTempPath(), $"mbn-stock-cut-candidates-{Guid.NewGuid():N}"); var externalPath = Path.Combine( root, "local", "MBN_STOCK_WEBVIEW", "Res", "종목.ini"); var packagedBaseDirectory = Path.Combine(root, "package"); return ( root, externalPath, packagedBaseDirectory, Path.Combine(packagedBaseDirectory, "Res", "종목.ini")); } private static void WriteMenu(string path, string label) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllBytes(path, EncodeCp949($"[종목]\r\n{label}\r\n")); } private sealed class EmptyStockLookup : ILegacyStockLookup { public Task SearchAsync( string query, CancellationToken cancellationToken = default) => Task.FromResult(new LegacyStockSearchData(query, [], [])); } }