Files
MBN_STOCK_WEBVIEW/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeStockCutMenuLoaderTests.cs

448 lines
16 KiB
C#

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<string, LegacyRuntimeStockCutMenuFailure> 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<string>(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<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
}