Migrate remaining legacy operator workflows

This commit is contained in:
2026-07-12 05:39:27 +09:00
parent ffb8f43c19
commit a01836a2d7
132 changed files with 20566 additions and 720 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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.
}
}
}
}

View File

@@ -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);
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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()
{

View File

@@ -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;
}
}

View File

@@ -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()
{

View File

@@ -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")

View File

@@ -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>

View File

@@ -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([])
];

View File

@@ -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.
}
}
}
}

View File

@@ -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 _));
}
}

View File

@@ -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.
}
}
}
}

View File

@@ -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()
{

View File

@@ -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);

View File

@@ -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()
{

View File

@@ -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));
}
}

View File

@@ -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);
}

View 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
}
}
}

View 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);
});

View 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);
});

View File

@@ -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

View 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);
});

View 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);
});

View 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);
});

View 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"
}
]);
});

View File

@@ -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();

View File

@@ -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);
});

View File

@@ -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"),

View File

@@ -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", {

View 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);
});

View 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"
]);
});

View 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);
});

View 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);
});

View File

@@ -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);
});

View File

@@ -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");
});

View File

@@ -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);
});

View 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);
});

View 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);
});

View File

@@ -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);

View 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);
});

View File

@@ -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);
});

View File

@@ -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, {

View File

@@ -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);

View File

@@ -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"

View 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);
});

View File

@@ -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, {