Migrate remaining legacy operator workflows
This commit is contained in:
@@ -55,8 +55,8 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
|
||||
string builderKey)
|
||||
{
|
||||
var executor = new RecordingExecutor(
|
||||
Count(1), Count(0),
|
||||
Count(0), Count(1));
|
||||
StockIdentity("첫번째", "111111"),
|
||||
StockIdentity("두번째", "222222"));
|
||||
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
|
||||
|
||||
var result = await resolver.ResolveAsync(Entry(
|
||||
@@ -65,7 +65,7 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
|
||||
subject: " 첫번째 , 두번째 ",
|
||||
subtype: "ignored-sub",
|
||||
graphicType: "ignored-forCutInfo",
|
||||
dataCode: "ignored-dataCode"));
|
||||
dataCode: "KOSPI:111111|KOSDAQ:222222"));
|
||||
|
||||
Assert.Equal(builderKey, result.BuilderKey);
|
||||
var request = result switch
|
||||
@@ -76,35 +76,90 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
|
||||
};
|
||||
Assert.Equal(ComparisonGraphPeriod.FiveDays, request.Period);
|
||||
Assert.Equal(
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "첫번째"),
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "첫번째", "111111"),
|
||||
request.First);
|
||||
Assert.Equal(
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "두번째"),
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "두번째", "222222"),
|
||||
request.Second);
|
||||
AssertStockLookupOrder(executor, "첫번째", "두번째");
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exact_pair_identity_preserves_market_and_code_without_cross_market_name_fallback()
|
||||
{
|
||||
var executor = new RecordingExecutor(
|
||||
StockIdentity("Duplicate", "111111"),
|
||||
StockIdentity("Second", "222222"));
|
||||
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
|
||||
|
||||
var result = Assert.IsType<LegacyS5026SceneLoadRequest>(await resolver.ResolveAsync(Entry(
|
||||
"5026",
|
||||
"STOCK",
|
||||
"Duplicate,Second",
|
||||
"",
|
||||
dataCode: "KOSPI:111111|KOSDAQ:222222")));
|
||||
|
||||
Assert.Equal(
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "Duplicate", "111111"),
|
||||
result.Request.First);
|
||||
Assert.Equal(
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "222222"),
|
||||
result.Request.Second);
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
Assert.Equal("SCENE_COMPARISON_RESOLVE_EXACT_KOSPI_STOCK", executor.Calls[0].TableName);
|
||||
Assert.Equal("SCENE_COMPARISON_RESOLVE_EXACT_KOSDAQ_STOCK", executor.Calls[1].TableName);
|
||||
Assert.Equal(
|
||||
new object?[] { "Duplicate", "111111" },
|
||||
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value));
|
||||
Assert.DoesNotContain(
|
||||
executor.Calls,
|
||||
call => call.TableName == "SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("일봉", ComparisonGraphPeriod.Daily)]
|
||||
[InlineData("5일", ComparisonGraphPeriod.FiveDays)]
|
||||
[InlineData("1개월", ComparisonGraphPeriod.OneMonth)]
|
||||
[InlineData("3개월", ComparisonGraphPeriod.ThreeMonths)]
|
||||
[InlineData("6개월", ComparisonGraphPeriod.SixMonths)]
|
||||
[InlineData("12개월", ComparisonGraphPeriod.TwelveMonths)]
|
||||
[InlineData("")]
|
||||
[InlineData("KOSPI:111111")]
|
||||
[InlineData("KOSPI:111111|KOSDAQ:")]
|
||||
[InlineData("KOSPI:111111|KOSDAQ:222222|KOSPI:333333")]
|
||||
[InlineData("KOSPI:111111|KOSDAQ:bad/code")]
|
||||
public async Task Malformed_exact_pair_identity_fails_before_database_access(string dataCode)
|
||||
{
|
||||
var executor = new RecordingExecutor();
|
||||
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
|
||||
|
||||
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
|
||||
Entry("5026", "STOCK", "First,Second", "", dataCode: dataCode)));
|
||||
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("\uC77C\uBD09", ComparisonGraphPeriod.Daily)]
|
||||
[InlineData("5\uC77C", ComparisonGraphPeriod.FiveDays)]
|
||||
[InlineData("1\uAC1C\uC6D4", ComparisonGraphPeriod.OneMonth)]
|
||||
[InlineData("3\uAC1C\uC6D4", ComparisonGraphPeriod.ThreeMonths)]
|
||||
[InlineData("6\uAC1C\uC6D4", ComparisonGraphPeriod.SixMonths)]
|
||||
[InlineData("12\uAC1C\uC6D4", ComparisonGraphPeriod.TwelveMonths)]
|
||||
public async Task S5029_maps_every_original_period(
|
||||
string subtype,
|
||||
ComparisonGraphPeriod expected)
|
||||
{
|
||||
var executor = new RecordingExecutor(
|
||||
Count(1), Count(0),
|
||||
Count(0), Count(1));
|
||||
StockIdentity("A", "111111"),
|
||||
StockIdentity("B", "222222"));
|
||||
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
|
||||
|
||||
var result = Assert.IsType<LegacyS5029SceneLoadRequest>(
|
||||
await resolver.ResolveAsync(Entry("5029", "", "A,B", subtype)));
|
||||
await resolver.ResolveAsync(Entry(
|
||||
"5029",
|
||||
"STOCK",
|
||||
"A,B",
|
||||
subtype,
|
||||
dataCode: "KOSPI:111111|KOSDAQ:222222")));
|
||||
|
||||
Assert.Equal(expected, result.Request.Period);
|
||||
Assert.Equal("A", result.Request.First.StockName);
|
||||
Assert.Equal("111111", result.Request.First.StockCode);
|
||||
Assert.Equal("B", result.Request.Second.StockName);
|
||||
}
|
||||
|
||||
@@ -251,34 +306,33 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
|
||||
const string firstName = "A' OR '1'='1";
|
||||
const string secondName = "B; DELETE FROM T_STOCK";
|
||||
var executor = new RecordingExecutor(
|
||||
Count(1), Count(0),
|
||||
Count(0), Count(1));
|
||||
StockIdentity(firstName, "111111"),
|
||||
StockIdentity(secondName, "222222"));
|
||||
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
|
||||
|
||||
_ = await resolver.ResolveAsync(Entry(
|
||||
"5026",
|
||||
"",
|
||||
"STOCK",
|
||||
firstName + "," + secondName,
|
||||
""));
|
||||
"",
|
||||
dataCode: "KOSPI:111111|KOSDAQ:222222"));
|
||||
|
||||
AssertStockLookupOrder(executor, firstName, secondName);
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
Assert.All(executor.Calls, call =>
|
||||
{
|
||||
Assert.Equal(DataSourceKind.Oracle, call.Source);
|
||||
Assert.Contains("SELECT COUNT(*)", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("SELECT STOCK_NAME, STOCK_CODE", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("INSERT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("UPDATE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("DELETE FROM", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
var parameter = Assert.Single(call.Spec.Parameters);
|
||||
Assert.Equal("stockName", parameter.Name);
|
||||
Assert.Equal(DbType.String, parameter.DbType);
|
||||
Assert.DoesNotContain(
|
||||
Assert.IsType<string>(parameter.Value),
|
||||
call.Spec.Sql,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(new[] { "stockName", "stockCode" },
|
||||
call.Spec.Parameters.Select(parameter => parameter.Name));
|
||||
Assert.All(call.Spec.Parameters, parameter => Assert.Equal(DbType.String, parameter.DbType));
|
||||
Assert.All(call.Spec.Parameters, parameter => Assert.DoesNotContain(
|
||||
Assert.IsType<string>(parameter.Value), call.Spec.Sql, StringComparison.Ordinal));
|
||||
});
|
||||
Assert.Contains("FROM T_STOCK ", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("FROM T_KOSDAQ_STOCK ", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("FROM T_STOCK", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("FROM T_KOSDAQ_STOCK", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -355,11 +409,35 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
|
||||
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
|
||||
|
||||
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
|
||||
resolver.ResolveAsync(Entry("5026", "", "A,B", "")));
|
||||
resolver.ResolveAsync(Entry(
|
||||
"5026",
|
||||
"STOCK",
|
||||
"A,B",
|
||||
"",
|
||||
dataCode: "KOSPI:111111|KOSDAQ:222222")));
|
||||
|
||||
Assert.Single(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exact_identity_duplicate_rows_are_ambiguous_even_when_name_and_code_match()
|
||||
{
|
||||
var duplicate = StockIdentity("A", "111111");
|
||||
duplicate.ImportRow(duplicate.Rows[0]);
|
||||
var executor = new RecordingExecutor(duplicate);
|
||||
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
|
||||
|
||||
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(Entry(
|
||||
"5026",
|
||||
"STOCK",
|
||||
"A,B",
|
||||
"",
|
||||
dataCode: "KOSPI:111111|KOSDAQ:222222")));
|
||||
|
||||
Assert.Single(executor.Calls);
|
||||
Assert.DoesNotContain("DISTINCT", executor.Calls[0].Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static LegacyPlaylistEntry Entry(
|
||||
string cutCode,
|
||||
string groupCode,
|
||||
@@ -384,6 +462,15 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable StockIdentity(string name, string code)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("STOCK_NAME", typeof(string));
|
||||
table.Columns.Add("STOCK_CODE", typeof(string));
|
||||
table.Rows.Add(name, code);
|
||||
return table;
|
||||
}
|
||||
|
||||
private static void AssertStockLookupOrder(
|
||||
RecordingExecutor executor,
|
||||
params string[] stockNames)
|
||||
|
||||
@@ -52,10 +52,11 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
|
||||
Assert.Contains("FROM t_kosdaq_online1", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(firstName, executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(secondName, executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Equal(new[] { "stockName", "rowLimit" },
|
||||
Assert.Equal(new[] { "stockName", "stockCode", "rowLimit" },
|
||||
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Name));
|
||||
Assert.Equal(new object?[] { firstName, 5 },
|
||||
Assert.Equal(new object?[] { firstName, "FIRST001", 5 },
|
||||
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value));
|
||||
Assert.Contains("b.f_stock_code = s.STOCK_CODE", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Equal(new[] { ComparisonSeriesTarget.First, ComparisonSeriesTarget.Second },
|
||||
data.Series.Select(series => series.Quote.Series));
|
||||
Assert.Equal(new[] { firstName, secondName },
|
||||
@@ -92,6 +93,7 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
|
||||
Assert.Equal(DataSourceKind.Oracle, call.Source);
|
||||
Assert.Equal(rowLimit, call.Spec.Parameters.Single(p => p.Name == "rowLimit").Value);
|
||||
Assert.Contains(":stockName", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains(":stockCode", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains(":rowLimit", call.Spec.Sql, StringComparison.Ordinal);
|
||||
});
|
||||
Assert.Contains("t_candle_history", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
|
||||
@@ -502,7 +504,15 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
|
||||
new ComparisonPairSceneLoadRequest(
|
||||
ComparisonGraphPeriod.FiveDays,
|
||||
null!,
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second"))
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002")),
|
||||
new ComparisonPairSceneLoadRequest(
|
||||
ComparisonGraphPeriod.FiveDays,
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "First", "bad/code"),
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002")),
|
||||
new ComparisonPairSceneLoadRequest(
|
||||
ComparisonGraphPeriod.FiveDays,
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "First", null!),
|
||||
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002"))
|
||||
};
|
||||
foreach (var request in invalidPairRequests)
|
||||
{
|
||||
@@ -543,8 +553,8 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
|
||||
string secondName,
|
||||
ComparisonEquityMarket secondMarket) => new(
|
||||
period,
|
||||
new ComparisonStockSelection(firstMarket, firstName),
|
||||
new ComparisonStockSelection(secondMarket, secondName));
|
||||
new ComparisonStockSelection(firstMarket, firstName, "FIRST001"),
|
||||
new ComparisonStockSelection(secondMarket, secondName, "SECOND002"));
|
||||
|
||||
private static DataTable CandleTable(
|
||||
string name,
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyComparisonPairImportServiceTests
|
||||
{
|
||||
private static readonly Encoding Cp949 = CreateCp949();
|
||||
|
||||
[Fact]
|
||||
public void Parser_AcceptsTheExactUc3NineFieldCp949Shape()
|
||||
{
|
||||
var rows = LegacyComparisonFileParser.Parse(Encode(
|
||||
"1^삼성전자,삼성전자(NXT)^코스피^코스피_NXT^^^^^\r\n" +
|
||||
"2^코스닥 지수,코스피 지수^^^^^^^\r\n" +
|
||||
"3^엔비디아,삼성전자^^코스피^^^^^\r\n"));
|
||||
|
||||
Assert.Collection(
|
||||
rows,
|
||||
row =>
|
||||
{
|
||||
Assert.Equal(1, row.RowNumber);
|
||||
Assert.Equal(new LegacyComparisonFileTarget("삼성전자", "코스피"), row.First);
|
||||
Assert.Equal(
|
||||
new LegacyComparisonFileTarget("삼성전자(NXT)", "코스피_NXT"),
|
||||
row.Second);
|
||||
},
|
||||
row =>
|
||||
{
|
||||
Assert.Equal(2, row.RowNumber);
|
||||
Assert.Equal(string.Empty, row.First.Market);
|
||||
Assert.Equal(string.Empty, row.Second.Market);
|
||||
},
|
||||
row =>
|
||||
{
|
||||
Assert.Equal(3, row.RowNumber);
|
||||
Assert.Equal("엔비디아", row.First.Name);
|
||||
Assert.Equal("코스피", row.Second.Market);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_AcceptsTheAuditedEightRowOriginalFixture()
|
||||
{
|
||||
var bytes = Encode(
|
||||
"1^삼성전자,삼성전자(NXT)^코스피^코스피_NXT^^^^^\r\n" +
|
||||
"2^기아(NXT),기아^코스피_NXT^코스피^^^^^\r\n" +
|
||||
"3^기아(NXT),삼성전자(NXT)^코스피_NXT^코스피_NXT^^^^^\r\n" +
|
||||
"4^SK하이닉스,삼성전자^코스피^코스피^^^^^\r\n" +
|
||||
"5^에이비엘바이오(NXT),에이비엘바이오^코스닥_NXT^코스닥^^^^^\r\n" +
|
||||
"6^코스닥 지수,코스피 지수^^^^^^^\r\n" +
|
||||
"7^엔비디아,삼성전자^^코스피^^^^^\r\n" +
|
||||
"8^삼성전자(NXT),엔비디아^코스피_NXT^^^^^^\r\n");
|
||||
|
||||
Assert.Equal(
|
||||
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2",
|
||||
Convert.ToHexString(SHA256.HashData(bytes)));
|
||||
var rows = LegacyComparisonFileParser.Parse(bytes);
|
||||
Assert.Equal(8, rows.Count);
|
||||
Assert.Equal("엔비디아", rows[7].Second.Name);
|
||||
Assert.Equal(string.Empty, rows[7].Second.Market);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1^삼성전자,기아^코스피^코스피^^^^\r\n")]
|
||||
[InlineData("01^삼성전자,기아^코스피^코스피^^^^^\r\n")]
|
||||
[InlineData("2^삼성전자,기아^코스피^코스피^^^^^\r\n")]
|
||||
[InlineData("1^삼성전자^코스피^코스피^^^^^\r\n")]
|
||||
[InlineData("1^삼성전자,기아^코스피^코스피^unexpected^^^^\r\n")]
|
||||
[InlineData("1^삼성전자(NXT),기아^코스피^코스피^^^^^\r\n")]
|
||||
[InlineData("1^삼성전자,기아^코스피_NXT^코스피^^^^^\r\n")]
|
||||
[InlineData("1^ 삼성전자,기아^코스피^코스피^^^^^\r\n")]
|
||||
public void Parser_RejectsMalformedRows(string text)
|
||||
{
|
||||
var exception = Assert.Throws<LegacyComparisonImportException>(
|
||||
() => LegacyComparisonFileParser.Parse(Encode(text)));
|
||||
|
||||
Assert.Equal(LegacyComparisonImportFailure.SourceInvalid, exception.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_RejectsDuplicatePairsAndInvalidCp949()
|
||||
{
|
||||
var duplicate = Encode(
|
||||
"1^삼성전자,기아^코스피^코스피^^^^^\r\n" +
|
||||
"2^삼성전자,기아^코스피^코스피^^^^^\r\n");
|
||||
Assert.Equal(
|
||||
LegacyComparisonImportFailure.SourceInvalid,
|
||||
Assert.Throws<LegacyComparisonImportException>(
|
||||
() => LegacyComparisonFileParser.Parse(duplicate)).Failure);
|
||||
|
||||
Assert.Equal(
|
||||
LegacyComparisonImportFailure.SourceInvalid,
|
||||
Assert.Throws<LegacyComparisonImportException>(
|
||||
() => LegacyComparisonFileParser.Parse([0x81])).Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrustedSource_UsesOnlyTheFixedReadOnlyPathAndReturnsItsDigest()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
var source = new TrustedLegacyComparisonFileSource(directory.Path);
|
||||
var expected = Encode("1^코스닥 지수,코스피 지수^^^^^^^\r\n");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(source.SourcePath)!);
|
||||
await File.WriteAllBytesAsync(source.SourcePath, expected);
|
||||
|
||||
var result = await source.ReadAsync();
|
||||
|
||||
Assert.Equal(expected, result.Bytes);
|
||||
Assert.Equal(Convert.ToHexString(SHA256.HashData(expected)), result.Sha256);
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(Path.Combine(directory.Path, TrustedLegacyComparisonFileSource.FixedRelativePath)),
|
||||
source.SourcePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrustedSource_RejectsMissingAndOversizedFiles()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
var source = new TrustedLegacyComparisonFileSource(directory.Path);
|
||||
|
||||
var missing = await Assert.ThrowsAsync<LegacyComparisonImportException>(
|
||||
() => source.ReadAsync());
|
||||
Assert.Equal(LegacyComparisonImportFailure.SourceUnavailable, missing.Failure);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(source.SourcePath)!);
|
||||
await File.WriteAllBytesAsync(
|
||||
source.SourcePath,
|
||||
new byte[TrustedLegacyComparisonFileSource.MaximumFileBytes + 1]);
|
||||
var oversized = await Assert.ThrowsAsync<LegacyComparisonImportException>(
|
||||
() => source.ReadAsync());
|
||||
Assert.Equal(LegacyComparisonImportFailure.SourceUnavailable, oversized.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrustedSource_RejectsAHardLinkedFileEvenWhenItsBytesAreValid()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var directory = new TemporaryDirectory();
|
||||
var source = new TrustedLegacyComparisonFileSource(directory.Path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(source.SourcePath)!);
|
||||
var outside = Path.Combine(directory.Path, "outside-valid-comparison.dat");
|
||||
await File.WriteAllBytesAsync(
|
||||
outside,
|
||||
Encode("1^코스피 지수,코스닥 지수^^^^^^\r\n"));
|
||||
Assert.True(CreateHardLinkW(source.SourcePath, outside, IntPtr.Zero));
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyComparisonImportException>(
|
||||
() => source.ReadAsync());
|
||||
|
||||
Assert.Equal(LegacyComparisonImportFailure.SourceUnavailable, error.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_ResolvesEveryTargetKindWithBoundExactReadsAndCachesIdentity()
|
||||
{
|
||||
var bytes = Encode(
|
||||
"1^삼성전자,삼성전자(NXT)^코스피^코스피_NXT^^^^^\r\n" +
|
||||
"2^코스닥 지수,삼성전자^^코스피^^^^^\r\n" +
|
||||
"3^엔비디아,삼성전자^^코스피^^^^^\r\n");
|
||||
var source = new MemorySource(bytes);
|
||||
var executor = new RecordingExecutor(call => call.TableName switch
|
||||
{
|
||||
"LEGACY_COMPARISON_IMPORT_KOSPI" => StockTable("삼성전자", "005930"),
|
||||
"LEGACY_COMPARISON_IMPORT_NXT_KOSPI" => StockTable("삼성전자", "005930"),
|
||||
"LEGACY_COMPARISON_IMPORT_WORLD_STOCK" =>
|
||||
string.Equals(
|
||||
call.Spec.Parameters[0].Value as string,
|
||||
"엔비디아",
|
||||
StringComparison.Ordinal)
|
||||
? WorldTable("엔비디아", "NAS@NVDA", "US")
|
||||
: WorldTable(),
|
||||
_ => throw new InvalidOperationException("Unexpected identity query.")
|
||||
});
|
||||
var service = new LegacyComparisonPairImportService(executor, source);
|
||||
|
||||
var result = await service.ImportAsync();
|
||||
|
||||
Assert.Equal(3, result.SourceRowCount);
|
||||
Assert.Equal(source.Digest, result.SourceSha256);
|
||||
Assert.Collection(
|
||||
result.Pairs,
|
||||
row =>
|
||||
{
|
||||
Assert.Equal(LegacyComparisonImportTargetKind.KrxStock, row.First.Kind);
|
||||
Assert.Equal("kospi", row.First.Market);
|
||||
Assert.Equal("005930", row.First.StockCode);
|
||||
Assert.Equal(LegacyComparisonImportTargetKind.NxtStock, row.Second.Kind);
|
||||
Assert.Equal("삼성전자", row.Second.StockName);
|
||||
},
|
||||
row =>
|
||||
{
|
||||
Assert.Equal("Kosdaq", row.First.MarketTarget);
|
||||
Assert.Equal(LegacyComparisonImportTargetKind.MarketTarget, row.First.Kind);
|
||||
Assert.Equal("005930", row.Second.StockCode);
|
||||
},
|
||||
row =>
|
||||
{
|
||||
Assert.Equal(LegacyComparisonImportTargetKind.WorldStock, row.First.Kind);
|
||||
Assert.Equal("NAS@NVDA", row.First.Symbol);
|
||||
Assert.Equal("US", row.First.Nation);
|
||||
Assert.Equal("005930", row.Second.StockCode);
|
||||
});
|
||||
|
||||
Assert.Equal(4, executor.Calls.Count);
|
||||
Assert.Equal(0, executor.StringSqlCallCount);
|
||||
Assert.All(executor.Calls, call =>
|
||||
{
|
||||
call.Spec.ValidateFor(call.Source);
|
||||
Assert.Single(call.Spec.Parameters);
|
||||
Assert.DoesNotContain(
|
||||
Assert.IsType<string>(call.Spec.Parameters[0].Value),
|
||||
call.Spec.Sql,
|
||||
StringComparison.Ordinal);
|
||||
});
|
||||
Assert.Equal("삼성전자", executor.Calls[0].Spec.Parameters[0].Value);
|
||||
Assert.Equal("삼성전자", executor.Calls[1].Spec.Parameters[0].Value);
|
||||
Assert.Equal("코스닥 지수", executor.Calls[2].Spec.Parameters[0].Value);
|
||||
Assert.Equal("엔비디아", executor.Calls[3].Spec.Parameters[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_MapsAllTwentyFourOriginalFixedTargetsExactly()
|
||||
{
|
||||
(string Name, string Target)[] expected =
|
||||
[
|
||||
("코스피 지수", "Kospi"),
|
||||
("코스닥 지수", "Kosdaq"),
|
||||
("코스피200 지수", "Kospi200"),
|
||||
("선물 지수", "Futures"),
|
||||
("KRX100지수", "Krx100"),
|
||||
("환율-원달러", "WonDollar"),
|
||||
("환율-원엔", "WonYen"),
|
||||
("환율-원위엔", "WonYuan"),
|
||||
("환율-원유로", "WonEuro"),
|
||||
("해외지수-다우", "Dow"),
|
||||
("해외지수-나스닥", "Nasdaq"),
|
||||
("해외지수-S&P", "Sp500"),
|
||||
("해외지수-독일", "GermanyDax"),
|
||||
("해외지수-영국", "UnitedKingdomFtse"),
|
||||
("해외지수-프랑스", "FranceCac"),
|
||||
("해외지수-일본", "Nikkei"),
|
||||
("해외지수-홍콩", "HangSeng"),
|
||||
("해외지수-대만", "TaiwanWeighted"),
|
||||
("해외지수-싱가포르", "SingaporeStraitsTimes"),
|
||||
("해외지수-태국", "ThailandSet"),
|
||||
("해외지수-필리핀", "PhilippinesComposite"),
|
||||
("해외지수-말레이시아", "MalaysiaKlse"),
|
||||
("해외지수-인도네시아", "IndonesiaComposite"),
|
||||
("해외지수-중국", "ShanghaiComposite")
|
||||
];
|
||||
var text = new StringBuilder();
|
||||
for (var index = 0; index < expected.Length; index += 2)
|
||||
{
|
||||
text.Append(index / 2 + 1)
|
||||
.Append('^')
|
||||
.Append(expected[index].Name)
|
||||
.Append(',')
|
||||
.Append(expected[index + 1].Name)
|
||||
.Append("^^^^^^^\r\n");
|
||||
}
|
||||
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
Assert.Equal("LEGACY_COMPARISON_IMPORT_WORLD_STOCK", call.TableName);
|
||||
return WorldTable();
|
||||
});
|
||||
var service = new LegacyComparisonPairImportService(
|
||||
executor,
|
||||
new MemorySource(Encode(text.ToString())));
|
||||
|
||||
var result = await service.ImportAsync();
|
||||
var actual = result.Pairs
|
||||
.SelectMany(row => new[] { row.First, row.Second })
|
||||
.Select(target => target.MarketTarget)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(expected.Select(value => value.Target), actual);
|
||||
Assert.Equal(24, executor.Calls.Count);
|
||||
Assert.Equal(
|
||||
expected.Select(value => value.Name),
|
||||
executor.Calls.Select(call => Assert.IsType<string>(call.Spec.Parameters[0].Value)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_FailsClosedForMissingAmbiguousAndMalformedMasterRows()
|
||||
{
|
||||
var bytes = Encode("1^삼성전자,기아^코스피^코스피^^^^^\r\n");
|
||||
|
||||
await AssertFailureAsync(
|
||||
bytes,
|
||||
_ => StockTable(),
|
||||
LegacyComparisonImportFailure.IdentityNotFound);
|
||||
await AssertFailureAsync(
|
||||
bytes,
|
||||
_ => StockTable(("삼성전자", "005930"), ("삼성전자", "999999")),
|
||||
LegacyComparisonImportFailure.IdentityAmbiguous);
|
||||
|
||||
var wrongSchema = new DataTable();
|
||||
wrongSchema.Columns.Add("stock_name", typeof(string));
|
||||
wrongSchema.Columns.Add("STOCK_CODE", typeof(string));
|
||||
await AssertFailureAsync(
|
||||
bytes,
|
||||
_ => wrongSchema,
|
||||
LegacyComparisonImportFailure.DatabaseDataInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_RejectsAWorldNameThatCollidesWithAFixedMarketLabel()
|
||||
{
|
||||
var bytes = Encode("1^코스닥 지수,코스피 지수^^^^^^^\r\n");
|
||||
var service = new LegacyComparisonPairImportService(
|
||||
new RecordingExecutor(call =>
|
||||
WorldTable(
|
||||
Assert.IsType<string>(call.Spec.Parameters[0].Value),
|
||||
"COLLISION",
|
||||
"US")),
|
||||
new MemorySource(bytes));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<LegacyComparisonImportException>(
|
||||
() => service.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyComparisonImportFailure.IdentityAmbiguous, exception.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_RejectsAChangedDigestBeforeDatabaseAccess()
|
||||
{
|
||||
var bytes = Encode("1^코스닥 지수,코스피 지수^^^^^^^\r\n");
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var source = new CorruptDigestSource(bytes);
|
||||
var service = new LegacyComparisonPairImportService(executor, source);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<LegacyComparisonImportException>(
|
||||
() => service.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyComparisonImportFailure.SourceInvalid, exception.Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_HonorsPreCanceledRequestsWithoutReadingOrQuerying()
|
||||
{
|
||||
var source = new CountingSource();
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyComparisonPairImportService(executor, source);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => service.ImportAsync(cancellation.Token));
|
||||
|
||||
Assert.Equal(0, source.ReadCount);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
private static async Task AssertFailureAsync(
|
||||
byte[] bytes,
|
||||
Func<RecordedCall, DataTable> handler,
|
||||
LegacyComparisonImportFailure expected)
|
||||
{
|
||||
var service = new LegacyComparisonPairImportService(
|
||||
new RecordingExecutor(handler),
|
||||
new MemorySource(bytes));
|
||||
var exception = await Assert.ThrowsAsync<LegacyComparisonImportException>(
|
||||
() => service.ImportAsync());
|
||||
Assert.Equal(expected, exception.Failure);
|
||||
}
|
||||
|
||||
private static DataTable StockTable(params (string Name, string Code)[] rows)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("STOCK_NAME", typeof(string));
|
||||
table.Columns.Add("STOCK_CODE", typeof(string));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(row.Name, row.Code);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable StockTable(string name, string code) =>
|
||||
StockTable((name, code));
|
||||
|
||||
private static DataTable WorldTable(
|
||||
params (string InputName, string Symbol, string Nation)[] rows)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("F_INPUT_NAME", typeof(string));
|
||||
table.Columns.Add("F_SYMB", typeof(string));
|
||||
table.Columns.Add("F_NATC", typeof(string));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(row.InputName, row.Symbol, row.Nation);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable WorldTable(string inputName, string symbol, string nation) =>
|
||||
WorldTable((inputName, symbol, nation));
|
||||
|
||||
private static byte[] Encode(string value) => Cp949.GetBytes(value);
|
||||
|
||||
private static Encoding CreateCp949()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
return Encoding.GetEncoding(
|
||||
949,
|
||||
EncoderFallback.ExceptionFallback,
|
||||
DecoderFallback.ExceptionFallback);
|
||||
}
|
||||
|
||||
private sealed class MemorySource(byte[] bytes) : ILegacyComparisonFileSource
|
||||
{
|
||||
public string Digest { get; } = Convert.ToHexString(SHA256.HashData(bytes));
|
||||
|
||||
public Task<LegacyComparisonSourceSnapshot> ReadAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new LegacyComparisonSourceSnapshot(bytes, Digest));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CorruptDigestSource(byte[] bytes) : ILegacyComparisonFileSource
|
||||
{
|
||||
public Task<LegacyComparisonSourceSnapshot> ReadAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new LegacyComparisonSourceSnapshot(bytes, new string('0', 64)));
|
||||
}
|
||||
|
||||
private sealed class CountingSource : ILegacyComparisonFileSource
|
||||
{
|
||||
public int ReadCount { get; private set; }
|
||||
|
||||
public Task<LegacyComparisonSourceSnapshot> ReadAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ReadCount++;
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RecordedCall(
|
||||
DataSourceKind Source,
|
||||
string TableName,
|
||||
DataQuerySpec Spec,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
|
||||
: IDataQueryExecutor
|
||||
{
|
||||
private int _stringSqlCallCount;
|
||||
|
||||
public List<RecordedCall> Calls { get; } = [];
|
||||
|
||||
public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);
|
||||
|
||||
public DataTable Execute(DataSourceKind source, string tableName, string query)
|
||||
{
|
||||
Interlocked.Increment(ref _stringSqlCallCount);
|
||||
throw new InvalidOperationException("String SQL fallback is forbidden.");
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _stringSqlCallCount);
|
||||
throw new InvalidOperationException("String SQL fallback is forbidden.");
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
DataQuerySpec query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
query.ValidateFor(source);
|
||||
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||
Calls.Add(call);
|
||||
return Task.FromResult(handler(call).Copy());
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateHardLinkW(
|
||||
string fileName,
|
||||
string existingFileName,
|
||||
IntPtr securityAttributes);
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
public TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"mbn-comparison-import-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort test cleanup only.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyCutRequirementManifestTests
|
||||
{
|
||||
private static readonly StringComparer PathComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
[Fact]
|
||||
public void Critical_builder_assets_match_the_closed_preflight_manifest()
|
||||
{
|
||||
var manifest = ReadManifest();
|
||||
var actual = new HashSet<string>(PathComparer);
|
||||
var directions = new[]
|
||||
{
|
||||
ScenePriceDirection.Up,
|
||||
ScenePriceDirection.Flat,
|
||||
ScenePriceDirection.Down
|
||||
};
|
||||
|
||||
foreach (var direction in directions)
|
||||
{
|
||||
AddAssets(actual, new S5006SceneMutationBuilder().Build(new S5006SceneData(
|
||||
LegacyDomesticEquityMarket.Kospi,
|
||||
"fixture",
|
||||
direction,
|
||||
1,
|
||||
1,
|
||||
1m,
|
||||
1,
|
||||
1m,
|
||||
1,
|
||||
1m,
|
||||
1,
|
||||
1m)));
|
||||
|
||||
foreach (var target in Enum.GetValues<S6001QuoteTarget>())
|
||||
{
|
||||
AddAssets(actual, new S6001SceneMutationBuilder().Build(new S6001SceneData(
|
||||
target,
|
||||
"fixture",
|
||||
1m,
|
||||
1m,
|
||||
1m,
|
||||
direction)));
|
||||
}
|
||||
}
|
||||
|
||||
var expected = manifest.Assets
|
||||
.Where(asset => asset.Builder is "s5006" or "s6001" or "shared-price-direction")
|
||||
.Select(asset => asset.Path)
|
||||
.ToHashSet(PathComparer);
|
||||
|
||||
Assert.Equal(expected.Count, actual.Count);
|
||||
Assert.Empty(expected.Except(actual, PathComparer));
|
||||
Assert.Empty(actual.Except(expected, PathComparer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manifest_is_relative_closed_and_includes_all_s6001_videos()
|
||||
{
|
||||
var manifest = ReadManifest();
|
||||
|
||||
Assert.Equal(1, manifest.SchemaVersion);
|
||||
Assert.Equal(34, manifest.ReachableBuilders);
|
||||
Assert.Equal(45, manifest.ActiveAliases.Count);
|
||||
Assert.Equal(23, manifest.Assets.Count);
|
||||
Assert.Equal(
|
||||
13,
|
||||
manifest.Assets
|
||||
.Where(asset => asset.Builder == "s6001" && asset.Kind == "Video")
|
||||
.Select(asset => asset.Path)
|
||||
.Distinct(PathComparer)
|
||||
.Count());
|
||||
Assert.Contains(
|
||||
manifest.Assets,
|
||||
asset => asset.Builder == "s5006" &&
|
||||
PathComparer.Equals(asset.Path, @"Video\큐브배경.vrv"));
|
||||
|
||||
Assert.All(manifest.Assets, asset =>
|
||||
{
|
||||
Assert.Contains(asset.Kind, new[] { "Image", "Texture", "Video" });
|
||||
Assert.False(Path.IsPathRooted(asset.Path));
|
||||
Assert.DoesNotContain(
|
||||
"..",
|
||||
asset.Path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddAssets(
|
||||
ISet<string> paths,
|
||||
IEnumerable<PlayoutMutation> mutations)
|
||||
{
|
||||
foreach (var mutation in mutations)
|
||||
{
|
||||
switch (mutation)
|
||||
{
|
||||
case PlayoutSetAssetValue asset:
|
||||
paths.Add(asset.AssetPath);
|
||||
break;
|
||||
case PlayoutSetBackgroundTexture texture:
|
||||
paths.Add(texture.AssetPath);
|
||||
break;
|
||||
case PlayoutSetBackgroundVideo video:
|
||||
paths.Add(video.AssetPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyCutRequirementManifest ReadManifest()
|
||||
{
|
||||
var path = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures",
|
||||
"LegacyCutRequirements.json");
|
||||
var manifest = JsonSerializer.Deserialize<LegacyCutRequirementManifest>(
|
||||
File.ReadAllText(path),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return Assert.IsType<LegacyCutRequirementManifest>(manifest);
|
||||
}
|
||||
|
||||
private sealed record LegacyCutRequirementManifest(
|
||||
int SchemaVersion,
|
||||
int ReachableBuilders,
|
||||
IReadOnlyList<string> ActiveAliases,
|
||||
IReadOnlyList<LegacyBuilderAssetRequirement> Assets);
|
||||
|
||||
private sealed record LegacyBuilderAssetRequirement(
|
||||
string Builder,
|
||||
string Kind,
|
||||
string Path);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyForeignIndexCandleRestoreServiceTests
|
||||
{
|
||||
public static TheoryData<string, string, string, string, int, string> SupportedProfiles =>
|
||||
new()
|
||||
{
|
||||
{ "다우존스", "5일", "Dow", "DJI@DJI", 5, "8061" },
|
||||
{ "다우존스", "20일", "Dow", "DJI@DJI", 20, "8040" },
|
||||
{ "다우존스", "60일", "Dow", "DJI@DJI", 60, "8046" },
|
||||
{ "다우존스", "120일", "Dow", "DJI@DJI", 120, "8051" },
|
||||
{ "다우존스", "240일", "Dow", "DJI@DJI", 240, "8056" },
|
||||
{ "나스닥", "5일", "Nasdaq", "NAS@IXIC", 5, "8061" },
|
||||
{ "나스닥", "20일", "Nasdaq", "NAS@IXIC", 20, "8040" },
|
||||
{ "나스닥", "60일", "Nasdaq", "NAS@IXIC", 60, "8046" },
|
||||
{ "나스닥", "120일", "Nasdaq", "NAS@IXIC", 120, "8051" },
|
||||
{ "나스닥", "240일", "Nasdaq", "NAS@IXIC", 240, "8056" },
|
||||
{ "S&P500", "5일", "Sp500", "SPI@SPX", 5, "8061" },
|
||||
{ "S&P500", "20일", "Sp500", "SPI@SPX", 20, "8040" },
|
||||
{ "S&P500", "60일", "Sp500", "SPI@SPX", 60, "8046" },
|
||||
{ "S&P500", "120일", "Sp500", "SPI@SPX", 120, "8051" },
|
||||
{ "S&P500", "240일", "Sp500", "SPI@SPX", 240, "8056" }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SupportedProfiles))]
|
||||
public async Task ResolveAsync_VerifiesEveryClosedTargetAndPeriodExactly(
|
||||
string subject,
|
||||
string period,
|
||||
string targetKey,
|
||||
string symbol,
|
||||
int periodDays,
|
||||
string cutCode)
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
Assert.Equal(DataSourceKind.Oracle, call.Source);
|
||||
Assert.Equal(LegacyForeignIndexCandleRestoreService.QueryName, call.QueryName);
|
||||
call.Spec.ValidateFor(DataSourceKind.Oracle);
|
||||
Assert.Contains("F_INPUT_NAME = :input_name", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("F_SYMB = :symbol", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("F_NATC = 'US'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("F_FDTC = '0'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ROWNUM <= 2", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(subject, call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(symbol, call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Collection(
|
||||
call.Spec.Parameters,
|
||||
parameter =>
|
||||
{
|
||||
Assert.Equal("input_name", parameter.Name);
|
||||
Assert.Equal(subject, parameter.Value);
|
||||
Assert.Equal(DbType.String, parameter.DbType);
|
||||
},
|
||||
parameter =>
|
||||
{
|
||||
Assert.Equal("symbol", parameter.Name);
|
||||
Assert.Equal(symbol, parameter.Value);
|
||||
Assert.Equal(DbType.String, parameter.DbType);
|
||||
});
|
||||
return IdentityTable((subject, symbol, "US", "0"));
|
||||
});
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
var row = Row(17, subject, period);
|
||||
|
||||
Assert.True(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
|
||||
var result = await service.ResolveAsync([row]);
|
||||
|
||||
var outcome = Assert.Single(result.Rows);
|
||||
Assert.Equal(17, outcome.ItemIndex);
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Null(outcome.Failure);
|
||||
Assert.Equal(
|
||||
new LegacyForeignIndexCandleRestoreIdentity(
|
||||
targetKey,
|
||||
subject,
|
||||
symbol,
|
||||
"US",
|
||||
"0",
|
||||
"s8010",
|
||||
"PRICE",
|
||||
periodDays,
|
||||
cutCode),
|
||||
outcome.Identity);
|
||||
Assert.Single(executor.Calls);
|
||||
Assert.Equal(0, executor.StringSqlCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PreservesBatchOrderAndCachesEachTargetIdentity()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
var inputName = Assert.IsType<string>(call.Spec.Parameters[0].Value);
|
||||
var symbol = Assert.IsType<string>(call.Spec.Parameters[1].Value);
|
||||
return IdentityTable((inputName, symbol, "US", "0"));
|
||||
});
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
var rows = new[]
|
||||
{
|
||||
Row(900, "S&P500", "240일"),
|
||||
Row(2, "다우존스", "20일"),
|
||||
Row(701, "나스닥", "5일"),
|
||||
Row(3, "다우존스", "5일")
|
||||
};
|
||||
|
||||
var result = await service.ResolveAsync(rows);
|
||||
|
||||
Assert.Equal([900, 2, 701, 3], result.Rows.Select(row => row.ItemIndex));
|
||||
Assert.Equal(
|
||||
["Sp500", "Dow", "Nasdaq", "Dow"],
|
||||
result.Rows.Select(row => row.Identity!.TargetKey));
|
||||
Assert.Equal([240, 20, 5, 5], result.Rows.Select(row => row.Identity!.PeriodDays));
|
||||
Assert.Equal(3, executor.Calls.Count);
|
||||
Assert.Equal(
|
||||
["S&P500", "다우존스", "나스닥"],
|
||||
executor.Calls.Select(call => call.Spec.Parameters[0].Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ReturnsNotFoundAmbiguousAndInvalidPerRow()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
var inputName = Assert.IsType<string>(call.Spec.Parameters[0].Value);
|
||||
var symbol = Assert.IsType<string>(call.Spec.Parameters[1].Value);
|
||||
return inputName switch
|
||||
{
|
||||
"다우존스" => IdentityTable(),
|
||||
"나스닥" => IdentityTable(
|
||||
(inputName, symbol, "US", "0"),
|
||||
(inputName, symbol, "US", "0")),
|
||||
"S&P500" => WrongSchemaTable(),
|
||||
_ => throw new InvalidOperationException(inputName)
|
||||
};
|
||||
});
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
|
||||
var result = await service.ResolveAsync(
|
||||
[
|
||||
Row(10, "다우존스", "5일"),
|
||||
Row(11, "나스닥", "20일"),
|
||||
Row(12, "S&P500", "60일")
|
||||
]);
|
||||
|
||||
Assert.Equal(
|
||||
new LegacyForeignIndexCandleRestoreFailure?[]
|
||||
{
|
||||
LegacyForeignIndexCandleRestoreFailure.IdentityNotFound,
|
||||
LegacyForeignIndexCandleRestoreFailure.IdentityAmbiguous,
|
||||
LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid
|
||||
},
|
||||
result.Rows.Select(row => row.Failure));
|
||||
Assert.All(result.Rows, row =>
|
||||
{
|
||||
Assert.False(row.IsResolved);
|
||||
Assert.Null(row.Identity);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("다우", "5일")]
|
||||
[InlineData("DOW", "5일")]
|
||||
[InlineData(" 다우존스", "5일")]
|
||||
[InlineData("다우존스", "5")]
|
||||
[InlineData("다우존스", "240")]
|
||||
[InlineData("다우존스", " 240일")]
|
||||
public async Task ResolveAsync_RejectsNonExactRawRowsWithoutDatabaseAccess(
|
||||
string subject,
|
||||
string period)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
var row = Row(0, subject, period);
|
||||
|
||||
Assert.False(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
|
||||
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
|
||||
|
||||
Assert.Equal(LegacyForeignIndexCandleRestoreFailure.InvalidRow, outcome.Failure);
|
||||
Assert.Null(outcome.Identity);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("FOREIGN_INDEX", "캔들그래프", "1/1", "")]
|
||||
[InlineData(" 해외지수", "캔들그래프", "1/1", "")]
|
||||
[InlineData("해외지수", "캔들 그래프", "1/1", "")]
|
||||
[InlineData("해외지수", "CANDLE", "1/1", "")]
|
||||
[InlineData("해외지수", "캔들그래프", "01/01", "")]
|
||||
[InlineData("해외지수", "캔들그래프", "1/2", "")]
|
||||
[InlineData("해외지수", "캔들그래프", "1/1", "DJI@DJI")]
|
||||
public async Task ResolveAsync_RevalidatesFullRawPlaylistBoundary(
|
||||
string groupCode,
|
||||
string graphicType,
|
||||
string pageText,
|
||||
string dataCode)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
var row = Row(
|
||||
groupCode: groupCode,
|
||||
graphicType: graphicType,
|
||||
pageText: pageText,
|
||||
dataCode: dataCode);
|
||||
|
||||
Assert.False(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
|
||||
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
|
||||
|
||||
Assert.Equal(LegacyForeignIndexCandleRestoreFailure.InvalidRow, outcome.Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_AllowsDisabledRowSoCallerCanPreserveEnabledState()
|
||||
{
|
||||
var executor = new RecordingExecutor(call => IdentityTable((
|
||||
Assert.IsType<string>(call.Spec.Parameters[0].Value),
|
||||
Assert.IsType<string>(call.Spec.Parameters[1].Value),
|
||||
"US",
|
||||
"0")));
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
var row = Row(isEnabled: false);
|
||||
|
||||
Assert.True(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
|
||||
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
|
||||
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Equal("Dow", outcome.Identity!.TargetKey);
|
||||
Assert.Single(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RejectsMalformedBatchSchemaAndDuplicateIndexes()
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
var tooMany = Enumerable.Range(
|
||||
0,
|
||||
LegacyForeignIndexCandleRestoreService.MaximumRows + 1)
|
||||
.Select(index => Row(index))
|
||||
.ToArray();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync(tooMany));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
|
||||
[
|
||||
Row(4, "다우존스", "5일"),
|
||||
Row(4, "나스닥", "20일")
|
||||
]));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
|
||||
[
|
||||
null!
|
||||
]));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
|
||||
[
|
||||
Row(-1, "다우존스", "5일")
|
||||
]));
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PropagatesDatabaseFailureWithoutRetry()
|
||||
{
|
||||
var executor = new RecordingExecutor(_ =>
|
||||
throw new DataException("simulated read failure"));
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
|
||||
await Assert.ThrowsAsync<DataException>(() => service.ResolveAsync(
|
||||
[Row(0, "다우존스", "5일")]));
|
||||
|
||||
Assert.Single(executor.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("extra-column")]
|
||||
[InlineData("wrong-case")]
|
||||
[InlineData("wrong-type")]
|
||||
[InlineData("unexpected-value")]
|
||||
[InlineData("too-many")]
|
||||
public async Task ResolveAsync_FailsClosedOnExpandedOrTamperedDatabaseData(string mode)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => mode switch
|
||||
{
|
||||
"extra-column" => TableWithExtraColumn(),
|
||||
"wrong-case" => WrongSchemaTable(),
|
||||
"wrong-type" => WrongTypeTable(),
|
||||
"unexpected-value" => IdentityTable(("다우존스", "WRONG", "US", "0")),
|
||||
"too-many" => IdentityTable(
|
||||
("다우존스", "DJI@DJI", "US", "0"),
|
||||
("다우존스", "DJI@DJI", "US", "0"),
|
||||
("다우존스", "DJI@DJI", "US", "0")),
|
||||
_ => throw new InvalidOperationException(mode)
|
||||
});
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
|
||||
var outcome = Assert.Single((await service.ResolveAsync(
|
||||
[Row(0, "다우존스", "5일")])).Rows);
|
||||
|
||||
Assert.Equal(
|
||||
LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid,
|
||||
outcome.Failure);
|
||||
Assert.False(outcome.IsResolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_HonorsPreCancellationWithoutQueryOrRetry()
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ResolveAsync(
|
||||
[Row(0, "다우존스", "5일")],
|
||||
cancellation.Token));
|
||||
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_StopsAfterCancellationObservedByFirstQuery()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
cancellation.Cancel();
|
||||
return IdentityTable((
|
||||
Assert.IsType<string>(call.Spec.Parameters[0].Value),
|
||||
Assert.IsType<string>(call.Spec.Parameters[1].Value),
|
||||
"US",
|
||||
"0"));
|
||||
}, throwBeforeHandlerWhenCanceled: false);
|
||||
var service = new LegacyForeignIndexCandleRestoreService(executor);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ResolveAsync(
|
||||
[
|
||||
Row(0, "다우존스", "5일"),
|
||||
Row(1, "나스닥", "5일")
|
||||
], cancellation.Token));
|
||||
|
||||
Assert.Single(executor.Calls);
|
||||
}
|
||||
|
||||
private static DataTable IdentityTable(
|
||||
params (string InputName, string Symbol, string Nation, string ForeignDomestic)[] rows)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("F_INPUT_NAME", typeof(string));
|
||||
table.Columns.Add("F_SYMB", typeof(string));
|
||||
table.Columns.Add("F_NATC", typeof(string));
|
||||
table.Columns.Add("F_FDTC", typeof(string));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(row.InputName, row.Symbol, row.Nation, row.ForeignDomestic);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static LegacyForeignIndexCandleRestoreRow Row(
|
||||
int itemIndex = 0,
|
||||
string subject = "다우존스",
|
||||
string subtype = "5일",
|
||||
bool isEnabled = true,
|
||||
string groupCode = "해외지수",
|
||||
string graphicType = "캔들그래프",
|
||||
string pageText = "1/1",
|
||||
string dataCode = "") =>
|
||||
new(
|
||||
itemIndex,
|
||||
isEnabled,
|
||||
groupCode,
|
||||
subject,
|
||||
graphicType,
|
||||
subtype,
|
||||
pageText,
|
||||
dataCode);
|
||||
|
||||
private static DataTable WrongSchemaTable()
|
||||
{
|
||||
var table = IdentityTable();
|
||||
table.Columns[0].ColumnName = "f_input_name";
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable WrongTypeTable()
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("F_INPUT_NAME", typeof(string));
|
||||
table.Columns.Add("F_SYMB", typeof(string));
|
||||
table.Columns.Add("F_NATC", typeof(string));
|
||||
table.Columns.Add("F_FDTC", typeof(int));
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable TableWithExtraColumn()
|
||||
{
|
||||
var table = IdentityTable(("다우존스", "DJI@DJI", "US", "0"));
|
||||
table.Columns.Add("UNEXPECTED", typeof(string));
|
||||
return table;
|
||||
}
|
||||
|
||||
private sealed record RecordedCall(
|
||||
DataSourceKind Source,
|
||||
string QueryName,
|
||||
DataQuerySpec Spec,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed class RecordingExecutor(
|
||||
Func<RecordedCall, DataTable> handler,
|
||||
bool throwBeforeHandlerWhenCanceled = true) : IDataQueryExecutor
|
||||
{
|
||||
private int _stringSqlCallCount;
|
||||
|
||||
public List<RecordedCall> Calls { get; } = [];
|
||||
|
||||
public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);
|
||||
|
||||
public DataTable Execute(DataSourceKind source, string tableName, string query)
|
||||
{
|
||||
Interlocked.Increment(ref _stringSqlCallCount);
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _stringSqlCallCount);
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
DataQuerySpec query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (throwBeforeHandlerWhenCanceled)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
query.ValidateFor(source);
|
||||
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||
Calls.Add(call);
|
||||
return Task.FromResult(handler(call).Copy());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyManualOperatorDataCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void NetSell_requires_five_rows_and_the_original_empty_terminal_record()
|
||||
{
|
||||
var expected = Rows();
|
||||
var canonical = LegacyManualOperatorDataCodec.SerializeNetSell(expected);
|
||||
|
||||
Assert.Equal(expected, LegacyManualOperatorDataCodec.ParseNetSell(canonical));
|
||||
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
|
||||
LegacyManualOperatorDataCodec.ParseNetSell(canonical.AsSpan(0, canonical.Length - 2)));
|
||||
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
|
||||
LegacyManualOperatorDataCodec.SerializeNetSell(expected.Take(4).ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NetSell_rejects_unicode_bom_delimiters_and_non_cp949_text()
|
||||
{
|
||||
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
|
||||
LegacyManualOperatorDataCodec.ParseNetSell(
|
||||
Encoding.UTF8.GetBytes("a^b^c^d\r\n")));
|
||||
|
||||
var delimiter = Rows().ToArray();
|
||||
delimiter[0] = delimiter[0] with { LeftName = "bad^name" };
|
||||
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
|
||||
LegacyManualOperatorDataCodec.SerializeNetSell(delimiter));
|
||||
|
||||
var emoji = Rows().ToArray();
|
||||
emoji[0] = emoji[0] with { LeftName = "😀" };
|
||||
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
|
||||
LegacyManualOperatorDataCodec.SerializeNetSell(emoji));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vi_preserves_original_order_and_duplicates_in_nine_columns()
|
||||
{
|
||||
LegacyManualViItem[] expected =
|
||||
[
|
||||
new("PKR7005930003", "삼성전자"),
|
||||
new("PKR7016360000", "삼성증권"),
|
||||
new("PKR7005930003", "삼성전자")
|
||||
];
|
||||
|
||||
var bytes = LegacyManualOperatorDataCodec.SerializeVi(expected);
|
||||
var actual = LegacyManualOperatorDataCodec.ParseVi(bytes);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
Assert.All(
|
||||
Encoding.GetEncoding(949).GetString(bytes)
|
||||
.Split("\r\n", StringSplitOptions.RemoveEmptyEntries),
|
||||
row => Assert.Equal(9, row.Split('^').Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vi_enforces_twenty_pages_and_the_4000_byte_serialized_bound()
|
||||
{
|
||||
var tooMany = Enumerable.Range(1, 101)
|
||||
.Select(index => new LegacyManualViItem(index.ToString("D6"), $"종목{index}"))
|
||||
.ToArray();
|
||||
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
|
||||
LegacyManualOperatorDataCodec.SerializeVi(tooMany));
|
||||
|
||||
var tooLarge = Enumerable.Range(1, 100)
|
||||
.Select(index => new LegacyManualViItem(
|
||||
$"CODE{index:D3}" + new string('X', 30),
|
||||
$"종목{index:D3}" + new string('가', 30)))
|
||||
.ToArray();
|
||||
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
|
||||
LegacyManualOperatorDataCodec.SerializeVi(tooLarge));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vi_accepts_an_existing_empty_file_but_rejects_nonempty_extra_columns()
|
||||
{
|
||||
Assert.Empty(LegacyManualOperatorDataCodec.ParseVi(Array.Empty<byte>()));
|
||||
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
|
||||
LegacyManualOperatorDataCodec.ParseVi(
|
||||
Cp949("005930^삼성전자^unexpected^^^^^^\r\n")));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<S5025TrustedManualRow> Rows() =>
|
||||
[
|
||||
new("삼성전자", "1,000", "SK하이닉스", "-2,000"),
|
||||
new("현대차", "2", "기아", "-2"),
|
||||
new("NAVER", "3", "카카오", "-3"),
|
||||
new("LG화학", "4", "포스코", "-4"),
|
||||
new("한화", "5", "셀트리온", "-5")
|
||||
];
|
||||
|
||||
private static byte[] Cp949(string value)
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
return Encoding.GetEncoding(949).GetBytes(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyNamedComparisonRestoreServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RestoresFixedPairsWithoutGuessingOrDatabaseAccess()
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
|
||||
var result = await service.ResolveAsync(
|
||||
[
|
||||
Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판"),
|
||||
Row(
|
||||
itemIndex: 1,
|
||||
groupCode: "지수",
|
||||
subject: "코스피200 지수,KRX100지수",
|
||||
graphicType: "2열판",
|
||||
subtype: "예상체결")
|
||||
]);
|
||||
|
||||
Assert.Collection(
|
||||
result.Rows,
|
||||
row =>
|
||||
{
|
||||
Assert.True(row.IsResolved);
|
||||
Assert.Equal("two-column-current", row.ActionId);
|
||||
Assert.Equal("Kospi", row.First!.MarketTarget);
|
||||
Assert.Equal("Kosdaq", row.Second!.MarketTarget);
|
||||
},
|
||||
row =>
|
||||
{
|
||||
Assert.True(row.IsResolved);
|
||||
Assert.Equal("two-column-expected", row.ActionId);
|
||||
Assert.Equal("Kospi200", row.First!.MarketTarget);
|
||||
Assert.Equal("Krx100", row.Second!.MarketTarget);
|
||||
});
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("삼성전자,SK하이닉스")]
|
||||
[InlineData("삼성전자(NXT),기아(NXT)")]
|
||||
[InlineData("엔비디아,애플")]
|
||||
public async Task ResolveAsync_RejectsTwoColumnStocksBecauseLegacyRowsLostEachMarket(
|
||||
string subject)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
|
||||
var result = await service.ResolveAsync(
|
||||
[
|
||||
Row(groupCode: "종목", subject: subject, graphicType: "2열판")
|
||||
]);
|
||||
|
||||
var outcome = Assert.Single(result.Rows);
|
||||
Assert.False(outcome.IsResolved);
|
||||
Assert.Equal(
|
||||
LegacyNamedComparisonRestoreFailure.MissingMarketIdentity,
|
||||
outcome.Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ReportsUnsupportedFixedExpectedPairAsOneFailedRow()
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
|
||||
var result = await service.ResolveAsync(
|
||||
[
|
||||
Row(
|
||||
groupCode: "지수",
|
||||
subject: "해외지수-다우,해외지수-나스닥",
|
||||
graphicType: "2열판",
|
||||
subtype: "예상체결")
|
||||
]);
|
||||
|
||||
Assert.Equal(
|
||||
LegacyNamedComparisonRestoreFailure.UnsupportedAction,
|
||||
Assert.Single(result.Rows).Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_BlocksAllSixteenStaleMarketLostRowsWithoutSearchingByName()
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
var rows = Enumerable.Range(0, 16)
|
||||
.Select(index => Row(
|
||||
itemIndex: index,
|
||||
groupCode: "종목",
|
||||
subject: $"과거종목{index * 2},과거종목{index * 2 + 1}",
|
||||
graphicType: "2열판"))
|
||||
.ToArray();
|
||||
|
||||
var result = await service.ResolveAsync(rows);
|
||||
|
||||
Assert.Equal(16, result.Rows.Count);
|
||||
Assert.All(result.Rows, outcome => Assert.Equal(
|
||||
LegacyNamedComparisonRestoreFailure.MissingMarketIdentity,
|
||||
outcome.Failure));
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("종목별 비교분석_캔들 그래프", "", "comparison-candle")]
|
||||
[InlineData("종목별 비교분석_라인 그래프", "", "comparison-line")]
|
||||
[InlineData("종목별 수익률 비교", "5일", "return-5d")]
|
||||
[InlineData("종목별 수익률 비교", "1개월", "return-1m")]
|
||||
[InlineData("종목별 수익률 비교", "3개월", "return-3m")]
|
||||
[InlineData("종목별 수익률 비교", "6개월", "return-6m")]
|
||||
[InlineData("종목별 수익률 비교", "12개월", "return-12m")]
|
||||
public async Task ResolveAsync_RestoresEveryGraphSubtypeWithExactPerTargetMarkets(
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string expectedAction)
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
|
||||
return name switch
|
||||
{
|
||||
"삼성전자" => StockTable("삼성전자", "005930"),
|
||||
"에이비엘바이오" => StockTable("에이비엘바이오", "298380"),
|
||||
_ => throw new InvalidOperationException()
|
||||
};
|
||||
});
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
|
||||
var result = await service.ResolveAsync(
|
||||
[
|
||||
Row(
|
||||
groupCode: "코스피,코스닥",
|
||||
subject: "삼성전자,에이비엘바이오",
|
||||
graphicType: graphicType,
|
||||
subtype: subtype)
|
||||
]);
|
||||
|
||||
var outcome = Assert.Single(result.Rows);
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Equal(expectedAction, outcome.ActionId);
|
||||
Assert.Equal("kospi", outcome.First!.Market);
|
||||
Assert.Equal("005930", outcome.First.StockCode);
|
||||
Assert.Equal("kosdaq", outcome.Second!.Market);
|
||||
Assert.Equal("298380", outcome.Second.StockCode);
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
Assert.All(executor.Calls, call =>
|
||||
{
|
||||
call.Spec.ValidateFor(call.Source);
|
||||
Assert.DoesNotContain(
|
||||
Assert.IsType<string>(call.Spec.Parameters[0].Value),
|
||||
call.Spec.Sql,
|
||||
StringComparison.Ordinal);
|
||||
});
|
||||
Assert.Equal(0, executor.StringSqlCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_CachesRepeatedIdentityAcrossRows()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
StockTable(
|
||||
Assert.IsType<string>(call.Spec.Parameters[0].Value),
|
||||
call.TableName.EndsWith("KOSPI", StringComparison.Ordinal)
|
||||
? "005930"
|
||||
: "298380"));
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
|
||||
var result = await service.ResolveAsync(
|
||||
[
|
||||
Row(
|
||||
groupCode: "코스피,코스닥",
|
||||
subject: "삼성전자,에이비엘바이오",
|
||||
graphicType: "종목별 비교분석_캔들 그래프"),
|
||||
Row(
|
||||
itemIndex: 1,
|
||||
groupCode: "코스피,코스닥",
|
||||
subject: "삼성전자,에이비엘바이오",
|
||||
graphicType: "종목별 수익률 비교",
|
||||
subtype: "5일")
|
||||
]);
|
||||
|
||||
Assert.All(result.Rows, row => Assert.True(row.IsResolved));
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FailsEachMissingAmbiguousAndMalformedIdentityClosed()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
var name = Assert.IsType<string>(call.Spec.Parameters[0].Value);
|
||||
return name switch
|
||||
{
|
||||
"없음" => StockTable(),
|
||||
"중복" => StockTable(("중복", "111111"), ("중복", "222222")),
|
||||
"오염" => WrongSchemaTable(),
|
||||
"정상" => StockTable("정상", "333333"),
|
||||
_ => throw new InvalidOperationException()
|
||||
};
|
||||
});
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
|
||||
var result = await service.ResolveAsync(
|
||||
[
|
||||
Row(
|
||||
groupCode: "코스피,코스닥",
|
||||
subject: "없음,정상",
|
||||
graphicType: "종목별 비교분석_캔들 그래프"),
|
||||
Row(
|
||||
itemIndex: 1,
|
||||
groupCode: "코스피,코스닥",
|
||||
subject: "중복,정상",
|
||||
graphicType: "종목별 비교분석_라인 그래프"),
|
||||
Row(
|
||||
itemIndex: 2,
|
||||
groupCode: "코스피,코스닥",
|
||||
subject: "오염,정상",
|
||||
graphicType: "종목별 수익률 비교",
|
||||
subtype: "1개월")
|
||||
]);
|
||||
|
||||
Assert.Equal(
|
||||
new LegacyNamedComparisonRestoreFailure?[]
|
||||
{
|
||||
LegacyNamedComparisonRestoreFailure.IdentityNotFound,
|
||||
LegacyNamedComparisonRestoreFailure.IdentityAmbiguous,
|
||||
LegacyNamedComparisonRestoreFailure.DatabaseDataInvalid
|
||||
},
|
||||
result.Rows.Select(row => row.Failure));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RejectsNxtGraphsBeforeTheyCanBecomePlayable()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
StockTable(
|
||||
Assert.IsType<string>(call.Spec.Parameters[0].Value),
|
||||
"005930"));
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
|
||||
var result = await service.ResolveAsync(
|
||||
[
|
||||
Row(
|
||||
groupCode: "코스피_NXT,코스피_NXT",
|
||||
subject: "삼성전자(NXT),기아(NXT)",
|
||||
graphicType: "종목별 비교분석_캔들 그래프")
|
||||
]);
|
||||
|
||||
var outcome = Assert.Single(result.Rows);
|
||||
Assert.Equal(
|
||||
LegacyNamedComparisonRestoreFailure.UnsupportedAction,
|
||||
outcome.Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("종목별 수익률 비교", "20일")]
|
||||
[InlineData("종목별 비교분석_캔들 그래프", "5일")]
|
||||
[InlineData("2열판", "CURRENT")]
|
||||
public async Task ResolveAsync_RejectsUnknownOrCanonicalizedSubtypeGrammar(
|
||||
string graphicType,
|
||||
string subtype)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
|
||||
var result = await service.ResolveAsync(
|
||||
[
|
||||
Row(
|
||||
groupCode: graphicType == "2열판" ? "지수" : "코스피,코스피",
|
||||
subject: graphicType == "2열판"
|
||||
? "코스피 지수,코스닥 지수"
|
||||
: "삼성전자,기아",
|
||||
graphicType: graphicType,
|
||||
subtype: subtype)
|
||||
]);
|
||||
|
||||
Assert.Equal(
|
||||
LegacyNamedComparisonRestoreFailure.InvalidRow,
|
||||
Assert.Single(result.Rows).Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_HonorsCancellationAndRejectsDuplicateIndexes()
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyNamedComparisonRestoreService(executor);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => service.ResolveAsync(
|
||||
[Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판")],
|
||||
cancellation.Token));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
|
||||
[
|
||||
Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판"),
|
||||
Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판")
|
||||
]));
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
private static LegacyNamedComparisonRestoreRow Row(
|
||||
int itemIndex = 0,
|
||||
string groupCode = "코스피,코스피",
|
||||
string subject = "삼성전자,기아",
|
||||
string graphicType = "종목별 비교분석_캔들 그래프",
|
||||
string subtype = "",
|
||||
string pageText = "1/1",
|
||||
string dataCode = "") =>
|
||||
new(
|
||||
itemIndex,
|
||||
true,
|
||||
groupCode,
|
||||
subject,
|
||||
graphicType,
|
||||
subtype,
|
||||
pageText,
|
||||
dataCode);
|
||||
|
||||
private static DataTable StockTable(params (string Name, string Code)[] rows)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("STOCK_NAME", typeof(string));
|
||||
table.Columns.Add("STOCK_CODE", typeof(string));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(row.Name, row.Code);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable StockTable(string name, string code) =>
|
||||
StockTable((name, code));
|
||||
|
||||
private static DataTable WrongSchemaTable()
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("stock_name", typeof(string));
|
||||
table.Columns.Add("STOCK_CODE", typeof(string));
|
||||
return table;
|
||||
}
|
||||
|
||||
private sealed record RecordedCall(
|
||||
DataSourceKind Source,
|
||||
string TableName,
|
||||
DataQuerySpec Spec,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
|
||||
: IDataQueryExecutor
|
||||
{
|
||||
private int _stringSqlCallCount;
|
||||
|
||||
public List<RecordedCall> Calls { get; } = [];
|
||||
|
||||
public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);
|
||||
|
||||
public DataTable Execute(DataSourceKind source, string tableName, string query)
|
||||
{
|
||||
Interlocked.Increment(ref _stringSqlCallCount);
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _stringSqlCallCount);
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
DataQuerySpec query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
query.ValidateFor(source);
|
||||
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||
Calls.Add(call);
|
||||
return Task.FromResult(handler(call).Copy());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyNamedNxtThemeRestoreServiceTests
|
||||
{
|
||||
public static TheoryData<string, string, string, string, int, string> SupportedRows =>
|
||||
new()
|
||||
{
|
||||
{ "5단 표그래프", "테마-현재가(입력순)", "five-row-current", "s5074", 5, "INPUT_ORDER" },
|
||||
{ "5단 표그래프", "테마-현재가(상승률순)", "five-row-current", "s5074", 5, "GAIN_DESC" },
|
||||
{ "5단 표그래프", "테마-현재가(하락률순)", "five-row-current", "s5074", 5, "GAIN_ASC" },
|
||||
{ "5단 표그래프", "테마-현재가", "five-row-current", "s5074", 5, "GAIN_ASC" },
|
||||
{ "6종목 현재가", "테마-현재가(입력순)", "six-row-current", "s5077", 6, "INPUT_ORDER" },
|
||||
{ "6종목 현재가", "테마-현재가(상승률순)", "six-row-current", "s5077", 6, "GAIN_DESC" },
|
||||
{ "6종목 현재가", "테마-현재가(하락률순)", "six-row-current", "s5077", 6, "GAIN_ASC" },
|
||||
{ "6종목 현재가", "테마-현재가", "six-row-current", "s5077", 6, "GAIN_ASC" },
|
||||
{ "12종목 현재가", "테마-현재가(입력순)", "twelve-row-current", "s5088", 12, "INPUT_ORDER" },
|
||||
{ "12종목 현재가", "테마-현재가(상승률순)", "twelve-row-current", "s5088", 12, "GAIN_DESC" },
|
||||
{ "12종목 현재가", "테마-현재가(하락률순)", "twelve-row-current", "s5088", 12, "GAIN_ASC" },
|
||||
{ "12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", 12, "GAIN_ASC" }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SupportedRows))]
|
||||
public async Task ResolveAsync_MapsOnlyOriginalCurrentPriceActionsAndRemapsStaleCode(
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string actionId,
|
||||
string builderKey,
|
||||
int pageSize,
|
||||
string sort)
|
||||
{
|
||||
var executor = GoodExecutor("반도체", "87654321", previewCount: 2, liveCount: 2);
|
||||
var service = new LegacyNamedNxtThemeRestoreService(
|
||||
executor,
|
||||
new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 12, 59, 0, TimeSpan.Zero)));
|
||||
|
||||
var outcome = Assert.Single((await service.ResolveAsync(
|
||||
[Row(graphicType: graphicType, subtype: subtype)])).Rows);
|
||||
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Null(outcome.Failure);
|
||||
Assert.NotNull(outcome.Identity);
|
||||
Assert.Equal(actionId, outcome.Identity.ActionId);
|
||||
Assert.Equal(builderKey, outcome.Identity.BuilderKey);
|
||||
Assert.Equal(builderKey[1..], outcome.Identity.CutCode);
|
||||
Assert.Equal(pageSize, outcome.Identity.PageSize);
|
||||
Assert.Equal(sort, outcome.Identity.Sort);
|
||||
Assert.Equal(ThemeSession.PreMarket, outcome.Identity.Session);
|
||||
Assert.Equal("반도체", outcome.Identity.ThemeTitle);
|
||||
Assert.Equal("0123", outcome.Identity.StoredThemeCode);
|
||||
Assert.Equal("87654321", outcome.Identity.CurrentThemeCode);
|
||||
Assert.True(outcome.Identity.CodeWasRemapped);
|
||||
Assert.Equal(2, outcome.Identity.PreviewItemCount);
|
||||
Assert.Equal(2, outcome.Identity.LiveItemCount);
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
|
||||
var identityCall = executor.Calls[0];
|
||||
Assert.Equal(DataSourceKind.MariaDb, identityCall.Source);
|
||||
Assert.Equal(LegacyNamedNxtThemeRestoreService.IdentityQueryName, identityCall.QueryName);
|
||||
identityCall.Spec.ValidateFor(DataSourceKind.MariaDb);
|
||||
Assert.Contains("BINARY s.SB_TITLE = BINARY @theme_title", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("BINARY d.SB_CODE = BINARY s.SB_CODE", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("F_CURR_PRICE != 0", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("LIMIT 2", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("반도체", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Collection(identityCall.Spec.Parameters, parameter =>
|
||||
{
|
||||
Assert.Equal("theme_title", parameter.Name);
|
||||
Assert.Equal("반도체", parameter.Value);
|
||||
Assert.Equal(DbType.String, parameter.DbType);
|
||||
});
|
||||
|
||||
var previewCall = executor.Calls[1];
|
||||
Assert.Equal("THEME_PREVIEW_NXT", previewCall.QueryName);
|
||||
Assert.Equal("87654321", previewCall.Spec.Parameters[0].Value);
|
||||
Assert.Equal("반도체", previewCall.Spec.Parameters[1].Value);
|
||||
Assert.Equal(241, previewCall.Spec.Parameters[2].Value);
|
||||
Assert.Equal(0, executor.StringSqlCallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, ThemeSession.PreMarket)]
|
||||
[InlineData(12, ThemeSession.PreMarket)]
|
||||
[InlineData(13, ThemeSession.AfterMarket)]
|
||||
[InlineData(23, ThemeSession.AfterMarket)]
|
||||
public void SessionForLocalHour_PreservesOriginalBoundary(
|
||||
int hour,
|
||||
ThemeSession expected) =>
|
||||
Assert.Equal(expected, LegacyNamedNxtThemeRestoreService.SessionForLocalHour(hour));
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_CachesExactTitleButPreservesBatchOrderAndStoredCodes()
|
||||
{
|
||||
var executor = GoodExecutor("AI", "90000001", previewCount: 1, liveCount: 1);
|
||||
var service = new LegacyNamedNxtThemeRestoreService(executor);
|
||||
var rows = new[]
|
||||
{
|
||||
Row(itemIndex: 7, subject: "AI(NXT)", dataCode: "111"),
|
||||
Row(itemIndex: 2, subject: "AI(NXT)", dataCode: "2222", graphicType: "6종목 현재가")
|
||||
};
|
||||
|
||||
var result = await service.ResolveAsync(rows);
|
||||
|
||||
Assert.Equal([7, 2], result.Rows.Select(row => row.ItemIndex));
|
||||
Assert.Equal(["111", "2222"], result.Rows.Select(row => row.Identity!.StoredThemeCode));
|
||||
Assert.All(result.Rows, row => Assert.Equal("90000001", row.Identity!.CurrentThemeCode));
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("테마", "반도체(NXT)", "5단 표그래프", "테마-현재가", "1/1", "0123")]
|
||||
[InlineData("테마_NXT", "반도체", "5단 표그래프", "테마-현재가", "1/1", "0123")]
|
||||
[InlineData("테마_NXT", "반도체(NXT)", "5단 표그래프", "테마-현재가", "01/1", "0123")]
|
||||
[InlineData("테마_NXT", "반도체(NXT)", "5단 표그래프", "테마-현재가", "1/1", "R0123")]
|
||||
public async Task ResolveAsync_InvalidRowsFailWithoutDatabaseAccess(
|
||||
string groupCode,
|
||||
string subject,
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string pageText,
|
||||
string dataCode)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyNamedNxtThemeRestoreService(executor);
|
||||
var row = Row(
|
||||
groupCode: groupCode,
|
||||
subject: subject,
|
||||
graphicType: graphicType,
|
||||
subtype: subtype,
|
||||
pageText: pageText,
|
||||
dataCode: dataCode);
|
||||
|
||||
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
|
||||
|
||||
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.InvalidRow, outcome.Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("5단 표그래프", "테마-예상체결가(입력순)")]
|
||||
[InlineData("네모그래프", "테마-현재가(입력순)")]
|
||||
[InlineData("5단 표그래프", "테마-현재가(거래량순)")]
|
||||
public async Task ResolveAsync_UnsupportedActionsFailClosedPerRow(
|
||||
string graphicType,
|
||||
string subtype)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var outcome = Assert.Single((await new LegacyNamedNxtThemeRestoreService(executor)
|
||||
.ResolveAsync([Row(graphicType: graphicType, subtype: subtype)])).Rows);
|
||||
|
||||
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.UnsupportedAction, outcome.Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ReportsNotFoundAndAmbiguousPerTitleWithoutPreview()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
var title = Assert.IsType<string>(call.Spec.Parameters[0].Value);
|
||||
return title == "없음"
|
||||
? IdentityTable()
|
||||
: IdentityTable(
|
||||
(title, "111", "NXT", "1", "2", "2"),
|
||||
(title, "222", "NXT", "1", "2", "2"));
|
||||
});
|
||||
var result = await new LegacyNamedNxtThemeRestoreService(executor).ResolveAsync(
|
||||
[
|
||||
Row(itemIndex: 0, subject: "없음(NXT)"),
|
||||
Row(itemIndex: 1, subject: "중복(NXT)")
|
||||
]);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
LegacyNamedNxtThemeRestoreFailure.IdentityNotFound,
|
||||
LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous
|
||||
],
|
||||
result.Rows.Select(row => row.Failure));
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, LegacyNamedNxtThemeRestoreFailure.PreviewEmpty)]
|
||||
[InlineData(2, 0, LegacyNamedNxtThemeRestoreFailure.LiveItemsEmpty)]
|
||||
public async Task ResolveAsync_RequiresValidatedPreviewAndAtLeastOneLiveItem(
|
||||
int previewCount,
|
||||
int liveCount,
|
||||
LegacyNamedNxtThemeRestoreFailure expected)
|
||||
{
|
||||
var executor = new RecordingExecutor(call => call.QueryName switch
|
||||
{
|
||||
LegacyNamedNxtThemeRestoreService.IdentityQueryName =>
|
||||
IdentityTable(("반도체", "00000001", "NXT", "1",
|
||||
previewCount.ToString(), liveCount.ToString())),
|
||||
"THEME_PREVIEW_NXT" => previewCount == 0
|
||||
? EmptyPreviewTable("반도체", "00000001")
|
||||
: PreviewTable("반도체", "00000001", previewCount),
|
||||
_ => throw new InvalidOperationException(call.QueryName)
|
||||
});
|
||||
|
||||
var outcome = Assert.Single((await new LegacyNamedNxtThemeRestoreService(executor)
|
||||
.ResolveAsync([Row()])).Rows);
|
||||
|
||||
Assert.Equal(expected, outcome.Failure);
|
||||
Assert.False(outcome.IsResolved);
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FailsClosedOnInvalidDatabaseSchemaOrCodeCardinality()
|
||||
{
|
||||
var wrongSchemaExecutor = new RecordingExecutor(_ => new DataTable());
|
||||
var wrongSchema = Assert.Single((await new LegacyNamedNxtThemeRestoreService(
|
||||
wrongSchemaExecutor).ResolveAsync([Row()])).Rows);
|
||||
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.DatabaseDataInvalid, wrongSchema.Failure);
|
||||
|
||||
var duplicateCodeExecutor = new RecordingExecutor(_ =>
|
||||
IdentityTable(("반도체", "00000001", "NXT", "2", "1", "1")));
|
||||
var duplicateCode = Assert.Single((await new LegacyNamedNxtThemeRestoreService(
|
||||
duplicateCodeExecutor).ResolveAsync([Row()])).Rows);
|
||||
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous, duplicateCode.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PropagatesReadFailureOnceAndHonorsCancellation()
|
||||
{
|
||||
var failing = new RecordingExecutor(_ => throw new DataException("simulated"));
|
||||
await Assert.ThrowsAsync<DataException>(() =>
|
||||
new LegacyNamedNxtThemeRestoreService(failing).ResolveAsync([Row()]));
|
||||
Assert.Single(failing.Calls);
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var canceled = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
new LegacyNamedNxtThemeRestoreService(canceled)
|
||||
.ResolveAsync([Row()], cancellation.Token));
|
||||
Assert.Empty(canceled.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RejectsInvalidBatchAndAllowsDisabledRows()
|
||||
{
|
||||
var executor = GoodExecutor("반도체", "00000001", 1, 1);
|
||||
var service = new LegacyNamedNxtThemeRestoreService(executor);
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
|
||||
[Row(itemIndex: 3), Row(itemIndex: 3)]));
|
||||
|
||||
var outcome = Assert.Single((await service.ResolveAsync([Row(isEnabled: false)])).Rows);
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.True(LegacyNamedNxtThemeRestoreService.IsCandidate(Row(isEnabled: false)));
|
||||
}
|
||||
|
||||
private static RecordingExecutor GoodExecutor(
|
||||
string title,
|
||||
string code,
|
||||
int previewCount,
|
||||
int liveCount) =>
|
||||
new(call => call.QueryName switch
|
||||
{
|
||||
LegacyNamedNxtThemeRestoreService.IdentityQueryName => IdentityTable((
|
||||
title,
|
||||
code,
|
||||
"NXT",
|
||||
"1",
|
||||
previewCount.ToString(),
|
||||
liveCount.ToString())),
|
||||
"THEME_PREVIEW_NXT" => PreviewTable(title, code, previewCount),
|
||||
_ => throw new InvalidOperationException(call.QueryName)
|
||||
});
|
||||
|
||||
private static LegacyNamedNxtThemeRestoreRow Row(
|
||||
int itemIndex = 0,
|
||||
bool isEnabled = true,
|
||||
string groupCode = "테마_NXT",
|
||||
string subject = "반도체(NXT)",
|
||||
string graphicType = "5단 표그래프",
|
||||
string subtype = "테마-현재가(입력순)",
|
||||
string pageText = "1/1",
|
||||
string dataCode = "0123") =>
|
||||
new(itemIndex, isEnabled, groupCode, subject, graphicType, subtype, pageText, dataCode);
|
||||
|
||||
private static DataTable IdentityTable(
|
||||
params (string Title, string Code, string Market, string CodeCount,
|
||||
string PreviewCount, string LiveCount)[] rows)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("THEME_TITLE", typeof(string));
|
||||
table.Columns.Add("THEME_CODE", typeof(string));
|
||||
table.Columns.Add("THEME_MARKET", typeof(string));
|
||||
table.Columns.Add("CODE_IDENTITY_COUNT", typeof(string));
|
||||
table.Columns.Add("PREVIEW_ITEM_COUNT", typeof(string));
|
||||
table.Columns.Add("LIVE_ITEM_COUNT", typeof(string));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(
|
||||
row.Title,
|
||||
row.Code,
|
||||
row.Market,
|
||||
row.CodeCount,
|
||||
row.PreviewCount,
|
||||
row.LiveCount);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable PreviewTable(string title, string code, int count)
|
||||
{
|
||||
var table = CreatePreviewTable();
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
table.Rows.Add(
|
||||
title,
|
||||
code,
|
||||
"NXT",
|
||||
$"종목{index + 1}",
|
||||
$"X{index + 1:D6}",
|
||||
index.ToString());
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable EmptyPreviewTable(string title, string code)
|
||||
{
|
||||
var table = CreatePreviewTable();
|
||||
table.Rows.Add(title, code, "NXT", DBNull.Value, DBNull.Value, DBNull.Value);
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable CreatePreviewTable()
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("THEME_TITLE", typeof(string));
|
||||
table.Columns.Add("THEME_CODE", typeof(string));
|
||||
table.Columns.Add("THEME_MARKET", typeof(string));
|
||||
table.Columns.Add("ITEM_NAME", typeof(string));
|
||||
table.Columns.Add("ITEM_CODE", typeof(string));
|
||||
table.Columns.Add("ITEM_INDEX", typeof(string));
|
||||
return table;
|
||||
}
|
||||
|
||||
private sealed record RecordedCall(
|
||||
DataSourceKind Source,
|
||||
string QueryName,
|
||||
DataQuerySpec Spec,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
|
||||
: IDataQueryExecutor
|
||||
{
|
||||
public List<RecordedCall> Calls { get; } = [];
|
||||
|
||||
public int StringSqlCallCount { get; private set; }
|
||||
|
||||
public DataTable Execute(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query)
|
||||
{
|
||||
StringSqlCallCount++;
|
||||
throw new InvalidOperationException("String SQL must not be used.");
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string sql,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
StringSqlCallCount++;
|
||||
throw new InvalidOperationException("String SQL must not be used.");
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
DataQuerySpec query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||
Calls.Add(call);
|
||||
return Task.FromResult(handler(call));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => value.ToUniversalTime();
|
||||
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,21 @@ public sealed class LegacyNamedPlaylistPersistenceServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_AllowsManualViSubjectBeyondTheGeneralFieldLimit()
|
||||
{
|
||||
var names = string.Join(",", Enumerable.Range(0, 100).Select(index => $"종목{index:D3}"));
|
||||
Assert.True(names.Length > 256);
|
||||
Assert.True($"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^".Length <= 4_000);
|
||||
var service = new LegacyNamedPlaylistPersistenceService(
|
||||
new RecordingQueryExecutor(_ => LoadTable(
|
||||
("00000001", "VI", $"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^", "0"))));
|
||||
|
||||
var document = await service.LoadAsync("00000001");
|
||||
|
||||
Assert.Equal(names, Assert.Single(document.Items).Selection.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ReturnsEmptyDocumentFromLeftJoinSentinel()
|
||||
{
|
||||
@@ -294,6 +309,28 @@ public sealed class LegacyNamedPlaylistPersistenceServiceTests
|
||||
Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceItemsAsync_AllowsManualViSubjectBeyondTheGeneralFieldLimit()
|
||||
{
|
||||
var names = string.Join(",", Enumerable.Range(0, 100).Select(index => $"종목{index:D3}"));
|
||||
var mutations = new RecordingMutationExecutor(call => Result(call, [0, 1]));
|
||||
var service = new LegacyNamedPlaylistPersistenceService(
|
||||
new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
|
||||
mutations);
|
||||
|
||||
await service.ReplaceItemsAsync(
|
||||
"00000001",
|
||||
[Item(0, true, "", names, "5단 표그래프", "VI 발동(수동)", "", 1, 20)]);
|
||||
|
||||
var insert = Assert.Single(
|
||||
Assert.Single(mutations.Calls).Transaction.Commands,
|
||||
command => command.Kind == NamedPlaylistDbCommandKind.InsertItem);
|
||||
var listText = Assert.Single(
|
||||
insert.Parameters,
|
||||
parameter => parameter.Name == "list_text");
|
||||
Assert.Equal($"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^", listText.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceItemsAsync_RejectsGapsAndCaretInjectionBeforeMutation()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Data;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyStockMasterIdentityValidationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ThemeItems_ResolveExactPrefixMarketWithBoundFreshQueries()
|
||||
{
|
||||
var executor = new CatalogRecordingQueryExecutor(call => call.TableName switch
|
||||
{
|
||||
"STOCK_MASTER_VERIFY_KOSPI" => StockTable(("삼성전자", "005930")),
|
||||
"STOCK_MASTER_VERIFY_KOSDAQ" => StockTable(("카카오", "035720")),
|
||||
_ => throw new InvalidOperationException(call.TableName)
|
||||
});
|
||||
var service = new LegacyStockMasterIdentityValidationService(executor);
|
||||
|
||||
var result = await service.ValidateThemeItemsAsync(
|
||||
[
|
||||
new ThemeCatalogItem(0, "P005930", "삼성전자"),
|
||||
new ThemeCatalogItem(1, "D035720", "카카오")
|
||||
]);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
new StockMasterIdentity(
|
||||
StockMarket.Kospi,
|
||||
DataSourceKind.Oracle,
|
||||
"삼성전자",
|
||||
"005930"),
|
||||
new StockMasterIdentity(
|
||||
StockMarket.Kosdaq,
|
||||
DataSourceKind.Oracle,
|
||||
"카카오",
|
||||
"035720")
|
||||
], result);
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
Assert.All(executor.Calls, call =>
|
||||
{
|
||||
call.Spec.ValidateFor(call.Source);
|
||||
Assert.Equal(2, call.Spec.Parameters.Count);
|
||||
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NxtThemeItem_ValidatesStoredNameWithoutDisplaySuffix()
|
||||
{
|
||||
var executor = new CatalogRecordingQueryExecutor(call =>
|
||||
{
|
||||
Assert.Equal("STOCK_MASTER_VERIFY_NXT_KOSPI", call.TableName);
|
||||
Assert.DoesNotContain("CONCAT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("삼성전자", call.Spec.Parameters[1].Value);
|
||||
return StockTable(("삼성전자", "005930"));
|
||||
});
|
||||
var service = new LegacyStockMasterIdentityValidationService(executor);
|
||||
|
||||
var result = await service.ValidateThemeItemsAsync(
|
||||
[new ThemeCatalogItem(0, "X005930", "삼성전자")]);
|
||||
|
||||
var identity = Assert.Single(result);
|
||||
Assert.Equal(StockMarket.NxtKospi, identity.Market);
|
||||
Assert.Equal(DataSourceKind.MariaDb, identity.Source);
|
||||
Assert.Equal("삼성전자", identity.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpertRecommendation_UsesNxtDisplaySuffixAndRequiresOneMarket()
|
||||
{
|
||||
var executor = new CatalogRecordingQueryExecutor(call => call.TableName switch
|
||||
{
|
||||
"STOCK_MASTER_VERIFY_NXT_KOSPI" => StockTable(("삼성전자(NXT)", "005930")),
|
||||
_ => StockTable()
|
||||
});
|
||||
var service = new LegacyStockMasterIdentityValidationService(executor);
|
||||
|
||||
var result = await service.ValidateExpertRecommendationsAsync(
|
||||
[new ExpertCatalogRecommendation(0, "005930", "삼성전자(NXT)", 70_000)]);
|
||||
|
||||
var identity = Assert.Single(result);
|
||||
Assert.Equal(StockMarket.NxtKospi, identity.Market);
|
||||
Assert.Equal("삼성전자(NXT)", identity.Name);
|
||||
Assert.Equal(4, executor.Calls.Count);
|
||||
var nxt = Assert.Single(
|
||||
executor.Calls,
|
||||
call => call.TableName == "STOCK_MASTER_VERIFY_NXT_KOSPI");
|
||||
Assert.Contains("CONCAT", nxt.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpertRecommendation_RejectsSameExactIdentityAcrossMarkets()
|
||||
{
|
||||
var executor = new CatalogRecordingQueryExecutor(call => call.TableName switch
|
||||
{
|
||||
"STOCK_MASTER_VERIFY_KOSPI" or "STOCK_MASTER_VERIFY_KOSDAQ" =>
|
||||
StockTable(("동명이인", "000001")),
|
||||
_ => StockTable()
|
||||
});
|
||||
var service = new LegacyStockMasterIdentityValidationService(executor);
|
||||
|
||||
await Assert.ThrowsAsync<StockMasterIdentityDataException>(() =>
|
||||
service.ValidateExpertRecommendationsAsync(
|
||||
[new ExpertCatalogRecommendation(0, "000001", "동명이인", 1)]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("missing")]
|
||||
[InlineData("duplicate")]
|
||||
[InlineData("extra-column")]
|
||||
public async Task ThemeItem_RejectsMissingDuplicateOrExpandedResult(string mode)
|
||||
{
|
||||
var executor = new CatalogRecordingQueryExecutor(_ => mode switch
|
||||
{
|
||||
"missing" => StockTable(),
|
||||
"duplicate" => StockTable(("삼성전자", "005930"), ("삼성전자", "005930")),
|
||||
"extra-column" => StockTableWithExtraColumn(("삼성전자", "005930")),
|
||||
_ => throw new InvalidOperationException(mode)
|
||||
});
|
||||
var service = new LegacyStockMasterIdentityValidationService(executor);
|
||||
|
||||
await Assert.ThrowsAsync<StockMasterIdentityDataException>(() =>
|
||||
service.ValidateThemeItemsAsync(
|
||||
[new ThemeCatalogItem(0, "P005930", "삼성전자")]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreCanceledValidation_DoesNotTouchTheDatabase()
|
||||
{
|
||||
var executor = new CatalogRecordingQueryExecutor(_ => StockTable());
|
||||
var service = new LegacyStockMasterIdentityValidationService(executor);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
service.ValidateThemeItemsAsync(
|
||||
[new ThemeCatalogItem(0, "P005930", "삼성전자")],
|
||||
cancellation.Token));
|
||||
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
private static DataTable StockTable(params (string Name, string Code)[] rows)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("STOCK_NAME", typeof(string));
|
||||
table.Columns.Add("STOCK_CODE", typeof(string));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(row.Name, row.Code);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable StockTableWithExtraColumn(params (string Name, string Code)[] rows)
|
||||
{
|
||||
var table = StockTable(rows);
|
||||
table.Columns.Add("UNEXPECTED", typeof(string));
|
||||
return table;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
|
||||
{
|
||||
Assert.Equal(DataSourceKind.Oracle, call.Source);
|
||||
Assert.Contains("TO_NUMBER(TRIM(SB_CODE))", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("'^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("FROM SB_LIST", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Empty(call.Spec.Parameters);
|
||||
},
|
||||
@@ -35,7 +36,8 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
|
||||
{
|
||||
Assert.Equal(DataSourceKind.MariaDb, call.Source);
|
||||
Assert.Contains("CAST(TRIM(SB_CODE) AS UNSIGNED)", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("REGEXP '^[0-9]{8}$'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("REGEXP BINARY '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("INVALID", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Empty(call.Spec.Parameters);
|
||||
});
|
||||
}
|
||||
@@ -166,6 +168,60 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
|
||||
transaction.Commands[1].AffectedRowsRule);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("123")]
|
||||
[InlineData("0123")]
|
||||
[InlineData("000123")]
|
||||
[InlineData("00000123")]
|
||||
public async Task ExistingIdentity_PreservesLegacyThreeThroughEightDigitCode(string code)
|
||||
{
|
||||
var mutation = new CatalogRecordingMutationExecutor();
|
||||
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
|
||||
var identity = new ThemeSelectionIdentity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
code,
|
||||
"기존 테마");
|
||||
|
||||
await service.ReplaceAsync(identity, "수정 테마", []);
|
||||
|
||||
var transaction = Assert.Single(mutation.Calls).Transaction;
|
||||
Assert.Equal(code, transaction.IdentityCode);
|
||||
Assert.Equal(code, Parameter(transaction.Commands[0], "theme_code").Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("12")]
|
||||
[InlineData("123456789")]
|
||||
[InlineData("12A")]
|
||||
public async Task ExistingIdentity_RejectsCodeOutsideClosedLegacyRange(string code)
|
||||
{
|
||||
var mutation = new CatalogRecordingMutationExecutor();
|
||||
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
|
||||
var identity = new ThemeSelectionIdentity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
code,
|
||||
"기존 테마");
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
service.DeleteAsync(identity));
|
||||
|
||||
Assert.Empty(mutation.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateStillRequiresNewEightDigitCode()
|
||||
{
|
||||
var mutation = new CatalogRecordingMutationExecutor();
|
||||
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
|
||||
new ThemeCatalogDefinition(ThemeMarket.Krx, "123", "신규 테마", [])));
|
||||
|
||||
Assert.Empty(mutation.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_DeletesChildrenBeforeIdentityBoundParent()
|
||||
{
|
||||
|
||||
@@ -58,6 +58,9 @@ public sealed class LegacyThemeSelectionServiceTests
|
||||
Assert.Equal(DataSourceKind.Oracle, call.Source);
|
||||
Assert.Equal("THEME_SEARCH_KRX", call.TableName);
|
||||
Assert.Contains("SB_MARKET = 'KRX'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("REGEXP_LIKE(s.SB_CODE, '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("d.SB_TITLE = s.SB_TITLE", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("d.SB_CODE = s.SB_CODE", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Equal("AI", call.Spec.Parameters[0].Value);
|
||||
Assert.Equal(26, call.Spec.Parameters[1].Value);
|
||||
@@ -68,6 +71,9 @@ public sealed class LegacyThemeSelectionServiceTests
|
||||
Assert.Equal(DataSourceKind.MariaDb, call.Source);
|
||||
Assert.Equal("THEME_SEARCH_NXT", call.TableName);
|
||||
Assert.Contains("SB_MARKET = 'NXT'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("s.SB_CODE REGEXP BINARY '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("BINARY d.SB_TITLE = BINARY s.SB_TITLE", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("BINARY d.SB_CODE = BINARY s.SB_CODE", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("LIMIT @row_limit", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Equal("AI", call.Spec.Parameters[0].Value);
|
||||
Assert.Equal(26, call.Spec.Parameters[1].Value);
|
||||
@@ -97,6 +103,24 @@ public sealed class LegacyThemeSelectionServiceTests
|
||||
Assert.All(executor.Calls, call => Assert.Equal("A!!!_!%", call.Spec.Parameters[0].Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_PreservesLegacyThreeAndFourDigitReadIdentities()
|
||||
{
|
||||
var executor = new RecordingExecutor(call => call.Source switch
|
||||
{
|
||||
DataSourceKind.Oracle => SearchTable(("Legacy KRX", "123", "KRX")),
|
||||
DataSourceKind.MariaDb => SearchTable(("Legacy NXT", "0123", "NXT")),
|
||||
_ => throw new InvalidOperationException()
|
||||
});
|
||||
var service = new LegacyThemeSelectionService(executor);
|
||||
|
||||
var result = await service.SearchAsync("", ThemeSession.PreMarket);
|
||||
|
||||
Assert.Equal(
|
||||
["123", "0123"],
|
||||
result.Items.Select(static item => item.Identity.ThemeCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_AppliesCombinedBoundAndDeterministicMarketOrder()
|
||||
{
|
||||
@@ -340,7 +364,7 @@ public sealed class LegacyThemeSelectionServiceTests
|
||||
new(ThemeMarket.Nxt, ThemeSession.NotApplicable, "00000001", "AI"),
|
||||
new(ThemeMarket.Nxt, (ThemeSession)99, "00000001", "AI"),
|
||||
new((ThemeMarket)99, ThemeSession.NotApplicable, "00000001", "AI"),
|
||||
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "1", "AI"),
|
||||
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "12", "AI"),
|
||||
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "0000000A", "AI"),
|
||||
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", " AI"),
|
||||
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", "bad\u0000title")
|
||||
|
||||
@@ -22,4 +22,10 @@
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\scripts\LegacyCutRequirements.json"
|
||||
Link="Fixtures\LegacyCutRequirements.json"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -90,6 +90,7 @@ public sealed class PagedQuoteSceneDataLoadersTests
|
||||
|
||||
var data = await new S5088SceneDataLoader(executor).LoadAsync(
|
||||
new ThemePagedQuoteLoadRequest(
|
||||
"0001",
|
||||
"AI",
|
||||
PagedQuoteThemeSort.GainRateDescending,
|
||||
PagedQuoteThemeValueKind.ExpectedExecution));
|
||||
@@ -97,13 +98,15 @@ public sealed class PagedQuoteSceneDataLoadersTests
|
||||
|
||||
Assert.Equal(DataSourceKind.Oracle, executor.Source);
|
||||
Assert.NotNull(executor.Spec);
|
||||
Assert.Contains("l.SB_CODE = :themeCode", executor.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("l.SB_TITLE = :themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("t_online1_call", executor.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ORDER BY q.RATE DESC", executor.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Equal(new[] { "themeTitle", "maxRows" },
|
||||
Assert.Equal(new[] { "themeCode", "themeTitle", "maxRows" },
|
||||
executor.Spec.Parameters.Select(parameter => parameter.Name));
|
||||
Assert.Equal("AI", executor.Spec.Parameters[0].Value);
|
||||
Assert.Equal(240, executor.Spec.Parameters[1].Value);
|
||||
Assert.Equal("0001", executor.Spec.Parameters[0].Value);
|
||||
Assert.Equal("AI", executor.Spec.Parameters[1].Value);
|
||||
Assert.Equal(240, executor.Spec.Parameters[2].Value);
|
||||
Assert.Equal(PagedQuoteBranch.Theme, data.Branch);
|
||||
Assert.Equal(PagedQuoteTitleStyle.ExpectedExecution, data.TitleStyle);
|
||||
Assert.Equal(NxtMarketSession.NotApplicable, data.NxtSession);
|
||||
@@ -120,16 +123,19 @@ public sealed class PagedQuoteSceneDataLoadersTests
|
||||
|
||||
var data = await new S5074SceneDataLoader(executor).LoadAsync(
|
||||
new NxtThemePagedQuoteLoadRequest(
|
||||
"0002",
|
||||
"Robotics(NXT)",
|
||||
PagedQuoteThemeSort.InputOrder,
|
||||
NxtMarketSession.PreMarket));
|
||||
|
||||
Assert.Equal(DataSourceKind.MariaDb, executor.Source);
|
||||
Assert.NotNull(executor.Spec);
|
||||
Assert.Contains("l.SB_CODE = @themeCode", executor.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("l.SB_TITLE = @themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("l.SB_MARKET = 'NXT'", executor.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ORDER BY i.SB_I_INDEX", executor.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Equal("Robotics", executor.Spec.Parameters[0].Value);
|
||||
Assert.Equal("0002", executor.Spec.Parameters[0].Value);
|
||||
Assert.Equal("Robotics", executor.Spec.Parameters[1].Value);
|
||||
Assert.Equal(PagedQuoteBranch.NxtTheme, data.Branch);
|
||||
Assert.Equal(NxtMarketSession.PreMarket, data.NxtSession);
|
||||
Assert.Equal(PagedQuoteTitleStyle.Exact, data.TitleStyle);
|
||||
@@ -351,6 +357,7 @@ public sealed class PagedQuoteSceneDataLoadersTests
|
||||
|
||||
await new S5074SceneDataLoader(executor).LoadAsync(
|
||||
new ThemePagedQuoteLoadRequest(
|
||||
"123",
|
||||
"Theme",
|
||||
sort,
|
||||
PagedQuoteThemeValueKind.Current));
|
||||
@@ -456,17 +463,40 @@ public sealed class PagedQuoteSceneDataLoadersTests
|
||||
NxtPagedQuoteList.GainRate,
|
||||
NxtMarketSession.NotApplicable),
|
||||
new ThemePagedQuoteLoadRequest(
|
||||
"123",
|
||||
"Theme",
|
||||
(PagedQuoteThemeSort)99,
|
||||
PagedQuoteThemeValueKind.Current),
|
||||
new ThemePagedQuoteLoadRequest(
|
||||
"123",
|
||||
"Theme",
|
||||
PagedQuoteThemeSort.InputOrder,
|
||||
(PagedQuoteThemeValueKind)99),
|
||||
new NxtThemePagedQuoteLoadRequest(
|
||||
"123",
|
||||
"Theme",
|
||||
PagedQuoteThemeSort.InputOrder,
|
||||
NxtMarketSession.NotApplicable),
|
||||
new ThemePagedQuoteLoadRequest(
|
||||
"12",
|
||||
"Theme",
|
||||
PagedQuoteThemeSort.InputOrder,
|
||||
PagedQuoteThemeValueKind.Current),
|
||||
new NxtThemePagedQuoteLoadRequest(
|
||||
"12A4",
|
||||
"Theme",
|
||||
PagedQuoteThemeSort.InputOrder,
|
||||
NxtMarketSession.PreMarket),
|
||||
new NxtThemePagedQuoteLoadRequest(
|
||||
"1234",
|
||||
"Theme",
|
||||
PagedQuoteThemeSort.InputOrder,
|
||||
NxtMarketSession.PreMarket),
|
||||
new NxtThemePagedQuoteLoadRequest(
|
||||
"1234",
|
||||
"Theme(NXT)Copy(NXT)",
|
||||
PagedQuoteThemeSort.InputOrder,
|
||||
NxtMarketSession.PreMarket),
|
||||
new ExpertPagedQuoteLoadRequest("001", "Analyst A"),
|
||||
new ViPagedQuoteLoadRequest([])
|
||||
];
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
||||
|
||||
public sealed class LegacyManualOperatorDataImporterTests
|
||||
{
|
||||
private static readonly string[] DestinationFiles =
|
||||
[
|
||||
"개인.dat",
|
||||
"외국인.dat",
|
||||
"기관.dat",
|
||||
"VI발동.dat"
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Fixed_source_reads_the_four_original_files_without_modifying_them()
|
||||
{
|
||||
using var sourceDirectory = new TemporaryDirectory();
|
||||
WriteSourceFiles(sourceDirectory.Path);
|
||||
var before = DestinationFiles.ToDictionary(
|
||||
name => name,
|
||||
name => File.ReadAllBytes(Path.Combine(sourceDirectory.Path, name)));
|
||||
var source = new FixedLegacyManualOperatorDataSource(
|
||||
sourceDirectory.Path,
|
||||
TimeSpan.Zero);
|
||||
|
||||
var snapshot = await source.ReadAsync();
|
||||
|
||||
Assert.Equal(5, snapshot.IndividualRows.Count);
|
||||
Assert.Equal(5, snapshot.ForeignRows.Count);
|
||||
Assert.Equal(5, snapshot.InstitutionRows.Count);
|
||||
Assert.Equal(3, snapshot.ViItems.Count);
|
||||
Assert.Matches("^[a-f0-9]{64}$", snapshot.SourceSha256);
|
||||
Assert.All(DestinationFiles, name =>
|
||||
Assert.Equal(before[name], File.ReadAllBytes(Path.Combine(sourceDirectory.Path, name))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_commits_all_four_files_and_the_versioned_marker_last()
|
||||
{
|
||||
using var sourceDirectory = new TemporaryDirectory();
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
WriteSourceFiles(sourceDirectory.Path);
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var now = new DateTimeOffset(2026, 7, 12, 1, 2, 3, TimeSpan.Zero);
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new FixedLegacyManualOperatorDataSource(sourceDirectory.Path, TimeSpan.Zero),
|
||||
() => now);
|
||||
|
||||
var result = await importer.ImportAsync();
|
||||
|
||||
Assert.Equal(1, result.MarkerVersion);
|
||||
Assert.Equal(15, result.NetSellRowCount);
|
||||
Assert.Equal(3, result.ViItemCount);
|
||||
Assert.Equal(now, result.ImportedAt);
|
||||
Assert.All(DestinationFiles, name =>
|
||||
Assert.True(File.Exists(Path.Combine(destinationDirectory.Path, name))));
|
||||
var markerPath = Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName);
|
||||
Assert.True(File.Exists(markerPath));
|
||||
using var marker = JsonDocument.Parse(await File.ReadAllBytesAsync(markerPath));
|
||||
Assert.Equal(1, marker.RootElement.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal(result.SourceSha256, marker.RootElement.GetProperty("sourceSha256").GetString());
|
||||
Assert.Matches(
|
||||
"^[a-f0-9]{64}$",
|
||||
marker.RootElement.GetProperty("productionSha256").GetString()!);
|
||||
Assert.Equal(15, marker.RootElement.GetProperty("netSellRowCount").GetInt32());
|
||||
Assert.Equal(3, marker.RootElement.GetProperty("viItemCount").GetInt32());
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.ReadyImportedData,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
|
||||
using var netStore = new S5025TrustedManualFileStore(destinationDirectory.Path);
|
||||
using var viStore = new ViTrustedManualFileStore(destinationDirectory.Path);
|
||||
Assert.Equal(Rows(), await netStore.ReadAsync(S5025ManualAudience.Individual));
|
||||
Assert.Equal(3, (await viStore.ReadAsync()).Count);
|
||||
|
||||
var serializedResult = JsonSerializer.Serialize(result);
|
||||
var serializedMarker = await File.ReadAllTextAsync(markerPath);
|
||||
Assert.DoesNotContain(sourceDirectory.Path, serializedResult, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(sourceDirectory.Path, serializedMarker, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("source\\repos", serializedMarker, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_vi_file_keeps_its_distinct_zero_length_contract_after_import_and_restart()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var snapshot = new LegacyManualOperatorSourceSnapshot(
|
||||
Rows(),
|
||||
Rows(),
|
||||
Rows(),
|
||||
Array.Empty<LegacyManualViItem>(),
|
||||
new string('c', 64));
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(snapshot));
|
||||
|
||||
var result = await importer.ImportAsync();
|
||||
|
||||
Assert.Equal(0, result.ViItemCount);
|
||||
Assert.Equal(
|
||||
0,
|
||||
new FileInfo(Path.Combine(destinationDirectory.Path, "VI발동.dat")).Length);
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.ReadyImportedData,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
using var viStore = new ViTrustedManualFileStore(destinationDirectory.Path);
|
||||
Assert.Empty(await viStore.ReadAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("개인.dat")]
|
||||
[InlineData("외국인.dat")]
|
||||
[InlineData("기관.dat")]
|
||||
[InlineData("VI발동.dat")]
|
||||
public async Task Any_existing_private_data_file_blocks_the_whole_import_without_overwrite(
|
||||
string existingFile)
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var path = Path.Combine(destinationDirectory.Path, existingFile);
|
||||
var original = new byte[] { 1, 2, 3, 4 };
|
||||
await File.WriteAllBytesAsync(path, original);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source);
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.DestinationNotEmpty, error.Failure);
|
||||
Assert.False(error.OutcomeUnknown);
|
||||
Assert.Equal(0, source.ReadCount);
|
||||
Assert.Equal(original, await File.ReadAllBytesAsync(path));
|
||||
Assert.Single(Directory.EnumerateFileSystemEntries(destinationDirectory.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Marker_blocks_a_second_import_before_the_source_is_read_again()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source);
|
||||
|
||||
_ = await importer.ImportAsync();
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.AlreadyImported, error.Failure);
|
||||
Assert.False(error.OutcomeUnknown);
|
||||
Assert.Equal(1, source.ReadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preexisting_intent_and_partial_data_fail_closed_before_source_or_store_use()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var intentPath = Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName);
|
||||
await File.WriteAllTextAsync(intentPath, "{}");
|
||||
var partialPath = Path.Combine(destinationDirectory.Path, DestinationFiles[0]);
|
||||
var partialBytes = LegacyManualOperatorDataCodec.SerializeNetSell(Rows());
|
||||
await File.WriteAllBytesAsync(partialPath, partialBytes);
|
||||
|
||||
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(
|
||||
destinationDirectory.Path);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source);
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorDataRuntimeState.InterruptedImport, inspection.State);
|
||||
Assert.False(inspection.IsReady);
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.InterruptedImport, error.Failure);
|
||||
Assert.True(error.OutcomeUnknown);
|
||||
Assert.Equal(0, source.ReadCount);
|
||||
Assert.Equal(partialBytes, await File.ReadAllBytesAsync(partialPath));
|
||||
Assert.Equal("{}", await File.ReadAllTextAsync(intentPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_legacy_files_without_marker_or_intent_remain_runtime_eligible()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
await File.WriteAllBytesAsync(
|
||||
Path.Combine(destinationDirectory.Path, DestinationFiles[0]),
|
||||
LegacyManualOperatorDataCodec.SerializeNetSell(Rows()));
|
||||
|
||||
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(
|
||||
destinationDirectory.Path);
|
||||
|
||||
Assert.True(inspection.IsReady);
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.ReadyLegacyData,
|
||||
inspection.State);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public async Task Failure_after_each_data_move_rolls_back_data_and_intent(
|
||||
int failAfterMove)
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(Snapshot()),
|
||||
utcNow: null,
|
||||
afterDataFileMoved: movedCount =>
|
||||
{
|
||||
if (movedCount == failAfterMove)
|
||||
{
|
||||
throw new IOException("injected move failure");
|
||||
}
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.ImportFailed, error.Failure);
|
||||
Assert.False(error.OutcomeUnknown);
|
||||
Assert.All(DestinationFiles, name =>
|
||||
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, name))));
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName)));
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public async Task Abrupt_termination_after_each_move_leaves_intent_and_blocks_all_reuse(
|
||||
int crashAfterMove)
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(Snapshot()),
|
||||
utcNow: null,
|
||||
afterDataFileMoved: movedCount =>
|
||||
{
|
||||
if (movedCount == crashAfterMove)
|
||||
{
|
||||
throw new LegacyManualOperatorImportCrashSimulationException();
|
||||
}
|
||||
}))
|
||||
{
|
||||
await Assert.ThrowsAsync<LegacyManualOperatorImportCrashSimulationException>(
|
||||
() => importer.ImportAsync());
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
Assert.Equal(
|
||||
crashAfterMove,
|
||||
DestinationFiles.Count(name => File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
name))));
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.InterruptedImport,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
|
||||
var secondSource = new CountingSource(Snapshot());
|
||||
using var secondImporter = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
secondSource);
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => secondImporter.ImportAsync());
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.InterruptedImport, error.Failure);
|
||||
Assert.True(error.OutcomeUnknown);
|
||||
Assert.Equal(0, secondSource.ReadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abrupt_termination_after_completed_marker_keeps_stale_intent_fail_closed()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(Snapshot()),
|
||||
utcNow: null,
|
||||
afterDataFileMoved: null,
|
||||
afterMarkerCommitted: () =>
|
||||
throw new LegacyManualOperatorImportCrashSimulationException()))
|
||||
{
|
||||
await Assert.ThrowsAsync<LegacyManualOperatorImportCrashSimulationException>(
|
||||
() => importer.ImportAsync());
|
||||
}
|
||||
|
||||
Assert.All(DestinationFiles, name =>
|
||||
Assert.True(File.Exists(Path.Combine(destinationDirectory.Path, name))));
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName)));
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(
|
||||
destinationDirectory.Path);
|
||||
Assert.False(inspection.IsReady);
|
||||
Assert.Equal(LegacyManualOperatorDataRuntimeState.InterruptedImport, inspection.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_marker_with_missing_or_modified_data_is_rejected()
|
||||
{
|
||||
using var missingDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(missingDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
missingDirectory.Path,
|
||||
new CountingSource(Snapshot())))
|
||||
{
|
||||
_ = await importer.ImportAsync();
|
||||
}
|
||||
File.Delete(Path.Combine(missingDirectory.Path, DestinationFiles[0]));
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.InvalidImportedState,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(missingDirectory.Path).State);
|
||||
|
||||
using var modifiedDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(modifiedDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
modifiedDirectory.Path,
|
||||
new CountingSource(Snapshot())))
|
||||
{
|
||||
_ = await importer.ImportAsync();
|
||||
}
|
||||
await File.AppendAllTextAsync(
|
||||
Path.Combine(modifiedDirectory.Path, DestinationFiles[0]),
|
||||
"tamper");
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.InvalidImportedState,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(modifiedDirectory.Path).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_marker_allows_later_verified_operator_edits()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new CountingSource(Snapshot())))
|
||||
{
|
||||
_ = await importer.ImportAsync();
|
||||
}
|
||||
|
||||
var editedRows = Rows().ToArray();
|
||||
editedRows[0] = editedRows[0] with { LeftAmount = "9,999" };
|
||||
using (var store = new S5025TrustedManualFileStore(destinationDirectory.Path))
|
||||
{
|
||||
await store.WriteAsync(S5025ManualAudience.Individual, editedRows);
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.ReadyImportedData,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Commit_failure_rolls_back_every_file_created_by_the_operation_without_retry()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source,
|
||||
utcNow: null,
|
||||
afterDataFileMoved: movedCount =>
|
||||
{
|
||||
if (movedCount == 1)
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(destinationDirectory.Path, "외국인.dat"));
|
||||
}
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.ImportFailed, error.Failure);
|
||||
Assert.False(error.OutcomeUnknown);
|
||||
Assert.Equal(1, source.ReadCount);
|
||||
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, "개인.dat")));
|
||||
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, "기관.dat")));
|
||||
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, "VI발동.dat")));
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName)));
|
||||
Assert.Empty(Directory.EnumerateFiles(destinationDirectory.Path, "*.tmp"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsafe_rollback_is_reported_as_outcome_unknown_and_is_never_retried()
|
||||
{
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
var source = new CountingSource(Snapshot());
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
source,
|
||||
utcNow: null,
|
||||
afterDataFileMoved: movedCount =>
|
||||
{
|
||||
if (movedCount != 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(destinationDirectory.Path, "개인.dat"), "tampered");
|
||||
Directory.CreateDirectory(Path.Combine(destinationDirectory.Path, "외국인.dat"));
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.RollbackFailed, error.Failure);
|
||||
Assert.True(error.OutcomeUnknown);
|
||||
Assert.Equal(1, source.ReadCount);
|
||||
Assert.True(File.Exists(Path.Combine(destinationDirectory.Path, "개인.dat")));
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.MarkerFileName)));
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
destinationDirectory.Path,
|
||||
LegacyManualOperatorDataImporter.IntentFileName)));
|
||||
Assert.Equal(
|
||||
LegacyManualOperatorDataRuntimeState.InterruptedImport,
|
||||
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_source_format_creates_no_private_file_and_does_not_disclose_the_path()
|
||||
{
|
||||
using var sourceDirectory = new TemporaryDirectory();
|
||||
using var destinationDirectory = new TemporaryDirectory();
|
||||
WriteSourceFiles(sourceDirectory.Path);
|
||||
await File.WriteAllTextAsync(Path.Combine(sourceDirectory.Path, "기관.dat"), "invalid");
|
||||
PreparePrivate(destinationDirectory.Path);
|
||||
using var importer = new LegacyManualOperatorDataImporter(
|
||||
destinationDirectory.Path,
|
||||
new FixedLegacyManualOperatorDataSource(sourceDirectory.Path, TimeSpan.Zero));
|
||||
|
||||
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
|
||||
() => importer.ImportAsync());
|
||||
|
||||
Assert.Equal(LegacyManualOperatorImportFailure.SourceInvalid, error.Failure);
|
||||
Assert.DoesNotContain(sourceDirectory.Path, error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(Directory.EnumerateFileSystemEntries(destinationDirectory.Path));
|
||||
}
|
||||
|
||||
private static void WriteSourceFiles(string directory)
|
||||
{
|
||||
foreach (var fileName in DestinationFiles.Take(3))
|
||||
{
|
||||
File.WriteAllBytes(
|
||||
Path.Combine(directory, fileName),
|
||||
LegacyManualOperatorDataCodec.SerializeNetSell(Rows()));
|
||||
}
|
||||
|
||||
File.WriteAllBytes(
|
||||
Path.Combine(directory, "VI발동.dat"),
|
||||
LegacyManualOperatorDataCodec.SerializeVi(ViItems()));
|
||||
}
|
||||
|
||||
private static LegacyManualOperatorSourceSnapshot Snapshot() => new(
|
||||
Rows(),
|
||||
Rows(),
|
||||
Rows(),
|
||||
ViItems(),
|
||||
new string('a', 64));
|
||||
|
||||
private static IReadOnlyList<S5025TrustedManualRow> Rows() =>
|
||||
[
|
||||
new("삼성전자", "1,000", "SK하이닉스", "-2,000"),
|
||||
new("현대차", "2", "기아", "-2"),
|
||||
new("NAVER", "3", "카카오", "-3"),
|
||||
new("LG화학", "4", "포스코", "-4"),
|
||||
new("한화", "5", "셀트리온", "-5")
|
||||
];
|
||||
|
||||
private static IReadOnlyList<LegacyManualViItem> ViItems() =>
|
||||
[
|
||||
new("PKR7005930003", "삼성전자"),
|
||||
new("PKR7016360000", "삼성증권"),
|
||||
new("PKR7005930003", "삼성전자")
|
||||
];
|
||||
|
||||
private static void PreparePrivate(string path) =>
|
||||
TrustedManualDirectory.PreparePrivateWritableDirectory(path);
|
||||
|
||||
private sealed class CountingSource : ILegacyManualOperatorDataSource
|
||||
{
|
||||
private readonly LegacyManualOperatorSourceSnapshot _snapshot;
|
||||
|
||||
public CountingSource(LegacyManualOperatorSourceSnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public int ReadCount { get; private set; }
|
||||
|
||||
public Task<LegacyManualOperatorSourceSnapshot> ReadAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ReadCount++;
|
||||
return Task.FromResult(_snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
public TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Cleanup must not hide the test result.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
||||
|
||||
public sealed class NativeOperatorLogTransitionTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void DatabaseMapping_CoversEveryClosedHealthState()
|
||||
{
|
||||
var expected = new Dictionary<DatabaseHealthState, NativeOperatorDatabaseState>
|
||||
{
|
||||
[DatabaseHealthState.NotConfigured] = NativeOperatorDatabaseState.NotConfigured,
|
||||
[DatabaseHealthState.Healthy] = NativeOperatorDatabaseState.Healthy,
|
||||
[DatabaseHealthState.Unhealthy] = NativeOperatorDatabaseState.Unhealthy,
|
||||
[DatabaseHealthState.TimedOut] = NativeOperatorDatabaseState.TimedOut
|
||||
};
|
||||
|
||||
Assert.Equal(Enum.GetValues<DatabaseHealthState>().Length, expected.Count);
|
||||
foreach (var pair in expected)
|
||||
{
|
||||
Assert.True(NativeOperatorLogTransitionTracker.TryMapDatabaseState(
|
||||
pair.Key,
|
||||
out var actual));
|
||||
Assert.Equal(pair.Value, actual);
|
||||
}
|
||||
Assert.False(NativeOperatorLogTransitionTracker.TryMapDatabaseState(
|
||||
(DatabaseHealthState)int.MaxValue,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TornadoMapping_CoversEveryClosedConnectionState()
|
||||
{
|
||||
var expected = new Dictionary<PlayoutConnectionState, NativeOperatorTornadoState>
|
||||
{
|
||||
[PlayoutConnectionState.Disabled] = NativeOperatorTornadoState.Disabled,
|
||||
[PlayoutConnectionState.DryRunReady] = NativeOperatorTornadoState.DryRunReady,
|
||||
[PlayoutConnectionState.Disconnected] = NativeOperatorTornadoState.Disconnected,
|
||||
[PlayoutConnectionState.Connecting] = NativeOperatorTornadoState.Connecting,
|
||||
[PlayoutConnectionState.Connected] = NativeOperatorTornadoState.Connected,
|
||||
[PlayoutConnectionState.Reconnecting] = NativeOperatorTornadoState.Reconnecting,
|
||||
[PlayoutConnectionState.Disconnecting] = NativeOperatorTornadoState.Disconnecting,
|
||||
[PlayoutConnectionState.Faulted] = NativeOperatorTornadoState.Faulted,
|
||||
[PlayoutConnectionState.OutcomeUnknown] = NativeOperatorTornadoState.OutcomeUnknown,
|
||||
[PlayoutConnectionState.Disposed] = NativeOperatorTornadoState.Disposed
|
||||
};
|
||||
|
||||
Assert.Equal(Enum.GetValues<PlayoutConnectionState>().Length, expected.Count);
|
||||
foreach (var pair in expected)
|
||||
{
|
||||
Assert.True(NativeOperatorLogTransitionTracker.TryMapTornadoState(
|
||||
pair.Key,
|
||||
out var actual));
|
||||
Assert.Equal(pair.Value, actual);
|
||||
}
|
||||
Assert.False(NativeOperatorLogTransitionTracker.TryMapTornadoState(
|
||||
(PlayoutConnectionState)int.MaxValue,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatabaseTransitions_SuppressDuplicatesIndependentlyPerProvider()
|
||||
{
|
||||
var tracker = new NativeOperatorLogTransitionTracker();
|
||||
|
||||
Assert.True(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.Healthy,
|
||||
out var oracleHealthy));
|
||||
Assert.Equal(NativeOperatorLogEvent.OracleStateChanged, oracleHealthy.Event);
|
||||
Assert.Equal(NativeOperatorDatabaseState.Healthy, oracleHealthy.DatabaseState);
|
||||
Assert.False(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.Healthy,
|
||||
out _));
|
||||
|
||||
Assert.True(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.MariaDb,
|
||||
DatabaseHealthState.Healthy,
|
||||
out var mariaHealthy));
|
||||
Assert.Equal(NativeOperatorLogEvent.MariaDbStateChanged, mariaHealthy.Event);
|
||||
Assert.False(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.MariaDb,
|
||||
DatabaseHealthState.Healthy,
|
||||
out _));
|
||||
|
||||
Assert.True(tracker.TryObserveDatabaseState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.TimedOut,
|
||||
out var oracleTimedOut));
|
||||
Assert.Equal(NativeOperatorDatabaseState.TimedOut, oracleTimedOut.DatabaseState);
|
||||
Assert.False(tracker.TryObserveDatabaseState(
|
||||
(DataSourceKind)int.MaxValue,
|
||||
DatabaseHealthState.Healthy,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TornadoTransitions_SuppressOnlyIdenticalConsecutiveStates()
|
||||
{
|
||||
var tracker = new NativeOperatorLogTransitionTracker();
|
||||
|
||||
Assert.True(tracker.TryObserveTornadoState(
|
||||
PlayoutConnectionState.Disconnected,
|
||||
out var disconnected));
|
||||
Assert.Equal(NativeOperatorTornadoState.Disconnected, disconnected.TornadoState);
|
||||
Assert.False(tracker.TryObserveTornadoState(
|
||||
PlayoutConnectionState.Disconnected,
|
||||
out _));
|
||||
Assert.True(tracker.TryObserveTornadoState(
|
||||
PlayoutConnectionState.Connecting,
|
||||
out var connecting));
|
||||
Assert.Equal(NativeOperatorTornadoState.Connecting, connecting.TornadoState);
|
||||
Assert.True(tracker.TryObserveTornadoState(
|
||||
PlayoutConnectionState.Disconnected,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandTracker_EmitsOneRequestAndExactlyOneMatchingTerminalRecord()
|
||||
{
|
||||
var tracker = new NativeOperatorCommandLogTracker();
|
||||
|
||||
Assert.True(tracker.TryBegin(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
out var requested));
|
||||
Assert.Equal(NativeOperatorLogEvent.PlayoutCommandRequested, requested.Event);
|
||||
Assert.Equal(NativeOperatorPlayoutCommand.Prepare, requested.Command);
|
||||
Assert.False(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.Next,
|
||||
NativeOperatorPlayoutCompletion.Succeeded,
|
||||
out _));
|
||||
Assert.True(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
NativeOperatorPlayoutCompletion.Rejected,
|
||||
out var completed));
|
||||
Assert.Equal(NativeOperatorLogEvent.PlayoutCommandCompleted, completed.Event);
|
||||
Assert.Equal(NativeOperatorPlayoutCompletion.Rejected, completed.Completion);
|
||||
Assert.False(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
NativeOperatorPlayoutCompletion.Succeeded,
|
||||
out _));
|
||||
Assert.False(tracker.TryMarkCurrentOutcomeUnknown(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandTracker_TimeoutOutcomeWinsAndSuppressesLateCompletion()
|
||||
{
|
||||
var tracker = new NativeOperatorCommandLogTracker();
|
||||
Assert.True(tracker.TryBegin(NativeOperatorPlayoutCommand.TakeIn, out _));
|
||||
|
||||
Assert.True(tracker.TryMarkCurrentOutcomeUnknown(out var unknown));
|
||||
Assert.Equal(NativeOperatorLogEvent.OutcomeUnknown, unknown.Event);
|
||||
Assert.Equal(NativeOperatorPlayoutCommand.TakeIn, unknown.Command);
|
||||
Assert.False(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.TakeIn,
|
||||
NativeOperatorPlayoutCompletion.Succeeded,
|
||||
out _));
|
||||
|
||||
Assert.True(tracker.TryBegin(NativeOperatorPlayoutCommand.Next, out _));
|
||||
Assert.True(tracker.TryMarkOutcomeUnknown(
|
||||
NativeOperatorPlayoutCommand.Next,
|
||||
out var secondUnknown));
|
||||
Assert.Equal(NativeOperatorLogEvent.OutcomeUnknown, secondUnknown.Event);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandTracker_UndefinedScalarsFailClosed()
|
||||
{
|
||||
var tracker = new NativeOperatorCommandLogTracker();
|
||||
|
||||
Assert.False(tracker.TryBegin((NativeOperatorPlayoutCommand)int.MaxValue, out _));
|
||||
Assert.False(tracker.TryComplete(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
(NativeOperatorPlayoutCompletion)int.MaxValue,
|
||||
out _));
|
||||
Assert.False(tracker.TryMarkOutcomeUnknown(
|
||||
(NativeOperatorPlayoutCommand)int.MaxValue,
|
||||
out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Reflection;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
|
||||
|
||||
public sealed class NativeOperatorLogWriterTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedUtcNow =
|
||||
new(2026, 7, 12, 3, 4, 5, 678, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_WritesOnlyClosedEventsToTheFixedLocalApplicationDataPath()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var writer = CreateWriter(scope.Path);
|
||||
var records = new[]
|
||||
{
|
||||
NativeOperatorLogRecord.ForStartup(),
|
||||
NativeOperatorLogRecord.ForShutdown(),
|
||||
NativeOperatorLogRecord.ForOracleStateChanged(NativeOperatorDatabaseState.Healthy),
|
||||
NativeOperatorLogRecord.ForMariaDbStateChanged(NativeOperatorDatabaseState.Unhealthy),
|
||||
NativeOperatorLogRecord.ForTornadoStateChanged(NativeOperatorTornadoState.Connected),
|
||||
NativeOperatorLogRecord.ForPlayoutCommandRequested(NativeOperatorPlayoutCommand.Prepare),
|
||||
NativeOperatorLogRecord.ForPlayoutCommandCompleted(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
NativeOperatorPlayoutCompletion.Succeeded),
|
||||
NativeOperatorLogRecord.ForOutcomeUnknown(NativeOperatorPlayoutCommand.TakeIn)
|
||||
};
|
||||
|
||||
Assert.All(records, record => Assert.True(writer.TryWrite(record)));
|
||||
|
||||
var expectedPath = Path.Combine(
|
||||
scope.Path,
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"Logs",
|
||||
"Log_0712.log");
|
||||
Assert.True(File.Exists(expectedPath));
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"[2026-07-12T03:04:05.678+00:00] event=Startup",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=Shutdown",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=OracleStateChanged state=Healthy",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=MariaDbStateChanged state=Unhealthy",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=TornadoStateChanged state=Connected",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=PlayoutCommandRequested command=Prepare",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=PlayoutCommandCompleted command=Prepare result=Succeeded",
|
||||
"[2026-07-12T03:04:05.678+00:00] event=OutcomeUnknown command=TakeIn"
|
||||
},
|
||||
File.ReadAllLines(expectedPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicApi_CannotAcceptFreeFormTextPathsSqlExceptionsOrSceneData()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"Startup",
|
||||
"Shutdown",
|
||||
"OracleStateChanged",
|
||||
"MariaDbStateChanged",
|
||||
"TornadoStateChanged",
|
||||
"PlayoutCommandRequested",
|
||||
"PlayoutCommandCompleted",
|
||||
"OutcomeUnknown"
|
||||
},
|
||||
Enum.GetNames<NativeOperatorLogEvent>());
|
||||
|
||||
var recordType = typeof(NativeOperatorLogRecord);
|
||||
Assert.All(
|
||||
recordType.GetProperties(BindingFlags.Instance | BindingFlags.Public),
|
||||
property =>
|
||||
{
|
||||
var scalarType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
|
||||
Assert.True(scalarType.IsEnum, $"{property.Name} must remain a closed enum scalar.");
|
||||
});
|
||||
Assert.DoesNotContain(
|
||||
recordType.GetConstructors(BindingFlags.Instance | BindingFlags.Public),
|
||||
constructor => constructor.GetParameters().Length > 0);
|
||||
|
||||
var factories = recordType.GetMethods(BindingFlags.Static | BindingFlags.Public)
|
||||
.Where(method => method.Name.StartsWith("For", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.Equal(8, factories.Length);
|
||||
Assert.All(
|
||||
factories.SelectMany(factory => factory.GetParameters()),
|
||||
parameter => Assert.True(parameter.ParameterType.IsEnum));
|
||||
|
||||
var publicWriterConstructors = typeof(NativeOperatorLogWriter)
|
||||
.GetConstructors(BindingFlags.Instance | BindingFlags.Public);
|
||||
var constructor = Assert.Single(publicWriterConstructors);
|
||||
Assert.Empty(constructor.GetParameters());
|
||||
var tryWrite = typeof(INativeOperatorLogWriter).GetMethod(nameof(INativeOperatorLogWriter.TryWrite));
|
||||
Assert.NotNull(tryWrite);
|
||||
Assert.Equal(
|
||||
new[] { typeof(NativeOperatorLogRecord) },
|
||||
tryWrite.GetParameters().Select(parameter => parameter.ParameterType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_InvalidDefaultAndUndefinedEnumsFailClosedWithoutCreatingALog()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var writer = CreateWriter(scope.Path);
|
||||
|
||||
Assert.False(writer.TryWrite(default));
|
||||
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForOracleStateChanged(
|
||||
(NativeOperatorDatabaseState)int.MaxValue)));
|
||||
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForPlayoutCommandCompleted(
|
||||
NativeOperatorPlayoutCommand.Prepare,
|
||||
(NativeOperatorPlayoutCompletion)int.MaxValue)));
|
||||
|
||||
Assert.False(Directory.Exists(Path.Combine(scope.Path, "MBN_STOCK_WEBVIEW")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_AcceptsEveryDefinedClosedScalarAndNoUnmappedRuntimeState()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var writer = CreateWriter(scope.Path);
|
||||
|
||||
foreach (var state in Enum.GetValues<NativeOperatorDatabaseState>())
|
||||
{
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForOracleStateChanged(state)));
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForMariaDbStateChanged(state)));
|
||||
}
|
||||
|
||||
foreach (var state in Enum.GetValues<NativeOperatorTornadoState>())
|
||||
{
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForTornadoStateChanged(state)));
|
||||
}
|
||||
|
||||
foreach (var command in Enum.GetValues<NativeOperatorPlayoutCommand>())
|
||||
{
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForPlayoutCommandRequested(command)));
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForOutcomeUnknown(command)));
|
||||
foreach (var completion in Enum.GetValues<NativeOperatorPlayoutCompletion>())
|
||||
{
|
||||
Assert.True(writer.TryWrite(
|
||||
NativeOperatorLogRecord.ForPlayoutCommandCompleted(command, completion)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_StopsAtTheConfiguredFileSizeWithoutChangingExistingBytes()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var initialWriter = CreateWriter(scope.Path);
|
||||
Assert.True(initialWriter.TryWrite(NativeOperatorLogRecord.ForStartup()));
|
||||
var path = LogPath(scope.Path);
|
||||
var original = File.ReadAllBytes(path);
|
||||
|
||||
var boundedWriter = CreateWriter(scope.Path, maximumFileBytes: original.Length);
|
||||
Assert.False(boundedWriter.TryWrite(NativeOperatorLogRecord.ForShutdown()));
|
||||
|
||||
Assert.Equal(original, File.ReadAllBytes(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_DeletesOnlyExpiredClosedLogNamesAndKeepsRecentOrUnrelatedFiles()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var logDirectory = Path.Combine(scope.Path, "MBN_STOCK_WEBVIEW", "Logs");
|
||||
Directory.CreateDirectory(logDirectory);
|
||||
var expired = Path.Combine(logDirectory, "Log_0601.log");
|
||||
var recent = Path.Combine(logDirectory, "Log_0711.log");
|
||||
var unrelated = Path.Combine(logDirectory, "operator-notes.txt");
|
||||
File.WriteAllText(expired, "expired");
|
||||
File.WriteAllText(recent, "recent");
|
||||
File.WriteAllText(unrelated, "unrelated");
|
||||
File.SetLastWriteTimeUtc(expired, FixedUtcNow.UtcDateTime.AddDays(-12));
|
||||
File.SetLastWriteTimeUtc(recent, FixedUtcNow.UtcDateTime.AddDays(-1));
|
||||
File.SetLastWriteTimeUtc(unrelated, FixedUtcNow.UtcDateTime.AddDays(-100));
|
||||
var writer = CreateWriter(scope.Path, retentionDays: 10);
|
||||
|
||||
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForStartup()));
|
||||
|
||||
Assert.False(File.Exists(expired));
|
||||
Assert.True(File.Exists(recent));
|
||||
Assert.True(File.Exists(unrelated));
|
||||
Assert.True(File.Exists(LogPath(scope.Path)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryWrite_ConcurrentWritersProduceCompleteNonInterleavedLines()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
var writers = Enumerable.Range(0, 4)
|
||||
.Select(_ => CreateWriter(scope.Path))
|
||||
.ToArray();
|
||||
const int taskCount = 8;
|
||||
const int writesPerTask = 40;
|
||||
|
||||
var tasks = Enumerable.Range(0, taskCount)
|
||||
.Select(taskIndex => Task.Run(() =>
|
||||
{
|
||||
var writer = writers[taskIndex % writers.Length];
|
||||
for (var index = 0; index < writesPerTask; index++)
|
||||
{
|
||||
if (!writer.TryWrite(NativeOperatorLogRecord.ForTornadoStateChanged(
|
||||
NativeOperatorTornadoState.Connected)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}))
|
||||
.ToArray();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
Assert.All(results, Assert.True);
|
||||
var lines = File.ReadAllLines(LogPath(scope.Path));
|
||||
Assert.Equal(taskCount * writesPerTask, lines.Length);
|
||||
Assert.All(
|
||||
lines,
|
||||
line => Assert.Equal(
|
||||
"[2026-07-12T03:04:05.678+00:00] event=TornadoStateChanged state=Connected",
|
||||
line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_UnsafeDirectoryLayoutsAndReparseAttributesFailWithoutThrowing()
|
||||
{
|
||||
using var scope = TemporaryDirectory.Create();
|
||||
File.WriteAllText(Path.Combine(scope.Path, "MBN_STOCK_WEBVIEW"), "not a directory");
|
||||
var writer = CreateWriter(scope.Path);
|
||||
|
||||
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForStartup()));
|
||||
Assert.False(NativeOperatorLogWriter.IsSafeDirectoryAttributes(
|
||||
FileAttributes.Directory | FileAttributes.ReparsePoint));
|
||||
Assert.False(NativeOperatorLogWriter.IsSafeFileAttributes(
|
||||
FileAttributes.Normal | FileAttributes.ReparsePoint));
|
||||
Assert.False(NativeOperatorLogWriter.IsSafeFileAttributes(FileAttributes.Directory));
|
||||
Assert.True(NativeOperatorLogWriter.IsSafeDirectoryAttributes(FileAttributes.Directory));
|
||||
Assert.True(NativeOperatorLogWriter.IsSafeFileAttributes(FileAttributes.Normal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWrite_MalformedInternalRootIsAlwaysBestEffort()
|
||||
{
|
||||
var writer = new NativeOperatorLogWriter(
|
||||
"relative-root",
|
||||
new FixedTimeProvider(FixedUtcNow),
|
||||
NativeOperatorLogWriter.DefaultMaximumFileBytes,
|
||||
NativeOperatorLogWriter.DefaultRetentionDays);
|
||||
|
||||
var exception = Record.Exception(() =>
|
||||
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForStartup())));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
private static NativeOperatorLogWriter CreateWriter(
|
||||
string localApplicationDataRoot,
|
||||
int maximumFileBytes = NativeOperatorLogWriter.DefaultMaximumFileBytes,
|
||||
int retentionDays = NativeOperatorLogWriter.DefaultRetentionDays) =>
|
||||
new(
|
||||
localApplicationDataRoot,
|
||||
new FixedTimeProvider(FixedUtcNow),
|
||||
maximumFileBytes,
|
||||
retentionDays);
|
||||
|
||||
private static string LogPath(string localApplicationDataRoot) =>
|
||||
Path.Combine(localApplicationDataRoot, "MBN_STOCK_WEBVIEW", "Logs", "Log_0712.log");
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
private TemporaryDirectory(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public static TemporaryDirectory Create()
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"MBN_STOCK_WEBVIEW-log-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return new TemporaryDirectory(path);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Test cleanup is best effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,52 @@ public sealed class OperatorCatalogMutationExecutorTests
|
||||
Assert.All(plan.Commands, command => Assert.Contains('@', command.Sql));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("123", ThemeMarket.Krx)]
|
||||
[InlineData("0123", ThemeMarket.Krx)]
|
||||
[InlineData("001234", ThemeMarket.Krx)]
|
||||
[InlineData("00001234", ThemeMarket.Krx)]
|
||||
[InlineData("123", ThemeMarket.Nxt)]
|
||||
[InlineData("0123", ThemeMarket.Nxt)]
|
||||
[InlineData("001234", ThemeMarket.Nxt)]
|
||||
[InlineData("00001234", ThemeMarket.Nxt)]
|
||||
public async Task ExecuteTransactionAsync_ReplaceAndDeleteAcceptLegacyThemeIdentityLengths(
|
||||
string code,
|
||||
ThemeMarket market)
|
||||
{
|
||||
var source = market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb;
|
||||
var session = market == ThemeMarket.Krx
|
||||
? ThemeSession.NotApplicable
|
||||
: ThemeSession.PreMarket;
|
||||
var identity = new ThemeSelectionIdentity(market, session, code, "기존 테마");
|
||||
|
||||
var replacePlan = new CatalogMutationConnectionPlan();
|
||||
replacePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
|
||||
replacePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
|
||||
var replaceFactory = new CatalogMutationConnectionFactory(replacePlan);
|
||||
var replaceService = new LegacyThemeCatalogPersistenceService(
|
||||
new CatalogNoQueryExecutor(),
|
||||
CreateExecutor(replaceFactory));
|
||||
|
||||
await replaceService.ReplaceAsync(identity, "변경 테마", []);
|
||||
|
||||
Assert.Equal(source, replaceFactory.LastSource);
|
||||
Assert.Equal(1, replacePlan.CommitCount);
|
||||
|
||||
var deletePlan = new CatalogMutationConnectionPlan();
|
||||
deletePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
|
||||
deletePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
|
||||
var deleteFactory = new CatalogMutationConnectionFactory(deletePlan);
|
||||
var deleteService = new LegacyThemeCatalogPersistenceService(
|
||||
new CatalogNoQueryExecutor(),
|
||||
CreateExecutor(deleteFactory));
|
||||
|
||||
await deleteService.DeleteAsync(identity);
|
||||
|
||||
Assert.Equal(source, deleteFactory.LastSource);
|
||||
Assert.Equal(1, deletePlan.CommitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteTransactionAsync_AffectedRowConflictRollsBackBeforeNextCommand()
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET",
|
||||
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT",
|
||||
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE",
|
||||
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY",
|
||||
"MBN_STOCK_PLAYOUT_SCENE_DIRECTORY",
|
||||
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
|
||||
"MBN_STOCK_PLAYOUT_QUEUE_CAPACITY",
|
||||
@@ -53,6 +54,7 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
Assert.Null(options.LegacySceneBackgroundAssetPath);
|
||||
Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount);
|
||||
Assert.True(options.LegacySceneBackgroundVideoLoopInfinite);
|
||||
Assert.Null(options.LegacyBackgroundDirectory);
|
||||
Assert.Empty(options.TestSceneAllowlist);
|
||||
Assert.False(options.TrustedLiveOutputEnabled);
|
||||
Assert.True(options.ReconnectEnabled);
|
||||
@@ -74,6 +76,7 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET", "Video\\studio.vrv")
|
||||
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT", "2004")
|
||||
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE", "false")
|
||||
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY", "C:\\env-backgrounds")
|
||||
.Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\env-scenes")
|
||||
.Set("MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", "ENV TEST")
|
||||
.Set("MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", "14")
|
||||
@@ -125,6 +128,7 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
Assert.Equal("Video\\studio.vrv", options.LegacySceneBackgroundAssetPath);
|
||||
Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount);
|
||||
Assert.False(options.LegacySceneBackgroundVideoLoopInfinite);
|
||||
Assert.Equal("C:\\env-backgrounds", options.LegacyBackgroundDirectory);
|
||||
Assert.Equal("C:\\env-scenes", options.SceneDirectory);
|
||||
Assert.Equal("ENV TEST", options.TestProcessWindowTitlePattern);
|
||||
Assert.Equal(14, options.QueueCapacity);
|
||||
|
||||
@@ -368,6 +368,85 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
|
||||
ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCue_OperatorBackgroundUsesOnlyTheSeparateTrustedRoot()
|
||||
{
|
||||
using var backgrounds = TemporarySceneDirectory.Create("studio.vrv");
|
||||
var options = ValidTestOptions();
|
||||
options.LegacyBackgroundDirectory = backgrounds.Path;
|
||||
var validated = ValidatedPlayoutOptions.Create(options);
|
||||
|
||||
var resolved = validated.ResolveCue(new PlayoutCue(
|
||||
"test-scene.t2s",
|
||||
"test-scene",
|
||||
Mutations:
|
||||
[
|
||||
new PlayoutSetBackgroundVideo(
|
||||
"studio.vrv",
|
||||
LoopCount: 2004,
|
||||
LoopInfinite: true,
|
||||
AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory)
|
||||
]));
|
||||
|
||||
var video = Assert.IsType<PlayoutSetBackgroundVideo>(Assert.Single(resolved.Mutations!));
|
||||
Assert.Equal(
|
||||
Path.Combine(backgrounds.Path, "studio.vrv"),
|
||||
video.AssetPath,
|
||||
ignoreCase: true);
|
||||
Assert.Equal(PlayoutAssetRoot.OperatorBackgroundDirectory, video.AssetRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCue_OperatorBackgroundDoesNotFallBackToCutsRoot()
|
||||
{
|
||||
using var backgrounds = TemporarySceneDirectory.Create();
|
||||
var options = ValidTestOptions();
|
||||
options.LegacyBackgroundDirectory = backgrounds.Path;
|
||||
var validated = ValidatedPlayoutOptions.Create(options);
|
||||
|
||||
Assert.Throws<PlayoutRequestException>(() => validated.ResolveCue(new PlayoutCue(
|
||||
"test-scene.t2s",
|
||||
"test-scene",
|
||||
Mutations:
|
||||
[
|
||||
new PlayoutSetBackgroundVideo(
|
||||
"Video\\background.vrv",
|
||||
LoopCount: 2004,
|
||||
LoopInfinite: true,
|
||||
AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory)
|
||||
])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCue_RevalidatesConfiguredBackgroundRootAtCommandTime()
|
||||
{
|
||||
var lateRoot = Path.Combine(Directory.GetParent(_scenes.Path)!.FullName, "late-background");
|
||||
var options = ValidTestOptions();
|
||||
options.LegacyBackgroundDirectory = lateRoot;
|
||||
var validated = ValidatedPlayoutOptions.Create(options);
|
||||
|
||||
Directory.CreateDirectory(lateRoot);
|
||||
File.WriteAllText(Path.Combine(lateRoot, "studio.vrv"), "late trusted background");
|
||||
|
||||
var resolved = validated.ResolveCue(new PlayoutCue(
|
||||
"test-scene.t2s",
|
||||
"test-scene",
|
||||
Mutations:
|
||||
[
|
||||
new PlayoutSetBackgroundVideo(
|
||||
"studio.vrv",
|
||||
LoopCount: 2004,
|
||||
LoopInfinite: true,
|
||||
AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory)
|
||||
]));
|
||||
|
||||
var video = Assert.IsType<PlayoutSetBackgroundVideo>(Assert.Single(resolved.Mutations!));
|
||||
Assert.Equal(
|
||||
Path.Combine(lateRoot, "studio.vrv"),
|
||||
video.AssetPath,
|
||||
ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCue_AcceptsTheLegacy2004BackgroundVideoLoopValue()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
@@ -17,18 +18,24 @@ public sealed class PlayoutSceneCompositionFactoryTests
|
||||
[Fact]
|
||||
public void TrustedRelativeVideo_IsVerifiedEvenInDryRun()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("Video\\studio.vrv");
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var backgroundRoot = Path.Combine(Directory.GetParent(scenes.Path)!.FullName, "배경");
|
||||
Directory.CreateDirectory(backgroundRoot);
|
||||
File.WriteAllText(Path.Combine(backgroundRoot, "studio.vrv"), "trusted background");
|
||||
var result = PlayoutSceneCompositionFactory.Create(new PlayoutOptions
|
||||
{
|
||||
Mode = PlayoutMode.DryRun,
|
||||
SceneDirectory = scenes.Path,
|
||||
LegacySceneBackgroundKind = LegacySceneBackgroundKind.Video,
|
||||
LegacySceneBackgroundAssetPath = "Video\\studio.vrv"
|
||||
LegacySceneBackgroundAssetPath = "studio.vrv"
|
||||
});
|
||||
|
||||
Assert.Equal(LegacySceneBackgroundKind.Video, result.BackgroundKind);
|
||||
Assert.Equal(Path.Combine("Video", "studio.vrv"), result.BackgroundAssetPath);
|
||||
Assert.Equal("studio.vrv", result.BackgroundAssetPath);
|
||||
Assert.Equal(2004, result.BackgroundVideoLoopCount);
|
||||
Assert.Equal(
|
||||
PlayoutAssetRoot.OperatorBackgroundDirectory,
|
||||
result.BackgroundAssetRoot);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -38,7 +45,13 @@ public sealed class PlayoutSceneCompositionFactoryTests
|
||||
[InlineData("Video\\studio.exe")]
|
||||
public void InvalidOrMissingBackground_FailsBeforeDryRunPrepare(string relativePath)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("Video\\studio.vrv");
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var backgroundRoot = Path.Combine(Directory.GetParent(scenes.Path)!.FullName, "배경");
|
||||
Directory.CreateDirectory(backgroundRoot);
|
||||
Directory.CreateDirectory(Path.Combine(backgroundRoot, "Video"));
|
||||
File.WriteAllText(
|
||||
Path.Combine(backgroundRoot, "Video", "studio.vrv"),
|
||||
"trusted background");
|
||||
var options = new PlayoutOptions
|
||||
{
|
||||
Mode = PlayoutMode.DryRun,
|
||||
@@ -50,4 +63,33 @@ public sealed class PlayoutSceneCompositionFactoryTests
|
||||
Assert.Throws<PlayoutConfigurationException>(() =>
|
||||
PlayoutSceneCompositionFactory.Create(options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyDefault_UsesSiblingBackgroundRoot_NotTheCutsRoot()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("기본.vrv");
|
||||
var backgroundRoot = Path.Combine(Directory.GetParent(scenes.Path)!.FullName, "배경");
|
||||
Directory.CreateDirectory(backgroundRoot);
|
||||
File.WriteAllText(
|
||||
Path.Combine(backgroundRoot, PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName),
|
||||
"trusted background");
|
||||
|
||||
var result = PlayoutSceneCompositionFactory.CreateLegacyDefault(
|
||||
new PlayoutOptions { SceneDirectory = scenes.Path },
|
||||
fadeDuration: 6);
|
||||
|
||||
Assert.Equal("기본.vrv", result.BackgroundAssetPath);
|
||||
Assert.Equal(PlayoutAssetRoot.OperatorBackgroundDirectory, result.BackgroundAssetRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyDefault_MissingSiblingDirectory_FailsClosed()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("기본.vrv");
|
||||
|
||||
Assert.Throws<PlayoutConfigurationException>(() =>
|
||||
PlayoutSceneCompositionFactory.CreateLegacyDefault(
|
||||
new PlayoutOptions { SceneDirectory = scenes.Path },
|
||||
fadeDuration: 6));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class TrustedPlayoutAssetPathTests
|
||||
{
|
||||
private static readonly IReadOnlySet<string> Allowed = new HashSet<string>(
|
||||
[".vrv", ".png"],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelativeFile_AcceptsOneInternalRegularFile()
|
||||
{
|
||||
using var root = TemporarySceneDirectory.Create("studio.vrv");
|
||||
|
||||
var resolved = TrustedPlayoutAssetPath.ResolveRelativeFile(
|
||||
root.Path,
|
||||
"studio.vrv",
|
||||
Allowed);
|
||||
|
||||
Assert.Equal(Path.Combine(root.Path, "studio.vrv"), resolved, ignoreCase: true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("..\\outside.vrv")]
|
||||
[InlineData("C:\\outside.vrv")]
|
||||
public void ResolveRelativeFile_RejectsRootEscape(string input)
|
||||
{
|
||||
using var root = TemporarySceneDirectory.Create("studio.vrv");
|
||||
|
||||
Assert.Throws<TrustedPlayoutAssetException>(() =>
|
||||
TrustedPlayoutAssetPath.ResolveRelativeFile(root.Path, input, Allowed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelativeFile_RejectsMissingFile()
|
||||
{
|
||||
using var root = TemporarySceneDirectory.Create();
|
||||
|
||||
Assert.Throws<TrustedPlayoutAssetException>(() =>
|
||||
TrustedPlayoutAssetPath.ResolveRelativeFile(root.Path, "missing.vrv", Allowed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAbsoluteFile_RejectsPickerSelectionOutsideTheRoot()
|
||||
{
|
||||
using var root = TemporarySceneDirectory.Create();
|
||||
using var outside = TemporarySceneDirectory.Create("outside.vrv");
|
||||
|
||||
Assert.Throws<TrustedPlayoutAssetException>(() =>
|
||||
TrustedPlayoutAssetPath.ResolveAbsoluteFile(
|
||||
root.Path,
|
||||
Path.Combine(outside.Path, "outside.vrv"),
|
||||
Allowed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelativeFile_RejectsHardLinkedFile()
|
||||
{
|
||||
using var root = TemporarySceneDirectory.Create();
|
||||
var parent = Directory.GetParent(root.Path)!.FullName;
|
||||
var outside = Path.Combine(parent, "outside.vrv");
|
||||
File.WriteAllText(outside, "outside trusted-looking bytes");
|
||||
var link = Path.Combine(root.Path, "linked.vrv");
|
||||
Assert.True(CreateHardLinkW(link, outside, IntPtr.Zero));
|
||||
|
||||
Assert.Throws<TrustedPlayoutAssetException>(() =>
|
||||
TrustedPlayoutAssetPath.ResolveRelativeFile(root.Path, "linked.vrv", Allowed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRelativeFile_RejectsReparseDirectory()
|
||||
{
|
||||
using var root = TemporarySceneDirectory.Create();
|
||||
var parent = Directory.GetParent(root.Path)!.FullName;
|
||||
var outside = Path.Combine(parent, "outside");
|
||||
Directory.CreateDirectory(outside);
|
||||
File.WriteAllText(Path.Combine(outside, "studio.vrv"), "outside bytes");
|
||||
var link = Path.Combine(root.Path, "linked");
|
||||
try
|
||||
{
|
||||
Directory.CreateSymbolicLink(link, outside);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is UnauthorizedAccessException or IOException or PlatformNotSupportedException)
|
||||
{
|
||||
throw Xunit.Sdk.SkipException.ForSkip(
|
||||
"The Windows test host does not permit creating a symbolic link.");
|
||||
}
|
||||
|
||||
Assert.Throws<TrustedPlayoutAssetException>(() =>
|
||||
TrustedPlayoutAssetPath.ResolveRelativeFile(
|
||||
root.Path,
|
||||
"linked\\studio.vrv",
|
||||
Allowed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureOpenedRegularFile_BindsTheHandleToTheExpectedPath()
|
||||
{
|
||||
using var root = TemporarySceneDirectory.Create("expected.vrv", "other.vrv");
|
||||
var expected = Path.Combine(root.Path, "expected.vrv");
|
||||
var other = Path.Combine(root.Path, "other.vrv");
|
||||
using var stream = new FileStream(
|
||||
other,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read);
|
||||
|
||||
Assert.Throws<TrustedPlayoutAssetException>(() =>
|
||||
TrustedPlayoutAssetPath.EnsureOpenedRegularFile(
|
||||
stream.SafeFileHandle,
|
||||
expected));
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateHardLinkW(
|
||||
string fileName,
|
||||
string existingFileName,
|
||||
IntPtr securityAttributes);
|
||||
}
|
||||
186
tests/Scripts/Test-LegacyCutCoverage.Fixture.ps1
Normal file
186
tests/Scripts/Test-LegacyCutCoverage.Fixture.ps1
Normal file
@@ -0,0 +1,186 @@
|
||||
#Requires -Version 5.1
|
||||
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
|
||||
$modulePath = Join-Path $repositoryRoot 'scripts\LegacyCutCoverage.psm1'
|
||||
$manifestPath = Join-Path $repositoryRoot 'scripts\LegacyCutRequirements.json'
|
||||
$entryScriptPath = Join-Path $repositoryRoot 'scripts\Test-LegacyCutCoverage.ps1'
|
||||
Import-Module -Name $modulePath -Force
|
||||
|
||||
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$aliases = @($manifest.activeAliases)
|
||||
$assets = @($manifest.assets)
|
||||
$reachableBuilders = [int] $manifest.reachableBuilders
|
||||
$temporaryBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$fixtureRoot = [IO.Path]::GetFullPath((Join-Path $temporaryBase ("MBN_STOCK_WEBVIEW-cut-fixture-" + [Guid]::NewGuid().ToString('N'))))
|
||||
$externalRoot = [IO.Path]::GetFullPath((Join-Path $temporaryBase ("MBN_STOCK_WEBVIEW-cut-external-" + [Guid]::NewGuid().ToString('N'))))
|
||||
$junctionPath = Join-Path $fixtureRoot 'FixtureJunction'
|
||||
|
||||
function Assert-Equal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] $Expected,
|
||||
[Parameter(Mandatory = $true)] $Actual,
|
||||
[Parameter(Mandatory = $true)] [string] $Message
|
||||
)
|
||||
|
||||
if ($Expected -ne $Actual) {
|
||||
throw "$Message Expected=$Expected Actual=$Actual"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-True {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [bool] $Condition,
|
||||
[Parameter(Mandatory = $true)] [string] $Message
|
||||
)
|
||||
|
||||
if (-not $Condition) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
function Write-FixtureFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $Root,
|
||||
[Parameter(Mandatory = $true)] [string] $RelativePath,
|
||||
[byte[]] $Content = ([byte[]] @(1))
|
||||
)
|
||||
|
||||
$rootPrefix = $Root.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
|
||||
$path = [IO.Path]::GetFullPath((Join-Path $Root $RelativePath))
|
||||
if (-not $path.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Fixture path escaped its temporary root.'
|
||||
}
|
||||
|
||||
$parent = Split-Path -Parent $path
|
||||
if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $parent -Force | Out-Null
|
||||
}
|
||||
[IO.File]::WriteAllBytes($path, $Content)
|
||||
}
|
||||
|
||||
function Invoke-FixtureCoverage {
|
||||
param(
|
||||
[object[]] $Requirements = $assets
|
||||
)
|
||||
|
||||
return Test-LegacyCutCoverageState `
|
||||
-CutRoot $fixtureRoot `
|
||||
-Aliases $aliases `
|
||||
-AssetRequirements $Requirements `
|
||||
-ReachableBuilders $reachableBuilders
|
||||
}
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $fixtureRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $externalRoot -Force | Out-Null
|
||||
|
||||
foreach ($alias in $aliases) {
|
||||
Write-FixtureFile -Root $fixtureRoot -RelativePath ($alias + '.t2s')
|
||||
}
|
||||
foreach ($asset in $assets) {
|
||||
Write-FixtureFile -Root $fixtureRoot -RelativePath ([string] $asset.Path)
|
||||
}
|
||||
|
||||
$complete = Invoke-FixtureCoverage
|
||||
Assert-Equal 'Passed' $complete.Status 'A complete fixture must pass.'
|
||||
Assert-Equal 0 $complete.Missing 'The legacy alias result regressed.'
|
||||
Assert-Equal 0 $complete.Unsafe 'The legacy alias safety result regressed.'
|
||||
Assert-Equal $assets.Count $complete.RequiredAssets 'The complete manifest was not inspected.'
|
||||
$entryScriptReport = & $entryScriptPath -CutRoot $fixtureRoot
|
||||
Assert-Equal 'Passed' $entryScriptReport.Status 'The public coverage script must pass the complete fixture.'
|
||||
|
||||
$cubePath = [string] @($assets | Where-Object {
|
||||
$_.Builder -eq 's5006' -and $_.Kind -eq 'Video'
|
||||
})[0].Path
|
||||
$usVideoPath = [string] @($assets | Where-Object {
|
||||
$_.Builder -eq 's6001' -and $_.Kind -eq 'Video'
|
||||
})[0].Path
|
||||
Remove-Item -LiteralPath (Join-Path $fixtureRoot $cubePath) -Force
|
||||
Remove-Item -LiteralPath (Join-Path $fixtureRoot $usVideoPath) -Force
|
||||
$missing = Invoke-FixtureCoverage
|
||||
Assert-Equal 'Failed' $missing.Status 'Missing builder assets must fail.'
|
||||
Assert-Equal 2 $missing.MissingAssets 'Both missing builder assets must be counted.'
|
||||
Assert-Equal 1 @($missing.AssetFindings | Where-Object {
|
||||
$_.Builder -eq 's5006' -and $_.RelativePath -eq $cubePath -and $_.Status -eq 'Missing'
|
||||
}).Count 'The s5006 video must be reported explicitly.'
|
||||
Assert-Equal 1 @($missing.AssetFindings | Where-Object {
|
||||
$_.Builder -eq 's6001' -and $_.RelativePath -eq $usVideoPath -and $_.Status -eq 'Missing'
|
||||
}).Count 'The s6001 video must be reported explicitly.'
|
||||
Write-FixtureFile -Root $fixtureRoot -RelativePath $cubePath
|
||||
Write-FixtureFile -Root $fixtureRoot -RelativePath $usVideoPath
|
||||
|
||||
[IO.File]::WriteAllBytes((Join-Path $fixtureRoot $usVideoPath), [byte[]] @())
|
||||
$empty = Invoke-FixtureCoverage
|
||||
Assert-Equal 1 $empty.UnsafeAssets 'An empty builder asset must be unsafe.'
|
||||
Assert-Equal 'Empty' @($empty.AssetFindings | Where-Object {
|
||||
$_.RelativePath -eq $usVideoPath
|
||||
})[0].Status 'The empty asset status must be explicit.'
|
||||
Write-FixtureFile -Root $fixtureRoot -RelativePath $usVideoPath
|
||||
|
||||
$escape = Invoke-FixtureCoverage -Requirements @(
|
||||
[pscustomobject]@{
|
||||
Builder = 'fixture-root-escape'
|
||||
Kind = 'Image'
|
||||
Path = '..\outside.png'
|
||||
}
|
||||
)
|
||||
Assert-Equal 1 $escape.RootEscapes 'A manifest root escape must be rejected.'
|
||||
Assert-Equal 'RootEscape' $escape.AssetFindings[0].Status 'The root escape status must be explicit.'
|
||||
|
||||
Write-FixtureFile -Root $externalRoot -RelativePath 'linked.vrv'
|
||||
New-Item -ItemType Junction -Path $junctionPath -Target $externalRoot | Out-Null
|
||||
$reparse = Invoke-FixtureCoverage -Requirements @(
|
||||
[pscustomobject]@{
|
||||
Builder = 'fixture-reparse'
|
||||
Kind = 'Video'
|
||||
Path = 'FixtureJunction\linked.vrv'
|
||||
}
|
||||
)
|
||||
Assert-Equal 1 $reparse.ReparseAssets 'A reparse ancestor must be rejected.'
|
||||
Assert-Equal 'Reparse' $reparse.AssetFindings[0].Status 'The reparse status must be explicit.'
|
||||
[IO.Directory]::Delete($junctionPath)
|
||||
|
||||
Remove-Item -LiteralPath (Join-Path $fixtureRoot '5001.t2s') -Force
|
||||
[IO.File]::WriteAllBytes((Join-Path $fixtureRoot '5006.t2s'), [byte[]] @())
|
||||
$aliasesRegressed = Invoke-FixtureCoverage
|
||||
Assert-Equal 1 $aliasesRegressed.Missing 'A missing alias must retain the legacy result.'
|
||||
Assert-Equal 1 $aliasesRegressed.Unsafe 'An empty alias must retain the legacy result.'
|
||||
|
||||
$s6001Videos = @($assets | Where-Object {
|
||||
$_.Builder -eq 's6001' -and $_.Kind -eq 'Video'
|
||||
} | Select-Object -ExpandProperty Path -Unique)
|
||||
Assert-Equal 13 $s6001Videos.Count 'The closed manifest must contain 13 distinct s6001 videos.'
|
||||
Assert-True ($assets.Path -contains $cubePath) 'The closed manifest must contain the s5006 cube video.'
|
||||
|
||||
[pscustomobject][ordered]@{
|
||||
Status = 'Passed'
|
||||
ActiveAliases = $aliases.Count
|
||||
RequiredAssets = $assets.Count
|
||||
S6001DistinctVideos = $s6001Videos.Count
|
||||
RootEscapeFixture = 'Passed'
|
||||
ReparseFixture = 'Passed'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $junctionPath) {
|
||||
[IO.Directory]::Delete($junctionPath)
|
||||
}
|
||||
|
||||
$temporaryPrefix = $temporaryBase.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
|
||||
foreach ($path in @($fixtureRoot, $externalRoot)) {
|
||||
$resolved = [IO.Path]::GetFullPath($path)
|
||||
if (-not $resolved.StartsWith($temporaryPrefix, [StringComparison]::OrdinalIgnoreCase) -or
|
||||
-not ([IO.Path]::GetFileName($resolved)).StartsWith('MBN_STOCK_WEBVIEW-cut-', [StringComparison]::Ordinal)) {
|
||||
throw 'Refusing to remove a fixture directory outside the intended temporary boundary.'
|
||||
}
|
||||
if (Test-Path -LiteralPath $resolved) {
|
||||
Remove-Item -LiteralPath $resolved -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
58
tests/Web/comparison-import-app-integration.test.cjs
Normal file
58
tests/Web/comparison-import-app-integration.test.cjs
Normal file
@@ -0,0 +1,58 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "Web/index.html"), "utf8");
|
||||
const bridge = fs.readFileSync(path.join(root, "MainWindow.LegacyComparisonImport.cs"), "utf8");
|
||||
const host = fs.readFileSync(path.join(root, "MainWindow.xaml.cs"), "utf8");
|
||||
const core = fs.readFileSync(
|
||||
path.join(root, "src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs"),
|
||||
"utf8");
|
||||
|
||||
test("explicit one-time import is visible and persists only the normalized local schema", () => {
|
||||
assert.match(index, /id="comparisonImportLegacy"/u);
|
||||
assert.match(index, /<script src="comparison-import-workflow\.js"><\/script>/u);
|
||||
assert.match(app, /comparisonImportMarkerStorageKey/u);
|
||||
assert.match(app, /comparisonImportWorkflow\.normalizeStorageEnvelope/u);
|
||||
assert.match(app, /comparisonImportWorkflow\.createStorageEnvelope/u);
|
||||
assert.match(app, /comparisonImportWorkflow\.mergeImport/u);
|
||||
assert.match(app, /persistComparisonLegacyImport/u);
|
||||
assert.match(app, /window\.confirm\(/u);
|
||||
assert.match(app, /postNative\("import-legacy-comparison-pairs", \{ requestId \}\)/u);
|
||||
assert.doesNotMatch(app, /import-legacy-comparison-pairs"[^\n]*path/iu);
|
||||
assert.match(app, /자동 재시도하지 않습니다/u);
|
||||
});
|
||||
|
||||
test("native import owns the fixed path and performs only bound read validation", () => {
|
||||
assert.match(core, /FixedRelativePath[\s\S]*종목비교\.dat/u);
|
||||
assert.match(core, /FileMode\.Open/u);
|
||||
assert.match(core, /FileAccess\.Read/u);
|
||||
assert.match(core, /FileShare\.Read/u);
|
||||
assert.match(core, /FileAttributes\.ReparsePoint/u);
|
||||
assert.match(core, /GetFinalPathNameByHandleW/u);
|
||||
assert.match(core, /GetFileInformationByHandle/u);
|
||||
assert.match(core, /NumberOfLinks != 1/u);
|
||||
assert.match(core, /Encoding\.GetEncoding\([\s\S]*949/u);
|
||||
assert.match(core, /ExactFieldCount = 9/u);
|
||||
assert.match(core, /new DataQueryParameter\("stock_name"/u);
|
||||
assert.match(core, /new DataQueryParameter\("input_name"/u);
|
||||
assert.doesNotMatch(core, /\b(?:INSERT\s+INTO|UPDATE\s+[A-Z_]|DELETE\s+FROM|MERGE\s+INTO)\b/iu);
|
||||
assert.doesNotMatch(bridge, /Tornado|TakeIn|PrepareAsync|ExecuteNonQuery/iu);
|
||||
});
|
||||
|
||||
test("bridge routing cancels correlation loss and never accepts a Web-provided path", () => {
|
||||
assert.match(host, /TryHandleLegacyComparisonImportRequest\(request\.Type, request\.Payload\)/u);
|
||||
assert.match(host, /InvalidateLegacyComparisonImportRequest\(\)/u);
|
||||
assert.match(host, /ShutdownLegacyComparisonImportRuntime\(\)/u);
|
||||
assert.match(host, /CancelRequest\(Volatile\.Read\(ref _legacyComparisonImportCancellation\)\)/u);
|
||||
assert.match(bridge, /HasOnlyProperties\(payload, "requestId"\)/u);
|
||||
assert.match(bridge, /_databaseActivityGate\.WaitAsync/u);
|
||||
assert.match(bridge, /Volatile\.Read\(ref _playoutCommandInFlight\)/u);
|
||||
assert.match(bridge, /Never retry or publish a stale result/u);
|
||||
assert.doesNotMatch(bridge, /GetString\(payload, "path"\)/u);
|
||||
});
|
||||
171
tests/Web/comparison-import-workflow.test.cjs
Normal file
171
tests/Web/comparison-import-workflow.test.cjs
Normal file
@@ -0,0 +1,171 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const comparison = require("../../Web/comparison-workflow.js");
|
||||
const workflow = require("../../Web/comparison-import-workflow.js");
|
||||
|
||||
const digest = "A".repeat(64);
|
||||
const retrievedAt = "2026-07-12T08:00:00+09:00";
|
||||
const samsung = Object.freeze({
|
||||
kind: "krx-stock", market: "kospi", stockName: "삼성전자", stockCode: "005930"
|
||||
});
|
||||
const nxtSamsung = Object.freeze({
|
||||
kind: "nxt-stock", market: "kospi", stockName: "삼성전자", stockCode: "005930"
|
||||
});
|
||||
const nvidia = Object.freeze({
|
||||
kind: "world-stock", inputName: "엔비디아", symbol: "NAS@NVDA", nation: "US"
|
||||
});
|
||||
|
||||
function response(pairs = [
|
||||
{ rowNumber: 1, first: samsung, second: nxtSamsung },
|
||||
{
|
||||
rowNumber: 2,
|
||||
first: { kind: "market-target", target: "Kosdaq" },
|
||||
second: nvidia
|
||||
}
|
||||
]) {
|
||||
return {
|
||||
requestId: "import-request-1",
|
||||
sourceSha256: digest,
|
||||
sourceRowCount: pairs.length,
|
||||
retrievedAt,
|
||||
pairs
|
||||
};
|
||||
}
|
||||
|
||||
test("strict import response normalizes all four trusted target shapes", () => {
|
||||
const normalized = workflow.normalizeImportResponse(response(), "import-request-1");
|
||||
|
||||
assert.ok(normalized);
|
||||
assert.equal(normalized.sourceRowCount, 2);
|
||||
assert.equal(normalized.pairs[0].pair[0].displayName, "삼성전자");
|
||||
assert.equal(normalized.pairs[0].pair[1].displayName, "삼성전자(NXT)");
|
||||
assert.equal(normalized.pairs[1].pair[0], comparison.getMarketTarget("Kosdaq"));
|
||||
assert.deepEqual(normalized.pairs[1].pair[1], {
|
||||
...nvidia,
|
||||
displayName: "엔비디아"
|
||||
});
|
||||
assert.ok(Object.isFrozen(normalized));
|
||||
assert.ok(Object.isFrozen(normalized.pairs));
|
||||
});
|
||||
|
||||
test("response validation rejects correlation, property, digest, row and target tampering", () => {
|
||||
const valid = response();
|
||||
assert.equal(workflow.normalizeImportResponse(valid, "wrong-request"), null);
|
||||
assert.equal(workflow.normalizeImportResponse({ ...valid, extra: true }, valid.requestId), null);
|
||||
assert.equal(workflow.normalizeImportResponse({ ...valid, sourceSha256: digest.toLowerCase() }, valid.requestId), null);
|
||||
assert.equal(workflow.normalizeImportResponse({ ...valid, sourceRowCount: 3 }, valid.requestId), null);
|
||||
assert.equal(workflow.normalizeImportResponse({
|
||||
...valid,
|
||||
pairs: [{ ...valid.pairs[0], rowNumber: 2 }, valid.pairs[1]]
|
||||
}, valid.requestId), null);
|
||||
assert.equal(workflow.normalizeImportResponse({
|
||||
...valid,
|
||||
pairs: [{ ...valid.pairs[0], first: { ...samsung, displayName: "삼성전자" } }, valid.pairs[1]]
|
||||
}, valid.requestId), null);
|
||||
assert.equal(workflow.normalizeImportResponse({
|
||||
...valid,
|
||||
pairs: [valid.pairs[0], { ...valid.pairs[1], second: { ...nvidia, nation: "KR" } }]
|
||||
}, valid.requestId), null);
|
||||
});
|
||||
|
||||
test("duplicate source pairs fail closed before local persistence", () => {
|
||||
const duplicate = response([
|
||||
{ rowNumber: 1, first: samsung, second: nxtSamsung },
|
||||
{ rowNumber: 2, first: samsung, second: nxtSamsung }
|
||||
]);
|
||||
assert.equal(workflow.normalizeImportResponse(duplicate, duplicate.requestId), null);
|
||||
});
|
||||
|
||||
test("merge appends deterministic records without replacing current local pairs", () => {
|
||||
const normalized = workflow.normalizeImportResponse(response(), "import-request-1");
|
||||
let current = comparison.deleteAllPairs();
|
||||
current = comparison.appendPair(
|
||||
current,
|
||||
"existing",
|
||||
[comparison.getMarketTarget("Kospi"), samsung]);
|
||||
|
||||
const merged = workflow.mergeImport(current, normalized);
|
||||
|
||||
assert.deepEqual(merged.pairs.map(value => value.id), [
|
||||
"existing",
|
||||
"legacy-compare-aaaaaaaaaaaa-1",
|
||||
"legacy-compare-aaaaaaaaaaaa-2"
|
||||
]);
|
||||
assert.equal(current.pairs.length, 1);
|
||||
assert.equal(merged.pairs[2].pair[1].symbol, "NAS@NVDA");
|
||||
});
|
||||
|
||||
test("merge rejects duplicate existing pairs and the 500-pair boundary atomically", () => {
|
||||
const normalized = workflow.normalizeImportResponse(response(), "import-request-1");
|
||||
let duplicate = comparison.deleteAllPairs();
|
||||
duplicate = comparison.appendPair(duplicate, "already-present", normalized.pairs[0].pair);
|
||||
assert.throws(() => workflow.mergeImport(duplicate, normalized), /existing pair/i);
|
||||
assert.equal(duplicate.pairs.length, 1);
|
||||
|
||||
let full = comparison.deleteAllPairs();
|
||||
for (let index = 0; index < 499; index += 1) {
|
||||
full = comparison.appendPair(full, `pair-${index}`, [
|
||||
comparison.getMarketTarget(index % 2 ? "Kospi" : "Kosdaq"),
|
||||
{ ...samsung, stockCode: String(index).padStart(6, "0") }
|
||||
]);
|
||||
}
|
||||
assert.throws(() => workflow.mergeImport(full, normalized), /cannot fit/i);
|
||||
assert.equal(full.pairs.length, 499);
|
||||
});
|
||||
|
||||
test("merge independently rejects unnormalized count and row-order input", () => {
|
||||
const normalized = workflow.normalizeImportResponse(response(), "import-request-1");
|
||||
const empty = comparison.deleteAllPairs();
|
||||
assert.throws(() => workflow.mergeImport(empty, {
|
||||
...normalized,
|
||||
sourceRowCount: 1
|
||||
}), /cannot fit/i);
|
||||
assert.throws(() => workflow.mergeImport(empty, {
|
||||
...normalized,
|
||||
pairs: [{ ...normalized.pairs[0], rowNumber: 2 }, normalized.pairs[1]]
|
||||
}), /row order/i);
|
||||
});
|
||||
|
||||
test("one-time marker is schema-versioned, strict and digest-bound", () => {
|
||||
const normalized = workflow.normalizeImportResponse(response(), "import-request-1");
|
||||
const marker = workflow.createMarker(normalized, "2026-07-12T00:00:00.000Z");
|
||||
assert.deepEqual(marker, {
|
||||
schemaVersion: 1,
|
||||
sourceSha256: digest,
|
||||
importedPairCount: 2,
|
||||
importedAt: "2026-07-12T00:00:00.000Z"
|
||||
});
|
||||
assert.deepEqual(workflow.normalizeMarker(JSON.parse(JSON.stringify(marker))), marker);
|
||||
assert.equal(workflow.normalizeMarker({ ...marker, schemaVersion: 2 }), null);
|
||||
assert.equal(workflow.normalizeMarker({ ...marker, sourceSha256: "0".repeat(63) }), null);
|
||||
assert.equal(workflow.normalizeMarker({ ...marker, extra: true }), null);
|
||||
});
|
||||
|
||||
test("pair list and one-time marker form one strict crash-consistent envelope", () => {
|
||||
const normalized = workflow.normalizeImportResponse(response(), "import-request-1");
|
||||
const merged = workflow.mergeImport(comparison.deleteAllPairs(), normalized);
|
||||
const marker = workflow.createMarker(normalized, "2026-07-12T00:00:00.000Z");
|
||||
const envelope = workflow.createStorageEnvelope(merged, marker);
|
||||
|
||||
assert.ok(envelope);
|
||||
assert.equal(envelope.schemaVersion, workflow.storageEnvelopeSchemaVersion);
|
||||
assert.equal(envelope.pairList.pairs.length, 2);
|
||||
assert.equal(envelope.legacyImportMarker.sourceSha256, digest);
|
||||
assert.deepEqual(
|
||||
workflow.normalizeStorageEnvelope(JSON.parse(JSON.stringify(envelope))),
|
||||
envelope);
|
||||
});
|
||||
|
||||
test("a legacy-import record without its marker is fail-closed as a split commit", () => {
|
||||
const normalized = workflow.normalizeImportResponse(response(), "import-request-1");
|
||||
const merged = workflow.mergeImport(comparison.deleteAllPairs(), normalized);
|
||||
|
||||
assert.equal(workflow.createStorageEnvelope(merged, null), null);
|
||||
assert.equal(workflow.normalizeStorageEnvelope({
|
||||
schemaVersion: workflow.storageEnvelopeSchemaVersion,
|
||||
pairList: merged,
|
||||
legacyImportMarker: null
|
||||
}), null);
|
||||
});
|
||||
@@ -161,7 +161,8 @@ test("candle, line and all five return actions create explicit selections in leg
|
||||
assert.equal(candle.code, "5026");
|
||||
assert.deepEqual(candle.selection, {
|
||||
groupCode: "STOCK", subject: "삼성전자,SK하이닉스",
|
||||
graphicType: "COMPARISON_CANDLE", subtype: "FiveDays", dataCode: ""
|
||||
graphicType: "COMPARISON_CANDLE", subtype: "FiveDays",
|
||||
dataCode: "KOSPI:005930|KOSPI:000660"
|
||||
});
|
||||
|
||||
const line = workflow.createComparisonPlaylistEntry("comparison-line", [samsung, skHynix], {
|
||||
@@ -169,6 +170,7 @@ test("candle, line and all five return actions create explicit selections in leg
|
||||
});
|
||||
assert.equal(line.builderKey, "s5087");
|
||||
assert.equal(line.selection.graphicType, "COMPARISON_LINE");
|
||||
assert.equal(line.selection.dataCode, "KOSPI:005930|KOSPI:000660");
|
||||
|
||||
const batch = workflow.createYieldBatchEntries([samsung, skHynix], {
|
||||
idPrefix: "pair-yield", fadeDuration: 4
|
||||
|
||||
54
tests/Web/legacy-batch-selection-app-integration.test.cjs
Normal file
54
tests/Web/legacy-batch-selection-app-integration.test.cjs
Normal file
@@ -0,0 +1,54 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..", "..");
|
||||
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "Web", "index.html"), "utf8");
|
||||
const styles = fs.readFileSync(path.join(root, "Web", "styles.css"), "utf8");
|
||||
|
||||
function functionBody(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}`);
|
||||
const end = source.indexOf(`function ${nextName}`, start + 1);
|
||||
assert.ok(start >= 0, `${name} must exist`);
|
||||
assert.ok(end > start, `${nextName} must follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("stock source list exposes explicit original-style multi-selection controls", () => {
|
||||
const moduleOffset = index.indexOf('src="legacy-batch-selection-workflow.js"');
|
||||
const appOffset = index.indexOf('src="app.js"');
|
||||
assert.ok(moduleOffset >= 0 && moduleOffset < appOffset);
|
||||
for (const id of [
|
||||
"stockCutSelectionCount",
|
||||
"stockCutBatchAdd",
|
||||
"stockCutSelectionClear"
|
||||
]) {
|
||||
assert.match(index, new RegExp(`id="${id}"`, "u"));
|
||||
}
|
||||
assert.match(index, /aria-multiselectable="true"/u);
|
||||
assert.match(styles, /\.stock-cut-row\.selected/u);
|
||||
});
|
||||
|
||||
test("stock gestures use Ctrl Shift selection and materialize a complete batch before push", () => {
|
||||
assert.match(app, /MbnLegacyBatchSelectionWorkflow/u);
|
||||
assert.match(app, /applyPointerSelection\([\s\S]*ctrlKey[\s\S]*shiftKey/u);
|
||||
const batch = functionBody(app, "addStockCuts", "renderGlobalCandleOptions");
|
||||
const materialize = batch.indexOf("materializeAtomicBatch");
|
||||
const commit = batch.indexOf("state.playlist.push(...entries)");
|
||||
assert.ok(materialize >= 0 && commit > materialize);
|
||||
assert.match(batch, /isStockCutAllowed/u);
|
||||
});
|
||||
|
||||
test("fixed source section supports ordered all-or-nothing child addition", () => {
|
||||
const batch = functionBody(app, "addFixedActions", "renderFixedWorkflow");
|
||||
const materialize = batch.indexOf("materializeAtomicBatch");
|
||||
const commit = batch.indexOf("state.playlist.push(...entries)");
|
||||
assert.ok(materialize >= 0 && commit > materialize);
|
||||
assert.match(batch, /action\?\.available === false/u);
|
||||
assert.match(app, /legacy-fixed-batch-add/u);
|
||||
assert.match(app, /sectionActions/u);
|
||||
});
|
||||
82
tests/Web/legacy-batch-selection-workflow.test.cjs
Normal file
82
tests/Web/legacy-batch-selection-workflow.test.cjs
Normal file
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/legacy-batch-selection-workflow.js");
|
||||
|
||||
test("legacy pointer gestures preserve MainForm selection and anchor semantics", () => {
|
||||
let selection = workflow.createSelection(8);
|
||||
selection = workflow.applyPointerSelection(selection, 3);
|
||||
assert.deepEqual(selection.selectedIndices, [3]);
|
||||
assert.equal(selection.anchorIndex, 3);
|
||||
|
||||
selection = workflow.applyPointerSelection(selection, 5, { ctrlKey: true });
|
||||
assert.deepEqual(selection.selectedIndices, [3, 5]);
|
||||
assert.equal(selection.anchorIndex, 5);
|
||||
|
||||
selection = workflow.applyPointerSelection(selection, 2, { shiftKey: true });
|
||||
assert.deepEqual(selection.selectedIndices, [2, 3, 4, 5]);
|
||||
assert.equal(selection.anchorIndex, 2);
|
||||
|
||||
selection = workflow.applyPointerSelection(selection, 6, { ctrlKey: true, shiftKey: true });
|
||||
assert.deepEqual(selection.selectedIndices, [2, 3, 4, 5, 6]);
|
||||
assert.equal(selection.anchorIndex, 6);
|
||||
|
||||
selection = workflow.applyPointerSelection(selection, 4, { ctrlKey: true });
|
||||
assert.deepEqual(selection.selectedIndices, [2, 3, 5, 6]);
|
||||
assert.equal(selection.anchorIndex, 4);
|
||||
});
|
||||
|
||||
test("shift without an existing anchor safely selects only the clicked row", () => {
|
||||
const selection = workflow.applyPointerSelection(
|
||||
workflow.createSelection(4),
|
||||
2,
|
||||
{ shiftKey: true });
|
||||
assert.deepEqual(selection.selectedIndices, [2]);
|
||||
assert.equal(selection.anchorIndex, 2);
|
||||
});
|
||||
|
||||
test("double-click activation is allowed only for the single clicked selection", () => {
|
||||
assert.equal(workflow.canActivateSingle(workflow.createSelection(5, [2], 2), 2), true);
|
||||
assert.equal(workflow.canActivateSingle(workflow.createSelection(5, [2], 2), 1), false);
|
||||
assert.equal(workflow.canActivateSingle(workflow.createSelection(5, [1, 2], 2), 2), false);
|
||||
assert.equal(workflow.canActivateSingle(null, 0), false);
|
||||
});
|
||||
|
||||
test("selection snapshots reject malformed or out-of-range state", () => {
|
||||
assert.throws(() => workflow.createSelection(-1), RangeError);
|
||||
assert.throws(() => workflow.createSelection(3, [3]), RangeError);
|
||||
assert.throws(() => workflow.createSelection(3, [0], 3), RangeError);
|
||||
assert.throws(() => workflow.applyPointerSelection(workflow.createSelection(3), -1), RangeError);
|
||||
});
|
||||
|
||||
test("atomic batch materialization preserves source order and commits only after success", () => {
|
||||
const source = [{ code: "A" }, { code: "B" }, { code: "C" }];
|
||||
const playlist = [{ id: "existing" }];
|
||||
const entries = workflow.materializeAtomicBatch(source, (item, index) => ({
|
||||
id: `batch-${index}`,
|
||||
code: item.code
|
||||
}));
|
||||
playlist.push(...entries);
|
||||
assert.deepEqual(playlist.map(item => item.code || item.id), ["existing", "A", "B", "C"]);
|
||||
|
||||
const unchanged = [...playlist];
|
||||
assert.throws(() => {
|
||||
const failed = workflow.materializeAtomicBatch(source, (item, index) => {
|
||||
if (index === 1) throw new Error("unsupported");
|
||||
return { id: `failed-${index}`, code: item.code };
|
||||
});
|
||||
playlist.push(...failed);
|
||||
}, /unsupported/u);
|
||||
assert.deepEqual(playlist, unchanged);
|
||||
});
|
||||
|
||||
test("atomic batches reject empty, malformed, unsafe, or duplicate entries", () => {
|
||||
assert.throws(() => workflow.materializeAtomicBatch([], () => ({})), RangeError);
|
||||
assert.throws(() => workflow.materializeAtomicBatch([1], null), TypeError);
|
||||
assert.throws(() => workflow.materializeAtomicBatch([1], () => null), TypeError);
|
||||
assert.throws(() => workflow.materializeAtomicBatch([1], () => ({ id: "unsafe id" })), TypeError);
|
||||
assert.throws(
|
||||
() => workflow.materializeAtomicBatch([1, 2], () => ({ id: "duplicate" })),
|
||||
TypeError);
|
||||
});
|
||||
223
tests/Web/legacy-foreign-index-candle-workflow.test.cjs
Normal file
223
tests/Web/legacy-foreign-index-candle-workflow.test.cjs
Normal file
@@ -0,0 +1,223 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/legacy-foreign-index-candle-workflow.js");
|
||||
|
||||
const profiles = [
|
||||
["다우존스", "Dow", "DJI@DJI"],
|
||||
["나스닥", "Nasdaq", "NAS@IXIC"],
|
||||
["S&P500", "Sp500", "SPI@SPX"]
|
||||
];
|
||||
const periods = [
|
||||
["5일", 5, "8061"],
|
||||
["20일", 20, "8040"],
|
||||
["60일", 60, "8046"],
|
||||
["120일", 120, "8051"],
|
||||
["240일", 240, "8056"]
|
||||
];
|
||||
|
||||
function raw(subject = "다우존스", subtype = "5일", itemIndex = 0, enabled = true) {
|
||||
return {
|
||||
itemIndex,
|
||||
enabled,
|
||||
groupCode: "해외지수",
|
||||
subject,
|
||||
graphicType: "캔들그래프",
|
||||
subtype,
|
||||
pageText: "1/1",
|
||||
dataCode: ""
|
||||
};
|
||||
}
|
||||
|
||||
function outcome(pending) {
|
||||
return {
|
||||
itemIndex: pending.itemIndex,
|
||||
status: "resolved",
|
||||
targetKey: pending.targetKey,
|
||||
inputName: pending.inputName,
|
||||
symbol: pending.symbol,
|
||||
nationCode: "US",
|
||||
foreignDomesticCode: "0",
|
||||
sceneCode: "s8010",
|
||||
valueType: "PRICE",
|
||||
periodDays: pending.periodDays,
|
||||
cutCode: pending.cutCode
|
||||
};
|
||||
}
|
||||
|
||||
function response(requestId, pendingRows, rows = pendingRows.map(outcome)) {
|
||||
return {
|
||||
requestId,
|
||||
retrievedAt: "2026-07-12T12:34:56+09:00",
|
||||
totalRowCount: rows.length,
|
||||
rows
|
||||
};
|
||||
}
|
||||
|
||||
test("all three fixed targets and five periods classify including inactive 240-day contract", () => {
|
||||
let itemIndex = 0;
|
||||
for (const [subject, targetKey, symbol] of profiles) {
|
||||
for (const [legacyPeriod, periodDays, cutCode] of periods) {
|
||||
const pending = workflow.classify(raw(subject, legacyPeriod, itemIndex), {
|
||||
id: `foreign_${itemIndex}`,
|
||||
fadeDuration: 6
|
||||
});
|
||||
assert.ok(pending);
|
||||
assert.equal(pending.targetKey, targetKey);
|
||||
assert.equal(pending.symbol, symbol);
|
||||
assert.equal(pending.periodDays, periodDays);
|
||||
assert.equal(pending.cutCode, cutCode);
|
||||
assert.equal(workflow.isPending(pending), true);
|
||||
itemIndex += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(itemIndex, 15);
|
||||
});
|
||||
|
||||
test("raw classifier is exact and preserves disabled state", () => {
|
||||
const disabled = workflow.classify(raw("다우존스", "240일", 9, false), {
|
||||
id: "foreign_disabled",
|
||||
fadeDuration: 0
|
||||
});
|
||||
assert.ok(disabled);
|
||||
assert.equal(disabled.enabled, false);
|
||||
for (const invalid of [
|
||||
{ ...raw(), groupCode: "FOREIGN_INDEX" },
|
||||
{ ...raw(), subject: "다우" },
|
||||
{ ...raw(), graphicType: "캔들 그래프" },
|
||||
{ ...raw(), subtype: "1년" },
|
||||
{ ...raw(), pageText: "1/2" },
|
||||
{ ...raw(), dataCode: "DJI@DJI|US|0" },
|
||||
{ ...raw(), itemIndex: 1000 }
|
||||
]) {
|
||||
assert.equal(workflow.classify(invalid, { id: "foreign_bad", fadeDuration: 6 }), null);
|
||||
}
|
||||
assert.equal(workflow.classify(raw(), { id: "foreign_bad", fadeDuration: 6, extra: true }), null);
|
||||
});
|
||||
|
||||
test("only an exact correlated native result materializes s8010 PRICE identity", () => {
|
||||
const pending = workflow.classify(raw("S&P500", "120일", 7, false), {
|
||||
id: "foreign_materialized",
|
||||
fadeDuration: 4
|
||||
});
|
||||
const normalized = workflow.normalizeResponse(
|
||||
response("load_1", [pending]),
|
||||
"load_1",
|
||||
[pending]);
|
||||
assert.ok(normalized);
|
||||
const entry = workflow.materialize(pending, normalized.rows[0]);
|
||||
assert.ok(entry);
|
||||
assert.equal(workflow.isMaterializedEntry(entry), true);
|
||||
assert.equal(entry.builderKey, "s8010");
|
||||
assert.equal(entry.code, "8051");
|
||||
assert.equal(entry.enabled, false);
|
||||
assert.deepEqual(entry.selection, {
|
||||
groupCode: "FOREIGN_INDEX",
|
||||
subject: "S&P500",
|
||||
graphicType: "",
|
||||
subtype: "PRICE",
|
||||
dataCode: "SPI@SPX|US|0"
|
||||
});
|
||||
assert.equal(entry.operator.source, "legacy-foreign-index-candle-workflow");
|
||||
});
|
||||
|
||||
test("identity, order, count, correlation and result-shape tampering fail closed", () => {
|
||||
const first = workflow.classify(raw("다우존스", "5일", 2), {
|
||||
id: "foreign_first", fadeDuration: 6
|
||||
});
|
||||
const second = workflow.classify(raw("나스닥", "20일", 8), {
|
||||
id: "foreign_second", fadeDuration: 6
|
||||
});
|
||||
const pending = [first, second];
|
||||
const exact = response("load_2", pending);
|
||||
assert.ok(workflow.normalizeResponse(exact, "load_2", pending));
|
||||
assert.equal(workflow.normalizeResponse(exact, "stale_load", pending), null);
|
||||
assert.equal(workflow.normalizeResponse({ ...exact, totalRowCount: 1 }, "load_2", pending), null);
|
||||
assert.equal(workflow.normalizeResponse({ ...exact, rows: [...exact.rows].reverse() }, "load_2", pending), null);
|
||||
assert.equal(workflow.normalizeResponse({ ...exact, extra: true }, "load_2", pending), null);
|
||||
|
||||
for (const [key, value] of [
|
||||
["targetKey", "Nasdaq"],
|
||||
["inputName", "나스닥"],
|
||||
["symbol", "NAS@IXIC"],
|
||||
["nationCode", "KR"],
|
||||
["foreignDomesticCode", "1"],
|
||||
["sceneCode", "s5001"],
|
||||
["valueType", "VOLUME"],
|
||||
["periodDays", 20],
|
||||
["cutCode", "8040"]
|
||||
]) {
|
||||
const rows = [{ ...exact.rows[0], [key]: value }, exact.rows[1]];
|
||||
assert.equal(workflow.normalizeResponse({ ...exact, rows }, "load_2", pending), null, key);
|
||||
}
|
||||
});
|
||||
|
||||
test("per-row native failures remain terminal and cannot materialize", () => {
|
||||
const pending = workflow.classify(raw(), { id: "foreign_failed", fadeDuration: 6 });
|
||||
for (const failure of [
|
||||
"INVALID_ROW", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS",
|
||||
"DATABASE_DATA_INVALID", "RESTORE_FAILED"
|
||||
]) {
|
||||
const normalized = workflow.normalizeResponse(response("load_3", [pending], [{
|
||||
itemIndex: pending.itemIndex,
|
||||
status: "failed",
|
||||
failure
|
||||
}]), "load_3", [pending]);
|
||||
assert.ok(normalized);
|
||||
assert.equal(workflow.materialize(pending, normalized.rows[0]), null);
|
||||
}
|
||||
assert.equal(workflow.normalizeResponse(response("load_3", [pending], [{
|
||||
itemIndex: pending.itemIndex,
|
||||
status: "failed",
|
||||
failure: "RETRY"
|
||||
}]), "load_3", [pending]), null);
|
||||
});
|
||||
|
||||
test("forged pending and forged resolved values never materialize", () => {
|
||||
const pending = workflow.classify(raw(), { id: "foreign_trusted", fadeDuration: 6 });
|
||||
const normalized = workflow.normalizeResponse(response("load_4", [pending]), "load_4", [pending]);
|
||||
assert.equal(workflow.materialize({ ...pending }, normalized.rows[0]), null);
|
||||
assert.equal(workflow.materialize(pending, { ...normalized.rows[0] }), null);
|
||||
});
|
||||
|
||||
test("legacy signature round-trips only the exact verified entry", () => {
|
||||
const pending = workflow.classify(raw("나스닥", "240일", 3, false), {
|
||||
id: "foreign_round_trip", fadeDuration: 5
|
||||
});
|
||||
const normalized = workflow.normalizeResponse(response("load_5", [pending]), "load_5", [pending]);
|
||||
const entry = workflow.materialize(pending, normalized.rows[0]);
|
||||
const restored = workflow.restorePlaylistEntry(entry);
|
||||
assert.ok(restored);
|
||||
assert.deepEqual(workflow.legacySelectionForEntry(restored), {
|
||||
groupCode: "해외지수",
|
||||
subject: "나스닥",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "240일",
|
||||
dataCode: ""
|
||||
});
|
||||
assert.equal(workflow.matchesRaw(restored, raw("나스닥", "240일", 3, false)), true);
|
||||
assert.equal(workflow.matchesRaw(restored, raw("나스닥", "120일", 3, false)), false);
|
||||
for (const tampered of [
|
||||
{ ...entry, code: "8040" },
|
||||
{ ...entry, selection: { ...entry.selection, subject: "다우존스" } },
|
||||
{ ...entry, operator: { ...entry.operator, symbol: "DJI@DJI" } },
|
||||
{ ...entry, operator: { ...entry.operator, periodDays: 120 } },
|
||||
{ ...entry, extra: true }
|
||||
]) {
|
||||
assert.equal(workflow.restorePlaylistEntry(tampered), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("bridge errors are request-bound and never retryable", () => {
|
||||
const error = {
|
||||
requestId: "load_error",
|
||||
code: "PLAYOUT_PRIORITY",
|
||||
message: "송출 우선 처리로 취소했습니다.",
|
||||
retryable: false
|
||||
};
|
||||
assert.deepEqual(workflow.normalizeError(error, "load_error"), error);
|
||||
assert.equal(workflow.normalizeError({ ...error, retryable: true }, "load_error"), null);
|
||||
assert.equal(workflow.normalizeError(error, "stale"), null);
|
||||
assert.equal(workflow.normalizeError({ ...error, code: "RETRY" }, "load_error"), null);
|
||||
});
|
||||
446
tests/Web/legacy-named-row-workflow.test.cjs
Normal file
446
tests/Web/legacy-named-row-workflow.test.cjs
Normal file
@@ -0,0 +1,446 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const legacyRows = require("../../Web/legacy-named-row-workflow.js");
|
||||
const namedPlaylists = require("../../Web/named-playlist-workflow.js");
|
||||
const stocks = require("../../Web/operator-workflow.js");
|
||||
const fixed = require("../../Web/legacy-fixed-workflow.js");
|
||||
const themes = require("../../Web/theme-workflow.js");
|
||||
const experts = require("../../Web/expert-workflow.js");
|
||||
const comparisons = require("../../Web/comparison-workflow.js");
|
||||
const industries = require("../../Web/industry-workflow.js");
|
||||
|
||||
function rawRow(overrides = {}) {
|
||||
return {
|
||||
itemIndex: 0,
|
||||
enabled: true,
|
||||
groupCode: "코스피",
|
||||
subject: "삼성전자",
|
||||
graphicType: "1열판기본",
|
||||
subtype: "현재가",
|
||||
pageText: "1/1",
|
||||
dataCode: "005930",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function legacySelection(value) {
|
||||
const { groupCode, subject, graphicType, subtype, dataCode } = value;
|
||||
return { groupCode, subject, graphicType, subtype, dataCode };
|
||||
}
|
||||
|
||||
test("the original MainForm Korean stock row maps to exactly one canonical stock cut", () => {
|
||||
const entry = stocks.createStockPlaylistEntry({
|
||||
market: "kospi",
|
||||
stockName: "삼성전자",
|
||||
displayName: "삼성전자",
|
||||
stockCode: "005930",
|
||||
isNxt: false
|
||||
}, "basic-current", { id: "stock-1", fadeDuration: 6 });
|
||||
const raw = rawRow();
|
||||
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
|
||||
assert.deepEqual(legacyRows.legacySelectionForEntry(entry), legacySelection(raw));
|
||||
assert.equal(legacyRows.selectUniqueCanonicalEntry([entry], raw), entry);
|
||||
for (const [field, value] of [
|
||||
["groupCode", "코스닥"],
|
||||
["subject", "삼성전자우"],
|
||||
["graphicType", "1열판상세"],
|
||||
["subtype", "예상체결가"],
|
||||
["dataCode", "000660"]
|
||||
]) {
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, { ...raw, [field]: value }), false, field);
|
||||
}
|
||||
});
|
||||
|
||||
test("all 322 closed fixed actions reproduce their original UC1 Korean row signature", () => {
|
||||
const available = fixed.actions.filter(action => action.available);
|
||||
assert.equal(available.length, 322);
|
||||
const signatures = new Set();
|
||||
|
||||
for (const action of available) {
|
||||
const signature = legacyRows.fixedLegacySignature(action);
|
||||
assert.ok(signature, action.id);
|
||||
assert.equal(legacyRows.matchesFixedActionRaw(signature, action), true, action.id);
|
||||
const entry = fixed.createFixedPlaylistEntry(action.id, {
|
||||
id: `entry-${action.id}`,
|
||||
fadeDuration: 6,
|
||||
now: new Date(2026, 6, 12, 9, 0, 0)
|
||||
});
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, signature), true, action.id);
|
||||
assert.deepEqual(legacyRows.legacySelectionForEntry(entry), signature, action.id);
|
||||
signatures.add(JSON.stringify(signature));
|
||||
}
|
||||
|
||||
assert.equal(signatures.size, 322, "legacy fixed rows must remain unambiguous");
|
||||
const dow = fixed.actions.find(action => action.id === "fixed-001");
|
||||
assert.deepEqual(legacyRows.fixedLegacySignature(dow), {
|
||||
groupCode: "해외지수",
|
||||
subject: "다우",
|
||||
graphicType: "1열판기본",
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
});
|
||||
assert.equal(legacyRows.matchesFixedActionRaw({
|
||||
...legacyRows.fixedLegacySignature(dow), subtype: "현재가"
|
||||
}, dow), false);
|
||||
});
|
||||
|
||||
test("the original UC4 theme rows map only through the closed action and sort grammar", () => {
|
||||
const raw = rawRow({
|
||||
groupCode: "테마",
|
||||
subject: "반도체",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "테마-현재가(상승률순)",
|
||||
pageText: "1/4",
|
||||
dataCode: "0123"
|
||||
});
|
||||
const descriptor = legacyRows.themeLegacyDescriptor(raw);
|
||||
assert.deepEqual(descriptor, {
|
||||
actionId: "five-row-current",
|
||||
sort: "GAIN_DESC",
|
||||
theme: {
|
||||
market: "krx",
|
||||
themeCode: "0123",
|
||||
title: "반도체",
|
||||
session: null,
|
||||
itemCount: 1
|
||||
}
|
||||
});
|
||||
const entry = themes.createThemePlaylistEntry(
|
||||
descriptor.actionId,
|
||||
descriptor.theme,
|
||||
descriptor.sort,
|
||||
{ id: "theme-1", fadeDuration: 6 });
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
|
||||
assert.deepEqual(legacyRows.legacySelectionForEntry(entry), legacySelection(raw));
|
||||
const originalFallback = { ...raw, subtype: "테마-현재가" };
|
||||
const fallbackDescriptor = legacyRows.themeLegacyDescriptor(originalFallback);
|
||||
assert.equal(fallbackDescriptor.sort, "GAIN_ASC");
|
||||
const fallbackEntry = themes.createThemePlaylistEntry(
|
||||
fallbackDescriptor.actionId,
|
||||
fallbackDescriptor.theme,
|
||||
fallbackDescriptor.sort,
|
||||
{ id: "theme-fallback", fadeDuration: 6 });
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(fallbackEntry, originalFallback), true);
|
||||
assert.equal(
|
||||
legacyRows.legacySelectionForEntry(fallbackEntry).subtype,
|
||||
"테마-현재가(하락률순)",
|
||||
"new saves make the original implicit fallback explicit");
|
||||
assert.equal(legacyRows.themeLegacyDescriptor({
|
||||
...raw, subtype: "테마-현재가(상승률)"
|
||||
}), null);
|
||||
const nxtRaw = {
|
||||
...raw,
|
||||
groupCode: "테마_NXT",
|
||||
subject: "반도체(NXT)",
|
||||
subtype: "테마-현재가(입력순)"
|
||||
};
|
||||
const morning = legacyRows.themeLegacyDescriptor(nxtRaw, {
|
||||
now: new Date(2026, 6, 12, 12, 59, 59)
|
||||
});
|
||||
const afternoon = legacyRows.themeLegacyDescriptor(nxtRaw, {
|
||||
now: new Date(2026, 6, 12, 13, 0, 0)
|
||||
});
|
||||
assert.equal(morning.theme.session, "PRE_MARKET");
|
||||
assert.equal(afternoon.theme.session, "AFTER_MARKET");
|
||||
const nxtEntry = themes.createThemePlaylistEntry(
|
||||
morning.actionId,
|
||||
morning.theme,
|
||||
morning.sort,
|
||||
{ id: "theme-nxt-legacy", fadeDuration: 6 });
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(nxtEntry, nxtRaw, {
|
||||
now: new Date(2026, 6, 12, 12, 30, 0)
|
||||
}), true);
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(nxtEntry, nxtRaw, {
|
||||
now: new Date(2026, 6, 12, 13, 0, 0)
|
||||
}), false);
|
||||
assert.equal(legacyRows.legacySelectionForEntry(nxtEntry), null,
|
||||
"new named saves remain blocked because the old row does not persist a session");
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, { ...raw, dataCode: "87654321" }), false);
|
||||
});
|
||||
|
||||
test("the original UC6 expert row maps to the canonical paged expert entry", () => {
|
||||
const raw = rawRow({
|
||||
groupCode: "전문가 추천",
|
||||
subject: "김전문",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "현재가",
|
||||
pageText: "1/2",
|
||||
dataCode: "0123"
|
||||
});
|
||||
const descriptor = legacyRows.expertLegacyDescriptor(raw);
|
||||
assert.deepEqual(descriptor, {
|
||||
expertCode: "0123",
|
||||
expertName: "김전문",
|
||||
source: "oracle",
|
||||
itemCount: 1,
|
||||
previewTruncated: false
|
||||
});
|
||||
const entry = experts.createExpertPlaylistEntry(descriptor, {
|
||||
id: "expert-1",
|
||||
fadeDuration: 6
|
||||
});
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
|
||||
assert.deepEqual(legacyRows.legacySelectionForEntry(entry), legacySelection(raw));
|
||||
assert.equal(legacyRows.expertLegacyDescriptor({ ...raw, dataCode: "123" }), null);
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, { ...raw, subject: "다른전문가" }), false);
|
||||
});
|
||||
|
||||
test("comparison rows project only identities that the original seven fields preserve", () => {
|
||||
const fixedEntry = comparisons.createComparisonPlaylistEntry("two-column-current", [
|
||||
comparisons.getMarketTarget("Kospi"),
|
||||
comparisons.getMarketTarget("Kosdaq")
|
||||
], { id: "comparison-fixed", fadeDuration: 6 });
|
||||
const fixedRaw = rawRow({
|
||||
groupCode: "지수",
|
||||
subject: "코스피 지수,코스닥 지수",
|
||||
graphicType: "2열판",
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
});
|
||||
assert.deepEqual(legacyRows.comparisonLegacySignature(fixedEntry.operator), legacySelection(fixedRaw));
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(fixedEntry, fixedRaw), true);
|
||||
assert.deepEqual(legacyRows.legacySelectionForEntry(fixedEntry), legacySelection(fixedRaw));
|
||||
const exchangeOnly = comparisons.createComparisonPlaylistEntry("two-column-current", [
|
||||
comparisons.getMarketTarget("WonDollar"),
|
||||
comparisons.getMarketTarget("WonYen")
|
||||
], { id: "comparison-exchange-lossy", fadeDuration: 6 });
|
||||
assert.equal(legacyRows.legacySelectionForEntry(exchangeOnly), null);
|
||||
|
||||
const pair = [
|
||||
{ kind: "krx-stock", market: "kospi", stockName: "삼성전자", stockCode: "005930" },
|
||||
{ kind: "krx-stock", market: "kosdaq", stockName: "에이비엘바이오", stockCode: "298380" }
|
||||
];
|
||||
const candle = comparisons.createComparisonPlaylistEntry(
|
||||
"comparison-candle", pair, { id: "comparison-candle", fadeDuration: 6 });
|
||||
const candleRaw = rawRow({
|
||||
groupCode: "코스피,코스닥",
|
||||
subject: "삼성전자,에이비엘바이오",
|
||||
graphicType: "종목별 비교분석_캔들 그래프",
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
});
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(candle, candleRaw), true);
|
||||
assert.deepEqual(legacyRows.legacySelectionForEntry(candle), legacySelection(candleRaw));
|
||||
|
||||
const oneMonth = comparisons.createComparisonPlaylistEntry(
|
||||
"return-1m", pair, { id: "comparison-return", fadeDuration: 6 });
|
||||
assert.deepEqual(legacyRows.legacySelectionForEntry(oneMonth), {
|
||||
groupCode: "코스피,코스닥",
|
||||
subject: "삼성전자,에이비엘바이오",
|
||||
graphicType: "종목별 수익률 비교",
|
||||
subtype: "1개월",
|
||||
dataCode: ""
|
||||
});
|
||||
|
||||
const lossyStockPlate = comparisons.createComparisonPlaylistEntry(
|
||||
"two-column-current", pair, { id: "comparison-lossy", fadeDuration: 6 });
|
||||
assert.equal(legacyRows.legacySelectionForEntry(lossyStockPlate), null);
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(lossyStockPlate, {
|
||||
...fixedRaw,
|
||||
groupCode: "종목",
|
||||
subject: "삼성전자,에이비엘바이오"
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("identity-free industry market rows map only to the three closed fixed actions", () => {
|
||||
const now = new Date(2026, 6, 12, 9, 0, 0);
|
||||
const cases = [
|
||||
["kospi", "five-row", "업종_코스피", "코스피", "5단 표그래프"],
|
||||
["kospi", "square", "업종_코스피", "코스피", "네모그래프"],
|
||||
["kospi", "sector", "업종_코스피", "코스피", "섹터지수"],
|
||||
["kosdaq", "five-row", "업종_코스닥", "코스닥", "5단 표그래프"],
|
||||
["kosdaq", "square", "업종_코스닥", "코스닥", "네모그래프"],
|
||||
["kosdaq", "sector", "업종_코스닥", "코스닥", "섹터지수"]
|
||||
];
|
||||
for (let index = 0; index < cases.length; index += 1) {
|
||||
const [market, cutId, groupCode, subject, graphicType] = cases[index];
|
||||
const raw = rawRow({ groupCode, subject, graphicType, subtype: "", dataCode: "" });
|
||||
const descriptor = legacyRows.industryFixedDescriptor(raw);
|
||||
assert.deepEqual(descriptor, { market, cutId });
|
||||
const entry = industries.createIndustryPlaylistEntry(market, cutId, {
|
||||
id: `industry-fixed-${index}`,
|
||||
fadeDuration: 6,
|
||||
now
|
||||
});
|
||||
assert.deepEqual(legacyRows.industryFixedLegacySignature(entry.operator), legacySelection(raw));
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
|
||||
assert.deepEqual(legacyRows.legacySelectionForEntry(entry), legacySelection(raw));
|
||||
if (cutId === "five-row") {
|
||||
assert.equal(entry.selection.dataCode, "2026-07-12");
|
||||
assert.equal(entry.builderKey, "s5074");
|
||||
}
|
||||
}
|
||||
|
||||
const selected = { market: "kospi", name: "전기전자", code: "013" };
|
||||
const individual = industries.createIndustryPlaylistEntry("kospi", "one-column", {
|
||||
id: "industry-identity-required",
|
||||
selected
|
||||
});
|
||||
assert.equal(legacyRows.industryFixedLegacySignature(individual.operator), null);
|
||||
assert.equal(legacyRows.industryFixedDescriptor({
|
||||
...rawRow(), groupCode: "업종_코스피", subject: "전기전자",
|
||||
graphicType: "1열판", subtype: "", dataCode: ""
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("named restoration accepts a closed legacy mapping but rejects it by default or after tamper", () => {
|
||||
const raw = rawRow();
|
||||
const load = namedPlaylists.normalizeLoadResponse({
|
||||
requestId: "load-legacy-1",
|
||||
definition: { programCode: "00000001", title: "Legacy" },
|
||||
retrievedAt: "2026-07-12T09:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
rows: [raw]
|
||||
});
|
||||
const entry = stocks.createStockPlaylistEntry({
|
||||
market: "kospi",
|
||||
stockName: "삼성전자",
|
||||
displayName: "삼성전자",
|
||||
stockCode: "005930",
|
||||
isNxt: false
|
||||
}, "basic-current", { id: "stock-restore-1", fadeDuration: 6 });
|
||||
|
||||
assert.equal(namedPlaylists.restoreRawRows(load, () => entry).rows[0].trusted, false);
|
||||
assert.equal(namedPlaylists.restoreRawRows(
|
||||
load, () => entry, legacyRows.matchesCanonicalEntry).rows[0].trusted, true);
|
||||
|
||||
const tamperedLoad = namedPlaylists.normalizeLoadResponse({
|
||||
...{
|
||||
requestId: "load-legacy-2",
|
||||
definition: { programCode: "00000001", title: "Legacy" },
|
||||
retrievedAt: "2026-07-12T09:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
canMutate: true,
|
||||
writeQuarantined: false
|
||||
},
|
||||
rows: [{ ...raw, dataCode: "000660" }]
|
||||
});
|
||||
assert.equal(namedPlaylists.restoreRawRows(
|
||||
tamperedLoad, () => entry, legacyRows.matchesCanonicalEntry).rows[0].trusted, false);
|
||||
assert.throws(() => namedPlaylists.restoreRawRows(load, () => entry, true), /resolver/i);
|
||||
});
|
||||
|
||||
test("canonical rows that identify multiple fixed actions remain blocked as ambiguous", () => {
|
||||
const first = fixed.createFixedPlaylistEntry("fixed-136", {
|
||||
id: "candle-1",
|
||||
fadeDuration: 6,
|
||||
now: new Date(2026, 6, 12, 9, 0, 0)
|
||||
});
|
||||
const second = fixed.createFixedPlaylistEntry("fixed-137", {
|
||||
id: "candle-2",
|
||||
fadeDuration: 6,
|
||||
now: new Date(2026, 6, 12, 9, 0, 0)
|
||||
});
|
||||
assert.deepEqual(first.selection, second.selection);
|
||||
const raw = legacySelection(first.selection);
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(first, raw), true);
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(second, raw), true);
|
||||
assert.equal(legacyRows.selectUniqueCanonicalEntry([first, second], raw), null);
|
||||
});
|
||||
|
||||
test("save projection is closed and refuses unknown or lossy NXT theme entries", () => {
|
||||
assert.equal(legacyRows.legacySelectionForEntry({
|
||||
selection: {
|
||||
groupCode: "FREE", subject: "text", graphicType: "type", subtype: "sub", dataCode: "code"
|
||||
},
|
||||
operator: { source: "unknown" }
|
||||
}), null);
|
||||
|
||||
const fixedEntry = fixed.createFixedPlaylistEntry("fixed-001", {
|
||||
id: "tampered-fixed-save",
|
||||
fadeDuration: 6,
|
||||
now: new Date(2026, 6, 12, 9, 0, 0)
|
||||
});
|
||||
assert.equal(legacyRows.legacySelectionForEntry({
|
||||
...fixedEntry,
|
||||
operator: { ...fixedEntry.operator, actionId: "fixed-002" }
|
||||
}), null);
|
||||
|
||||
const nxt = themes.createThemePlaylistEntry("five-row-current", {
|
||||
market: "nxt",
|
||||
themeCode: "0123",
|
||||
title: "NXT 테마",
|
||||
session: "PRE_MARKET",
|
||||
itemCount: 5
|
||||
}, "INPUT_ORDER", { id: "nxt-theme-save", fadeDuration: 6 });
|
||||
assert.equal(legacyRows.legacySelectionForEntry(nxt), null);
|
||||
assert.equal(legacyRows.legacySelectionForEntry({
|
||||
...nxt,
|
||||
selection: { ...nxt.selection, subject: "bad^value" }
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("named replace serializes stock, fixed, theme and expert as original Korean rows", () => {
|
||||
const entries = [
|
||||
stocks.createStockPlaylistEntry({
|
||||
market: "kospi",
|
||||
stockName: "삼성전자",
|
||||
displayName: "삼성전자",
|
||||
stockCode: "005930",
|
||||
isNxt: false
|
||||
}, "basic-current", { id: "save-stock", fadeDuration: 6 }),
|
||||
fixed.createFixedPlaylistEntry("fixed-001", {
|
||||
id: "save-fixed",
|
||||
fadeDuration: 6,
|
||||
now: new Date(2026, 6, 12, 9, 0, 0)
|
||||
}),
|
||||
themes.createThemePlaylistEntry("five-row-current", {
|
||||
market: "krx",
|
||||
themeCode: "0123",
|
||||
title: "반도체",
|
||||
session: null,
|
||||
itemCount: 5
|
||||
}, "GAIN_DESC", { id: "save-theme", fadeDuration: 6 }),
|
||||
experts.createExpertPlaylistEntry({
|
||||
expertCode: "0123",
|
||||
expertName: "김전문",
|
||||
source: "oracle",
|
||||
itemCount: 5,
|
||||
previewTruncated: false
|
||||
}, { id: "save-expert", fadeDuration: 6 })
|
||||
];
|
||||
const request = namedPlaylists.createReplaceRequest(
|
||||
"replace-legacy-projection",
|
||||
"00000001",
|
||||
entries,
|
||||
{
|
||||
isTrustedEntry: entry => legacyRows.legacySelectionForEntry(entry) !== null,
|
||||
fieldsForEntry(entry, _index, pageText) {
|
||||
const selection = legacyRows.legacySelectionForEntry(entry);
|
||||
return [
|
||||
entry.enabled ? "1" : "0",
|
||||
selection.groupCode,
|
||||
selection.subject,
|
||||
selection.graphicType,
|
||||
selection.subtype,
|
||||
pageText,
|
||||
selection.dataCode
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(request.items, [
|
||||
{
|
||||
itemIndex: 0, enabled: true, groupCode: "코스피", subject: "삼성전자",
|
||||
graphicType: "1열판기본", subtype: "현재가", pageText: "1/1", dataCode: "005930"
|
||||
},
|
||||
{
|
||||
itemIndex: 1, enabled: true, groupCode: "해외지수", subject: "다우",
|
||||
graphicType: "1열판기본", subtype: "", pageText: "1/1", dataCode: ""
|
||||
},
|
||||
{
|
||||
itemIndex: 2, enabled: true, groupCode: "테마", subject: "반도체",
|
||||
graphicType: "5단 표그래프", subtype: "테마-현재가(상승률순)",
|
||||
pageText: "1/1", dataCode: "0123"
|
||||
},
|
||||
{
|
||||
itemIndex: 3, enabled: true, groupCode: "전문가 추천", subject: "김전문",
|
||||
graphicType: "5단 표그래프", subtype: "현재가", pageText: "1/1", dataCode: "0123"
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -57,7 +57,7 @@ function records() {
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
function createHarness(options = {}) {
|
||||
const posted = [];
|
||||
const entries = [];
|
||||
const toasts = [];
|
||||
@@ -72,7 +72,7 @@ function createHarness() {
|
||||
return true;
|
||||
},
|
||||
isLocked() {
|
||||
return false;
|
||||
return typeof options.isLocked === "function" ? options.isLocked() : false;
|
||||
},
|
||||
getSelectedStock() {
|
||||
return { market: "kospi", source: "oracle", name: "Alpha", code: "000001" };
|
||||
@@ -118,6 +118,35 @@ test("all four GraphE records round-trip through pure form conversion", () => {
|
||||
assert.equal(ui.resolveScreen({ screen: "growth-metrics" }), "growth-metrics");
|
||||
});
|
||||
|
||||
test("GraphE display sorting is deterministic for ASCII and Korean with stable ties", () => {
|
||||
const rows = [
|
||||
{ stockName: "나다", id: 1 },
|
||||
{ stockName: "beta", id: 2 },
|
||||
{ stockName: "가나", id: 3 },
|
||||
{ stockName: "Alpha", id: 4 }
|
||||
];
|
||||
assert.deepEqual(
|
||||
ui.sortDisplaySnapshots(rows, "ascending").map(row => row.stockName),
|
||||
["Alpha", "beta", "가나", "나다"]);
|
||||
assert.deepEqual(
|
||||
ui.sortDisplaySnapshots(rows, "descending").map(row => row.stockName),
|
||||
["나다", "가나", "beta", "Alpha"]);
|
||||
assert.deepEqual(rows.map(row => row.id), [1, 2, 3, 4]);
|
||||
|
||||
const ties = [
|
||||
{ stockName: "동일", id: "first" },
|
||||
{ stockName: "동일", id: "second" }
|
||||
];
|
||||
assert.deepEqual(
|
||||
ui.sortDisplaySnapshots(ties, "descending").map(row => row.id),
|
||||
["first", "second"]);
|
||||
assert.deepEqual(ui.sortDisplaySnapshots([], "ascending"), []);
|
||||
assert.deepEqual(ui.sortDisplaySnapshots([{ stockName: "Only" }], "descending"), [
|
||||
{ stockName: "Only" }
|
||||
]);
|
||||
assert.throws(() => ui.sortDisplaySnapshots(rows, "sideways"), /sort direction/i);
|
||||
});
|
||||
|
||||
test("controller accepts only the pending list correlation and ignores shared stock traffic", () => {
|
||||
const harness = createHarness();
|
||||
assert.equal(harness.controller.open({ id: "manual-sales" }), true);
|
||||
@@ -226,6 +255,118 @@ function findByClass(node, className) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findAllByClass(node, className, found = []) {
|
||||
if (String(node.className || "").split(/\s+/).includes(className)) found.push(node);
|
||||
for (const child of node.children || []) findAllByClass(child, className, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
function findByText(node, textContent) {
|
||||
if (node.textContent === textContent) return node;
|
||||
for (const child of node.children || []) {
|
||||
const found = findByText(child, textContent);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function keyboardEvent(key, options = {}) {
|
||||
return {
|
||||
key,
|
||||
isComposing: options.isComposing === true,
|
||||
defaultPrevented: false,
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function recordFor(screen, stockName) {
|
||||
return { ...JSON.parse(JSON.stringify(records()[screen])), stockName };
|
||||
}
|
||||
|
||||
function acceptList(harness, screen, stockNames, query = "") {
|
||||
const request = harness.posted.at(-1).payload;
|
||||
assert.equal(request.screen, screen);
|
||||
assert.equal(request.query, query);
|
||||
return harness.controller.handleMessage(workflow.bridgeContract.listResultType, {
|
||||
requestId: request.requestId,
|
||||
screen,
|
||||
query,
|
||||
retrievedAt: "2026-07-12T10:00:00+09:00",
|
||||
totalRowCount: stockNames.length,
|
||||
truncated: false,
|
||||
items: stockNames.map((stockName, index) => ({
|
||||
stockName,
|
||||
rowVersion: String.fromCharCode(65 + index).repeat(64),
|
||||
record: recordFor(screen, stockName)
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
function acceptLoad(harness, screen, stockName) {
|
||||
const request = harness.posted.at(-1).payload;
|
||||
assert.equal(request.screen, screen);
|
||||
assert.equal(request.stockName, stockName);
|
||||
const itemIndex = ["Alpha", "Alpine", "Beta", "Gamma"].indexOf(stockName);
|
||||
return harness.controller.handleMessage(workflow.bridgeContract.loadResultType, {
|
||||
requestId: request.requestId,
|
||||
screen,
|
||||
retrievedAt: "2026-07-12T10:01:00+09:00",
|
||||
snapshot: {
|
||||
stockName,
|
||||
rowVersion: String.fromCharCode(65 + Math.max(itemIndex, 0)).repeat(64),
|
||||
record: recordFor(screen, stockName)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function acceptExactStockSearch(harness, stockName = "Alpha") {
|
||||
const request = harness.posted.at(-1);
|
||||
assert.equal(request.type, "search-stocks");
|
||||
assert.equal(request.payload.query, stockName);
|
||||
return harness.controller.handleMessage(STOCK_RESULT, {
|
||||
requestId: request.payload.requestId,
|
||||
query: stockName,
|
||||
retrievedAt: "2026-07-12T10:02:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
results: [{ market: "kospi", source: "oracle", name: stockName, code: "000001" }]
|
||||
});
|
||||
}
|
||||
|
||||
function setFindQuery(overlay, value) {
|
||||
const query = findByClass(overlay, "mfui-search-input");
|
||||
query.value = value;
|
||||
query.listeners.get("input")();
|
||||
return query;
|
||||
}
|
||||
|
||||
function assertFindControlsDoNotPost(harness, overlay) {
|
||||
const before = harness.posted.length;
|
||||
findByClass(overlay, "mfui-find-previous").listeners.get("click")();
|
||||
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
||||
assert.equal(harness.posted.length, before);
|
||||
}
|
||||
|
||||
function displayedStockNames(overlay) {
|
||||
return findAllByClass(overlay, "mfui-list-row").map(row => row.textContent);
|
||||
}
|
||||
|
||||
function assertLocalSortOnly(harness, overlay) {
|
||||
const before = harness.controller.render();
|
||||
const postedCount = harness.posted.length;
|
||||
const playlistCount = harness.entries.length;
|
||||
findByClass(overlay, "mfui-list-sort").listeners.get("click")();
|
||||
const after = harness.controller.render();
|
||||
assert.equal(
|
||||
after.sortDirection,
|
||||
before.sortDirection === "ascending" ? "descending" : "ascending");
|
||||
assert.equal(after.selectedStockName, before.selectedStockName);
|
||||
assert.equal(harness.posted.length, postedCount);
|
||||
assert.equal(harness.entries.length, playlistCount);
|
||||
}
|
||||
|
||||
test("mount creates the isolated text-only dialog and exposes exactly the integration methods", () => {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
@@ -241,6 +382,387 @@ test("mount creates the isolated text-only dialog and exposes exactly the integr
|
||||
assert.equal(harness.posted[0].type, workflow.bridgeContract.listRequestType);
|
||||
});
|
||||
|
||||
test("GraphE stock-name header toggles descending first and keeps selected identity local", () => {
|
||||
const harness = createHarness();
|
||||
const overlay = harness.controller.mount(new FakeDocument().body);
|
||||
harness.controller.open("sales");
|
||||
acceptList(harness, "sales", ["Alpha", "beta", "가나", "나다"]);
|
||||
|
||||
const header = findByClass(overlay, "mfui-list-sort");
|
||||
assert.ok(header);
|
||||
assert.equal(header.textContent, "종목명 ↕");
|
||||
assert.equal(header.dataset.direction, "ascending");
|
||||
assert.equal(header.attributes.get("aria-pressed"), "false");
|
||||
assert.deepEqual(displayedStockNames(overlay), ["Alpha", "beta", "가나", "나다"]);
|
||||
|
||||
const beforeSort = harness.posted.length;
|
||||
header.listeners.get("click")();
|
||||
assert.equal(harness.controller.render().sortDirection, "descending");
|
||||
assert.deepEqual(displayedStockNames(overlay), ["나다", "가나", "beta", "Alpha"]);
|
||||
assert.equal(harness.posted.length, beforeSort);
|
||||
|
||||
findByText(overlay, "beta").listeners.get("click")();
|
||||
acceptLoad(harness, "sales", "beta");
|
||||
const beforeSelectedSort = harness.posted.length;
|
||||
header.listeners.get("click")();
|
||||
assert.equal(harness.controller.render().sortDirection, "ascending");
|
||||
assert.equal(harness.controller.render().selectedStockName, "beta");
|
||||
assert.equal(harness.posted.length, beforeSelectedSort);
|
||||
const selectedRows = findAllByClass(overlay, "mfui-list-row")
|
||||
.filter(row => row.attributes.get("aria-selected") === "true");
|
||||
assert.deepEqual(selectedRows.map(row => row.textContent), ["beta"]);
|
||||
});
|
||||
|
||||
test("GraphE previous and next find follow the current displayed sort order", () => {
|
||||
const harness = createHarness();
|
||||
const overlay = harness.controller.mount(new FakeDocument().body);
|
||||
harness.controller.open("operating-profit");
|
||||
acceptList(harness, "operating-profit", ["Alpha", "Beta", "Gamma"]);
|
||||
findByClass(overlay, "mfui-list-sort").listeners.get("click")();
|
||||
assert.deepEqual(displayedStockNames(overlay), ["Gamma", "Beta", "Alpha"]);
|
||||
setFindQuery(overlay, "a");
|
||||
|
||||
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Gamma");
|
||||
acceptLoad(harness, "operating-profit", "Gamma");
|
||||
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Beta");
|
||||
acceptLoad(harness, "operating-profit", "Beta");
|
||||
findByClass(overlay, "mfui-find-previous").listeners.get("click")();
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Gamma");
|
||||
});
|
||||
|
||||
test("GraphE list result and each mode open reset display sorting to ascending", () => {
|
||||
for (const screen of ["revenue-composition", "growth-metrics", "sales", "operating-profit"]) {
|
||||
const harness = createHarness();
|
||||
const overlay = harness.controller.mount(new FakeDocument().body);
|
||||
harness.controller.open(screen);
|
||||
acceptList(harness, screen, ["Alpha", "Beta"]);
|
||||
assertLocalSortOnly(harness, overlay);
|
||||
assert.equal(harness.controller.render().sortDirection, "descending", screen);
|
||||
|
||||
const searchForm = findByClass(overlay, "mfui-search");
|
||||
const query = setFindQuery(overlay, "Alpha");
|
||||
const submitEvent = keyboardEvent("Enter");
|
||||
searchForm.listeners.get("submit")(submitEvent);
|
||||
assert.equal(submitEvent.defaultPrevented, true);
|
||||
assert.equal(harness.controller.render().sortDirection, "descending", screen);
|
||||
acceptList(harness, screen, ["Alpha"], "Alpha");
|
||||
assert.equal(harness.controller.render().sortDirection, "ascending", screen);
|
||||
assert.deepEqual(displayedStockNames(overlay), ["Alpha"], screen);
|
||||
|
||||
findByClass(overlay, "mfui-list-sort").listeners.get("click")();
|
||||
assert.equal(harness.controller.render().sortDirection, "descending", screen);
|
||||
harness.controller.open(screen);
|
||||
assert.equal(harness.controller.render().sortDirection, "ascending", screen);
|
||||
assert.deepEqual(displayedStockNames(overlay), [], screen);
|
||||
assert.equal(query.value, "", screen);
|
||||
}
|
||||
});
|
||||
|
||||
test("GraphE empty and single-row lists still toggle locally without native traffic", () => {
|
||||
const emptyHarness = createHarness();
|
||||
const emptyOverlay = emptyHarness.controller.mount(new FakeDocument().body);
|
||||
emptyHarness.controller.open("revenue-composition");
|
||||
acceptList(emptyHarness, "revenue-composition", []);
|
||||
assertLocalSortOnly(emptyHarness, emptyOverlay);
|
||||
assert.deepEqual(displayedStockNames(emptyOverlay), []);
|
||||
|
||||
const singleHarness = createHarness();
|
||||
const singleOverlay = singleHarness.controller.mount(new FakeDocument().body);
|
||||
singleHarness.controller.open("growth-metrics");
|
||||
acceptList(singleHarness, "growth-metrics", ["Alpha"]);
|
||||
assertLocalSortOnly(singleHarness, singleOverlay);
|
||||
assert.deepEqual(displayedStockNames(singleOverlay), ["Alpha"]);
|
||||
});
|
||||
|
||||
test("GraphE display sorting stays local during list, load, stock, selection, and write pending", () => {
|
||||
const readHarness = createHarness();
|
||||
const readOverlay = readHarness.controller.mount(new FakeDocument().body);
|
||||
readHarness.controller.open("sales");
|
||||
assert.ok(readHarness.controller.render().pending.list);
|
||||
assertLocalSortOnly(readHarness, readOverlay);
|
||||
acceptList(readHarness, "sales", ["Alpha", "Beta"]);
|
||||
|
||||
setFindQuery(readOverlay, "a");
|
||||
findByClass(readOverlay, "mfui-find-next").listeners.get("click")();
|
||||
assert.ok(readHarness.controller.render().pending.load);
|
||||
assertLocalSortOnly(readHarness, readOverlay);
|
||||
acceptLoad(readHarness, "sales", "Alpha");
|
||||
|
||||
findByClass(readOverlay, "mfui-accent").listeners.get("click")();
|
||||
assert.ok(readHarness.controller.render().pending.stock);
|
||||
assertLocalSortOnly(readHarness, readOverlay);
|
||||
acceptExactStockSearch(readHarness);
|
||||
assert.ok(readHarness.controller.render().pending.selection);
|
||||
assertLocalSortOnly(readHarness, readOverlay);
|
||||
|
||||
const writeHarness = createHarness();
|
||||
const writeOverlay = writeHarness.controller.mount(new FakeDocument().body);
|
||||
writeHarness.controller.open("sales");
|
||||
acceptList(writeHarness, "sales", ["Alpha", "Beta"]);
|
||||
setFindQuery(writeOverlay, "Alpha");
|
||||
findByClass(writeOverlay, "mfui-find-next").listeners.get("click")();
|
||||
acceptLoad(writeHarness, "sales", "Alpha");
|
||||
findByText(writeOverlay, "수정 저장").listeners.get("click")();
|
||||
acceptExactStockSearch(writeHarness);
|
||||
assert.ok(writeHarness.controller.render().pending.write);
|
||||
assertLocalSortOnly(writeHarness, writeOverlay);
|
||||
});
|
||||
|
||||
test("loaded GraphE list Enter finds downward from the current row without wrapping", () => {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
const overlay = harness.controller.mount(document.body);
|
||||
harness.controller.open("sales");
|
||||
assert.equal(acceptList(harness, "sales", ["Alpha", "Alpine", "Beta", "Gamma"]), true);
|
||||
|
||||
const query = findByClass(overlay, "mfui-search-input");
|
||||
query.value = " al ";
|
||||
query.listeners.get("input")();
|
||||
let event = keyboardEvent("Enter");
|
||||
query.listeners.get("keydown")(event);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.equal(harness.posted.at(-1).type, workflow.bridgeContract.loadRequestType);
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha");
|
||||
assert.equal(acceptLoad(harness, "sales", "Alpha"), true);
|
||||
|
||||
event = keyboardEvent("Enter");
|
||||
query.listeners.get("keydown")(event);
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Alpine");
|
||||
assert.equal(acceptLoad(harness, "sales", "Alpine"), true);
|
||||
|
||||
const countAtLastMatch = harness.posted.length;
|
||||
event = keyboardEvent("Enter");
|
||||
query.listeners.get("keydown")(event);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.equal(harness.posted.length, countAtLastMatch);
|
||||
assert.equal(harness.controller.render().selectedStockName, "Alpine");
|
||||
});
|
||||
|
||||
test("visible GraphE find buttons move around the current row without wrapping", () => {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
const overlay = harness.controller.mount(document.body);
|
||||
harness.controller.open("sales");
|
||||
acceptList(harness, "sales", ["Alpha", "Alpine", "Beta", "Gamma"]);
|
||||
|
||||
const previous = findByClass(overlay, "mfui-find-previous");
|
||||
const next = findByClass(overlay, "mfui-find-next");
|
||||
assert.ok(previous);
|
||||
assert.ok(next);
|
||||
assert.equal(previous.textContent, "위로 찾기");
|
||||
assert.equal(next.textContent, "아래로 찾기");
|
||||
assert.equal(previous.attributes.get("aria-label"), "현재 선택 이전의 일치 종목 찾기");
|
||||
assert.equal(next.attributes.get("aria-label"), "현재 선택 다음의 일치 종목 찾기");
|
||||
|
||||
setFindQuery(overlay, " al ");
|
||||
const beforeNoSelectionPrevious = harness.posted.length;
|
||||
previous.listeners.get("click")();
|
||||
assert.equal(harness.posted.length, beforeNoSelectionPrevious);
|
||||
|
||||
next.listeners.get("click")();
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha");
|
||||
acceptLoad(harness, "sales", "Alpha");
|
||||
next.listeners.get("click")();
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Alpine");
|
||||
acceptLoad(harness, "sales", "Alpine");
|
||||
|
||||
// The WinForms loop used i > 0 and accidentally skipped row 0. The Web port
|
||||
// intentionally includes the first row as a valid previous match.
|
||||
previous.listeners.get("click")();
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha");
|
||||
acceptLoad(harness, "sales", "Alpha");
|
||||
const beforeTop = harness.posted.length;
|
||||
previous.listeners.get("click")();
|
||||
assert.equal(harness.posted.length, beforeTop);
|
||||
});
|
||||
|
||||
test("GraphE below-find button and Enter use the same next-match request path", () => {
|
||||
const buttonHarness = createHarness();
|
||||
const buttonOverlay = buttonHarness.controller.mount(new FakeDocument().body);
|
||||
buttonHarness.controller.open("growth-metrics");
|
||||
acceptList(buttonHarness, "growth-metrics", ["Alpha", "Beta"]);
|
||||
setFindQuery(buttonOverlay, " beta ");
|
||||
findByClass(buttonOverlay, "mfui-find-next").listeners.get("click")();
|
||||
const buttonRequest = buttonHarness.posted.at(-1);
|
||||
|
||||
const enterHarness = createHarness();
|
||||
const enterOverlay = enterHarness.controller.mount(new FakeDocument().body);
|
||||
enterHarness.controller.open("growth-metrics");
|
||||
acceptList(enterHarness, "growth-metrics", ["Alpha", "Beta"]);
|
||||
const enterQuery = setFindQuery(enterOverlay, " beta ");
|
||||
const enterEvent = keyboardEvent("Enter");
|
||||
enterQuery.listeners.get("keydown")(enterEvent);
|
||||
const enterRequest = enterHarness.posted.at(-1);
|
||||
|
||||
assert.equal(enterEvent.defaultPrevented, true);
|
||||
assert.equal(buttonRequest.type, workflow.bridgeContract.loadRequestType);
|
||||
assert.equal(enterRequest.type, workflow.bridgeContract.loadRequestType);
|
||||
assert.deepEqual(
|
||||
{ screen: buttonRequest.payload.screen, stockName: buttonRequest.payload.stockName },
|
||||
{ screen: enterRequest.payload.screen, stockName: enterRequest.payload.stockName });
|
||||
});
|
||||
|
||||
test("GraphE next-find supports query changes, no match, and a single match", () => {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
const overlay = harness.controller.mount(document.body);
|
||||
harness.controller.open("revenue-composition");
|
||||
acceptList(harness, "revenue-composition", ["Alpha", "Alpine", "Beta", "Gamma"]);
|
||||
const query = findByClass(overlay, "mfui-search-input");
|
||||
|
||||
query.value = "Alpha";
|
||||
query.listeners.get("input")();
|
||||
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
||||
acceptLoad(harness, "revenue-composition", "Alpha");
|
||||
|
||||
query.value = "beta";
|
||||
query.listeners.get("input")();
|
||||
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Beta");
|
||||
acceptLoad(harness, "revenue-composition", "Beta");
|
||||
|
||||
const beforeNoMatch = harness.posted.length;
|
||||
query.value = "missing";
|
||||
query.listeners.get("input")();
|
||||
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
||||
assert.equal(harness.posted.length, beforeNoMatch);
|
||||
|
||||
query.value = "beta";
|
||||
query.listeners.get("input")();
|
||||
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
||||
assert.equal(harness.posted.length, beforeNoMatch);
|
||||
});
|
||||
|
||||
test("all four GraphE modes share the loaded-list Enter next-find path", () => {
|
||||
for (const screen of ["revenue-composition", "growth-metrics", "sales", "operating-profit"]) {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
const overlay = harness.controller.mount(document.body);
|
||||
harness.controller.open(screen);
|
||||
acceptList(harness, screen, ["Alpha", "Beta"]);
|
||||
const query = findByClass(overlay, "mfui-search-input");
|
||||
query.value = "beta";
|
||||
query.listeners.get("input")();
|
||||
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
||||
assert.equal(harness.posted.at(-1).type, workflow.bridgeContract.loadRequestType, screen);
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Beta", screen);
|
||||
}
|
||||
});
|
||||
|
||||
test("all four GraphE modes share visible previous and next find controls", () => {
|
||||
for (const screen of ["revenue-composition", "growth-metrics", "sales", "operating-profit"]) {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
const overlay = harness.controller.mount(document.body);
|
||||
harness.controller.open(screen);
|
||||
acceptList(harness, screen, ["Alpha", "Beta"]);
|
||||
setFindQuery(overlay, "a");
|
||||
|
||||
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha", screen);
|
||||
acceptLoad(harness, screen, "Alpha");
|
||||
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Beta", screen);
|
||||
acceptLoad(harness, screen, "Beta");
|
||||
findByClass(overlay, "mfui-find-previous").listeners.get("click")();
|
||||
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha", screen);
|
||||
}
|
||||
});
|
||||
|
||||
test("GraphE find buttons no-op while hidden, locked, or any request class is pending", () => {
|
||||
let externalLocked = false;
|
||||
const listHarness = createHarness({ isLocked: () => externalLocked });
|
||||
const listOverlay = listHarness.controller.mount(new FakeDocument().body);
|
||||
listHarness.controller.open("sales");
|
||||
setFindQuery(listOverlay, "Alpha");
|
||||
assert.equal(findByClass(listOverlay, "mfui-find-previous").disabled, true);
|
||||
assert.equal(findByClass(listOverlay, "mfui-find-next").disabled, true);
|
||||
assertFindControlsDoNotPost(listHarness, listOverlay);
|
||||
|
||||
acceptList(listHarness, "sales", ["Alpha", "Beta"]);
|
||||
findByClass(listOverlay, "mfui-find-next").listeners.get("click")();
|
||||
assert.equal(listHarness.controller.render().pending.load, listHarness.posted.at(-1).payload.requestId);
|
||||
assertFindControlsDoNotPost(listHarness, listOverlay);
|
||||
acceptLoad(listHarness, "sales", "Alpha");
|
||||
|
||||
externalLocked = true;
|
||||
listHarness.controller.render();
|
||||
assertFindControlsDoNotPost(listHarness, listOverlay);
|
||||
externalLocked = false;
|
||||
listHarness.controller.render();
|
||||
findByClass(listOverlay, "mfui-close").listeners.get("click")();
|
||||
assertFindControlsDoNotPost(listHarness, listOverlay);
|
||||
|
||||
const selectionHarness = createHarness();
|
||||
const selectionOverlay = selectionHarness.controller.mount(new FakeDocument().body);
|
||||
selectionHarness.controller.open("sales");
|
||||
acceptList(selectionHarness, "sales", ["Alpha", "Beta"]);
|
||||
setFindQuery(selectionOverlay, "Alpha");
|
||||
findByClass(selectionOverlay, "mfui-find-next").listeners.get("click")();
|
||||
acceptLoad(selectionHarness, "sales", "Alpha");
|
||||
findByClass(selectionOverlay, "mfui-accent").listeners.get("click")();
|
||||
assert.ok(selectionHarness.controller.render().pending.stock);
|
||||
assertFindControlsDoNotPost(selectionHarness, selectionOverlay);
|
||||
acceptExactStockSearch(selectionHarness);
|
||||
assert.ok(selectionHarness.controller.render().pending.selection);
|
||||
assertFindControlsDoNotPost(selectionHarness, selectionOverlay);
|
||||
|
||||
const writeHarness = createHarness();
|
||||
const writeOverlay = writeHarness.controller.mount(new FakeDocument().body);
|
||||
writeHarness.controller.open("sales");
|
||||
acceptList(writeHarness, "sales", ["Alpha", "Beta"]);
|
||||
setFindQuery(writeOverlay, "Alpha");
|
||||
findByClass(writeOverlay, "mfui-find-next").listeners.get("click")();
|
||||
acceptLoad(writeHarness, "sales", "Alpha");
|
||||
findByText(writeOverlay, "수정 저장").listeners.get("click")();
|
||||
assert.ok(writeHarness.controller.render().pending.stock);
|
||||
acceptExactStockSearch(writeHarness);
|
||||
assert.ok(writeHarness.controller.render().pending.write);
|
||||
assertFindControlsDoNotPost(writeHarness, writeOverlay);
|
||||
});
|
||||
|
||||
test("GraphE Enter preserves initial DB submit and blocks pending, lock, and hidden modal", () => {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
const overlay = harness.controller.mount(document.body);
|
||||
harness.controller.open("operating-profit");
|
||||
const query = findByClass(overlay, "mfui-search-input");
|
||||
const searchForm = findByClass(overlay, "mfui-search");
|
||||
|
||||
query.value = "Alpha";
|
||||
query.listeners.get("input")();
|
||||
let event = keyboardEvent("Enter");
|
||||
query.listeners.get("keydown")(event);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
searchForm.listeners.get("submit")(event);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.equal(harness.posted.at(-1).type, workflow.bridgeContract.listRequestType);
|
||||
assert.equal(harness.posted.at(-1).payload.query, "Alpha");
|
||||
acceptList(harness, "operating-profit", ["Alpha"], "Alpha");
|
||||
|
||||
event = keyboardEvent("Enter");
|
||||
query.listeners.get("keydown")(event);
|
||||
assert.equal(harness.posted.at(-1).type, workflow.bridgeContract.loadRequestType);
|
||||
const pendingCount = harness.posted.length;
|
||||
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
||||
assert.equal(harness.posted.length, pendingCount);
|
||||
acceptLoad(harness, "operating-profit", "Alpha");
|
||||
|
||||
harness.controller.setLocked(true);
|
||||
query.value = "Alpha";
|
||||
query.listeners.get("input")();
|
||||
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
||||
assert.equal(harness.posted.length, pendingCount);
|
||||
harness.controller.setLocked(false);
|
||||
|
||||
const close = findByClass(overlay, "mfui-close");
|
||||
close.listeners.get("click")();
|
||||
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
||||
assert.equal(harness.posted.length, pendingCount);
|
||||
});
|
||||
|
||||
test("fresh load, exact stock revalidation and native selection create one playable entry", () => {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
|
||||
@@ -7,8 +7,22 @@ const assert = require("node:assert/strict");
|
||||
|
||||
const root = path.resolve(__dirname, "..", "..");
|
||||
const native = fs.readFileSync(path.join(root, "MainWindow.ManualLists.cs"), "utf8");
|
||||
const dispatch = fs.readFileSync(path.join(root, "MainWindow.xaml.cs"), "utf8");
|
||||
const importer = fs.readFileSync(path.join(
|
||||
root,
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.Infrastructure",
|
||||
"Playout",
|
||||
"LegacyManualOperatorDataImporter.cs"), "utf8");
|
||||
const playout = fs.readFileSync(path.join(root, "MainWindow.Playout.cs"), "utf8");
|
||||
const factory = fs.readFileSync(path.join(root, "LegacySceneRuntimeFactory.cs"), "utf8");
|
||||
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
|
||||
|
||||
test("generic catalog cannot add the s5025 sample outside the trusted manual-list workflow", () => {
|
||||
assert.match(app, /operatorInputBuilderKeys[\s\S]*"s5025"/u);
|
||||
assert.match(app,
|
||||
/item\.builderKey === "s5025"[\s\S]*투자자별 수동 목록 화면에서 추가/u);
|
||||
});
|
||||
|
||||
test("native VI responses expose the canonical version and no-op persistence receipt", () => {
|
||||
assert.match(native, /ReadSnapshotAsync\(_lifetimeCancellation\.Token\)/u);
|
||||
@@ -49,8 +63,56 @@ test("configured external operator paths are disabled before any directory creat
|
||||
assert.equal(native.includes("Directory.CreateDirectory(directory)"), false);
|
||||
});
|
||||
|
||||
test("interrupted import guard runs before either manual store can be constructed", () => {
|
||||
const inspection = native.indexOf("LegacyManualOperatorDataRuntimeGuard.Inspect(directory)");
|
||||
const readyGuard = native.indexOf("if (!inspection.IsReady)", inspection);
|
||||
const failClosedReturn = native.indexOf("return;", readyGuard);
|
||||
const netStore = native.indexOf("new S5025TrustedManualFileStore(directory)", inspection);
|
||||
const viStore = native.indexOf("new ViTrustedManualFileStore(directory)", inspection);
|
||||
assert.ok(inspection >= 0 && readyGuard > inspection && failClosedReturn > readyGuard);
|
||||
assert.ok(netStore > failClosedReturn && viStore > failClosedReturn);
|
||||
assert.match(importer, /if \(EntryExists\(intentPath\)\)[\s\S]*Do not read any production file/u);
|
||||
});
|
||||
|
||||
test("legacy import commit has a durable intent before moves and marker remains last", () => {
|
||||
const commitStart = importer.indexOf("private void CommitAndVerify");
|
||||
const commitEnd = importer.indexOf("private void WriteIntentDurably", commitStart);
|
||||
const commit = importer.slice(commitStart, commitEnd);
|
||||
const intent = commit.indexOf("WriteIntentDurably(intentPath, intentBytes)");
|
||||
const dataMove = commit.indexOf("File.Move(file.TemporaryPath, file.FinalPath");
|
||||
const readback = commit.indexOf("VerifyImportedData(snapshot)");
|
||||
const markerMove = commit.indexOf("File.Move(marker.TemporaryPath, marker.FinalPath");
|
||||
const intentRemoval = commit.indexOf("DeleteOwnedFile(", markerMove);
|
||||
assert.ok(intent >= 0 && intent < dataMove && dataMove < readback);
|
||||
assert.ok(readback < markerMove && markerMove < intentRemoval);
|
||||
assert.match(importer, /FileOptions\.WriteThrough[\s\S]*Flush\(flushToDisk: true\)/u);
|
||||
});
|
||||
|
||||
test("trusted VI source is injected and checked for both page plan and page load", () => {
|
||||
assert.match(playout, /trustedViSource: _viManualListStore/u);
|
||||
assert.match(factory, /ITrustedViStockNameSnapshotSource\? trustedViSource/u);
|
||||
assert.ok((factory.match(/TrustedViManualReference\.ResolveAsync/gu) || []).length >= 2);
|
||||
});
|
||||
|
||||
test("legacy manual import bridge is closed, fixed under UserProfile, and never accepts a path", () => {
|
||||
assert.match(dispatch, /case "import-legacy-manual-operator-data"/u);
|
||||
const methodStart = native.indexOf("private async Task HandleLegacyManualOperatorImportAsync");
|
||||
const methodEnd = native.indexOf("private async Task HandleManualNetSellReadAsync", methodStart);
|
||||
const method = native.slice(methodStart, methodEnd);
|
||||
assert.ok(methodStart >= 0 && methodEnd > methodStart);
|
||||
assert.match(method, /HasOnlyProperties\(payload, "requestId"\)/u);
|
||||
assert.equal(/GetProperty\([^)]*(?:path|directory|source)/iu.test(method), false);
|
||||
assert.match(importer, /Environment\.SpecialFolder\.UserProfile/u);
|
||||
assert.match(importer, /FixedRelativeDirectory/u);
|
||||
assert.equal(importer.includes("MBN_STOCK_OPERATOR_DATA_DIRECTORY"), false);
|
||||
assert.equal(importer.includes("C:\\Users\\MD"), false);
|
||||
});
|
||||
|
||||
test("legacy manual import returns only a versioned receipt and terminal no-retry errors", () => {
|
||||
assert.match(native, /PostMessage\("legacy-manual-operator-import-result"/u);
|
||||
assert.match(native, /markerVersion = result\.MarkerVersion/u);
|
||||
assert.match(native, /sourceSha256 = result\.SourceSha256/u);
|
||||
assert.match(native, /retryable: false,[\s\S]*outcomeUnknown: false/u);
|
||||
assert.match(native, /PostManualOperatorQuarantine\(requestId, operation\)/u);
|
||||
assert.equal(native.includes("result.SourcePath"), false);
|
||||
});
|
||||
|
||||
@@ -53,11 +53,35 @@ function enableManualStore(context) {
|
||||
assert.equal(context.controller.handleMessage("manual-operator-data-status", {
|
||||
requestId,
|
||||
available: true,
|
||||
code: "READY",
|
||||
writeQuarantined: false,
|
||||
message: null
|
||||
}), true);
|
||||
}
|
||||
|
||||
test("interrupted import status remains explicit and disables every manual store action", () => {
|
||||
const context = fixture();
|
||||
context.controller.openVi();
|
||||
const requestId = context.controller.getState().pending.status;
|
||||
|
||||
assert.equal(context.controller.handleMessage("manual-operator-data-status", {
|
||||
requestId,
|
||||
available: false,
|
||||
code: "IMPORT_INTERRUPTED",
|
||||
writeQuarantined: false,
|
||||
message: "A prior import is unresolved; no FSell or VI file was opened."
|
||||
}), true);
|
||||
|
||||
const state = context.controller.getState();
|
||||
assert.equal(state.available, false);
|
||||
assert.equal(state.unavailableCode, "IMPORT_INTERRUPTED");
|
||||
assert.equal(
|
||||
state.unavailableMessage,
|
||||
"A prior import is unresolved; no FSell or VI file was opened.");
|
||||
assert.equal(context.controller.actions.importLegacyManualData(), false);
|
||||
assert.equal(context.controller.actions.saveVi(), false);
|
||||
});
|
||||
|
||||
test("FSell cut opens only after five saved rows are re-read unchanged", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
@@ -343,6 +367,89 @@ test("explicit and host locks block edits, saves, and cut creation", () => {
|
||||
assert.equal(controller.getState().locked, true);
|
||||
});
|
||||
|
||||
test("legacy FSell and VI import requires explicit confirmation and sends no path", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
const previousConfirm = globalThis.confirm;
|
||||
try {
|
||||
controller.openVi();
|
||||
enableManualStore(context);
|
||||
controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: controller.getState().pending.viRead,
|
||||
itemCount: 0,
|
||||
pageCount: 0,
|
||||
items: [],
|
||||
version: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
});
|
||||
|
||||
globalThis.confirm = () => false;
|
||||
const before = context.posts.length;
|
||||
assert.equal(controller.actions.importLegacyManualData(), false);
|
||||
assert.equal(context.posts.length, before);
|
||||
|
||||
let prompt = "";
|
||||
globalThis.confirm = message => {
|
||||
prompt = message;
|
||||
return true;
|
||||
};
|
||||
assert.equal(controller.actions.importLegacyManualData(), true);
|
||||
const request = context.latest("import-legacy-manual-operator-data");
|
||||
assert.deepEqual(Object.keys(request.payload), ["requestId"]);
|
||||
assert.equal(/path|directory|sourceRoot/iu.test(JSON.stringify(request)), false);
|
||||
assert.match(prompt, /기존 대상 파일이 하나라도 있으면/u);
|
||||
assert.match(prompt, /덮어쓰지 않고 중단/u);
|
||||
|
||||
assert.equal(controller.handleMessage("legacy-manual-operator-import-result", {
|
||||
requestId: request.payload.requestId,
|
||||
markerVersion: 1,
|
||||
sourceSha256: "b".repeat(64),
|
||||
importedAt: "2026-07-12T01:02:03+00:00",
|
||||
netSellRowCount: 15,
|
||||
viItemCount: 9
|
||||
}), true);
|
||||
assert.ok(controller.getState().pending.viRead);
|
||||
assert.ok(context.toasts.some(item => item.message.includes("가져왔습니다")));
|
||||
} finally {
|
||||
if (previousConfirm === undefined) delete globalThis.confirm;
|
||||
else globalThis.confirm = previousConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy import failure is terminal and never auto-retries", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
const previousConfirm = globalThis.confirm;
|
||||
try {
|
||||
globalThis.confirm = () => true;
|
||||
controller.openVi();
|
||||
enableManualStore(context);
|
||||
controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: controller.getState().pending.viRead,
|
||||
itemCount: 0,
|
||||
pageCount: 0,
|
||||
items: [],
|
||||
version: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
});
|
||||
controller.actions.importLegacyManualData();
|
||||
const request = context.latest("import-legacy-manual-operator-data");
|
||||
const before = context.posts.length;
|
||||
|
||||
assert.equal(controller.handleMessage("manual-operator-data-error", {
|
||||
requestId: request.payload.requestId,
|
||||
operation: "import-legacy-manual-data",
|
||||
code: "DESTINATION_NOT_EMPTY",
|
||||
message: "기존 데이터가 있어 아무 파일도 덮어쓰지 않았습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false
|
||||
}), true);
|
||||
assert.equal(context.posts.length, before);
|
||||
assert.equal(controller.getState().pending.legacyImport, null);
|
||||
} finally {
|
||||
if (previousConfirm === undefined) delete globalThis.confirm;
|
||||
else globalThis.confirm = previousConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test("controller source constructs DOM without HTML injection or filesystem paths", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "..", "..", "Web", "manual-lists-ui.js"),
|
||||
|
||||
@@ -31,6 +31,27 @@ test("bridge requests expose only closed operations and keys", () => {
|
||||
type: "request-vi-manual-list",
|
||||
payload: { requestId }
|
||||
});
|
||||
const legacyImport = workflow.createLegacyImportRequest(requestId);
|
||||
assert.deepEqual(legacyImport, {
|
||||
type: "import-legacy-manual-operator-data",
|
||||
payload: { requestId }
|
||||
});
|
||||
assert.equal(/path|directory|sourceRoot/iu.test(JSON.stringify(legacyImport)), false);
|
||||
});
|
||||
|
||||
test("legacy import receipt is exact, correlated, versioned, and path-free", () => {
|
||||
const response = {
|
||||
requestId,
|
||||
markerVersion: 1,
|
||||
sourceSha256: "b".repeat(64),
|
||||
importedAt: "2026-07-12T01:02:03+00:00",
|
||||
netSellRowCount: 15,
|
||||
viItemCount: 9
|
||||
};
|
||||
assert.deepEqual(workflow.normalizeLegacyImportResponse(response, requestId), response);
|
||||
assert.equal(workflow.normalizeLegacyImportResponse({ ...response, markerVersion: 2 }), null);
|
||||
assert.equal(workflow.normalizeLegacyImportResponse({ ...response, path: "C:\\private" }), null);
|
||||
assert.equal(workflow.normalizeLegacyImportResponse(response, "other_request"), null);
|
||||
});
|
||||
|
||||
test("net-sell save request requires exactly five strict rows", () => {
|
||||
@@ -83,21 +104,38 @@ test("status response preserves process-lifetime write quarantine", () => {
|
||||
assert.deepEqual(workflow.normalizeStatusResponse({
|
||||
requestId,
|
||||
available: true,
|
||||
code: "READY",
|
||||
writeQuarantined: true,
|
||||
message: "저장 결과를 확인할 수 없습니다."
|
||||
}, requestId), {
|
||||
requestId,
|
||||
available: true,
|
||||
code: "READY",
|
||||
writeQuarantined: true,
|
||||
message: "저장 결과를 확인할 수 없습니다."
|
||||
});
|
||||
assert.equal(workflow.normalizeStatusResponse({
|
||||
requestId,
|
||||
available: true,
|
||||
code: "READY",
|
||||
writeQuarantined: true,
|
||||
message: "저장 결과를 확인할 수 없습니다.",
|
||||
extra: true
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeStatusResponse({
|
||||
requestId,
|
||||
available: true,
|
||||
code: "IMPORT_INTERRUPTED",
|
||||
writeQuarantined: false,
|
||||
message: null
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeStatusResponse({
|
||||
requestId,
|
||||
available: false,
|
||||
code: "UNRECOGNIZED_STATE",
|
||||
writeQuarantined: false,
|
||||
message: "unavailable"
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("net-sell data response is correlated and exact", () => {
|
||||
@@ -280,6 +318,49 @@ test("trusted restore recreates manual entries and rejects tampering", () => {
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("only entries bound to a correlated fresh read are playable", () => {
|
||||
const unverifiedNet = workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
id: "entry_net_unverified",
|
||||
fadeDuration: 6
|
||||
});
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(unverifiedNet), false);
|
||||
|
||||
const netRead = workflow.normalizeNetSellDataResponse({
|
||||
requestId,
|
||||
audience: "FOREIGN",
|
||||
rows: rows()
|
||||
}, { requestId, audience: "FOREIGN" });
|
||||
const verifiedNet = workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
id: "entry_net_verified",
|
||||
fadeDuration: 6
|
||||
}, netRead);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(verifiedNet), true);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(
|
||||
workflow.restorePlaylistEntry(JSON.parse(JSON.stringify(verifiedNet)))), false);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(workflow.createNetSellPlaylistEntry(
|
||||
"INDIVIDUAL",
|
||||
{ id: "entry_net_wrong_audience", fadeDuration: 6 },
|
||||
netRead)), false);
|
||||
|
||||
const viRead = workflow.normalizeViDataResponse({
|
||||
requestId,
|
||||
itemCount: viItems().length,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: rowVersion
|
||||
}, requestId);
|
||||
const verifiedVi = workflow.createViPlaylistEntry(viItems(), rowVersion, {
|
||||
id: "entry_vi_verified",
|
||||
fadeDuration: 6
|
||||
}, viRead);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(verifiedVi), true);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(workflow.createViPlaylistEntry(
|
||||
viItems().slice(0, 2),
|
||||
rowVersion,
|
||||
{ id: "entry_vi_stale", fadeDuration: 6 },
|
||||
viRead)), false);
|
||||
});
|
||||
|
||||
test("request identifiers and option bounds are strict", () => {
|
||||
assert.throws(() => workflow.createStatusRequest("bad id"));
|
||||
assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
|
||||
55
tests/Web/market-nav-reorder-app-integration.test.cjs
Normal file
55
tests/Web/market-nav-reorder-app-integration.test.cjs
Normal file
@@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "Web/index.html"), "utf8");
|
||||
const styles = fs.readFileSync(path.join(root, "Web/styles.css"), "utf8");
|
||||
const moduleSource = fs.readFileSync(path.join(root, "Web/market-nav-reorder.js"), "utf8");
|
||||
|
||||
test("market reorder module loads before app and binds only to marketNav", () => {
|
||||
const moduleIndex = index.indexOf('<script src="market-nav-reorder.js"></script>');
|
||||
const appIndex = index.indexOf('<script src="app.js"></script>');
|
||||
assert.ok(moduleIndex >= 0 && appIndex > moduleIndex);
|
||||
assert.match(app, /const marketNavReorder = globalThis\.MbnMarketNavReorder;/u);
|
||||
assert.match(app, /marketNavReorder\.createController\(\{\s*nav: marketNav,/u);
|
||||
assert.match(app,
|
||||
/isBlocked: \(\) => hasOpenOperatorModal\(\) \|\| isPlaylistSnapshotLocked\(\)/u);
|
||||
assert.match(app, /if \(!marketNavReorderController\.mount\(\)\)/u);
|
||||
});
|
||||
|
||||
test("dashboard remains first and exactly ten original data-market identities reorder", () => {
|
||||
const nav = index.match(/<nav id="marketNav"[\s\S]*?<\/nav>/u)?.[0] || "";
|
||||
const markets = [...nav.matchAll(/data-market="([^"]+)"/gu)].map(match => match[1]);
|
||||
assert.deepEqual(markets, [
|
||||
"dashboard", "overseas", "exchange", "index", "kospi", "kosdaq",
|
||||
"compare", "theme", "foreign-stock", "expert", "halted"
|
||||
]);
|
||||
assert.match(moduleSource, /const fixedMarket = "dashboard";/u);
|
||||
assert.match(moduleSource, /button\.draggable = reorderable;/u);
|
||||
assert.match(moduleSource, /"aria-keyshortcuts", "Alt\+ArrowUp Alt\+ArrowDown"/u);
|
||||
assert.match(moduleSource, /"드래그 또는 Alt\+화살표로 업무 탭 순서 변경"/u);
|
||||
});
|
||||
|
||||
test("reorder is DOM/session-only and cannot alter view, DB, or playlist state", () => {
|
||||
assert.doesNotMatch(moduleSource,
|
||||
/localStorage|sessionStorage|requestMarketData|state\.|playlist|postNative/u);
|
||||
assert.match(moduleSource, /nav\.prepend\(dashboard\)/u);
|
||||
assert.match(moduleSource, /for \(const market of normalized\) nav\.append/u);
|
||||
|
||||
const clickStart = app.indexOf('marketNav.addEventListener("click"');
|
||||
const clickEnd = app.indexOf("elements.catalogSearch.addEventListener", clickStart);
|
||||
const clickHandler = app.slice(clickStart, clickEnd);
|
||||
assert.match(clickHandler, /state\.activeMarket = button\.dataset\.market;/u);
|
||||
assert.doesNotMatch(clickHandler, /children\[|childNodes\[|selectedIndex/u);
|
||||
assert.match(clickHandler, /requestMarketData\(state\.activeMarket\)/u);
|
||||
});
|
||||
|
||||
test("drag affordance is visible without changing dashboard markup semantics", () => {
|
||||
assert.match(styles, /\.nav-item\[draggable="true"\] \{ cursor: grab; \}/u);
|
||||
assert.match(styles, /\.nav-item\.dragging \{ opacity: \.55; cursor: grabbing; \}/u);
|
||||
});
|
||||
229
tests/Web/market-nav-reorder.test.cjs
Normal file
229
tests/Web/market-nav-reorder.test.cjs
Normal file
@@ -0,0 +1,229 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const reorder = require("../../Web/market-nav-reorder.js");
|
||||
|
||||
class FakeClassList {
|
||||
constructor(values = []) { this.values = new Set(values); }
|
||||
add(value) { this.values.add(value); }
|
||||
remove(value) { this.values.delete(value); }
|
||||
contains(value) { return this.values.has(value); }
|
||||
}
|
||||
|
||||
class FakeButton {
|
||||
constructor(market, active = false) {
|
||||
this.dataset = { market };
|
||||
this.parentElement = null;
|
||||
this.classList = new FakeClassList(active ? ["active"] : []);
|
||||
this.attributes = new Map();
|
||||
this.draggable = false;
|
||||
this.focusCount = 0;
|
||||
}
|
||||
|
||||
closest(selector) { return selector === "button[data-market]" ? this : null; }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
focus() { this.focusCount += 1; }
|
||||
}
|
||||
|
||||
class FakeNav {
|
||||
constructor(markets, activeMarket = "compare") {
|
||||
this.children = markets.map(market => new FakeButton(market, market === activeMarket));
|
||||
for (const child of this.children) child.parentElement = this;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
addEventListener(type, callback) {
|
||||
const callbacks = this.listeners.get(type) || [];
|
||||
callbacks.push(callback);
|
||||
this.listeners.set(type, callbacks);
|
||||
}
|
||||
|
||||
removeEventListener(type, callback) {
|
||||
this.listeners.set(type, (this.listeners.get(type) || []).filter(value => value !== callback));
|
||||
}
|
||||
|
||||
append(child) {
|
||||
this.children = this.children.filter(value => value !== child);
|
||||
this.children.push(child);
|
||||
child.parentElement = this;
|
||||
}
|
||||
|
||||
prepend(child) {
|
||||
this.children = this.children.filter(value => value !== child);
|
||||
this.children.unshift(child);
|
||||
child.parentElement = this;
|
||||
}
|
||||
|
||||
dispatch(type, event) {
|
||||
event.currentTarget = this;
|
||||
for (const callback of this.listeners.get(type) || []) callback(event);
|
||||
}
|
||||
|
||||
button(market) { return this.children.find(button => button.dataset.market === market); }
|
||||
}
|
||||
|
||||
class FakeDataTransfer {
|
||||
constructor() {
|
||||
this.values = new Map();
|
||||
this.effectAllowed = "none";
|
||||
this.dropEffect = "none";
|
||||
}
|
||||
setData(type, value) { this.values.set(type, value); }
|
||||
getData(type) { return this.values.get(type) || ""; }
|
||||
}
|
||||
|
||||
function eventFor(target, values = {}) {
|
||||
return {
|
||||
target,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
repeat: false,
|
||||
defaultPrevented: false,
|
||||
propagationStopped: false,
|
||||
preventDefault() { this.defaultPrevented = true; },
|
||||
stopPropagation() { this.propagationStopped = true; },
|
||||
...values
|
||||
};
|
||||
}
|
||||
|
||||
function createNav(activeMarket = "compare") {
|
||||
return new FakeNav([reorder.fixedMarket, ...reorder.originalMarkets], activeMarket);
|
||||
}
|
||||
|
||||
test("dashboard is fixed while original market identities swap by ID", () => {
|
||||
const nav = createNav();
|
||||
const result = reorder.swapOrder(
|
||||
reorder.readOrder(nav),
|
||||
"exchange",
|
||||
"expert");
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(reorder.applyOrder(nav, result.order), true);
|
||||
assert.equal(nav.children[0].dataset.market, "dashboard");
|
||||
assert.equal(nav.children[2].dataset.market, "expert");
|
||||
assert.equal(nav.children[9].dataset.market, "exchange");
|
||||
assert.equal(nav.button("compare").classList.contains("active"), true);
|
||||
});
|
||||
|
||||
test("Alt+Arrow provides adjacent reorder and honors first/last boundaries", () => {
|
||||
const nav = createNav("overseas");
|
||||
const controller = reorder.createController({ nav });
|
||||
assert.equal(controller.mount(), true);
|
||||
|
||||
const first = nav.button("overseas");
|
||||
const firstBoundary = eventFor(first, { key: "ArrowUp", altKey: true });
|
||||
nav.dispatch("keydown", firstBoundary);
|
||||
assert.deepEqual(controller.readOrder(), reorder.originalMarkets);
|
||||
assert.equal(firstBoundary.defaultPrevented, true);
|
||||
|
||||
const last = nav.button("halted");
|
||||
const lastBoundary = eventFor(last, { key: "ArrowDown", altKey: true });
|
||||
nav.dispatch("keydown", lastBoundary);
|
||||
assert.deepEqual(controller.readOrder(), reorder.originalMarkets);
|
||||
|
||||
const move = eventFor(first, { key: "ArrowDown", altKey: true });
|
||||
nav.dispatch("keydown", move);
|
||||
assert.deepEqual(controller.readOrder().slice(0, 3), ["exchange", "overseas", "index"]);
|
||||
assert.equal(nav.button("overseas").classList.contains("active"), true);
|
||||
assert.equal(first.focusCount, 1);
|
||||
});
|
||||
|
||||
test("drag swap preserves active state and existing click behavior", () => {
|
||||
const nav = createNav("compare");
|
||||
let clicked = null;
|
||||
nav.addEventListener("click", event => {
|
||||
clicked = event.target.closest("button[data-market]")?.dataset.market ?? null;
|
||||
});
|
||||
const controller = reorder.createController({ nav });
|
||||
assert.equal(controller.mount(), true);
|
||||
|
||||
const source = nav.button("compare");
|
||||
const target = nav.button("overseas");
|
||||
const dataTransfer = new FakeDataTransfer();
|
||||
nav.dispatch("dragstart", eventFor(source, { dataTransfer }));
|
||||
const over = eventFor(target, { dataTransfer });
|
||||
nav.dispatch("dragover", over);
|
||||
assert.equal(over.defaultPrevented, true);
|
||||
nav.dispatch("drop", eventFor(target, { dataTransfer }));
|
||||
|
||||
assert.equal(controller.readOrder()[0], "compare");
|
||||
assert.equal(controller.readOrder()[5], "overseas");
|
||||
assert.equal(nav.children[0].dataset.market, "dashboard");
|
||||
assert.equal(source.classList.contains("active"), true);
|
||||
|
||||
const click = eventFor(source);
|
||||
nav.dispatch("click", click);
|
||||
assert.equal(clicked, "compare");
|
||||
assert.equal(click.defaultPrevented, false);
|
||||
});
|
||||
|
||||
test("malformed, external, dashboard and mismatched drags fail closed", () => {
|
||||
const nav = createNav();
|
||||
const controller = reorder.createController({ nav });
|
||||
controller.mount();
|
||||
const initial = [...controller.readOrder()];
|
||||
|
||||
const dashboardStart = eventFor(nav.button("dashboard"), {
|
||||
dataTransfer: new FakeDataTransfer()
|
||||
});
|
||||
nav.dispatch("dragstart", dashboardStart);
|
||||
assert.equal(dashboardStart.defaultPrevented, true);
|
||||
|
||||
const source = nav.button("exchange");
|
||||
const target = nav.button("expert");
|
||||
const malformed = new FakeDataTransfer();
|
||||
nav.dispatch("dragstart", eventFor(source, { dataTransfer: malformed }));
|
||||
malformed.setData(reorder.dragMime, "mbn-market-nav:v1:dashboard");
|
||||
nav.dispatch("drop", eventFor(target, { dataTransfer: malformed }));
|
||||
assert.deepEqual(controller.readOrder(), initial);
|
||||
|
||||
const dashboardTarget = new FakeDataTransfer();
|
||||
nav.dispatch("dragstart", eventFor(source, { dataTransfer: dashboardTarget }));
|
||||
nav.dispatch("drop", eventFor(nav.button("dashboard"), { dataTransfer: dashboardTarget }));
|
||||
assert.deepEqual(controller.readOrder(), initial);
|
||||
|
||||
const external = new FakeDataTransfer();
|
||||
external.setData(reorder.dragMime, reorder.createDragToken("exchange"));
|
||||
nav.dispatch("drop", eventFor(target, { dataTransfer: external }));
|
||||
assert.deepEqual(controller.readOrder(), initial);
|
||||
assert.equal(reorder.parseDragToken("mbn-market-nav:v1:unknown"), null);
|
||||
assert.equal(reorder.parseDragToken(" mbn-market-nav:v1:exchange"), null);
|
||||
});
|
||||
|
||||
test("input, modal/playout lock and malformed DOM cannot reorder", () => {
|
||||
const nav = createNav();
|
||||
let blocked = true;
|
||||
const controller = reorder.createController({ nav, isBlocked: () => blocked });
|
||||
assert.equal(controller.mount(), true);
|
||||
const initial = [...controller.readOrder()];
|
||||
const locked = eventFor(nav.button("exchange"), {
|
||||
key: "ArrowDown",
|
||||
altKey: true
|
||||
});
|
||||
nav.dispatch("keydown", locked);
|
||||
assert.deepEqual(controller.readOrder(), initial);
|
||||
|
||||
blocked = false;
|
||||
const input = { closest: () => null };
|
||||
const typing = eventFor(input, { key: "ArrowDown", altKey: true });
|
||||
nav.dispatch("keydown", typing);
|
||||
assert.equal(typing.defaultPrevented, false);
|
||||
assert.deepEqual(controller.readOrder(), initial);
|
||||
|
||||
const malformedNav = createNav();
|
||||
malformedNav.button("expert").dataset.market = "compare";
|
||||
const malformedController = reorder.createController({ nav: malformedNav });
|
||||
assert.equal(malformedController.mount(), false);
|
||||
});
|
||||
|
||||
test("reorder state is session-only and has no persistence surface", () => {
|
||||
assert.equal("storageKey" in reorder, false);
|
||||
assert.equal("save" in reorder, false);
|
||||
assert.deepEqual(reorder.originalMarkets, [
|
||||
"overseas", "exchange", "index", "kospi", "kosdaq",
|
||||
"compare", "theme", "foreign-stock", "expert", "halted"
|
||||
]);
|
||||
});
|
||||
54
tests/Web/named-comparison-restore-app-integration.test.cjs
Normal file
54
tests/Web/named-comparison-restore-app-integration.test.cjs
Normal file
@@ -0,0 +1,54 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "Web/index.html"), "utf8");
|
||||
const bridge = fs.readFileSync(path.join(root, "MainWindow.NamedComparisonRestore.cs"), "utf8");
|
||||
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
|
||||
const core = fs.readFileSync(
|
||||
path.join(root, "src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedComparisonRestore.cs"),
|
||||
"utf8");
|
||||
|
||||
test("named loads perform one correlated native comparison identity pass", () => {
|
||||
assert.match(index, /<script src="named-comparison-restore-workflow\.js"><\/script>/u);
|
||||
assert.match(app, /MbnNamedComparisonRestoreWorkflow/u);
|
||||
assert.match(app, /namedComparisonRestoreWorkflow\.classify/u);
|
||||
assert.match(app, /namedComparisonRestoreWorkflow\.normalizeResponse/u);
|
||||
assert.match(app, /namedComparisonRestoreWorkflow\.materialize/u);
|
||||
assert.match(app, /case "named-comparison-restore-results"/u);
|
||||
assert.match(app, /case "named-comparison-restore-error"/u);
|
||||
assert.match(namedBridge, /await PostNamedComparisonRestoreAsync\(/u);
|
||||
assert.match(bridge, /PostMessage\("named-comparison-restore-results"/u);
|
||||
assert.match(bridge, /PostMessage\("named-comparison-restore-error"/u);
|
||||
});
|
||||
|
||||
test("the old fake KOSPI and fabricated R-code restore path is gone", () => {
|
||||
assert.doesNotMatch(app, /function namedComparisonTarget/u);
|
||||
assert.doesNotMatch(app, /R\$\{itemIndex\}\$\{tokenIndex\}/u);
|
||||
assert.doesNotMatch(app, /addComparisonNamedCandidates/u);
|
||||
assert.match(core, /MissingMarketIdentity/u);
|
||||
assert.match(core, /row\.GroupCode == "종목"/u);
|
||||
});
|
||||
|
||||
test("generic catalog and stored-entry paths cannot create name-only comparison graphs", () => {
|
||||
assert.match(app, /operatorInputBuilderKeys[\s\S]*"s5026"[\s\S]*"s5029"[\s\S]*"s5087"/u);
|
||||
assert.match(app,
|
||||
/if \(\["s5026", "s5029", "s5087"\]\.includes\(item\.builderKey\)\)[\s\S]*전용 입력 화면/u);
|
||||
assert.match(app,
|
||||
/if \(definition\.reachable === false \|\| operatorInputBuilderKeys\.has\(definition\.builderKey\)\) continue/u);
|
||||
assert.match(app,
|
||||
/if \(operatorInputBuilderKeys\.has\(String\(item\.builderKey \|\| ""\)\) \|\| item\.operator !== undefined\)[\s\S]*return \[\]/u);
|
||||
});
|
||||
|
||||
test("identity restoration is read-only, bound, playout-priority aware, and never reaches Tornado", () => {
|
||||
assert.match(core, /new DataQueryParameter\("stock_name"/u);
|
||||
assert.doesNotMatch(core, /\b(?:INSERT\s+INTO|UPDATE\s+[A-Z_]|DELETE\s+FROM|MERGE\s+INTO)\b/iu);
|
||||
assert.match(bridge, /Volatile\.Read\(ref _playoutCommandInFlight\)/u);
|
||||
assert.match(bridge, /자동 재시도하지 않습니다/u);
|
||||
assert.doesNotMatch(bridge, /Tornado|TakeIn|PrepareAsync|ExecuteNonQuery/iu);
|
||||
});
|
||||
180
tests/Web/named-comparison-restore-workflow.test.cjs
Normal file
180
tests/Web/named-comparison-restore-workflow.test.cjs
Normal file
@@ -0,0 +1,180 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/named-comparison-restore-workflow.js");
|
||||
|
||||
function raw(overrides = {}) {
|
||||
return {
|
||||
itemIndex: 0,
|
||||
enabled: true,
|
||||
groupCode: "코스피,코스닥",
|
||||
subject: "삼성전자,에이비엘바이오",
|
||||
graphicType: "종목별 비교분석_캔들 그래프",
|
||||
subtype: "",
|
||||
pageText: "1/1",
|
||||
dataCode: "",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function pending(value = raw(), id = "db-comparison-0") {
|
||||
return workflow.classify(value, { id, fadeDuration: 6 });
|
||||
}
|
||||
|
||||
function response(rows, requestId = "named-load-1") {
|
||||
return {
|
||||
requestId,
|
||||
retrievedAt: "2026-07-12T12:00:00+09:00",
|
||||
totalRowCount: rows.length,
|
||||
rows
|
||||
};
|
||||
}
|
||||
|
||||
test("the exact original action grammar classifies all nine comparison leaves", () => {
|
||||
const cases = [
|
||||
["2열판", "", "two-column-current"],
|
||||
["2열판", "예상체결", "two-column-expected"],
|
||||
["종목별 비교분석_캔들 그래프", "", "comparison-candle"],
|
||||
["종목별 비교분석_라인 그래프", "", "comparison-line"],
|
||||
["종목별 수익률 비교", "5일", "return-5d"],
|
||||
["종목별 수익률 비교", "1개월", "return-1m"],
|
||||
["종목별 수익률 비교", "3개월", "return-3m"],
|
||||
["종목별 수익률 비교", "6개월", "return-6m"],
|
||||
["종목별 수익률 비교", "12개월", "return-12m"]
|
||||
];
|
||||
for (let index = 0; index < cases.length; index += 1) {
|
||||
const [graphicType, subtype, actionId] = cases[index];
|
||||
const value = pending(raw({
|
||||
itemIndex: index,
|
||||
groupCode: graphicType === "2열판" ? "지수" : "코스피,코스닥",
|
||||
subject: graphicType === "2열판"
|
||||
? "코스피 지수,코스닥 지수"
|
||||
: "삼성전자,에이비엘바이오",
|
||||
graphicType,
|
||||
subtype
|
||||
}), `comparison-${index}`);
|
||||
assert.ok(value);
|
||||
assert.equal(value.actionId, actionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("market-lost stock rows are submitted but can only normalize as a failed outcome", () => {
|
||||
const value = pending(raw({
|
||||
groupCode: "종목",
|
||||
subject: "삼성전자,엔비디아",
|
||||
graphicType: "2열판"
|
||||
}));
|
||||
assert.ok(value);
|
||||
const normalized = workflow.normalizeResponse(response([{
|
||||
itemIndex: 0,
|
||||
status: "failed",
|
||||
failure: "MISSING_MARKET_IDENTITY"
|
||||
}]), "named-load-1", [value]);
|
||||
assert.equal(normalized.rows[0].failure, "MISSING_MARKET_IDENTITY");
|
||||
assert.equal(workflow.materialize(value, normalized.rows[0]), null);
|
||||
});
|
||||
|
||||
test("a correlated fixed-pair result materializes the explicit comparison selection", () => {
|
||||
const value = pending(raw({
|
||||
groupCode: "지수",
|
||||
subject: "코스피 지수,코스닥 지수",
|
||||
graphicType: "2열판"
|
||||
}));
|
||||
const normalized = workflow.normalizeResponse(response([{
|
||||
itemIndex: 0,
|
||||
status: "resolved",
|
||||
actionId: "two-column-current",
|
||||
first: { kind: "market-target", target: "Kospi" },
|
||||
second: { kind: "market-target", target: "Kosdaq" }
|
||||
}]), "named-load-1", [value]);
|
||||
const entry = workflow.materialize(value, normalized.rows[0]);
|
||||
|
||||
assert.equal(entry.operator.source, "legacy-comparison-workflow");
|
||||
assert.equal(entry.operator.actionId, "two-column-current");
|
||||
assert.equal(entry.selection.subject, "Kospi,Kosdaq");
|
||||
assert.equal(entry.builderKey, "s8018");
|
||||
assert.equal(entry.enabled, true);
|
||||
});
|
||||
|
||||
test("a correlated exact KRX result materializes candle and preserves disabled state", () => {
|
||||
const value = pending(raw({ enabled: false }), "db-comparison-disabled");
|
||||
const normalized = workflow.normalizeResponse(response([{
|
||||
itemIndex: 0,
|
||||
status: "resolved",
|
||||
actionId: "comparison-candle",
|
||||
first: {
|
||||
kind: "krx-stock", market: "kospi", stockName: "삼성전자", stockCode: "005930"
|
||||
},
|
||||
second: {
|
||||
kind: "krx-stock", market: "kosdaq", stockName: "에이비엘바이오", stockCode: "298380"
|
||||
}
|
||||
}]), "named-load-1", [value]);
|
||||
const entry = workflow.materialize(value, normalized.rows[0]);
|
||||
|
||||
assert.equal(entry.builderKey, "s5026");
|
||||
assert.equal(entry.operator.pair[0].stockCode, "005930");
|
||||
assert.equal(entry.selection.dataCode, "KOSPI:005930|KOSDAQ:298380");
|
||||
assert.equal(entry.enabled, false);
|
||||
});
|
||||
|
||||
test("tampered correlation, ordering, action, target, and failure codes are rejected", () => {
|
||||
const first = pending(raw(), "first");
|
||||
const second = pending(raw({
|
||||
itemIndex: 1,
|
||||
graphicType: "종목별 비교분석_라인 그래프"
|
||||
}), "second");
|
||||
const validRows = [
|
||||
{
|
||||
itemIndex: 0,
|
||||
status: "failed",
|
||||
failure: "IDENTITY_NOT_FOUND"
|
||||
},
|
||||
{
|
||||
itemIndex: 1,
|
||||
status: "failed",
|
||||
failure: "IDENTITY_AMBIGUOUS"
|
||||
}
|
||||
];
|
||||
assert.ok(workflow.normalizeResponse(response(validRows), "named-load-1", [first, second]));
|
||||
assert.equal(workflow.normalizeResponse(response(validRows, "stale"), "named-load-1", [first, second]), null);
|
||||
assert.equal(workflow.normalizeResponse(response([...validRows].reverse()), "named-load-1", [first, second]), null);
|
||||
assert.equal(workflow.normalizeResponse(response([
|
||||
{ ...validRows[0], failure: "GUESS_MARKET" }, validRows[1]
|
||||
]), "named-load-1", [first, second]), null);
|
||||
|
||||
const wrongAction = response([{
|
||||
itemIndex: 0,
|
||||
status: "resolved",
|
||||
actionId: "comparison-line",
|
||||
first: { kind: "krx-stock", market: "kospi", stockName: "삼성전자", stockCode: "005930" },
|
||||
second: { kind: "krx-stock", market: "kosdaq", stockName: "에이비엘바이오", stockCode: "298380" }
|
||||
}]);
|
||||
assert.equal(workflow.normalizeResponse(wrongAction, "named-load-1", [first]), null);
|
||||
});
|
||||
|
||||
test("malformed raw fields and retryable native errors are rejected", () => {
|
||||
assert.equal(pending(raw({ pageText: "1/2" })), null);
|
||||
assert.equal(pending(raw({ dataCode: "005930" })), null);
|
||||
assert.equal(pending(raw({ subject: "삼성전자" })), null);
|
||||
assert.equal(pending(raw({ subject: " 삼성전자,기아" })), null);
|
||||
assert.equal(pending(raw({ subtype: "20일", graphicType: "종목별 수익률 비교" })), null);
|
||||
|
||||
assert.deepEqual(workflow.normalizeError({
|
||||
requestId: "named-load-1",
|
||||
code: "DATABASE_UNAVAILABLE",
|
||||
message: "현재 DB를 사용할 수 없습니다.",
|
||||
retryable: false
|
||||
}, "named-load-1"), {
|
||||
requestId: "named-load-1",
|
||||
code: "DATABASE_UNAVAILABLE",
|
||||
message: "현재 DB를 사용할 수 없습니다.",
|
||||
retryable: false
|
||||
});
|
||||
assert.equal(workflow.normalizeError({
|
||||
requestId: "named-load-1",
|
||||
code: "DATABASE_UNAVAILABLE",
|
||||
message: "현재 DB를 사용할 수 없습니다.",
|
||||
retryable: true
|
||||
}, "named-load-1"), null);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const foreign = require("../../Web/legacy-foreign-index-candle-workflow.js");
|
||||
const legacyRows = require("../../Web/legacy-named-row-workflow.js");
|
||||
const namedPlaylists = require("../../Web/named-playlist-workflow.js");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "Web/index.html"), "utf8");
|
||||
const bridge = fs.readFileSync(
|
||||
path.join(root, "MainWindow.NamedForeignIndexCandleRestore.cs"), "utf8");
|
||||
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
|
||||
const core = fs.readFileSync(
|
||||
path.join(root, "src/MBN_STOCK_WEBVIEW.Core/Data/LegacyForeignIndexCandleRestore.cs"),
|
||||
"utf8");
|
||||
|
||||
function rawRow(period = "240일", enabled = false) {
|
||||
return {
|
||||
itemIndex: 0,
|
||||
enabled,
|
||||
groupCode: "해외지수",
|
||||
subject: "다우존스",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: period,
|
||||
pageText: "1/1",
|
||||
dataCode: ""
|
||||
};
|
||||
}
|
||||
|
||||
function resolved(pending) {
|
||||
return {
|
||||
requestId: "foreign-load-1",
|
||||
retrievedAt: "2026-07-12T12:34:56+09:00",
|
||||
totalRowCount: 1,
|
||||
rows: [{
|
||||
itemIndex: pending.itemIndex,
|
||||
status: "resolved",
|
||||
targetKey: pending.targetKey,
|
||||
inputName: pending.inputName,
|
||||
symbol: pending.symbol,
|
||||
nationCode: "US",
|
||||
foreignDomesticCode: "0",
|
||||
sceneCode: "s8010",
|
||||
valueType: "PRICE",
|
||||
periodDays: pending.periodDays,
|
||||
cutCode: pending.cutCode
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
test("named load performs one correlated exact foreign-index candle batch", () => {
|
||||
assert.match(index, /<script src="legacy-foreign-index-candle-workflow\.js"><\/script>[\s\S]*<script src="legacy-named-row-workflow\.js"><\/script>/u);
|
||||
assert.match(app, /MbnLegacyForeignIndexCandleWorkflow/u);
|
||||
assert.match(app, /foreignIndexCandleRestoreWorkflow\.classify/u);
|
||||
assert.match(app, /foreignIndexCandleRestoreWorkflow\.normalizeResponse/u);
|
||||
assert.match(app, /foreignIndexCandleRestoreWorkflow\.materialize/u);
|
||||
assert.match(app, /case "named-foreign-index-candle-restore-results"/u);
|
||||
assert.match(app, /case "named-foreign-index-candle-restore-error"/u);
|
||||
assert.match(namedBridge, /await PostNamedForeignIndexCandleRestoreAsync\(/u);
|
||||
assert.match(bridge, /PostMessage\("named-foreign-index-candle-restore-results"/u);
|
||||
assert.match(bridge, /PostMessage\("named-foreign-index-candle-restore-error"/u);
|
||||
});
|
||||
|
||||
test("native and Core retain the full raw boundary and exact bound Oracle identity", () => {
|
||||
for (const field of [
|
||||
"item.ItemIndex", "item.IsEnabled", "item.Selection.GroupCode", "item.Selection.Subject",
|
||||
"item.Selection.GraphicType", "item.Selection.Subtype", "item.Page?.ToString()",
|
||||
"item.Selection.DataCode"
|
||||
]) assert.match(bridge, new RegExp(field.replace(/[.?]/g, value => `\\${value}`), "u"));
|
||||
assert.match(core, /F_INPUT_NAME = :input_name/u);
|
||||
assert.match(core, /F_SYMB = :symbol/u);
|
||||
assert.match(core, /F_NATC = 'US'/u);
|
||||
assert.match(core, /F_FDTC = '0'/u);
|
||||
assert.match(core, /new DataQueryParameter\("input_name"/u);
|
||||
assert.match(core, /new DataQueryParameter\("symbol"/u);
|
||||
assert.doesNotMatch(core, /\b(?:INSERT\s+INTO|UPDATE\s+[A-Z_]|DELETE\s+FROM|MERGE\s+INTO)\b/iu);
|
||||
assert.doesNotMatch(bridge, /Tornado|TakeIn|PrepareAsync|ExecuteNonQuery/iu);
|
||||
});
|
||||
|
||||
test("restore pending locks edit, page planning and finalization until its terminal result", () => {
|
||||
assert.match(app, /state\.namedPlaylist\.foreignIndexCandleRestorePending/u);
|
||||
assert.match(app, /comparisonRestorePending \|\|[\s\S]*foreignIndexCandleRestorePending/u);
|
||||
assert.match(app, /pendingForeignIndexCandles\.length === 0/u);
|
||||
assert.match(app, /if \(state\.namedPlaylist\.manualRestoreFinalized\) requestNamedPlaylistPagePlans\(\)/u);
|
||||
assert.match(app, /foreignIndexCandleRestorePending[\s\S]*state\.manualFinancialRestore\.entries\.size > 0/u);
|
||||
assert.match(bridge, /PLAYOUT_PRIORITY/u);
|
||||
assert.match(bridge, /자동 재시도하지 않습니다/u);
|
||||
assert.match(bridge, /retryable = false/u);
|
||||
});
|
||||
|
||||
test("verified 240-day entry round-trips through the exact legacy named signature", () => {
|
||||
const raw = rawRow();
|
||||
const pending = foreign.classify(raw, { id: "db-foreign-240", fadeDuration: 6 });
|
||||
const response = foreign.normalizeResponse(resolved(pending), "foreign-load-1", [pending]);
|
||||
const entry = foreign.materialize(pending, response.rows[0]);
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.code, "8056");
|
||||
assert.deepEqual(legacyRows.legacySelectionForEntry(entry), {
|
||||
groupCode: "해외지수",
|
||||
subject: "다우존스",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "240일",
|
||||
dataCode: ""
|
||||
});
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
|
||||
|
||||
const fields = namedPlaylists.toLegacySevenFields(
|
||||
{ ...entry, selection: legacyRows.legacySelectionForEntry(entry) },
|
||||
"1/1");
|
||||
assert.deepEqual(fields, ["0", "해외지수", "다우존스", "캔들그래프", "240일", "1/1", ""]);
|
||||
|
||||
const load = namedPlaylists.normalizeLoadResponse({
|
||||
requestId: "foreign-load-roundtrip",
|
||||
definition: { programCode: "00000001", title: "Foreign" },
|
||||
retrievedAt: "2026-07-12T12:34:56+09:00",
|
||||
totalRowCount: 1,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
rows: [raw]
|
||||
});
|
||||
const restoration = namedPlaylists.restoreRawRows(
|
||||
load,
|
||||
() => entry,
|
||||
legacyRows.matchesCanonicalEntry);
|
||||
assert.equal(restoration.rows[0].trusted, true);
|
||||
assert.equal(restoration.rows[0].entry.code, "8056");
|
||||
});
|
||||
|
||||
test("identity and saved-signature tampering remain blocked row by row", () => {
|
||||
const raw = rawRow("20일", true);
|
||||
const pending = foreign.classify(raw, { id: "db-foreign-tamper", fadeDuration: 6 });
|
||||
const response = foreign.normalizeResponse(resolved(pending), "foreign-load-1", [pending]);
|
||||
const entry = foreign.materialize(pending, response.rows[0]);
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(
|
||||
{ ...entry, selection: { ...entry.selection, dataCode: "NAS@IXIC|US|0" } },
|
||||
raw), false);
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, { ...raw, subtype: "60일" }), false);
|
||||
assert.match(app, /MATERIALIZATION_MISMATCH/u);
|
||||
assert.match(app, /outcome\.status === "failed"/u);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const industry = require("../../Web/industry-workflow.js");
|
||||
const legacyRows = require("../../Web/legacy-named-row-workflow.js");
|
||||
const namedPlaylists = require("../../Web/named-playlist-workflow.js");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
|
||||
const legacy = fs.readFileSync(path.join(root, "Web/legacy-named-row-workflow.js"), "utf8");
|
||||
|
||||
test("named industry restore creates candidates only from the exact fixed descriptor", () => {
|
||||
const start = app.indexOf("function addIndustryNamedCandidates(");
|
||||
const end = app.indexOf("function addThemeNamedCandidates(", start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const resolver = app.slice(start, end);
|
||||
assert.match(resolver, /legacyNamedRowWorkflow\.industryFixedDescriptor\(rawRow\)/u);
|
||||
assert.match(resolver, /descriptor\.market/u);
|
||||
assert.match(resolver, /descriptor\.cutId/u);
|
||||
assert.doesNotMatch(resolver, /industryWorkflow\.cuts\.forEach/u);
|
||||
assert.doesNotMatch(resolver, /code:\s*`R/u);
|
||||
assert.doesNotMatch(app, /code:\s*`R\$\{itemIndex/u);
|
||||
});
|
||||
|
||||
test("the exact legacy signature is closed to two markets and three fixed actions", () => {
|
||||
assert.match(legacy, /"업종_코스피": "kospi"/u);
|
||||
assert.match(legacy, /"업종_코스닥": "kosdaq"/u);
|
||||
assert.match(legacy, /"5단 표그래프": "five-row"/u);
|
||||
assert.match(legacy, /"네모그래프": "square"/u);
|
||||
assert.match(legacy, /"섹터지수": "sector"/u);
|
||||
assert.match(legacy, /case "legacy-industry-workflow"/u);
|
||||
});
|
||||
|
||||
test("five-row fixed restore remains on the fresh page-plan path", () => {
|
||||
assert.match(app, /const namedPagedBuilderKeys = new Set\(\["s5074", "s5077", "s5088"\]\)/u);
|
||||
assert.match(app, /prepareNamedPagePreflight\(restoration\)/u);
|
||||
assert.match(app, /requestNamedPlaylistPagePlans\(\)/u);
|
||||
assert.match(app, /industryWorkflow\.restoreIndustryPlaylistEntry\(entry\)/u);
|
||||
});
|
||||
|
||||
test("a stored five-row page is replaced by today's selection and mandatory fresh preflight", () => {
|
||||
const rawRow = {
|
||||
itemIndex: 0,
|
||||
enabled: true,
|
||||
groupCode: "업종_코스피",
|
||||
subject: "코스피",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "",
|
||||
pageText: "1/6",
|
||||
dataCode: ""
|
||||
};
|
||||
const load = namedPlaylists.normalizeLoadResponse({
|
||||
requestId: "industry-load-1",
|
||||
definition: { programCode: "00000001", title: "Industry" },
|
||||
retrievedAt: "2026-07-12T09:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
rows: [rawRow]
|
||||
});
|
||||
const restoration = namedPlaylists.restoreRawRows(load, row => {
|
||||
const descriptor = legacyRows.industryFixedDescriptor(row);
|
||||
return industry.createIndustryPlaylistEntry(descriptor.market, descriptor.cutId, {
|
||||
id: "industry-fixed-five-row",
|
||||
fadeDuration: 6,
|
||||
now: new Date(2026, 6, 12, 9, 0, 0)
|
||||
});
|
||||
}, legacyRows.matchesCanonicalEntry);
|
||||
assert.equal(restoration.rows[0].trusted, true);
|
||||
assert.equal(restoration.rows[0].entry.selection.dataCode, "2026-07-12");
|
||||
|
||||
const preflight = namedPlaylists.preparePagePreflight(
|
||||
restoration,
|
||||
entry => entry.builderKey === "s5074");
|
||||
assert.equal(preflight.rows[0].pageRecalculationRequired, true);
|
||||
assert.equal(preflight.readyForPrepare, false);
|
||||
const request = namedPlaylists.createPagePlanRequest("industry-page-plan-1", preflight);
|
||||
assert.equal(request.entries[0].selection.dataCode, "2026-07-12");
|
||||
assert.equal(request.entries[0].code, "5074");
|
||||
});
|
||||
@@ -4,6 +4,7 @@ const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/named-manual-restore-workflow.js");
|
||||
const manualLists = require("../../Web/manual-lists-workflow.js");
|
||||
const manualFinancial = require("../../Web/manual-financial-workflow.js");
|
||||
const namedPlaylists = require("../../Web/named-playlist-workflow.js");
|
||||
|
||||
function raw(overrides = {}) {
|
||||
return {
|
||||
@@ -14,6 +15,7 @@ function raw(overrides = {}) {
|
||||
graphicType: "MANUAL_NET_SELL",
|
||||
subtype: "MANUAL_NET_SELL",
|
||||
dataCode: "",
|
||||
pageText: "1/1",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
@@ -31,7 +33,7 @@ test("s5025 raw restoration accepts only canonical or exact legacy audience sign
|
||||
|
||||
const legacy = workflow.classify(raw({
|
||||
groupCode: "외국인 순매도 상위(수동)",
|
||||
subject: "",
|
||||
subject: "지수",
|
||||
graphicType: "순매도 상위",
|
||||
subtype: "순매도 상위"
|
||||
}), options);
|
||||
@@ -39,11 +41,12 @@ test("s5025 raw restoration accepts only canonical or exact legacy audience sign
|
||||
assert.equal(legacy.historical, true);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "외국인 순매도 상위",
|
||||
subject: "",
|
||||
subject: "지수",
|
||||
graphicType: "순매도 상위",
|
||||
subtype: "순매도 상위"
|
||||
}), options), null);
|
||||
assert.equal(workflow.classify(raw({ dataCode: "path.dat" }), options), null);
|
||||
assert.equal(workflow.classify(raw({ pageText: "1/2" }), options), null);
|
||||
});
|
||||
|
||||
test("s5025 becomes playable only after one correlated trusted five-row read", () => {
|
||||
@@ -57,6 +60,7 @@ test("s5025 becomes playable only after one correlated trusted five-row read", (
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.enabled, false);
|
||||
assert.equal(entry.builderKey, "s5025");
|
||||
assert.equal(manualLists.isPlaylistEntryPlayable(entry), true);
|
||||
assert.equal(manualLists.restorePlaylistEntry(entry).operator.audience, "INDIVIDUAL");
|
||||
assert.equal(workflow.materializeManualList(pending, {
|
||||
requestId: "stale-read",
|
||||
@@ -84,6 +88,17 @@ test("versioned and historical VI raw rows require the current exact native snap
|
||||
};
|
||||
assert.equal(workflow.materializeManualList(current, payload, request).builderKey, "s5074");
|
||||
assert.equal(workflow.materializeManualList(current, { ...payload, version: "b".repeat(64) }, request), null);
|
||||
assert.equal(workflow.materializeManualList(
|
||||
workflow.classify(raw({
|
||||
groupCode: "PAGED_VI",
|
||||
subject: manualLists.viSnapshotSubject,
|
||||
graphicType: "INPUT_ORDER",
|
||||
subtype: "CURRENT",
|
||||
dataCode: version,
|
||||
pageText: "1/2"
|
||||
}), options),
|
||||
payload,
|
||||
request), null);
|
||||
|
||||
const historical = workflow.classify(raw({
|
||||
groupCode: "",
|
||||
@@ -102,11 +117,28 @@ test("versioned and historical VI raw rows require the current exact native snap
|
||||
requestId: "named-vi-history",
|
||||
items: [...payload.items].reverse()
|
||||
}, historicalRequest), null);
|
||||
assert.equal(workflow.classify(raw({
|
||||
const duplicate = workflow.classify(raw({
|
||||
groupCode: "",
|
||||
subject: "삼성전자,삼성전자",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "VI 발동(수동)"
|
||||
}), options);
|
||||
const duplicateRequest = workflow.createManualListReadRequest("named-vi-duplicate", duplicate);
|
||||
assert.ok(workflow.materializeManualList(duplicate, {
|
||||
...payload,
|
||||
requestId: "named-vi-duplicate",
|
||||
items: [payload.items[0], payload.items[0]]
|
||||
}, duplicateRequest));
|
||||
assert.equal(workflow.materializeManualList(duplicate, {
|
||||
...payload,
|
||||
requestId: "named-vi-duplicate"
|
||||
}, duplicateRequest), null);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "",
|
||||
subject: "삼성전자,SK하이닉스",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "VI 발동(수동)",
|
||||
pageText: "1/2"
|
||||
}), options), null);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "",
|
||||
@@ -132,6 +164,20 @@ test("GraphE raw rows yield only a fresh-load descriptor with exact screen, mark
|
||||
assert.deepEqual(pending.identity, { screen: profile.screen, stockName: "삼성전자" });
|
||||
}
|
||||
const profile = manualFinancial.sourceContract.screens[0];
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "KOSPI",
|
||||
subject: "삼성전자",
|
||||
graphicType: profile.label,
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
}), options), null);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "코스피",
|
||||
subject: "삼성전자",
|
||||
graphicType: profile.graphicType,
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
}), options), null);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "UNKNOWN",
|
||||
subject: "삼성전자",
|
||||
@@ -146,4 +192,168 @@ test("GraphE raw rows yield only a fresh-load descriptor with exact screen, mark
|
||||
subtype: "",
|
||||
dataCode: "005930"
|
||||
}), options), null);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "KOSPI",
|
||||
subject: "삼성전자",
|
||||
graphicType: profile.graphicType,
|
||||
subtype: "",
|
||||
dataCode: "",
|
||||
pageText: "1/2"
|
||||
}), options), null);
|
||||
});
|
||||
|
||||
function createFreshGraphEntry() {
|
||||
const screen = "sales";
|
||||
const record = {
|
||||
kind: screen,
|
||||
stockName: "삼성전자",
|
||||
quarters: ["1Q", "2Q", "3Q", "4Q", "5Q", "6Q"]
|
||||
.map((quarter, index) => ({ quarter, value: index + 1 }))
|
||||
};
|
||||
const rowVersion = "A".repeat(64);
|
||||
const loadRequest = manualFinancial.createLoadRequest("named-save-load", screen, "삼성전자");
|
||||
const load = manualFinancial.normalizeLoadResponse({
|
||||
requestId: "named-save-load",
|
||||
screen,
|
||||
retrievedAt: "2026-07-12T01:00:00+09:00",
|
||||
snapshot: { stockName: "삼성전자", rowVersion, record }
|
||||
}, loadRequest);
|
||||
const stockResponse = manualFinancial.normalizeStockSearchResponse({
|
||||
requestId: "named-save-stock",
|
||||
query: "삼성전자",
|
||||
retrievedAt: "2026-07-12T01:00:01+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }]
|
||||
});
|
||||
const stock = manualFinancial.verifyStockForRecord(record, stockResponse, "kospi", "005930");
|
||||
const selectionRequest = manualFinancial.createSelectionRequest("named-save-selection", load.snapshot, stock);
|
||||
const selection = manualFinancial.normalizeSelectionResponse({
|
||||
requestId: "named-save-selection",
|
||||
screen,
|
||||
stockName: "삼성전자",
|
||||
rowVersion,
|
||||
builderKey: "s5080",
|
||||
code: "5080",
|
||||
aliases: ["5080"],
|
||||
selection: {
|
||||
groupCode: "KOSPI",
|
||||
subject: "삼성전자",
|
||||
graphicType: "SALES",
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
},
|
||||
currentPage: 1,
|
||||
totalPages: 1
|
||||
}, selectionRequest);
|
||||
return manualFinancial.createPlaylistEntry(load.snapshot, stock, selection, {
|
||||
id: "named-save-graph",
|
||||
fadeDuration: 6
|
||||
});
|
||||
}
|
||||
|
||||
test("named save accepts only live fresh-read manual identities", () => {
|
||||
const netRead = manualLists.normalizeNetSellDataResponse({
|
||||
requestId: "named-save-net-read",
|
||||
audience: "FOREIGN",
|
||||
rows
|
||||
}, { requestId: "named-save-net-read", audience: "FOREIGN" });
|
||||
const net = manualLists.createNetSellPlaylistEntry("FOREIGN", {
|
||||
id: "named-save-net",
|
||||
fadeDuration: 6
|
||||
}, netRead);
|
||||
assert.equal(workflow.isTrustedEntryForSave(net, 0), true);
|
||||
assert.equal(workflow.isTrustedEntryForSave(JSON.parse(JSON.stringify(net)), 0), false);
|
||||
|
||||
const version = "b".repeat(64);
|
||||
const viPayload = {
|
||||
requestId: "named-save-vi-read",
|
||||
itemCount: 2,
|
||||
pageCount: 1,
|
||||
items: [{ code: "P005930", name: "삼성전자" }, { code: "P000660", name: "SK하이닉스" }],
|
||||
version
|
||||
};
|
||||
const viRead = manualLists.normalizeViDataResponse(viPayload, viPayload.requestId);
|
||||
const vi = manualLists.createViPlaylistEntry(viRead.items, viRead.version, {
|
||||
id: "named-save-vi",
|
||||
fadeDuration: 6
|
||||
}, viRead);
|
||||
assert.equal(workflow.isTrustedEntryForSave(vi, 1), true);
|
||||
vi.pageCount = 2;
|
||||
assert.equal(workflow.isTrustedEntryForSave(vi, 1), false);
|
||||
|
||||
const graph = createFreshGraphEntry();
|
||||
assert.equal(workflow.isTrustedEntryForSave(graph, 2), true);
|
||||
assert.equal(workflow.isTrustedEntryForSave(JSON.parse(JSON.stringify(graph)), 2), false);
|
||||
|
||||
vi.pageCount = 1;
|
||||
const replace = namedPlaylists.createReplaceRequest(
|
||||
"named-save-replace",
|
||||
"12345678",
|
||||
[net, vi, graph],
|
||||
{
|
||||
isTrustedEntry: workflow.isTrustedEntryForSave,
|
||||
pageTextForEntry: entry => entry.operator?.kind === "vi" ? `1/${entry.pageCount}` : "1/1",
|
||||
fieldsForEntry: workflow.legacyFieldsForEntry
|
||||
});
|
||||
assert.deepEqual(replace.items.map(item => [
|
||||
item.groupCode,
|
||||
item.subject,
|
||||
item.graphicType,
|
||||
item.subtype,
|
||||
item.pageText,
|
||||
item.dataCode
|
||||
]), [
|
||||
["외국인 순매도 상위(수동)", "지수", "순매도 상위", "순매도 상위", "1/1", ""],
|
||||
["", "삼성전자,SK하이닉스", "5단 표그래프", "VI 발동(수동)", "1/1", ""],
|
||||
["코스피", "삼성전자", "매출액", "", "1/1", ""]
|
||||
]);
|
||||
});
|
||||
|
||||
test("stored local FSell and VI entries require the same fresh-read materialization", () => {
|
||||
const net = manualLists.createNetSellPlaylistEntry("INSTITUTION", {
|
||||
id: "stored-local-net",
|
||||
fadeDuration: 6,
|
||||
enabled: false
|
||||
});
|
||||
const pendingNet = workflow.createStoredManualListPending(
|
||||
JSON.parse(JSON.stringify(net)),
|
||||
0);
|
||||
assert.equal(workflow.isStoredManualListPending(pendingNet), true);
|
||||
const netRequest = workflow.createManualListReadRequest("stored-net-read", pendingNet);
|
||||
const restoredNet = workflow.materializeManualList(pendingNet, {
|
||||
requestId: "stored-net-read",
|
||||
audience: "INSTITUTION",
|
||||
rows
|
||||
}, netRequest);
|
||||
assert.ok(restoredNet);
|
||||
assert.equal(restoredNet.enabled, false);
|
||||
assert.equal(manualLists.isPlaylistEntryPlayable(restoredNet), true);
|
||||
|
||||
const version = "c".repeat(64);
|
||||
const items = [{ code: "P005930", name: "삼성전자" }];
|
||||
const vi = manualLists.createViPlaylistEntry(items, version, {
|
||||
id: "stored-local-vi",
|
||||
fadeDuration: 6
|
||||
});
|
||||
const pendingVi = workflow.createStoredManualListPending(
|
||||
JSON.parse(JSON.stringify(vi)),
|
||||
1);
|
||||
const viRequest = workflow.createManualListReadRequest("stored-vi-read", pendingVi);
|
||||
const viPayload = {
|
||||
requestId: "stored-vi-read",
|
||||
itemCount: 1,
|
||||
pageCount: 1,
|
||||
items,
|
||||
version
|
||||
};
|
||||
assert.equal(workflow.materializeManualList(pendingVi, viPayload, viRequest).builderKey, "s5074");
|
||||
assert.equal(workflow.materializeManualList(
|
||||
pendingVi,
|
||||
{ ...viPayload, version: "d".repeat(64) },
|
||||
viRequest), null);
|
||||
assert.equal(workflow.createStoredManualListPending({
|
||||
...JSON.parse(JSON.stringify(vi)),
|
||||
selection: { ...vi.selection, dataCode: "tampered" }
|
||||
}, 1), null);
|
||||
});
|
||||
|
||||
46
tests/Web/named-nxt-theme-restore-app-integration.test.cjs
Normal file
46
tests/Web/named-nxt-theme-restore-app-integration.test.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..", "..");
|
||||
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "Web", "index.html"), "utf8");
|
||||
const native = fs.readFileSync(path.join(root, "MainWindow.NamedNxtThemeRestore.cs"), "utf8");
|
||||
|
||||
function functionBody(name, nextName) {
|
||||
const start = app.indexOf(`function ${name}(`);
|
||||
const end = app.indexOf(`function ${nextName}(`, start + 1);
|
||||
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
|
||||
return app.slice(start, end);
|
||||
}
|
||||
|
||||
test("named NXT restore remains correlated through native, Web, and page preflight", () => {
|
||||
assert.match(index, /src="named-nxt-theme-restore-workflow\.js"/u);
|
||||
assert.match(native, /PostMessage\("named-nxt-theme-restore-results"/u);
|
||||
assert.match(app, /nxtThemeRestorePending/u);
|
||||
assert.match(app, /case "named-nxt-theme-restore-results"/u);
|
||||
const finalize = functionBody("finalizeNamedManualRestores", "handleNamedManualListResults");
|
||||
assert.match(finalize, /state\.namedPlaylist\.nxtThemeRestorePending/u);
|
||||
assert.match(finalize, /prepareNamedPagePreflight/u);
|
||||
});
|
||||
|
||||
test("native-verified in-memory identity is not lost to an object clone before PREPARE", () => {
|
||||
const materialize = functionBody("materializeNamedRestoration", "namedPlaylistPrepareBlockers");
|
||||
assert.match(materialize,
|
||||
/namedNxtThemeRestoreWorkflow\.isMaterializedEntry\(row\.entry\)/u);
|
||||
assert.match(materialize,
|
||||
/trustedOperatorEntry[\s\S]*\? \(trustedOperatorEntry \? row\.entry : \{ \.\.\.row\.entry/u);
|
||||
const synchronize = functionBody("synchronizeThemeEntries", "clearOverseasTimeout");
|
||||
assert.match(synchronize,
|
||||
/namedNxtThemeRestoreWorkflow\.isMaterializedEntry\(refreshed\)[\s\S]*\? refreshed/u);
|
||||
});
|
||||
|
||||
test("NXT restore preserves the old DB code for save while current code drives playout", () => {
|
||||
const fields = functionBody("namedLegacyFieldsForEntry", "isTrustedNamedPlaylistEntry");
|
||||
assert.match(fields, /legacySelectionForEntry\(entry\)/u);
|
||||
assert.match(fields, /matchesRaw\(entry, rawRow\)/u);
|
||||
assert.match(fields, /과거 code와 현재 송출 code/u);
|
||||
});
|
||||
143
tests/Web/named-nxt-theme-restore-workflow.test.cjs
Normal file
143
tests/Web/named-nxt-theme-restore-workflow.test.cjs
Normal file
@@ -0,0 +1,143 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/named-nxt-theme-restore-workflow.js");
|
||||
|
||||
function raw(overrides = {}) {
|
||||
return {
|
||||
itemIndex: 4,
|
||||
enabled: true,
|
||||
groupCode: "테마_NXT",
|
||||
subject: "반도체(NXT)",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "테마-현재가(입력순)",
|
||||
pageText: "1/9",
|
||||
dataCode: "0123",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function resolved(overrides = {}) {
|
||||
return {
|
||||
itemIndex: 4,
|
||||
status: "resolved",
|
||||
actionId: "five-row-current",
|
||||
builderKey: "s5074",
|
||||
cutCode: "5074",
|
||||
pageSize: 5,
|
||||
sort: "INPUT_ORDER",
|
||||
session: "PRE_MARKET",
|
||||
themeTitle: "반도체",
|
||||
storedThemeCode: "0123",
|
||||
currentThemeCode: "87654321",
|
||||
previewItemCount: 7,
|
||||
liveItemCount: 6,
|
||||
previewIsTruncated: false,
|
||||
codeWasRemapped: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function response(row) {
|
||||
return {
|
||||
requestId: "load-1",
|
||||
retrievedAt: "2026-07-12T12:59:00+09:00",
|
||||
totalRowCount: 1,
|
||||
rows: [row]
|
||||
};
|
||||
}
|
||||
|
||||
test("classify routes every exact NXT theme group row for native fail-closed verification", () => {
|
||||
const pending = workflow.classify(raw({
|
||||
subject: "",
|
||||
graphicType: "unsupported",
|
||||
subtype: "",
|
||||
pageText: "",
|
||||
dataCode: ""
|
||||
}), { id: "db-4", fadeDuration: 6 });
|
||||
assert.equal(workflow.isPending(pending), true);
|
||||
assert.equal(pending.rawSelection.groupCode, "테마_NXT");
|
||||
assert.equal(workflow.classify(raw({ groupCode: "테마" }), { id: "db-4", fadeDuration: 6 }), null);
|
||||
});
|
||||
|
||||
test("normalizeResponse accepts one exact correlated current identity", () => {
|
||||
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||
const result = workflow.normalizeResponse(response(resolved()), "load-1", [pending]);
|
||||
assert.equal(result.rows[0].currentThemeCode, "87654321");
|
||||
assert.equal(result.rows[0].liveItemCount, 6);
|
||||
});
|
||||
|
||||
test("normalizeResponse rejects expanded, stale, uncorrelated, or impossible identities", () => {
|
||||
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||
assert.equal(workflow.normalizeResponse(response({ ...resolved(), extra: true }), "load-1", [pending]), null);
|
||||
assert.equal(workflow.normalizeResponse(response(resolved({ storedThemeCode: "999" })), "load-1", [pending]), null);
|
||||
assert.equal(workflow.normalizeResponse(response(resolved({ themeTitle: "다른제목" })), "load-1", [pending]), null);
|
||||
assert.equal(workflow.normalizeResponse(response(resolved({ liveItemCount: 8 })), "load-1", [pending]), null);
|
||||
assert.equal(workflow.normalizeResponse(response(resolved({ previewItemCount: 241, previewIsTruncated: false })), "load-1", [pending]), null);
|
||||
});
|
||||
|
||||
test("failed rows preserve a closed no-retry reason", () => {
|
||||
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||
for (const failure of [
|
||||
"INVALID_ROW", "UNSUPPORTED_ACTION", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS",
|
||||
"PREVIEW_EMPTY", "LIVE_ITEMS_EMPTY", "DATABASE_DATA_INVALID", "RESTORE_FAILED"
|
||||
]) {
|
||||
const result = workflow.normalizeResponse(response({
|
||||
itemIndex: 4,
|
||||
status: "failed",
|
||||
failure
|
||||
}), "load-1", [pending]);
|
||||
assert.equal(result.rows[0].failure, failure);
|
||||
}
|
||||
assert.equal(workflow.normalizeError({
|
||||
requestId: "load-1",
|
||||
code: "DATABASE_UNAVAILABLE",
|
||||
message: "재검증 실패",
|
||||
retryable: false
|
||||
}, "load-1").retryable, false);
|
||||
});
|
||||
|
||||
test("materialize uses only the current code while retaining the old raw identity for lossless save", () => {
|
||||
const sourceRaw = raw({ enabled: false });
|
||||
const pending = workflow.classify(sourceRaw, { id: "db-4", fadeDuration: 6 });
|
||||
const outcome = workflow.normalizeResponse(response(resolved()), "load-1", [pending]).rows[0];
|
||||
const entry = workflow.materialize(pending, outcome);
|
||||
|
||||
assert.equal(workflow.isMaterializedEntry(entry), true);
|
||||
assert.equal(entry.enabled, false);
|
||||
assert.equal(entry.selection.groupCode, "PAGED_NXT_THEME");
|
||||
assert.equal(entry.selection.dataCode, "87654321");
|
||||
assert.equal(entry.operator.theme.themeCode, "87654321");
|
||||
assert.equal(entry.pagePreview.itemCount, 6);
|
||||
assert.deepEqual(workflow.legacySelectionForEntry(entry), {
|
||||
groupCode: "테마_NXT",
|
||||
subject: "반도체(NXT)",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "테마-현재가(입력순)",
|
||||
dataCode: "0123"
|
||||
});
|
||||
assert.equal(workflow.matchesRaw(entry, sourceRaw), true);
|
||||
});
|
||||
|
||||
test("dynamic NXT session changes exactly at local 13:00 without losing native proof", () => {
|
||||
const sourceRaw = raw();
|
||||
const pending = workflow.classify(sourceRaw, { id: "db-4", fadeDuration: 6 });
|
||||
const outcome = workflow.normalizeResponse(response(resolved()), "load-1", [pending]).rows[0];
|
||||
const entry = workflow.materialize(pending, outcome);
|
||||
const atTwelve = workflow.refreshDynamicSession(entry, new Date(2026, 6, 12, 12, 59));
|
||||
const atThirteen = workflow.refreshDynamicSession(atTwelve, new Date(2026, 6, 12, 13, 0));
|
||||
|
||||
assert.equal(atTwelve.selection.graphicType, "PRE_MARKET");
|
||||
assert.equal(atThirteen.selection.graphicType, "AFTER_MARKET");
|
||||
assert.equal(workflow.isMaterializedEntry(atThirteen), true);
|
||||
assert.equal(workflow.matchesRaw(atThirteen, sourceRaw), true);
|
||||
});
|
||||
|
||||
test("materialization refuses a failed outcome and non-pending objects", () => {
|
||||
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||
assert.equal(workflow.materialize(pending, {
|
||||
itemIndex: 4,
|
||||
status: "failed",
|
||||
failure: "LIVE_ITEMS_EMPTY"
|
||||
}), null);
|
||||
assert.equal(workflow.materialize({}, resolved()), null);
|
||||
});
|
||||
@@ -127,6 +127,28 @@ test("trusted entries serialize to the exact original seven LIST_TEXT fields", (
|
||||
]);
|
||||
});
|
||||
|
||||
test("legacy LIST_TEXT accepts a bounded 100-name VI subject beyond 256 characters", () => {
|
||||
const names = Array.from(
|
||||
{ length: 100 },
|
||||
(_, index) => `종목${String(index).padStart(3, "0")}`).join(",");
|
||||
assert.ok(names.length > 256);
|
||||
const fields = ["1", "", names, "5단 표그래프", "VI 발동(수동)", "1/20", ""];
|
||||
const serialized = workflow.legacyListText(fields);
|
||||
assert.ok(serialized.length <= 4000);
|
||||
const response = workflow.normalizeLoadResponse(loadResponse([nativeRow({
|
||||
groupCode: "",
|
||||
subject: names,
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "VI 발동(수동)",
|
||||
pageText: "1/20",
|
||||
dataCode: ""
|
||||
})]));
|
||||
assert.equal(response.rawRows[0].subject, names);
|
||||
assert.throws(() => workflow.legacyListText([
|
||||
"1", "", "가".repeat(3990), "5단 표그래프", "VI 발동(수동)", "1/20", ""
|
||||
]), /too long/u);
|
||||
});
|
||||
|
||||
test("database replacement requires trusted verification and enforces the 1000-row bound", () => {
|
||||
assert.throws(() => workflow.createReplaceRequest(
|
||||
"replace", "00000001", [trustedEntry()]), /trusted-entry verifier/i);
|
||||
|
||||
102
tests/Web/native-operator-log-integration.test.cjs
Normal file
102
tests/Web/native-operator-log-integration.test.cjs
Normal file
@@ -0,0 +1,102 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const read = relativePath => fs.readFileSync(path.join(root, relativePath), "utf8");
|
||||
const host = read("MainWindow.xaml.cs");
|
||||
const playout = read("MainWindow.Playout.cs");
|
||||
const integration = read("MainWindow.NativeOperatorLog.cs");
|
||||
const writer = read("src/MBN_STOCK_WEBVIEW.Infrastructure/Diagnostics/NativeOperatorLogWriter.cs");
|
||||
const transitions = read("src/MBN_STOCK_WEBVIEW.Infrastructure/Diagnostics/NativeOperatorLogTransitions.cs");
|
||||
|
||||
function sliceBetween(source, startMarker, endMarker) {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start + startMarker.length);
|
||||
assert.ok(start >= 0, `${startMarker} must exist`);
|
||||
assert.ok(end > start, `${endMarker} must follow ${startMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("lifecycle and provider transitions are wired with closed scalars", () => {
|
||||
const constructor = sliceBetween(host, "public MainWindow()", "private void InitializeDatabaseRuntime(");
|
||||
assert.match(constructor,
|
||||
/InitializeComponent\(\);\s*LogNativeOperatorStartup\(\);\s*EnsureCloseConfirmationAttached\(\);/u);
|
||||
const closed = sliceBetween(host, "private void OnClosed(", "private sealed record WebRequest(");
|
||||
assert.match(closed, /LogNativeOperatorShutdown\(\);/u);
|
||||
assert.match(integration,
|
||||
/Interlocked\.Exchange\(ref _nativeStartupRecorded, 1\) == 0/u);
|
||||
assert.match(integration,
|
||||
/Interlocked\.Exchange\(ref _nativeShutdownRecorded, 1\) == 0/u);
|
||||
|
||||
const database = sliceBetween(
|
||||
host,
|
||||
"private async Task QueryAndPostDatabaseStatusAsync(",
|
||||
"private void PostDatabaseStatus(");
|
||||
assert.match(database,
|
||||
/ObserveNativeDatabaseState\(DataSourceKind\.Oracle, DatabaseHealthState\.NotConfigured\)/u);
|
||||
assert.match(database,
|
||||
/ObserveNativeDatabaseState\(DataSourceKind\.MariaDb, DatabaseHealthState\.NotConfigured\)/u);
|
||||
assert.match(database,
|
||||
/foreach \(var status in statuses\)[\s\S]*ObserveNativeDatabaseState\(status\.Source, status\.State\)/u);
|
||||
assert.match(database,
|
||||
/ObserveNativeDatabaseState\(DataSourceKind\.Oracle, DatabaseHealthState\.Unhealthy\)/u);
|
||||
assert.doesNotMatch(database, /ObserveNativeDatabaseState\([^\n]*(?:Message|Sql|Path|RequestId)/u);
|
||||
|
||||
assert.match(playout,
|
||||
/_playoutEngine\.StatusChanged \+= OnPlayoutStatusChanged;\s*ObserveNativeTornadoState\(_playoutEngine\.Status\.State\);/u);
|
||||
assert.match(playout,
|
||||
/catch \(Exception exception\)\s*\{\s*ObserveNativeTornadoState\(PlayoutConnectionState\.Faulted\);/u);
|
||||
assert.match(playout,
|
||||
/private void OnPlayoutStatusChanged[\s\S]*?ObserveNativeTornadoState\(args\.Current\.State\);/u);
|
||||
assert.match(transitions, /_oracleState != mapped/u);
|
||||
assert.match(transitions, /_mariaDbState != mapped/u);
|
||||
assert.match(transitions, /if \(_tornadoState == mapped\)/u);
|
||||
});
|
||||
|
||||
test("only gate-entered commands receive one request and a closed terminal outcome", () => {
|
||||
const commandHandler = sliceBetween(
|
||||
playout,
|
||||
"private async Task HandlePlayoutCommandAsync(",
|
||||
"private async Task HandlePlayoutTimeoutQuarantineAsync(");
|
||||
const gate = commandHandler.indexOf("_playoutCommandGate.WaitAsync(0)");
|
||||
const requested = commandHandler.indexOf("LogNativePlayoutCommandRequested(request.Command)");
|
||||
assert.ok(gate >= 0 && requested > gate, "request logging must happen only after gate entry");
|
||||
assert.equal(commandHandler.indexOf("LogNativePlayoutCommandRequested(request.Command)", requested + 1), -1);
|
||||
assert.match(commandHandler,
|
||||
/var result = await ExecutePlayoutCommandAsync[\s\S]*LogNativePlayoutCommandCompleted\(request\.Command, result\.Code\);/u);
|
||||
assert.match(commandHandler,
|
||||
/catch \(OperationCanceledException\)[\s\S]*NativeOperatorPlayoutCompletion\.Cancelled/u);
|
||||
assert.match(commandHandler,
|
||||
/catch \(DatabaseOperationException exception\)[\s\S]*NativeOperatorPlayoutCompletion\.Unavailable/u);
|
||||
assert.match(commandHandler,
|
||||
/LegacySceneDataException or ArgumentException\)[\s\S]*NativeOperatorPlayoutCompletion\.Rejected/u);
|
||||
assert.match(commandHandler, /catch\s*\{\s*LogNativePlayoutOutcomeUnknown\(request\.Command\);/u);
|
||||
assert.match(playout,
|
||||
/QuarantineForBrowserCorrelationLossAsync[\s\S]*LogCurrentNativePlayoutOutcomeUnknown\(\);/u);
|
||||
|
||||
assert.match(integration, /"prepare" => NativeOperatorPlayoutCommand\.Prepare/u);
|
||||
assert.match(integration, /"take-in" => NativeOperatorPlayoutCommand\.TakeIn/u);
|
||||
assert.match(integration, /"next" => NativeOperatorPlayoutCommand\.Next/u);
|
||||
assert.match(integration, /"take-out" => NativeOperatorPlayoutCommand\.TakeOut/u);
|
||||
for (const result of ["Success", "Rejected", "Cancelled", "Unavailable", "Failed"]) {
|
||||
assert.match(integration, new RegExp(`PlayoutResultCode\\.${result} => NativeOperatorPlayoutCompletion\\.`));
|
||||
}
|
||||
assert.match(integration,
|
||||
/result is PlayoutResultCode\.OutcomeUnknown or PlayoutResultCode\.TimedOut[\s\S]*TryMarkOutcomeUnknown/u);
|
||||
assert.match(transitions, /if \(_currentCommand != command \|\| _terminalRecorded\)/u);
|
||||
});
|
||||
|
||||
test("native boundary cannot receive or persist operational free-form data", () => {
|
||||
assert.match(writer, /public readonly record struct NativeOperatorLogRecord/u);
|
||||
assert.doesNotMatch(writer,
|
||||
/public\s+(?:string|Exception|JsonElement|object|long|int)\??\s+\w+\s*\{\s*get;/u);
|
||||
assert.doesNotMatch(integration, /(?:RequestId|Scene|Title|Path|Sql|Message)/u);
|
||||
assert.match(integration, /_nativeOperatorLogWriter\.TryWrite\(record\)/u);
|
||||
assert.match(integration,
|
||||
/try[\s\S]*_nativeOperatorLogWriter\.TryWrite\(record\);[\s\S]*catch/u);
|
||||
assert.doesNotMatch(integration, /TryWrite\((?:command|result|source|state|exception|payload)/u);
|
||||
});
|
||||
@@ -90,6 +90,21 @@ function completeSchema(h, overrides = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function searchAndSelectStock(h, identity, query = identity.name, expectedSelection = true) {
|
||||
assert.equal(h.controller.searchStocks(query), true);
|
||||
const pending = h.controller.render().activeRead;
|
||||
assert.equal(pending.type, workflow.stockMasterContract.request);
|
||||
assert.equal(h.controller.handleMessage(workflow.stockMasterContract.result, {
|
||||
requestId: pending.request.requestId,
|
||||
query: pending.request.query,
|
||||
retrievedAt,
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
results: [identity]
|
||||
}), true);
|
||||
assert.equal(h.controller.selectStockResult(0), expectedSelection);
|
||||
}
|
||||
|
||||
function mutationResult(active, overrides = {}) {
|
||||
return {
|
||||
requestId: active.requestId,
|
||||
@@ -124,12 +139,15 @@ test("theme edit starts only from a closed selection and complete preview", () =
|
||||
assert.throws(() => h.controller.openTheme({ selection: themeContext().selection }), /exactly/i);
|
||||
});
|
||||
|
||||
test("theme prefixes, duplicate checks and reordering remain explicit in replace requests", () => {
|
||||
test("theme stock identities, duplicate checks and reordering remain explicit in replace requests", () => {
|
||||
const h = harness();
|
||||
h.controller.openTheme(themeContext());
|
||||
completeSchema(h);
|
||||
assert.equal(h.controller.addThemeItem("X", "000660", "SK하이닉스"), false);
|
||||
assert.equal(h.controller.addThemeItem("P", "000660", "SK하이닉스"), true);
|
||||
assert.equal(h.controller.addThemeItem("P", "000660", "SK하이닉스"), false);
|
||||
searchAndSelectStock(h, {
|
||||
market: "kospi", source: "oracle", code: "000660", name: "SK하이닉스"
|
||||
});
|
||||
assert.equal(h.controller.addThemeItem(), true);
|
||||
assert.equal(h.controller.moveDraftItem(2, -1), true);
|
||||
assert.equal(h.controller.updateDraft({ themeTitle: "AI·로봇" }), true);
|
||||
|
||||
@@ -218,9 +236,22 @@ test("schema, catalog search and next-code reads are serialized and exactly corr
|
||||
writeQuarantined: false
|
||||
});
|
||||
assert.equal(h.controller.render().mode, "new");
|
||||
assert.equal(h.controller.addThemeItem("P", "005930", "삼성전자"), false);
|
||||
assert.equal(h.controller.addThemeItem("X", "005930", "삼성전자"), true);
|
||||
assert.equal(h.controller.addThemeItem("T", "000660", "SK하이닉스"), true);
|
||||
searchAndSelectStock(h, {
|
||||
market: "kospi", source: "oracle", code: "005930", name: "삼성전자"
|
||||
}, "삼성전자", false);
|
||||
assert.equal(h.controller.addThemeItem(), false);
|
||||
searchAndSelectStock(h, {
|
||||
market: "nxt-kospi", source: "mariaDb", code: "005930", name: "삼성전자(NXT)"
|
||||
});
|
||||
assert.equal(h.controller.addThemeItem(), true);
|
||||
searchAndSelectStock(h, {
|
||||
market: "nxt-kosdaq", source: "mariaDb", code: "000660", name: "SK하이닉스(NXT)"
|
||||
});
|
||||
assert.equal(h.controller.addThemeItem(), true);
|
||||
assert.deepEqual(h.controller.render().draft.items, [
|
||||
{ inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "T000660", itemName: "SK하이닉스" }
|
||||
]);
|
||||
});
|
||||
|
||||
test("expert recommendations preserve exact code, name, buy amount and order", () => {
|
||||
@@ -228,7 +259,10 @@ test("expert recommendations preserve exact code, name, buy amount and order", (
|
||||
h.controller.openExpert(expertContext());
|
||||
completeSchema(h);
|
||||
assert.equal(h.controller.addExpertRecommendation("005930", "중복", 1), false);
|
||||
assert.equal(h.controller.addExpertRecommendation("000660", "SK하이닉스", "123456"), true);
|
||||
searchAndSelectStock(h, {
|
||||
market: "kospi", source: "oracle", code: "000660", name: "SK하이닉스"
|
||||
});
|
||||
assert.equal(h.controller.addExpertRecommendation("123456"), true);
|
||||
assert.equal(h.controller.moveDraftItem(2, -1), true);
|
||||
assert.equal(h.controller.updateDraft({ expertName: "홍길동 위원" }), true);
|
||||
assert.equal(h.controller.submit(), true);
|
||||
@@ -243,6 +277,36 @@ test("expert recommendations preserve exact code, name, buy amount and order", (
|
||||
]);
|
||||
});
|
||||
|
||||
test("stock search stale and truncated responses fail closed without selecting free text", () => {
|
||||
const h = harness();
|
||||
h.controller.openTheme(themeContext());
|
||||
completeSchema(h);
|
||||
assert.equal(h.controller.searchStocks("삼성"), true);
|
||||
const pending = h.controller.render().activeRead;
|
||||
assert.equal(h.controller.handleMessage(workflow.stockMasterContract.result, {
|
||||
requestId: "stale-request",
|
||||
query: pending.request.query,
|
||||
retrievedAt,
|
||||
totalRowCount: 0,
|
||||
truncated: false,
|
||||
results: []
|
||||
}), false);
|
||||
assert.equal(h.controller.render().activeRead.request.requestId, pending.request.requestId);
|
||||
assert.equal(h.controller.handleMessage(workflow.stockMasterContract.result, {
|
||||
requestId: pending.request.requestId,
|
||||
query: pending.request.query,
|
||||
retrievedAt,
|
||||
totalRowCount: 1,
|
||||
truncated: true,
|
||||
results: [{
|
||||
market: "kospi", source: "oracle", code: "005930", name: "삼성전자"
|
||||
}]
|
||||
}), true);
|
||||
assert.equal(h.controller.render().stockSearch.selected, null);
|
||||
assert.equal(h.controller.render().stockSearch.results.length, 0);
|
||||
assert.equal(h.controller.addThemeItem("P", "005930", "삼성전자"), false);
|
||||
});
|
||||
|
||||
test("OutcomeUnknown latches a process quarantine and never retries", () => {
|
||||
const h = harness();
|
||||
h.controller.openExpert(expertContext());
|
||||
@@ -295,13 +359,36 @@ class FakeElement {
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this.open = false;
|
||||
this.parentElement = null;
|
||||
this.isContentEditable = false;
|
||||
}
|
||||
append(...values) { values.forEach(value => this.appendChild(value)); }
|
||||
appendChild(value) { this.children.push(value); return value; }
|
||||
replaceChildren(...values) { this.children = []; this.append(...values); }
|
||||
appendChild(value) {
|
||||
value.parentElement = this;
|
||||
this.children.push(value);
|
||||
return value;
|
||||
}
|
||||
replaceChildren(...values) {
|
||||
this.children.forEach(value => { value.parentElement = null; });
|
||||
this.children = [];
|
||||
this.append(...values);
|
||||
}
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; }
|
||||
removeAttribute(name) { this.attributes.delete(name); }
|
||||
addEventListener(name, callback) { this.listeners.set(name, callback); }
|
||||
contains(value) {
|
||||
if (value === this) return true;
|
||||
return this.children.some(child => child.contains(value));
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
if (!event.target) event.target = this;
|
||||
event.currentTarget = this;
|
||||
const callback = this.listeners.get(event.type);
|
||||
if (callback) callback(event);
|
||||
if (!event.propagationStopped && this.parentElement) this.parentElement.dispatchEvent(event);
|
||||
return !event.defaultPrevented;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
@@ -332,6 +419,21 @@ function findRole(node, role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function fakeEvent(type, values = {}) {
|
||||
return {
|
||||
type,
|
||||
defaultPrevented: false,
|
||||
propagationStopped: false,
|
||||
preventDefault() { this.defaultPrevented = true; },
|
||||
stopPropagation() { this.propagationStopped = true; },
|
||||
...values
|
||||
};
|
||||
}
|
||||
|
||||
function draftRow(document, index) {
|
||||
return findRole(document.body, "draft-list").children[index];
|
||||
}
|
||||
|
||||
test("mount creates a dialog with text-only rendering and its own stylesheet", () => {
|
||||
const h = harness();
|
||||
const document = new FakeDocument();
|
||||
@@ -340,9 +442,185 @@ test("mount creates a dialog with text-only rendering and its own stylesheet", (
|
||||
assert.equal(document.body.children[0].tagName, "DIALOG");
|
||||
assert.equal(document.head.children[0].href, "operator-catalog-ui.css");
|
||||
assert.equal(findRole(document.body, "identity-name").value, "<b>AI</b>");
|
||||
assert.ok(findRole(document.body, "stock-query"));
|
||||
assert.equal(findRole(document.body, "stock-code"), null);
|
||||
assert.equal(findRole(document.body, "stock-name"), null);
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../Web/operator-catalog-ui.js"),
|
||||
"utf8");
|
||||
assert.doesNotMatch(source, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\s*\(/);
|
||||
});
|
||||
|
||||
test("Delete removes only the focused or selected expert draft row through the row removal mutation", () => {
|
||||
const h = harness();
|
||||
const document = new FakeDocument();
|
||||
h.controller.mount(document.body);
|
||||
h.controller.openExpert(expertContext());
|
||||
|
||||
let row = draftRow(document, 1);
|
||||
row.dispatchEvent(fakeEvent("focus"));
|
||||
assert.equal(h.controller.render().selectedDraftIndex, 1);
|
||||
row = draftRow(document, 1);
|
||||
const deleteKey = fakeEvent("keydown", { key: "Delete" });
|
||||
row.dispatchEvent(deleteKey);
|
||||
|
||||
assert.equal(deleteKey.defaultPrevented, true);
|
||||
assert.equal(deleteKey.propagationStopped, true);
|
||||
assert.deepEqual(h.controller.render().draft.recommendations, [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 }
|
||||
]);
|
||||
|
||||
row = draftRow(document, 0);
|
||||
const rowDeleteButton = row.children[3];
|
||||
rowDeleteButton.dispatchEvent(fakeEvent("click"));
|
||||
assert.deepEqual(h.controller.render().draft.recommendations, []);
|
||||
});
|
||||
|
||||
test("Delete removes focused and selected ThemeA rows, reindexes, and stops at the empty boundary", () => {
|
||||
const h = harness();
|
||||
const document = new FakeDocument();
|
||||
const context = themeContext();
|
||||
context.preview.totalRowCount = 3;
|
||||
context.preview.items.push({ inputIndex: 9, itemCode: "P000660", itemName: "SK하이닉스" });
|
||||
h.controller.mount(document.body);
|
||||
h.controller.openTheme(context);
|
||||
const nativeRequestCount = h.posted.length;
|
||||
|
||||
let row = draftRow(document, 1);
|
||||
row.dispatchEvent(fakeEvent("focus"));
|
||||
const focusedDelete = fakeEvent("keydown", { key: "Delete" });
|
||||
row.dispatchEvent(focusedDelete);
|
||||
assert.equal(focusedDelete.defaultPrevented, true);
|
||||
assert.equal(focusedDelete.propagationStopped, true);
|
||||
assert.equal(h.controller.render().selectedDraftIndex, 1);
|
||||
assert.deepEqual(h.controller.render().draft.items, [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "P000660", itemName: "SK하이닉스" }
|
||||
]);
|
||||
|
||||
const selectedDelete = fakeEvent("keydown", { key: "Delete" });
|
||||
findRole(document.body, "draft-list").dispatchEvent(selectedDelete);
|
||||
assert.equal(selectedDelete.defaultPrevented, true);
|
||||
assert.equal(selectedDelete.propagationStopped, true);
|
||||
assert.equal(h.controller.render().selectedDraftIndex, 0);
|
||||
assert.deepEqual(h.controller.render().draft.items, [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" }
|
||||
]);
|
||||
|
||||
row = draftRow(document, 0);
|
||||
row.dispatchEvent(fakeEvent("keydown", { key: "Delete" }));
|
||||
assert.equal(h.controller.render().selectedDraftIndex, -1);
|
||||
assert.deepEqual(h.controller.render().draft.items, []);
|
||||
|
||||
const emptyBoundary = fakeEvent("keydown", { key: "Delete" });
|
||||
findRole(document.body, "draft-list").dispatchEvent(emptyBoundary);
|
||||
assert.equal(emptyBoundary.defaultPrevented, false);
|
||||
assert.equal(emptyBoundary.propagationStopped, false);
|
||||
assert.deepEqual(h.controller.render().draft.items, []);
|
||||
assert.equal(h.posted.length, nativeRequestCount);
|
||||
});
|
||||
|
||||
test("Delete ignores editor inputs, contenteditable regions, closed modal, and outside events", () => {
|
||||
for (const entity of ["theme", "expert"]) {
|
||||
const h = harness();
|
||||
const document = new FakeDocument();
|
||||
const outside = document.createElement("button");
|
||||
document.body.appendChild(outside);
|
||||
h.controller.mount(document.body);
|
||||
if (entity === "theme") h.controller.openTheme(themeContext());
|
||||
else h.controller.openExpert(expertContext());
|
||||
|
||||
let row = draftRow(document, 0);
|
||||
row.dispatchEvent(fakeEvent("focus"));
|
||||
const textarea = document.createElement("textarea");
|
||||
const contentEditable = document.createElement("div");
|
||||
contentEditable.setAttribute("contenteditable", "true");
|
||||
document.body.children.find(element => element.tagName === "DIALOG")
|
||||
.append(textarea, contentEditable);
|
||||
for (const editable of [findRole(document.body, "identity-name"), textarea, contentEditable]) {
|
||||
const editableDelete = fakeEvent("keydown", { key: "Delete" });
|
||||
editable.dispatchEvent(editableDelete);
|
||||
assert.equal(editableDelete.defaultPrevented, false, entity);
|
||||
assert.equal(editableDelete.propagationStopped, false, entity);
|
||||
}
|
||||
const draftLength = () => entity === "theme"
|
||||
? h.controller.render().draft.items.length
|
||||
: h.controller.render().draft.recommendations.length;
|
||||
assert.equal(draftLength(), 2, entity);
|
||||
|
||||
const outsideDelete = fakeEvent("keydown", { key: "Delete" });
|
||||
outside.dispatchEvent(outsideDelete);
|
||||
assert.equal(outsideDelete.defaultPrevented, false, entity);
|
||||
assert.equal(outsideDelete.propagationStopped, false, entity);
|
||||
assert.equal(draftLength(), 2, entity);
|
||||
|
||||
row = draftRow(document, 0);
|
||||
h.controller.close();
|
||||
const closedDelete = fakeEvent("keydown", { key: "Delete" });
|
||||
row.dispatchEvent(closedDelete);
|
||||
assert.equal(closedDelete.defaultPrevented, false, entity);
|
||||
assert.equal(closedDelete.propagationStopped, false, entity);
|
||||
assert.equal(draftLength(), 2, entity);
|
||||
}
|
||||
});
|
||||
|
||||
test("ThemeA draft Delete is consumed but blocked while locked or a DB write is active", () => {
|
||||
const h = harness();
|
||||
const document = new FakeDocument();
|
||||
h.controller.mount(document.body);
|
||||
h.controller.openTheme(themeContext());
|
||||
completeSchema(h);
|
||||
|
||||
let row = draftRow(document, 0);
|
||||
row.dispatchEvent(fakeEvent("focus"));
|
||||
h.setExternalLocked(true);
|
||||
h.controller.render();
|
||||
row = draftRow(document, 0);
|
||||
const lockedDelete = fakeEvent("keydown", { key: "Delete" });
|
||||
row.dispatchEvent(lockedDelete);
|
||||
assert.equal(lockedDelete.defaultPrevented, true);
|
||||
assert.equal(lockedDelete.propagationStopped, true);
|
||||
assert.equal(h.controller.render().draft.items.length, 2);
|
||||
|
||||
h.setExternalLocked(false);
|
||||
h.controller.render();
|
||||
assert.equal(h.controller.submit(), true);
|
||||
assert.ok(h.controller.render().mutation);
|
||||
row = draftRow(document, 0);
|
||||
const writingDelete = fakeEvent("keydown", { key: "Delete" });
|
||||
row.dispatchEvent(writingDelete);
|
||||
assert.equal(writingDelete.defaultPrevented, true);
|
||||
assert.equal(writingDelete.propagationStopped, true);
|
||||
assert.equal(h.controller.render().draft.items.length, 2);
|
||||
});
|
||||
|
||||
test("expert draft Delete is consumed but blocked while locked or a DB write is active", () => {
|
||||
const h = harness();
|
||||
const document = new FakeDocument();
|
||||
h.controller.mount(document.body);
|
||||
h.controller.openExpert(expertContext());
|
||||
completeSchema(h);
|
||||
|
||||
let row = draftRow(document, 0);
|
||||
row.dispatchEvent(fakeEvent("focus"));
|
||||
h.setExternalLocked(true);
|
||||
h.controller.render();
|
||||
row = draftRow(document, 0);
|
||||
const lockedDelete = fakeEvent("keydown", { key: "Delete" });
|
||||
row.dispatchEvent(lockedDelete);
|
||||
assert.equal(lockedDelete.defaultPrevented, true);
|
||||
assert.equal(lockedDelete.propagationStopped, true);
|
||||
assert.equal(h.controller.render().draft.recommendations.length, 2);
|
||||
|
||||
h.setExternalLocked(false);
|
||||
h.controller.render();
|
||||
assert.equal(h.controller.submit(), true);
|
||||
assert.ok(h.controller.render().mutation);
|
||||
row = draftRow(document, 0);
|
||||
const writingDelete = fakeEvent("keydown", { key: "Delete" });
|
||||
row.dispatchEvent(writingDelete);
|
||||
assert.equal(writingDelete.defaultPrevented, true);
|
||||
assert.equal(writingDelete.propagationStopped, true);
|
||||
assert.equal(h.controller.render().draft.recommendations.length, 2);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,10 @@ const playoutBridge = fs.readFileSync(
|
||||
const operatorGate = fs.readFileSync(
|
||||
path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Playout", "OperatorMutationGate.cs"),
|
||||
"utf8");
|
||||
const stockMasterValidation = fs.readFileSync(
|
||||
path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Data",
|
||||
"StockMasterIdentityValidation.cs"),
|
||||
"utf8");
|
||||
|
||||
const krxSelection = Object.freeze({
|
||||
market: "krx",
|
||||
@@ -89,6 +93,11 @@ test("bridge contract pins every independent request and event name", () => {
|
||||
mutation: "operator-catalog-mutation-results",
|
||||
error: "operator-catalog-error"
|
||||
});
|
||||
assert.deepEqual(workflow.stockMasterContract, {
|
||||
request: "search-stocks",
|
||||
result: "stock-search-results",
|
||||
error: "stock-search-error"
|
||||
});
|
||||
assert.deepEqual(workflow.limits, {
|
||||
maximumThemeItems: 240,
|
||||
maximumRecommendations: 100,
|
||||
@@ -97,6 +106,43 @@ test("bridge contract pins every independent request and event name", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("stock master search accepts only complete unique typed identities", () => {
|
||||
const request = workflow.createStockMasterSearchRequest("stock-master-1", " 삼성 ", 25);
|
||||
assert.deepEqual(request, {
|
||||
requestId: "stock-master-1",
|
||||
query: "삼성",
|
||||
maximumResults: 25
|
||||
});
|
||||
const base = {
|
||||
requestId: request.requestId,
|
||||
query: request.query,
|
||||
retrievedAt: "2026-07-12T10:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
results: [{
|
||||
market: "kospi", source: "oracle", name: "삼성전자", code: "005930"
|
||||
}]
|
||||
};
|
||||
assert.ok(workflow.normalizeStockMasterSearchResult(base, request));
|
||||
assert.equal(workflow.normalizeStockMasterSearchResult({ ...base, truncated: true }, request), null);
|
||||
assert.equal(workflow.normalizeStockMasterSearchResult({
|
||||
...base,
|
||||
results: [{ ...base.results[0], extra: true }]
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeStockMasterSearchResult({
|
||||
...base,
|
||||
totalRowCount: 2,
|
||||
results: [
|
||||
base.results[0],
|
||||
{ market: "kosdaq", source: "oracle", name: "삼성전자", code: "123456" }
|
||||
]
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeStockMasterSearchResult({
|
||||
...base,
|
||||
requestId: "stale"
|
||||
}, request), null);
|
||||
});
|
||||
|
||||
test("native partial pins the same contract and fail-closed write gates without a retry loop", () => {
|
||||
for (const requestType of Object.values(workflow.bridgeContract.requests)) {
|
||||
assert.match(nativeBridge, new RegExp(`"${requestType}"`));
|
||||
@@ -120,6 +166,13 @@ test("native partial pins the same contract and fail-closed write gates without
|
||||
assert.match(nativeBridge, /LatchOperatorCatalogWriteQuarantine/);
|
||||
assert.match(nativeBridge, /OUTCOME_UNKNOWN/);
|
||||
assert.match(nativeBridge, /OperatorCatalogMutationExecutor/);
|
||||
assert.match(nativeBridge, /LegacyStockMasterIdentityValidationService/);
|
||||
assert.match(nativeBridge,
|
||||
/await validationBody\(requestToken\)[\s\S]*mutationDispatched = true;/s);
|
||||
assert.match(stockMasterValidation, /STOCK_MASTER_VERIFY_KOSPI/);
|
||||
assert.match(stockMasterValidation, /STOCK_MASTER_VERIFY_KOSDAQ/);
|
||||
assert.match(stockMasterValidation, /STOCK_MASTER_VERIFY_NXT_KOSPI/);
|
||||
assert.match(stockMasterValidation, /STOCK_MASTER_VERIFY_NXT_KOSDAQ/);
|
||||
assert.doesNotMatch(nativeBridge, /MaximumRetryCount|Task\.Delay|while\s*\(/);
|
||||
});
|
||||
|
||||
@@ -247,6 +300,23 @@ test("new theme drafts require a valid title before create and delete keeps full
|
||||
});
|
||||
});
|
||||
|
||||
test("existing theme edits preserve legacy three through eight digit codes", () => {
|
||||
const edit = workflow.createThemeEditDraft(
|
||||
{ ...krxSelection, themeCode: "007" },
|
||||
{
|
||||
...themePreview(),
|
||||
selection: { ...themePreview().selection, themeCode: "007" }
|
||||
});
|
||||
edit.draft.themeTitle = "수정 테마";
|
||||
assert.equal(
|
||||
workflow.createThemeReplaceRequest("legacy-theme-replace", edit)
|
||||
.expectedIdentity.themeCode,
|
||||
"007");
|
||||
assert.throws(() => workflow.createThemeCreateRequest(
|
||||
"legacy-theme-create",
|
||||
edit.draft), /eight-digit/i);
|
||||
});
|
||||
|
||||
test("expert selection plus preview becomes editable and preserves buy amounts while reindexing", () => {
|
||||
const edit = workflow.createExpertEditDraft(expertSelection, expertPreview());
|
||||
assert.deepEqual(edit, {
|
||||
|
||||
@@ -12,10 +12,13 @@ const manualListsWorkflow = require("../../Web/manual-lists-workflow.js");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const read = relativePath => fs.readFileSync(path.join(repositoryRoot, relativePath), "utf8");
|
||||
const appHost = read("App.xaml.cs");
|
||||
const app = read("Web/app.js");
|
||||
const index = read("Web/index.html");
|
||||
const styles = read("Web/styles.css");
|
||||
const manualFinancialUi = read("Web/manual-financial-ui.js");
|
||||
const mainWindow = read("MainWindow.xaml.cs");
|
||||
const closeConfirmationBridge = read("MainWindow.CloseConfirmation.cs");
|
||||
const manualFinancialBridge = read("MainWindow.ManualFinancial.cs");
|
||||
const manualListsBridge = read("MainWindow.ManualLists.cs");
|
||||
const namedPlaylistBridge = read("MainWindow.NamedPlaylists.cs");
|
||||
@@ -67,6 +70,24 @@ test("operator styles and scripts load once in dependency order before app.js",
|
||||
]);
|
||||
});
|
||||
|
||||
test("the packaged app registers one primary instance before constructing any runtime", () => {
|
||||
const register = appHost.indexOf("AppInstance.FindOrRegisterForKey(MainInstanceKey)");
|
||||
const currentCheck = appHost.indexOf("if (!registeredInstance.IsCurrent)", register);
|
||||
const redirect = appHost.indexOf("RedirectActivationToAsync(activation)", currentCheck);
|
||||
const secondaryExit = appHost.indexOf("Exit();", redirect);
|
||||
const windowConstruction = appHost.indexOf("_window = new MainWindow();", secondaryExit);
|
||||
|
||||
assert.ok(register >= 0);
|
||||
assert.ok(currentCheck > register);
|
||||
assert.ok(redirect > currentCheck);
|
||||
assert.ok(secondaryExit > redirect);
|
||||
assert.ok(windowConstruction > secondaryExit);
|
||||
assert.match(appHost, /_mainInstance\.Activated \+= OnMainInstanceActivated/u);
|
||||
assert.match(appHost,
|
||||
/OnMainInstanceActivated\([\s\S]*DispatcherQueue\.TryEnqueue\(window\.Activate\)/u);
|
||||
assert.doesNotMatch(appHost, /GetProcessesByName|Process\.Kill|Environment\.Exit/u);
|
||||
});
|
||||
|
||||
test("legacy GraphE, FSell, VIList, ThemeA and EList controls open their real controllers", () => {
|
||||
assert.match(index, /id="themeCatalogManageButton"/u);
|
||||
assert.match(index, /id="expertCatalogManageButton"/u);
|
||||
@@ -90,6 +111,52 @@ test("legacy GraphE, FSell, VIList, ThemeA and EList controls open their real co
|
||||
assert.match(app, /expertCatalogManageButton\.disabled = operatorUiLocked\(\)/u);
|
||||
});
|
||||
|
||||
test("the original stock search and 31-cut surface stays independent of every business tab", () => {
|
||||
const stockPanelStart = index.indexOf('<section class="panel stock-panel">');
|
||||
const stockWorkflow = index.indexOf('id="stockWorkflow"', stockPanelStart);
|
||||
const catalogPanelStart = index.indexOf('<section class="panel catalog-panel">', stockWorkflow);
|
||||
assert.ok(stockPanelStart >= 0);
|
||||
assert.ok(stockWorkflow > stockPanelStart);
|
||||
assert.ok(catalogPanelStart > stockWorkflow);
|
||||
assert.match(styles,
|
||||
/\.console-grid\s*\{[^}]*grid-template-columns:\s*minmax\([^;]*minmax\([^;]*minmax\([^;]*minmax\(/u);
|
||||
assert.match(styles,
|
||||
/\.stock-panel \.stock-workflow\s*\{[^}]*flex:\s*1 1 auto/u);
|
||||
|
||||
const render = functionBody(app, "renderStockWorkflow", "requestStockSearch");
|
||||
assert.match(render, /elements\.stockWorkflow\.hidden = false/u);
|
||||
assert.doesNotMatch(render, /state\.activeMarket/u);
|
||||
|
||||
const binding = functionBody(app, "bindEvents", null);
|
||||
assert.doesNotMatch(binding,
|
||||
/industryWorkflow\.markets\.includes\(state\.activeMarket\)[\s\S]{0,160}state\.stockWorkflowCollapsed = true/u);
|
||||
});
|
||||
|
||||
test("the original first overseas tab is the one and only initial market-data view", () => {
|
||||
assert.match(index,
|
||||
/<button class="nav-item active" data-market="overseas"><span>◉<\/span>해외<\/button>/u);
|
||||
assert.match(index,
|
||||
/<button class="nav-item" data-market="dashboard"><span>⌂<\/span>대시보드<\/button>/u);
|
||||
assert.match(index, /<h1 id="workspaceTitle">해외 그래픽<\/h1>/u);
|
||||
assert.match(app, /const state = \{\s*activeMarket: "overseas",/u);
|
||||
|
||||
const initialize = functionBody(app, "initialize", null);
|
||||
assert.equal([...initialize.matchAll(/requestMarketData\(/gu)].length, 1);
|
||||
assert.match(initialize, /requestMarketData\(state\.activeMarket\);/u);
|
||||
assert.doesNotMatch(initialize, /requestMarketData\("(?:index|dashboard|foreign-stock)"\)/u);
|
||||
|
||||
const request = functionBody(app, "requestMarketData", "handleMarketData");
|
||||
assert.match(request,
|
||||
/postNative\("request-market-data", \{ requestId, view, maximumRowsPerTable: 250 \}\)/u);
|
||||
});
|
||||
|
||||
test("the Web message allowlist has no unused external-open or reload command surface", () => {
|
||||
assert.doesNotMatch(mainWindow, /case "open-external"/u);
|
||||
assert.doesNotMatch(mainWindow, /case "reload"/u);
|
||||
assert.doesNotMatch(app, /postNative\("open-external"/u);
|
||||
assert.doesNotMatch(app, /postNative\("reload"/u);
|
||||
});
|
||||
|
||||
test("first-routing consumes only messages owned by an operator controller", () => {
|
||||
const nativeHandler = functionBody(app, "handleNativeMessage", "bindEvents");
|
||||
const financialOffset = nativeHandler.indexOf("manualFinancialUi?.handleMessage");
|
||||
@@ -162,6 +229,7 @@ test("operator locks and trusted append checks cover every migrated manual surfa
|
||||
const append = functionBody(app, "appendTrustedOperatorEntry", "isPlaylistSnapshotLocked");
|
||||
assert.match(append, /if \(operatorUiLocked\(\)\) throw/u);
|
||||
assert.match(append, /manualListsWorkflow\.restorePlaylistEntry\(entry\)/u);
|
||||
assert.match(append, /manualListsWorkflow\.isPlaylistEntryPlayable\(entry\)/u);
|
||||
assert.match(append, /manualFinancialWorkflow\.isPlaylistEntryPlayable\(entry\)/u);
|
||||
assert.match(append,
|
||||
/catalog\.find\(value => value\.builderKey === entry\.builderKey &&[\s\S]*sceneAliases\(value\)\.includes/u);
|
||||
@@ -170,6 +238,103 @@ test("operator locks and trusted append checks cover every migrated manual surfa
|
||||
/if \(!workflow\.isPlaylistEntryPlayable\(entry\)\) throw new TypeError\("Untrusted entry\."\)/u);
|
||||
});
|
||||
|
||||
test("named PLAY_LIST replacement admits manual rows only through live fresh-read trust", () => {
|
||||
const trusted = functionBody(
|
||||
app,
|
||||
"isTrustedNamedPlaylistEntry",
|
||||
"selectedNamedPlaylistDefinition");
|
||||
assert.match(trusted,
|
||||
/namedManualRestoreWorkflow\.isTrustedEntryForSave\(entry, itemIndex\)/u);
|
||||
assert.match(trusted,
|
||||
/guard && \(!guard\.trusted \|\| \(guard\.pageRecalculationRequired && !guard\.pageRecalculated\)\)/u);
|
||||
|
||||
const projection = functionBody(
|
||||
app,
|
||||
"namedLegacyFieldsForEntry",
|
||||
"isTrustedNamedPlaylistEntry");
|
||||
assert.match(projection, /namedManualRestoreWorkflow\.legacyFieldsForEntry\(entry, itemIndex\)/u);
|
||||
assert.match(projection, /legacyNamedRowWorkflow\.legacySelectionForEntry\(entry\)/u);
|
||||
assert.match(projection, /resolveNamedPlaylistRawRow\(/u);
|
||||
assert.match(projection, /if \(!selection\)[\s\S]*throw new Error/u);
|
||||
|
||||
const replace = functionBody(
|
||||
app,
|
||||
"requestNamedPlaylistReplace",
|
||||
"requestNamedPlaylistDelete");
|
||||
assert.match(replace, /isTrustedEntry: isTrustedNamedPlaylistEntry/u);
|
||||
assert.match(replace, /pageTextForEntry: namedPageTextForEntry/u);
|
||||
assert.match(replace, /fieldsForEntry: namedLegacyFieldsForEntry/u);
|
||||
assert.match(replace, /beginNamedPlaylistOperation\("replace", request/u);
|
||||
});
|
||||
|
||||
test("local FSell and VI restores remain blocked until a correlated fresh read", () => {
|
||||
const normalizeStored = functionBody(
|
||||
app,
|
||||
"normalizeStoredPlaylist",
|
||||
"manualFinancialRestoreRecordForRequest");
|
||||
assert.match(normalizeStored, /manualListsWorkflow\.restorePlaylistEntry\(item\)/u);
|
||||
assert.match(normalizeStored,
|
||||
/namedManualRestoreWorkflow\.createStoredManualListPending\([\s\S]*restoredManualListEntry,[\s\S]*itemIndex/u);
|
||||
assert.match(normalizeStored,
|
||||
/options\.manualFinancialRestores\.push\(pendingManualListEntry\)/u);
|
||||
|
||||
const install = functionBody(
|
||||
app,
|
||||
"installManualFinancialRestores",
|
||||
"handleManualFinancialRestoreLoadResults");
|
||||
assert.match(install, /isStoredManualListPending\(pending\)[\s\S]*"stored-list"/u);
|
||||
|
||||
const handler = functionBody(
|
||||
app,
|
||||
"handleNamedManualListResults",
|
||||
"handleNamedManualListError");
|
||||
assert.match(handler, /\["named-list", "stored-list"\]\.includes\(record\.restoreKind\)/u);
|
||||
assert.match(handler,
|
||||
/record\.restoreKind === "named-list"[\s\S]*trustNamedManualRestoration[\s\S]*matchesMaterializedEntry\(record\.pending, current\)/u);
|
||||
|
||||
const blocker = functionBody(app, "manualFinancialPrepareBlocked", "loadPlaylist");
|
||||
assert.match(blocker,
|
||||
/legacy-manual-lists-workflow[\s\S]*manualListsWorkflow\.isPlaylistEntryPlayable\(item\)/u);
|
||||
});
|
||||
|
||||
test("named DB operations stay locked for the whole asynchronous manual restore", () => {
|
||||
const busy = functionBody(app, "namedPlaylistBusy", "renderNamedPlaylist");
|
||||
assert.match(busy, /state\.manualFinancialRestore\.entries\.size > 0/u);
|
||||
|
||||
const render = functionBody(app, "renderNamedPlaylist", "beginNamedPlaylistOperation");
|
||||
assert.match(render, /const freshRestorePending = state\.manualFinancialRestore\.entries\.size > 0/u);
|
||||
assert.match(render, /const busy = Boolean\(pending\) \|\| freshRestorePending/u);
|
||||
assert.match(render, /else if \(freshRestorePending\)[\s\S]*재검증 중/u);
|
||||
assert.match(render, /namedPlaylistLoadButton\.disabled = !nativeBridge \|\| locked \|\| busy/u);
|
||||
|
||||
for (const name of [
|
||||
"requestNamedPlaylistLoad",
|
||||
"requestNamedPlaylistCreate",
|
||||
"requestNamedPlaylistReplace",
|
||||
"requestNamedPlaylistDelete"
|
||||
]) {
|
||||
const body = functionBody(app, name, null);
|
||||
assert.match(body, /namedPlaylistBusy\(\)/u);
|
||||
}
|
||||
});
|
||||
|
||||
test("playlist editing cannot orphan a queued manual restore and failed rows remain removable", () => {
|
||||
const editable = functionBody(app, "requirePlaylistEditable", "updateClock");
|
||||
assert.match(editable, /restoreRecords[\s\S]*record\.status === "error"/u);
|
||||
assert.match(editable,
|
||||
/state\.namedPlaylist\.pending \|\| state\.namedPlaylist\.pagePlanPending/u);
|
||||
assert.match(editable, /allowFailedManualRestore && failedRestoreOnly/u);
|
||||
|
||||
const remove = functionBody(app, "deleteSelected", "deleteAllPlaylistEntries");
|
||||
assert.match(remove, /deletingFailedRestores/u);
|
||||
assert.match(remove, /requirePlaylistEditable\(deletingFailedRestores\)/u);
|
||||
assert.match(remove, /requestNextManualFinancialRestore\(\)/u);
|
||||
|
||||
const removeAll = functionBody(app, "deleteAllPlaylistEntries", "selectPlaylistBoundary");
|
||||
assert.match(removeAll, /requirePlaylistEditable\(failedRestoreOnly\)/u);
|
||||
assert.match(removeAll, /clearNamedPlaylistLoadState\(\)/u);
|
||||
});
|
||||
|
||||
test("stored GraphE entries remain unplayable until exact non-truncated DB revalidation", () => {
|
||||
const normalizeStored = functionBody(app, "normalizeStoredPlaylist", "manualFinancialRestoreRecordForRequest");
|
||||
assert.match(normalizeStored, /manualFinancialWorkflow\.restorePlaylistEntry\(item\)/u);
|
||||
@@ -203,6 +368,24 @@ test("stored GraphE entries remain unplayable until exact non-truncated DB reval
|
||||
/if \(command === "prepare"\)[\s\S]*if \(manualFinancialPrepareBlocked\(\)\)[\s\S]*return false/u);
|
||||
});
|
||||
|
||||
test("NXT GraphE restore searches the raw master name but verifies the suffixed display identity", () => {
|
||||
const loadResults = functionBody(
|
||||
app,
|
||||
"handleManualFinancialRestoreLoadResults",
|
||||
"handleManualFinancialRestoreLoadError");
|
||||
assert.match(loadResults,
|
||||
/expectedMarket[\s\S]*startsWith\("nxt-"\)[\s\S]*endsWith\(suffix\)[\s\S]*slice\(0, -suffix\.length\)/u);
|
||||
assert.match(loadResults, /query: searchQuery/u);
|
||||
|
||||
const stockResults = functionBody(
|
||||
app,
|
||||
"handleManualFinancialRestoreStockResults",
|
||||
"handleManualFinancialRestoreStockError");
|
||||
assert.match(stockResults,
|
||||
/item\.name === record\.pending\.stockName && item\.market === record\.pending\.market/u);
|
||||
assert.match(stockResults, /manualFinancialWorkflow\.verifyStockForRecord/u);
|
||||
});
|
||||
|
||||
test("native root routes all operator families and invalidates every browser correlation", () => {
|
||||
assert.match(mainWindow, /TryHandleOperatorCatalogRequest\(request\.Type, request\.Payload\)/u);
|
||||
assert.match(mainWindow, /TryHandleManualFinancialRequest\(request\.Type, request\.Payload\)/u);
|
||||
@@ -338,7 +521,7 @@ test("malformed operator payloads retain a family-specific fail-closed fallback"
|
||||
/case "request-named-playlist-list":[\s\S]*case "delete-named-playlist":[\s\S]*PostNamedPlaylistError\([\s\S]*"INVALID_REQUEST"/u);
|
||||
});
|
||||
|
||||
test("window shutdown detaches and disposes both trusted manual stores after their gate is idle", () => {
|
||||
test("window shutdown disposes both trusted manual stores and the one-time importer after their gate is idle", () => {
|
||||
const closed = sliceBetween(mainWindow, "private void OnClosed(", "private sealed record WebRequest(");
|
||||
assert.match(closed, /ShutdownManualOperatorDataRuntime\(\)/u);
|
||||
|
||||
@@ -349,18 +532,81 @@ test("window shutdown detaches and disposes both trusted manual stores after the
|
||||
assert.match(shutdown, /InvalidateManualOperatorBrowserRequests\(\)/u);
|
||||
assert.match(shutdown, /Interlocked\.Exchange\(ref _manualNetSellStore, null\)/u);
|
||||
assert.match(shutdown, /Interlocked\.Exchange\(ref _viManualListStore, null\)/u);
|
||||
assert.match(shutdown, /DisposeManualOperatorStoresWhenIdleAsync\(netSellStore, viStore\)/u);
|
||||
assert.match(shutdown, /Interlocked\.Exchange\(ref _legacyManualOperatorDataImporter, null\)/u);
|
||||
assert.match(shutdown, /DisposeManualOperatorStoresWhenIdleAsync\(netSellStore, viStore, importer\)/u);
|
||||
|
||||
const dispose = sliceBetween(
|
||||
manualListsBridge,
|
||||
"private async Task DisposeManualOperatorStoresWhenIdleAsync(",
|
||||
"private void InvalidateManualOperatorBrowserRequests(");
|
||||
assert.match(dispose, /await _manualOperatorDataGate\.WaitAsync\(\)/u);
|
||||
assert.match(dispose, /importer\?\.Dispose\(\)/u);
|
||||
assert.match(dispose, /viStore\?\.Dispose\(\)/u);
|
||||
assert.match(dispose, /netSellStore\?\.Dispose\(\)/u);
|
||||
assert.match(dispose, /_manualOperatorDataGate\.Release\(\)/u);
|
||||
});
|
||||
|
||||
test("system close requires an explicit default-cancel confirmation without an automatic playout command", () => {
|
||||
assert.match(mainWindow,
|
||||
/InitializeComponent\(\);\s*LogNativeOperatorStartup\(\);\s*EnsureCloseConfirmationAttached\(\);/u);
|
||||
assert.match(mainWindow,
|
||||
/private void OnClosed\([\s\S]*DetachCloseConfirmation\(\);[\s\S]*ShutdownPlayoutRuntime\(\)/u);
|
||||
|
||||
assert.match(closeConfirmationBridge,
|
||||
/appWindow\.Closing \+= OnAppWindowClosing/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/if \(Volatile\.Read\(ref _closeConfirmed\) != 0\)[\s\S]*args\.Cancel = true/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/Interlocked\.CompareExchange\(ref _closeConfirmationPending, 1, 0\)/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/DefaultButton = ContentDialogButton\.Close/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/result != ContentDialogResult\.Primary/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/Interlocked\.Exchange\(ref _closeConfirmed, 1\);\s*Close\(\);/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/appWindow\.Closing -= OnAppWindowClosing/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다/u);
|
||||
assert.doesNotMatch(closeConfirmationBridge,
|
||||
/TakeOutAsync|StopAll|StopAllAsync|playout-command/u);
|
||||
});
|
||||
|
||||
test("operator modals isolate global playout and playlist keyboard shortcuts", () => {
|
||||
const modalGate = functionBody(app, "hasOpenOperatorModal", "isolateOperatorModalShortcut");
|
||||
assert.match(modalGate,
|
||||
/dialog\[open\], \[role="dialog"\]\[aria-modal="true"\]/u);
|
||||
assert.match(modalGate, /!element\.hidden && !element\.closest\("\[hidden\]"\)/u);
|
||||
|
||||
const isolation = functionBody(app, "isolateOperatorModalShortcut", null);
|
||||
assert.match(isolation, /if \(!hasOpenOperatorModal\(\)\) return false/u);
|
||||
for (const key of ["F2", "F3", "F8", "Escape", "Home", "End", "Delete"]) {
|
||||
assert.match(isolation, new RegExp(`"${key}"`, "u"));
|
||||
}
|
||||
assert.match(isolation, /event\.ctrlKey && key\.toLowerCase\(\) === "k"/u);
|
||||
assert.match(isolation, /showToast\("편집 창을 먼저 닫은 뒤 송출 명령을 실행하세요\."\)/u);
|
||||
|
||||
const keyHandler = app.slice(app.indexOf('document.addEventListener("keydown"'));
|
||||
const gate = keyHandler.indexOf("if (isolateOperatorModalShortcut(event)) return;");
|
||||
const playout = keyHandler.indexOf('if (["F2", "F3", "F8", "Escape"].includes(event.key))');
|
||||
const playlistDelete = keyHandler.indexOf('if (event.key === "Delete")');
|
||||
assert.ok(gate >= 0 && playout > gate && playlistDelete > gate);
|
||||
});
|
||||
|
||||
test("generic native bridge errors are bounded and visible without inventing correlation", () => {
|
||||
const bridgeError = functionBody(app, "handleBridgeError", "handleNativeMessage");
|
||||
assert.match(bridgeError, /rawMessage\.length <= 512/u);
|
||||
assert.match(bridgeError, /Native Bridge가 올바르지 않은 요청을 거부했습니다/u);
|
||||
assert.match(bridgeError, /addLog\(`Native Bridge 요청 오류 · \$\{message\}`\)/u);
|
||||
assert.match(bridgeError, /showToast\(message\)/u);
|
||||
assert.doesNotMatch(bridgeError,
|
||||
/pendingCommand\s*=\s*null|clearPlayoutError|requestPlayoutCommand|retry/u);
|
||||
|
||||
const nativeSwitch = functionBody(app, "handleNativeMessage", null);
|
||||
assert.match(nativeSwitch,
|
||||
/case "bridge-error":\s*handleBridgeError\(message\.payload\);\s*break;/u);
|
||||
});
|
||||
|
||||
test("GraphE async UI boundaries retain the dispatcher context before posting WebView replies", () => {
|
||||
assert.doesNotMatch(manualFinancialBridge,
|
||||
/operationBody\(requestToken\)\.ConfigureAwait\(false\)/u);
|
||||
|
||||
@@ -52,6 +52,12 @@ test("KRX and NXT database identities normalize without inferring market from a
|
||||
Market: "Krx", ThemeCode: "00000001", ThemeTitle: "AI 반도체", Session: "NotApplicable",
|
||||
previewItemCount: 17
|
||||
}), krxTheme);
|
||||
assert.equal(workflow.normalizeTheme({
|
||||
market: "krx", themeCode: "123", title: "Legacy"
|
||||
}).themeCode, "123");
|
||||
assert.equal(workflow.normalizeTheme({
|
||||
market: "krx", themeCode: "0123", title: "Legacy"
|
||||
}).themeCode, "0123");
|
||||
assert.deepEqual(workflow.normalizeTheme({
|
||||
market: "NXT", themeCode: "00000002", title: "로봇", session: "PreMarket", items: new Array(121)
|
||||
}), nxtPreTheme);
|
||||
@@ -71,7 +77,7 @@ test("typed theme validation reports clear market, code, session and preview fai
|
||||
market: "other", themeCode: "00000001", title: "AI"
|
||||
}, "INPUT_ORDER").reason, "invalid-theme-market");
|
||||
assert.equal(workflow.getCompatibility("five-row-current", {
|
||||
market: "krx", themeCode: "1", title: "AI"
|
||||
market: "krx", themeCode: "12", title: "AI"
|
||||
}, "INPUT_ORDER").reason, "invalid-theme-code");
|
||||
assert.equal(workflow.getCompatibility("five-row-current", {
|
||||
market: "krx", themeCode: " 00000001 ", title: "AI"
|
||||
|
||||
77
tests/Web/trading-halt-name-sort-app-integration.test.cjs
Normal file
77
tests/Web/trading-halt-name-sort-app-integration.test.cjs
Normal file
@@ -0,0 +1,77 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const read = relativePath => fs.readFileSync(path.join(root, relativePath), "utf8");
|
||||
const app = read("Web/app.js");
|
||||
const index = read("Web/index.html");
|
||||
const styles = read("Web/styles.css");
|
||||
const native = fs.readdirSync(root)
|
||||
.filter(name => /^MainWindow(?:\..+)?\.cs$/u.test(name))
|
||||
.map(name => read(name))
|
||||
.join("\n");
|
||||
|
||||
function functionBody(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
assert.ok(start >= 0, `${name} must exist`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start + 1);
|
||||
assert.ok(end > start, `${name} must precede ${nextName}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("trading-halt results expose an accessible stock-name sort header", () => {
|
||||
assert.match(index,
|
||||
/class="trading-halt-results-header" role="group" aria-label="거래 정지 검색 결과 정렬"[\s\S]*id="tradingHaltNameSort"[\s\S]*aria-controls="tradingHaltResults"[\s\S]*data-sort-direction="none">종목명 ↕<\/button>/u);
|
||||
assert.match(styles, /\.trading-halt-results-header\s*\{/u);
|
||||
assert.match(styles, /\.trading-halt-results-header button:disabled\s*\{/u);
|
||||
});
|
||||
|
||||
test("render sorts only a copied display view and preserves market-code selection identity", () => {
|
||||
const identity = functionBody(app, "tradingHaltIdentity", "toggleTradingHaltNameSort");
|
||||
const render = functionBody(app, "renderTradingHaltWorkflow", "requestTradingHaltSearch");
|
||||
|
||||
assert.match(identity, /tradingHaltWorkflow\.tradingHaltIdentity\(value\)/u);
|
||||
assert.doesNotMatch(identity, /displayName/u);
|
||||
assert.match(render,
|
||||
/sortTradingHaltsForDisplay\(\s*workflow\.results,\s*workflow\.sortDirection\)/u);
|
||||
assert.match(render,
|
||||
/tradingHaltIdentity\(item\) === selectedIdentity/u);
|
||||
assert.doesNotMatch(render, /workflow\.results\.sort\(/u);
|
||||
});
|
||||
|
||||
test("sort is blocked while pending, locked, or empty and otherwise toggles locally", () => {
|
||||
const toggle = functionBody(app, "toggleTradingHaltNameSort", "selectTradingHalt");
|
||||
const render = functionBody(app, "renderTradingHaltWorkflow", "requestTradingHaltSearch");
|
||||
|
||||
assert.match(toggle,
|
||||
/isPlaylistSnapshotLocked\(\) \|\| workflow\.status !== "ready" \|\| !workflow\.results\.length/u);
|
||||
assert.match(toggle,
|
||||
/workflow\.sortDirection = tradingHaltWorkflow\.nextTradingHaltNameSort\(workflow\.sortDirection\)/u);
|
||||
assert.match(render,
|
||||
/const canSort = workflow\.status === "ready" && workflow\.results\.length > 0/u);
|
||||
assert.match(render, /tradingHaltNameSort\.disabled = locked \|\| !canSort/u);
|
||||
assert.doesNotMatch(toggle,
|
||||
/postNative|requestTradingHaltSearch|state\.playlist|renderPlaylist|updatePreview/u);
|
||||
});
|
||||
|
||||
test("each new search and matching result restores the server-default order", () => {
|
||||
const request = functionBody(app, "requestTradingHaltSearch", "handleTradingHaltResults");
|
||||
const results = functionBody(app, "handleTradingHaltResults", "handleTradingHaltError");
|
||||
|
||||
assert.match(request,
|
||||
/workflow\.status = "loading";[\s\S]*workflow\.results = \[\];[\s\S]*workflow\.sortDirection = null;/u);
|
||||
assert.match(results,
|
||||
/clearTradingHaltTimeout\(\);\s*workflow\.sortDirection = null;[\s\S]*workflow\.results = \[\.\.\.response\.results\]/u);
|
||||
});
|
||||
|
||||
test("the display sort adds no native command surface or playlist mutation", () => {
|
||||
const toggle = functionBody(app, "toggleTradingHaltNameSort", "selectTradingHalt");
|
||||
assert.doesNotMatch(toggle,
|
||||
/postNative|state\.playlist|renderPlaylist|updatePreview|prepare|take-in|take-out/iu);
|
||||
assert.doesNotMatch(native, /sort-trading-halt|trading-halt-sort/iu);
|
||||
assert.doesNotMatch(app, /postNative\("(?:sort-trading-halt|trading-halt-sort)"/iu);
|
||||
});
|
||||
@@ -142,6 +142,65 @@ test("search responses reject duplicates, ambiguous names, invalid rows and reor
|
||||
})), null);
|
||||
});
|
||||
|
||||
test("UC7 name-sort toggles descending first and ascending second", () => {
|
||||
assert.equal(workflow.nextTradingHaltNameSort(null), "descending");
|
||||
assert.equal(workflow.nextTradingHaltNameSort("descending"), "ascending");
|
||||
assert.equal(workflow.nextTradingHaltNameSort("ascending"), "descending");
|
||||
});
|
||||
|
||||
test("display-only name sort handles mixed markets and deterministic duplicate-name ties", () => {
|
||||
const rows = [
|
||||
{ market: "kospi", stockCode: "000002", displayName: "동명" },
|
||||
{ market: "kosdaq", stockCode: "000003", displayName: "동명" },
|
||||
{ market: "kospi", stockCode: "000004", displayName: "마지막" },
|
||||
{ market: "kosdaq", stockCode: "000001", displayName: "동명" },
|
||||
{ market: "kospi", stockCode: "000005", displayName: "가나다" }
|
||||
];
|
||||
const original = [...rows];
|
||||
|
||||
assert.deepEqual(
|
||||
workflow.sortTradingHaltsForDisplay(rows, "ascending").map(value => `${value.displayName}|${value.market}|${value.stockCode}`),
|
||||
[
|
||||
"가나다|kospi|000005",
|
||||
"동명|kosdaq|000001",
|
||||
"동명|kosdaq|000003",
|
||||
"동명|kospi|000002",
|
||||
"마지막|kospi|000004"
|
||||
]);
|
||||
assert.deepEqual(
|
||||
workflow.sortTradingHaltsForDisplay(rows, "descending").map(value => `${value.displayName}|${value.market}|${value.stockCode}`),
|
||||
[
|
||||
"마지막|kospi|000004",
|
||||
"동명|kosdaq|000001",
|
||||
"동명|kosdaq|000003",
|
||||
"동명|kospi|000002",
|
||||
"가나다|kospi|000005"
|
||||
]);
|
||||
const selectedIdentity = workflow.tradingHaltIdentity(rows[0]);
|
||||
const selectedAfterSort = workflow.sortTradingHaltsForDisplay(rows, "ascending")
|
||||
.find(value => workflow.tradingHaltIdentity(value) === selectedIdentity);
|
||||
assert.equal(selectedAfterSort, rows[0]);
|
||||
const serverOrder = workflow.sortTradingHaltsForDisplay(rows, null);
|
||||
assert.deepEqual(rows, original);
|
||||
assert.deepEqual(serverOrder, rows);
|
||||
assert.notEqual(serverOrder, rows);
|
||||
assert.equal(serverOrder[0], rows[0]);
|
||||
assert.throws(() => workflow.sortTradingHaltsForDisplay(rows, "server"), /ascending/i);
|
||||
});
|
||||
|
||||
test("trading-halt selection identity is market plus stock code, independent of display name", () => {
|
||||
assert.equal(
|
||||
workflow.tradingHaltIdentity({ market: "kospi", stockCode: "005930", displayName: "삼성전자" }),
|
||||
"kospi\u0000005930");
|
||||
assert.equal(
|
||||
workflow.tradingHaltIdentity({ market: "kospi", stockCode: "005930", displayName: "표시명 변경" }),
|
||||
"kospi\u0000005930");
|
||||
assert.notEqual(
|
||||
workflow.tradingHaltIdentity({ market: "kosdaq", stockCode: "005930", displayName: "삼성전자" }),
|
||||
"kospi\u0000005930");
|
||||
assert.equal(workflow.tradingHaltIdentity(null), "");
|
||||
});
|
||||
|
||||
test("5001 current selections bind the exact halted-stock Core contract", () => {
|
||||
for (const [stock, groupCode] of [[kospi, "KOSPI"], [kosdaq, "KOSDAQ"]]) {
|
||||
const mapping = workflow.buildTradingHaltSelection("current", stock, {
|
||||
|
||||
Reference in New Issue
Block a user