421 lines
15 KiB
C#
421 lines
15 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 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, [], []));
|
|
}
|
|
}
|