feat: align legacy operator and playout behavior
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
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, [], []));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user