Complete legacy operator UI and playout migration

This commit is contained in:
2026-07-12 02:05:49 +09:00
parent d2e16bf0c2
commit ffb8f43c19
131 changed files with 48473 additions and 228 deletions

View File

@@ -111,7 +111,35 @@ public sealed class CandleSceneDataLoaderTests
Request(CandleCutAlias.Cut8035, CandleMarketTarget.KosdaqStock, CandleChartMode.TradingHalt, "Stock"),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, CandleChartMode.TradingHalt, null),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, new string('x', 257)),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "bad\nname")
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "bad\nname"),
new(
CandleCutAlias.Cut8061,
CandleMarketTarget.OverseasIndex,
CandleChartMode.Price,
"World",
true,
true),
Request(
CandleCutAlias.Cut8061,
CandleMarketTarget.OverseasStock,
CandleChartMode.Price,
"World",
"NAS@WORLD",
"DE"),
Request(
CandleCutAlias.Cut8061,
CandleMarketTarget.OverseasIndex,
CandleChartMode.Price,
"World",
"WORLD@INDEX",
"us"),
Request(
CandleCutAlias.Cut8061,
CandleMarketTarget.KospiStock,
CandleChartMode.Price,
"Stock",
"INVALID@DOMESTIC",
"KR")
};
[Theory]
@@ -173,6 +201,100 @@ public sealed class CandleSceneDataLoaderTests
Assert.DoesNotContain("{0}", call.Spec.Sql, StringComparison.Ordinal));
}
[Fact]
public async Task Overseas_candles_scope_master_type_and_preserve_global_index_support()
{
var indexExecutor = new RecordingExecutor(QuoteTable(), RowsTable(false));
await new S8010SceneDataLoader(indexExecutor).LoadAsync(
Request(
CandleCutAlias.Cut8061,
CandleMarketTarget.OverseasIndex,
CandleChartMode.Price,
"Germany DAX",
"XTR@DAX30",
"DE"));
Assert.All(indexExecutor.Calls, call =>
{
Assert.Contains("a.f_fdtc = '0'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
var type = Assert.Single(
call.Spec.Parameters,
parameter => parameter.Name == "instrumentType");
Assert.Equal("0", type.Value);
Assert.Equal(
"XTR@DAX30",
Assert.Single(
call.Spec.Parameters,
parameter => parameter.Name == "symbol").Value);
Assert.Equal(
"DE",
Assert.Single(
call.Spec.Parameters,
parameter => parameter.Name == "nationCode").Value);
});
var stockExecutor = new RecordingExecutor(QuoteTable(), RowsTable(false));
await new S8010SceneDataLoader(stockExecutor).LoadAsync(
Request(
CandleCutAlias.Cut8061,
CandleMarketTarget.OverseasStock,
CandleChartMode.Price,
"NVIDIA",
"NAS@NVDA",
"US"));
Assert.All(stockExecutor.Calls, call =>
{
Assert.Contains("a.f_fdtc = '1'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"a.f_natc IN ('US', 'TW')",
call.Spec.Sql,
StringComparison.OrdinalIgnoreCase);
var type = Assert.Single(
call.Spec.Parameters,
parameter => parameter.Name == "instrumentType");
Assert.Equal("1", type.Value);
Assert.Equal(
"NAS@NVDA",
Assert.Single(
call.Spec.Parameters,
parameter => parameter.Name == "symbol").Value);
Assert.Equal(
"US",
Assert.Single(
call.Spec.Parameters,
parameter => parameter.Name == "nationCode").Value);
});
}
[Fact]
public async Task Overseas_stock_candle_preserves_the_original_taiwan_scope()
{
var executor = new RecordingExecutor(QuoteTable(), RowsTable(false));
await new S8010SceneDataLoader(executor).LoadAsync(
Request(
CandleCutAlias.Cut8061,
CandleMarketTarget.OverseasStock,
CandleChartMode.Price,
"Taiwan Stock",
"TWS@2330",
"TW"));
Assert.All(executor.Calls, call =>
{
Assert.Contains(
"a.f_natc IN ('US', 'TW')",
call.Spec.Sql,
StringComparison.OrdinalIgnoreCase);
Assert.Equal(
"TW",
Assert.Single(
call.Spec.Parameters,
parameter => parameter.Name == "nationCode").Value);
});
}
[Theory]
[MemberData(nameof(AliasLimits))]
public async Task Every_alias_binds_its_legacy_row_bound_and_maps_time_shape(
@@ -336,8 +458,28 @@ public sealed class CandleSceneDataLoaderTests
CandleCutAlias alias,
CandleMarketTarget target,
CandleChartMode mode,
string? selection) =>
new(alias, target, mode, selection, true, true);
string? selection,
string? foreignSymbol = null,
string? foreignNationCode = null)
{
if (target is CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock)
{
foreignSymbol ??= target == CandleMarketTarget.OverseasIndex
? "TEST@INDEX"
: "TEST@STOCK";
foreignNationCode ??= "US";
}
return new(
alias,
target,
mode,
selection,
true,
true,
foreignSymbol,
foreignNationCode);
}
private static int AliasLimit(CandleCutAlias alias) => alias switch
{

View File

@@ -8,6 +8,45 @@ namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ComparisonAndYieldLegacyRequestResolverTests
{
[Theory]
[InlineData("WonDollar", "FiveDays", YieldGraphInstrumentKind.WonDollar, YieldGraphPeriod.FiveDays)]
[InlineData("WonYen", "TwentyDays", YieldGraphInstrumentKind.WonYen, YieldGraphPeriod.TwentyDays)]
[InlineData("WonYuan", "SixtyDays", YieldGraphInstrumentKind.WonYuan, YieldGraphPeriod.SixtyDays)]
[InlineData("WonEuro", "OneHundredTwentyDays", YieldGraphInstrumentKind.WonEuro, YieldGraphPeriod.OneHundredTwentyDays)]
[InlineData("WonDollar", "TwoHundredFortyDays", YieldGraphInstrumentKind.WonDollar, YieldGraphPeriod.TwoHundredFortyDays)]
public async Task Fixed_operator_catalog_accepts_closed_FX_and_period_enum_names(
string subject,
string subtype,
YieldGraphInstrumentKind expectedKind,
YieldGraphPeriod expectedPeriod)
{
var resolver = new ComparisonAndYieldLegacyRequestResolver(new RecordingExecutor());
var result = Assert.IsType<LegacyS5086SceneLoadRequest>(await resolver.ResolveAsync(
Entry("5086", "EXCHANGE_RATE", subject, subtype)));
Assert.Equal(expectedKind, result.Request.Instrument.Kind);
Assert.Equal(expectedPeriod, result.Request.Period);
}
[Theory]
[InlineData("KOSPI", YieldGraphInstrumentKind.KospiIndex)]
[InlineData("KOSDAQ", YieldGraphInstrumentKind.KosdaqIndex)]
[InlineData("KOSPI200", YieldGraphInstrumentKind.Kospi200Index)]
[InlineData("KRX100", YieldGraphInstrumentKind.Krx100Index)]
public async Task Fixed_operator_catalog_accepts_closed_index_selectors(
string groupCode,
YieldGraphInstrumentKind expectedKind)
{
var resolver = new ComparisonAndYieldLegacyRequestResolver(new RecordingExecutor());
var result = Assert.IsType<LegacyS5086SceneLoadRequest>(await resolver.ResolveAsync(
Entry("5086", groupCode, "INDEX", "TwentyDays")));
Assert.Equal(expectedKind, result.Request.Instrument.Kind);
Assert.Equal(YieldGraphPeriod.TwentyDays, result.Request.Period);
}
[Theory]
[InlineData("5026", "s5026")]
[InlineData("5087", "s5087")]

View File

@@ -0,0 +1,235 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyExpertCatalogPersistenceServiceTests
{
[Fact]
public async Task SuggestNextCodeAsync_UsesOriginalFourDigitOracleSequenceReadOnly()
{
var query = new CatalogRecordingQueryExecutor(call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(LegacyExpertCatalogPersistenceService.NextCodeQueryName, call.TableName);
Assert.Contains("TO_NUMBER(TRIM(EP_CODE))", call.Spec.Sql, StringComparison.Ordinal);
Assert.Empty(call.Spec.Parameters);
return OperatorCatalogTestTables.SingleString("EXPERT_CODE", "0042");
});
var service = new LegacyExpertCatalogPersistenceService(query);
Assert.Equal("0042", await service.SuggestNextCodeAsync());
Assert.False(service.CanMutate);
Assert.Single(query.Calls);
}
[Fact]
public async Task SuggestNextCodeAsync_RejectsExhaustedCodeRange()
{
var service = new LegacyExpertCatalogPersistenceService(
new CatalogRecordingQueryExecutor(_ =>
OperatorCatalogTestTables.SingleString("EXPERT_CODE", "EXHAUSTED")));
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
service.SuggestNextCodeAsync());
}
[Fact]
public async Task CreateAsync_BindsExpertThenRecommendationsInOneOracleTransaction()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation);
using var cancellation = new CancellationTokenSource();
var definition = new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0042", "홍'길동"),
[
new ExpertCatalogRecommendation(0, "005930", "삼성전자", 72000m),
new ExpertCatalogRecommendation(1, "035720", "카카오", 45000m)
]);
var receipt = await service.CreateAsync(definition, cancellation.Token);
Assert.True(service.CanMutate);
Assert.Equal(OperatorCatalogMutationKind.CreateExpert, receipt.Kind);
var call = Assert.Single(mutation.Calls);
Assert.Equal(cancellation.Token, call.Token);
var transaction = call.Transaction;
Assert.Equal(DataSourceKind.Oracle, transaction.Source);
Assert.Equal("0042", transaction.IdentityCode);
Assert.Equal(
[
OperatorCatalogDbCommandKind.InsertExpert,
OperatorCatalogDbCommandKind.InsertRecommendation,
OperatorCatalogDbCommandKind.InsertRecommendation
],
transaction.Commands.Select(static command => command.Kind));
Assert.All(transaction.Commands, command =>
Assert.DoesNotContain("홍'길동", command.Sql, StringComparison.Ordinal));
var parent = transaction.Commands[0];
Assert.Contains("NOT EXISTS", parent.Sql, StringComparison.Ordinal);
Assert.Equal("홍'길동", Parameter(parent, "expert_name").Value);
Assert.Equal("홍'길동", Parameter(parent, "conflict_name").Value);
var first = transaction.Commands[1];
Assert.Equal("005930", Parameter(first, "stock_code").Value);
Assert.Equal(72000m, Parameter(first, "buy_amount").Value);
Assert.Equal(DbType.Decimal, Parameter(first, "buy_amount").DbType);
Assert.Equal(0, Parameter(first, "play_index").Value);
}
[Fact]
public async Task ReplaceAsync_UsesExpectedCodeAndNameBeforeChildReplacement()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation);
await service.ReplaceAsync(
new ExpertSelectionIdentity("0010", "기존 전문가"),
"새 전문가",
[new ExpertCatalogRecommendation(0, "005930", "삼성전자", 70000m)]);
var transaction = Assert.Single(mutation.Calls).Transaction;
Assert.Equal(OperatorCatalogMutationKind.ReplaceExpert, transaction.Kind);
Assert.Equal(
[
OperatorCatalogDbCommandKind.UpdateExpert,
OperatorCatalogDbCommandKind.DeleteRecommendations,
OperatorCatalogDbCommandKind.InsertRecommendation
],
transaction.Commands.Select(static command => command.Kind));
var update = transaction.Commands[0];
Assert.Equal("기존 전문가", Parameter(update, "expected_name").Value);
Assert.Equal("새 전문가", Parameter(update, "new_name").Value);
Assert.Equal("새 전문가", Parameter(update, "duplicate_name").Value);
Assert.Contains("NOT EXISTS", update.Sql, StringComparison.Ordinal);
Assert.Equal(
OperatorCatalogAffectedRowsRule.ExactlyOne,
update.AffectedRowsRule);
Assert.Equal(
OperatorCatalogAffectedRowsRule.AnyNonNegative,
transaction.Commands[1].AffectedRowsRule);
}
[Fact]
public async Task DeleteAsync_DeletesRecommendationsBeforeIdentityBoundExpert()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation);
await service.DeleteAsync(new ExpertSelectionIdentity("0010", "전문가"));
var transaction = Assert.Single(mutation.Calls).Transaction;
Assert.Equal(
[
OperatorCatalogDbCommandKind.DeleteRecommendations,
OperatorCatalogDbCommandKind.DeleteExpert
],
transaction.Commands.Select(static command => command.Kind));
Assert.Equal("전문가", Parameter(transaction.Commands[1], "expected_name").Value);
Assert.Equal(
OperatorCatalogAffectedRowsRule.ExactlyOne,
transaction.Commands[1].AffectedRowsRule);
}
[Fact]
public async Task Mutations_RejectDuplicateNamesCodesOrderAndUnsafeAmountsBeforeExecutor()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ExpertSelectionIdentity("0001", "전문가");
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
new ExpertCatalogDefinition(
identity,
[
new ExpertCatalogRecommendation(0, "005930", "삼성전자", 1m),
new ExpertCatalogRecommendation(1, "005930", "다른이름", 2m)
])));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
new ExpertCatalogDefinition(
identity,
[
new ExpertCatalogRecommendation(0, "005930", "삼성전자", 1m),
new ExpertCatalogRecommendation(1, "035720", "삼성전자", 2m)
])));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
new ExpertCatalogDefinition(
identity,
[new ExpertCatalogRecommendation(1, "005930", "삼성전자", 1m)])));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.CreateAsync(
new ExpertCatalogDefinition(
identity,
[new ExpertCatalogRecommendation(0, "005930", "삼성전자", 1.5m)])));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.CreateAsync(
new ExpertCatalogDefinition(
identity,
[new ExpertCatalogRecommendation(0, "005930", "삼성전자", 0m)])));
Assert.Empty(mutation.Calls);
}
[Fact]
public async Task Mutations_FailClosedForReadOnlyCancellationAndKnownConflict()
{
var definition = new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[]);
var readOnly = new LegacyExpertCatalogPersistenceService(UnusedQuery());
await Assert.ThrowsAsync<InvalidOperationException>(() => readOnly.CreateAsync(definition));
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.CreateAsync(definition, cancellation.Token));
Assert.Empty(mutation.Calls);
mutation.Exception = new OperatorCatalogConflictException(
OperatorCatalogMutationKind.CreateExpert,
OperatorCatalogDbCommandKind.InsertExpert);
var conflict = await Assert.ThrowsAsync<OperatorCatalogConflictException>(() =>
service.CreateAsync(definition));
Assert.False(conflict.OutcomeUnknown);
Assert.Equal(OperatorCatalogDbCommandKind.InsertExpert, conflict.CommandKind);
}
[Fact]
public async Task CreateAsync_TreatsUnclassifiedOrAmbiguousResultAsOutcomeUnknown()
{
var definition = new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[]);
var mutation = new CatalogRecordingMutationExecutor
{
Exception = new TimeoutException("provider detail")
};
var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation);
var unclassified = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(definition));
Assert.True(unclassified.OutcomeUnknown);
Assert.Contains("do not retry automatically", unclassified.Message, StringComparison.Ordinal);
var ambiguousMutation = new CatalogRecordingMutationExecutor(transaction =>
new OperatorCatalogDbTransactionResult(
transaction.OperationId,
[0],
DateTimeOffset.UtcNow));
var ambiguousService = new LegacyExpertCatalogPersistenceService(
UnusedQuery(),
ambiguousMutation);
var ambiguous = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
ambiguousService.CreateAsync(definition));
Assert.True(ambiguous.OutcomeUnknown);
}
private static CatalogRecordingQueryExecutor UnusedQuery() =>
new(_ => throw new InvalidOperationException("No read query was expected."));
private static OperatorCatalogDbParameter Parameter(
OperatorCatalogDbCommand command,
string name) =>
Assert.Single(command.Parameters, parameter =>
string.Equals(parameter.Name, name, StringComparison.Ordinal));
}

View File

@@ -0,0 +1,476 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyExpertSelectionServiceTests
{
[Fact]
public async Task SearchAsync_UsesBoundOracleExpertListAndReturnsTypedDeterministicRows()
{
var executor = new RecordingExecutor(_ => SearchTable(
("Zulu", "0002"),
("Alpha", "0001")));
var service = new LegacyExpertSelectionService(executor);
using var cancellation = new CancellationTokenSource();
var result = await service.SearchAsync(" a!_% ", 25, cancellation.Token);
Assert.Equal("a!_%", result.Query);
Assert.False(result.IsTruncated);
Assert.Collection(
result.Items,
item =>
{
Assert.Equal(new ExpertSelectionIdentity("0001", "Alpha"), item.Identity);
Assert.Equal(DataSourceKind.Oracle, item.Source);
},
item => Assert.Equal(new ExpertSelectionIdentity("0002", "Zulu"), item.Identity));
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("EXPERT_SEARCH", call.QueryName);
Assert.Equal(cancellation.Token, call.CancellationToken);
call.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains("FROM EXPERT_LIST", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("EP_NAME EXPERT_NAME", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("EP_CODE EXPERT_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("a!_%", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Collection(
call.Spec.Parameters,
parameter =>
{
Assert.Equal("search_term", parameter.Name);
Assert.Equal("A!!!_!%", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
},
parameter =>
{
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(26, parameter.Value);
Assert.Equal(DbType.Int32, parameter.DbType);
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task SearchAsync_AllowsBlankInitialLoadAndReportsGlobalBound()
{
var executor = new RecordingExecutor(_ => SearchTable(
("Alpha", "0001"),
("Beta", "0002")));
var service = new LegacyExpertSelectionService(executor);
var result = await service.SearchAsync(" ", maximumResults: 1);
Assert.Equal(string.Empty, result.Query);
Assert.True(result.IsTruncated);
Assert.Single(result.Items);
Assert.Equal(string.Empty, executor.Calls[0].Spec.Parameters[0].Value);
Assert.Equal(2, executor.Calls[0].Spec.Parameters[1].Value);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
public async Task SearchAsync_RejectsInvalidLimitBeforeDatabaseAccess(int limit)
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyExpertSelectionService(executor);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.SearchAsync("", limit));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("bad\u0000value")]
[InlineData("bad\u200Bvalue")]
[InlineData("bad\uD800value")]
public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query)
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyExpertSelectionService(executor);
await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(query));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsNullAndOverlongQueryBeforeDatabaseAccess()
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyExpertSelectionService(executor);
await Assert.ThrowsAsync<ArgumentNullException>(() => service.SearchAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(
new string('A', LegacyExpertSelectionService.MaximumQueryLength + 1)));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsExactSchemaAndRowBoundViolations()
{
var wrongSchema = SearchTable();
wrongSchema.Columns[0].ColumnName = "expert_name";
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(new RecordingExecutor(_ => wrongSchema))
.SearchAsync());
var overflow = SearchTable(
("A", "0001"),
("B", "0002"),
("C", "0003"));
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(new RecordingExecutor(_ => overflow))
.SearchAsync(maximumResults: 1));
}
[Theory]
[InlineData("Analyst", "0001", "Analyst", "0002")]
[InlineData("Analyst", "0001", "Other", "0001")]
public async Task SearchAsync_RejectsDuplicateExpertNameOrCode(
string name1,
string code1,
string name2,
string code2)
{
var executor = new RecordingExecutor(_ => SearchTable(
(name1, code1),
(name2, code2)));
var service = new LegacyExpertSelectionService(executor);
var exception = await Assert.ThrowsAsync<ExpertSelectionDataException>(
() => service.SearchAsync());
Assert.Contains("duplicate expert name or code", exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData(" Analyst", "0001")]
[InlineData("Analyst", "001")]
[InlineData("Analyst", "00A1")]
[InlineData("Bad\nName", "0001")]
public async Task SearchAsync_RejectsNonCanonicalExpertIdentity(string name, string code)
{
var executor = new RecordingExecutor(_ => SearchTable((name, code)));
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(executor).SearchAsync());
}
[Fact]
public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess()
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyExpertSelectionService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.SearchAsync(cancellationToken: cancellation.Token));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task PreviewAsync_BindsCodeAndNameAndMapsOriginalRecommendationFields()
{
var identity = Identity();
var executor = new RecordingExecutor(_ => PreviewTable(
("Analyst A", "0001", "005930", "Samsung", "70000", "0"),
("Analyst A", "0001", "035720", "Kakao", "42000", "2")));
var service = new LegacyExpertSelectionService(executor);
using var cancellation = new CancellationTokenSource();
var result = await service.PreviewAsync(identity, 12, cancellation.Token);
Assert.Equal(identity, result.Identity);
Assert.False(result.IsTruncated);
Assert.Equal(
[
new ExpertRecommendationPreview(0, "005930", "Samsung", 70000m),
new ExpertRecommendationPreview(2, "035720", "Kakao", 42000m)
],
result.Items);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("EXPERT_PREVIEW", call.QueryName);
Assert.Equal(cancellation.Token, call.CancellationToken);
Assert.Contains("FROM EXPERT_LIST e", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("LEFT JOIN RECOMMEND_LIST r", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY r.PLAYINDEX", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.Collection(
call.Spec.Parameters,
parameter =>
{
Assert.Equal("expert_code", parameter.Name);
Assert.Equal("0001", parameter.Value);
},
parameter =>
{
Assert.Equal("expert_name", parameter.Name);
Assert.Equal("Analyst A", parameter.Value);
},
parameter =>
{
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(13, parameter.Value);
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task PreviewAsync_MapsSingleNullRecommendationSentinelToEmptyExpert()
{
var executor = new RecordingExecutor(_ => PreviewTable(
("Analyst A", "0001", null, null, null, null)));
var result = await new LegacyExpertSelectionService(executor).PreviewAsync(Identity());
Assert.Empty(result.Items);
Assert.False(result.IsTruncated);
}
[Fact]
public async Task PreviewAsync_AppliesFiveByTwentyMaximumAndReportsTruncation()
{
var executor = new RecordingExecutor(_ => PreviewTable(
("Analyst A", "0001", "000001", "Alpha", "100", "0"),
("Analyst A", "0001", "000002", "Beta", "200", "1")));
var service = new LegacyExpertSelectionService(executor);
var result = await service.PreviewAsync(Identity(), maximumItems: 1);
Assert.True(result.IsTruncated);
Assert.Single(result.Items);
Assert.Equal(2, executor.Calls[0].Spec.Parameters[2].Value);
Assert.Equal(100, LegacyExpertSelectionService.MaximumPreviewItems);
}
public static TheoryData<ExpertSelectionIdentity> UnsafeIdentities => new()
{
new("001", "Analyst A"),
new("00A1", "Analyst A"),
new("0001", " Analyst A"),
new("0001", "Bad\u0000Name")
};
[Theory]
[MemberData(nameof(UnsafeIdentities))]
public async Task PreviewAsync_RejectsUnsafeIdentityBeforeDatabaseAccess(
ExpertSelectionIdentity identity)
{
var executor = new RecordingExecutor(_ => PreviewTable());
await Assert.ThrowsAsync<ArgumentException>(() =>
new LegacyExpertSelectionService(executor).PreviewAsync(identity));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(101)]
public async Task PreviewAsync_RejectsInvalidLimitBeforeDatabaseAccess(int limit)
{
var executor = new RecordingExecutor(_ => PreviewTable());
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
new LegacyExpertSelectionService(executor).PreviewAsync(Identity(), limit));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task PreviewAsync_RejectsMissingOrMismatchedSelectedIdentity()
{
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(new RecordingExecutor(_ => PreviewTable()))
.PreviewAsync(Identity()));
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(new RecordingExecutor(_ => PreviewTable(
("Other", "0001", "005930", "Samsung", "70000", "0"))))
.PreviewAsync(Identity()));
}
[Fact]
public async Task PreviewAsync_RejectsWrongSchemaPartialSentinelAndRowOverflow()
{
var wrongSchema = PreviewTable();
wrongSchema.Columns[5].ColumnName = "play_index";
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(new RecordingExecutor(_ => wrongSchema))
.PreviewAsync(Identity()));
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(new RecordingExecutor(_ => PreviewTable(
("Analyst A", "0001", "005930", null, null, null))))
.PreviewAsync(Identity()));
var overflow = PreviewTable(
("Analyst A", "0001", "000001", "One", "100", "0"),
("Analyst A", "0001", "000002", "Two", "200", "1"),
("Analyst A", "0001", "000003", "Three", "300", "2"));
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(new RecordingExecutor(_ => overflow))
.PreviewAsync(Identity(), maximumItems: 1));
}
[Theory]
[InlineData("005930", "Samsung", "0", "0")]
[InlineData("005930", "Samsung", "-1", "0")]
[InlineData("005930", "Samsung", "70000.5", "0")]
[InlineData("005930", "Samsung", "9007199254740992", "0")]
[InlineData("005930", "Samsung", "070000", "0")]
[InlineData("00-5930", "Samsung", "70000", "0")]
[InlineData("005930", " Samsung", "70000", "0")]
[InlineData("005930", "Samsung", "70000", "01")]
[InlineData("005930", "Samsung", "70000", "-1")]
public async Task PreviewAsync_RejectsInvalidRecommendationValues(
string stockCode,
string stockName,
string buyAmount,
string playIndex)
{
var executor = new RecordingExecutor(_ => PreviewTable(
("Analyst A", "0001", stockCode, stockName, buyAmount, playIndex)));
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(executor).PreviewAsync(Identity()));
}
[Theory]
[InlineData("005930", "Samsung", "0", "005930", "Other", "1")]
[InlineData("005930", "Samsung", "0", "035720", "Samsung", "1")]
[InlineData("005930", "Samsung", "0", "035720", "Kakao", "0")]
[InlineData("005930", "Samsung", "2", "035720", "Kakao", "1")]
public async Task PreviewAsync_RejectsDuplicateOrNonIncreasingRecommendationIdentity(
string code1,
string name1,
string index1,
string code2,
string name2,
string index2)
{
var executor = new RecordingExecutor(_ => PreviewTable(
("Analyst A", "0001", code1, name1, "100", index1),
("Analyst A", "0001", code2, name2, "200", index2)));
await Assert.ThrowsAsync<ExpertSelectionDataException>(() =>
new LegacyExpertSelectionService(executor).PreviewAsync(Identity()));
}
[Fact]
public async Task PreviewAsync_StopsAfterProviderCancellation()
{
using var cancellation = new CancellationTokenSource();
var executor = new RecordingExecutor(_ =>
{
cancellation.Cancel();
return PreviewTable(
("Analyst A", "0001", "005930", "Samsung", "70000", "0"));
});
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new LegacyExpertSelectionService(executor).PreviewAsync(
Identity(),
cancellationToken: cancellation.Token));
Assert.Single(executor.Calls);
}
private static ExpertSelectionIdentity Identity() => new("0001", "Analyst A");
private static DataTable SearchTable(params (string Name, string Code)[] rows)
{
var table = new DataTable();
table.Columns.Add("EXPERT_NAME", typeof(string));
table.Columns.Add("EXPERT_CODE", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Name, row.Code);
}
return table;
}
private static DataTable PreviewTable(
params (string ExpertName, string ExpertCode, string? StockCode,
string? StockName, string? BuyAmount, string? PlayIndex)[] rows)
{
var table = new DataTable();
table.Columns.Add("EXPERT_NAME", typeof(string));
table.Columns.Add("EXPERT_CODE", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
table.Columns.Add("STOCK_NAME", typeof(string));
table.Columns.Add("BUY_AMOUNT", typeof(string));
table.Columns.Add("PLAY_INDEX", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(
row.ExpertName,
row.ExpertCode,
row.StockCode is null ? DBNull.Value : row.StockCode,
row.StockName is null ? DBNull.Value : row.StockName,
row.BuyAmount is null ? DBNull.Value : row.BuyAmount,
row.PlayIndex is null ? DBNull.Value : row.PlayIndex);
}
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string QueryName,
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());
}
}
}

View File

@@ -0,0 +1,139 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyIndustrySelectionServiceTests
{
[Theory]
[InlineData(IndustryMarket.Kospi, "OPERATOR_KOSPI_INDUSTRIES", "FROM T_PART")]
[InlineData(IndustryMarket.Kosdaq, "OPERATOR_KOSDAQ_INDUSTRIES", "FROM T_KOSDAQ_PART")]
public async Task GetAsync_uses_one_closed_parameterized_query_and_normalizes_rows(
IndustryMarket market,
string expectedTableName,
string expectedSqlFragment)
{
var executor = new RecordingExecutor(Table((" 반도체 ", "013"), ("운송", "021")));
var service = new LegacyIndustrySelectionService(executor);
var result = await service.GetAsync(market);
Assert.Equal(2, result.Count);
Assert.All(result, value => Assert.Equal(market, value.Market));
Assert.Equal(new IndustrySelection(market, "반도체", "013"), result[0]);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(expectedTableName, call.TableName);
Assert.Contains(expectedSqlFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Empty(call.Spec.Parameters);
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task GetAsync_rejects_wrong_schema_duplicate_unsafe_code_and_control_text()
{
var wrongSchema = Table(("반도체", "013"));
wrongSchema.Columns[0].ColumnName = "NAME";
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(wrongSchema))
.GetAsync(IndustryMarket.Kospi));
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(
Table(("반도체", "013"), ("반도체", "013"))))
.GetAsync(IndustryMarket.Kospi));
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(Table(("반도체", "01-3"))))
.GetAsync(IndustryMarket.Kospi));
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(Table(("반\n도체", "013"))))
.GetAsync(IndustryMarket.Kospi));
}
[Fact]
public async Task GetAsync_rejects_ambiguous_duplicate_name_or_code()
{
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(
Table(("반도체", "013"), ("반도체", "014"))))
.GetAsync(IndustryMarket.Kospi));
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(
Table(("반도체", "013"), ("운송", "013"))))
.GetAsync(IndustryMarket.Kosdaq));
}
[Fact]
public async Task GetAsync_rejects_unbounded_results_and_unknown_market_before_string_SQL()
{
var oversized = Table(Enumerable.Range(0, LegacyIndustrySelectionService.MaximumResults + 1)
.Select(index => ($"업종{index}", index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture)))
.ToArray());
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(oversized))
.GetAsync(IndustryMarket.Kosdaq));
var executor = new RecordingExecutor(Table());
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
new LegacyIndustrySelectionService(executor).GetAsync((IndustryMarket)999));
Assert.Empty(executor.Calls);
Assert.Equal(0, executor.StringCalls);
}
private static DataTable Table(params (string Name, string Code)[] rows)
{
var table = new DataTable();
table.Columns.Add("INDUSTRY_NAME", typeof(string));
table.Columns.Add("INDUSTRY_CODE", typeof(string));
foreach (var row in rows) table.Rows.Add(row.Name, row.Code);
return table;
}
private sealed record QueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
{
private readonly Queue<DataTable> _results = new(results);
public List<QueryCall> Calls { get; } = [];
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new QueryCall(source, tableName, query));
if (_results.Count == 0) throw new InvalidOperationException("No result remains.");
return Task.FromResult(_results.Dequeue().Copy());
}
}
}

View File

@@ -0,0 +1,616 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyManualFinancialScreenServiceTests
{
private const string Version = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
public static TheoryData<ManualFinancialScreenKind, string, string, string, string> Screens => new()
{
{ ManualFinancialScreenKind.RevenueComposition, "INPUT_PIE", "s5076", "5076", "REVENUE_COMPOSITION" },
{ ManualFinancialScreenKind.GrowthMetrics, "INPUT_GROW", "s5079", "5079", "GROWTH_METRICS" },
{ ManualFinancialScreenKind.Sales, "INPUT_SELL", "s5080", "5080", "SALES" },
{ ManualFinancialScreenKind.OperatingProfit, "INPUT_PROFIT", "s5081", "5081", "OPERATING_PROFIT" }
};
[Theory]
[MemberData(nameof(Screens))]
public void Contracts_pin_the_original_table_builder_cut_and_one_page_selection(
ManualFinancialScreenKind screen,
string table,
string builder,
string cut,
string graphic)
{
var contract = ManualFinancialCutContracts.Get(screen);
Assert.Equal(table, contract.TableName);
Assert.Equal(builder, contract.BuilderKey);
Assert.Equal(cut, contract.CutCode);
Assert.Equal(graphic, contract.CanonicalGraphicType);
Assert.StartsWith("INPUT_", contract.TableName, StringComparison.Ordinal);
Assert.Contains("_", contract.CompoundStorageShape, StringComparison.Ordinal);
}
[Theory]
[MemberData(nameof(Screens))]
public async Task Search_uses_one_parameterized_Oracle_profile_and_maps_the_closed_DTO(
ManualFinancialScreenKind screen,
string tableName,
string builder,
string cut,
string graphic)
{
var executor = new RecordingQueryExecutor(Table(screen, "삼성전자"));
var service = new LegacyManualFinancialScreenService(executor);
var result = await service.SearchAsync(screen, " 삼성 ", 25);
Assert.Equal(screen, result.Screen);
Assert.Equal("삼성", result.Query);
Assert.Single(result.Items);
Assert.Equal(screen, result.Items[0].Record.Identity.Screen);
Assert.Equal(builder, ManualFinancialCutContracts.Get(screen).BuilderKey);
Assert.Equal(cut, ManualFinancialCutContracts.Get(screen).CutCode);
Assert.Equal(graphic, ManualFinancialCutContracts.Get(screen).CanonicalGraphicType);
Assert.Equal("삼성전자", result.Items[0].Record.Identity.StockName);
Assert.Equal(Version, result.Items[0].RowVersion);
Assert.False(result.IsTruncated);
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.Equal("MANUAL_FINANCIAL_" + QueryToken(screen) + "_SEARCH", executor.TableName);
Assert.NotNull(executor.Spec);
Assert.Contains("FROM " + tableName, executor.Spec!.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("삼성", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(["search_empty", "search_term", "row_limit"],
executor.Spec.Parameters.Select(parameter => parameter.Name));
Assert.Equal(0, executor.Spec.Parameters[0].Value);
Assert.Equal("삼성", executor.Spec.Parameters[1].Value);
Assert.Equal(26, executor.Spec.Parameters[2].Value);
}
[Fact]
public async Task Blank_list_and_literal_substring_search_are_bound_and_escaped()
{
var blankExecutor = new RecordingQueryExecutor(Table(
ManualFinancialScreenKind.Sales,
"A%_!"));
await new LegacyManualFinancialScreenService(blankExecutor)
.SearchAsync(ManualFinancialScreenKind.Sales, " ");
Assert.Equal(1, blankExecutor.Spec!.Parameters[0].Value);
Assert.Equal(string.Empty, blankExecutor.Spec.Parameters[1].Value);
var escapedExecutor = new RecordingQueryExecutor(Table(
ManualFinancialScreenKind.Sales,
"A%_!"));
await new LegacyManualFinancialScreenService(escapedExecutor)
.SearchAsync(ManualFinancialScreenKind.Sales, "a%_!");
Assert.Equal("A!%!_!!", escapedExecutor.Spec!.Parameters[1].Value);
Assert.Contains("ESCAPE '!'", escapedExecutor.Spec.Sql, StringComparison.Ordinal);
}
[Fact]
public async Task Revenue_storage_maps_five_label_percentage_fields_and_empty_slice()
{
var table = EmptyTable(ManualFinancialScreenKind.RevenueComposition);
table.Rows.Add("A", "2026-06", "반도체_50.0", "서비스_-", "", "기타_25", "해외_25", Version);
var result = await new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.RevenueComposition);
var record = Assert.IsType<ManualRevenueCompositionRecord>(result.Items[0].Record);
Assert.Equal("2026-06", record.BaseDate);
Assert.Equal(5, record.Slices.Count);
Assert.Equal(new ManualRevenueSlice("반도체", "50.0", 50d), record.Slices[0]);
Assert.Equal(new ManualRevenueSlice("서비스", "-", 0d), record.Slices[1]);
Assert.Null(record.Slices[2]);
}
[Fact]
public async Task Growth_storage_maps_four_named_series_and_four_periods()
{
var result = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(Table(ManualFinancialScreenKind.GrowthMetrics, "A")))
.SearchAsync(ManualFinancialScreenKind.GrowthMetrics);
var record = Assert.IsType<ManualGrowthMetricsRecord>(result.Items[0].Record);
Assert.Equal([1d, 2d, 3d, 4d], record.SalesGrowth.ToArray());
Assert.Equal([5d, 6d, null, 8d], record.OperatingProfitGrowth.ToArray());
Assert.Equal(["1Q", "2Q", "3Q", "4Q"], record.Periods.ToArray());
}
[Theory]
[InlineData(ManualFinancialScreenKind.Sales)]
[InlineData(ManualFinancialScreenKind.OperatingProfit)]
public async Task Six_quarter_storage_maps_signed_integer_values(ManualFinancialScreenKind screen)
{
var result = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(Table(screen, "A")))
.SearchAsync(screen);
var quarters = result.Items[0].Record switch
{
ManualSalesRecord sales => sales.Quarters,
ManualOperatingProfitRecord profit => profit.Quarters,
_ => throw new InvalidOperationException()
};
Assert.Equal(6, quarters.Count);
Assert.Equal("1Q", quarters[0].Quarter);
Assert.Equal(10, quarters[0].Value);
Assert.Equal(-20, quarters[1].Value);
}
[Fact]
public async Task Search_rejects_duplicate_or_case_ambiguous_stock_names()
{
var table = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(table, "Alpha");
AddQuarterRow(table, "ALPHA", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
await Assert.ThrowsAsync<ManualFinancialAmbiguousIdentityException>(() =>
new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.Sales));
}
[Fact]
public async Task Exact_get_binds_name_and_rejects_missing_or_duplicate_rows()
{
var identity = new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Alpha");
var missing = new RecordingQueryExecutor(EmptyTable(ManualFinancialScreenKind.Sales));
await Assert.ThrowsAsync<ManualFinancialNotFoundException>(() =>
new LegacyManualFinancialScreenService(missing).GetAsync(identity));
Assert.Equal("stock_name", missing.Spec!.Parameters.Single().Name);
Assert.Equal("Alpha", missing.Spec.Parameters.Single().Value);
Assert.DoesNotContain("Alpha", missing.Spec.Sql, StringComparison.Ordinal);
var duplicate = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(duplicate, "Alpha");
AddQuarterRow(duplicate, "Alpha", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
await Assert.ThrowsAsync<ManualFinancialAmbiguousIdentityException>(() =>
new LegacyManualFinancialScreenService(new RecordingQueryExecutor(duplicate))
.GetAsync(identity));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Read_schema_and_row_version_fail_closed(bool badSchema)
{
var table = Table(ManualFinancialScreenKind.Sales, "A");
if (badSchema)
{
table.Columns[0].ColumnName = "stock_name";
}
else
{
table.Rows[0]["ROW_VERSION"] = "not-a-version";
}
await Assert.ThrowsAsync<ManualFinancialDataException>(() =>
new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.Sales));
}
[Theory]
[MemberData(nameof(Screens))]
public async Task Create_uses_bound_values_one_locked_transaction_and_exactly_one_row(
ManualFinancialScreenKind screen,
string table,
string builder,
string cut,
string graphic)
{
var mutation = new RecordingMutationExecutor();
var service = new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(EmptyTable(screen)),
mutation);
var record = Record(screen, "Operator ' quoted");
var receipt = await service.CreateAsync(record);
Assert.Equal(1, mutation.CallCount);
Assert.Equal(ManualFinancialMutationKind.Create, receipt.Kind);
Assert.Equal(screen, receipt.Screen);
Assert.Equal("Operator ' quoted", receipt.StockName);
Assert.Equal(1, receipt.AffectedRows);
Assert.NotEqual(Guid.Empty, receipt.OperationId);
Assert.Equal(DataSourceKind.Oracle, mutation.Source);
var transaction = Assert.IsType<ManualFinancialDbTransaction>(mutation.Transaction);
Assert.Equal(screen, transaction.Screen);
Assert.Equal(builder, ManualFinancialCutContracts.Get(screen).BuilderKey);
Assert.Equal(cut, ManualFinancialCutContracts.Get(screen).CutCode);
Assert.Equal(graphic, ManualFinancialCutContracts.Get(screen).CanonicalGraphicType);
Assert.Equal(table, transaction.TableName);
Assert.Equal(ManualFinancialMutationKind.Create, transaction.Kind);
Assert.True(transaction.RequiresExclusiveTableLock);
Assert.Equal("LOCK TABLE " + table + " IN EXCLUSIVE MODE", transaction.ExclusiveTableLockSql);
var command = Assert.Single(transaction.Commands);
Assert.Equal(ManualFinancialDbCommandKind.Insert, command.Kind);
Assert.Equal(ManualFinancialAffectedRowsRule.ExactlyOne, command.AffectedRowsRule);
Assert.Contains("INSERT INTO " + table, command.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("Operator ' quoted", command.Sql, StringComparison.Ordinal);
Assert.Equal("Operator ' quoted", command.Parameters.Single(p => p.Name == "stock_name").Value);
Assert.Equal("Operator ' quoted", command.Parameters.Single(p => p.Name == "unique_stock_name").Value);
Assert.All(command.Parameters, parameter => Assert.Equal(DbType.String, parameter.DbType));
}
[Fact]
public async Task Update_preserves_identity_binds_row_version_and_serializes_loader_shapes()
{
var mutation = new RecordingMutationExecutor();
var service = MutableService(ManualFinancialScreenKind.GrowthMetrics, mutation);
var original = Snapshot(Record(ManualFinancialScreenKind.GrowthMetrics, "A"));
var replacement = new ManualGrowthMetricsRecord(
original.Record.Identity,
new ManualGrowthSeriesValues(1, null, 3.5, 4),
new ManualGrowthSeriesValues(5, 6, 7, 8),
new ManualGrowthSeriesValues(9, 10, 11, 12),
new ManualGrowthSeriesValues(13, 14, 15, 16),
new ManualGrowthPeriodLabels("1Q", "2Q", "3Q", "4Q"));
await service.UpdateAsync(original, replacement);
var transaction = Assert.IsType<ManualFinancialDbTransaction>(mutation.Transaction);
var command = Assert.Single(transaction.Commands);
Assert.Equal(ManualFinancialDbCommandKind.Update, command.Kind);
Assert.Equal(Version, command.Parameters.Single(p => p.Name == "expected_row_version").Value);
Assert.Equal("1__3.5_4", command.Parameters.Single(p => p.Name == "field_1").Value);
Assert.Equal("1Q_2Q_3Q_4Q", command.Parameters.Single(p => p.Name == "field_5").Value);
Assert.Contains("STANDARD_HASH", command.Sql, StringComparison.Ordinal);
Assert.Contains("COUNT(*)", command.Sql, StringComparison.Ordinal);
}
[Fact]
public async Task Update_rejects_rename_or_screen_change_before_executor()
{
var mutation = new RecordingMutationExecutor();
var service = MutableService(ManualFinancialScreenKind.Sales, mutation);
var original = Snapshot(Record(ManualFinancialScreenKind.Sales, "A"));
await Assert.ThrowsAsync<ArgumentException>(() =>
service.UpdateAsync(original, Record(ManualFinancialScreenKind.Sales, "B")));
await Assert.ThrowsAsync<ArgumentException>(() =>
service.UpdateAsync(original, Record(ManualFinancialScreenKind.OperatingProfit, "A")));
Assert.Equal(0, mutation.CallCount);
}
[Fact]
public async Task Delete_one_binds_identity_and_version_while_delete_all_has_no_values()
{
var mutation = new RecordingMutationExecutor();
var service = MutableService(ManualFinancialScreenKind.OperatingProfit, mutation);
var snapshot = Snapshot(Record(ManualFinancialScreenKind.OperatingProfit, "A"));
await service.DeleteAsync(snapshot);
var deleteOne = Assert.Single(mutation.Transaction!.Commands);
Assert.Equal(ManualFinancialDbCommandKind.DeleteOne, deleteOne.Kind);
Assert.Equal(["identity_stock_name", "expected_row_version", "unique_stock_name"],
deleteOne.Parameters.Select(parameter => parameter.Name));
Assert.Contains("STANDARD_HASH", deleteOne.Sql, StringComparison.Ordinal);
await service.DeleteAllAsync(ManualFinancialScreenKind.OperatingProfit);
var deleteAll = Assert.Single(mutation.Transaction!.Commands);
Assert.Equal(ManualFinancialDbCommandKind.DeleteAll, deleteAll.Kind);
Assert.Equal(ManualFinancialAffectedRowsRule.AnyNonNegative, deleteAll.AffectedRowsRule);
Assert.Empty(deleteAll.Parameters);
Assert.Equal("DELETE FROM INPUT_PROFIT", deleteAll.Sql);
}
[Fact]
public async Task Read_only_service_blocks_every_mutation_before_database_work()
{
var service = new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(EmptyTable(ManualFinancialScreenKind.Sales)));
Assert.False(service.CanMutate);
var record = Record(ManualFinancialScreenKind.Sales, "A");
var snapshot = Snapshot(record);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.CreateAsync(record));
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateAsync(snapshot, record));
await Assert.ThrowsAsync<InvalidOperationException>(() => service.DeleteAsync(snapshot));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.DeleteAllAsync(ManualFinancialScreenKind.Sales));
}
[Fact]
public async Task Unknown_outcome_is_not_retried_and_known_rollback_is_preserved()
{
var unknown = new RecordingMutationExecutor { Exception = new TimeoutException("late") };
var service = MutableService(ManualFinancialScreenKind.Sales, unknown);
var exception = await Assert.ThrowsAsync<ManualFinancialMutationException>(() =>
service.CreateAsync(Record(ManualFinancialScreenKind.Sales, "A")));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, unknown.CallCount);
var rejectedException = new ManualFinancialMutationRejectedException(
ManualFinancialMutationRejectionReason.DuplicateIdentity,
"rolled back");
var rejected = new RecordingMutationExecutor { Exception = rejectedException };
var rejectedService = MutableService(ManualFinancialScreenKind.Sales, rejected);
var actual = await Assert.ThrowsAsync<ManualFinancialMutationRejectedException>(() =>
rejectedService.CreateAsync(Record(ManualFinancialScreenKind.Sales, "A")));
Assert.Same(rejectedException, actual);
Assert.False(actual.OutcomeUnknown);
Assert.Equal(1, rejected.CallCount);
}
[Theory]
[InlineData(-1)]
[InlineData(0)]
[InlineData(2)]
public async Task Ambiguous_committed_row_count_is_OutcomeUnknown_and_never_retried(int affectedRows)
{
var mutation = new RecordingMutationExecutor { AffectedRows = affectedRows };
var service = MutableService(ManualFinancialScreenKind.Sales, mutation);
var exception = await Assert.ThrowsAsync<ManualFinancialMutationException>(() =>
service.CreateAsync(Record(ManualFinancialScreenKind.Sales, "A")));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, mutation.CallCount);
}
[Fact]
public void Verified_stock_rejects_duplicate_names_duplicate_rows_or_wrong_provider()
{
var identity = new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Alpha");
var selected = new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "Alpha", "000001");
Assert.Throws<ArgumentException>(() => ManualFinancialCutContracts.VerifyStock(
identity,
[selected, new(StockMarket.Kosdaq, DataSourceKind.Oracle, "Alpha", "000002")],
StockMarket.Kospi,
"000001"));
Assert.Throws<ArgumentException>(() => ManualFinancialCutContracts.VerifyStock(
identity,
[selected, selected],
StockMarket.Kospi,
"000001"));
Assert.Throws<ArgumentException>(() => ManualFinancialCutContracts.VerifyStock(
identity,
[new(StockMarket.Kospi, DataSourceKind.MariaDb, "Alpha", "000001")],
StockMarket.Kospi,
"000001"));
}
[Theory]
[MemberData(nameof(Screens))]
public void Verified_stock_creates_the_exact_s5076_s5079_s5080_s5081_contract(
ManualFinancialScreenKind screen,
string _,
string builder,
string cut,
string graphic)
{
var record = Record(screen, "Alpha");
var snapshot = Snapshot(record);
var candidate = new StockSearchItem(
StockMarket.Kosdaq,
DataSourceKind.Oracle,
"Alpha",
"000001");
var stock = ManualFinancialCutContracts.VerifyStock(
record.Identity,
[candidate],
StockMarket.Kosdaq,
"000001");
var selection = ManualFinancialCutContracts.CreateSelection(snapshot, stock);
Assert.Equal(builder, selection.BuilderKey);
Assert.Equal(cut, selection.CutCode);
Assert.Equal(1, selection.CurrentPage);
Assert.Equal(1, selection.TotalPages);
Assert.Equal("KOSDAQ", selection.Selection.GroupCode);
Assert.Equal("Alpha", selection.Selection.Subject);
Assert.Equal(graphic, selection.Selection.GraphicType);
Assert.Equal(string.Empty, selection.Selection.Subtype);
Assert.Equal(string.Empty, selection.Selection.DataCode);
}
[Fact]
public async Task Invalid_closed_DTO_shapes_fail_before_a_mutation()
{
var mutation = new RecordingMutationExecutor();
var service = MutableService(ManualFinancialScreenKind.RevenueComposition, mutation);
var identity = new ManualFinancialIdentity(
ManualFinancialScreenKind.RevenueComposition,
"A");
var wrongCount = new ManualRevenueCompositionRecord(
identity,
"2026",
[new("A", "50", 50)]);
var inconsistent = new ManualRevenueCompositionRecord(
identity,
"2026",
[new("A", "50", 49), null, null, null, null]);
var wrongType = new ManualSalesRecord(identity, SixQuarters());
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(wrongCount));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(inconsistent));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(wrongType));
Assert.Equal(0, mutation.CallCount);
}
private static LegacyManualFinancialScreenService MutableService(
ManualFinancialScreenKind screen,
RecordingMutationExecutor mutation) =>
new(new RecordingQueryExecutor(EmptyTable(screen)), mutation);
private static ManualFinancialSnapshot Snapshot(ManualFinancialRecord record) =>
new(record, Version);
private static ManualFinancialRecord Record(
ManualFinancialScreenKind screen,
string stockName)
{
var identity = new ManualFinancialIdentity(screen, stockName);
return screen switch
{
ManualFinancialScreenKind.RevenueComposition =>
new ManualRevenueCompositionRecord(
identity,
"2026-06",
[
new("반도체", "50", 50),
new("서비스", "25", 25),
new("해외", "25", 25),
null,
null
]),
ManualFinancialScreenKind.GrowthMetrics =>
new ManualGrowthMetricsRecord(
identity,
new(1, 2, 3, 4),
new(5, 6, null, 8),
new(9, 10, 11, 12),
new(13, 14, 15, 16),
new("1Q", "2Q", "3Q", "4Q")),
ManualFinancialScreenKind.Sales => new ManualSalesRecord(identity, SixQuarters()),
ManualFinancialScreenKind.OperatingProfit =>
new ManualOperatingProfitRecord(identity, SixQuarters()),
_ => throw new ArgumentOutOfRangeException(nameof(screen))
};
}
private static IReadOnlyList<ManualQuarterValue> SixQuarters() =>
[
new("1Q", 10),
new("2Q", -20),
new("3Q", 30),
new("4Q", 40),
new("5Q", 50),
new("6Q", 60)
];
private static DataTable Table(ManualFinancialScreenKind screen, string stockName)
{
var table = EmptyTable(screen);
switch (screen)
{
case ManualFinancialScreenKind.RevenueComposition:
table.Rows.Add(stockName, "2026-06", "반도체_50", "서비스_25", "해외_25", "", "", Version);
break;
case ManualFinancialScreenKind.GrowthMetrics:
table.Rows.Add(stockName, "1_2_3_4", "5_6__8", "9_10_11_12", "13_14_15_16", "1Q_2Q_3Q_4Q", Version);
break;
case ManualFinancialScreenKind.Sales:
case ManualFinancialScreenKind.OperatingProfit:
AddQuarterRow(table, stockName);
break;
default:
throw new ArgumentOutOfRangeException(nameof(screen));
}
return table;
}
private static DataTable EmptyTable(ManualFinancialScreenKind screen)
{
var table = new DataTable();
foreach (var column in ManualFinancialCutContracts.Get(screen).StorageColumns)
{
table.Columns.Add(column, typeof(string));
}
table.Columns.Add("ROW_VERSION", typeof(string));
return table;
}
private static void AddQuarterRow(
DataTable table,
string stockName,
string version = Version) =>
table.Rows.Add(
stockName,
"1Q_10",
"2Q_-20",
"3Q_30",
"4Q_40",
"5Q_50",
"6Q_60",
version);
private static string QueryToken(ManualFinancialScreenKind screen) => screen switch
{
ManualFinancialScreenKind.RevenueComposition => "PIE",
ManualFinancialScreenKind.GrowthMetrics => "GROW",
ManualFinancialScreenKind.Sales => "SELL",
ManualFinancialScreenKind.OperatingProfit => "PROFIT",
_ => throw new ArgumentOutOfRangeException(nameof(screen))
};
private sealed class RecordingQueryExecutor : IDataQueryExecutor
{
private readonly DataTable _table;
public RecordingQueryExecutor(DataTable table)
{
_table = table;
}
public DataSourceKind? Source { get; private set; }
public string? TableName { get; private set; }
public DataQuerySpec? Spec { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new NotSupportedException();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Source = source;
TableName = tableName;
Spec = query;
return Task.FromResult(_table);
}
}
private sealed class RecordingMutationExecutor : IManualFinancialMutationExecutor
{
public int CallCount { get; private set; }
public int AffectedRows { get; init; } = 1;
public Exception? Exception { get; init; }
public DataSourceKind? Source { get; private set; }
public ManualFinancialDbTransaction? Transaction { get; private set; }
public Task<ManualFinancialDbTransactionResult> ExecuteTransactionAsync(
DataSourceKind source,
ManualFinancialDbTransaction transaction,
CancellationToken cancellationToken = default)
{
CallCount++;
Source = source;
Transaction = transaction;
if (Exception is not null)
{
return Task.FromException<ManualFinancialDbTransactionResult>(Exception);
}
return Task.FromResult(new ManualFinancialDbTransactionResult(
transaction.OperationId,
[AffectedRows],
DateTimeOffset.Now));
}
}
}

View File

@@ -0,0 +1,271 @@
using System.Data;
using System.Globalization;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
/// <summary>
/// Component integration of the typed service and its read/transaction seams.
/// The store is in-memory: no production Oracle row is changed by this test.
/// </summary>
public sealed class LegacyNamedPlaylistPersistenceIntegrationTests
{
[Fact]
public async Task NamedPlaylistLifecycle_PreservesTitleAndVisibleItemOrder()
{
var store = new InMemoryOracleContractStore();
var service = new LegacyNamedPlaylistPersistenceService(store, store);
var firstCode = await service.SuggestNextProgramCodeAsync();
await service.CreateDefinitionAsync(firstCode, "Morning");
var secondCode = await service.SuggestNextProgramCodeAsync();
await service.CreateDefinitionAsync(secondCode, "Afternoon");
var definitions = await service.ListAsync();
Assert.Equal(
["Afternoon", "Morning"],
definitions.Items.Select(static item => item.Title));
var alpha = StoredItem(0, "Alpha", "000001");
var beta = StoredItem(1, "Beta", "000002");
await service.ReplaceItemsAsync(firstCode, [alpha, beta]);
var initial = await service.LoadAsync(firstCode);
Assert.Equal(
["Alpha", "Beta"],
initial.Items.Select(static item => item.Selection.Subject));
await service.ReplaceItemsAsync(
firstCode,
[beta with { ItemIndex = 0 }, alpha with { ItemIndex = 1 }]);
var reordered = await service.LoadAsync(firstCode);
Assert.Equal(
["Beta", "Alpha"],
reordered.Items.Select(static item => item.Selection.Subject));
Assert.Equal([0, 1], reordered.Items.Select(static item => item.ItemIndex));
await service.DeleteAsync(firstCode);
await Assert.ThrowsAsync<NamedPlaylistNotFoundException>(
() => service.LoadAsync(firstCode));
var remaining = await service.ListAsync();
Assert.Equal(secondCode, Assert.Single(remaining.Items).ProgramCode);
}
private static NamedPlaylistStoredItem StoredItem(
int index,
string subject,
string dataCode) =>
new(
index,
true,
new LegacySceneSelection("KOSPI", subject, "1열판기본", "현재가", dataCode),
new NamedPlaylistPageState(1, 1));
private sealed class InMemoryOracleContractStore
: IDataQueryExecutor, INamedPlaylistMutationExecutor
{
private Dictionary<string, string> _definitions = new(StringComparer.Ordinal);
private Dictionary<string, SortedDictionary<int, string>> _items = new(StringComparer.Ordinal);
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("String SQL fallback is forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("String SQL fallback is forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Assert.Equal(DataSourceKind.Oracle, source);
query.ValidateFor(source);
var table = tableName switch
{
LegacyNamedPlaylistPersistenceService.ListQueryName => List(query),
LegacyNamedPlaylistPersistenceService.NextCodeQueryName => NextCode(),
LegacyNamedPlaylistPersistenceService.LoadQueryName => Load(query),
_ => throw new InvalidOperationException("Unexpected query contract.")
};
return Task.FromResult(table);
}
public Task<NamedPlaylistDbTransactionResult> ExecuteTransactionAsync(
DataSourceKind source,
NamedPlaylistDbTransaction transaction,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Assert.Equal(DataSourceKind.Oracle, source);
var definitions = new Dictionary<string, string>(_definitions, StringComparer.Ordinal);
var items = _items.ToDictionary(
static pair => pair.Key,
static pair => new SortedDictionary<int, string>(pair.Value),
StringComparer.Ordinal);
var affected = new List<int>(transaction.Commands.Count);
foreach (var command in transaction.Commands)
{
switch (command.Kind)
{
case NamedPlaylistDbCommandKind.InsertDefinition:
{
var code = StringValue(command, "program_code");
var title = StringValue(command, "program_title");
if (!definitions.TryAdd(code, title))
{
throw new NamedPlaylistMutationException(
"Duplicate definition; transaction rolled back.",
outcomeUnknown: false);
}
affected.Add(1);
break;
}
case NamedPlaylistDbCommandKind.DeleteItems:
{
var code = StringValue(command, "program_code");
var count = items.TryGetValue(code, out var existing) ? existing.Count : 0;
items.Remove(code);
affected.Add(count);
break;
}
case NamedPlaylistDbCommandKind.InsertItem:
{
var code = StringValue(command, "program_code");
if (!definitions.ContainsKey(code))
{
throw new NamedPlaylistMutationException(
"Missing definition; transaction rolled back.",
outcomeUnknown: false);
}
var listText = StringValue(command, "list_text");
var index = IntValue(command, "item_index");
if (!items.TryGetValue(code, out var playlistItems))
{
playlistItems = new SortedDictionary<int, string>();
items.Add(code, playlistItems);
}
playlistItems.Add(index, listText);
affected.Add(1);
break;
}
case NamedPlaylistDbCommandKind.DeleteDefinition:
{
var code = StringValue(command, "program_code");
affected.Add(definitions.Remove(code) ? 1 : 0);
break;
}
default:
throw new InvalidOperationException("Unexpected mutation contract.");
}
}
_definitions = definitions;
_items = items;
return Task.FromResult(new NamedPlaylistDbTransactionResult(
transaction.OperationId,
affected,
DateTimeOffset.UtcNow));
}
private DataTable List(DataQuerySpec query)
{
var rowLimit = Convert.ToInt32(
Parameter(query.Parameters, "row_limit").Value,
CultureInfo.InvariantCulture);
var table = new DataTable();
table.Columns.Add("DC_CODE", typeof(string));
table.Columns.Add("DC_TITLE", typeof(string));
foreach (var pair in _definitions
.OrderBy(static pair => pair.Value, StringComparer.Ordinal)
.ThenBy(static pair => pair.Key, StringComparer.Ordinal)
.Take(rowLimit))
{
table.Rows.Add(pair.Key, pair.Value);
}
return table;
}
private DataTable NextCode()
{
var next = _definitions.Count == 0
? 1
: _definitions.Keys.Max(static code =>
int.Parse(code, NumberStyles.None, CultureInfo.InvariantCulture)) + 1;
var table = new DataTable();
table.Columns.Add("DC_CODE", typeof(string));
table.Rows.Add(next.ToString("D8", CultureInfo.InvariantCulture));
return table;
}
private DataTable Load(DataQuerySpec query)
{
var code = Convert.ToString(
Parameter(query.Parameters, "program_code").Value,
CultureInfo.InvariantCulture)!;
var rowLimit = Convert.ToInt32(
Parameter(query.Parameters, "row_limit").Value,
CultureInfo.InvariantCulture);
var table = new DataTable();
table.Columns.Add("DC_CODE", typeof(string));
table.Columns.Add("DC_TITLE", typeof(string));
table.Columns.Add("LIST_TEXT", typeof(string));
table.Columns.Add("ITEM_INDEX", typeof(string));
if (!_definitions.TryGetValue(code, out var title))
{
return table;
}
if (!_items.TryGetValue(code, out var playlistItems) || playlistItems.Count == 0)
{
table.Rows.Add(code, title, DBNull.Value, DBNull.Value);
return table;
}
foreach (var item in playlistItems.Take(rowLimit))
{
table.Rows.Add(
code,
title,
item.Value,
item.Key.ToString(CultureInfo.InvariantCulture));
}
return table;
}
private static string StringValue(NamedPlaylistDbCommand command, string name) =>
Assert.IsType<string>(CommandParameter(command, name).Value);
private static int IntValue(NamedPlaylistDbCommand command, string name) =>
Assert.IsType<int>(CommandParameter(command, name).Value);
private static NamedPlaylistDbParameter CommandParameter(
NamedPlaylistDbCommand command,
string name) =>
Assert.Single(command.Parameters, parameter =>
string.Equals(parameter.Name, name, StringComparison.Ordinal));
private static DataQueryParameter Parameter(
IReadOnlyList<DataQueryParameter> parameters,
string name) =>
Assert.Single(parameters, parameter =>
string.Equals(parameter.Name, name, StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,578 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyNamedPlaylistPersistenceServiceTests
{
[Fact]
public async Task ListAsync_UsesBoundOracleLimitAndReportsTruncation()
{
var query = new RecordingQueryExecutor(call =>
{
Assert.Equal(LegacyNamedPlaylistPersistenceService.ListQueryName, call.QueryName);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(LegacyNamedPlaylistPersistenceService.ListSql, call.Spec.Sql);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(3, parameter.Value);
Assert.Equal(DbType.Int32, parameter.DbType);
return ListTable(
("00000001", "Alpha"),
("00000002", "Beta"),
("00000003", "Gamma"));
});
var service = new LegacyNamedPlaylistPersistenceService(query);
var result = await service.ListAsync(2);
Assert.True(result.IsTruncated);
Assert.Equal(
["00000001", "00000002"],
result.Items.Select(static item => item.ProgramCode));
Assert.Equal(["Alpha", "Beta"], result.Items.Select(static item => item.Title));
Assert.Single(query.Calls);
Assert.Equal(0, query.StringSqlCallCount);
}
[Fact]
public async Task ListAsync_AllowsDuplicateTitlesButRejectsDuplicateCodes()
{
var duplicateTitles = new RecordingQueryExecutor(_ =>
ListTable(("00000001", "Morning"), ("00000002", "Morning")));
var validService = new LegacyNamedPlaylistPersistenceService(duplicateTitles);
var valid = await validService.ListAsync();
Assert.Equal(2, valid.Items.Count);
var duplicateCodes = new RecordingQueryExecutor(_ =>
ListTable(("00000001", "Morning"), ("00000001", "Evening")));
var invalidService = new LegacyNamedPlaylistPersistenceService(duplicateCodes);
await Assert.ThrowsAsync<NamedPlaylistDataException>(() => invalidService.ListAsync());
}
[Fact]
public async Task ListAsync_RejectsSchemaDriftAndInvalidLimit()
{
var wrongSchema = new DataTable();
wrongSchema.Columns.Add("DC_TITLE", typeof(string));
wrongSchema.Columns.Add("DC_CODE", typeof(string));
var query = new RecordingQueryExecutor(_ => wrongSchema);
var service = new LegacyNamedPlaylistPersistenceService(query);
await Assert.ThrowsAsync<NamedPlaylistDataException>(() => service.ListAsync());
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ListAsync(0));
}
[Fact]
public async Task SuggestNextProgramCodeAsync_PreservesOriginalEightDigitContract()
{
var query = new RecordingQueryExecutor(call =>
{
Assert.Equal(LegacyNamedPlaylistPersistenceService.NextCodeQueryName, call.QueryName);
Assert.Equal(LegacyNamedPlaylistPersistenceService.NextCodeSql, call.Spec.Sql);
Assert.Empty(call.Spec.Parameters);
return NextCodeTable("00000124");
});
var service = new LegacyNamedPlaylistPersistenceService(query);
var code = await service.SuggestNextProgramCodeAsync();
Assert.Equal("00000124", code);
}
[Theory]
[InlineData("124")]
[InlineData("0000000A")]
[InlineData("100000000")]
public async Task SuggestNextProgramCodeAsync_RejectsInvalidDatabaseCode(string code)
{
var query = new RecordingQueryExecutor(_ => NextCodeTable(code));
var service = new LegacyNamedPlaylistPersistenceService(query);
await Assert.ThrowsAsync<NamedPlaylistDataException>(
() => service.SuggestNextProgramCodeAsync());
}
[Fact]
public async Task LoadAsync_ParsesOriginalSevenFieldRowsAndPageWhitespace()
{
var query = new RecordingQueryExecutor(call =>
{
Assert.Equal(LegacyNamedPlaylistPersistenceService.LoadQueryName, call.QueryName);
Assert.Equal(LegacyNamedPlaylistPersistenceService.LoadSql, call.Spec.Sql);
Assert.Collection(
call.Spec.Parameters,
parameter =>
{
Assert.Equal("program_code", parameter.Name);
Assert.Equal("00000042", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
},
parameter =>
{
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(1_001, parameter.Value);
Assert.Equal(DbType.Int32, parameter.DbType);
});
return LoadTable(
("00000042", "Opening", "1^KOSPI^삼성전자^1열판기본^현재가^ 1 / 2 ^005930", "0"),
("00000042", "Opening", "0^INDEX^코스피 지수^2열판^현재가^1/1^", "1"));
});
var service = new LegacyNamedPlaylistPersistenceService(query);
var document = await service.LoadAsync("00000042");
Assert.Equal(new NamedPlaylistSummary("00000042", "Opening"), document.Definition);
Assert.Collection(
document.Items,
item =>
{
Assert.Equal(0, item.ItemIndex);
Assert.True(item.IsEnabled);
Assert.Equal("KOSPI", item.Selection.GroupCode);
Assert.Equal("삼성전자", item.Selection.Subject);
Assert.Equal("1열판기본", item.Selection.GraphicType);
Assert.Equal("현재가", item.Selection.Subtype);
Assert.Equal("005930", item.Selection.DataCode);
Assert.Equal(new NamedPlaylistPageState(1, 2), item.Page);
},
item =>
{
Assert.Equal(1, item.ItemIndex);
Assert.False(item.IsEnabled);
Assert.Equal("INDEX", item.Selection.GroupCode);
Assert.Equal(string.Empty, item.Selection.DataCode);
Assert.Equal(new NamedPlaylistPageState(1, 1), item.Page);
});
}
[Fact]
public async Task LoadAsync_ReturnsEmptyDocumentFromLeftJoinSentinel()
{
var table = LoadTable();
table.Rows.Add("00000007", "Empty", DBNull.Value, DBNull.Value);
var query = new RecordingQueryExecutor(_ => table);
var service = new LegacyNamedPlaylistPersistenceService(query);
var document = await service.LoadAsync("00000007");
Assert.Empty(document.Items);
Assert.Equal("Empty", document.Definition.Title);
}
[Fact]
public async Task LoadAsync_RejectsMissingDefinitionAndNonContiguousIndexes()
{
var notFound = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => LoadTable()));
await Assert.ThrowsAsync<NamedPlaylistNotFoundException>(
() => notFound.LoadAsync("00000001"));
var gap = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => LoadTable(
("00000001", "Gap", "1^KOSPI^Alpha^Plate^Current^1/1^000001", "1"))));
await Assert.ThrowsAsync<NamedPlaylistDataException>(
() => gap.LoadAsync("00000001"));
}
[Theory]
[InlineData("1^KOSPI^Alpha^Plate^Current^1/1")]
[InlineData("yes^KOSPI^Alpha^Plate^Current^1/1^000001")]
[InlineData("1^KOSPI^Alpha^Plate^Current^0/1^000001")]
[InlineData("1^KOSPI^Alpha^Plate^Current^1/10000^000001")]
[InlineData("1^KOSPI^Al\npha^Plate^Current^1/1^000001")]
public async Task LoadAsync_RejectsMalformedLegacyListText(string listText)
{
var query = new RecordingQueryExecutor(_ =>
LoadTable(("00000001", "Malformed", listText, "0")));
var service = new LegacyNamedPlaylistPersistenceService(query);
await Assert.ThrowsAsync<NamedPlaylistDataException>(
() => service.LoadAsync("00000001"));
}
[Fact]
public async Task LoadAsync_PreservesHistoricalPageDisplayOutsideCurrentPlayoutBound()
{
var query = new RecordingQueryExecutor(_ => LoadTable(
("00000001", "Historical", "1^KOSPI^Alpha^Plate^Current^1/0^000001", "0"),
("00000001", "Historical", "1^KOSPI^Beta^Plate^Current^1/24^000002", "1")));
var service = new LegacyNamedPlaylistPersistenceService(query);
var document = await service.LoadAsync("00000001");
Assert.Collection(
document.Items,
item =>
{
Assert.Equal(new NamedPlaylistPageState(1, 0), item.Page);
Assert.False(item.Page!.IsWithinPlayoutBounds);
},
item =>
{
Assert.Equal(new NamedPlaylistPageState(1, 24), item.Page);
Assert.False(item.Page!.IsWithinPlayoutBounds);
});
Assert.True(new NamedPlaylistPageState(4, 20).IsWithinPlayoutBounds);
}
[Fact]
public async Task CreateDefinitionAsync_BindsTitleAndNeverAddsItToSql()
{
var mutations = new RecordingMutationExecutor(SuccessResult);
var query = new RecordingQueryExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedPlaylistPersistenceService(query, mutations);
await service.CreateDefinitionAsync("00000009", "O'Brien 오전");
var call = Assert.Single(mutations.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(NamedPlaylistMutationKind.CreateDefinition, call.Transaction.Kind);
Assert.Equal("00000009", call.Transaction.ProgramCode);
var command = Assert.Single(call.Transaction.Commands);
Assert.Equal(NamedPlaylistDbCommandKind.InsertDefinition, command.Kind);
Assert.Equal(LegacyNamedPlaylistPersistenceService.InsertDefinitionSql, command.Sql);
Assert.DoesNotContain("O'Brien", command.Sql, StringComparison.Ordinal);
Assert.Collection(
command.Parameters,
parameter => AssertParameter(parameter, "program_code", "00000009", DbType.String),
parameter => AssertParameter(parameter, "program_title", "O'Brien 오전", DbType.String));
Assert.Empty(query.Calls);
}
[Fact]
public async Task ReplaceItemsAsync_AtomicallyDeletesThenInsertsInVisibleOrder()
{
var mutations = new RecordingMutationExecutor(call =>
Result(call, [7, 1, 1]));
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
mutations);
var items = new[]
{
Item(0, true, "KOSPI", "삼성전자", "1열판기본", "현재가", "005930", 1, 1),
Item(1, false, "THEME", "반도체", "5단 표그래프", "상승률순", "00000012", 2, 3)
};
await service.ReplaceItemsAsync("00000011", items);
var transaction = Assert.Single(mutations.Calls).Transaction;
Assert.Equal(NamedPlaylistMutationKind.ReplaceItems, transaction.Kind);
Assert.Collection(
transaction.Commands,
command =>
{
Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind);
Assert.Equal(LegacyNamedPlaylistPersistenceService.DeleteItemsSql, command.Sql);
AssertParameter(Assert.Single(command.Parameters), "program_code", "00000011", DbType.String);
},
command => AssertInsertItem(
command,
"1^KOSPI^삼성전자^1열판기본^현재가^1/1^005930",
0),
command => AssertInsertItem(
command,
"0^THEME^반도체^5단 표그래프^상승률순^2/3^00000012",
1));
}
[Fact]
public async Task ReplaceItemsAsync_AllowsAnEmptyAtomicReplacement()
{
var mutations = new RecordingMutationExecutor(call => Result(call, [3]));
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
mutations);
await service.ReplaceItemsAsync("00000001", []);
var command = Assert.Single(Assert.Single(mutations.Calls).Transaction.Commands);
Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind);
}
[Fact]
public async Task ReplaceItemsAsync_RejectsGapsAndCaretInjectionBeforeMutation()
{
var mutations = new RecordingMutationExecutor(SuccessResult);
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
mutations);
await Assert.ThrowsAsync<ArgumentException>(() => service.ReplaceItemsAsync(
"00000001",
[Item(1, true, "KOSPI", "Alpha", "Plate", "Current", "", 1, 1)]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ReplaceItemsAsync(
"00000001",
[Item(0, true, "KOSPI", "Alpha^Beta", "Plate", "Current", "", 1, 1)]));
Assert.Empty(mutations.Calls);
}
[Fact]
public async Task DeleteAsync_DeletesChildrenThenDefinitionInOneTransaction()
{
var mutations = new RecordingMutationExecutor(call => Result(call, [4, 1]));
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
mutations);
await service.DeleteAsync("00000012");
var transaction = Assert.Single(mutations.Calls).Transaction;
Assert.Equal(NamedPlaylistMutationKind.DeletePlaylist, transaction.Kind);
Assert.Collection(
transaction.Commands,
command => Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind),
command => Assert.Equal(NamedPlaylistDbCommandKind.DeleteDefinition, command.Kind));
}
[Fact]
public async Task Mutations_RequireConfiguredWriterAndHonorPreCancellation()
{
var readOnly = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => ListTable()));
Assert.False(readOnly.CanMutate);
await Assert.ThrowsAsync<InvalidOperationException>(
() => readOnly.DeleteAsync("00000001"));
var mutations = new RecordingMutationExecutor(SuccessResult);
var writable = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => ListTable()),
mutations);
Assert.True(writable.CanMutate);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => writable.DeleteAsync("00000001", cancellation.Token));
Assert.Empty(mutations.Calls);
}
[Fact]
public async Task MutationExecutorUnknownFailure_IsMarkedOutcomeUnknownWithoutRetry()
{
var mutations = new RecordingMutationExecutor(_ =>
throw new TimeoutException("simulated timeout"));
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => ListTable()),
mutations);
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.DeleteAsync("00000001"));
Assert.True(exception.OutcomeUnknown);
Assert.Single(mutations.Calls);
Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task MutationExecutorKnownRollback_IsPreserved()
{
var expected = new NamedPlaylistMutationException(
"Unique constraint; rolled back.",
outcomeUnknown: false);
var mutations = new RecordingMutationExecutor(_ => throw expected);
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => ListTable()),
mutations);
var actual = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000001", "Duplicate"));
Assert.Same(expected, actual);
Assert.False(actual.OutcomeUnknown);
Assert.Single(mutations.Calls);
}
[Fact]
public async Task AmbiguousMutationResult_IsMarkedOutcomeUnknown()
{
var mutations = new RecordingMutationExecutor(call => Result(call, [0]));
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => ListTable()),
mutations);
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000001", "No row"));
Assert.True(exception.OutcomeUnknown);
}
[Fact]
public async Task DeleteMissingDefinition_ReportsNotFoundAfterKnownCommit()
{
var mutations = new RecordingMutationExecutor(call => Result(call, [0, 0]));
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => ListTable()),
mutations);
await Assert.ThrowsAsync<NamedPlaylistNotFoundException>(
() => service.DeleteAsync("00000001"));
}
private static NamedPlaylistStoredItem Item(
int index,
bool enabled,
string groupCode,
string subject,
string graphicType,
string subtype,
string dataCode,
int currentPage,
int totalPages) =>
new(
index,
enabled,
new LegacySceneSelection(groupCode, subject, graphicType, subtype, dataCode),
new NamedPlaylistPageState(currentPage, totalPages));
private static void AssertInsertItem(
NamedPlaylistDbCommand command,
string expectedListText,
int expectedIndex)
{
Assert.Equal(NamedPlaylistDbCommandKind.InsertItem, command.Kind);
Assert.Equal(LegacyNamedPlaylistPersistenceService.InsertItemSql, command.Sql);
Assert.Equal(11, command.Parameters.Count);
AssertParameter(command.Parameters[0], "program_code", "00000011", DbType.String);
AssertParameter(command.Parameters[1], "list_text", expectedListText, DbType.String);
foreach (var parameter in command.Parameters.Skip(2).Take(8))
{
Assert.Null(parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
}
AssertParameter(command.Parameters[10], "item_index", expectedIndex, DbType.Int32);
}
private static void AssertParameter(
NamedPlaylistDbParameter parameter,
string name,
object? value,
DbType dbType)
{
Assert.Equal(name, parameter.Name);
Assert.Equal(value, parameter.Value);
Assert.Equal(dbType, parameter.DbType);
}
private static DataTable ListTable(params (string Code, string Title)[] rows)
{
var table = new DataTable();
table.Columns.Add("DC_CODE", typeof(string));
table.Columns.Add("DC_TITLE", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Code, row.Title);
}
return table;
}
private static DataTable NextCodeTable(string code)
{
var table = new DataTable();
table.Columns.Add("DC_CODE", typeof(string));
table.Rows.Add(code);
return table;
}
private static DataTable LoadTable(
params (string Code, string Title, string ListText, string ItemIndex)[] rows)
{
var table = new DataTable();
table.Columns.Add("DC_CODE", typeof(string));
table.Columns.Add("DC_TITLE", typeof(string));
table.Columns.Add("LIST_TEXT", typeof(string));
table.Columns.Add("ITEM_INDEX", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Code, row.Title, row.ListText, row.ItemIndex);
}
return table;
}
private static NamedPlaylistDbTransactionResult SuccessResult(MutationCall call) =>
Result(
call,
call.Transaction.Commands.Select(static command =>
command.Kind is NamedPlaylistDbCommandKind.DeleteItems ? 0 : 1).ToArray());
private static NamedPlaylistDbTransactionResult Result(
MutationCall call,
IReadOnlyList<int> affectedRows) =>
new(call.Transaction.OperationId, affectedRows, DateTimeOffset.UtcNow);
private sealed record QueryCall(
DataSourceKind Source,
string QueryName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingQueryExecutor(Func<QueryCall, DataTable> handler)
: IDataQueryExecutor
{
private int _stringSqlCallCount;
public List<QueryCall> 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 QueryCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call).Copy());
}
}
private sealed record MutationCall(
DataSourceKind Source,
NamedPlaylistDbTransaction Transaction,
CancellationToken CancellationToken);
private sealed class RecordingMutationExecutor(
Func<MutationCall, NamedPlaylistDbTransactionResult> handler)
: INamedPlaylistMutationExecutor
{
public List<MutationCall> Calls { get; } = [];
public Task<NamedPlaylistDbTransactionResult> ExecuteTransactionAsync(
DataSourceKind source,
NamedPlaylistDbTransaction transaction,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var call = new MutationCall(source, transaction, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call));
}
}
}

View File

@@ -0,0 +1,101 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyOperatorCatalogSchemaValidationServiceTests
{
[Fact]
public async Task ValidateAsync_ExecutesOnlyZeroRowOracleAndMariaSchemaQueries()
{
var query = new CatalogRecordingQueryExecutor(call => call.Source switch
{
DataSourceKind.Oracle => OperatorCatalogTestTables.Empty(
"THEME_CODE",
"THEME_TITLE",
"THEME_MARKET",
"ITEM_THEME_CODE",
"ITEM_CODE",
"ITEM_NAME",
"ITEM_INDEX",
"EXPERT_CODE",
"EXPERT_NAME",
"RECOMMEND_EXPERT_CODE",
"STOCK_CODE",
"STOCK_NAME",
"BUY_AMOUNT",
"PLAY_INDEX"),
DataSourceKind.MariaDb => OperatorCatalogTestTables.Empty(
"THEME_CODE",
"THEME_TITLE",
"THEME_MARKET",
"ITEM_THEME_CODE",
"ITEM_CODE",
"ITEM_NAME",
"ITEM_INDEX"),
_ => throw new InvalidOperationException()
});
var service = new LegacyOperatorCatalogSchemaValidationService(query);
using var cancellation = new CancellationTokenSource();
var result = await service.ValidateAsync(cancellation.Token);
Assert.Equal(
[DataSourceKind.Oracle, DataSourceKind.MariaDb],
result.ValidatedSources);
Assert.Collection(
query.Calls,
call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(
LegacyOperatorCatalogSchemaValidationService.OracleQueryName,
call.TableName);
Assert.Contains("EXPERT_LIST", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("RECOMMEND_LIST", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("WHERE 1 = 0", call.Spec.Sql, StringComparison.Ordinal);
Assert.Empty(call.Spec.Parameters);
Assert.Equal(cancellation.Token, call.CancellationToken);
},
call =>
{
Assert.Equal(DataSourceKind.MariaDb, call.Source);
Assert.Equal(
LegacyOperatorCatalogSchemaValidationService.MariaDbQueryName,
call.TableName);
Assert.Contains("SB_LIST", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("SB_ITEM", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("WHERE 1 = 0", call.Spec.Sql, StringComparison.Ordinal);
Assert.Empty(call.Spec.Parameters);
Assert.Equal(cancellation.Token, call.CancellationToken);
});
}
[Fact]
public async Task ValidateAsync_FailsClosedOnUnexpectedSchemaAndStopsBeforeSecondSource()
{
var query = new CatalogRecordingQueryExecutor(_ =>
OperatorCatalogTestTables.Empty("WRONG_COLUMN"));
var service = new LegacyOperatorCatalogSchemaValidationService(query);
var exception = await Assert.ThrowsAsync<OperatorCatalogSchemaException>(() =>
service.ValidateAsync());
Assert.Equal(DataSourceKind.Oracle, exception.DataSource);
Assert.Single(query.Calls);
}
[Fact]
public async Task ValidateAsync_PropagatesCancellationWithoutAdditionalQuery()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var query = new CatalogRecordingQueryExecutor(_ =>
throw new InvalidOperationException("No query expected."));
var service = new LegacyOperatorCatalogSchemaValidationService(query);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.ValidateAsync(cancellation.Token));
Assert.Empty(query.Calls);
}
}

View File

@@ -0,0 +1,312 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyOverseasIndustryIndexSearchServiceTests
{
[Fact]
public async Task SearchAsync_UsesBoundUsIndexMasterQueryAndMapsAllLoaderKeys()
{
var executor = new RecordingExecutor(_ => ResultTable(
("Philadelphia Semiconductor", "US_SEMICONDUCTOR", "SOX"),
("Dow Transportation", "US_DOW_TRANSPORT", "DJT")));
var service = new LegacyOverseasIndustryIndexSearchService(executor);
using var cancellation = new CancellationTokenSource();
var result = await service.SearchAsync(
" semiconductor ",
25,
cancellation.Token);
Assert.Equal("semiconductor", result.Query);
Assert.False(result.IsTruncated);
Assert.Collection(
result.Items,
item => Assert.Equal(
new OverseasIndustryIndexItem(
"Philadelphia Semiconductor",
"US_SEMICONDUCTOR",
"SOX"),
item),
item => Assert.Equal(
new OverseasIndustryIndexItem(
"Dow Transportation",
"US_DOW_TRANSPORT",
"DJT"),
item));
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("OVERSEAS_INDUSTRY_INDEX_SEARCH", call.TableName);
Assert.Equal(cancellation.Token, call.CancellationToken);
call.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains("FROM T_WORLD_IX_EQ_MASTER", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_FDTC = '0'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_NATC = 'US'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(
"COUNT(*) OVER (PARTITION BY F_KNAM)",
call.Spec.Sql,
StringComparison.Ordinal);
Assert.Contains("KNAM_COUNT = 1", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("INPUT_NAME_COUNT = 1", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("SYMBOL_COUNT = 1", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("UPPER(F_KNAM)", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("semiconductor", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Collection(
call.Spec.Parameters,
parameter =>
{
Assert.Equal("search_term", parameter.Name);
Assert.Equal("SEMICONDUCTOR", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
},
parameter =>
{
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(26, parameter.Value);
Assert.Equal(DbType.Int32, parameter.DbType);
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task SearchAsync_AllowsOriginalUc5BlankInitialList(string query)
{
var executor = new RecordingExecutor(_ => ResultTable());
var service = new LegacyOverseasIndustryIndexSearchService(executor);
var result = await service.SearchAsync(query);
Assert.Equal(string.Empty, result.Query);
var call = Assert.Single(executor.Calls);
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
}
[Fact]
public async Task SearchAsync_EscapesLikeMetacharactersOnlyInsideBoundValue()
{
var executor = new RecordingExecutor(_ => ResultTable());
var service = new LegacyOverseasIndustryIndexSearchService(executor);
await service.SearchAsync(" a!_%b ");
var call = Assert.Single(executor.Calls);
Assert.Equal("A!!!_!%B", call.Spec.Parameters[0].Value);
Assert.DoesNotContain("A!!!_!%B", call.Spec.Sql, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_ReturnsBoundedRowsAndReportsTruncation()
{
var executor = new RecordingExecutor(_ => ResultTable(
("Alpha", "ALPHA", "A"),
("Beta", "BETA", "B"),
("Gamma", "GAMMA", "C")));
var service = new LegacyOverseasIndustryIndexSearchService(executor);
var result = await service.SearchAsync("", maximumResults: 2);
Assert.True(result.IsTruncated);
Assert.Equal(2, result.Items.Count);
Assert.Equal(3, executor.Calls[0].Spec.Parameters[1].Value);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
[InlineData(int.MaxValue)]
public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults)
{
var executor = new RecordingExecutor(_ => ResultTable());
var service = new LegacyOverseasIndustryIndexSearchService(executor);
var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.SearchAsync("industry", maximumResults));
Assert.Equal("maximumResults", exception.ParamName);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("bad\u0000value")]
[InlineData("\nindustry")]
[InlineData("bad\u200Bvalue")]
[InlineData("bad\uD800value")]
public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query)
{
var executor = new RecordingExecutor(_ => ResultTable());
var service = new LegacyOverseasIndustryIndexSearchService(executor);
var exception = await Assert.ThrowsAsync<ArgumentException>(
() => service.SearchAsync(query));
Assert.Equal("query", exception.ParamName);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsNullAndOverlongQueryBeforeDatabaseAccess()
{
var executor = new RecordingExecutor(_ => ResultTable());
var service = new LegacyOverseasIndustryIndexSearchService(executor);
await Assert.ThrowsAsync<ArgumentNullException>(() => service.SearchAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(
() => service.SearchAsync(
new string(
'A',
LegacyOverseasIndustryIndexSearchService.MaximumQueryLength + 1)));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsAnythingOtherThanExactOrderedStringSchema()
{
var wrongCase = ResultTable();
wrongCase.Columns[0].ColumnName = "f_knam";
var executor = new RecordingExecutor(_ => wrongCase);
var service = new LegacyOverseasIndustryIndexSearchService(executor);
var exception = await Assert.ThrowsAsync<OverseasIndustryIndexSearchDataException>(
() => service.SearchAsync(""));
Assert.Contains("schema", exception.Message, StringComparison.Ordinal);
Assert.Single(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsProviderRowsBeyondBoundQueryContract()
{
var executor = new RecordingExecutor(_ => ResultTable(
("One", "ONE", "1"),
("Two", "TWO", "2"),
("Three", "THREE", "3")));
var service = new LegacyOverseasIndustryIndexSearchService(executor);
await Assert.ThrowsAsync<OverseasIndustryIndexSearchDataException>(
() => service.SearchAsync("", maximumResults: 1));
}
[Theory]
[InlineData(" Leading", "INPUT", "SYM")]
[InlineData("Name", "INPUT ", "SYM")]
[InlineData("Name", "INPUT", " SYM")]
[InlineData("Name\nBad", "INPUT", "SYM")]
[InlineData("Name", "IN\u0000PUT", "SYM")]
[InlineData("Name", "INPUT", "SY\u200BM")]
public async Task SearchAsync_RejectsNonCanonicalDatabaseValues(
string koreanName,
string inputName,
string symbol)
{
var executor = new RecordingExecutor(_ => ResultTable(
(koreanName, inputName, symbol)));
var service = new LegacyOverseasIndustryIndexSearchService(executor);
await Assert.ThrowsAsync<OverseasIndustryIndexSearchDataException>(
() => service.SearchAsync(""));
}
[Theory]
[InlineData("Shared", "ONE", "1", "Shared", "TWO", "2")]
[InlineData("One", "SHARED", "1", "Two", "SHARED", "2")]
[InlineData("One", "ONE", "SHARED", "Two", "TWO", "SHARED")]
public async Task SearchAsync_RejectsDuplicateAnyDownstreamLookupKey(
string koreanName1,
string inputName1,
string symbol1,
string koreanName2,
string inputName2,
string symbol2)
{
var executor = new RecordingExecutor(_ => ResultTable(
(koreanName1, inputName1, symbol1),
(koreanName2, inputName2, symbol2)));
var service = new LegacyOverseasIndustryIndexSearchService(executor);
var exception = await Assert.ThrowsAsync<OverseasIndustryIndexSearchDataException>(
() => service.SearchAsync(""));
Assert.Contains("duplicate downstream lookup key", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess()
{
var executor = new RecordingExecutor(_ => ResultTable());
var service = new LegacyOverseasIndustryIndexSearchService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.SearchAsync("", cancellationToken: cancellation.Token));
Assert.Empty(executor.Calls);
}
private static DataTable ResultTable(
params (string KoreanName, string InputName, string Symbol)[] rows)
{
var table = new DataTable();
table.Columns.Add("F_KNAM", typeof(string));
table.Columns.Add("F_INPUT_NAME", typeof(string));
table.Columns.Add("F_SYMB", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.KoreanName, row.InputName, row.Symbol);
}
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("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());
}
}
}

View File

@@ -46,6 +46,56 @@ public sealed class LegacyParameterizedSceneRequestResolverTests
Assert.Equal(LegacyMarketQuoteTarget.Kosdaq, request.PrimaryTarget);
}
[Theory]
[InlineData(
"코스피 지수,삼성전자(NXT)",
1,
0,
LegacyDomesticEquityMarket.NxtKospi,
S8018PairSide.Second,
LegacyMarketQuoteTarget.Kospi)]
[InlineData(
"에이비엘바이오(NXT),원달러 환율",
0,
1,
LegacyDomesticEquityMarket.NxtKosdaq,
S8018PairSide.First,
LegacyMarketQuoteTarget.WonDollar)]
public async Task Current_mixed_market_and_nxt_stock_preserves_the_original_nxt_branch(
string subject,
int nxtKospiMatches,
int nxtKosdaqMatches,
LegacyDomesticEquityMarket expectedStockMarket,
S8018PairSide expectedStockSide,
LegacyMarketQuoteTarget expectedMarketTarget)
{
var executor = new RecordingExecutor(
Count(nxtKospiMatches),
Count(nxtKosdaqMatches));
var resolver = new LegacyParameterizedSceneRequestResolver(executor);
var result = await resolver.ResolveTwoColumnRequestAsync(Entry(
"8018", "종목", subject, "현재가"));
var routed = Assert.IsType<LegacyS8018LoadRequest>(result);
var request = Assert.IsType<S8018MixedMarketAndStockLoadRequest>(routed.Request);
Assert.Equal(expectedStockSide, request.StockSide);
Assert.Equal(expectedMarketTarget, request.MarketTarget);
Assert.Equal(expectedStockMarket, request.StockMarket);
Assert.Equal(S8018MarketDataMode.Current, request.Mode);
Assert.Equal(
["SCENE_RESOLVE_NXT_KOSPI_STOCK", "SCENE_RESOLVE_NXT_KOSDAQ_STOCK"],
executor.Calls.Select(call => call.TableName));
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.MariaDb, call.Source);
var parameter = Assert.Single(call.Spec.Parameters);
var value = Assert.IsType<string>(parameter.Value);
Assert.DoesNotContain("(NXT)", value, StringComparison.Ordinal);
Assert.DoesNotContain(value, call.Spec.Sql, StringComparison.Ordinal);
});
}
[Fact]
public async Task Current_stock_pair_uses_original_master_table_precedence_and_session_boundary()
{
@@ -96,6 +146,81 @@ public sealed class LegacyParameterizedSceneRequestResolverTests
Assert.Empty(executor.Calls);
}
[Fact]
public async Task Expected_mixed_nxt_pair_fails_before_any_database_lookup()
{
var executor = new RecordingExecutor();
var resolver = new LegacyParameterizedSceneRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveTwoColumnRequestAsync(Entry(
"8018", "지수", "코스피 지수,A(NXT)", "예상체결")));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task Current_world_stock_identity_uses_the_operator_input_name_consistently()
{
var executor = new RecordingExecutor(
Count(0), Count(0), Count(1),
Count(1), Count(0));
var resolver = new LegacyParameterizedSceneRequestResolver(executor);
var result = await resolver.ResolveTwoColumnRequestAsync(Entry(
"8018", "종목", "NVIDIA,삼성전자", "현재가"));
var routed = Assert.IsType<LegacyS8018LoadRequest>(result);
var request = Assert.IsType<S8018CurrentStockPairLoadRequest>(routed.Request);
Assert.Equal(S8018StockMarket.World, request.First.Market);
Assert.Equal("NVIDIA", request.First.StockName);
Assert.Equal(S8018StockMarket.Kospi, request.Second.Market);
var worldCall = Assert.Single(
executor.Calls,
call => call.TableName == "SCENE_RESOLVE_WORLD_STOCK");
Assert.Equal(DataSourceKind.Oracle, worldCall.Source);
Assert.Contains("F_INPUT_NAME = :stockName", worldCall.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("F_FDTC = '1'", worldCall.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"F_NATC IN ('US', 'TW')",
worldCall.Spec.Sql,
StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("F_KNAM", worldCall.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal("NVIDIA", Assert.Single(worldCall.Spec.Parameters).Value);
}
[Fact]
public async Task Mixed_market_and_world_stock_remains_closed_without_a_generalized_mixed_dto()
{
var executor = new RecordingExecutor(Count(0), Count(0));
var resolver = new LegacyParameterizedSceneRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveTwoColumnRequestAsync(Entry(
"8018", "지수", "코스피 지수,NVIDIA", "현재가")));
Assert.Equal(
["SCENE_RESOLVE_KOSPI_STOCK", "SCENE_RESOLVE_KOSDAQ_STOCK"],
executor.Calls.Select(call => call.TableName));
Assert.DoesNotContain(
executor.Calls,
call => call.TableName == "SCENE_RESOLVE_WORLD_STOCK");
}
[Fact]
public async Task Futures_and_domestic_stock_remains_closed_before_database_lookup()
{
var executor = new RecordingExecutor();
var resolver = new LegacyParameterizedSceneRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveTwoColumnRequestAsync(Entry(
"5032", "종목", "선물,삼성전자", "현재가")));
Assert.Empty(executor.Calls);
}
[Fact]
public void S8001_accepts_only_the_two_original_markets()
{

View File

@@ -425,12 +425,65 @@ public sealed class LegacyPlayoutWorkflowTests
Assert.Equal("one", workflow.State.CurrentEntryId);
}
[Fact]
public async Task PreflightPagePlans_UsesNoEngineCommandAndReturnsEmptyAndCappedPlans()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (101, ScenePageSize.Five),
["5077"] = (0, ScenePageSize.Six),
["5088"] = (241, ScenePageSize.Twelve)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
var plans = await workflow.PreflightPagePlansAsync(
[
new("five", "5074"),
new("six", "5077", IsEnabled: false),
new("twelve", "5088")
]);
Assert.Empty(engine.Calls);
Assert.Empty(provider.Calls);
Assert.Equal(["5074", "5077", "5088"], provider.PlanCalls);
Assert.Equal(
new LegacyPlaylistPagePlan("five", 101, 5, 20, 100, true, "s5074"),
plans[0]);
Assert.Equal(
new LegacyPlaylistPagePlan("six", 0, 6, 0, 0, false, "s5077"),
plans[1]);
Assert.Equal(
new LegacyPlaylistPagePlan("twelve", 241, 12, 20, 240, true, "s5088"),
plans[2]);
Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
}
[Fact]
public async Task PreflightPagePlans_IsRejectedAfterPrepareWithoutQueryingAnotherDto()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (5, ScenePageSize.Five)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5074")], 0)).IsSuccess);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
workflow.PreflightPagePlansAsync([new("other", "5074")]));
Assert.Empty(provider.PlanCalls);
}
private sealed class RecordingProvider(
IReadOnlyDictionary<string, (int Count, ScenePageSize? Size)> definitions)
: ILegacySceneCueProvider
: ILegacySceneCueProvider, ILegacyScenePagePlanProvider
{
public List<string> Calls { get; } = [];
public List<string> PlanCalls { get; } = [];
public Func<string, int, string>? SceneNameOverride { get; init; }
public string? BuilderKey { get; init; }
@@ -456,6 +509,26 @@ public sealed class LegacyPlayoutWorkflowTests
BuilderKey,
PreviewFields));
}
public Task<LegacyScenePagePlan> CreatePagePlanAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
PlanCalls.Add(entry.CutCode);
var definition = definitions[entry.CutCode];
var pageSize = definition.Size ?? throw new LegacySceneDataException(
"The test entry is not paged.");
var result = ScenePaging.CreatePlan(definition.Count, pageSize);
var plan = Assert.IsType<ScenePagingPlan>(result.Plan);
return Task.FromResult(new LegacyScenePagePlan(
"s" + entry.CutCode,
plan.ItemCount,
plan.PageSize,
plan.TotalPages,
plan.AccessibleItemCount,
plan.ExcludedItemCount > 0));
}
}
private sealed class RecordingEngine : IPlayoutEngine

View File

@@ -0,0 +1,259 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyStockSearchServiceTests
{
[Fact]
public async Task SearchAsync_UsesFourParameterizedProviderQueriesInLegacyMarketOrder()
{
var executor = new RecordingExecutor(_ => StockTable());
var service = new LegacyStockSearchService(executor);
using var cancellation = new CancellationTokenSource();
var result = await service.SearchAsync(" 삼성 ", 25, cancellation.Token);
Assert.Equal("삼성", result.Query);
Assert.False(result.IsTruncated);
Assert.Empty(result.Items);
Assert.Equal(
[
"STOCK_SEARCH_KOSPI",
"STOCK_SEARCH_KOSDAQ",
"STOCK_SEARCH_NXT_KOSPI",
"STOCK_SEARCH_NXT_KOSDAQ"
],
executor.Calls.Select(static call => call.TableName));
Assert.Equal(
[
DataSourceKind.Oracle,
DataSourceKind.Oracle,
DataSourceKind.MariaDb,
DataSourceKind.MariaDb
],
executor.Calls.Select(static call => call.Source));
Assert.All(executor.Calls, call =>
{
call.Spec.ValidateFor(call.Source);
Assert.Equal(cancellation.Token, call.CancellationToken);
Assert.Equal(2, call.Spec.Parameters.Count);
Assert.Equal("search_term", call.Spec.Parameters[0].Name);
Assert.Equal("삼성", call.Spec.Parameters[0].Value);
Assert.Equal(DbType.String, call.Spec.Parameters[0].DbType);
Assert.Equal("row_limit", call.Spec.Parameters[1].Name);
Assert.Equal(26, call.Spec.Parameters[1].Value);
Assert.Equal(DbType.Int32, call.Spec.Parameters[1].DbType);
Assert.Contains("ORDER BY", call.Spec.Sql, StringComparison.Ordinal);
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task SearchAsync_EscapesLikeMetacharactersInsideBoundValue()
{
var executor = new RecordingExecutor(_ => StockTable());
var service = new LegacyStockSearchService(executor);
await service.SearchAsync(" 50%!_ ");
Assert.All(
executor.Calls,
call => Assert.Equal("50!%!!!_", call.Spec.Parameters[0].Value));
Assert.All(
executor.Calls,
call => Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal));
}
[Fact]
public async Task SearchAsync_ValidatesSchemaAndReturnsDeterministicCappedOrder()
{
var tables = new Queue<DataTable>(
[
StockTable(("가나다", "000002"), ("가나다", "000001")),
StockTable(("코스닥", "100001")),
StockTable(("NXT가(NXT)", "000002")),
StockTable(("NXT나(NXT)", "100001"))
]);
var executor = new RecordingExecutor(_ => tables.Dequeue());
var service = new LegacyStockSearchService(executor);
var result = await service.SearchAsync("가", maximumResults: 3);
Assert.True(result.IsTruncated);
Assert.Collection(
result.Items,
item => Assert.Equal(
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "가나다", "000001"),
item),
item => Assert.Equal(
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "가나다", "000002"),
item),
item => Assert.Equal(
new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "코스닥", "100001"),
item));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
[InlineData(int.MaxValue)]
public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults)
{
var executor = new RecordingExecutor(_ => StockTable());
var service = new LegacyStockSearchService(executor);
var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.SearchAsync("삼성", maximumResults));
Assert.Equal("maximumResults", exception.ParamName);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("bad\u0000value")]
[InlineData("\n삼성")]
[InlineData("bad\u200Bvalue")]
[InlineData("bad\uD800value")]
public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query)
{
var executor = new RecordingExecutor(_ => StockTable());
var service = new LegacyStockSearchService(executor);
var exception = await Assert.ThrowsAsync<ArgumentException>(
() => service.SearchAsync(query));
Assert.Equal("query", exception.ParamName);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsOverlongQueryBeforeDatabaseAccess()
{
var executor = new RecordingExecutor(_ => StockTable());
var service = new LegacyStockSearchService(executor);
await Assert.ThrowsAsync<ArgumentException>(
() => service.SearchAsync(new string('A', LegacyStockSearchService.MaximumQueryLength + 1)));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsNullQueryBeforeDatabaseAccess()
{
var executor = new RecordingExecutor(_ => StockTable());
var service = new LegacyStockSearchService(executor);
var exception = await Assert.ThrowsAsync<ArgumentNullException>(
() => service.SearchAsync(null!));
Assert.Equal("query", exception.ParamName);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsAnythingOtherThanExactStringSchema()
{
var wrongCase = new DataTable();
wrongCase.Columns.Add("stock_name", typeof(string));
wrongCase.Columns.Add("STOCK_CODE", typeof(string));
var executor = new RecordingExecutor(_ => wrongCase);
var service = new LegacyStockSearchService(executor);
var exception = await Assert.ThrowsAsync<StockSearchDataException>(
() => service.SearchAsync("삼성"));
Assert.Contains("STOCK_SEARCH_KOSPI", exception.Message, StringComparison.Ordinal);
Assert.Single(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsNullOrControlCharacterDatabaseValues()
{
var malformed = StockTable();
malformed.Rows.Add("삼성\n전자", "005930");
var executor = new RecordingExecutor(_ => malformed);
var service = new LegacyStockSearchService(executor);
await Assert.ThrowsAsync<StockSearchDataException>(
() => service.SearchAsync("삼성"));
Assert.Single(executor.Calls);
}
[Fact]
public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess()
{
var executor = new RecordingExecutor(_ => StockTable());
var service = new LegacyStockSearchService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.SearchAsync("삼성", cancellationToken: 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 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());
}
}
}

View File

@@ -0,0 +1,293 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyThemeCatalogPersistenceServiceTests
{
[Fact]
public async Task SuggestNextCodeAsync_UsesReadOnlyProviderSpecificQueries()
{
var query = new CatalogRecordingQueryExecutor(call => call.TableName switch
{
LegacyThemeCatalogPersistenceService.NextKrxCodeQueryName =>
OperatorCatalogTestTables.SingleString("THEME_CODE", "00000042"),
LegacyThemeCatalogPersistenceService.NextNxtCodeQueryName =>
OperatorCatalogTestTables.SingleString("THEME_CODE", "00000009"),
_ => throw new InvalidOperationException(call.TableName)
});
var service = new LegacyThemeCatalogPersistenceService(query);
Assert.Equal("00000042", await service.SuggestNextCodeAsync(ThemeMarket.Krx));
Assert.Equal("00000009", await service.SuggestNextCodeAsync(ThemeMarket.Nxt));
Assert.False(service.CanMutate);
Assert.Collection(
query.Calls,
call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Contains("TO_NUMBER(TRIM(SB_CODE))", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM SB_LIST", call.Spec.Sql, StringComparison.Ordinal);
Assert.Empty(call.Spec.Parameters);
},
call =>
{
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.Empty(call.Spec.Parameters);
});
}
[Fact]
public async Task SuggestNextCodeAsync_RejectsExhaustedCodeRange()
{
var service = new LegacyThemeCatalogPersistenceService(
new CatalogRecordingQueryExecutor(_ =>
OperatorCatalogTestTables.SingleString("THEME_CODE", "EXHAUSTED")));
await Assert.ThrowsAsync<ThemeSelectionDataException>(() =>
service.SuggestNextCodeAsync(ThemeMarket.Krx));
}
[Fact]
public async Task CreateAsync_KrxBindsParentThenOrderedChildrenInOneTransaction()
{
var query = UnusedQuery();
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(query, mutation);
using var cancellation = new CancellationTokenSource();
var definition = new ThemeCatalogDefinition(
ThemeMarket.Krx,
"00000042",
"AI'테마",
[
new ThemeCatalogItem(0, "P005930", "삼성전자"),
new ThemeCatalogItem(1, "D035720", "카카오")
]);
var receipt = await service.CreateAsync(definition, cancellation.Token);
Assert.True(service.CanMutate);
Assert.Equal(OperatorCatalogMutationKind.CreateTheme, receipt.Kind);
var call = Assert.Single(mutation.Calls);
Assert.Equal(cancellation.Token, call.Token);
var transaction = call.Transaction;
Assert.Equal(DataSourceKind.Oracle, transaction.Source);
Assert.Equal("00000042", transaction.IdentityCode);
Assert.NotEqual(Guid.Empty, transaction.OperationId);
Assert.Equal(
[
OperatorCatalogDbCommandKind.InsertTheme,
OperatorCatalogDbCommandKind.InsertThemeItem,
OperatorCatalogDbCommandKind.InsertThemeItem
],
transaction.Commands.Select(static command => command.Kind));
Assert.All(transaction.Commands, command =>
Assert.DoesNotContain("AI'테마", command.Sql, StringComparison.Ordinal));
var parent = transaction.Commands[0];
Assert.Contains("NOT EXISTS", parent.Sql, StringComparison.Ordinal);
Assert.Equal(OperatorCatalogAffectedRowsRule.ExactlyOne, parent.AffectedRowsRule);
Assert.Equal("AI'테마", Parameter(parent, "theme_title").Value);
Assert.Equal("AI'테마", Parameter(parent, "conflict_title").Value);
Assert.Equal(DbType.String, Parameter(parent, "theme_title").DbType);
Assert.Equal("P005930", Parameter(transaction.Commands[1], "item_code").Value);
Assert.Equal(0, Parameter(transaction.Commands[1], "item_index").Value);
Assert.Equal("D035720", Parameter(transaction.Commands[2], "item_code").Value);
Assert.Equal(1, Parameter(transaction.Commands[2], "item_index").Value);
}
[Fact]
public async Task CreateAsync_NxtUsesMariaMarkerAndRawStoredTitle()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var definition = new ThemeCatalogDefinition(
ThemeMarket.Nxt,
"00000007",
"로봇",
[new ThemeCatalogItem(0, "X005930", "삼성전자")]);
await service.CreateAsync(definition);
var transaction = Assert.Single(mutation.Calls).Transaction;
Assert.Equal(DataSourceKind.MariaDb, transaction.Source);
Assert.All(transaction.Commands, command =>
{
Assert.DoesNotContain(':', command.Sql);
Assert.All(command.Parameters, parameter =>
Assert.Contains($"@{parameter.Name}", command.Sql, StringComparison.OrdinalIgnoreCase));
});
Assert.Contains("'NXT'", transaction.Commands[0].Sql, StringComparison.Ordinal);
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
definition with { ThemeTitle = "로봇(NXT)" }));
Assert.Single(mutation.Calls);
}
[Fact]
public async Task ReplaceAsync_ChecksCodeAndOriginalTitleBeforeReplacingChildren()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000042",
"기존 테마");
await service.ReplaceAsync(
identity,
"새 테마",
[new ThemeCatalogItem(0, "P005930", "삼성전자")]);
var transaction = Assert.Single(mutation.Calls).Transaction;
Assert.Equal(OperatorCatalogMutationKind.ReplaceTheme, transaction.Kind);
Assert.Equal(
[
OperatorCatalogDbCommandKind.UpdateTheme,
OperatorCatalogDbCommandKind.DeleteThemeItems,
OperatorCatalogDbCommandKind.InsertThemeItem
],
transaction.Commands.Select(static command => command.Kind));
var update = transaction.Commands[0];
Assert.Equal("기존 테마", Parameter(update, "expected_title").Value);
Assert.Equal("새 테마", Parameter(update, "new_title").Value);
Assert.Equal("새 테마", Parameter(update, "duplicate_title").Value);
Assert.Contains("NOT EXISTS", update.Sql, StringComparison.Ordinal);
Assert.Equal(
OperatorCatalogAffectedRowsRule.ExactlyOne,
update.AffectedRowsRule);
Assert.Equal(
OperatorCatalogAffectedRowsRule.AnyNonNegative,
transaction.Commands[1].AffectedRowsRule);
}
[Fact]
public async Task DeleteAsync_DeletesChildrenBeforeIdentityBoundParent()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ThemeSelectionIdentity(
ThemeMarket.Nxt,
ThemeSession.AfterMarket,
"00000008",
"반도체");
await service.DeleteAsync(identity);
var transaction = Assert.Single(mutation.Calls).Transaction;
Assert.Equal(DataSourceKind.MariaDb, transaction.Source);
Assert.Equal(
[
OperatorCatalogDbCommandKind.DeleteThemeItems,
OperatorCatalogDbCommandKind.DeleteTheme
],
transaction.Commands.Select(static command => command.Kind));
Assert.Equal("반도체", Parameter(transaction.Commands[1], "expected_title").Value);
Assert.Equal(
OperatorCatalogAffectedRowsRule.ExactlyOne,
transaction.Commands[1].AffectedRowsRule);
}
[Fact]
public async Task Mutations_RejectDuplicateOrAmbiguousItemsBeforeExecutor()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var baseDefinition = new ThemeCatalogDefinition(
ThemeMarket.Krx,
"00000001",
"테마",
[]);
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
baseDefinition with
{
Items =
[
new ThemeCatalogItem(0, "P005930", "삼성전자"),
new ThemeCatalogItem(1, "P005930", "다른이름")
]
}));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
baseDefinition with
{
Items =
[
new ThemeCatalogItem(0, "P005930", "삼성전자"),
new ThemeCatalogItem(1, "D035720", "삼성전자")
]
}));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
baseDefinition with
{
Items = [new ThemeCatalogItem(1, "P005930", "삼성전자")]
}));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
baseDefinition with
{
Items = [new ThemeCatalogItem(0, "X005930", "삼성전자")]
}));
Assert.Empty(mutation.Calls);
}
[Fact]
public async Task Mutations_FailClosedForReadOnlyCancellationAndUnknownExecutorOutcome()
{
var definition = new ThemeCatalogDefinition(
ThemeMarket.Krx,
"00000001",
"테마",
[]);
var readOnly = new LegacyThemeCatalogPersistenceService(UnusedQuery());
await Assert.ThrowsAsync<InvalidOperationException>(() => readOnly.CreateAsync(definition));
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.CreateAsync(definition, cancellation.Token));
Assert.Empty(mutation.Calls);
mutation.Exception = new TimeoutException("provider detail");
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(definition));
Assert.True(exception.OutcomeUnknown);
Assert.Contains("do not retry automatically", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task CreateAsync_TreatsMismatchedCommittedReceiptAsOutcomeUnknown()
{
var mutation = new CatalogRecordingMutationExecutor(transaction =>
new OperatorCatalogDbTransactionResult(
Guid.NewGuid(),
[1],
DateTimeOffset.UtcNow));
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ThemeCatalogDefinition(
ThemeMarket.Krx,
"00000001",
"테마",
[])));
Assert.True(exception.OutcomeUnknown);
}
private static CatalogRecordingQueryExecutor UnusedQuery() =>
new(_ => throw new InvalidOperationException("No read query was expected."));
private static OperatorCatalogDbParameter Parameter(
OperatorCatalogDbCommand command,
string name) =>
Assert.Single(command.Parameters, parameter =>
string.Equals(parameter.Name, name, StringComparison.Ordinal));
}

View File

@@ -0,0 +1,566 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyThemeSelectionServiceTests
{
[Fact]
public async Task SearchAsync_UsesOriginalOracleAndMariaProvidersWithClosedIdentity()
{
var executor = new RecordingExecutor(call => call.TableName switch
{
"THEME_SEARCH_KRX" => SearchTable(("AI", "00000001", "KRX")),
"THEME_SEARCH_NXT" => SearchTable(("Robotics", "00000002", "NXT")),
_ => throw new InvalidOperationException(call.TableName)
});
var service = new LegacyThemeSelectionService(executor);
using var cancellation = new CancellationTokenSource();
var result = await service.SearchAsync(
" ai ",
ThemeSession.PreMarket,
25,
cancellation.Token);
Assert.Equal("ai", result.Query);
Assert.Equal(ThemeSession.PreMarket, result.NxtSession);
Assert.False(result.IsTruncated);
Assert.Collection(
result.Items,
item =>
{
Assert.Equal(DataSourceKind.Oracle, item.Source);
Assert.Equal(
new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
"AI"),
item.Identity);
},
item =>
{
Assert.Equal(DataSourceKind.MariaDb, item.Source);
Assert.Equal(
new ThemeSelectionIdentity(
ThemeMarket.Nxt,
ThemeSession.PreMarket,
"00000002",
"Robotics"),
item.Identity);
});
Assert.Collection(
executor.Calls,
call =>
{
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("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("AI", call.Spec.Parameters[0].Value);
Assert.Equal(26, call.Spec.Parameters[1].Value);
Assert.Equal(cancellation.Token, call.CancellationToken);
},
call =>
{
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("LIMIT @row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("AI", call.Spec.Parameters[0].Value);
Assert.Equal(26, call.Spec.Parameters[1].Value);
Assert.Equal(cancellation.Token, call.CancellationToken);
});
Assert.All(executor.Calls, call =>
{
call.Spec.ValidateFor(call.Source);
Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(DbType.String, call.Spec.Parameters[0].DbType);
Assert.Equal(DbType.Int32, call.Spec.Parameters[1].DbType);
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task SearchAsync_AllowsOriginalBlankInitialLoadAndEscapesLikeValue()
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyThemeSelectionService(executor);
await service.SearchAsync(" ", ThemeSession.AfterMarket);
Assert.All(executor.Calls, call => Assert.Equal(string.Empty, call.Spec.Parameters[0].Value));
executor.Calls.Clear();
await service.SearchAsync(" a!_% ", ThemeSession.AfterMarket);
Assert.All(executor.Calls, call => Assert.Equal("A!!!_!%", call.Spec.Parameters[0].Value));
}
[Fact]
public async Task SearchAsync_AppliesCombinedBoundAndDeterministicMarketOrder()
{
var executor = new RecordingExecutor(call => call.Source switch
{
DataSourceKind.Oracle => SearchTable(
("Zulu", "00000002", "KRX"),
("Alpha", "00000001", "KRX")),
DataSourceKind.MariaDb => SearchTable(("Beta", "00000003", "NXT")),
_ => throw new InvalidOperationException()
});
var service = new LegacyThemeSelectionService(executor);
var result = await service.SearchAsync("", ThemeSession.PreMarket, 2);
Assert.True(result.IsTruncated);
Assert.Equal(
["Alpha", "Zulu"],
result.Items.Select(static item => item.Identity.ThemeTitle));
Assert.All(executor.Calls, call => Assert.Equal(3, call.Spec.Parameters[1].Value));
}
[Theory]
[InlineData(ThemeSession.NotApplicable)]
[InlineData((ThemeSession)99)]
public async Task SearchAsync_RejectsUnsafeNxtSessionBeforeDatabaseAccess(ThemeSession session)
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.SearchAsync("AI", session));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
public async Task SearchAsync_RejectsInvalidLimitBeforeDatabaseAccess(int limit)
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.SearchAsync("AI", ThemeSession.PreMarket, limit));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("bad\u0000value")]
[InlineData("bad\u200Bvalue")]
[InlineData("bad\uD800value")]
public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query)
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ArgumentException>(
() => service.SearchAsync(query, ThemeSession.PreMarket));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsNullAndOverlongQueryBeforeDatabaseAccess()
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ArgumentNullException>(
() => service.SearchAsync(null!, ThemeSession.PreMarket));
await Assert.ThrowsAsync<ArgumentException>(
() => service.SearchAsync(
new string('A', LegacyThemeSelectionService.MaximumQueryLength + 1),
ThemeSession.PreMarket));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsSchemaMarketAndBoundViolations()
{
var wrongSchema = SearchTable();
wrongSchema.Columns[0].ColumnName = "theme_title";
var executor = new RecordingExecutor(_ => wrongSchema);
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.SearchAsync("", ThemeSession.PreMarket));
executor = new RecordingExecutor(_ => SearchTable(("AI", "00000001", "NXT")));
service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.SearchAsync("", ThemeSession.PreMarket));
executor = new RecordingExecutor(_ => SearchTable(
("A", "00000001", "KRX"),
("B", "00000002", "KRX"),
("C", "00000003", "KRX")));
service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.SearchAsync("", ThemeSession.PreMarket, 1));
}
[Theory]
[InlineData("AI", "00000001", "AI", "00000002")]
[InlineData("AI", "00000001", "Robotics", "00000001")]
public async Task SearchAsync_RejectsDuplicateTitleOrCodeWithinMarket(
string title1,
string code1,
string title2,
string code2)
{
var executor = new RecordingExecutor(_ => SearchTable(
(title1, code1, "KRX"),
(title2, code2, "KRX")));
var service = new LegacyThemeSelectionService(executor);
var exception = await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.SearchAsync("", ThemeSession.PreMarket));
Assert.Contains("duplicate title or code", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess()
{
var executor = new RecordingExecutor(_ => SearchTable());
var service = new LegacyThemeSelectionService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.SearchAsync(
"AI",
ThemeSession.PreMarket,
cancellationToken: cancellation.Token));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task PreviewAsync_KrxUsesBoundIdentityAndMapsInputOrder()
{
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
"AI");
var executor = new RecordingExecutor(_ => PreviewTable(
("AI", "00000001", "KRX", "Alpha", "P005930", "0"),
("AI", "00000001", "KRX", "Beta", "D035720", "2")));
var service = new LegacyThemeSelectionService(executor);
using var cancellation = new CancellationTokenSource();
var result = await service.PreviewAsync(identity, 12, cancellation.Token);
Assert.Equal(identity, result.Identity);
Assert.False(result.IsTruncated);
Assert.Equal(
[
new ThemeItemPreview(0, "P005930", "Alpha"),
new ThemeItemPreview(2, "D035720", "Beta")
],
result.Items);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("THEME_PREVIEW_KRX", call.TableName);
Assert.Contains("LEFT JOIN", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_MKT_HALT = 'N'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(
["theme_code", "theme_title", "row_limit"],
call.Spec.Parameters.Select(static parameter => parameter.Name));
Assert.Equal("00000001", call.Spec.Parameters[0].Value);
Assert.Equal("AI", call.Spec.Parameters[1].Value);
Assert.Equal(13, call.Spec.Parameters[2].Value);
Assert.Equal(cancellation.Token, call.CancellationToken);
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task PreviewAsync_NxtUsesMariaAndPreservesExplicitSession()
{
var identity = new ThemeSelectionIdentity(
ThemeMarket.Nxt,
ThemeSession.AfterMarket,
"00000002",
"Robotics");
var executor = new RecordingExecutor(_ => PreviewTable(
("Robotics", "00000002", "NXT", "Gamma", "X005930", "0")));
var service = new LegacyThemeSelectionService(executor);
var result = await service.PreviewAsync(identity);
Assert.Equal(ThemeSession.AfterMarket, result.Identity.Session);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.MariaDb, call.Source);
Assert.Equal("THEME_PREVIEW_NXT", call.TableName);
Assert.Contains("F_STOP_GUBUN = 'N'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("LIMIT @row_limit", call.Spec.Sql, StringComparison.Ordinal);
call.Spec.ValidateFor(DataSourceKind.MariaDb);
}
[Fact]
public async Task PreviewAsync_MapsSingleNullItemSentinelToEmptyTheme()
{
var identity = KrxIdentity();
var executor = new RecordingExecutor(_ => PreviewTable(
("AI", "00000001", "KRX", null, null, null)));
var service = new LegacyThemeSelectionService(executor);
var result = await service.PreviewAsync(identity);
Assert.Empty(result.Items);
Assert.False(result.IsTruncated);
}
[Fact]
public async Task PreviewAsync_AppliesMaximumAndReportsTruncation()
{
var identity = KrxIdentity();
var executor = new RecordingExecutor(_ => PreviewTable(
("AI", "00000001", "KRX", "Alpha", "P000001", "0"),
("AI", "00000001", "KRX", "Beta", "P000002", "1")));
var service = new LegacyThemeSelectionService(executor);
var result = await service.PreviewAsync(identity, maximumItems: 1);
Assert.True(result.IsTruncated);
Assert.Single(result.Items);
Assert.Equal(2, executor.Calls[0].Spec.Parameters[2].Value);
}
public static TheoryData<ThemeSelectionIdentity> UnsafeIdentities => new()
{
new(ThemeMarket.Krx, ThemeSession.PreMarket, "00000001", "AI"),
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, "0000000A", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", " AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", "bad\u0000title")
};
[Theory]
[MemberData(nameof(UnsafeIdentities))]
public async Task PreviewAsync_RejectsUnsafeIdentityBeforeDatabaseAccess(
ThemeSelectionIdentity identity)
{
var executor = new RecordingExecutor(_ => PreviewTable());
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ArgumentException>(() => service.PreviewAsync(identity));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(241)]
public async Task PreviewAsync_RejectsInvalidLimitBeforeDatabaseAccess(int limit)
{
var executor = new RecordingExecutor(_ => PreviewTable());
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.PreviewAsync(KrxIdentity(), limit));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task PreviewAsync_RejectsMissingOrMismatchedSelectedIdentity()
{
var executor = new RecordingExecutor(_ => PreviewTable());
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.PreviewAsync(KrxIdentity()));
executor = new RecordingExecutor(_ => PreviewTable(
("Other", "00000001", "KRX", "Alpha", "P005930", "0")));
service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.PreviewAsync(KrxIdentity()));
}
[Fact]
public async Task PreviewAsync_RejectsPartialSentinelAndWrongMarketItemCode()
{
var executor = new RecordingExecutor(_ => PreviewTable(
("AI", "00000001", "KRX", "Alpha", null, null)));
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.PreviewAsync(KrxIdentity()));
executor = new RecordingExecutor(_ => PreviewTable(
("AI", "00000001", "KRX", "Alpha", "X005930", "0")));
service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.PreviewAsync(KrxIdentity()));
}
[Theory]
[InlineData("P005930", "0", "P005930", "1")]
[InlineData("P005930", "0", "D035720", "0")]
[InlineData("P005930", "2", "D035720", "1")]
public async Task PreviewAsync_RejectsDuplicateOrNonIncreasingItemIdentity(
string code1,
string index1,
string code2,
string index2)
{
var executor = new RecordingExecutor(_ => PreviewTable(
("AI", "00000001", "KRX", "Alpha", code1, index1),
("AI", "00000001", "KRX", "Beta", code2, index2)));
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.PreviewAsync(KrxIdentity()));
}
[Theory]
[InlineData("01")]
[InlineData("-1")]
[InlineData("10000")]
[InlineData("x")]
public async Task PreviewAsync_RejectsNonCanonicalOrOutOfRangeIndex(string index)
{
var executor = new RecordingExecutor(_ => PreviewTable(
("AI", "00000001", "KRX", "Alpha", "P005930", index)));
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.PreviewAsync(KrxIdentity()));
}
[Fact]
public async Task PreviewAsync_RejectsWrongSchemaAndProviderRowOverflow()
{
var wrongSchema = PreviewTable();
wrongSchema.Columns[5].ColumnName = "item_index";
var executor = new RecordingExecutor(_ => wrongSchema);
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.PreviewAsync(KrxIdentity()));
executor = new RecordingExecutor(_ => PreviewTable(
("AI", "00000001", "KRX", "A", "P000001", "0"),
("AI", "00000001", "KRX", "B", "P000002", "1"),
("AI", "00000001", "KRX", "C", "P000003", "2")));
service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.PreviewAsync(KrxIdentity(), maximumItems: 1));
}
[Fact]
public async Task PreviewAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess()
{
var executor = new RecordingExecutor(_ => PreviewTable());
var service = new LegacyThemeSelectionService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.PreviewAsync(
KrxIdentity(),
cancellationToken: cancellation.Token));
Assert.Empty(executor.Calls);
}
private static ThemeSelectionIdentity KrxIdentity() => new(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
"AI");
private static DataTable SearchTable(
params (string Title, string Code, string Market)[] 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));
foreach (var row in rows)
{
table.Rows.Add(row.Title, row.Code, row.Market);
}
return table;
}
private static DataTable PreviewTable(
params (string Title, string Code, string Market, string? ItemName, string? ItemCode, string? Index)[] 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("ITEM_NAME", typeof(string));
table.Columns.Add("ITEM_CODE", typeof(string));
table.Columns.Add("ITEM_INDEX", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(
row.Title,
row.Code,
row.Market,
row.ItemName is null ? DBNull.Value : row.ItemName,
row.ItemCode is null ? DBNull.Value : row.ItemCode,
row.Index is null ? DBNull.Value : row.Index);
}
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("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());
}
}
}

View File

@@ -0,0 +1,352 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyTradingHaltSelectionServiceTests
{
[Fact]
public async Task SearchAsync_EmptyQueryUsesBothBoundedUc7OracleQueries()
{
var results = new Queue<DataTable>(
[
TradingHaltTable(("000002", "Beta"), ("000001", "Alpha")),
TradingHaltTable(("100002", "Delta"), ("100001", "Gamma"))
]);
var executor = new RecordingExecutor(_ => results.Dequeue());
var service = new LegacyTradingHaltSelectionService(executor);
using var cancellation = new CancellationTokenSource();
var result = await service.SearchAsync("", 25, cancellation.Token);
Assert.Equal("", result.Query);
Assert.False(result.IsTruncated);
Assert.Collection(
result.Items,
item => Assert.Equal(
new TradingHaltSelection(TradingHaltMarket.Kospi, "000001", "Alpha"),
item),
item => Assert.Equal(
new TradingHaltSelection(TradingHaltMarket.Kospi, "000002", "Beta"),
item),
item => Assert.Equal(
new TradingHaltSelection(TradingHaltMarket.Kosdaq, "100002", "Delta"),
item),
item => Assert.Equal(
new TradingHaltSelection(TradingHaltMarket.Kosdaq, "100001", "Gamma"),
item));
Assert.Equal(
["TRADING_HALT_KOSPI", "TRADING_HALT_KOSDAQ"],
executor.Calls.Select(static call => call.QueryName));
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(cancellation.Token, call.CancellationToken);
call.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains("F_MKT_HALT = 'Y'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ON a.F_STOCK_CODE = b.F_STOCK_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("F_CURR_PRICE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(26, parameter.Value);
Assert.Equal(DbType.Int32, parameter.DbType);
});
Assert.Contains("FROM T_STOP_ONLINE1 a", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("INNER JOIN T_STOCK b", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM T_STOP_KOSDAQ_ONLINE1 a", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("INNER JOIN T_KOSDAQ_STOCK b", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task SearchAsync_FilteredQueryBindsAndEscapesTheUc7NameFilter()
{
var executor = new RecordingExecutor(_ => TradingHaltTable());
var service = new LegacyTradingHaltSelectionService(executor);
var result = await service.SearchAsync(" 50%!_ ", maximumResults: 12);
Assert.Equal("50%!_", result.Query);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
Assert.Contains(
"UPPER(b.F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!'",
call.Spec.Sql,
StringComparison.Ordinal);
Assert.DoesNotContain("50", call.Spec.Sql, StringComparison.Ordinal);
Assert.Collection(
call.Spec.Parameters,
parameter =>
{
Assert.Equal("search_term", parameter.Name);
Assert.Equal("50!%!!!_", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
},
parameter =>
{
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(13, parameter.Value);
Assert.Equal(DbType.Int32, parameter.DbType);
});
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task SearchAsync_ReturnsDeterministicGlobalLimitAndTruncation()
{
var results = new Queue<DataTable>(
[
TradingHaltTable(("000003", "Gamma"), ("000001", "Alpha")),
TradingHaltTable(("100001", "Beta"))
]);
var executor = new RecordingExecutor(_ => results.Dequeue());
var service = new LegacyTradingHaltSelectionService(executor);
var result = await service.SearchAsync(maximumResults: 2);
Assert.True(result.IsTruncated);
Assert.Equal(
[
new TradingHaltSelection(TradingHaltMarket.Kospi, "000001", "Alpha"),
new TradingHaltSelection(TradingHaltMarket.Kospi, "000003", "Gamma")
],
result.Items);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
[InlineData(int.MaxValue)]
public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults)
{
var executor = new RecordingExecutor(_ => TradingHaltTable());
var service = new LegacyTradingHaltSelectionService(executor);
var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.SearchAsync("", maximumResults));
Assert.Equal("maximumResults", exception.ParamName);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("bad\u0000value")]
[InlineData("bad\nvalue")]
[InlineData("bad\u200Bvalue")]
[InlineData("bad\uD800value")]
public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query)
{
var executor = new RecordingExecutor(_ => TradingHaltTable());
var service = new LegacyTradingHaltSelectionService(executor);
var exception = await Assert.ThrowsAsync<ArgumentException>(
() => service.SearchAsync(query));
Assert.Equal("query", exception.ParamName);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsNullAndOverlongQueryBeforeDatabaseAccess()
{
var executor = new RecordingExecutor(_ => TradingHaltTable());
var service = new LegacyTradingHaltSelectionService(executor);
await Assert.ThrowsAsync<ArgumentNullException>(() => service.SearchAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(
new string('A', LegacyTradingHaltSelectionService.MaximumQueryLength + 1)));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsAnythingOtherThanExactTwoStringColumnSchema()
{
var wrongCase = new DataTable();
wrongCase.Columns.Add("stock_code", typeof(string));
wrongCase.Columns.Add("STOCK_NAME", typeof(string));
var executor = new RecordingExecutor(_ => wrongCase);
var service = new LegacyTradingHaltSelectionService(executor);
var exception = await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
() => service.SearchAsync());
Assert.Contains("TRADING_HALT_KOSPI", exception.Message, StringComparison.Ordinal);
Assert.Single(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsRowsBeyondTheBoundQueryContract()
{
var executor = new RecordingExecutor(_ => TradingHaltTable(
("000001", "One"),
("000002", "Two"),
("000003", "Three")));
var service = new LegacyTradingHaltSelectionService(executor);
await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
() => service.SearchAsync(maximumResults: 1));
Assert.Single(executor.Calls);
}
[Theory]
[InlineData(" 000001", "Name")]
[InlineData("000001", "Name ")]
[InlineData("000001", "Bad\nName")]
[InlineData("00\u000001", "Name")]
[InlineData("", "Name")]
[InlineData("000001", "")]
public async Task SearchAsync_RejectsNonCanonicalIdentityValues(
string stockCode,
string displayName)
{
var executor = new RecordingExecutor(_ => TradingHaltTable((stockCode, displayName)));
var service = new LegacyTradingHaltSelectionService(executor);
await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
() => service.SearchAsync());
}
[Fact]
public async Task SearchAsync_RejectsDuplicateMarketCodeIdentity()
{
var executor = new RecordingExecutor(_ => TradingHaltTable(
("000001", "First Name"),
("000001", "Second Name")));
var service = new LegacyTradingHaltSelectionService(executor);
var exception = await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
() => service.SearchAsync());
Assert.Contains("duplicate market/code", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_RejectsAmbiguousMarketNameLookupKey()
{
var executor = new RecordingExecutor(_ => TradingHaltTable(
("000001", "Shared Name"),
("000002", "Shared Name")));
var service = new LegacyTradingHaltSelectionService(executor);
var exception = await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
() => service.SearchAsync());
Assert.Contains("ambiguous market/name", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_AllowsSameCodeAndNameInDifferentTypedMarkets()
{
var results = new Queue<DataTable>(
[
TradingHaltTable(("000001", "Shared Name")),
TradingHaltTable(("000001", "Shared Name"))
]);
var executor = new RecordingExecutor(_ => results.Dequeue());
var service = new LegacyTradingHaltSelectionService(executor);
var result = await service.SearchAsync();
Assert.Collection(
result.Items,
item => Assert.Equal(TradingHaltMarket.Kospi, item.Market),
item => Assert.Equal(TradingHaltMarket.Kosdaq, item.Market));
}
[Fact]
public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess()
{
var executor = new RecordingExecutor(_ => TradingHaltTable());
var service = new LegacyTradingHaltSelectionService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.SearchAsync(cancellationToken: cancellation.Token));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_StopsBeforeKosdaqWhenCanceledAfterKospiQuery()
{
using var cancellation = new CancellationTokenSource();
var executor = new RecordingExecutor(_ =>
{
cancellation.Cancel();
return TradingHaltTable(("000001", "Alpha"));
});
var service = new LegacyTradingHaltSelectionService(executor);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.SearchAsync(cancellationToken: cancellation.Token));
var call = Assert.Single(executor.Calls);
Assert.Equal("TRADING_HALT_KOSPI", call.QueryName);
}
private static DataTable TradingHaltTable(params (string StockCode, string DisplayName)[] rows)
{
var table = new DataTable();
table.Columns.Add("STOCK_CODE", typeof(string));
table.Columns.Add("STOCK_NAME", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.StockCode, row.DisplayName);
}
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string QueryName,
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());
}
}
}

View File

@@ -0,0 +1,313 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyWorldStockSearchServiceTests
{
[Fact]
public async Task SearchAsync_UsesBoundOracleQueryAndReturnsExactIdentityFields()
{
var executor = new RecordingExecutor(_ => WorldStockTable(
("Apple", "AAPL", "US"),
("Taiwan Semiconductor", "2330", "TW")));
var service = new LegacyWorldStockSearchService(executor);
using var cancellation = new CancellationTokenSource();
var result = await service.SearchAsync(" apple ", 25, cancellation.Token);
Assert.Equal("apple", result.Query);
Assert.False(result.IsTruncated);
Assert.Collection(
result.Items,
item => Assert.Equal(new WorldStockSearchItem("Apple", "AAPL", "US"), item),
item => Assert.Equal(
new WorldStockSearchItem("Taiwan Semiconductor", "2330", "TW"),
item));
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("WORLD_STOCK_SEARCH", call.TableName);
Assert.Equal(cancellation.Token, call.CancellationToken);
call.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains("FROM T_WORLD_IX_EQ_MASTER", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_FDTC = '1'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_NATC IN ('US', 'TW')", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("apple", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Collection(
call.Spec.Parameters,
parameter =>
{
Assert.Equal("search_term", parameter.Name);
Assert.Equal("APPLE", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
},
parameter =>
{
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(26, parameter.Value);
Assert.Equal(DbType.Int32, parameter.DbType);
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task SearchAsync_EscapesLikeMetacharactersInsideBoundValue()
{
var executor = new RecordingExecutor(_ => WorldStockTable());
var service = new LegacyWorldStockSearchService(executor);
await service.SearchAsync(" a!_%b ");
var call = Assert.Single(executor.Calls);
Assert.Equal("A!!!_!%B", call.Spec.Parameters[0].Value);
Assert.Contains("LIKE '%' || :search_term || '%' ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task SearchAsync_AllowsOriginalUc5BlankInitialList(string query)
{
var executor = new RecordingExecutor(_ => WorldStockTable());
var service = new LegacyWorldStockSearchService(executor);
var result = await service.SearchAsync(query);
Assert.Equal(string.Empty, result.Query);
var call = Assert.Single(executor.Calls);
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
}
[Fact]
public async Task SearchAsync_ReturnsBoundedRowsAndReportsTruncation()
{
var executor = new RecordingExecutor(_ => WorldStockTable(
("Alpha", "AAA", "US"),
("Beta", "BBB", "US"),
("Gamma", "CCC", "TW")));
var service = new LegacyWorldStockSearchService(executor);
var result = await service.SearchAsync("a", maximumResults: 2);
Assert.True(result.IsTruncated);
Assert.Equal(2, result.Items.Count);
Assert.Equal(3, executor.Calls[0].Spec.Parameters[1].Value);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
[InlineData(int.MaxValue)]
public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults)
{
var executor = new RecordingExecutor(_ => WorldStockTable());
var service = new LegacyWorldStockSearchService(executor);
var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.SearchAsync("apple", maximumResults));
Assert.Equal("maximumResults", exception.ParamName);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("bad\u0000value")]
[InlineData("\napple")]
[InlineData("bad\u200Bvalue")]
[InlineData("bad\uD800value")]
public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query)
{
var executor = new RecordingExecutor(_ => WorldStockTable());
var service = new LegacyWorldStockSearchService(executor);
var exception = await Assert.ThrowsAsync<ArgumentException>(
() => service.SearchAsync(query));
Assert.Equal("query", exception.ParamName);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsNullAndOverlongQueriesBeforeDatabaseAccess()
{
var executor = new RecordingExecutor(_ => WorldStockTable());
var service = new LegacyWorldStockSearchService(executor);
await Assert.ThrowsAsync<ArgumentNullException>(() => service.SearchAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(
() => service.SearchAsync(
new string('A', LegacyWorldStockSearchService.MaximumQueryLength + 1)));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsAnythingOtherThanExactThreeStringColumnSchema()
{
var wrongCase = new DataTable();
wrongCase.Columns.Add("f_input_name", typeof(string));
wrongCase.Columns.Add("F_SYMB", typeof(string));
wrongCase.Columns.Add("F_NATC", typeof(string));
var executor = new RecordingExecutor(_ => wrongCase);
var service = new LegacyWorldStockSearchService(executor);
var exception = await Assert.ThrowsAsync<WorldStockSearchDataException>(
() => service.SearchAsync("apple"));
Assert.Contains("WORLD_STOCK_SEARCH", exception.Message, StringComparison.Ordinal);
Assert.Single(executor.Calls);
}
[Fact]
public async Task SearchAsync_RejectsExtraRowsBeyondTheBoundQueryContract()
{
var executor = new RecordingExecutor(_ => WorldStockTable(
("One", "1", "US"),
("Two", "2", "US"),
("Three", "3", "US")));
var service = new LegacyWorldStockSearchService(executor);
await Assert.ThrowsAsync<WorldStockSearchDataException>(
() => service.SearchAsync("o", maximumResults: 1));
Assert.Single(executor.Calls);
}
[Theory]
[InlineData(" Apple", "AAPL", "US")]
[InlineData("Apple", "AAPL ", "US")]
[InlineData("Apple\nInc", "AAPL", "US")]
[InlineData("Apple", "AA\u0000PL", "US")]
[InlineData("Apple", "AAPL", "KR")]
[InlineData("Apple", "AAPL", "us")]
public async Task SearchAsync_RejectsNonCanonicalIdentityValues(
string inputName,
string symbol,
string nationCode)
{
var executor = new RecordingExecutor(_ => WorldStockTable(
(inputName, symbol, nationCode)));
var service = new LegacyWorldStockSearchService(executor);
await Assert.ThrowsAsync<WorldStockSearchDataException>(
() => service.SearchAsync("apple"));
}
[Fact]
public async Task SearchAsync_RejectsDuplicateCompositeIdentity()
{
var executor = new RecordingExecutor(_ => WorldStockTable(
("Apple", "AAPL", "US"),
("Apple", "AAPL", "US")));
var service = new LegacyWorldStockSearchService(executor);
var exception = await Assert.ThrowsAsync<WorldStockSearchDataException>(
() => service.SearchAsync("apple"));
Assert.Contains("duplicate identity", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_RejectsDuplicateInputNameAcrossSymbolsAndNations()
{
var executor = new RecordingExecutor(_ => WorldStockTable(
("Shared Name", "SHARED", "US"),
("Shared Name", "2330", "TW")));
var service = new LegacyWorldStockSearchService(executor);
var exception = await Assert.ThrowsAsync<WorldStockSearchDataException>(
() => service.SearchAsync("shared"));
Assert.Contains("duplicate F_INPUT_NAME", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_AllowsSameSymbolForDistinctInputNameKeys()
{
var executor = new RecordingExecutor(_ => WorldStockTable(
("First Name", "SHARED", "US"),
("Second Name", "SHARED", "TW")));
var service = new LegacyWorldStockSearchService(executor);
var result = await service.SearchAsync("name");
Assert.Equal(2, result.Items.Count);
}
[Fact]
public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess()
{
var executor = new RecordingExecutor(_ => WorldStockTable());
var service = new LegacyWorldStockSearchService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.SearchAsync("apple", cancellationToken: cancellation.Token));
Assert.Empty(executor.Calls);
}
private static DataTable WorldStockTable(
params (string InputName, string Symbol, string NationCode)[] 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.NationCode);
}
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("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());
}
}
}

View File

@@ -0,0 +1,87 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
internal sealed record CatalogQueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
internal sealed class CatalogRecordingQueryExecutor(
Func<CatalogQueryCall, DataTable> handler) : IDataQueryExecutor
{
internal List<CatalogQueryCall> Calls { get; } = [];
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("Synchronous catalog queries are forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("String SQL catalog queries are forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
var call = new CatalogQueryCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call));
}
}
internal sealed class CatalogRecordingMutationExecutor(
Func<OperatorCatalogDbTransaction, OperatorCatalogDbTransactionResult>? handler = null)
: IOperatorCatalogMutationExecutor
{
internal List<(OperatorCatalogDbTransaction Transaction, CancellationToken Token)> Calls
{ get; } = [];
internal Exception? Exception { get; set; }
public Task<OperatorCatalogDbTransactionResult> ExecuteTransactionAsync(
OperatorCatalogDbTransaction transaction,
CancellationToken cancellationToken = default)
{
Calls.Add((transaction, cancellationToken));
if (Exception is not null)
{
return Task.FromException<OperatorCatalogDbTransactionResult>(Exception);
}
var result = handler?.Invoke(transaction) ?? new OperatorCatalogDbTransactionResult(
transaction.OperationId,
transaction.Commands.Select(command =>
command.AffectedRowsRule == OperatorCatalogAffectedRowsRule.ExactlyOne ? 1 : 0)
.ToArray(),
DateTimeOffset.Parse("2026-07-11T00:00:00+09:00"));
return Task.FromResult(result);
}
}
internal static class OperatorCatalogTestTables
{
internal static DataTable SingleString(string columnName, string value)
{
var table = new DataTable();
table.Columns.Add(columnName, typeof(string));
table.Rows.Add(value);
return table;
}
internal static DataTable Empty(params string[] columns)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(string));
}
return table;
}
}

View File

@@ -0,0 +1,61 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class OperatorMutationGateTests
{
private static readonly OperatorMutationGateSnapshot Ready = new(
IsFamilyWriteQuarantined: false,
IsGlobalWriteQuarantined: false,
IsLifetimeCancellationRequested: false,
IsPlayoutCommandInFlight: false,
IsPlayoutShutdownStarted: false,
IsBrowserCorrelationQuarantined: false,
IsEngineAvailable: true,
IsWorkflowAvailable: true,
IsCommandAvailable: true,
IsPlayCompletionPending: false,
PreparedSceneName: null,
OnAirSceneName: null,
PreparedCutCode: null,
OnAirCutCode: null,
IsRefreshActive: false,
IsRefreshTaskRunning: false);
public static TheoryData<string, OperatorMutationGateSnapshot> BlockingSnapshots =>
new()
{
{ "family write quarantine", Ready with { IsFamilyWriteQuarantined = true } },
{ "global write quarantine", Ready with { IsGlobalWriteQuarantined = true } },
{ "lifetime cancellation", Ready with { IsLifetimeCancellationRequested = true } },
{ "playout command in flight", Ready with { IsPlayoutCommandInFlight = true } },
{ "playout shutdown", Ready with { IsPlayoutShutdownStarted = true } },
{ "browser correlation quarantine", Ready with { IsBrowserCorrelationQuarantined = true } },
{ "missing engine", Ready with { IsEngineAvailable = false } },
{ "missing workflow", Ready with { IsWorkflowAvailable = false } },
{ "command unavailable", Ready with { IsCommandAvailable = false } },
{ "play completion pending", Ready with { IsPlayCompletionPending = true } },
{ "engine prepared scene", Ready with { PreparedSceneName = "prepared-scene" } },
{ "engine on-air scene", Ready with { OnAirSceneName = "on-air-scene" } },
{ "workflow prepared cut", Ready with { PreparedCutCode = "5001" } },
{ "workflow on-air cut", Ready with { OnAirCutCode = "5001" } },
{ "refresh active", Ready with { IsRefreshActive = true } },
{ "refresh task running", Ready with { IsRefreshTaskRunning = true } }
};
[Fact]
public void CanStart_WhenEveryConditionIsSafe_ReturnsTrue()
{
Assert.True(OperatorMutationGate.CanStart(Ready));
}
[Theory]
[MemberData(nameof(BlockingSnapshots))]
public void CanStart_WhenOneConditionIsUnsafe_ReturnsFalse(
string condition,
OperatorMutationGateSnapshot snapshot)
{
Assert.NotEmpty(condition);
Assert.False(OperatorMutationGate.CanStart(snapshot));
}
}

View File

@@ -143,13 +143,15 @@ public sealed class PagedQuoteSceneDataLoadersTests
["Gamma", 900m, 1000m, 11.11d, "2"]));
var data = await new S5077SceneDataLoader(executor).LoadAsync(
new ExpertPagedQuoteLoadRequest("Analyst A"));
new ExpertPagedQuoteLoadRequest("0001", "Analyst A"));
var mutations = new S5077SceneMutationBuilder().Build(data);
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("e.EP_CODE = :expertCode", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("e.EP_NAME = :expertName", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("Analyst A", executor.Spec.Parameters[0].Value);
Assert.Equal("0001", executor.Spec.Parameters[0].Value);
Assert.Equal("Analyst A", executor.Spec.Parameters[1].Value);
Assert.Equal(PagedQuoteBranch.Expert, data.Branch);
Assert.Equal(PagedQuoteHeaderStyle.ExpertReturns, data.HeaderStyle);
Assert.Contains(new PlayoutSetValue("column1", "\uB9E4\uC218\uAC00"), mutations);
@@ -465,6 +467,7 @@ public sealed class PagedQuoteSceneDataLoadersTests
"Theme",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.NotApplicable),
new ExpertPagedQuoteLoadRequest("001", "Analyst A"),
new ViPagedQuoteLoadRequest([])
];
@@ -477,6 +480,30 @@ public sealed class PagedQuoteSceneDataLoadersTests
}
}
[Fact]
public async Task PagePlanProbe_AllowsZeroRowsAndRequestsOneRowBeyondTwentyPages()
{
var emptyExecutor = new RecordingExecutor(NumberedQuoteTable(0));
var request = new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
DomesticPagedQuoteList.MarketCapitalization,
new DateOnly(2026, 7, 12));
var empty = await new S5074SceneDataLoader(emptyExecutor)
.LoadPagePlanAsync(request);
Assert.Empty(empty.Rows);
Assert.Equal(101, Assert.Single(emptyExecutor.Spec!.Parameters).Value);
var cappedExecutor = new RecordingExecutor(NumberedQuoteTable(101));
var capped = await new S5074SceneDataLoader(cappedExecutor)
.LoadPagePlanAsync(request);
Assert.Equal(101, capped.Rows.Count);
Assert.Equal(101, Assert.Single(cappedExecutor.Spec!.Parameters).Value);
Assert.Equal(0, cappedExecutor.StringCalls);
}
private static Task<PagedQuoteSceneData> LoadAsync(
string scene,
IDataQueryExecutor executor,

View File

@@ -95,6 +95,55 @@ public sealed class ParameterizedMarketSceneDataLoadersTests
}
}
[Fact]
public async Task Foreign_selector_queries_cannot_widen_beyond_their_master_scope()
{
const string industryName = "Sensitive US industry";
var industryExecutor = new RecordingExecutor(
QuoteTable(industryName, 100m, "+", 1m, 1m));
await new S5001SceneDataLoader(industryExecutor).LoadAsync(
new S5001ForeignIndustryLoadRequest(industryName));
var industry = Assert.Single(industryExecutor.Calls);
Assert.Equal("SCENE_FOREIGN_INDUSTRY", industry.TableName);
Assert.Contains("b.f_fdtc = '0'", industry.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("b.f_natc = 'US'", industry.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("a.f_fdtc = '0'", industry.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(industryName, industry.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(industryName, Assert.Single(industry.Spec.Parameters).Value);
const string stockName = "Sensitive world stock";
var stockExecutor = new RecordingExecutor(
QuoteTable(stockName, 100m, "+", 1m, 1m));
await new S5001SceneDataLoader(stockExecutor).LoadAsync(
new S5001ForeignStockLoadRequest(stockName));
var stock = Assert.Single(stockExecutor.Calls);
Assert.Equal("SCENE_FOREIGN_STOCK", stock.TableName);
Assert.Contains("b.f_fdtc = '1'", stock.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"b.f_natc IN ('US', 'TW')",
stock.Spec.Sql,
StringComparison.OrdinalIgnoreCase);
Assert.Contains("a.f_fdtc = '1'", stock.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(stockName, stock.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(stockName, Assert.Single(stock.Spec.Parameters).Value);
}
[Fact]
public async Task Closed_foreign_index_symbols_keep_global_markets_but_reject_stock_master_rows()
{
var executor = new RecordingExecutor(QuoteTable("DAX", 100m, "+", 1m, 1m));
await new S5001SceneDataLoader(executor).LoadAsync(
new S5001ForeignIndexLoadRequest(S5001ForeignIndexTarget.GermanyDax));
var call = Assert.Single(executor.Calls);
Assert.Contains("a.f_fdtc = '0'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("b.f_fdtc = '0'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("f_natc", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task S5001_closed_target_sets_all_construct_valid_specs_and_generate_typed_data()
{
@@ -301,6 +350,48 @@ public sealed class ParameterizedMarketSceneDataLoadersTests
AssertReadOnlyBounded);
}
[Fact]
public async Task S8018_current_mixed_market_and_nxt_stock_uses_the_existing_domestic_dto_safely()
{
var executor = new RecordingExecutor(
QuoteTable("에이비엘바이오", 120_000m, "+", 1_000m, 0.84m),
QuoteTable("코스피", 2_800m, "-", -10m, -0.36m));
var data = await new S8018SceneDataLoader(executor).LoadAsync(
new S8018MixedMarketAndStockLoadRequest(
S8018PairSide.First,
LegacyMarketQuoteTarget.Kospi,
LegacyDomesticEquityMarket.NxtKosdaq,
"에이비엘바이오",
S8018MarketDataMode.Current));
Assert.Equal(
[DataSourceKind.MariaDb, DataSourceKind.Oracle],
executor.Calls.Select(call => call.Source));
Assert.Equal(
["SCENE_NXT_KOSDAQ_STOCK", "SCENE_KOSPI_INDEX"],
executor.Calls.Select(call => call.TableName));
Assert.All(executor.Calls, AssertReadOnlyBounded);
Assert.NotEmpty(new S8018SceneMutationBuilder().Build(data));
}
[Fact]
public async Task S8018_expected_mixed_nxt_stock_is_rejected_before_queries()
{
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8018SceneDataLoader(executor).LoadAsync(
new S8018MixedMarketAndStockLoadRequest(
S8018PairSide.Second,
LegacyMarketQuoteTarget.Kospi,
LegacyDomesticEquityMarket.NxtKospi,
"삼성전자",
S8018MarketDataMode.Expected)));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task S8018_expected_market_mode_is_closed_to_domestic_targets()
{

View File

@@ -160,6 +160,57 @@ public sealed class PlayoutBridgeProtocolTests
Assert.Null(result.Request.Playlist);
}
[Fact]
public void ParsePagePlanRequest_AcceptsOnlyPagedEntriesWithoutStoredPageInput()
{
using var document = JsonDocument.Parse("""
{
"requestId":"PAGE_PLAN_1",
"entries":[
{
"id":"entry-5074",
"code":"5074",
"enabled":true,
"fadeDuration":6,
"selection":{
"groupCode":"PAGED_DOMESTIC_KOSPI",
"subject":"INDEX",
"graphicType":"",
"subtype":"MARKET_CAP",
"dataCode":"2026-07-12"
}
},
{"id":"entry-5088","code":"5088","enabled":false}
]
}
""");
var result = PlayoutBridgeProtocol.ParsePagePlanRequest(document.RootElement);
Assert.True(result.IsValid);
Assert.Equal("PAGE_PLAN_1", result.Request.RequestId);
Assert.Equal(["5074", "5088"], result.Request.Entries!.Select(entry => entry.CutCode));
Assert.Equal("2026-07-12", result.Request.Entries![0].Selection?.DataCode);
}
[Theory]
[InlineData("{\"requestId\":\"REQ\",\"entries\":[]}")]
[InlineData("{\"requestId\":\"REQ\",\"entries\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true}]}")]
[InlineData("{\"requestId\":\"REQ\",\"entries\":[{\"id\":\"one\",\"code\":\"5074\",\"enabled\":true,\"pageText\":\"1/24\"}]}")]
[InlineData("{\"requestId\":\"REQ\",\"entries\":[{\"id\":\"same\",\"code\":\"5074\",\"enabled\":true},{\"id\":\"same\",\"code\":\"5077\",\"enabled\":true}]}")]
[InlineData("{\"requestId\":\"REQ\",\"entries\":[{\"id\":\"one\",\"code\":\"5088\",\"enabled\":true}],\"cue\":{\"code\":\"5088\"}}")]
public void ParsePagePlanRequest_RejectsNonPagedStoredPageOrExtraControlInput(string json)
{
using var document = JsonDocument.Parse(json);
var result = PlayoutBridgeProtocol.ParsePagePlanRequest(document.RootElement);
Assert.False(result.IsValid);
Assert.Equal("REQ", result.Request.RequestId);
Assert.Null(result.Request.Entries);
Assert.NotEmpty(result.Error);
}
[Theory]
[InlineData("prepare")]
[InlineData("take-in")]

View File

@@ -54,6 +54,121 @@ public sealed class RegisteredLegacySceneCueProviderTests
page.Cue.Mutations!.Skip(2));
}
[Fact]
public async Task CreatePagePlan_UsesDedicatedDtoRouteWithoutBuildingACue()
{
var rows = Enumerable.Range(1, 101)
.Select(index => new PagedQuoteRowData(
$"item-{index}",
index,
index,
index,
ScenePriceDirection.Up))
.ToArray();
var page = new LegacySceneDataPage(
"s5074",
new PagedQuoteSceneData(
PagedQuoteBranch.Domestic,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"preflight",
rows,
PageNumberOneBased: 1),
rows.Length);
var dataSource = new PagePlanDataSource(page);
var provider = new RegisteredLegacySceneCueProvider(dataSource);
var plan = await provider.CreatePagePlanAsync(new("entry", "5074"));
Assert.Equal("s5074", plan.BuilderKey);
Assert.Equal(101, plan.ItemCount);
Assert.Equal(ScenePageSize.Five, plan.PageSize);
Assert.Equal(20, plan.PageCount);
Assert.Equal(100, plan.AccessibleItemCount);
Assert.True(plan.IsTruncated);
Assert.Equal(0, dataSource.PageCalls);
Assert.Equal(1, dataSource.PagePlanCalls);
}
[Fact]
public async Task CreatePagePlan_PreservesAnExplicitZeroItemResult()
{
var page = new LegacySceneDataPage(
"s5077",
new PagedQuoteSceneData(
PagedQuoteBranch.Domestic,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"empty",
[],
PageNumberOneBased: 1),
0);
var dataSource = new PagePlanDataSource(page);
var provider = new RegisteredLegacySceneCueProvider(dataSource);
var plan = await provider.CreatePagePlanAsync(new("empty-entry", "5077"));
Assert.Equal("s5077", plan.BuilderKey);
Assert.Equal(0, plan.ItemCount);
Assert.Equal(ScenePageSize.Six, plan.PageSize);
Assert.Equal(0, plan.PageCount);
Assert.Equal(0, plan.AccessibleItemCount);
Assert.False(plan.IsTruncated);
Assert.Equal(0, dataSource.PageCalls);
Assert.Equal(1, dataSource.PagePlanCalls);
}
[Fact]
public async Task MutableCompositionSource_AppliesOnlyToCuesStartedAfterTheUpdate()
{
var pageData = new LegacySceneDataPage(
"s5085",
new S5085SceneData(
[
new ProgramTradingMetricData(ProgramTradingMetric.NetBuy, 12.5)
]),
1);
var dataSource = new BlockingDataSource(pageData);
var optionsSource = new MutableLegacySceneCueCompositionOptionsSource(
LegacySceneCueCompositionOptions.Default);
var provider = new RegisteredLegacySceneCueProvider(dataSource, optionsSource);
var inFlight = provider.CreatePageAsync(new("entry-one", "5085"), 0);
await dataSource.Started;
optionsSource.Update(new LegacySceneCueCompositionOptions(
6,
LegacySceneBackgroundKind.Texture,
"Video\\studio.png"));
dataSource.Release();
var first = await inFlight;
var second = await provider.CreatePageAsync(new("entry-two", "5085"), 0);
Assert.Equal(new PlayoutUseBackground(false), first.Cue.Mutations![0]);
Assert.Collection(
second.Cue.Mutations!.Take(2),
mutation => Assert.Equal(
new PlayoutSetBackgroundTexture("Video\\studio.png"),
mutation),
mutation => Assert.Equal(new PlayoutUseBackground(true), mutation));
}
[Fact]
public void MutableCompositionSource_RejectsUnsafeReplacementAndKeepsTheCurrentSnapshot()
{
var source = new MutableLegacySceneCueCompositionOptionsSource(
LegacySceneCueCompositionOptions.Default);
Assert.Throws<ArgumentException>(() => source.Update(
new LegacySceneCueCompositionOptions(
6,
LegacySceneBackgroundKind.Video,
"..\\outside.vrv")));
Assert.Equal(LegacySceneCueCompositionOptions.Default, source.Current);
}
[Fact]
public async Task CreatePage_UsesCatalogPageSizeAndKeepsTheFullItemCount()
{
@@ -150,4 +265,56 @@ public sealed class RegisteredLegacySceneCueProviderTests
return Task.FromResult(page);
}
}
private sealed class PagePlanDataSource(LegacySceneDataPage planPage) :
ILegacySceneDataSource,
ILegacyScenePagePlanDataSource
{
public int PageCalls { get; private set; }
public int PagePlanCalls { get; private set; }
public Task<LegacySceneDataPage> LoadPageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken = default)
{
PageCalls++;
throw new InvalidOperationException("Normal cue creation must not run during preflight.");
}
public Task<LegacySceneDataPage> LoadPagePlanDataAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
PagePlanCalls++;
return Task.FromResult(planPage);
}
}
private sealed class BlockingDataSource(LegacySceneDataPage page) : ILegacySceneDataSource
{
private readonly TaskCompletionSource _started = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource _release = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public Task Started => _started.Task;
public void Release() => _release.TrySetResult();
public async Task<LegacySceneDataPage> LoadPageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken = default)
{
_started.TrySetResult();
await _release.Task.WaitAsync(cancellationToken);
_release = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
_release.TrySetResult();
return page;
}
}
}

View File

@@ -0,0 +1,117 @@
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 TrustedViManualReferenceTests
{
private const string Version =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
[Fact]
public async Task Realistic_twenty_page_web_reference_crosses_bridge_and_resolves_native_names()
{
var names = Enumerable.Range(1, 100)
.Select(index => $"현실적인긴종목명테스트{index}")
.ToArray();
using var document = JsonDocument.Parse($$"""
{
"requestId": "vi_prepare_20_pages",
"command": "prepare",
"playlist": [
{
"id": "vi_entry_20_pages",
"code": "5074",
"enabled": true,
"fadeDuration": 4,
"selection": {
"groupCode": "PAGED_VI",
"subject": "{{TrustedViManualReference.SubjectSentinel}}",
"graphicType": "INPUT_ORDER",
"subtype": "CURRENT",
"dataCode": "{{Version}}"
}
}
],
"selectedIndex": 0
}
""");
var parsed = PlayoutBridgeProtocol.ParseWorkflowCommand(document.RootElement);
Assert.True(parsed.IsValid, parsed.Error);
var selection = Assert.Single(parsed.Request.Playlist!).Selection!;
var request = await TrustedViManualReference.ResolveAsync(
selection,
pageIndexZeroBased: 19,
new StubSnapshotSource(names, Version));
Assert.Equal(20, request.PageNumberOneBased);
Assert.Equal(names, request.StockNames);
}
[Fact]
public async Task Version_mismatch_fails_closed_before_query_request_creation()
{
var selection = TrustedSelection(Version);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
TrustedViManualReference.ResolveAsync(
selection,
0,
new StubSnapshotSource(["삼성전자"], new string('b', 64))));
}
[Fact]
public async Task Modified_sentinel_cannot_downgrade_a_versioned_reference()
{
var selection = TrustedSelection(Version) with
{
Subject = "@MBN_VI_SNAPSHOT_V0"
};
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
TrustedViManualReference.ResolveAsync(
selection,
0,
new StubSnapshotSource(["삼성전자"], Version)));
}
[Fact]
public async Task Historical_bounded_name_list_remains_compatible()
{
var selection = new LegacySceneSelection(
"PAGED_VI",
"삼성전자,SK하이닉스,삼성전자",
"INPUT_ORDER",
"CURRENT",
string.Empty);
var request = await TrustedViManualReference.ResolveAsync(
selection,
1,
trustedSource: null);
Assert.Equal(2, request.PageNumberOneBased);
Assert.Equal(["삼성전자", "SK하이닉스", "삼성전자"], request.StockNames);
}
private static LegacySceneSelection TrustedSelection(string version) => new(
"PAGED_VI",
TrustedViManualReference.SubjectSentinel,
"INPUT_ORDER",
"CURRENT",
version);
private sealed class StubSnapshotSource(
IReadOnlyList<string> names,
string version) : ITrustedViStockNameSnapshotSource
{
public Task<TrustedViStockNameSnapshot> ReadStockNameSnapshotAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new TrustedViStockNameSnapshot(names, version));
}
}
}

View File

@@ -0,0 +1,494 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class OperatorCatalogMutationExecutorTests
{
[Fact]
public async Task ExecuteTransactionAsync_UsesOneSerializableTransactionAndCommitsOnce()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var receipt = await service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0042", "전문가"),
[
new ExpertCatalogRecommendation(0, "005930", "삼성전자", 70000m),
new ExpertCatalogRecommendation(1, "035720", "카카오", 45000m)
]));
Assert.Equal(OperatorCatalogMutationKind.CreateExpert, receipt.Kind);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(DataSourceKind.Oracle, factory.LastSource);
Assert.Equal(IsolationLevel.Serializable, plan.IsolationLevel);
Assert.Equal(1, plan.BeginCount);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(3, plan.Commands.Count);
Assert.Empty(plan.NonQuerySteps);
Assert.All(plan.Commands, command =>
{
Assert.Equal(7, command.CommandTimeoutSeconds);
Assert.True(command.HadTransaction);
Assert.All(command.Parameters, parameter =>
Assert.Equal(ParameterDirection.Input, parameter.Direction));
});
Assert.Contains("INSERT INTO EXPERT_LIST", plan.Commands[0].Sql, StringComparison.Ordinal);
Assert.Equal("0042", Parameter(plan.Commands[0], "expert_code").Value);
Assert.Equal("전문가", Parameter(plan.Commands[0], "expert_name").Value);
Assert.Contains("INSERT INTO RECOMMEND_LIST", plan.Commands[1].Sql, StringComparison.Ordinal);
Assert.Equal(70000m, Parameter(plan.Commands[1], "buy_amount").Value);
Assert.Equal(1, plan.ConnectionDisposeCount);
Assert.Equal(1, plan.TransactionDisposeCount);
}
[Fact]
public async Task ExecuteTransactionAsync_SupportsMariaThemeWithSameAtomicContract()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyThemeCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
await service.CreateAsync(new ThemeCatalogDefinition(
ThemeMarket.Nxt,
"00000007",
"반도체",
[new ThemeCatalogItem(0, "X005930", "삼성전자")]));
Assert.Equal(DataSourceKind.MariaDb, factory.LastSource);
Assert.Equal(IsolationLevel.Serializable, plan.IsolationLevel);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(2, plan.Commands.Count);
Assert.All(plan.Commands, command => Assert.Contains('@', command.Sql));
}
[Fact]
public async Task ExecuteTransactionAsync_AffectedRowConflictRollsBackBeforeNextCommand()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyThemeCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogConflictException>(() =>
service.CreateAsync(new ThemeCatalogDefinition(
ThemeMarket.Krx,
"00000001",
"중복 테마",
[new ThemeCatalogItem(0, "P005930", "삼성전자")])));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(OperatorCatalogDbCommandKind.InsertTheme, exception.CommandKind);
Assert.Single(plan.Commands);
Assert.Single(plan.NonQuerySteps);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task ExecuteTransactionAsync_EmptyChildDeleteThenIdentityParentDeleteCommits()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan));
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
await service.DeleteAsync(new ExpertSelectionIdentity("0001", "전문가"));
Assert.Equal(2, plan.Commands.Count);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ExecuteTransactionAsync_TimeoutOrCancellationAfterBeginIsOutcomeUnknown(
bool cancellation)
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ => cancellation
? Task.FromException<int>(new OperationCanceledException("cancelled in provider"))
: Task.FromException<int>(new TimeoutException("provider timeout")));
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[])));
Assert.True(exception.OutcomeUnknown);
Assert.Contains("do not retry automatically", exception.Message, StringComparison.Ordinal);
Assert.Single(plan.Commands);
Assert.Empty(plan.NonQuerySteps);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task ExecuteTransactionAsync_OrdinaryFailureWithKnownRollbackIsNotUnknown()
{
var plan = new CatalogMutationConnectionPlan();
plan.NonQuerySteps.Enqueue(_ =>
Task.FromException<int>(new TestDatabaseException("constraint")));
var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan));
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[])));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task ExecuteTransactionAsync_RollbackFailureMakesOutcomeUnknown()
{
var plan = new CatalogMutationConnectionPlan
{
RollbackAsync = _ => Task.FromException(new IOException("rollback disconnected"))
};
plan.NonQuerySteps.Enqueue(_ =>
Task.FromException<int>(new TestDatabaseException("constraint")));
var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan));
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[])));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task ExecuteTransactionAsync_CommitFailureIsUnknownAndDoesNotRollback()
{
var plan = new CatalogMutationConnectionPlan
{
CommitAsync = _ => Task.FromException(new TimeoutException("commit response lost"))
};
plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan));
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[])));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
}
[Fact]
public async Task ExecuteTransactionAsync_PreCancelledTokenDoesNotOpenConnection()
{
var plan = new CatalogMutationConnectionPlan();
var factory = new CatalogMutationConnectionFactory(plan);
var executor = CreateExecutor(factory);
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.CreateAsync(
new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "전문가"),
[]),
cancellation.Token));
Assert.Equal(0, factory.CreateCount);
Assert.Equal(0, plan.BeginCount);
}
private static OperatorCatalogMutationExecutor CreateExecutor(
IDatabaseConnectionFactory factory) =>
new(
factory,
new DatabaseResilienceOptions
{
OperationTimeoutSeconds = 30,
MaximumRetryCount = 5,
InitialRetryDelayMilliseconds = 1
},
new StubErrorDetector(transient: true),
static (_, _) => { });
private static ObservedCatalogMutationParameter Parameter(
ObservedCatalogMutationCommand command,
string name) =>
Assert.Single(command.Parameters, parameter =>
string.Equals(parameter.Name, name, StringComparison.Ordinal));
}
internal sealed class CatalogNoQueryExecutor : IDataQueryExecutor
{
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("No query expected.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("No query expected.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("No query expected.");
}
internal sealed class CatalogMutationConnectionPlan
{
internal Queue<Func<CancellationToken, Task<int>>> NonQuerySteps { get; } = new();
internal Func<CancellationToken, Task> OpenAsync { get; init; } =
_ => Task.CompletedTask;
internal Func<CancellationToken, Task> CommitAsync { get; init; } =
_ => Task.CompletedTask;
internal Func<CancellationToken, Task> RollbackAsync { get; init; } =
_ => Task.CompletedTask;
internal List<ObservedCatalogMutationCommand> Commands { get; } = [];
internal IsolationLevel? IsolationLevel { get; set; }
internal int BeginCount { get; set; }
internal int CommitCount { get; set; }
internal int RollbackCount { get; set; }
internal int ConnectionDisposeCount { get; set; }
internal int TransactionDisposeCount { get; set; }
}
internal sealed record ObservedCatalogMutationCommand(
string Sql,
int CommandTimeoutSeconds,
bool HadTransaction,
IReadOnlyList<ObservedCatalogMutationParameter> Parameters);
internal sealed record ObservedCatalogMutationParameter(
string Name,
object? Value,
DbType DbType,
ParameterDirection Direction);
internal sealed class CatalogMutationConnectionFactory(CatalogMutationConnectionPlan plan)
: IDatabaseConnectionFactory
{
internal int CreateCount { get; private set; }
internal DataSourceKind? LastSource { get; private set; }
public DbConnection Create(DataSourceKind source)
{
CreateCount++;
LastSource = source;
return new CatalogMutationDbConnection(plan);
}
public int GetCommandTimeoutSeconds(DataSourceKind source) => 7;
}
internal sealed class CatalogMutationDbConnection(CatalogMutationConnectionPlan plan)
: DbConnection
{
private ConnectionState _state = ConnectionState.Closed;
[AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "CatalogFake";
public override string DataSource => "CatalogFake";
public override string ServerVersion => "1";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName)
{
}
public override void Close() => _state = ConnectionState.Closed;
public override void Open() => throw new InvalidOperationException("Synchronous open forbidden.");
public override async Task OpenAsync(CancellationToken cancellationToken)
{
await plan.OpenAsync(cancellationToken);
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
{
plan.BeginCount++;
plan.IsolationLevel = isolationLevel;
return new CatalogMutationDbTransaction(this, plan, isolationLevel);
}
protected override DbCommand CreateDbCommand() =>
new CatalogMutationDbCommand(this, plan);
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.ConnectionDisposeCount++;
_state = ConnectionState.Closed;
}
base.Dispose(disposing);
}
}
internal sealed class CatalogMutationDbTransaction(
DbConnection connection,
CatalogMutationConnectionPlan plan,
IsolationLevel isolationLevel) : DbTransaction
{
public override IsolationLevel IsolationLevel => isolationLevel;
protected override DbConnection DbConnection => connection;
public override void Commit() => throw new InvalidOperationException("Sync commit forbidden.");
public override void Rollback() => throw new InvalidOperationException("Sync rollback forbidden.");
public override async Task CommitAsync(CancellationToken cancellationToken = default)
{
plan.CommitCount++;
await plan.CommitAsync(cancellationToken);
}
public override async Task RollbackAsync(CancellationToken cancellationToken = default)
{
plan.RollbackCount++;
await plan.RollbackAsync(cancellationToken);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.TransactionDisposeCount++;
}
base.Dispose(disposing);
}
}
internal sealed class CatalogMutationDbCommand(
DbConnection connection,
CatalogMutationConnectionPlan plan) : DbCommand
{
private readonly FakeDbParameterCollection _parameters = new();
[AllowNull]
public override string CommandText { get; set; } = string.Empty;
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; } = connection;
protected override DbParameterCollection DbParameterCollection => _parameters;
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel()
{
}
public override int ExecuteNonQuery() =>
throw new InvalidOperationException("Synchronous execution forbidden.");
public override object? ExecuteScalar() => throw new NotSupportedException();
public override void Prepare()
{
}
protected override DbParameter CreateDbParameter() => new FakeDbParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
throw new NotSupportedException();
public override Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
{
plan.Commands.Add(new ObservedCatalogMutationCommand(
CommandText,
CommandTimeout,
DbTransaction is not null,
_parameters.Cast<DbParameter>()
.Select(parameter => new ObservedCatalogMutationParameter(
parameter.ParameterName,
parameter.Value,
parameter.DbType,
parameter.Direction))
.ToArray()));
if (!plan.NonQuerySteps.TryDequeue(out var step))
{
throw new InvalidOperationException("No non-query plan remains.");
}
return step(cancellationToken);
}
}

View File

@@ -0,0 +1,501 @@
#nullable enable
using System.Collections;
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using MMoneyCoderSharp.Data;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class OracleManualFinancialMutationExecutorTests
{
private const string Version = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
[Fact]
public void ConfigureOracleCommand_sets_bind_by_name_and_rejects_another_provider()
{
using var oracleCommand = new OracleCommand();
OracleManualFinancialMutationExecutor.ConfigureOracleCommand(oracleCommand);
Assert.True(oracleCommand.BindByName);
var plan = new Plan();
using var fake = new FakeCommand(new FakeConnection(plan), plan, 0);
Assert.Throws<DatabaseConfigurationException>(() =>
OracleManualFinancialMutationExecutor.ConfigureOracleCommand(fake));
}
[Fact]
public async Task Create_locks_then_executes_bound_command_once_and_commits_one_transaction()
{
var plan = new Plan
{
ExecuteAsync = (index, _) => Task.FromResult(index == 0 ? -1 : 1)
};
var factory = new Factory(plan, 17);
var service = Service(Executor(factory));
var receipt = await service.CreateAsync(Sales("Operator ' quoted"));
Assert.Equal(1, factory.CreateCount);
Assert.Equal(ManualFinancialMutationKind.Create, receipt.Kind);
Assert.Equal(ManualFinancialScreenKind.Sales, receipt.Screen);
Assert.Equal("Operator ' quoted", receipt.StockName);
Assert.Equal(1, receipt.AffectedRows);
Assert.NotEqual(Guid.Empty, receipt.OperationId);
Assert.Equal(1, plan.OpenCount);
Assert.Equal(1, plan.BeginCount);
Assert.Equal(IsolationLevel.ReadCommitted, plan.IsolationLevel);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(2, plan.Commands.Count);
Assert.Equal("LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE", plan.Commands[0].CommandText);
Assert.Empty(plan.Commands[0].ParametersSnapshot);
Assert.StartsWith("INSERT INTO INPUT_SELL", plan.Commands[1].CommandText.TrimStart(), StringComparison.Ordinal);
Assert.DoesNotContain("Operator ' quoted", plan.Commands[1].CommandText, StringComparison.Ordinal);
Assert.Equal("Operator ' quoted", plan.Commands[1].ParametersSnapshot
.Single(parameter => parameter.ParameterName == "stock_name").Value);
Assert.All(plan.Commands, command =>
{
Assert.True(command.BindByName);
Assert.Equal(17, command.CommandTimeout);
Assert.Equal(1, command.ExecuteCount);
Assert.Equal(0, command.SynchronousExecuteCount);
Assert.True(command.WasDisposed);
});
Assert.True(plan.ConnectionDisposed);
Assert.True(plan.TransactionDisposed);
}
[Fact]
public async Task Zero_affected_create_rolls_back_as_known_duplicate_before_commit()
{
var plan = new Plan
{
ExecuteAsync = (index, _) => Task.FromResult(index == 0 ? -1 : 0)
};
var factory = new Factory(plan);
var service = Service(Executor(factory));
var exception = await Assert.ThrowsAsync<ManualFinancialMutationRejectedException>(() =>
service.CreateAsync(Sales("Alpha")));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(ManualFinancialMutationRejectionReason.DuplicateIdentity, exception.Reason);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Equal(2, plan.Commands.Count);
Assert.Equal([1, 1], plan.Commands.Select(command => command.ExecuteCount));
Assert.Equal(1, factory.CreateCount);
}
[Fact]
public async Task Command_timeout_is_OutcomeUnknown_and_is_never_retried()
{
var plan = new Plan
{
ExecuteAsync = (index, _) => index == 0
? Task.FromResult(-1)
: Task.FromException<int>(new TimeoutException("late"))
};
var factory = new Factory(plan);
var service = Service(Executor(factory));
var exception = await Assert.ThrowsAsync<ManualFinancialMutationException>(() =>
service.CreateAsync(Sales("Alpha")));
Assert.True(exception.OutcomeUnknown);
Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Equal(2, plan.Commands.Count);
Assert.Equal([1, 1], plan.Commands.Select(command => command.ExecuteCount));
}
[Fact]
public async Task Commit_response_loss_is_OutcomeUnknown_without_rollback_or_retry()
{
var plan = new Plan
{
ExecuteAsync = (index, _) => Task.FromResult(index == 0 ? -1 : 1),
CommitAsync = _ => Task.FromException(new TestDatabaseException("lost"))
};
var factory = new Factory(plan);
var service = Service(Executor(factory));
var exception = await Assert.ThrowsAsync<ManualFinancialMutationException>(() =>
service.DeleteAsync(new ManualFinancialSnapshot(Sales("Alpha"), Version)));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(2, plan.Commands.Count);
}
private static LegacyManualFinancialScreenService Service(
IManualFinancialMutationExecutor executor) =>
new(new ForbiddenQueryExecutor(), executor);
private static OracleManualFinancialMutationExecutor Executor(
Factory factory,
int operationTimeoutSeconds = 30) =>
new(
factory,
new DatabaseResilienceOptions
{
OperationTimeoutSeconds = operationTimeoutSeconds,
MaximumRetryCount = 5,
InitialRetryDelayMilliseconds = 30_000
},
new StubErrorDetector(false),
command => Assert.IsType<FakeCommand>(command).BindByName = true);
private static ManualSalesRecord Sales(string stockName) =>
new(
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName),
[
new("1Q", 10),
new("2Q", 20),
new("3Q", 30),
new("4Q", 40),
new("5Q", 50),
new("6Q", 60)
]);
private sealed class ForbiddenQueryExecutor : IDataQueryExecutor
{
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("A mutation test must not read.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("A mutation test must not read.");
}
private sealed class Factory(Plan plan, int commandTimeoutSeconds = 15)
: IDatabaseConnectionFactory
{
public int CreateCount { get; private set; }
public DbConnection Create(DataSourceKind source)
{
Assert.Equal(DataSourceKind.Oracle, source);
CreateCount++;
return new FakeConnection(plan);
}
public int GetCommandTimeoutSeconds(DataSourceKind source)
{
Assert.Equal(DataSourceKind.Oracle, source);
return commandTimeoutSeconds;
}
}
private sealed class Plan
{
public Func<int, CancellationToken, Task<int>> ExecuteAsync { get; init; } =
(_, _) => Task.FromResult(1);
public Func<CancellationToken, Task> CommitAsync { get; init; } =
_ => Task.CompletedTask;
public Func<CancellationToken, Task> RollbackAsync { get; init; } =
_ => Task.CompletedTask;
public int OpenCount { get; set; }
public int BeginCount { get; set; }
public int CommitCount { get; set; }
public int RollbackCount { get; set; }
public IsolationLevel? IsolationLevel { get; set; }
public bool ConnectionDisposed { get; set; }
public bool TransactionDisposed { get; set; }
public List<FakeCommand> Commands { get; } = [];
}
private sealed class FakeConnection(Plan plan) : DbConnection
{
private ConnectionState _state = ConnectionState.Closed;
[AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "Fake";
public override string DataSource => "Fake";
public override string ServerVersion => "1";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName)
{
}
public override void Close() => _state = ConnectionState.Closed;
public override void Open() => throw new InvalidOperationException("Synchronous open is forbidden.");
public override Task OpenAsync(CancellationToken cancellationToken)
{
plan.OpenCount++;
_state = ConnectionState.Open;
return Task.CompletedTask;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
throw new InvalidOperationException("Synchronous begin is forbidden.");
protected override ValueTask<DbTransaction> BeginDbTransactionAsync(
IsolationLevel isolationLevel,
CancellationToken cancellationToken)
{
plan.BeginCount++;
plan.IsolationLevel = isolationLevel;
return ValueTask.FromResult<DbTransaction>(new FakeTransaction(this, plan, isolationLevel));
}
protected override DbCommand CreateDbCommand()
{
var command = new FakeCommand(this, plan, plan.Commands.Count);
plan.Commands.Add(command);
return command;
}
public override ValueTask DisposeAsync()
{
plan.ConnectionDisposed = true;
_state = ConnectionState.Closed;
return ValueTask.CompletedTask;
}
}
private sealed class FakeTransaction(
DbConnection connection,
Plan plan,
IsolationLevel isolationLevel) : DbTransaction
{
public override IsolationLevel IsolationLevel => isolationLevel;
protected override DbConnection DbConnection => connection;
public override void Commit() => throw new InvalidOperationException("Synchronous commit is forbidden.");
public override async Task CommitAsync(CancellationToken cancellationToken = default)
{
plan.CommitCount++;
await plan.CommitAsync(cancellationToken);
}
public override void Rollback() => throw new InvalidOperationException("Synchronous rollback is forbidden.");
public override async Task RollbackAsync(CancellationToken cancellationToken = default)
{
plan.RollbackCount++;
await plan.RollbackAsync(cancellationToken);
}
public override ValueTask DisposeAsync()
{
plan.TransactionDisposed = true;
return ValueTask.CompletedTask;
}
}
private sealed class FakeCommand(
DbConnection connection,
Plan plan,
int commandIndex) : DbCommand
{
private readonly FakeParameterCollection _parameters = new();
public bool BindByName { get; set; }
public int ExecuteCount { get; private set; }
public int SynchronousExecuteCount { get; private set; }
public bool WasDisposed { get; private set; }
public IReadOnlyList<DbParameter> ParametersSnapshot =>
_parameters.Cast<DbParameter>().ToArray();
[AllowNull]
public override string CommandText { get; set; } = string.Empty;
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; } = connection;
protected override DbParameterCollection DbParameterCollection => _parameters;
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel()
{
}
public override int ExecuteNonQuery()
{
SynchronousExecuteCount++;
throw new InvalidOperationException("Synchronous execution is forbidden.");
}
public override async Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
{
ExecuteCount++;
return await plan.ExecuteAsync(commandIndex, cancellationToken);
}
public override object? ExecuteScalar() => throw new NotSupportedException();
public override void Prepare()
{
}
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
throw new NotSupportedException();
public override ValueTask DisposeAsync()
{
WasDisposed = true;
return ValueTask.CompletedTask;
}
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
[AllowNull]
public override string ParameterName { get; set; } = string.Empty;
public override int Size { get; set; }
[AllowNull]
public override string SourceColumn { get; set; } = string.Empty;
public override bool SourceColumnNullMapping { get; set; }
public override object? Value { get; set; }
public override void ResetDbType() => DbType = default;
}
private sealed class FakeParameterCollection : DbParameterCollection
{
private readonly List<DbParameter> _parameters = [];
public override int Count => _parameters.Count;
public override object SyncRoot => ((ICollection)_parameters).SyncRoot;
public override int Add(object value)
{
_parameters.Add(Require(value));
return _parameters.Count - 1;
}
public override void AddRange(Array values)
{
foreach (var value in values)
{
Add(value ?? throw new ArgumentNullException(nameof(values)));
}
}
public override void Clear() => _parameters.Clear();
public override bool Contains(object value) =>
value is DbParameter parameter && _parameters.Contains(parameter);
public override bool Contains(string value) => IndexOf(value) >= 0;
public override void CopyTo(Array array, int index) =>
((ICollection)_parameters).CopyTo(array, index);
public override IEnumerator GetEnumerator() => _parameters.GetEnumerator();
public override int IndexOf(object value) =>
value is DbParameter parameter ? _parameters.IndexOf(parameter) : -1;
public override int IndexOf(string parameterName) =>
_parameters.FindIndex(parameter => string.Equals(
parameter.ParameterName,
parameterName,
StringComparison.OrdinalIgnoreCase));
public override void Insert(int index, object value) =>
_parameters.Insert(index, Require(value));
public override void Remove(object value)
{
if (value is DbParameter parameter)
{
_parameters.Remove(parameter);
}
}
public override void RemoveAt(int index) => _parameters.RemoveAt(index);
public override void RemoveAt(string parameterName)
{
var index = IndexOf(parameterName);
if (index >= 0)
{
RemoveAt(index);
}
}
protected override DbParameter GetParameter(int index) => _parameters[index];
protected override DbParameter GetParameter(string parameterName)
{
var index = IndexOf(parameterName);
return index >= 0 ? _parameters[index] : throw new IndexOutOfRangeException();
}
protected override void SetParameter(int index, DbParameter value) =>
_parameters[index] = value;
protected override void SetParameter(string parameterName, DbParameter value)
{
var index = IndexOf(parameterName);
if (index >= 0)
{
_parameters[index] = value;
}
else
{
_parameters.Add(value);
}
}
private static DbParameter Require(object value) =>
value as DbParameter ?? throw new ArgumentException("A parameter is required.");
}
}

View File

@@ -0,0 +1,814 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class OracleNamedPlaylistMutationExecutorTests
{
[Fact]
public void ConfigureOracleCommand_SetsBindByNameAndRejectsAnotherProviderCommand()
{
using var oracleCommand = new OracleCommand();
OracleNamedPlaylistMutationExecutor.ConfigureOracleCommand(oracleCommand);
Assert.True(oracleCommand.BindByName);
using var fakeCommand = new MutationFakeCommand(
new MutationFakeConnection(new MutationPlan()),
new MutationPlan(),
0);
Assert.Throws<DatabaseConfigurationException>(
() => OracleNamedPlaylistMutationExecutor.ConfigureOracleCommand(fakeCommand));
}
[Fact]
public async Task ReplaceItemsAsync_UsesOneConnectionTransactionAndCommitWithBoundCommands()
{
var plan = new MutationPlan();
var factory = new MutationConnectionFactory(plan, commandTimeoutSeconds: 17);
var executor = CreateExecutor(factory);
var service = CreateService(executor);
await service.ReplaceItemsAsync(
"00000011",
[
Item(0, "Alpha", "000001"),
Item(1, "Beta", "000002")
]);
Assert.Equal(1, factory.CreateCount);
Assert.Equal([DataSourceKind.Oracle], factory.Sources);
Assert.Equal(1, plan.OpenCount);
Assert.Equal(1, plan.BeginCount);
Assert.Equal(IsolationLevel.ReadCommitted, plan.ObservedIsolationLevel);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(0, plan.SynchronousBeginCount);
Assert.Equal(0, plan.SynchronousCommitCount);
Assert.Equal(0, plan.SynchronousRollbackCount);
Assert.True(plan.ConnectionDisposed);
Assert.True(plan.TransactionDisposed);
Assert.Collection(
plan.Commands,
command =>
{
Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, CommandKind(command));
Assert.StartsWith(
"DELETE FROM PLAY_LIST",
command.CommandText.TrimStart(),
StringComparison.Ordinal);
AssertParameter(
Assert.Single(command.ParametersSnapshot),
"program_code",
"00000011",
DbType.String);
},
command => AssertInsert(command, 0, "Alpha", "000001"),
command => AssertInsert(command, 1, "Beta", "000002"));
var transaction = Assert.IsType<MutationFakeTransaction>(plan.Transaction);
foreach (var command in plan.Commands)
{
Assert.True(command.BindByName);
Assert.Equal(CommandType.Text, command.CommandType);
Assert.Equal(17, command.CommandTimeout);
Assert.Same(transaction, command.ObservedTransaction);
Assert.Equal(1, command.ExecuteCount);
Assert.Equal(0, command.SynchronousExecuteCount);
Assert.True(command.WasDisposed);
}
}
[Fact]
public async Task CommandFailure_RollsBackAndReportsKnownNonCommitWithoutRetry()
{
var plan = new MutationPlan
{
ExecuteAsync = (index, _) => index == 1
? Task.FromException<int>(new TestDatabaseException("command failure"))
: Task.FromResult(1)
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(() =>
service.ReplaceItemsAsync(
"00000011",
[Item(0, "Alpha", "000001"), Item(1, "Beta", "000002")]));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(2, plan.Commands.Count);
Assert.Equal([1, 1], plan.Commands.Select(static command => command.ExecuteCount));
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task CommandTimeout_AttemptsRollbackButStillQuarantinesOutcome()
{
var plan = new MutationPlan
{
ExecuteAsync = (_, _) => Task.FromException<int>(new TimeoutException("timeout"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.DeleteAsync("00000011"));
Assert.True(exception.OutcomeUnknown);
Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Equal(1, factory.CreateCount);
Assert.Single(plan.Commands);
Assert.Equal(1, plan.Commands[0].ExecuteCount);
}
[Fact]
public async Task CommandCancellation_AttemptsRollbackButStillQuarantinesOutcome()
{
var plan = new MutationPlan
{
ExecuteAsync = (_, token) =>
Task.FromException<int>(new OperationCanceledException(token))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.DeleteAsync("00000011"));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Equal(1, factory.CreateCount);
}
[Fact]
public async Task OverallOperationTimeout_CancelsCommandOnceAndDoesNotRetry()
{
var plan = new MutationPlan
{
ExecuteAsync = (_, token) => WaitForCancellationAsync(token)
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory, operationTimeoutSeconds: 1));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.DeleteAsync("00000011"));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, factory.CreateCount);
Assert.Single(plan.Commands);
Assert.Equal(1, plan.Commands[0].ExecuteCount);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
}
[Fact]
public async Task RollbackFailure_ChangesOrdinaryCommandFailureToOutcomeUnknown()
{
var plan = new MutationPlan
{
ExecuteAsync = (_, _) =>
Task.FromException<int>(new TestDatabaseException("command failure")),
RollbackAsync = _ =>
Task.FromException(new TestDatabaseException("rollback failure"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.DeleteAsync("00000011"));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Equal(1, factory.CreateCount);
}
[Fact]
public async Task CommitFailure_IsOutcomeUnknownAndNeverIssuesRollbackOrRetry()
{
var plan = new MutationPlan
{
CommitAsync = _ =>
Task.FromException(new TestDatabaseException("commit response lost"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000011", "Morning"));
Assert.True(exception.OutcomeUnknown);
Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(1, factory.CreateCount);
Assert.Single(plan.Commands);
Assert.Equal(1, plan.Commands[0].ExecuteCount);
}
[Fact]
public async Task CommitCancellation_IsOutcomeUnknownAndNeverIssuesRollback()
{
var plan = new MutationPlan
{
CommitAsync = token =>
Task.FromException(new OperationCanceledException(token))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000011", "Morning"));
Assert.True(exception.OutcomeUnknown);
Assert.Equal(1, plan.CommitCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(1, factory.CreateCount);
}
[Fact]
public async Task OpenFailure_HasKnownNonCommitAndCreatesNoTransaction()
{
var plan = new MutationPlan
{
OpenAsync = _ => Task.FromException(new TestDatabaseException("open failure"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000011", "Morning"));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(1, plan.OpenCount);
Assert.Equal(0, plan.BeginCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Empty(plan.Commands);
Assert.True(plan.ConnectionDisposed);
}
[Fact]
public async Task BeginFailure_HasKnownNonCommitAndExecutesNoCommand()
{
var plan = new MutationPlan
{
BeginAsync = (_, _) =>
Task.FromException<DbTransaction>(new TestDatabaseException("begin failure"))
};
var factory = new MutationConnectionFactory(plan);
var service = CreateService(CreateExecutor(factory));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000011", "Morning"));
Assert.False(exception.OutcomeUnknown);
Assert.Equal(1, factory.CreateCount);
Assert.Equal(1, plan.BeginCount);
Assert.Equal(0, plan.RollbackCount);
Assert.Equal(0, plan.CommitCount);
Assert.Empty(plan.Commands);
}
[Fact]
public void Constructor_RejectsInvalidCommandTimeout()
{
var factory = new MutationConnectionFactory(
new MutationPlan(),
commandTimeoutSeconds: 0);
Assert.Throws<DatabaseConfigurationException>(() => CreateExecutor(factory));
Assert.Equal(0, factory.CreateCount);
}
private static OracleNamedPlaylistMutationExecutor CreateExecutor(
MutationConnectionFactory factory,
int operationTimeoutSeconds = 30) =>
new(
factory,
new DatabaseResilienceOptions
{
OperationTimeoutSeconds = operationTimeoutSeconds,
MaximumRetryCount = 5,
InitialRetryDelayMilliseconds = 30_000
},
new TransientDatabaseErrorDetector(),
command => Assert.IsType<MutationFakeCommand>(command).BindByName = true);
private static LegacyNamedPlaylistPersistenceService CreateService(
INamedPlaylistMutationExecutor mutations) =>
new(new ForbiddenQueryExecutor(), mutations);
private static NamedPlaylistStoredItem Item(
int index,
string subject,
string dataCode) =>
new(
index,
true,
new LegacySceneSelection("KOSPI", subject, "1열판기본", "현재가", dataCode),
new NamedPlaylistPageState(1, 1));
private static void AssertInsert(
MutationFakeCommand command,
int expectedIndex,
string expectedSubject,
string expectedDataCode)
{
Assert.Equal(NamedPlaylistDbCommandKind.InsertItem, CommandKind(command));
Assert.StartsWith(
"INSERT INTO PLAY_LIST(",
command.CommandText.TrimStart(),
StringComparison.Ordinal);
Assert.Equal(11, command.ParametersSnapshot.Count);
AssertParameter(
command.ParametersSnapshot[0],
"program_code",
"00000011",
DbType.String);
AssertParameter(
command.ParametersSnapshot[1],
"list_text",
$"1^KOSPI^{expectedSubject}^1열판기본^현재가^1/1^{expectedDataCode}",
DbType.String);
foreach (var parameter in command.ParametersSnapshot.Skip(2).Take(8))
{
Assert.Same(DBNull.Value, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
Assert.Equal(ParameterDirection.Input, parameter.Direction);
}
AssertParameter(
command.ParametersSnapshot[10],
"item_index",
expectedIndex,
DbType.Int32);
}
private static NamedPlaylistDbCommandKind CommandKind(MutationFakeCommand command)
{
var sql = command.CommandText.TrimStart();
if (sql.StartsWith("INSERT INTO DC_LIST(", StringComparison.Ordinal))
{
return NamedPlaylistDbCommandKind.InsertDefinition;
}
if (sql.StartsWith("DELETE FROM PLAY_LIST", StringComparison.Ordinal))
{
return NamedPlaylistDbCommandKind.DeleteItems;
}
if (sql.StartsWith("INSERT INTO PLAY_LIST(", StringComparison.Ordinal))
{
return NamedPlaylistDbCommandKind.InsertItem;
}
if (sql.StartsWith("DELETE FROM DC_LIST", StringComparison.Ordinal))
{
return NamedPlaylistDbCommandKind.DeleteDefinition;
}
throw new InvalidOperationException("Unexpected command SQL.");
}
private static void AssertParameter(
DbParameter parameter,
string expectedName,
object expectedValue,
DbType expectedType)
{
Assert.Equal(expectedName, parameter.ParameterName);
Assert.Equal(expectedValue, parameter.Value);
Assert.Equal(expectedType, parameter.DbType);
Assert.Equal(ParameterDirection.Input, parameter.Direction);
}
private static Task<int> WaitForCancellationAsync(CancellationToken token)
{
var completion = new TaskCompletionSource<int>(
TaskCreationOptions.RunContinuationsAsynchronously);
token.Register(() => completion.TrySetCanceled(token));
return completion.Task;
}
private sealed class ForbiddenQueryExecutor : IDataQueryExecutor
{
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("A mutation test must not run a read query.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("A mutation test must not run a read query.");
}
private sealed class MutationConnectionFactory(
MutationPlan plan,
int commandTimeoutSeconds = 15) : IDatabaseConnectionFactory
{
public int CreateCount { get; private set; }
public List<DataSourceKind> Sources { get; } = [];
public DbConnection Create(DataSourceKind source)
{
CreateCount++;
Sources.Add(source);
return new MutationFakeConnection(plan);
}
public int GetCommandTimeoutSeconds(DataSourceKind source)
{
Assert.Equal(DataSourceKind.Oracle, source);
return commandTimeoutSeconds;
}
}
private sealed class MutationPlan
{
public Func<CancellationToken, Task> OpenAsync { get; init; } =
_ => Task.CompletedTask;
public Func<IsolationLevel, CancellationToken, Task<DbTransaction>>? BeginAsync
{
get;
init;
}
public Func<int, CancellationToken, Task<int>> ExecuteAsync { get; init; } =
(_, _) => Task.FromResult(1);
public Func<CancellationToken, Task> CommitAsync { get; init; } =
_ => Task.CompletedTask;
public Func<CancellationToken, Task> RollbackAsync { get; init; } =
_ => Task.CompletedTask;
public int OpenCount { get; set; }
public int BeginCount { get; set; }
public int CommitCount { get; set; }
public int RollbackCount { get; set; }
public int SynchronousBeginCount { get; set; }
public int SynchronousCommitCount { get; set; }
public int SynchronousRollbackCount { get; set; }
public IsolationLevel? ObservedIsolationLevel { get; set; }
public bool ConnectionDisposed { get; set; }
public bool TransactionDisposed { get; set; }
public MutationFakeTransaction? Transaction { get; set; }
public List<MutationFakeCommand> Commands { get; } = [];
}
private sealed class MutationFakeConnection(MutationPlan plan) : DbConnection
{
private ConnectionState _state = ConnectionState.Closed;
[AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "MutationFake";
public override string DataSource => "MutationFake";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName)
{
}
public override void Close() => _state = ConnectionState.Closed;
public override void Open() =>
throw new InvalidOperationException("Synchronous open is forbidden.");
public override async Task OpenAsync(CancellationToken cancellationToken)
{
plan.OpenCount++;
await plan.OpenAsync(cancellationToken);
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
{
plan.SynchronousBeginCount++;
throw new InvalidOperationException("Synchronous begin is forbidden.");
}
protected override async ValueTask<DbTransaction> BeginDbTransactionAsync(
IsolationLevel isolationLevel,
CancellationToken cancellationToken)
{
plan.BeginCount++;
plan.ObservedIsolationLevel = isolationLevel;
if (plan.BeginAsync is not null)
{
return await plan.BeginAsync(isolationLevel, cancellationToken);
}
var transaction = new MutationFakeTransaction(this, plan, isolationLevel);
plan.Transaction = transaction;
return transaction;
}
protected override DbCommand CreateDbCommand()
{
var command = new MutationFakeCommand(this, plan, plan.Commands.Count);
plan.Commands.Add(command);
return command;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.ConnectionDisposed = true;
_state = ConnectionState.Closed;
}
base.Dispose(disposing);
}
public override ValueTask DisposeAsync()
{
plan.ConnectionDisposed = true;
_state = ConnectionState.Closed;
return ValueTask.CompletedTask;
}
}
private sealed class MutationFakeTransaction(
DbConnection connection,
MutationPlan plan,
IsolationLevel isolationLevel) : DbTransaction
{
public override IsolationLevel IsolationLevel => isolationLevel;
protected override DbConnection DbConnection => connection;
public override void Commit()
{
plan.SynchronousCommitCount++;
throw new InvalidOperationException("Synchronous commit is forbidden.");
}
public override async Task CommitAsync(CancellationToken cancellationToken = default)
{
plan.CommitCount++;
await plan.CommitAsync(cancellationToken);
}
public override void Rollback()
{
plan.SynchronousRollbackCount++;
throw new InvalidOperationException("Synchronous rollback is forbidden.");
}
public override async Task RollbackAsync(CancellationToken cancellationToken = default)
{
plan.RollbackCount++;
await plan.RollbackAsync(cancellationToken);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.TransactionDisposed = true;
}
base.Dispose(disposing);
}
public override ValueTask DisposeAsync()
{
plan.TransactionDisposed = true;
return ValueTask.CompletedTask;
}
}
private sealed class MutationFakeCommand(
DbConnection connection,
MutationPlan plan,
int commandIndex) : DbCommand
{
private readonly MutationFakeParameterCollection _parameters = new();
public bool BindByName { get; set; }
public int ExecuteCount { get; private set; }
public int SynchronousExecuteCount { get; private set; }
public bool WasDisposed { get; private set; }
public DbTransaction? ObservedTransaction => DbTransaction;
public IReadOnlyList<DbParameter> ParametersSnapshot =>
_parameters.Cast<DbParameter>().ToArray();
[AllowNull]
public override string CommandText { get; set; } = string.Empty;
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; } = connection;
protected override DbParameterCollection DbParameterCollection => _parameters;
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel()
{
}
public override int ExecuteNonQuery()
{
SynchronousExecuteCount++;
throw new InvalidOperationException("Synchronous execution is forbidden.");
}
public override async Task<int> ExecuteNonQueryAsync(
CancellationToken cancellationToken)
{
ExecuteCount++;
return await plan.ExecuteAsync(commandIndex, cancellationToken);
}
public override object? ExecuteScalar() => throw new NotSupportedException();
public override void Prepare()
{
}
protected override DbParameter CreateDbParameter() => new MutationFakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
{
WasDisposed = true;
}
base.Dispose(disposing);
}
public override ValueTask DisposeAsync()
{
WasDisposed = true;
return ValueTask.CompletedTask;
}
}
private sealed class MutationFakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
[AllowNull]
public override string ParameterName { get; set; } = string.Empty;
public override int Size { get; set; }
[AllowNull]
public override string SourceColumn { get; set; } = string.Empty;
public override bool SourceColumnNullMapping { get; set; }
public override object? Value { get; set; }
public override void ResetDbType() => DbType = default;
}
private sealed class MutationFakeParameterCollection : DbParameterCollection
{
private readonly List<DbParameter> _parameters = [];
public override int Count => _parameters.Count;
public override object SyncRoot => ((ICollection)_parameters).SyncRoot;
public override int Add(object value)
{
_parameters.Add(RequireParameter(value));
return _parameters.Count - 1;
}
public override void AddRange(Array values)
{
foreach (var value in values)
{
Add(value ?? throw new ArgumentNullException(nameof(values)));
}
}
public override void Clear() => _parameters.Clear();
public override bool Contains(object value) =>
value is DbParameter parameter && _parameters.Contains(parameter);
public override bool Contains(string value) => IndexOf(value) >= 0;
public override void CopyTo(Array array, int index) =>
((ICollection)_parameters).CopyTo(array, index);
public override IEnumerator GetEnumerator() => _parameters.GetEnumerator();
public override int IndexOf(object value) =>
value is DbParameter parameter ? _parameters.IndexOf(parameter) : -1;
public override int IndexOf(string parameterName) =>
_parameters.FindIndex(parameter => string.Equals(
parameter.ParameterName,
parameterName,
StringComparison.OrdinalIgnoreCase));
public override void Insert(int index, object value) =>
_parameters.Insert(index, RequireParameter(value));
public override void Remove(object value)
{
if (value is DbParameter parameter)
{
_parameters.Remove(parameter);
}
}
public override void RemoveAt(int index) => _parameters.RemoveAt(index);
public override void RemoveAt(string parameterName)
{
var index = IndexOf(parameterName);
if (index >= 0)
{
RemoveAt(index);
}
}
protected override DbParameter GetParameter(int index) => _parameters[index];
protected override DbParameter GetParameter(string parameterName)
{
var index = IndexOf(parameterName);
return index >= 0 ? _parameters[index] : throw new IndexOutOfRangeException();
}
protected override void SetParameter(int index, DbParameter value) =>
_parameters[index] = value;
protected override void SetParameter(string parameterName, DbParameter value)
{
var index = IndexOf(parameterName);
if (index >= 0)
{
_parameters[index] = value;
}
else
{
_parameters.Add(value);
}
}
private static DbParameter RequireParameter(object value) =>
value as DbParameter ??
throw new ArgumentException("A DbParameter is required.", nameof(value));
}
}

View File

@@ -115,7 +115,7 @@ public sealed class S5025TrustedManualFileDataSourceTests
using var directory = new TemporaryDirectory();
await File.WriteAllBytesAsync(
Path.Combine(directory.Path, "개인.dat"),
Cp949.GetBytes(content));
Cp949.GetBytes(content + "\r\n\r\n"));
var source = new S5025TrustedManualFileDataSource(directory.Path);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
@@ -123,15 +123,16 @@ public sealed class S5025TrustedManualFileDataSourceTests
}
[Fact]
public async Task ReadAsync_accepts_one_trailing_line_terminator()
public async Task ReadAsync_rejects_missing_empty_terminal_record()
{
using var directory = new TemporaryDirectory();
WriteValidFile(Path.Combine(directory.Path, "개인.dat"), trailingNewLine: true);
await File.WriteAllBytesAsync(
Path.Combine(directory.Path, "개인.dat"),
Cp949.GetBytes(ValidContent() + "\r\n"));
var source = new S5025TrustedManualFileDataSource(directory.Path);
var rows = await source.ReadAsync(S5025ManualAudience.Individual);
Assert.Equal(5, rows.Count);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
source.ReadAsync(S5025ManualAudience.Individual));
}
[Fact]
@@ -244,9 +245,9 @@ public sealed class S5025TrustedManualFileDataSourceTests
source.ReadAsync(S5025ManualAudience.Individual, cancellation.Token));
}
private static void WriteValidFile(string path, bool trailingNewLine = false)
private static void WriteValidFile(string path)
{
var content = ValidContent() + (trailingNewLine ? "\r\n" : string.Empty);
var content = ValidContent() + "\r\n\r\n";
File.WriteAllBytes(path, Cp949.GetBytes(content));
}

View File

@@ -0,0 +1,184 @@
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class S5025TrustedManualFileStoreTests
{
[Theory]
[InlineData(S5025ManualAudience.Individual, "개인.dat")]
[InlineData(S5025ManualAudience.Foreign, "외국인.dat")]
[InlineData(S5025ManualAudience.Institution, "기관.dat")]
public async Task WriteAsync_round_trips_the_closed_audience_file(
S5025ManualAudience audience,
string expectedFileName)
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await store.WriteAsync(audience, Rows());
Assert.True(File.Exists(Path.Combine(directory.Path, expectedFileName)));
var actual = await store.ReadAsync(audience);
Assert.Equal(Rows(), actual);
Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp"));
}
[Fact]
public async Task WriteAsync_preserves_the_legacy_cp949_terminal_record()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await store.WriteAsync(S5025ManualAudience.Individual, Rows());
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp949 = Encoding.GetEncoding(949);
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory.Path, "개인.dat"));
var text = cp949.GetString(bytes);
Assert.EndsWith("\r\n\r\n", text, StringComparison.Ordinal);
Assert.Equal(5, text.Split("\r\n", StringSplitOptions.None).Length - 2);
}
[Theory]
[InlineData(0)]
[InlineData(4)]
[InlineData(6)]
public async Task WriteAsync_requires_exactly_five_rows(int rowCount)
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync(
S5025ManualAudience.Individual,
Rows().Take(rowCount).Concat(Enumerable.Repeat(Rows()[0], Math.Max(0, rowCount - 5)))
.Take(rowCount)
.ToArray()));
}
[Theory]
[InlineData("bad^name")]
[InlineData("bad\nname")]
public async Task WriteAsync_rejects_delimiter_and_control_characters(string value)
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var rows = Rows().ToArray();
rows[0] = rows[0] with { LeftName = value };
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync(S5025ManualAudience.Individual, rows));
}
[Fact]
public async Task WriteAsync_rejects_text_that_cp949_cannot_encode()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var rows = Rows().ToArray();
rows[0] = rows[0] with { LeftName = "😀" };
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync(S5025ManualAudience.Individual, rows));
}
[Fact]
public async Task WriteAsync_honors_pre_cancelled_token_without_creating_a_file()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
store.WriteAsync(S5025ManualAudience.Individual, Rows(), cancellation.Token));
Assert.Empty(Directory.EnumerateFiles(directory.Path));
}
[Fact]
public async Task WriteAsync_rejects_unknown_audience_before_creating_a_file()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync((S5025ManualAudience)99, Rows()));
Assert.Empty(Directory.EnumerateFiles(directory.Path));
}
[Fact]
public async Task WriteAsync_rejects_an_existing_reparse_destination()
{
using var directory = new TemporaryDirectory();
using var outside = new TemporaryDirectory();
var target = Path.Combine(outside.Path, "outside.dat");
await File.WriteAllTextAsync(target, "outside");
var link = Path.Combine(directory.Path, "개인.dat");
if (!TryCreateFileSymbolicLink(link, target))
{
return;
}
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<S5025TrustedManualDataException>(() =>
store.WriteAsync(S5025ManualAudience.Individual, Rows()));
Assert.Equal("outside", await File.ReadAllTextAsync(target));
}
private static IReadOnlyList<S5025TrustedManualRow> Rows() =>
[
new("삼성전자", "1,234", "SK하이닉스", "-987"),
new("현대차", "2", "기아", "-2"),
new("NAVER", "3", "카카오", "-3"),
new("LG화학", "4", "포스코", "-4"),
new("한화", "5", "셀트리온", "-5")
];
private static S5025TrustedManualFileStore CreateStore(string directory)
{
TrustedManualDirectory.PreparePrivateWritableDirectory(directory);
return new S5025TrustedManualFileStore(directory);
}
private static bool TryCreateFileSymbolicLink(string link, string target)
{
try
{
File.CreateSymbolicLink(link, target);
return true;
}
catch (Exception exception) when (exception is
UnauthorizedAccessException or IOException or PlatformNotSupportedException)
{
return false;
}
}
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,127 @@
using System.Security.AccessControl;
using System.Security.Principal;
using System.Runtime.Versioning;
using MBN_STOCK_WEBVIEW.Infrastructure;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class TrustedManualDirectoryTests
{
[Fact]
public void Prepare_creates_nested_private_directory_with_protected_acl()
{
using var parent = new TemporaryDirectory();
var target = Path.Combine(parent.Path, "operator", "private");
var prepared = TrustedManualDirectory.PreparePrivateWritableDirectory(target);
Assert.Equal(Path.GetFullPath(target), prepared);
Assert.True(Directory.Exists(prepared));
if (OperatingSystem.IsWindows())
{
AssertPrivateAcl(prepared);
}
}
[SupportedOSPlatform("windows")]
private static void AssertPrivateAcl(string directory)
{
var security = new DirectoryInfo(directory).GetAccessControl(
AccessControlSections.Access);
Assert.True(security.AreAccessRulesProtected);
Assert.DoesNotContain(
security.GetAccessRules(true, true, typeof(SecurityIdentifier))
.Cast<FileSystemAccessRule>(),
rule => rule.IsInherited);
}
[Fact]
public void Writable_store_rejects_acl_expanded_to_world_readable()
{
if (!OperatingSystem.IsWindows())
{
return;
}
using var directory = new TemporaryDirectory();
TrustedManualDirectory.PreparePrivateWritableDirectory(directory.Path);
var info = new DirectoryInfo(directory.Path);
var security = info.GetAccessControl();
security.AddAccessRule(new FileSystemAccessRule(
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
FileSystemRights.Read,
AccessControlType.Allow));
info.SetAccessControl(security);
Assert.Throws<S5025TrustedManualDataException>(() =>
new S5025TrustedManualFileStore(directory.Path));
}
[Fact]
public void Writable_lease_pins_directory_against_rename_race()
{
if (!OperatingSystem.IsWindows())
{
return;
}
using var parent = new TemporaryDirectory();
var directory = Path.Combine(parent.Path, "operator");
TrustedManualDirectory.PreparePrivateWritableDirectory(directory);
using (var store = new S5025TrustedManualFileStore(directory))
{
Assert.ThrowsAny<IOException>(() =>
Directory.Move(directory, Path.Combine(parent.Path, "redirected")));
}
Directory.Move(directory, Path.Combine(parent.Path, "released"));
}
[Fact]
public void Prepare_rejects_reparse_ancestor_before_creating_target()
{
using var parent = new TemporaryDirectory();
using var outside = new TemporaryDirectory();
var link = Path.Combine(parent.Path, "linked");
try
{
Directory.CreateSymbolicLink(link, outside.Path);
}
catch (Exception exception) when (exception is
UnauthorizedAccessException or IOException or PlatformNotSupportedException)
{
return;
}
Assert.Throws<S5025TrustedManualDataException>(() =>
TrustedManualDirectory.PreparePrivateWritableDirectory(
Path.Combine(link, "must-not-be-created")));
Assert.False(Directory.Exists(Path.Combine(outside.Path, "must-not-be-created")));
}
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,193 @@
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class ViTrustedManualFileStoreTests
{
[Fact]
public async Task ReadAsync_imports_the_original_nine_column_cp949_shape()
{
using var directory = new TemporaryDirectory();
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp949 = Encoding.GetEncoding(949);
await File.WriteAllBytesAsync(
Path.Combine(directory.Path, "VI발동.dat"),
cp949.GetBytes(
"PKR7005930003^삼성전자^^^^^^^\r\n" +
"PKR7016360000^삼성증권^^^^^^^\r\n"));
using var store = CreateStore(directory.Path);
var actual = await store.ReadAsync();
Assert.Equal(
[
new ViTrustedManualItem("PKR7005930003", "삼성전자"),
new ViTrustedManualItem("PKR7016360000", "삼성증권")
],
actual);
}
[Fact]
public async Task WriteAsync_round_trips_order_duplicates_and_version()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
ViTrustedManualItem[] expected =
[
new("005930", "삼성전자"),
new("016360", "삼성증권"),
new("005930", "삼성전자")
];
var write = await store.WriteAsync(expected);
Assert.True(write.Persisted);
Assert.Matches("^[a-f0-9]{64}$", write.Snapshot.RowVersion);
Assert.Equal(expected, await store.ReadAsync());
Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp"));
}
[Fact]
public async Task Empty_list_is_an_original_compatible_no_op()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var firstNoOp = await store.WriteAsync(Array.Empty<ViTrustedManualItem>());
Assert.False(firstNoOp.Persisted);
Assert.False(File.Exists(Path.Combine(directory.Path, "VI발동.dat")));
Assert.Empty(await store.ReadAsync());
var expected = new[] { new ViTrustedManualItem("005930", "삼성전자") };
var written = await store.WriteAsync(expected);
var secondNoOp = await store.WriteAsync(Array.Empty<ViTrustedManualItem>());
Assert.False(secondNoOp.Persisted);
Assert.Equal(written.Snapshot.RowVersion, secondNoOp.Snapshot.RowVersion);
Assert.Equal(expected, await store.ReadAsync());
}
[Fact]
public async Task Missing_file_is_an_empty_versioned_first_run_list()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var snapshot = await store.ReadSnapshotAsync();
Assert.Empty(snapshot.Items);
Assert.Equal(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
snapshot.RowVersion);
}
[Fact]
public async Task ReadAsync_rejects_nonempty_legacy_extra_columns()
{
using var directory = new TemporaryDirectory();
await File.WriteAllTextAsync(
Path.Combine(directory.Path, "VI발동.dat"),
"005930^삼성전자^unexpected^^^^^^\r\n");
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<ViTrustedManualDataException>(() => store.ReadAsync());
}
[Theory]
[InlineData("", "삼성전자")]
[InlineData("005930", "")]
[InlineData("005^930", "삼성전자")]
[InlineData("005930", "삼성\n전자")]
[InlineData(" 005930", "삼성전자")]
[InlineData("005930", "삼성전자 ")]
[InlineData("005930", "삼성,전자")]
[InlineData("005930", "😀")]
public async Task WriteAsync_rejects_invalid_or_non_cp949_values(string code, string name)
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
await Assert.ThrowsAsync<ViTrustedManualDataException>(() =>
store.WriteAsync([new ViTrustedManualItem(code, name)]));
}
[Fact]
public async Task WriteAsync_caps_the_original_five_row_scene_at_twenty_pages()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var items = Enumerable.Range(1, ViTrustedManualFileStore.MaximumItemCount + 1)
.Select(index => new ViTrustedManualItem(index.ToString("D6"), $"종목{index}"))
.ToArray();
await Assert.ThrowsAsync<ViTrustedManualDataException>(() => store.WriteAsync(items));
}
[Fact]
public async Task WriteAsync_honors_pre_cancelled_token()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
store.WriteAsync(
[new ViTrustedManualItem("005930", "삼성전자")],
cancellation.Token));
Assert.Empty(Directory.EnumerateFiles(directory.Path));
}
[Fact]
public async Task Canonical_snapshot_version_covers_all_twenty_pages()
{
using var directory = new TemporaryDirectory();
using var store = CreateStore(directory.Path);
var items = Enumerable.Range(1, ViTrustedManualFileStore.MaximumItemCount)
.Select(index => new ViTrustedManualItem(
$"P{index:D6}",
$"현실적인긴종목명테스트{index}"))
.ToArray();
var written = await store.WriteAsync(items);
var snapshot = await store.ReadSnapshotAsync();
Assert.True(written.Persisted);
Assert.Equal(100, snapshot.Items.Count);
Assert.Equal(written.Snapshot.RowVersion, snapshot.RowVersion);
Assert.Matches("^[a-f0-9]{64}$", snapshot.RowVersion);
}
private static ViTrustedManualFileStore CreateStore(string directory)
{
TrustedManualDirectory.PreparePrivateWritableDirectory(directory);
return new ViTrustedManualFileStore(directory);
}
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,119 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/candle-options-workflow.js");
const stock = require("../../Web/operator-workflow.js");
const fixed = require("../../Web/legacy-fixed-workflow.js");
const industry = require("../../Web/industry-workflow.js");
const overseas = require("../../Web/overseas-workflow.js");
const tradingHalt = require("../../Web/trading-halt-workflow.js");
const adapters = { stock, fixed, industry, overseas, tradingHalt };
const options = id => ({ id, fadeDuration: 6, ma5: false, ma20: false });
const domesticStock = Object.freeze({
market: "kospi",
stockName: "삼성전자",
displayName: "삼성전자",
stockCode: "005930",
isNxt: false
});
const industryItem = Object.freeze({ market: "kospi", name: "전기전자", code: "013" });
const overseasStock = Object.freeze({
inputName: "NVIDIA",
symbol: "NAS@NVDA",
nationCode: "US",
fdtc: "1"
});
const haltedStock = Object.freeze({
market: "kospi",
stockCode: "005930",
displayName: "삼성전자"
});
function entries() {
const fixedAction = fixed.actions.find(value => value.builderKey === "s8010" && value.available);
const industryCut = industry.cuts.find(value => value.kind === "candle");
return [
stock.createStockPlaylistEntry(domesticStock, "candle-120d", options("stock-candle")),
fixed.createFixedPlaylistEntry(fixedAction.id, options("fixed-candle")),
industry.createIndustryPlaylistEntry("kospi", industryCut.id, {
...options("industry-candle"),
selected: industryItem
}),
overseas.createOverseasStockPlaylistEntry(
overseasStock,
"stock-candle-120d",
options("overseas-candle")),
tradingHalt.createTradingHaltPlaylistEntry(
haltedStock,
"candle-120d",
options("halt-candle"))
];
}
test("global flags map to the exact closed candle graphic type", () => {
assert.equal(workflow.expectedGraphicType(false, false), "");
assert.equal(workflow.expectedGraphicType(true, false), "MA5");
assert.equal(workflow.expectedGraphicType(false, true), "MA20");
assert.equal(workflow.expectedGraphicType(true, true), "MA5,MA20");
assert.throws(() => workflow.expectedGraphicType("yes", true));
});
test("all five original candle entry sources can be recreated with global flags", () => {
for (const entry of entries()) {
const recreated = workflow.recreateCandleEntry(entry, true, true, adapters);
assert.ok(recreated, entry.operator.source);
assert.equal(recreated.selection.graphicType, "MA5,MA20");
assert.deepEqual(recreated.operator.movingAverages, { ma5: true, ma20: true });
assert.equal(recreated.enabled, entry.enabled);
}
});
test("playlist synchronization changes only s8010 entries and preserves order", () => {
const candles = entries();
const nonCandle = stock.createStockPlaylistEntry(domesticStock, "basic-current", {
id: "non-candle",
fadeDuration: 6,
ma5: false,
ma20: false
});
const input = [candles[0], nonCandle, ...candles.slice(1)];
const result = workflow.synchronizePlaylist(input, true, false, adapters);
assert.equal(result.valid, true);
assert.equal(result.changed, true);
assert.equal(result.playlist[1], nonCandle);
assert.deepEqual(result.playlist.map(value => value.id), input.map(value => value.id));
for (const entry of result.playlist.filter(value => value.builderKey === "s8010")) {
assert.equal(entry.selection.graphicType, "MA5");
}
});
test("an untrusted s8010 entry blocks synchronization instead of being rewritten", () => {
const untrusted = {
id: "unknown-candle",
builderKey: "s8010",
code: "8051",
enabled: true,
fadeDuration: 6,
selection: { graphicType: "" },
operator: { source: "unknown" }
};
const result = workflow.synchronizePlaylist([untrusted], true, true, adapters);
assert.equal(result.valid, false);
assert.equal(result.changed, false);
assert.deepEqual(result.invalidIds, ["unknown-candle"]);
assert.equal(result.playlist[0], untrusted);
});
test("a second synchronization with identical flags is stable", () => {
const first = workflow.synchronizePlaylist(entries(), true, true, adapters);
const second = workflow.synchronizePlaylist([...first.playlist], true, true, adapters);
assert.equal(first.valid, true);
assert.equal(second.valid, true);
assert.equal(second.changed, false);
second.playlist.forEach((entry, index) => assert.equal(entry, first.playlist[index]));
});

View File

@@ -0,0 +1,266 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/comparison-workflow.js");
const kospi = workflow.getMarketTarget("Kospi");
const kospi200 = workflow.getMarketTarget("코스피200 지수");
const futures = workflow.getMarketTarget("futures");
const wonDollar = workflow.getMarketTarget("WonDollar");
const samsung = Object.freeze({
kind: "krx-stock", market: "kospi", stockName: "삼성전자",
stockCode: "005930", displayName: "삼성전자"
});
const skHynix = Object.freeze({
kind: "krx-stock", market: "kospi", stockName: "SK하이닉스",
stockCode: "000660", displayName: "SK하이닉스"
});
const nxtSamsung = Object.freeze({
kind: "nxt-stock", market: "kospi", stockName: "삼성전자",
stockCode: "005930", displayName: "삼성전자(NXT)"
});
const apple = Object.freeze({
kind: "world-stock", inputName: "Apple Inc", displayName: "애플",
symbol: "NAS@AAPL", nation: "US"
});
test("comparison inventory pins the exact runtime asset, 24 targets and nine actions", () => {
assert.deepEqual(workflow.runtimeAsset, {
relativePath: "bin/Debug/Res/종목비교.ini",
sha256: "980F74C000EF6A34B85FDAAF6D2756269483637186C91A93548F35C5CFC1B7E4"
});
assert.equal(workflow.marketTargets.length, 24);
assert.equal(new Set(workflow.marketTargets.map(value => value.target)).size, 24);
assert.deepEqual(workflow.marketTargets.map(value => [value.displayName, value.target]), [
["코스피 지수", "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"]
]);
assert.deepEqual(workflow.actions.map(value => value.label), [
"2열판 예상체결", "2열판", "종목별 비교분석_캔들 그래프", "종목별 비교분석_라인 그래프",
"5일", "1개월", "3개월", "6개월", "12개월"
]);
assert.deepEqual(workflow.yieldActionIds, [
"return-5d", "return-1m", "return-3m", "return-6m", "return-12m"
]);
});
test("typed market, KRX, NXT and world targets normalize without heuristic aliases", () => {
assert.equal(workflow.getMarketTarget("코스피200 지수"), kospi200);
assert.equal(workflow.getMarketTarget("sp500").target, "Sp500");
assert.deepEqual(workflow.normalizeTarget({
kind: "krx-stock", market: "KOSPI", name: " 삼성전자 ", code: "005930"
}), samsung);
assert.deepEqual(workflow.normalizeTarget({
kind: "nxt-stock", market: "KOSPI", name: "삼성전자", code: "005930"
}), nxtSamsung);
assert.deepEqual(workflow.normalizeTarget(apple), apple);
assert.equal(workflow.subjectTokenForTarget(kospi200), "Kospi200");
assert.equal(workflow.subjectTokenForTarget(nxtSamsung), "삼성전자(NXT)");
assert.equal(workflow.subjectTokenForTarget(apple), "Apple Inc");
assert.equal(workflow.targetKey(nxtSamsung), "nxt:kospi:005930");
assert.equal(workflow.normalizeTarget({ ...kospi, displayName: "코스피" }), null);
assert.equal(workflow.normalizeTarget({ ...samsung, stockName: "삼성,전자" }), null);
assert.equal(workflow.normalizeTarget({ ...nxtSamsung, displayName: "삼성전자" }), null);
assert.equal(workflow.normalizeTarget({ ...apple, nation: "JP" }), null);
assert.equal(workflow.normalizeTarget({ ...apple, symbol: "AAPL;DROP" }), null);
});
test("target double-click rotation, swap and clear preserve the original slot rules", () => {
const empty = workflow.clearPair();
assert.deepEqual(empty, [null, null]);
const first = workflow.rotatePair(empty, samsung);
assert.deepEqual(first, [samsung, null]);
const second = workflow.rotatePair(first, skHynix);
assert.deepEqual(second, [skHynix, samsung]);
assert.deepEqual(workflow.swapPair(second), [samsung, skHynix]);
assert.deepEqual(workflow.rotatePair(second, skHynix), [skHynix, skHynix]);
assert.equal(workflow.normalizePair(first), null);
});
test("CURRENT compatibility mirrors supported s8018 and s5032 branches and fails closed", () => {
assert.equal(workflow.isActionAllowed("two-column-current", [kospi, kospi200]), true);
assert.equal(workflow.isActionAllowed("two-column-current", [kospi, samsung]), true);
assert.equal(workflow.isActionAllowed("two-column-current", [kospi, nxtSamsung]), true);
assert.equal(workflow.isActionAllowed("two-column-current", [samsung, nxtSamsung]), true);
assert.equal(workflow.isActionAllowed("two-column-current", [apple, samsung]), true);
assert.equal(workflow.isActionAllowed("two-column-current", [apple, nxtSamsung]), true);
assert.equal(workflow.getCompatibility("two-column-current", [kospi, apple]).reason,
"mixed-market-and-world-is-unsupported");
assert.equal(workflow.getCompatibility("two-column-current", [futures, samsung]).reason,
"futures-requires-a-market-target-counterpart");
assert.equal(workflow.getCompatibility("two-column-current", [futures, futures]).reason,
"two-futures-targets-are-unsupported");
assert.equal(workflow.isActionAllowed("two-column-current", [futures, kospi]), true);
});
test("EXPECTED and graph compatibility expose only combinations the Core can resolve", () => {
assert.equal(workflow.isActionAllowed("two-column-expected", [kospi, samsung]), true);
assert.equal(workflow.isActionAllowed("two-column-expected", [samsung, skHynix]), true);
assert.equal(workflow.isActionAllowed("two-column-expected", [wonDollar, samsung]), false);
assert.equal(workflow.isActionAllowed("two-column-expected", [nxtSamsung, samsung]), false);
assert.equal(workflow.isActionAllowed("two-column-expected", [apple, samsung]), false);
const futuresExpected = workflow.getCompatibility("two-column-expected", [futures, wonDollar]);
assert.equal(futuresExpected.supported, true);
assert.equal(futuresExpected.effectiveMode, "CURRENT");
assert.match(futuresExpected.warning, /current s5032/i);
for (const actionId of ["comparison-candle", "comparison-line", ...workflow.yieldActionIds]) {
assert.equal(workflow.isActionAllowed(actionId, [samsung, skHynix]), true);
assert.equal(workflow.isActionAllowed(actionId, [samsung, nxtSamsung]), false);
assert.equal(workflow.isActionAllowed(actionId, [samsung, apple]), false);
assert.equal(workflow.isActionAllowed(actionId, [kospi, samsung]), false);
}
});
test("two-column builders use canonical market subjects and the correct dynamic alias", () => {
const current = workflow.createComparisonPlaylistEntry("two-column-current", [kospi200, samsung], {
id: "current-pair"
});
assert.equal(current.builderKey, "s8018");
assert.equal(current.code, "8032");
assert.deepEqual(current.aliases, ["8032"]);
assert.deepEqual(current.selection, {
groupCode: "INDEX", subject: "Kospi200,삼성전자",
graphicType: "2열판", subtype: "CURRENT", dataCode: ""
});
const stockPair = workflow.buildComparisonSelection("two-column-current", [nxtSamsung, apple]);
assert.equal(stockPair.builderKey, "s8018");
assert.equal(stockPair.selection.groupCode, "STOCK");
assert.equal(stockPair.selection.subject, "삼성전자(NXT),Apple Inc");
const futuresPair = workflow.createComparisonPlaylistEntry("two-column-expected", [wonDollar, futures], {
id: "futures-pair", fadeDuration: 0
});
assert.equal(futuresPair.builderKey, "s5032");
assert.equal(futuresPair.code, "5032");
assert.equal(futuresPair.selection.subtype, "EXPECTED");
assert.equal(futuresPair.effectiveMode, "CURRENT");
assert.equal(futuresPair.fadeDuration, 0);
});
test("candle, line and all five return actions create explicit selections in legacy order", () => {
const candle = workflow.createComparisonPlaylistEntry("comparison-candle", [samsung, skHynix], {
id: "candle"
});
assert.equal(candle.builderKey, "s5026");
assert.equal(candle.code, "5026");
assert.deepEqual(candle.selection, {
groupCode: "STOCK", subject: "삼성전자,SK하이닉스",
graphicType: "COMPARISON_CANDLE", subtype: "FiveDays", dataCode: ""
});
const line = workflow.createComparisonPlaylistEntry("comparison-line", [samsung, skHynix], {
id: "line"
});
assert.equal(line.builderKey, "s5087");
assert.equal(line.selection.graphicType, "COMPARISON_LINE");
const batch = workflow.createYieldBatchEntries([samsung, skHynix], {
idPrefix: "pair-yield", fadeDuration: 4
});
assert.equal(batch.length, 5);
assert.deepEqual(batch.map(value => value.id), [
"pair-yield-5d", "pair-yield-1m", "pair-yield-3m", "pair-yield-6m", "pair-yield-12m"
]);
assert.deepEqual(batch.map(value => value.selection.subtype), [
"FiveDays", "OneMonth", "ThreeMonths", "SixMonths", "TwelveMonths"
]);
assert.ok(batch.every(value => value.builderKey === "s5029" && value.code === "5029"));
});
test("trusted restore reconstructs selections and rejects stored-entry tampering", () => {
const original = workflow.createComparisonPlaylistEntry("two-column-current", [kospi, nxtSamsung], {
id: "stored", fadeDuration: 7
});
assert.ok(workflow.restoreComparisonPlaylistEntry(original));
assert.equal(workflow.restoreComparisonPlaylistEntry({ ...original, enabled: false }).enabled, false);
assert.equal(workflow.restoreComparisonPlaylistEntry({ ...original, code: "5032" }), null);
assert.equal(workflow.restoreComparisonPlaylistEntry({
...original, selection: { ...original.selection, subject: "Kospi,삼성전자" }
}), null);
assert.equal(workflow.restoreComparisonPlaylistEntry({
...original, operator: { ...original.operator, assetSha256: "0".repeat(64) }
}), null);
assert.equal(workflow.restoreComparisonPlaylistEntry({
...original, operator: { ...original.operator, schemaVersion: 2 }
}), null);
assert.equal(workflow.restoreComparisonPlaylistEntry({
...original,
operator: {
...original.operator,
pair: [{ ...kospi, displayName: "변조" }, nxtSamsung]
}
}), null);
});
test("saved pair list normalization is schema-versioned, bounded and duplicate-safe", () => {
const first = workflow.createPairRecord("first", [samsung, skHynix]);
const second = workflow.createPairRecord("second", [kospi, nxtSamsung]);
assert.ok(first);
assert.equal(workflow.createPairRecord("bad id", [samsung, skHynix]), null);
assert.deepEqual(workflow.normalizePairList({ schemaVersion: 2, pairs: [first] }), {
schemaVersion: 1, pairs: []
});
const normalized = workflow.normalizePairList({
schemaVersion: 1,
pairs: [first, { id: "invalid", pair: [samsung, null] }, first, second]
});
assert.deepEqual(normalized.pairs.map(value => value.id), ["first", "second"]);
assert.ok(Object.isFrozen(normalized));
assert.ok(Object.isFrozen(normalized.pairs));
const appended = workflow.appendPair(normalized, "third", [apple, nxtSamsung]);
assert.deepEqual(appended.pairs.map(value => value.id), ["first", "second", "third"]);
assert.deepEqual(normalized.pairs.map(value => value.id), ["first", "second"]);
assert.throws(() => workflow.appendPair(appended, first), /already exists/i);
});
test("saved pair rows reorder and delete persistently even at the empty-list boundary", () => {
let list = workflow.deleteAllPairs();
list = workflow.appendPair(list, "first", [samsung, skHynix]);
list = workflow.appendPair(list, "second", [kospi, nxtSamsung]);
list = workflow.appendPair(list, "third", [apple, samsung]);
assert.deepEqual(workflow.reorderPairList(list, "first", "up").pairs.map(value => value.id),
["first", "second", "third"]);
list = workflow.reorderPairList(list, "third", -1);
assert.deepEqual(list.pairs.map(value => value.id), ["first", "third", "second"]);
list = workflow.reorderPairList(list, "first", "down");
assert.deepEqual(list.pairs.map(value => value.id), ["third", "first", "second"]);
list = workflow.deletePair(list, "first");
assert.deepEqual(list.pairs.map(value => value.id), ["third", "second"]);
list = workflow.deleteAllPairs();
assert.deepEqual(list, { schemaVersion: 1, pairs: [] });
});
test("unsafe ids, fades, incomplete pairs and unsupported combinations cannot create entries", () => {
assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [samsung, null], {
id: "missing"
}), /two comparison targets/i);
assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [kospi, apple], {
id: "unsupported"
}), /mixed-market-and-world/i);
assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [samsung, skHynix], {
id: "bad id"
}), /safe comparison playlist id/i);
assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [samsung, skHynix], {
id: "bad-fade", fadeDuration: 61
}), /0 through 60/i);
assert.equal(workflow.getCompatibility("unknown", [samsung, skHynix]).reason, "unknown-action");
});

View File

@@ -0,0 +1,310 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/expert-workflow.js");
const expert = Object.freeze({
expertCode: "0001",
expertName: "Analyst A",
source: "oracle"
});
function previewRequest(id = "preview-1", maximumItems) {
return workflow.createExpertPreviewRequest(id, expert, maximumItems);
}
function previewPayload(items, overrides = {}) {
return {
requestId: "preview-1",
selection: { expertCode: "0001", expertName: "Analyst A" },
retrievedAt: "2026-07-11T23:10:00+09:00",
totalRowCount: items.length,
truncated: false,
items,
...overrides
};
}
const recommendations = Object.freeze([
Object.freeze({ playIndex: 0, stockCode: "005930", stockName: "Samsung", buyAmount: 70000 }),
Object.freeze({ playIndex: 2, stockCode: "035720", stockName: "Kakao", buyAmount: 42000 })
]);
test("workflow pins the Oracle UC6 reader and one exact 5074 action", () => {
assert.deepEqual(workflow.bridgeContract, {
searchRequestType: "search-experts",
searchResultType: "expert-search-results",
searchErrorType: "expert-search-error",
previewRequestType: "request-expert-preview",
previewResultType: "expert-preview-results",
previewErrorType: "expert-preview-error",
defaultMaximumResults: 100,
maximumResults: 500,
maximumQueryLength: 64,
defaultMaximumPreviewItems: 100,
maximumPreviewItems: 100
});
assert.equal(workflow.sourceContract.provider, "oracle");
assert.equal(workflow.sourceContract.expertTable, "EXPERT_LIST");
assert.equal(workflow.sourceContract.recommendationTable, "RECOMMEND_LIST");
assert.equal(workflow.sourceContract.pageSize, 5);
assert.equal(workflow.sourceContract.maximumPageCount, 20);
assert.deepEqual(workflow.actions, [{
id: "five-row-current",
label: "전문가 추천 · 5단 표그래프 · 현재가",
builderKey: "s5074",
code: "5074",
aliases: ["5074"],
pageSize: 5
}]);
});
test("expert search requests are exact, bounded and normalize the original blank load", () => {
assert.deepEqual(workflow.createExpertSearchRequest("search-1", " "), {
requestId: "search-1",
query: ""
});
assert.deepEqual(workflow.createExpertSearchRequest("search-2", " Kim ", 25), {
requestId: "search-2",
query: "Kim",
maximumResults: 25
});
assert.throws(() => workflow.createExpertSearchRequest("bad id", ""), /request id/);
assert.throws(() => workflow.createExpertSearchRequest("safe", "bad\u0000query"), /query/);
assert.throws(() => workflow.createExpertSearchRequest("safe", "", 0), /limited/);
assert.throws(() => workflow.createExpertSearchRequest("safe", "", 501), /limited/);
});
test("search responses keep typed code and name and reject ambiguity or reordering", () => {
const request = workflow.createExpertSearchRequest("search-1", "", 5);
const payload = {
requestId: "search-1",
query: "",
retrievedAt: "2026-07-11T23:00:00+09:00",
totalRowCount: 2,
truncated: false,
results: [
{ expertCode: "0001", expertName: "Alpha", source: "oracle" },
{ expertCode: "0002", expertName: "Beta", source: "oracle" }
]
};
const normalized = workflow.normalizeExpertSearchResponse(payload, request);
assert.ok(normalized);
assert.deepEqual(normalized.results[0], {
expertCode: "0001",
expertName: "Alpha",
source: "oracle"
});
assert.ok(Object.isFrozen(normalized.results));
assert.equal(workflow.normalizeExpertSearchResponse({
...payload,
results: [...payload.results].reverse()
}, request), null);
assert.equal(workflow.normalizeExpertSearchResponse({
...payload,
results: [payload.results[0], { ...payload.results[1], expertName: "Alpha" }]
}, request), null);
assert.equal(workflow.normalizeExpertSearchResponse({
...payload,
results: [payload.results[0], { ...payload.results[1], expertCode: "0001" }]
}, request), null);
assert.equal(workflow.normalizeExpertSearchResponse({
...payload,
results: [{ ...payload.results[0], source: "mariaDb" }, payload.results[1]]
}, request), null);
assert.equal(workflow.normalizeExpertSearchResponse({ ...payload, requestId: "stale" }, request), null);
});
test("search errors are exact and correlated to the active request", () => {
const request = workflow.createExpertSearchRequest("search-1", "Kim");
assert.deepEqual(workflow.normalizeExpertSearchError({
requestId: "search-1",
query: "Kim",
message: "Database unavailable"
}, request), {
requestId: "search-1",
query: "Kim",
message: "Database unavailable"
});
assert.equal(workflow.normalizeExpertSearchError({
requestId: "other",
query: "Kim",
message: "Database unavailable"
}, request), null);
assert.equal(workflow.normalizeExpertSearchError({
requestId: "search-1",
query: "Kim",
message: "bad\nmessage"
}, request), null);
});
test("preview requests bind the exact expert code and name", () => {
assert.deepEqual(previewRequest("preview-1", 25), {
requestId: "preview-1",
expertCode: "0001",
expertName: "Analyst A",
maximumItems: 25
});
assert.throws(() => workflow.createExpertPreviewRequest("preview", {
expertCode: "0001",
expertName: "Analyst A",
source: "mariaDb"
}), /Oracle expert/);
assert.throws(() => workflow.createExpertPreviewRequest("preview", {
expertCode: "001",
expertName: "Analyst A",
source: "oracle"
}), /Oracle expert/);
assert.throws(() => previewRequest("preview", 101), /limited/);
});
test("preview responses preserve PLAYINDEX and reject duplicate or unsafe rows", () => {
const request = previewRequest();
const normalized = workflow.normalizeExpertPreviewResponse(
previewPayload(recommendations),
request);
assert.ok(normalized);
assert.deepEqual(normalized.items, recommendations);
assert.ok(Object.isFrozen(normalized.items));
for (const items of [
[recommendations[0], { ...recommendations[1], playIndex: 0 }],
[recommendations[0], { ...recommendations[1], stockCode: "005930" }],
[recommendations[0], { ...recommendations[1], stockName: "Samsung" }],
[recommendations[1], recommendations[0]],
[{ ...recommendations[0], buyAmount: 0 }],
[{ ...recommendations[0], buyAmount: 100.5 }],
[{ ...recommendations[0], stockCode: "../005930" }]
]) {
assert.equal(workflow.normalizeExpertPreviewResponse(previewPayload(items), request), null);
}
assert.equal(workflow.normalizeExpertPreviewResponse(
previewPayload(recommendations, {
selection: { expertCode: "0002", expertName: "Analyst A" }
}), request), null);
});
test("only a native-normalized preview can create the closed PAGED_EXPERT selection", () => {
const raw = previewPayload(recommendations);
assert.equal(workflow.createSelectableExpert(expert, raw), null);
const preview = workflow.normalizeExpertPreviewResponse(raw, previewRequest());
const selected = workflow.createSelectableExpert(expert, preview);
assert.deepEqual(selected, {
expertCode: "0001",
expertName: "Analyst A",
source: "oracle",
itemCount: 2,
previewTruncated: false
});
assert.deepEqual(workflow.buildExpertSelection(selected), {
builderKey: "s5074",
code: "5074",
aliases: ["5074"],
selection: {
groupCode: "PAGED_EXPERT",
subject: "Analyst A",
graphicType: "INPUT_ORDER",
subtype: "CURRENT",
dataCode: "0001"
}
});
assert.deepEqual(workflow.calculatePagePreview(selected), {
itemCount: 2,
accessibleItemCount: 2,
pageSize: 5,
pageCount: 1,
maximumPageCount: 20,
isTruncated: false
});
});
test("the 5 by 20 boundary is explicit and empty experts cannot create a cut", () => {
const hundred = Array.from({ length: 100 }, (_, index) => ({
playIndex: index,
stockCode: String(index + 1).padStart(6, "0"),
stockName: `Stock ${String(index + 1).padStart(3, "0")}`,
buyAmount: 1000 + index
}));
const request = previewRequest("preview-1", 100);
const preview = workflow.normalizeExpertPreviewResponse(
previewPayload(hundred, { truncated: true }),
request);
const selected = workflow.createSelectableExpert(expert, preview);
assert.deepEqual(workflow.calculatePagePreview(selected), {
itemCount: 100,
accessibleItemCount: 100,
pageSize: 5,
pageCount: 20,
maximumPageCount: 20,
isTruncated: true
});
const emptyPreview = workflow.normalizeExpertPreviewResponse(
previewPayload([]),
previewRequest());
const empty = workflow.createSelectableExpert(expert, emptyPreview);
assert.throws(() => workflow.createExpertPlaylistEntry(empty, { id: "empty" }), /no recommendation/);
});
test("playlist creation and trusted restore bind every identity and page field", () => {
const preview = workflow.normalizeExpertPreviewResponse(
previewPayload(recommendations),
previewRequest());
const selected = workflow.createSelectableExpert(expert, preview);
const entry = workflow.createExpertPlaylistEntry(selected, {
id: "expert-1",
fadeDuration: 8
});
assert.equal(entry.builderKey, "s5074");
assert.equal(entry.code, "5074");
assert.equal(entry.pageSize, 5);
assert.equal(entry.pageCount, 1);
assert.equal(entry.fadeDuration, 8);
assert.equal(entry.operator.schemaVersion, 1);
const stored = JSON.parse(JSON.stringify(entry));
stored.enabled = false;
const restored = workflow.restoreExpertPlaylistEntry(stored);
assert.ok(restored);
assert.equal(restored.enabled, false);
assert.equal(restored.selection.dataCode, "0001");
for (const tamper of [
value => { value.builderKey = "s5077"; },
value => { value.code = "5077"; },
value => { value.selection.subject = "Other"; },
value => { value.selection.dataCode = "0002"; },
value => { value.operator.expert.expertName = "Other"; },
value => { value.operator.pagePreview.pageCount = 2; },
value => { value.operator.actionId = "unknown"; },
value => { value.enabled = "false"; }
]) {
const candidate = JSON.parse(JSON.stringify(entry));
tamper(candidate);
assert.equal(workflow.restoreExpertPlaylistEntry(candidate), null);
}
assert.throws(() => workflow.createExpertPlaylistEntry(selected, { id: "bad id" }), /options/);
assert.throws(() => workflow.createExpertPlaylistEntry(selected, { id: "safe", fadeDuration: 61 }), /Fade/);
});
test("preview errors are exact and bound to expert identity", () => {
const request = previewRequest();
assert.deepEqual(workflow.normalizeExpertPreviewError({
requestId: "preview-1",
selection: { expertCode: "0001", expertName: "Analyst A" },
message: "Preview unavailable"
}, request), {
requestId: "preview-1",
selection: { expertCode: "0001", expertName: "Analyst A" },
message: "Preview unavailable"
});
assert.equal(workflow.normalizeExpertPreviewError({
requestId: "preview-1",
selection: { expertCode: "0002", expertName: "Analyst A" },
message: "Preview unavailable"
}, request), null);
assert.equal(workflow.normalizeExpertPreviewError({
requestId: "preview-1",
selection: { expertCode: "0001", expertName: "Analyst A" },
message: "bad\u200Bmessage"
}, request), null);
});

View File

@@ -0,0 +1,142 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/industry-workflow.js");
const kospi = Object.freeze({ market: "kospi", name: "전기전자", code: "013" });
const kospiSecond = Object.freeze({ market: "kospi", name: "운수장비", code: "015" });
const kosdaq = Object.freeze({ market: "kosdaq", name: "반도체", code: "027" });
const kosdaqSecond = Object.freeze({ market: "kosdaq", name: "IT부품", code: "029" });
test("industry runtime assets expose 22 exact cut templates per market", () => {
assert.equal(workflow.cuts.length, 22);
assert.equal(new Set(workflow.cuts.map(value => value.id)).size, 22);
assert.equal(workflow.runtimeAssets.kospi.sha256, "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52");
assert.equal(workflow.runtimeAssets.kosdaq.sha256, "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4");
});
test("native industry rows normalize only bounded KOSPI and KOSDAQ identities", () => {
assert.deepEqual(workflow.normalizeIndustry({ market: "KOSPI", name: " 전기전자 ", code: "013" }), kospi);
assert.equal(workflow.normalizeIndustry({ market: "nxt-kospi", name: "전기전자", code: "013" }), null);
assert.equal(workflow.normalizeIndustry({ market: "kospi", name: "전기\n전자", code: "013" }), null);
assert.equal(workflow.normalizeIndustry({ market: "kospi", name: "전기전자", code: "01-3" }), null);
});
test("single, pair, yield and candle actions project closed legacy selections", () => {
const single = workflow.createIndustryPlaylistEntry("kospi", "one-column", {
id: "single", selected: kospi
});
assert.equal(single.builderKey, "s5001");
assert.equal(single.code, "5001");
assert.deepEqual(single.selection, {
groupCode: "INDUSTRY_KOSPI", subject: "전기전자",
graphicType: "1열판", subtype: "CURRENT", dataCode: ""
});
const pair = workflow.createIndustryPlaylistEntry("kosdaq", "two-column", {
id: "pair", pair: [kosdaq, kosdaqSecond]
});
assert.equal(pair.builderKey, "s8018");
assert.equal(pair.code, "8032");
assert.equal(pair.selection.subject, "반도체-IT부품");
const yieldEntry = workflow.createIndustryPlaylistEntry("kospi", "yield-120일", {
id: "yield", selected: kospi
});
assert.equal(yieldEntry.builderKey, "s5086");
assert.equal(yieldEntry.selection.subtype, "OneHundredTwentyDays");
const candle = workflow.createIndustryPlaylistEntry("kosdaq", "candle-volume-240일", {
id: "candle", selected: kosdaq
});
assert.equal(candle.builderKey, "s8010");
assert.equal(candle.code, "8056");
assert.equal(candle.selection.groupCode, "KOSDAQ_INDUSTRY");
assert.equal(candle.selection.subtype, "VOLUME");
});
test("market-wide industry actions need no selected industry and use explicit aliases", () => {
const now = new Date(2026, 6, 11, 10, 0, 0);
const five = workflow.createIndustryPlaylistEntry("kosdaq", "five-row", { id: "five", now });
assert.equal(five.builderKey, "s5074");
assert.equal(five.selection.groupCode, "PAGED_DOMESTIC_KOSDAQ");
assert.equal(five.selection.subtype, "INDUSTRY");
assert.equal(five.selection.dataCode, "2026-07-11");
const square = workflow.createIndustryPlaylistEntry("kosdaq", "square", { id: "square" });
assert.equal(square.builderKey, "s8001");
assert.equal(square.code, "8002");
assert.equal(square.selection.subject, "KOSDAQ");
const sector = workflow.createIndustryPlaylistEntry("kospi", "sector", { id: "sector" });
assert.equal(sector.builderKey, "s5078");
assert.equal(sector.selection.graphicType, "SECTOR_INDEX");
});
test("industry candles carry and restore the global moving-average flags", () => {
const value = workflow.createIndustryPlaylistEntry("kospi", "candle-120일", {
id: "industry-candle-flags",
selected: kospi,
ma5: true,
ma20: true
});
assert.equal(value.selection.graphicType, "MA5,MA20");
assert.deepEqual(value.operator.movingAverages, { ma5: true, ma20: true });
assert.deepEqual(workflow.restoreIndustryPlaylistEntry(value), value);
assert.equal(workflow.restoreIndustryPlaylistEntry({
...value,
selection: { ...value.selection, graphicType: "MA20" }
}), null);
});
test("legacy industry candle entries gain closed moving-average metadata on restore", () => {
const value = workflow.createIndustryPlaylistEntry("kosdaq", "candle-volume-20일", {
id: "industry-candle-legacy",
selected: kosdaq
});
const legacy = {
...value,
operator: Object.freeze(Object.fromEntries(
Object.entries(value.operator).filter(([key]) => key !== "movingAverages")))
};
assert.deepEqual(
workflow.restoreIndustryPlaylistEntry(legacy).operator.movingAverages,
{ ma5: false, ma20: false });
});
test("selection prerequisites and cross-market values fail closed", () => {
assert.equal(workflow.isCutAllowed("kospi", "one-column", null, null, null), false);
assert.equal(workflow.isCutAllowed("kospi", "two-column", kospi, kospi, null), false);
assert.equal(workflow.isCutAllowed("kospi", "two-column", null, kospi, kospiSecond), true);
assert.equal(workflow.isCutAllowed("kospi", "five-row", null, null, null), true);
assert.throws(() => workflow.createIndustryPlaylistEntry("kospi", "one-column", {
id: "cross", selected: kosdaq
}));
});
test("stored industry entries restore from trusted bindings and reject tampering", () => {
const value = workflow.createIndustryPlaylistEntry("kospi", "two-column", {
id: "stored-pair", pair: [kospi, kospiSecond]
});
assert.ok(workflow.restoreIndustryPlaylistEntry(value));
assert.equal(workflow.restoreIndustryPlaylistEntry({ ...value, code: "5032" }), null);
assert.equal(workflow.restoreIndustryPlaylistEntry({
...value,
selection: { ...value.selection, subject: "전기전자-화학" }
}), null);
assert.equal(workflow.restoreIndustryPlaylistEntry({
...value,
operator: { ...value.operator, assetSha256: "0".repeat(64) }
}), null);
});
test("paged industry dates refresh at restore time", () => {
const original = workflow.createIndustryPlaylistEntry("kospi", "five-row", {
id: "paged", now: new Date(2026, 6, 10, 23, 59, 0)
});
const refreshed = workflow.restoreIndustryPlaylistEntry(original, {
now: new Date(2026, 6, 11, 0, 1, 0)
});
assert.equal(refreshed.selection.dataCode, "2026-07-11");
});

View File

@@ -0,0 +1,210 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/legacy-fixed-workflow.js");
const knownAliases = new Map([
["s5001", new Set(["5001"])],
["s5016", new Set(["5016"])],
["s50160", new Set(["50160"])],
["s5023", new Set(["5023"])],
["s5024", new Set(["5024"])],
["s5025", new Set(["5025"])],
["s5074", new Set(["5074"])],
["s5077", new Set(["5077"])],
["s5078", new Set(["5078"])],
["s5082", new Set(["5082"])],
["s5083", new Set(["5083"])],
["s5084", new Set(["5084"])],
["s5085", new Set(["5085"])],
["s5086", new Set(["5086"])],
["s50860", new Set(["50860"])],
["s5088", new Set(["5088"])],
["s6001", new Set(["6001"])],
["s6067", new Set(["6067"])],
["s8010", new Set(["8035", "8061", "8040", "8046", "8051", "8056"])],
["s8067", new Set(["8067", "5068", "5070", "5072"])]
]);
function action(market, section, label) {
const result = workflow.actions.find(value =>
value.market === market && value.section === section && value.label === label);
assert.ok(result, `${market}/${section}/${label} action is missing`);
return result;
}
test("runtime overseas, exchange and index assets expose all 328 leaf actions", () => {
assert.equal(workflow.actions.length, 328);
assert.equal(workflow.actionsForMarket("overseas").length, 78);
assert.equal(workflow.actionsForMarket("exchange").length, 46);
assert.equal(workflow.actionsForMarket("index").length, 204);
assert.equal(workflow.actions.filter(value => value.available).length, 322);
assert.equal(workflow.actions.filter(value => !value.available).length, 6);
assert.equal(new Set(workflow.actions.map(value => value.id)).size, 328);
assert.equal(workflow.runtimeAssets.overseas.sha256, "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A");
assert.equal(workflow.runtimeAssets.exchange.sha256, "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C");
assert.equal(workflow.runtimeAssets.index.sha256, "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6");
});
test("every available fixed action creates a closed registered builder and alias", () => {
for (const value of workflow.actions) {
if (!value.available) {
assert.throws(() => workflow.createFixedPlaylistEntry(value.id, { id: `entry-${value.id}` }));
continue;
}
const entry = workflow.createFixedPlaylistEntry(value.id, {
id: `entry-${value.id}`,
now: new Date(2026, 6, 11, 9, 30, 0)
});
assert.equal(entry.operator.source, "legacy-fixed-workflow");
assert.equal(entry.operator.actionId, value.id);
assert.ok(knownAliases.get(entry.builderKey)?.has(entry.code), `${entry.builderKey}/${entry.code}`);
assert.deepEqual(entry.aliases, [entry.code]);
assert.equal(typeof entry.selection.groupCode, "string");
assert.equal(typeof entry.selection.subject, "string");
assert.equal(typeof entry.selection.graphicType, "string");
assert.equal(typeof entry.selection.subtype, "string");
assert.equal(typeof entry.selection.dataCode, "string");
}
});
test("representative overseas and exchange actions preserve closed native selectors", () => {
const gold = workflow.createFixedPlaylistEntry(
action("overseas", "1열판기본", "국제 금").id,
{ id: "gold" });
assert.equal(gold.builderKey, "s5001");
assert.deepEqual(gold.selection, {
groupCode: "COMMODITY", subject: "InternationalGold",
graphicType: "1열판기본", subtype: "CURRENT", dataCode: ""
});
const fx = workflow.createFixedPlaylistEntry(
action("exchange", "라인 그래프", "원달러_20일").id,
{ id: "fx" });
assert.equal(fx.builderKey, "s50860");
assert.equal(fx.selection.subject, "WonDollar");
assert.equal(fx.selection.subtype, "TwentyDays");
const map = workflow.createFixedPlaylistEntry(
action("overseas", "글로벌 증시 지도", "미국 증시 지도").id,
{ id: "map" });
assert.equal(map.builderKey, "s8067");
assert.equal(map.code, "5068");
});
test("index variants map current, candle, trend and paged rows without free text", () => {
const current = workflow.createFixedPlaylistEntry(
action("index", "코스피200", "1열판기본_현재지수").id,
{ id: "current" });
assert.deepEqual(current.selection, {
groupCode: "KOSPI200", subject: "INDEX",
graphicType: "1열판기본", subtype: "CURRENT", dataCode: ""
});
const candle = workflow.createFixedPlaylistEntry(
action("index", "선물", "캔들그래프(예상지수)_240일").id,
{ id: "candle" });
assert.equal(candle.builderKey, "s8010");
assert.equal(candle.code, "8056");
assert.equal(candle.selection.groupCode, "FUTURES_INDEX");
assert.equal(candle.selection.subtype, "EXPECTED");
const trend = workflow.createFixedPlaylistEntry(
action("index", "매매동향", "외국인 매매동향_라인그래프").id,
{ id: "trend" });
assert.equal(trend.selection.graphicType, "FOREIGN_TRADING_TREND");
const page = workflow.createFixedPlaylistEntry(
action("index", "12종목 현재가", "코스닥 52주 신저가").id,
{ id: "page", now: new Date(2026, 6, 11, 13, 0, 0) });
assert.equal(page.builderKey, "s5088");
assert.equal(page.selection.groupCode, "PAGED_DOMESTIC_KOSDAQ");
assert.equal(page.selection.subtype, "52_WEEK_LOW");
assert.equal(page.selection.dataCode, "2026-07-11");
});
test("fixed candle actions carry the original global 5-day and 20-day flags", () => {
const candleAction = workflow.actions.find(value => value.builderKey === "s8010");
assert.ok(candleAction);
const both = workflow.createFixedPlaylistEntry(candleAction.id, {
id: "candle-both",
ma5: true,
ma20: true
});
assert.equal(both.selection.graphicType, "MA5,MA20");
assert.deepEqual(both.operator.movingAverages, { ma5: true, ma20: true });
assert.deepEqual(workflow.restoreFixedPlaylistEntry(both), both);
const ma20 = workflow.createFixedPlaylistEntry(candleAction.id, {
id: "candle-ma20",
ma5: false,
ma20: true
});
assert.equal(ma20.selection.graphicType, "MA20");
assert.deepEqual(workflow.restoreFixedPlaylistEntry(ma20), ma20);
assert.equal(workflow.restoreFixedPlaylistEntry({
...both,
selection: { ...both.selection, graphicType: "" }
}), null);
});
test("pre-global-option fixed candle entries restore once through a closed flag migration", () => {
const candleAction = workflow.actions.find(value => value.builderKey === "s8010");
const legacy = workflow.createFixedPlaylistEntry(candleAction.id, { id: "legacy-candle" });
const withoutMetadata = {
...legacy,
operator: Object.freeze(Object.fromEntries(
Object.entries(legacy.operator).filter(([key]) => key !== "movingAverages")))
};
const restored = workflow.restoreFixedPlaylistEntry(withoutMetadata);
assert.deepEqual(restored.operator.movingAverages, { ma5: false, ma20: false });
});
test("NXT session and domestic date refresh at restore instead of remaining stale", () => {
const nxtAction = action("index", "5단 표그래프", "코스피_NXT 거래량 상위");
const original = workflow.createFixedPlaylistEntry(nxtAction.id, {
id: "nxt-page", now: new Date(2026, 6, 11, 9, 0, 0)
});
assert.equal(original.selection.graphicType, "PRE_MARKET");
const refreshed = workflow.restoreFixedPlaylistEntry(original, {
now: new Date(2026, 6, 11, 15, 0, 0)
});
assert.equal(refreshed.selection.graphicType, "AFTER_MARKET");
const domesticAction = action("index", "6종목 현재가", "코스피 시가총액");
const domestic = workflow.createFixedPlaylistEntry(domesticAction.id, {
id: "domestic-page", now: new Date(2026, 6, 10, 23, 50, 0)
});
const nextDay = workflow.restoreFixedPlaylistEntry(domestic, {
now: new Date(2026, 6, 11, 0, 10, 0)
});
assert.equal(nextDay.selection.dataCode, "2026-07-11");
});
test("stored fixed entries fail closed after action, mapping, asset or selection tampering", () => {
const value = workflow.createFixedPlaylistEntry(
action("exchange", "1열판기본", "원엔").id,
{ id: "stored" });
assert.ok(workflow.restoreFixedPlaylistEntry(value));
assert.equal(workflow.restoreFixedPlaylistEntry({ ...value, code: "5006" }), null);
assert.equal(workflow.restoreFixedPlaylistEntry({
...value,
selection: { ...value.selection, subject: "WonDollar" }
}), null);
assert.equal(workflow.restoreFixedPlaylistEntry({
...value,
operator: { ...value.operator, assetSha256: "0".repeat(64) }
}), null);
assert.equal(workflow.restoreFixedPlaylistEntry({
...value,
operator: { ...value.operator, actionId: "fixed-999" }
}), null);
});
test("all three VI placeholders preserve the original VILIST five-row outcome", () => {
const vi = workflow.actions.filter(value => value.label === "VI 발동(수동)");
assert.equal(vi.length, 3);
assert.ok(vi.every(value => !value.available));
assert.ok(vi.every(value => value.builderKey === "s5074" && value.code === "5074"));
});

View File

@@ -0,0 +1,123 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const workflow = require("../../Web/manual-financial-workflow.js");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const bridge = fs.readFileSync(
path.join(repositoryRoot, "MainWindow.ManualFinancial.cs"),
"utf8");
const playoutBridge = fs.readFileSync(
path.join(repositoryRoot, "MainWindow.Playout.cs"),
"utf8");
const operatorGate = fs.readFileSync(
path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Playout", "OperatorMutationGate.cs"),
"utf8");
test("native partial exposes every strict workflow request and response", () => {
for (const [name, value] of Object.entries(workflow.bridgeContract)) {
if (name.endsWith("RequestType") || name.endsWith("ResultType") || name.endsWith("ErrorType")) {
assert.match(bridge, new RegExp(`"${value}"`));
}
}
assert.match(bridge, /private bool TryHandleManualFinancialRequest\(/);
assert.match(bridge, /HasOnlyProperties\(payload, "requestId", "screen", "stock", "record"\)/);
assert.match(bridge, /element\.GetArrayLength\(\) != 5/);
assert.match(bridge, /element\.GetArrayLength\(\) != 4/);
assert.match(bridge, /element\.GetArrayLength\(\) != 6/);
});
test("runtime helper composes the typed service with the non-retrying Oracle executor", () => {
assert.match(bridge,
/new LegacyManualFinancialScreenService\(\s*runtime\.Executor,\s*new OracleManualFinancialMutationExecutor\(/s);
assert.match(bridge, /runtime\.ConnectionFactory/);
assert.match(bridge, /runtime\.Options\.Resilience/);
});
test("list, load and selection reads own independent correlation lanes", () => {
assert.match(bridge,
/Dictionary<string, ManualFinancialReadRequest> _manualFinancialReadLanes/);
assert.match(bridge,
/new ManualFinancialReadRequest\(\s*operation,\s*requestId,/s);
assert.match(bridge, /RegisterManualFinancialRead\(request\)/);
assert.match(bridge, /_manualFinancialReadLanes\[request\.Lane\] = request/);
assert.doesNotMatch(bridge, /_manualFinancialReadCancellation/);
assert.match(bridge, /await _databaseActivityGate\.WaitAsync\(requestToken\)/);
assert.ok((bridge.match(/Volatile\.Read\(ref _playoutCommandInFlight\) != 0/g) || []).length >= 2);
assert.match(bridge, /browserGeneration != Volatile\.Read\(ref _manualFinancialBrowserGeneration\)/);
assert.match(bridge, /!TryCompleteManualFinancialReadIfCurrent\(request\)/);
});
test("same-lane supersede emits one correlated terminal cancellation", () => {
assert.match(bridge, /CancelRequest\(superseded\.Cancellation\)/);
assert.match(bridge, /superseded\?\.TryComplete\(\)/);
assert.match(bridge, /"CANCELLED"/);
assert.match(bridge, /newer request replaced it/);
assert.match(bridge,
/Interlocked\.CompareExchange\(ref _terminalState, 1, 0\) == 0/);
assert.match(bridge,
/TryCompleteManualFinancialReadIfCurrent\(request\)[\s\S]*PostMessage\(reply\.MessageType/s);
assert.match(bridge,
/ReferenceEquals\(current, request\) &&[\s\S]*request\.TryComplete\(\)/s);
});
test("writes are single-flight and impossible during command, PREPARED, PROGRAM or refresh", () => {
assert.match(bridge, /_manualFinancialWriteGate\.WaitAsync\(0\)/);
assert.match(bridge, /_playoutCommandGate\.WaitAsync\(0, requestToken\)/);
assert.match(bridge, /_databaseActivityGate\.WaitAsync\(0, requestToken\)/);
assert.match(bridge, /CanStartManualFinancialWrite\(\)/);
assert.match(bridge,
/CanStartOperatorMutation\(IsManualFinancialWriteQuarantined\(\)\)/);
assert.match(playoutBridge, /private bool CanStartOperatorMutation\(/);
assert.match(playoutBridge, /new OperatorMutationGateSnapshot\(/);
assert.match(playoutBridge, /IsEngineAvailable: engine is not null/);
assert.match(playoutBridge, /IsWorkflowAvailable: workflow is not null/);
assert.match(playoutBridge, /IsRefreshTaskRunning: _legacyRefreshTask is \{ IsCompleted: false \}/);
for (const condition of [
"IsCommandAvailable", "IsPlayCompletionPending", "PreparedSceneName", "OnAirSceneName",
"PreparedCutCode", "OnAirCutCode", "IsRefreshActive", "IsRefreshTaskRunning"
]) {
assert.match(operatorGate, new RegExp(`snapshot\\.${condition}`));
}
assert.match(bridge, /"PLAYOUT_ACTIVE"/);
});
test("OutcomeUnknown and browser correlation loss latch a process-lifetime no-retry quarantine", () => {
assert.match(bridge, /catch \(ManualFinancialMutationException exception\)/);
assert.match(bridge, /if \(exception\.OutcomeUnknown\)/);
assert.match(bridge,
/Interlocked\.CompareExchange\(ref _manualFinancialWriteQuarantined, 1, 0\)/);
assert.match(bridge, /retryable = false/);
assert.match(bridge, /rolled back and was not retried/i);
assert.doesNotMatch(bridge, /for\s*\([^)]*retry|while\s*\([^)]*retry/i);
});
test("browser, playout and shutdown hooks cancel correlation without clearing quarantine", () => {
assert.match(bridge, /private void InvalidateManualFinancialBrowserRequests\(\)/);
assert.match(bridge, /Interlocked\.Increment\(ref _manualFinancialBrowserGeneration\)/);
assert.match(bridge, /Volatile\.Read\(ref _manualFinancialWriteInFlight\) != 0/);
assert.match(bridge,
/Interlocked\.Exchange\(ref _manualFinancialWriteCancellation, null\)/);
assert.match(bridge, /private void CancelManualFinancialReadsForPlayout\(\)/);
assert.match(bridge, /private void ShutdownManualFinancialRuntime\(\)/);
assert.ok((bridge.match(/SnapshotManualFinancialReads\(clear: true\)/g) || []).length >= 2);
assert.match(bridge,
/CancelManualFinancialReadsForPlayout\(\)[\s\S]*SnapshotManualFinancialReads\(clear: false\)/s);
});
test("selection and restore boundaries re-read row version before exposing a cut", () => {
assert.match(bridge, /var snapshot = await service\.GetAsync\(identity, cancellationToken\)/);
assert.match(bridge, /EnsureManualFinancialRowVersion\(snapshot, rowVersion\)/);
assert.match(bridge, /ResolveManualFinancialStockAsync\(/);
assert.match(bridge, /ManualFinancialCutContracts\.CreateSelection\(snapshot, verified\)/);
assert.match(bridge, /"manual-financial-selection-results"/);
});
test("successful writes return the real transaction receipt fields", () => {
assert.match(bridge, /operationId = receipt\.OperationId\.ToString\("N"/);
assert.match(bridge, /receipt\.CommittedAt/);
assert.match(bridge, /receipt\.AffectedRows/);
});

View File

@@ -0,0 +1,372 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/manual-financial-workflow.js");
const ui = require("../../Web/manual-financial-ui.js");
const rowVersion = "A".repeat(64);
const STOCK_RESULT = "stock-search-results";
function quarters() {
return [
{ quarter: "1Q", value: 10 },
{ quarter: "2Q", value: -20 },
{ quarter: "3Q", value: 30 },
{ quarter: "4Q", value: 40 },
{ quarter: "5Q", value: 50 },
{ quarter: "6Q", value: 60 }
];
}
function records() {
return {
"revenue-composition": {
kind: "revenue-composition",
stockName: "Alpha",
baseDate: "2026-06",
slices: [
{ label: "Semiconductor", percentageText: "50.0", percentage: 50 },
{ label: "Service", percentageText: "-", percentage: 0 },
null,
{ label: "Overseas", percentageText: "25", percentage: 25 },
null
]
},
"growth-metrics": {
kind: "growth-metrics",
stockName: "Alpha",
salesGrowth: [1, 2.5, null, -4],
operatingProfitGrowth: [5, 6, 7, 8],
netAssetGrowth: [9, null, 11, 12],
netIncomeGrowth: [13, 14, 15, 16],
periods: ["1Q", "2Q", "3Q", "4Q"]
},
sales: {
kind: "sales",
stockName: "Alpha",
quarters: quarters()
},
"operating-profit": {
kind: "operating-profit",
stockName: "Alpha",
quarters: quarters().map(item => ({ ...item, value: item.value * 2 }))
}
};
}
function createHarness() {
const posted = [];
const entries = [];
const toasts = [];
const logs = [];
let ordinal = 0;
const controller = ui.createController({
postNative(type, payload) {
posted.push({ type, payload });
},
appendPlaylistEntry(entry) {
entries.push(entry);
return true;
},
isLocked() {
return false;
},
getSelectedStock() {
return { market: "kospi", source: "oracle", name: "Alpha", code: "000001" };
},
showToast(message) {
toasts.push(message);
},
addLog(message) {
logs.push(message);
},
createId() {
ordinal += 1;
return `manual-ui-${ordinal}`;
}
});
return { controller, posted, entries, toasts, logs };
}
test("all four GraphE records round-trip through pure form conversion", () => {
for (const [screen, record] of Object.entries(records())) {
const form = ui.recordToForm(screen, record);
assert.ok(form, screen);
assert.deepEqual(ui.formToRecord(screen, form), workflow.normalizeRecord(screen, record));
}
const revenue = ui.recordToForm("revenue-composition", records()["revenue-composition"]);
assert.equal(ui.formToRecord("revenue-composition", {
...revenue,
slice3Label: "Only label",
slice3Percentage: ""
}), null);
const growth = ui.recordToForm("growth-metrics", records()["growth-metrics"]);
assert.equal(ui.formToRecord("growth-metrics", {
...growth,
salesGrowth2: "not-a-number"
}), null);
const sales = ui.recordToForm("sales", records().sales);
assert.equal(ui.formToRecord("sales", { ...sales, value4: "10.5" }), null);
assert.equal(ui.resolveScreen("manual-operating-profit"), "operating-profit");
assert.equal(ui.resolveScreen({ id: "manual-revenue-mix" }), "revenue-composition");
assert.equal(ui.resolveScreen({ screen: "growth-metrics" }), "growth-metrics");
});
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);
assert.equal(harness.posted.length, 1);
assert.equal(harness.posted[0].type, workflow.bridgeContract.listRequestType);
const request = harness.posted[0].payload;
assert.deepEqual(request, {
requestId: "manual-ui-1",
screen: "sales",
query: "",
maximumResults: workflow.bridgeContract.defaultMaximumResults
});
assert.equal(harness.controller.render().pending.list, request.requestId);
const payload = {
requestId: request.requestId,
screen: "sales",
query: "",
retrievedAt: "2026-07-12T10:00:00+09:00",
totalRowCount: 1,
truncated: false,
items: [{
stockName: "Alpha",
rowVersion,
record: records().sales
}]
};
assert.equal(harness.controller.handleMessage(
workflow.bridgeContract.listResultType,
{ ...payload, requestId: "stale-list" }), false);
assert.equal(harness.controller.render().pending.list, request.requestId);
assert.equal(harness.controller.render().itemCount, 0);
assert.equal(harness.controller.handleMessage(workflow.bridgeContract.listResultType, payload), true);
assert.equal(harness.controller.render().pending.list, undefined);
assert.equal(harness.controller.render().itemCount, 1);
assert.equal(harness.controller.render().status, "ready");
assert.equal(harness.controller.handleMessage(STOCK_RESULT, {
requestId: "another-component",
query: "Alpha",
retrievedAt: "2026-07-12T10:01:00+09:00",
totalRowCount: 0,
truncated: false,
results: []
}), false);
assert.equal(harness.controller.setLocked(true).locked, true);
assert.equal(harness.controller.setLocked(false).locked, false);
});
class FakeElement {
constructor(tagName, ownerDocument) {
this.tagName = tagName.toUpperCase();
this.ownerDocument = ownerDocument;
this.children = [];
this.attributes = new Map();
this.listeners = new Map();
this.dataset = {};
this.textContent = "";
this.value = "";
this.hidden = false;
this.disabled = false;
this.readOnly = false;
this.className = "";
}
append(...values) {
for (const value of values) this.appendChild(value);
}
appendChild(value) {
this.children.push(value);
return value;
}
replaceChildren(...values) {
this.children = [];
this.append(...values);
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
addEventListener(name, callback) {
this.listeners.set(name, callback);
}
}
class FakeDocument {
constructor() {
this.body = new FakeElement("body", this);
}
createElement(tagName) {
return new FakeElement(tagName, this);
}
}
function findByClass(node, className) {
if (String(node.className || "").split(/\s+/).includes(className)) return node;
for (const child of node.children || []) {
const found = findByClass(child, className);
if (found) return found;
}
return null;
}
test("mount creates the isolated text-only dialog and exposes exactly the integration methods", () => {
const harness = createHarness();
const document = new FakeDocument();
const overlay = harness.controller.mount(document.body);
assert.equal(overlay.tagName, "DIV");
assert.equal(overlay.className, "mfui-overlay");
assert.equal(document.body.children.length, 1);
assert.deepEqual(Object.keys(harness.controller), [
"mount", "open", "handleMessage", "render", "setLocked"
]);
assert.equal(harness.controller.open("revenue-composition"), true);
assert.equal(overlay.hidden, false);
assert.equal(harness.posted[0].type, workflow.bridgeContract.listRequestType);
});
test("fresh load, exact stock revalidation and native selection create one playable entry", () => {
const harness = createHarness();
const document = new FakeDocument();
const overlay = harness.controller.mount(document.body);
harness.controller.open("sales");
const listRequest = harness.posted.at(-1).payload;
assert.equal(harness.controller.handleMessage(workflow.bridgeContract.listResultType, {
requestId: listRequest.requestId,
screen: "sales",
query: "",
retrievedAt: "2026-07-12T10:00:00+09:00",
totalRowCount: 1,
truncated: false,
items: [{ stockName: "Alpha", rowVersion, record: records().sales }]
}), true);
const listRow = findByClass(overlay, "mfui-list-row");
assert.ok(listRow);
listRow.listeners.get("click")();
const loadCall = harness.posted.at(-1);
assert.equal(loadCall.type, workflow.bridgeContract.loadRequestType);
assert.equal(harness.controller.handleMessage(workflow.bridgeContract.loadResultType, {
requestId: loadCall.payload.requestId,
screen: "sales",
retrievedAt: "2026-07-12T10:01:00+09:00",
snapshot: { stockName: "Alpha", rowVersion, record: records().sales }
}), true);
assert.equal(harness.controller.render().hasSnapshot, true);
const playlistButton = findByClass(overlay, "mfui-accent");
assert.ok(playlistButton);
playlistButton.listeners.get("click")();
const truncatedSearch = harness.posted.at(-1);
assert.equal(truncatedSearch.type, "search-stocks");
harness.controller.handleMessage(STOCK_RESULT, {
requestId: truncatedSearch.payload.requestId,
query: "Alpha",
retrievedAt: "2026-07-12T10:02:00+09:00",
totalRowCount: 1,
truncated: true,
results: [{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }]
});
assert.equal(harness.entries.length, 0);
assert.equal(harness.controller.render().stockStatus, "error");
playlistButton.listeners.get("click")();
const stockCall = harness.posted.at(-1);
harness.controller.handleMessage(STOCK_RESULT, {
requestId: stockCall.payload.requestId,
query: "Alpha",
retrievedAt: "2026-07-12T10:03:00+09:00",
totalRowCount: 1,
truncated: false,
results: [{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }]
});
const selectionCall = harness.posted.at(-1);
assert.equal(selectionCall.type, workflow.bridgeContract.selectionRequestType);
const profile = workflow.getScreenDefinition("sales");
harness.controller.handleMessage(workflow.bridgeContract.selectionResultType, {
requestId: selectionCall.payload.requestId,
screen: "sales",
stockName: "Alpha",
rowVersion,
builderKey: profile.builderKey,
code: profile.code,
aliases: [profile.code],
selection: {
groupCode: "KOSPI",
subject: "Alpha",
graphicType: profile.graphicType,
subtype: "",
dataCode: ""
},
currentPage: 1,
totalPages: 1
});
assert.equal(harness.entries.length, 1);
assert.equal(workflow.isPlaylistEntryPlayable(harness.entries[0]), true);
assert.equal(harness.entries[0].code, "5080");
});
test("UI source uses DOM construction and documents the isolated app integration hook", () => {
const source = fs.readFileSync(
path.join(__dirname, "..", "..", "Web", "manual-financial-ui.js"),
"utf8");
assert.match(source, /App integration hook/);
assert.match(source, /createElement/);
assert.doesNotMatch(source, /\.innerHTML\s*=/);
assert.doesNotMatch(source, /insertAdjacentHTML/);
});
test("pending writes correlate exactly and OutcomeUnknown latches process quarantine", () => {
const coordinator = ui.createPendingCoordinator();
const expected = {
requestId: "write-1",
screen: "sales",
stockName: "Alpha",
expectedRowVersion: rowVersion
};
coordinator.set("write", expected, { operation: "delete-one" });
const unknown = {
requestId: "write-1",
operation: "delete-one",
screen: "sales",
stockName: "Alpha",
code: "OUTCOME_UNKNOWN",
message: "Commit result is unknown",
retryable: false,
outcomeUnknown: true
};
assert.equal(coordinator.acceptMutationError({
...unknown,
requestId: "stale-write"
}).status, "stale");
assert.equal(coordinator.snapshot().write, "write-1");
assert.equal(ui.getProcessWriteQuarantine().active, false);
const accepted = coordinator.acceptMutationError(unknown);
assert.equal(accepted.status, "accepted");
assert.equal(accepted.value.outcomeUnknown, true);
assert.equal(coordinator.snapshot().write, undefined);
assert.equal(ui.getProcessWriteQuarantine().active, true);
assert.match(ui.getProcessWriteQuarantine().message, /unknown/i);
const laterController = createHarness().controller;
assert.equal(laterController.render().writeQuarantined, true);
assert.equal(laterController.render().canWrite, false);
});

View File

@@ -0,0 +1,740 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/manual-financial-workflow.js");
const version = "A".repeat(64);
function quarters() {
return [
{ quarter: "1Q", value: 10 },
{ quarter: "2Q", value: -20 },
{ quarter: "3Q", value: 30 },
{ quarter: "4Q", value: 40 },
{ quarter: "5Q", value: 50 },
{ quarter: "6Q", value: 60 }
];
}
function record(screen, stockName = "Alpha") {
switch (screen) {
case "revenue-composition":
return {
kind: screen,
stockName,
baseDate: "2026-06",
slices: [
{ label: "Semiconductor", percentageText: "50.0", percentage: 50 },
{ label: "Service", percentageText: "-", percentage: 0 },
{ label: "Overseas", percentageText: "25", percentage: 25 },
null,
null
]
};
case "growth-metrics":
return {
kind: screen,
stockName,
salesGrowth: [1, 2, 3, 4],
operatingProfitGrowth: [5, 6, null, 8],
netAssetGrowth: [9, 10, 11, 12],
netIncomeGrowth: [13, 14, 15, 16],
periods: ["1Q", "2Q", "3Q", "4Q"]
};
case "sales":
case "operating-profit":
return { kind: screen, stockName, quarters: quarters() };
default:
throw new Error("bad screen");
}
}
function listRequest(screen = "sales") {
return workflow.createListRequest("list-1", screen, "Alpha", 25);
}
function listPayload(screen = "sales", records = [record(screen)]) {
return {
requestId: "list-1",
screen,
query: "Alpha",
retrievedAt: "2026-07-12T00:10:00+09:00",
totalRowCount: records.length,
truncated: false,
items: records.map((item, index) => ({
stockName: item.stockName,
rowVersion: String.fromCharCode(65 + index).repeat(64),
record: item
}))
};
}
function trustedSnapshot(screen = "sales", stockName = "Alpha") {
const request = workflow.createListRequest("list-1", screen, "Alpha", 25);
const response = workflow.normalizeListResponse(
listPayload(screen, [record(screen, stockName)]),
request);
assert.ok(response);
return response.items[0];
}
function stockPayload(results = [{
market: "kospi",
source: "oracle",
name: "Alpha",
code: "000001"
}]) {
return {
requestId: "stock-1",
query: "Alpha",
retrievedAt: "2026-07-12T00:11:00+09:00",
totalRowCount: results.length,
truncated: false,
results
};
}
function verifiedStock(recordValue = record("sales"), results) {
const response = workflow.normalizeStockSearchResponse(stockPayload(results));
assert.ok(response);
const verified = workflow.verifyStockForRecord(
recordValue,
response,
"kospi",
"000001");
assert.ok(verified);
return verified;
}
function trustedSelection(snapshot, stock, requestId = "selection-1") {
const request = workflow.createSelectionRequest(requestId, snapshot, stock);
const profile = workflow.getScreenDefinition(snapshot.screen);
const groupCode = {
kospi: "KOSPI",
kosdaq: "KOSDAQ",
"nxt-kospi": "NXT_KOSPI",
"nxt-kosdaq": "NXT_KOSDAQ"
}[stock.market];
const response = workflow.normalizeSelectionResponse({
requestId,
screen: snapshot.screen,
stockName: snapshot.stockName,
rowVersion: snapshot.rowVersion,
builderKey: profile.builderKey,
code: profile.code,
aliases: [profile.code],
selection: {
groupCode,
subject: snapshot.stockName,
graphicType: profile.graphicType,
subtype: "",
dataCode: ""
},
currentPage: 1,
totalPages: 1
}, request);
assert.ok(response);
return response;
}
function trustedLoad(snapshot, requestId = "load-1") {
const request = workflow.createLoadRequest(requestId, snapshot.screen, snapshot.stockName);
const response = workflow.normalizeLoadResponse({
requestId,
screen: snapshot.screen,
retrievedAt: "2026-07-12T00:20:00+09:00",
snapshot: {
stockName: snapshot.stockName,
rowVersion: snapshot.rowVersion,
record: snapshot.record
}
}, request);
assert.ok(response);
return response;
}
test("workflow pins GraphE Oracle tables, CRUD bridge and four scene actions", () => {
assert.deepEqual(workflow.bridgeContract, {
requestErrorType: "manual-financial-request-error",
listRequestType: "request-manual-financial-list",
listResultType: "manual-financial-list-results",
listErrorType: "manual-financial-list-error",
loadRequestType: "request-manual-financial-load",
loadResultType: "manual-financial-load-results",
loadErrorType: "manual-financial-load-error",
selectionRequestType: "request-manual-financial-selection",
selectionResultType: "manual-financial-selection-results",
selectionErrorType: "manual-financial-selection-error",
createRequestType: "create-manual-financial-record",
updateRequestType: "update-manual-financial-record",
deleteRequestType: "delete-manual-financial-record",
deleteAllRequestType: "delete-all-manual-financial-records",
mutationResultType: "manual-financial-mutation-result",
mutationErrorType: "manual-financial-mutation-error",
defaultMaximumResults: 200,
maximumResults: 1000,
maximumQueryLength: 64
});
assert.equal(workflow.sourceContract.originalForm, "Form/GraphE.cs");
assert.equal(workflow.sourceContract.provider, "oracle");
assert.equal(workflow.sourceContract.operatorSchemaVersion, 1);
assert.equal(workflow.sourceContract.restoreRequiresNativeLoad, true);
assert.equal(workflow.sourceContract.rawNamedPlaylistRowPlayable, false);
assert.equal(workflow.sourceContract.writeSafety.outcomeUnknownAutomaticRetry, false);
assert.deepEqual(workflow.actions.map(action => [action.screen, action.builderKey, action.code]), [
["revenue-composition", "s5076", "5076"],
["growth-metrics", "s5079", "5079"],
["sales", "s5080", "5080"],
["operating-profit", "s5081", "5081"]
]);
});
test("generic native request errors are exact and operation-correlated", () => {
const error = {
requestId: "",
operation: "load",
code: "INVALID_REQUEST",
message: "Invalid screen."
};
assert.deepEqual(workflow.normalizeRequestError(error, "load"), error);
assert.equal(workflow.normalizeRequestError(error, "list"), null);
assert.equal(workflow.normalizeRequestError({ ...error, extra: true }, "load"), null);
assert.equal(workflow.normalizeRequestError({ ...error, requestId: "bad id" }, "load"), null);
assert.equal(workflow.normalizeRequestError({ ...error, operation: "unknown" }), null);
assert.equal(workflow.normalizeRequestError({ ...error, code: "OTHER" }), null);
assert.equal(workflow.normalizeRequestError({ ...error, message: "bad\nmessage" }), null);
});
test("all closed records serialize exactly to the existing underscore loader shapes", () => {
assert.deepEqual(workflow.serializeRecordForStorage(
"revenue-composition",
record("revenue-composition")), {
table: "INPUT_PIE",
columns: ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"],
values: ["Alpha", "2026-06", "Semiconductor_50.0", "Service_-", "Overseas_25", "", ""]
});
assert.deepEqual(workflow.serializeRecordForStorage(
"growth-metrics",
record("growth-metrics")), {
table: "INPUT_GROW",
columns: ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"],
values: ["Alpha", "1_2_3_4", "5_6__8", "9_10_11_12", "13_14_15_16", "1Q_2Q_3Q_4Q"]
});
assert.deepEqual(workflow.serializeRecordForStorage("sales", record("sales")).values, [
"Alpha", "1Q_10", "2Q_-20", "3Q_30", "4Q_40", "5Q_50", "6Q_60"
]);
assert.equal(workflow.serializeRecordForStorage(
"operating-profit",
record("operating-profit")).table, "INPUT_PROFIT");
});
test("closed DTO validation rejects malformed counts, separators and unsafe numbers", () => {
const revenue = record("revenue-composition");
assert.equal(workflow.normalizeRecord("revenue-composition", {
...revenue,
slices: revenue.slices.slice(0, 4)
}), null);
assert.equal(workflow.normalizeRecord("revenue-composition", {
...revenue,
slices: [{ label: "bad_label", percentageText: "50", percentage: 50 }, ...revenue.slices.slice(1)]
}), null);
assert.equal(workflow.normalizeRecord("revenue-composition", {
...revenue,
slices: [{ label: "A", percentageText: "50", percentage: 49 }, ...revenue.slices.slice(1)]
}), null);
assert.equal(workflow.normalizeRecord("growth-metrics", {
...record("growth-metrics"),
salesGrowth: [1, 2, Number.POSITIVE_INFINITY, 4]
}), null);
assert.equal(workflow.normalizeRecord("sales", {
...record("sales"),
quarters: quarters().slice(1)
}), null);
assert.equal(workflow.normalizeRecord("sales", {
...record("sales"),
stockName: "bad\nname"
}), null);
});
test("list requests normalize blank/search text and enforce exact screen bounds", () => {
assert.deepEqual(workflow.createListRequest("list-1", "sales", " "), {
requestId: "list-1",
screen: "sales",
query: ""
});
assert.deepEqual(listRequest(), {
requestId: "list-1",
screen: "sales",
query: "Alpha",
maximumResults: 25
});
assert.throws(() => workflow.createListRequest("bad id", "sales"), /request id/);
assert.throws(() => workflow.createListRequest("list", "unknown"), /screen/);
assert.throws(() => workflow.createListRequest("list", "sales", "", 1001), /limited/);
});
test("native list responses create trusted snapshots and reject duplicate or reordered names", () => {
const request = workflow.createListRequest("list-1", "sales", "Alpha", 25);
const payload = listPayload("sales", [record("sales", "Alpha"), record("sales", "Beta")]);
const normalized = workflow.normalizeListResponse(payload, request);
assert.ok(normalized);
assert.equal(normalized.items[0].rowVersion, version);
assert.ok(Object.isFrozen(normalized.items));
assert.equal(workflow.normalizeListResponse({
...payload,
items: [...payload.items].reverse()
}, request), null);
assert.equal(workflow.normalizeListResponse(listPayload("sales", [
record("sales", "Alpha"), record("sales", "ALPHA")
]), request), null);
assert.equal(workflow.normalizeListResponse({ ...payload, requestId: "stale" }, request), null);
assert.equal(workflow.normalizeListResponse({
...payload,
items: [{ ...payload.items[0], rowVersion: "bad" }, payload.items[1]]
}, request), null);
});
test("list errors are exact and correlated", () => {
const request = listRequest();
assert.deepEqual(workflow.normalizeListError({
requestId: "list-1",
screen: "sales",
query: "Alpha",
message: "Database unavailable"
}, request), {
requestId: "list-1",
screen: "sales",
query: "Alpha",
message: "Database unavailable"
});
assert.equal(workflow.normalizeListError({
requestId: "other",
screen: "sales",
query: "Alpha",
message: "Database unavailable"
}, request), null);
});
test("load responses are exact native refreshes and never trust a stale request", () => {
const snapshot = trustedSnapshot("sales");
const request = workflow.createLoadRequest("load-1", "sales", "Alpha");
assert.deepEqual(request, {
requestId: "load-1",
screen: "sales",
stockName: "Alpha"
});
const loaded = trustedLoad(snapshot);
assert.deepEqual(loaded.snapshot.record, snapshot.record);
assert.equal(workflow.normalizeLoadResponse({
requestId: "load-1",
screen: "sales",
retrievedAt: "2026-07-12T00:20:00+09:00",
snapshot: {
stockName: "Beta",
rowVersion: snapshot.rowVersion,
record: record("sales", "Beta")
}
}, request), null);
assert.deepEqual(workflow.normalizeLoadError({
requestId: "load-1",
screen: "sales",
stockName: "Alpha",
code: "NOT_FOUND",
message: "Missing",
retryable: false
}, request).code, "NOT_FOUND");
});
test("stock master normalization enforces provider identity and rejects duplicate rows", () => {
const normalized = workflow.normalizeStockSearchResponse(stockPayload());
assert.ok(normalized);
assert.equal(normalized.results[0].source, "oracle");
assert.equal(workflow.normalizeStockSearchResponse(stockPayload([{
market: "kospi", source: "mariaDb", name: "Alpha", code: "000001"
}])), null);
assert.equal(workflow.normalizeStockSearchResponse(stockPayload([
{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" },
{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }
])), null);
});
test("verification rejects same-name market ambiguity and fabricated responses", () => {
const value = record("sales");
assert.equal(workflow.verifyStockForRecord(value, stockPayload(), "kospi", "000001"), null);
const response = workflow.normalizeStockSearchResponse(stockPayload([
{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" },
{ market: "kosdaq", source: "oracle", name: "Alpha", code: "000002" }
]));
assert.ok(response);
assert.equal(workflow.verifyStockForRecord(value, response, "kospi", "000001"), null);
const one = workflow.normalizeStockSearchResponse(stockPayload());
assert.equal(workflow.verifyStockForRecord(value, one, "kosdaq", "000001"), null);
});
test("selection requests bind snapshot version and freshly verified stock to native result", () => {
const snapshot = trustedSnapshot("sales");
const stock = verifiedStock(snapshot.record);
const request = workflow.createSelectionRequest("selection-1", snapshot, stock);
assert.deepEqual(request, {
requestId: "selection-1",
screen: "sales",
stockName: "Alpha",
rowVersion: version,
stock: {
market: "kospi",
source: "oracle",
name: "Alpha",
code: "000001"
}
});
const response = trustedSelection(snapshot, stock);
assert.equal(response.builderKey, "s5080");
assert.equal(workflow.normalizeSelectionResponse({
...response,
rowVersion: "B".repeat(64)
}, request), null);
assert.deepEqual(workflow.normalizeSelectionError({
requestId: "selection-1",
screen: "sales",
stockName: "Alpha",
code: "STALE_ROW",
message: "Reload",
retryable: false
}, request).code, "STALE_ROW");
});
test("create requires a verified exact stock and sends no row version", () => {
const value = record("sales");
const stock = verifiedStock(value);
assert.deepEqual(workflow.createCreateRequest("create-1", value, stock), {
requestId: "create-1",
screen: "sales",
stock: {
market: "kospi",
source: "oracle",
name: "Alpha",
code: "000001"
},
record: value
});
assert.throws(() => workflow.createCreateRequest("create-1", value, {
market: "kospi", source: "oracle", name: "Alpha", code: "000001"
}), /verified stock/);
assert.throws(() => workflow.createCreateRequest(
"create-1",
record("sales", "Beta"),
stock), /verified stock/);
});
test("update and single delete require trusted snapshots and optimistic row version", () => {
const snapshot = trustedSnapshot("sales");
const replacement = record("sales");
assert.deepEqual(workflow.createUpdateRequest("update-1", snapshot, replacement), {
requestId: "update-1",
screen: "sales",
stockName: "Alpha",
expectedRowVersion: version,
record: replacement
});
assert.deepEqual(workflow.createDeleteRequest("delete-1", snapshot), {
requestId: "delete-1",
screen: "sales",
stockName: "Alpha",
expectedRowVersion: version
});
assert.throws(() => workflow.createUpdateRequest(
"update-1",
snapshot,
record("sales", "Beta")), /cannot rename/);
assert.throws(() => workflow.createDeleteRequest("delete-1", {
...snapshot
}), /native-normalized/);
});
test("delete-all requires the exact table-specific confirmation token", () => {
assert.deepEqual(workflow.createDeleteAllRequest(
"delete-all-1",
"sales",
"DELETE_ALL_INPUT_SELL"), {
requestId: "delete-all-1",
screen: "sales",
confirmationToken: "DELETE_ALL_INPUT_SELL"
});
assert.throws(() => workflow.createDeleteAllRequest(
"delete-all-1",
"sales",
"DELETE_ALL_INPUT_PROFIT"), /confirmation token/);
});
test("mutation results correlate and errors can never request automatic retry", () => {
const snapshot = trustedSnapshot("sales");
const request = workflow.createDeleteRequest("delete-1", snapshot);
assert.deepEqual(workflow.normalizeMutationResult({
requestId: "delete-1",
operationId: "operation-1",
operation: "delete-one",
screen: "sales",
stockName: "Alpha",
committedAt: "2026-07-12T00:12:00+09:00",
affectedRows: 1
}, request), {
requestId: "delete-1",
operationId: "operation-1",
operation: "delete-one",
screen: "sales",
stockName: "Alpha",
committedAt: "2026-07-12T00:12:00+09:00",
affectedRows: 1
});
assert.deepEqual(workflow.normalizeMutationError({
requestId: "delete-1",
operation: "delete-one",
screen: "sales",
stockName: "Alpha",
code: "OUTCOME_UNKNOWN",
message: "Do not retry automatically",
retryable: false,
outcomeUnknown: true
}, request).outcomeUnknown, true);
assert.equal(workflow.normalizeMutationResult({
requestId: "delete-1",
operationId: "operation-1",
operation: "update",
screen: "sales",
stockName: "Alpha",
committedAt: "2026-07-12T00:12:00+09:00",
affectedRows: 1
}, request), null);
assert.equal(workflow.normalizeMutationError({
requestId: "delete-1",
operation: "delete-one",
screen: "sales",
stockName: "Alpha",
code: "OUTCOME_UNKNOWN",
message: "retry",
retryable: true,
outcomeUnknown: true
}, request), null);
});
test("only trusted DB snapshots and verified master rows create the four exact one-page cuts", () => {
const expectations = [
["revenue-composition", "s5076", "5076", "REVENUE_COMPOSITION"],
["growth-metrics", "s5079", "5079", "GROWTH_METRICS"],
["sales", "s5080", "5080", "SALES"],
["operating-profit", "s5081", "5081", "OPERATING_PROFIT"]
];
for (const [screen, builderKey, code, graphicType] of expectations) {
const snapshot = trustedSnapshot(screen);
const stock = verifiedStock(snapshot.record);
assert.deepEqual(workflow.buildSelection(snapshot, stock), {
builderKey,
code,
aliases: [code],
selection: {
groupCode: "KOSPI",
subject: "Alpha",
graphicType,
subtype: "",
dataCode: ""
},
currentPage: 1,
pageCount: 1
});
}
const rawSnapshot = listPayload("sales").items[0];
const stock = verifiedStock(record("sales"));
assert.equal(workflow.buildSelection(rawSnapshot, stock), null);
});
test("playlist entry has the complete standard operator metadata and native trust", () => {
const snapshot = trustedSnapshot("operating-profit");
const stock = verifiedStock(snapshot.record);
const selection = trustedSelection(snapshot, stock);
const entry = workflow.createPlaylistEntry(snapshot, stock, selection, {
id: "profit-1",
fadeDuration: 8
});
assert.deepEqual(entry, {
id: "profit-1",
builderKey: "s5081",
code: "5081",
aliases: ["5081"],
title: "Alpha",
detail: "영업이익",
category: "manual-financial",
market: "kospi",
stockName: "Alpha",
stockCode: "000001",
isNxt: false,
selection: {
groupCode: "KOSPI",
subject: "Alpha",
graphicType: "OPERATING_PROFIT",
subtype: "",
dataCode: ""
},
enabled: true,
fadeDuration: 8,
graphicType: "OPERATING_PROFIT",
subtype: "",
currentPage: 1,
pageCount: 1,
operator: {
source: "manual-financial-workflow",
schemaVersion: 1,
screen: "operating-profit",
snapshot: {
screen: "operating-profit",
stockName: "Alpha",
rowVersion: version,
record: snapshot.record
},
verifiedStock: {
market: "kospi",
source: "oracle",
name: "Alpha",
code: "000001"
}
}
});
assert.equal(workflow.isPlaylistEntryPlayable(entry), true);
assert.equal(workflow.isPlaylistEntryPlayable(JSON.parse(JSON.stringify(entry))), false);
assert.throws(() => workflow.createPlaylistEntry(snapshot, stock, {
...selection
}, { id: "forged" }), /native-confirmed/);
});
test("stored entries restore only as non-playable refresh-required descriptors", () => {
const snapshot = trustedSnapshot("sales");
const stock = verifiedStock(snapshot.record);
const selection = trustedSelection(snapshot, stock);
const live = workflow.createPlaylistEntry(snapshot, stock, selection, {
id: "sales-restore",
fadeDuration: 7,
enabled: false
});
const stored = JSON.parse(JSON.stringify(live));
const pending = workflow.restorePlaylistEntry(stored);
assert.ok(pending);
assert.equal(pending.status, "refresh-required");
assert.equal(pending.playable, false);
assert.equal(workflow.isPlaylistEntryPlayable(pending.entry), false);
assert.deepEqual(workflow.createRestoreLoadRequest("restore-load", pending), {
requestId: "restore-load",
screen: "sales",
stockName: "Alpha"
});
assert.equal(workflow.restorePlaylistEntry({
...stored,
selection: { ...stored.selection, dataCode: "tampered" }
}), null);
assert.equal(workflow.restorePlaylistEntry({
...stored,
operator: { ...stored.operator, schemaVersion: 2 }
}), null);
});
test("native load, fresh stock verification and native selection materialize a stored entry", () => {
const originalSnapshot = trustedSnapshot("sales");
const originalStock = verifiedStock(originalSnapshot.record);
const originalSelection = trustedSelection(originalSnapshot, originalStock);
const stored = JSON.parse(JSON.stringify(workflow.createPlaylistEntry(
originalSnapshot,
originalStock,
originalSelection,
{ id: "sales-restore", fadeDuration: 7, enabled: false })));
const pending = workflow.restorePlaylistEntry(stored);
const loadRequest = workflow.createRestoreLoadRequest("restore-load", pending);
const loadResponse = workflow.normalizeLoadResponse({
requestId: "restore-load",
screen: "sales",
retrievedAt: "2026-07-12T00:20:00+09:00",
snapshot: {
stockName: stored.operator.snapshot.stockName,
rowVersion: stored.operator.snapshot.rowVersion,
record: stored.operator.snapshot.record
}
}, loadRequest);
assert.ok(loadResponse);
const freshStockResponse = workflow.normalizeStockSearchResponse(stockPayload());
const freshStock = workflow.verifyStockForRecord(
loadResponse.snapshot.record,
freshStockResponse,
pending.verifiedStock.market,
pending.verifiedStock.code);
assert.ok(freshStock);
const selectionRequest = workflow.createRestoreSelectionRequest(
"restore-selection",
pending,
loadResponse,
freshStock);
const profile = workflow.getScreenDefinition("sales");
const selectionResponse = workflow.normalizeSelectionResponse({
requestId: "restore-selection",
screen: "sales",
stockName: "Alpha",
rowVersion: version,
builderKey: profile.builderKey,
code: profile.code,
aliases: [profile.code],
selection: {
groupCode: "KOSPI",
subject: "Alpha",
graphicType: profile.graphicType,
subtype: "",
dataCode: ""
},
currentPage: 1,
totalPages: 1
}, selectionRequest);
const restored = workflow.materializeRestoredPlaylistEntry(
pending,
loadResponse,
freshStock,
selectionResponse);
assert.deepEqual(restored, stored);
assert.equal(workflow.isPlaylistEntryPlayable(restored), true);
});
test("row-version or record drift keeps a stored entry permanently unplayable", () => {
const snapshot = trustedSnapshot("sales");
const stock = verifiedStock(snapshot.record);
const selection = trustedSelection(snapshot, stock);
const stored = JSON.parse(JSON.stringify(workflow.createPlaylistEntry(
snapshot,
stock,
selection,
{ id: "stale-entry" })));
const pending = workflow.restorePlaylistEntry(stored);
const request = workflow.createRestoreLoadRequest("restore-load", pending);
const changed = workflow.normalizeLoadResponse({
requestId: "restore-load",
screen: "sales",
retrievedAt: "2026-07-12T00:20:00+09:00",
snapshot: {
stockName: "Alpha",
rowVersion: "B".repeat(64),
record: record("sales")
}
}, request);
const freshStockResponse = workflow.normalizeStockSearchResponse(stockPayload());
const freshStock = workflow.verifyStockForRecord(
changed.snapshot.record,
freshStockResponse,
"kospi",
"000001");
assert.throws(() => workflow.createRestoreSelectionRequest(
"restore-selection",
pending,
changed,
freshStock), /did not reproduce/);
assert.equal(workflow.materializeRestoredPlaylistEntry(
pending,
changed,
freshStock,
selection), null);
assert.equal(workflow.isPlaylistEntryPlayable(stored), false);
});

View File

@@ -0,0 +1,56 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const assert = require("node:assert/strict");
const root = path.resolve(__dirname, "..", "..");
const native = fs.readFileSync(path.join(root, "MainWindow.ManualLists.cs"), "utf8");
const playout = fs.readFileSync(path.join(root, "MainWindow.Playout.cs"), "utf8");
const factory = fs.readFileSync(path.join(root, "LegacySceneRuntimeFactory.cs"), "utf8");
test("native VI responses expose the canonical version and no-op persistence receipt", () => {
assert.match(native, /ReadSnapshotAsync\(_lifetimeCancellation\.Token\)/u);
assert.match(native, /version = snapshot\.RowVersion/u);
assert.match(native, /persisted = result\.Persisted/u);
assert.match(native, /version = result\.Snapshot\.RowVersion/u);
});
test("VI writes require a closed expected version and compare it under the data gate before mutation", () => {
assert.match(native, /HasOnlyProperties\(payload, "requestId", "expectedVersion", "items"\)/u);
assert.match(native, /TrustedViManualReference\.IsRowVersion\(expectedVersion\)/u);
const methodStart = native.indexOf("private async Task HandleViManualListWriteAsync");
const methodEnd = native.indexOf("private async Task<bool> TryEnterManualMutationGatesAsync", methodStart);
const method = native.slice(methodStart, methodEnd);
const gate = method.indexOf("TryEnterManualMutationGatesAsync");
const reread = method.indexOf("var currentSnapshot = await store.ReadSnapshotAsync");
const compare = method.indexOf("currentSnapshot.RowVersion");
const stale = method.indexOf('"STALE_DATA"');
const mutation = method.indexOf("var result = await store.WriteAsync");
assert.ok(methodStart >= 0 && methodEnd > methodStart);
assert.ok(gate >= 0 && gate < reread && reread < compare && compare < stale && stale < mutation);
assert.match(method.slice(stale, mutation), /retryable: false,[\s\S]*outcomeUnknown: false/u);
});
test("different post-error readback always enters OutcomeUnknown quarantine", () => {
assert.equal(native.includes('"SAVE_FAILED"'), false);
assert.match(native, /수동 순매도 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다/u);
assert.match(native, /VI 수동 목록 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다/u);
assert.ok((native.match(/PostManualOperatorQuarantine\(requestId, "save-/gu) || []).length >= 4);
});
test("configured external operator paths are disabled before any directory creation", () => {
const configuredGuard = native.indexOf("if (!string.IsNullOrWhiteSpace(configuredDirectory))");
const privatePrepare = native.indexOf("TrustedManualDirectory.PreparePrivateWritableDirectory");
assert.ok(configuredGuard >= 0);
assert.ok(privatePrepare > configuredGuard);
assert.equal(native.includes("Directory.CreateDirectory(directory)"), false);
});
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);
});

View File

@@ -0,0 +1,353 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const assert = require("node:assert/strict");
const ui = require("../../Web/manual-lists-ui.js");
const versionA = "a".repeat(64);
const versionB = "b".repeat(64);
const netRows = () => Array.from({ length: 5 }, (_, index) => ({
leftName: `좌측${index + 1}`,
leftAmount: `${index + 1},000`,
rightName: `우측${index + 1}`,
rightAmount: `-${index + 1},000`
}));
const viItems = () => [
{ code: "P005930", name: "삼성전자" },
{ code: "P000660", name: "SK하이닉스" },
{ code: "D035720", name: "카카오" }
];
function fixture() {
let sequence = 0;
let externallyLocked = false;
const posts = [];
const entries = [];
const toasts = [];
const logs = [];
const controller = ui.createController({
postNative: (type, payload) => posts.push({ type, payload }),
appendPlaylistEntry: entry => entries.push(entry),
isLocked: () => externallyLocked,
showToast: (message, kind) => toasts.push({ message, kind }),
addLog: message => logs.push(message),
createId: () => `manual_ui_${++sequence}`
});
return {
controller,
posts,
entries,
toasts,
logs,
setExternalLock: value => { externallyLocked = value; },
latest: type => [...posts].reverse().find(item => item.type === type)
};
}
function enableManualStore(context) {
const requestId = context.controller.getState().pending.status;
assert.equal(context.controller.handleMessage("manual-operator-data-status", {
requestId,
available: true,
writeQuarantined: false,
message: null
}), true);
}
test("FSell cut opens only after five saved rows are re-read unchanged", () => {
const context = fixture();
const { controller } = context;
controller.openNetSell("FOREIGN");
enableManualStore(context);
const initialRead = controller.getState().pending.netRead;
assert.equal(controller.handleMessage("manual-net-sell-data", {
requestId: initialRead,
audience: "FOREIGN",
rows: netRows()
}), true);
assert.equal(controller.actions.addNetSellCut(), false);
assert.equal(controller.actions.saveNetSell(), true);
const save = context.latest("save-manual-net-sell-data");
assert.equal(save.payload.rows.length, 5);
assert.equal(controller.handleMessage("manual-net-sell-save-result", {
requestId: save.payload.requestId,
audience: "FOREIGN",
saved: true,
recovered: false
}), true);
assert.equal(controller.actions.addNetSellCut(), false);
const verificationRead = controller.getState().pending.netRead;
assert.equal(controller.handleMessage("manual-net-sell-data", {
requestId: verificationRead,
audience: "FOREIGN",
rows: netRows()
}), true);
assert.equal(controller.getState().netVerified, true);
assert.equal(controller.actions.addNetSellCut(), true);
assert.equal(context.entries[0].builderKey, "s5025");
controller.actions.setNetCell(0, "leftAmount", "변경");
assert.equal(controller.actions.addNetSellCut(), false);
});
test("VI cut binds the latest version and reorder requires save plus matching reread", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
const read = controller.getState().pending.viRead;
assert.equal(controller.handleMessage("vi-manual-list-data", {
requestId: read,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
}), true);
assert.equal(controller.actions.addViCut(), true);
assert.equal(context.entries[0].selection.subject, "@MBN_VI_SNAPSHOT_V1");
assert.equal(context.entries[0].selection.dataCode, versionA);
assert.equal(controller.actions.moveViItem(2, -1), true);
assert.deepEqual(controller.getState().viItems.map(item => item.name), [
"삼성전자", "카카오", "SK하이닉스"
]);
assert.equal(controller.getState().viBaseVersion, versionA);
assert.equal(controller.getState().viVersion, null);
assert.equal(controller.actions.addViCut(), false);
assert.equal(controller.actions.saveVi(), true);
const save = context.latest("save-vi-manual-list");
assert.equal(save.payload.expectedVersion, versionA);
assert.equal(controller.handleMessage("vi-manual-list-save-result", {
requestId: save.payload.requestId,
saved: true,
recovered: false,
persisted: true,
itemCount: 3,
pageCount: 1,
version: versionB
}), true);
const verify = controller.getState().pending.viRead;
assert.equal(controller.handleMessage("vi-manual-list-data", {
requestId: verify,
itemCount: 3,
pageCount: 1,
items: save.payload.items,
version: versionB
}), true);
assert.equal(controller.getState().viVerified, true);
assert.equal(controller.actions.addViCut(), true);
assert.equal(context.entries[1].selection.dataCode, versionB);
assert.deepEqual(context.entries[1].operator.items, save.payload.items);
});
test("VI mismatched post-save version never opens the playlist gate", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
controller.actions.moveViItem(2, -1);
controller.actions.saveVi();
const save = context.latest("save-vi-manual-list");
controller.handleMessage("vi-manual-list-save-result", {
requestId: save.payload.requestId,
saved: true,
recovered: false,
persisted: true,
itemCount: 3,
pageCount: 1,
version: versionB
});
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: save.payload.items,
version: versionA
});
assert.equal(controller.getState().viVerified, false);
assert.equal(controller.actions.addViCut(), false);
});
test("VI stale-data conflict is terminal, preserves the draft, and requires reread", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
controller.actions.moveViItem(2, -1);
const draft = controller.getState().viItems;
assert.equal(controller.actions.saveVi(), true);
const save = context.latest("save-vi-manual-list");
const postCount = context.posts.length;
assert.equal(controller.handleMessage("manual-operator-data-error", {
requestId: save.payload.requestId,
operation: "save-vi",
code: "STALE_DATA",
message: "The VI list changed. Reload before saving.",
retryable: false,
outcomeUnknown: false
}), true);
assert.deepEqual(controller.getState().viItems, draft);
assert.equal(controller.getState().viBaseVersion, null);
assert.equal(controller.getState().viVersion, null);
assert.equal(controller.getState().viVerified, false);
assert.equal(controller.getState().quarantined, false);
assert.equal(controller.actions.saveVi(), false);
assert.equal(context.posts.length, postCount);
});
test("empty VI save is a successful no-op and can never create an empty cut", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
controller.actions.clearViItems();
assert.equal(controller.actions.addViCut(), false);
assert.equal(controller.actions.saveVi(), true);
const save = context.latest("save-vi-manual-list");
assert.equal(save.payload.expectedVersion, versionA);
assert.deepEqual(save.payload.items, []);
assert.equal(controller.handleMessage("vi-manual-list-save-result", {
requestId: save.payload.requestId,
saved: true,
recovered: false,
persisted: false,
itemCount: 3,
pageCount: 1,
version: versionA
}), true);
assert.ok(context.toasts.some(item => item.message.includes("변경하지 않았습니다")));
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
assert.equal(controller.getState().viItems.length, 3);
controller.actions.clearViItems();
assert.equal(controller.actions.addViCut(), false);
});
test("stale correlations are ignored and OutcomeUnknown quarantines every later save", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
const staleRead = controller.getState().pending.viRead;
controller.actions.requestViRead();
const currentRead = controller.getState().pending.viRead;
assert.notEqual(staleRead, currentRead);
assert.equal(controller.handleMessage("vi-manual-list-data", {
requestId: staleRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
}), false);
assert.equal(controller.getState().viItems.length, 0);
controller.handleMessage("vi-manual-list-data", {
requestId: currentRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
assert.equal(controller.actions.saveVi(), true);
const save = context.latest("save-vi-manual-list");
assert.equal(controller.handleMessage("manual-operator-data-error", {
requestId: save.payload.requestId,
operation: "save-vi",
code: "OUTCOME_UNKNOWN",
message: "저장 결과를 확인할 수 없습니다.",
retryable: false,
outcomeUnknown: true
}), true);
assert.equal(controller.getState().quarantined, true);
const postCount = context.posts.length;
assert.equal(controller.actions.saveVi(), false);
assert.equal(context.posts.length, postCount);
});
test("stock search is exact and correlated before it can append a VI row", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
assert.equal(controller.actions.searchStocks("삼성"), true);
const search = context.latest("search-stocks");
assert.equal(controller.handleMessage("stock-search-results", {
requestId: "stale_request",
query: "삼성",
retrievedAt: "2026-07-12T00:00:00+09:00",
totalRowCount: 1,
truncated: false,
results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }]
}), false);
assert.equal(controller.handleMessage("stock-search-results", {
requestId: search.payload.requestId,
query: "삼성",
retrievedAt: "2026-07-12T00:00:00+09:00",
totalRowCount: 1,
truncated: false,
results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }]
}), true);
assert.equal(controller.actions.addSearchResult(0), true);
assert.deepEqual(controller.getState().viItems, [{ code: "P005930", name: "삼성전자" }]);
});
test("explicit and host locks block edits, saves, and cut creation", () => {
const context = fixture();
const { controller } = context;
controller.openNetSell("INDIVIDUAL");
enableManualStore(context);
controller.handleMessage("manual-net-sell-data", {
requestId: controller.getState().pending.netRead,
audience: "INDIVIDUAL",
rows: netRows()
});
controller.setLocked(true);
assert.equal(controller.actions.setNetCell(0, "leftName", "변경"), false);
assert.equal(controller.actions.saveNetSell(), false);
controller.setLocked(false);
context.setExternalLock(true);
assert.equal(controller.actions.saveNetSell(), false);
assert.equal(controller.getState().locked, true);
});
test("controller source constructs DOM without HTML injection or filesystem paths", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "..", "..", "Web", "manual-lists-ui.js"),
"utf8");
assert.equal(source.includes("innerHTML"), false);
assert.equal(source.includes("insertAdjacentHTML"), false);
assert.equal(/(?:[A-Z]:\\|file:\/\/)/u.test(source), false);
});

View File

@@ -0,0 +1,297 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/manual-lists-workflow.js");
const requestId = "manual_req_1";
const rowVersion = "a".repeat(64);
const rows = () => Array.from({ length: 5 }, (_, index) => ({
leftName: `왼쪽${index + 1}`,
leftAmount: `${index + 1},000`,
rightName: `오른쪽${index + 1}`,
rightAmount: `-${index + 1},000`
}));
const viItems = () => [
{ code: "005930", name: "삼성전자" },
{ code: "000660", name: "SK하이닉스" },
{ code: "005930", name: "삼성전자" }
];
test("bridge requests expose only closed operations and keys", () => {
assert.deepEqual(workflow.createStatusRequest(requestId), {
type: "request-manual-operator-data-status",
payload: { requestId }
});
assert.deepEqual(workflow.createNetSellReadRequest(requestId, "FOREIGN"), {
type: "request-manual-net-sell-data",
payload: { requestId, audience: "FOREIGN" }
});
assert.deepEqual(workflow.createViReadRequest(requestId), {
type: "request-vi-manual-list",
payload: { requestId }
});
});
test("net-sell save request requires exactly five strict rows", () => {
const request = workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows());
assert.equal(request.type, "save-manual-net-sell-data");
assert.equal(request.payload.rows.length, 5);
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows().slice(0, 4)));
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "OTHER", rows()));
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", [
...rows().slice(0, 4),
{ ...rows()[4], extra: "not allowed" }
]));
});
test("manual value delimiter and control characters fail closed", () => {
for (const leftName of ["bad^name", "bad\nname", "bad\u200Ename", "bad\uFFFDname"]) {
const invalid = rows();
invalid[0].leftName = leftName;
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "FOREIGN", invalid));
}
});
test("VI save preserves order and duplicates but never exposes a path", () => {
const request = workflow.createViSaveRequest(requestId, viItems(), rowVersion);
assert.deepEqual(request, {
type: "save-vi-manual-list",
payload: { requestId, expectedVersion: rowVersion, items: viItems() }
});
assert.equal(JSON.stringify(request).includes("path"), false);
assert.throws(() => workflow.createViSaveRequest(requestId, viItems()));
assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "A".repeat(64)));
assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "a".repeat(63)));
});
test("VI list rejects comma ambiguity, unsafe text, and more than twenty pages", () => {
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A,B" }], rowVersion));
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1^2", name: "A" }], rowVersion));
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: " 1", name: "A" }], rowVersion));
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A " }], rowVersion));
assert.throws(() => workflow.createViSaveRequest(
requestId,
Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `Item${index + 1}` })),
rowVersion));
assert.throws(() => workflow.createViSaveRequest(
requestId,
Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `종목${index + 1}` }))));
});
test("status response preserves process-lifetime write quarantine", () => {
assert.deepEqual(workflow.normalizeStatusResponse({
requestId,
available: true,
writeQuarantined: true,
message: "저장 결과를 확인할 수 없습니다."
}, requestId), {
requestId,
available: true,
writeQuarantined: true,
message: "저장 결과를 확인할 수 없습니다."
});
assert.equal(workflow.normalizeStatusResponse({
requestId,
available: true,
writeQuarantined: true,
message: "저장 결과를 확인할 수 없습니다.",
extra: true
}), null);
});
test("net-sell data response is correlated and exact", () => {
const response = {
requestId,
audience: "INSTITUTION",
rows: rows()
};
assert.ok(workflow.normalizeNetSellDataResponse(response, {
requestId,
audience: "INSTITUTION"
}));
assert.equal(workflow.normalizeNetSellDataResponse(response, {
requestId: "other",
audience: "INSTITUTION"
}), null);
});
test("net-sell write result distinguishes verified recovery", () => {
const response = {
requestId,
audience: "FOREIGN",
saved: true,
recovered: true
};
assert.ok(workflow.normalizeNetSellSaveResponse(response, { requestId, audience: "FOREIGN" }));
assert.equal(workflow.normalizeNetSellSaveResponse({ ...response, saved: false }), null);
});
test("VI data response verifies item and page counts", () => {
const response = {
requestId,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: rowVersion
};
assert.ok(workflow.normalizeViDataResponse(response, requestId));
assert.equal(workflow.normalizeViDataResponse({ ...response, pageCount: 2 }), null);
assert.equal(workflow.normalizeViDataResponse({ ...response, itemCount: 2 }), null);
});
test("VI save response rejects impossible page counts", () => {
assert.ok(workflow.normalizeViSaveResponse({
requestId,
saved: true,
recovered: false,
persisted: true,
itemCount: 100,
pageCount: 20,
version: rowVersion
}, requestId));
assert.equal(workflow.normalizeViSaveResponse({
requestId,
saved: true,
recovered: false,
persisted: true,
itemCount: 100,
pageCount: 19,
version: rowVersion
}), null);
assert.ok(workflow.normalizeViSaveResponse({
requestId,
saved: true,
recovered: false,
persisted: false,
itemCount: 3,
pageCount: 1,
version: rowVersion
}, requestId));
});
test("OutcomeUnknown errors are never retryable", () => {
assert.ok(workflow.normalizeError({
requestId,
operation: "save-vi",
code: "OUTCOME_UNKNOWN",
message: "저장 결과를 확인할 수 없습니다.",
retryable: false,
outcomeUnknown: true
}, requestId));
assert.equal(workflow.normalizeError({
requestId,
operation: "save-vi",
code: "OUTCOME_UNKNOWN",
message: "저장 결과를 확인할 수 없습니다.",
retryable: true,
outcomeUnknown: true
}), null);
});
test("manual net-sell playlist mapping is original s5025 contract", () => {
const entry = workflow.createNetSellPlaylistEntry("INDIVIDUAL", {
id: "entry_1",
fadeDuration: 6
});
assert.equal(entry.builderKey, "s5025");
assert.equal(entry.code, "5025");
assert.deepEqual(entry.selection, {
groupCode: "INDIVIDUAL",
subject: "INDEX",
graphicType: "MANUAL_NET_SELL",
subtype: "MANUAL_NET_SELL",
dataCode: ""
});
});
test("VI playlist mapping uses a bounded trusted snapshot reference", () => {
const entry = workflow.createViPlaylistEntry(viItems(), rowVersion, {
id: "entry_2",
fadeDuration: 4
});
assert.equal(entry.builderKey, "s5074");
assert.equal(entry.code, "5074");
assert.equal(entry.itemCount, 3);
assert.equal(entry.pageSize, 5);
assert.equal(entry.pageCount, 1);
assert.equal(entry.selection.groupCode, "PAGED_VI");
assert.equal(entry.selection.subject, workflow.viSnapshotSubject);
assert.equal(entry.selection.dataCode, rowVersion);
assert.ok(entry.selection.subject.length <= 256);
});
test("one hundred realistic long names still create a bounded twenty-page reference", () => {
const items = Array.from({ length: 100 }, (_, index) => ({
code: `P${String(index + 1).padStart(6, "0")}`,
name: `현실적인긴종목명테스트${index + 1}`
}));
const entry = workflow.createViPlaylistEntry(items, rowVersion, {
id: "entry_20_pages",
fadeDuration: 4
});
assert.equal(entry.pageCount, 20);
assert.equal(entry.selection.subject, workflow.viSnapshotSubject);
assert.equal(entry.selection.dataCode.length, 64);
assert.equal(JSON.stringify(entry.selection).includes(items[0].name), false);
});
test("empty VI list can be saved but cannot create a playout entry", () => {
assert.deepEqual(workflow.createViSaveRequest(requestId, [], rowVersion), {
type: "save-vi-manual-list",
payload: { requestId, expectedVersion: rowVersion, items: [] }
});
assert.throws(() => workflow.createViPlaylistEntry([], rowVersion, {
id: "entry_empty",
fadeDuration: 6
}));
});
test("trusted restore recreates manual entries and rejects tampering", () => {
const netSell = workflow.createNetSellPlaylistEntry("FOREIGN", {
id: "entry_net",
fadeDuration: 6
});
const vi = workflow.createViPlaylistEntry(viItems(), rowVersion, {
id: "entry_vi",
fadeDuration: 6
});
assert.deepEqual(workflow.restorePlaylistEntry(netSell), netSell);
assert.deepEqual(workflow.restorePlaylistEntry(vi), vi);
assert.equal(workflow.restorePlaylistEntry({
...vi,
selection: { ...vi.selection, subject: "다른종목" }
}), null);
assert.equal(workflow.restorePlaylistEntry({ ...vi, extra: true }), null);
assert.equal(workflow.restorePlaylistEntry({
...vi,
operator: {
...vi.operator,
pagePreview: { ...vi.operator.pagePreview, pageCount: 20 }
}
}), null);
assert.equal(workflow.restorePlaylistEntry({
...vi,
selection: { ...vi.selection, extra: true }
}), null);
assert.equal(workflow.restorePlaylistEntry({
...netSell,
operator: { ...netSell.operator, audience: "OTHER" }
}), null);
});
test("request identifiers and option bounds are strict", () => {
assert.throws(() => workflow.createStatusRequest("bad id"));
assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {
id: "entry",
fadeDuration: 61
}));
assert.throws(() => workflow.createViPlaylistEntry(viItems(), rowVersion, {
id: "entry",
fadeDuration: -1
}));
assert.throws(() => workflow.createViPlaylistEntry(viItems(), "A".repeat(64), {
id: "entry",
fadeDuration: 4
}));
});

View File

@@ -0,0 +1,149 @@
const test = require("node:test");
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");
function raw(overrides = {}) {
return {
itemIndex: 0,
enabled: true,
groupCode: "INDIVIDUAL",
subject: "INDEX",
graphicType: "MANUAL_NET_SELL",
subtype: "MANUAL_NET_SELL",
dataCode: "",
...overrides
};
}
const options = { id: "db-invalid-00000001-0", fadeDuration: 6 };
const rows = Array.from({ length: 5 }, (_, index) => ({
leftName: `L${index}`, leftAmount: `${index}`,
rightName: `R${index}`, rightAmount: `${index + 10}`
}));
test("s5025 raw restoration accepts only canonical or exact legacy audience signatures", () => {
const canonical = workflow.classify(raw(), options);
assert.equal(canonical.kind, "net-sell");
assert.equal(canonical.audience, "INDIVIDUAL");
const legacy = workflow.classify(raw({
groupCode: "외국인 순매도 상위(수동)",
subject: "",
graphicType: "순매도 상위",
subtype: "순매도 상위"
}), options);
assert.equal(legacy.audience, "FOREIGN");
assert.equal(legacy.historical, true);
assert.equal(workflow.classify(raw({
groupCode: "외국인 순매도 상위",
subject: "",
graphicType: "순매도 상위",
subtype: "순매도 상위"
}), options), null);
assert.equal(workflow.classify(raw({ dataCode: "path.dat" }), options), null);
});
test("s5025 becomes playable only after one correlated trusted five-row read", () => {
const pending = workflow.classify(raw({ enabled: false }), options);
const request = workflow.createManualListReadRequest("named-net-read", pending);
const entry = workflow.materializeManualList(pending, {
requestId: "named-net-read",
audience: "INDIVIDUAL",
rows
}, request);
assert.ok(entry);
assert.equal(entry.enabled, false);
assert.equal(entry.builderKey, "s5025");
assert.equal(manualLists.restorePlaylistEntry(entry).operator.audience, "INDIVIDUAL");
assert.equal(workflow.materializeManualList(pending, {
requestId: "stale-read",
audience: "INDIVIDUAL",
rows
}, request), null);
});
test("versioned and historical VI raw rows require the current exact native snapshot", () => {
const version = "a".repeat(64);
const current = workflow.classify(raw({
groupCode: "PAGED_VI",
subject: manualLists.viSnapshotSubject,
graphicType: "INPUT_ORDER",
subtype: "CURRENT",
dataCode: version
}), options);
const request = workflow.createManualListReadRequest("named-vi-read", current);
const payload = {
requestId: "named-vi-read",
itemCount: 2,
pageCount: 1,
items: [{ code: "005930", name: "삼성전자" }, { code: "000660", name: "SK하이닉스" }],
version
};
assert.equal(workflow.materializeManualList(current, payload, request).builderKey, "s5074");
assert.equal(workflow.materializeManualList(current, { ...payload, version: "b".repeat(64) }, request), null);
const historical = workflow.classify(raw({
groupCode: "",
subject: "삼성전자,SK하이닉스",
graphicType: "5단 표그래프",
subtype: "VI 발동(수동)",
dataCode: ""
}), options);
const historicalRequest = workflow.createManualListReadRequest("named-vi-history", historical);
assert.ok(workflow.materializeManualList(historical, {
...payload,
requestId: "named-vi-history"
}, historicalRequest));
assert.equal(workflow.materializeManualList(historical, {
...payload,
requestId: "named-vi-history",
items: [...payload.items].reverse()
}, historicalRequest), null);
assert.equal(workflow.classify(raw({
groupCode: "",
subject: "삼성전자,삼성전자",
graphicType: "5단 표그래프",
subtype: "VI 발동(수동)"
}), options), null);
assert.equal(workflow.classify(raw({
groupCode: "",
subject: "삼성전자, SK하이닉스",
graphicType: "5단 표그래프",
subtype: "VI 발동(수동)"
}), options), null);
});
test("GraphE raw rows yield only a fresh-load descriptor with exact screen, market and name", () => {
for (const profile of manualFinancial.sourceContract.screens) {
const pending = workflow.classify(raw({
groupCode: "코스피",
subject: "삼성전자",
graphicType: profile.label,
subtype: "",
dataCode: ""
}), options);
assert.equal(pending.kind, "financial");
assert.equal(pending.screen, profile.screen);
assert.equal(pending.builderKey, profile.builderKey);
assert.equal(pending.market, "kospi");
assert.deepEqual(pending.identity, { screen: profile.screen, stockName: "삼성전자" });
}
const profile = manualFinancial.sourceContract.screens[0];
assert.equal(workflow.classify(raw({
groupCode: "UNKNOWN",
subject: "삼성전자",
graphicType: profile.graphicType,
subtype: "",
dataCode: ""
}), options), null);
assert.equal(workflow.classify(raw({
groupCode: "KOSPI",
subject: "삼성전자",
graphicType: profile.graphicType,
subtype: "",
dataCode: "005930"
}), options), null);
});

View File

@@ -0,0 +1,93 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const workflow = require("../../Web/named-playlist-workflow.js");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const mainWindow = fs.readFileSync(path.join(repositoryRoot, "MainWindow.xaml.cs"), "utf8");
const namedBridge = fs.readFileSync(path.join(repositoryRoot, "MainWindow.NamedPlaylists.cs"), "utf8");
const pagePlanBridge = fs.readFileSync(path.join(repositoryRoot, "MainWindow.PlaylistPagePlans.cs"), "utf8");
const appScript = fs.readFileSync(path.join(repositoryRoot, "Web", "app.js"), "utf8");
test("native switch and JavaScript contract use the same five requests and four responses", () => {
for (const requestType of Object.values(workflow.bridgeContract.requests)) {
assert.match(mainWindow, new RegExp(`case "${requestType}"`));
}
for (const responseType of Object.values(workflow.bridgeContract.responses)) {
assert.match(namedBridge, new RegExp(`"${responseType}"`));
}
assert.match(mainWindow, /new LegacyNamedPlaylistPersistenceService\(/);
assert.match(mainWindow, /new OracleNamedPlaylistMutationExecutor\(/);
});
test("named reads use latest-request cancellation and the shared playout-priority database gate", () => {
assert.match(namedBridge,
/Interlocked\.Exchange\(\s*ref _namedPlaylistReadCancellation,\s*requestCancellation\)/s);
assert.match(namedBridge, /CancelRequest\(previousCancellation\)/);
assert.ok((namedBridge.match(/await _databaseActivityGate\.WaitAsync\(requestToken\)/g) || []).length >= 2);
assert.ok((namedBridge.match(/Volatile\.Read\(ref _playoutCommandInFlight\) != 0/g) || []).length >= 4);
assert.match(mainWindow, /CancelRequest\(Volatile\.Read\(ref _namedPlaylistReadCancellation\)\)/);
assert.match(mainWindow, /CancelRequest\(Volatile\.Read\(ref _namedPlaylistWriteCancellation\)\)/);
});
test("writes are single-flight, never superseded, and permanently quarantine OutcomeUnknown", () => {
assert.match(namedBridge, /_namedPlaylistWriteGate\.WaitAsync\(0\)/);
assert.match(namedBridge, /catch \(NamedPlaylistMutationException exception\)/);
assert.match(namedBridge, /if \(exception\.OutcomeUnknown\)/);
assert.match(namedBridge,
/Interlocked\.CompareExchange\(ref _namedPlaylistWriteQuarantined, 1, 0\)/);
assert.match(namedBridge, /IsNamedPlaylistWriteQuarantined\(\)/);
assert.match(namedBridge, /do not retry|was not retried/i);
assert.doesNotMatch(namedBridge, /for\s*\([^)]*retry|while\s*\([^)]*retry/i);
});
test("browser correlation loss cancels active requests and latches an in-flight write", () => {
assert.match(mainWindow,
/private void QuarantineIfBrowserCorrelationCanBeLost\(\)\s*\{\s*InvalidateNamedPlaylistBrowserRequests\(\)/s);
assert.match(namedBridge, /Interlocked\.Increment\(ref _namedPlaylistBrowserGeneration\)/);
assert.match(namedBridge, /Volatile\.Read\(ref _namedPlaylistWriteInFlight\) != 0/);
assert.match(namedBridge,
/Interlocked\.Exchange\(\s*ref _namedPlaylistWriteCancellation,\s*null\)/s);
});
test("native replacement accepts only the exact eight raw-row properties and 1000 rows", () => {
for (const property of [
"itemIndex", "enabled", "groupCode", "subject",
"graphicType", "subtype", "pageText", "dataCode"
]) {
assert.match(namedBridge, new RegExp(`"${property}"`));
}
assert.match(namedBridge,
/itemsElement\.GetArrayLength\(\) > LegacyNamedPlaylistPersistenceService\.MaximumItems/);
assert.match(namedBridge, /itemIndex != expectedIndex/);
assert.match(namedBridge, /value\.Contains\('\^'\)/);
assert.match(namedBridge,
/return string\.Equals\(page\.ToString\(\), value, StringComparison\.Ordinal\)/);
});
test("page preflight uses one correlated read-only bridge with playout priority", () => {
assert.match(mainWindow, /case "request-playlist-page-plans"/);
assert.match(mainWindow, /CancelRequest\(Volatile\.Read\(ref _playlistPagePlanCancellation\)\)/);
assert.match(pagePlanBridge,
/Interlocked\.Exchange\(\s*ref _playlistPagePlanCancellation,\s*requestCancellation\)/s);
assert.match(pagePlanBridge, /await _databaseActivityGate\.WaitAsync\(requestToken\)/);
assert.ok((pagePlanBridge.match(/Volatile\.Read\(ref _playoutCommandInFlight\) != 0/g) || []).length >= 2);
assert.match(pagePlanBridge, /PreflightPagePlansAsync\(/);
assert.match(pagePlanBridge, /PostMessage\("playlist-page-plans-results"/);
assert.match(pagePlanBridge, /PostMessage\("playlist-page-plans-error"/);
assert.doesNotMatch(pagePlanBridge, /PrepareAsync\(|PlayAsync\(|ClearAsync\(|UnloadAsync\(/);
});
test("browser page-plan handling requires exact correlation and exposes explicit retry", () => {
assert.match(appScript, /normalizePagePlanResponse\(payload, pending\.request\)/);
assert.match(appScript, /payload\?\.requestId !== pending\.requestId/);
assert.match(appScript, /applyPagePlanResponse\(/);
assert.match(appScript, /page-result-empty/);
assert.match(appScript, /namedPlaylistPageRetryButton/);
assert.match(appScript, /case "playlist-page-plans-results"/);
assert.match(appScript, /case "playlist-page-plans-error"/);
assert.doesNotMatch(appScript, /markPageRecalculated\(/);
});

View File

@@ -0,0 +1,430 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/named-playlist-workflow.js");
const retrievedAt = "2026-07-11T09:30:00+09:00";
const selection = Object.freeze({
groupCode: "KOSPI",
subject: "Samsung Electronics",
graphicType: "ONE_COLUMN",
subtype: "CURRENT",
dataCode: "005930"
});
function trustedEntry(overrides = {}) {
return {
id: "entry-1",
builderKey: "s5001",
code: "5001",
enabled: true,
selection,
operator: { source: "trusted-test", schemaVersion: 1 },
...overrides
};
}
function nativeRow(overrides = {}) {
return {
itemIndex: 0,
enabled: true,
groupCode: "KOSPI",
subject: "Samsung Electronics",
graphicType: "ONE_COLUMN",
subtype: "CURRENT",
pageText: "1/1",
dataCode: "005930",
...overrides
};
}
function loadResponse(rows = [nativeRow()], overrides = {}) {
return {
requestId: "load-1",
definition: { programCode: "00000001", title: "Morning" },
retrievedAt,
totalRowCount: rows.length,
canMutate: true,
writeQuarantined: false,
rows,
...overrides
};
}
test("named-playlist bridge contract is closed and bounded", () => {
assert.deepEqual(workflow.bridgeContract, {
requests: {
list: "request-named-playlist-list",
load: "request-named-playlist-load",
create: "create-named-playlist",
replace: "replace-named-playlist",
delete: "delete-named-playlist"
},
responses: {
list: "named-playlist-list-results",
load: "named-playlist-load-results",
mutation: "named-playlist-mutation-results",
error: "named-playlist-error"
},
schemaVersion: 1,
maximumDefinitions: 1000,
maximumItems: 1000,
maximumPageCount: 20,
maximumStoredPageCount: 9999
});
assert.deepEqual(workflow.operations, ["list", "load", "create", "replace", "delete"]);
});
test("list, load, create and delete request payloads require canonical identities", () => {
assert.deepEqual(workflow.createListRequest("list-1"), {
requestId: "list-1", maximumResults: 200
});
assert.deepEqual(workflow.createListRequest("list-2", 1000), {
requestId: "list-2", maximumResults: 1000
});
assert.deepEqual(workflow.createLoadRequest("load-1", "00000001"), {
requestId: "load-1", programCode: "00000001"
});
assert.deepEqual(workflow.createDefinitionRequest("create-1", "00000001", "Morning"), {
requestId: "create-1", programCode: "00000001", title: "Morning"
});
assert.deepEqual(workflow.createDeleteRequest("delete-1", "00000001"), {
requestId: "delete-1", programCode: "00000001"
});
assert.throws(() => workflow.createListRequest("bad id"), /safe/i);
assert.throws(() => workflow.createListRequest("list", 1001), /1 through 1000/i);
assert.throws(() => workflow.createLoadRequest("load", "1"), /eight-digit/i);
assert.throws(() => workflow.createDefinitionRequest("create", "00000001", " Morning"), /canonical/i);
});
test("trusted entries serialize to the exact original seven LIST_TEXT fields", () => {
const entry = trustedEntry();
assert.deepEqual(workflow.toLegacySevenFields(entry, "2/4"), [
"1", "KOSPI", "Samsung Electronics", "ONE_COLUMN", "CURRENT", "2/4", "005930"
]);
assert.equal(
workflow.legacyListText(workflow.toLegacySevenFields(entry, "2/4")),
"1^KOSPI^Samsung Electronics^ONE_COLUMN^CURRENT^2/4^005930");
const request = workflow.createReplaceRequest(
"replace-1",
"00000001",
[entry, trustedEntry({ id: "entry-2", enabled: false })],
{
isTrustedEntry: () => true,
pageTextForEntry: (_, index) => index === 0 ? "2/4" : "1/0"
});
assert.deepEqual(request.items, [
{
itemIndex: 0, enabled: true, groupCode: "KOSPI", subject: "Samsung Electronics",
graphicType: "ONE_COLUMN", subtype: "CURRENT", pageText: "2/4", dataCode: "005930"
},
{
itemIndex: 1, enabled: false, groupCode: "KOSPI", subject: "Samsung Electronics",
graphicType: "ONE_COLUMN", subtype: "CURRENT", pageText: "1/0", dataCode: "005930"
}
]);
});
test("database replacement requires trusted verification and enforces the 1000-row bound", () => {
assert.throws(() => workflow.createReplaceRequest(
"replace", "00000001", [trustedEntry()]), /trusted-entry verifier/i);
assert.throws(() => workflow.createReplaceRequest(
"replace", "00000001", [trustedEntry()], { isTrustedEntry: () => false }), /trusted restoration/i);
const entries = Array.from({ length: 1000 }, (_, index) => trustedEntry({ id: `entry-${index}` }));
const bounded = workflow.createReplaceRequest(
"replace", "00000001", entries, { isTrustedEntry: () => true });
assert.equal(bounded.items.length, 1000);
assert.throws(() => workflow.createReplaceRequest(
"replace", "00000001", [...entries, trustedEntry({ id: "overflow" })],
{ isTrustedEntry: () => true }), /at most 1000/i);
});
test("unsafe selection delimiters, controls and noncanonical pages never reach the bridge", () => {
assert.throws(() => workflow.toLegacySevenFields(trustedEntry({
selection: { ...selection, subject: "Alpha^Beta" }
})), /cannot be stored safely/i);
assert.throws(() => workflow.toLegacySevenFields(trustedEntry({
selection: { ...selection, subject: "Alpha\nBeta" }
})), /cannot be stored safely/i);
assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "01/02"), /page text/i);
assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "0/0"), /page text/i);
assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "1/10000"), /page text/i);
assert.throws(() => workflow.legacyListText([
"yes", "KOSPI", "Alpha", "ONE_COLUMN", "CURRENT", "1/1", "000001"
]), /seven safe/i);
});
test("stored page parsing preserves historical values but marks the current 20-page boundary", () => {
assert.deepEqual(workflow.parseStoredPageText("4/20"), {
pageText: "4/20", currentPage: 4, totalPages: 20,
isWithinPlayoutBounds: true,
historicalPageOutOfBounds: false,
pageRecalculationRequired: true
});
assert.deepEqual(workflow.parseStoredPageText("1/0"), {
pageText: "1/0", currentPage: 1, totalPages: 0,
isWithinPlayoutBounds: false,
historicalPageOutOfBounds: true,
pageRecalculationRequired: true
});
assert.deepEqual(workflow.parseStoredPageText("1/24"), {
pageText: "1/24", currentPage: 1, totalPages: 24,
isWithinPlayoutBounds: false,
historicalPageOutOfBounds: true,
pageRecalculationRequired: true
});
for (const value of ["", "01/1", "1/01", "2/1", "2/0", "1/10000", "text"]) {
assert.equal(workflow.parseStoredPageText(value), null);
}
});
test("list responses are exact, duplicate-safe and expose permanent write quarantine", () => {
const response = {
requestId: "list-1",
retrievedAt,
totalRowCount: 2,
truncated: false,
suggestedProgramCode: "00000003",
canMutate: true,
writeQuarantined: false,
definitions: [
{ programCode: "00000001", title: "Morning" },
{ programCode: "00000002", title: "Noon" }
]
};
assert.deepEqual(workflow.normalizeListResponse(response), response);
assert.equal(workflow.normalizeListResponse({ ...response, extra: true }), null);
assert.equal(workflow.normalizeListResponse({
...response,
definitions: [response.definitions[0], response.definitions[0]]
}), null);
assert.equal(workflow.normalizeListResponse({
...response, canMutate: true, writeQuarantined: true
}), null);
const quarantined = workflow.normalizeListResponse({
...response, canMutate: false, writeQuarantined: true
});
assert.equal(workflow.isWriteBlocked(quarantined), true);
});
test("load normalization preserves every raw field before trusted restoration", () => {
const normalized = workflow.normalizeLoadResponse(loadResponse([
nativeRow({ pageText: "1/24" }),
nativeRow({ itemIndex: 1, enabled: false, subject: "SK hynix", dataCode: "000660", pageText: "" })
]));
assert.ok(normalized);
assert.equal(normalized.rawRows.length, 2);
assert.deepEqual(normalized.rawRows[0].legacyFields, [
"1", "KOSPI", "Samsung Electronics", "ONE_COLUMN", "CURRENT", "1/24", "005930"
]);
assert.equal(normalized.rawRows[0].listText,
"1^KOSPI^Samsung Electronics^ONE_COLUMN^CURRENT^1/24^005930");
assert.equal(normalized.rawRows[0].requiresTrustedRestore, true);
assert.equal(normalized.rawRows[0].pageRecalculationRequired, true);
assert.equal(normalized.rawRows[0].historicalPageOutOfBounds, true);
assert.equal(normalized.rawRows[1].requiresTrustedRestore, true);
assert.equal(normalized.rawRows[1].pageRecalculationRequired, false);
assert.match(workflow.pageStatusLabel(normalized.rawRows[0]), /20-page maximum/i);
});
test("load responses reject extra fields, reordered indexes and malformed historical pages", () => {
assert.equal(workflow.normalizeLoadResponse({ ...loadResponse(), extra: true }), null);
assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ itemIndex: 1 })])), null);
assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "01/1" })])), null);
assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ subject: " Alpha" })])), null);
assert.equal(workflow.normalizeLoadResponse(loadResponse([], { totalRowCount: 1 })), null);
});
test("raw database rows cannot become playable until a trusted resolver reconstructs them", () => {
const load = workflow.normalizeLoadResponse(loadResponse());
const unresolved = workflow.restoreRawRows(load, () => null);
assert.equal(unresolved.readyForPrepare, false);
assert.deepEqual(unresolved.blockers.map(value => value.reason), [
"trusted-restore-required", "page-recalculation-required"
]);
assert.throws(() => workflow.materializeTrustedPlaylist(unresolved), /trusted restoration/i);
assert.equal(unresolved.rows[0].rawRow, load.rawRows[0]);
});
test("trusted resolver output must reproduce enabled state and all five selection fields", () => {
const load = workflow.normalizeLoadResponse(loadResponse());
assert.equal(workflow.restoreRawRows(load, () => trustedEntry()).rows[0].trusted, true);
assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ enabled: false })).rows[0].trusted, false);
assert.equal(workflow.restoreRawRows(load, () => trustedEntry({
selection: { ...selection, subject: "Tampered" }
})).rows[0].trusted, false);
assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ code: "bad" })).rows[0].trusted, false);
assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ builderKey: "5001" })).rows[0].trusted, false);
});
test("fresh page plans are mandatory before PREPARE and cannot exceed 20 pages", () => {
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })]));
const restored = workflow.restoreRawRows(load, () => trustedEntry());
assert.equal(restored.readyForPrepare, false);
assert.throws(() => workflow.markPageRecalculated(restored, 0, {
itemCount: 101, pageSize: 5, pageCount: 21
}), /exceeds 20 pages/i);
const recalculated = workflow.markPageRecalculated(restored, 0, {
itemCount: 100, pageSize: 5, pageCount: 20
});
assert.equal(recalculated.readyForPrepare, true);
assert.equal(workflow.canPrepareRestoredRows(recalculated), true);
assert.deepEqual(recalculated.rows[0].recalculatedPagePlan, {
itemCount: 100, pageSize: 5, pageCount: 20
});
assert.equal(recalculated.rows[0].rawRow.pageText, "1/24");
assert.deepEqual(workflow.materializeTrustedPlaylist(recalculated), [trustedEntry()]);
});
test("paged trusted rows create a strict preflight request without using stored page text", () => {
function prepare(pageText) {
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText })]));
const restored = workflow.restoreRawRows(load, () => trustedEntry({
builderKey: "s5074",
code: "5074",
fadeDuration: 6
}));
return workflow.preparePagePreflight(restored, entry => entry.builderKey === "s5074");
}
const request = workflow.createPagePlanRequest("page-plan-1", prepare("1/24"));
const requestFromDifferentHistoricalPage = workflow.createPagePlanRequest(
"page-plan-1",
prepare("4/20"));
assert.deepEqual(request, {
requestId: "page-plan-1",
entries: [{
id: "entry-1",
code: "5074",
enabled: true,
fadeDuration: 6,
selection
}]
});
assert.deepEqual(requestFromDifferentHistoricalPage, request);
assert.equal(Object.hasOwn(request.entries[0], "pageText"), false);
});
test("correlated capped page plans enable PREPARE and preserve explicit truncation", () => {
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })]));
let restored = workflow.restoreRawRows(load, () => trustedEntry({
builderKey: "s5074",
code: "5074",
fadeDuration: 6
}));
restored = workflow.preparePagePreflight(restored, () => true);
const request = workflow.createPagePlanRequest("page-plan-2", restored);
const rawResponse = {
requestId: "page-plan-2",
calculatedAt: retrievedAt,
plans: [{
entryId: "entry-1",
itemCount: 101,
pageSize: 5,
pageCount: 20,
accessibleItemCount: 100,
isTruncated: true,
builderKey: "s5074"
}]
};
const response = workflow.normalizePagePlanResponse(rawResponse, request);
assert.ok(response);
restored = workflow.applyPagePlanResponse(restored, response);
assert.equal(restored.readyForPrepare, true);
assert.equal(restored.rows[0].pageRecalculated, true);
assert.deepEqual(restored.rows[0].recalculatedPagePlan, rawResponse.plans[0]);
assert.equal(workflow.normalizePagePlanResponse({
...rawResponse,
requestId: "stale-request"
}, request), null);
assert.equal(workflow.normalizePagePlanResponse({
...rawResponse,
plans: [{ ...rawResponse.plans[0], accessibleItemCount: 101 }]
}, request), null);
});
test("zero-row page preflight is complete but explicitly unavailable for PREPARE", () => {
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "" })]));
let restored = workflow.restoreRawRows(load, () => trustedEntry({
builderKey: "s5077",
code: "5077",
fadeDuration: 6
}));
restored = workflow.preparePagePreflight(restored, () => true);
const request = workflow.createPagePlanRequest("page-plan-zero", restored);
const response = workflow.normalizePagePlanResponse({
requestId: "page-plan-zero",
calculatedAt: retrievedAt,
plans: [{
entryId: "entry-1",
itemCount: 0,
pageSize: 6,
pageCount: 0,
accessibleItemCount: 0,
isTruncated: false,
builderKey: "s5077"
}]
}, request);
restored = workflow.applyPagePlanResponse(restored, response);
assert.equal(restored.readyForPrepare, false);
assert.equal(restored.rows[0].pagePreflightCompleted, true);
assert.equal(restored.rows[0].pageRecalculated, false);
assert.deepEqual(restored.blockers.map(value => value.reason), ["page-result-empty"]);
});
test("nonpaged rows become ready without a synthetic page-plan result", () => {
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })]));
const restored = workflow.preparePagePreflight(
workflow.restoreRawRows(load, () => trustedEntry()),
() => false);
assert.equal(restored.readyForPrepare, true);
assert.equal(workflow.createPagePlanRequest("no-page-plan", restored), null);
assert.equal(restored.rows[0].recalculatedPagePlan, null);
});
test("rows without stored page text still require trust but no artificial page plan", () => {
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "" })]));
const restored = workflow.restoreRawRows(load, () => trustedEntry());
assert.equal(restored.readyForPrepare, true);
assert.deepEqual(restored.blockers, []);
assert.equal(restored.rows[0].pageRecalculated, true);
});
test("mutation responses are strict and retain quarantine state", () => {
const response = {
requestId: "replace-1",
operation: "replace",
programCode: "00000001",
completedAt: retrievedAt,
writeQuarantined: false
};
assert.deepEqual(workflow.normalizeMutationResponse(response), response);
assert.equal(workflow.normalizeMutationResponse({ ...response, operation: "load" }), null);
assert.equal(workflow.normalizeMutationResponse({ ...response, extra: true }), null);
});
test("OutcomeUnknown errors permanently block writes and can never be retryable", () => {
const error = {
requestId: "replace-1",
operation: "replace",
programCode: "00000001",
code: "OUTCOME_UNKNOWN",
message: "Restart before another write.",
retryable: false,
outcomeUnknown: true,
writeQuarantined: true
};
const normalized = workflow.normalizeError(error);
assert.deepEqual(normalized, error);
assert.equal(workflow.isWriteBlocked(normalized), true);
assert.equal(workflow.normalizeError({ ...error, retryable: true }), null);
assert.equal(workflow.normalizeError({ ...error, writeQuarantined: false }), null);
assert.equal(workflow.normalizeError({ ...error, extra: true }), null);
});

View File

@@ -0,0 +1,53 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const bridge = fs.readFileSync(
path.join(repositoryRoot, "MainWindow.OperatorCatalogs.cs"),
"utf8");
test("theme, expert, schema and next-code reads use entity-operation lanes", () => {
assert.match(bridge,
/Dictionary<string, OperatorCatalogReadRequest> _operatorCatalogReadLanes/);
assert.match(bridge,
/new OperatorCatalogReadRequest\(\s*\$"\{entity\}:\{operation\}"/s);
assert.match(bridge, /_operatorCatalogReadLanes\[request\.Lane\] = request/);
assert.match(bridge, /_operatorCatalogReadLanes\.TryGetValue\(request\.Lane/);
assert.doesNotMatch(bridge, /_operatorCatalogReadCancellation/);
assert.match(bridge, /await _databaseActivityGate\.WaitAsync\(requestToken\)/);
assert.match(bridge, /OperatorCatalogBridgeReply/);
});
test("each catalog request publishes at most one terminal result or error", () => {
assert.match(bridge, /CancelRequest\(superseded\.Cancellation\)/);
assert.match(bridge, /superseded\?\.TryComplete\(\)/);
assert.match(bridge, /"CANCELLED"/);
assert.match(bridge, /newer request replaced it/);
assert.match(bridge,
/Interlocked\.CompareExchange\(ref _terminalState, 1, 0\) == 0/);
assert.match(bridge,
/TryCompleteOperatorCatalogReadIfCurrent\(request\)[\s\S]*PostMessage\(reply\.MessageType/s);
assert.match(bridge,
/ReferenceEquals\(current, request\) &&[\s\S]*request\.TryComplete\(\)/s);
assert.match(bridge,
/PostOperatorCatalogReadErrorIfCurrent\([\s\S]*!TryCompleteOperatorCatalogReadIfCurrent\(request\)/s);
});
test("browser invalidation, playout priority and shutdown cover every active lane", () => {
assert.match(bridge, /private OperatorCatalogReadRequest\[\] SnapshotOperatorCatalogReads/);
assert.ok((bridge.match(/SnapshotOperatorCatalogReads\(clear: true\)/g) || []).length >= 2);
assert.match(bridge,
/CancelOperatorCatalogReadsForPlayout\(\)[\s\S]*SnapshotOperatorCatalogReads\(clear: false\)/s);
});
test("write safety gate and process quarantine remain intact", () => {
assert.match(bridge,
/CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/);
assert.match(bridge, /LatchOperatorCatalogWriteQuarantine/);
assert.match(bridge, /Interlocked\.Exchange\(ref _operatorCatalogWriteCancellation, null\)/);
assert.doesNotMatch(bridge, /operationBody\(requestToken\)\.ConfigureAwait\(false\)/);
});

View File

@@ -0,0 +1,348 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const workflow = require("../../Web/operator-catalog-workflow.js");
const ui = require("../../Web/operator-catalog-ui.js");
const retrievedAt = "2026-07-12T10:00:00+09:00";
function themeContext(title = "AI 반도체") {
return {
selection: {
market: "krx",
session: "notApplicable",
themeCode: "00000001",
themeTitle: title,
source: "oracle"
},
preview: {
requestId: "theme-preview-1",
selection: {
market: "krx",
session: "notApplicable",
themeCode: "00000001",
title
},
retrievedAt,
totalRowCount: 2,
truncated: false,
items: [
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
{ inputIndex: 4, itemCode: "D035720", itemName: "카카오" }
]
}
};
}
function expertContext() {
return {
selection: { expertCode: "0001", expertName: "홍길동", source: "oracle" },
preview: {
requestId: "expert-preview-1",
selection: { expertCode: "0001", expertName: "홍길동" },
retrievedAt,
totalRowCount: 2,
truncated: false,
items: [
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
{ playIndex: 8, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
]
}
};
}
function harness() {
const posted = [];
const toasts = [];
const logs = [];
let sequence = 0;
let isLocked = false;
const controller = ui.createController({
postNative(type, payload) { posted.push({ type, payload }); },
isLocked() { return isLocked; },
showToast(message) { toasts.push(message); },
addLog(message) { logs.push(message); },
createId() { sequence += 1; return String(sequence); }
});
return {
controller,
posted,
toasts,
logs,
setExternalLocked(value) { isLocked = value; }
};
}
function completeSchema(h, overrides = {}) {
const pending = h.controller.render().activeRead;
assert.equal(pending.type, workflow.bridgeContract.requests.schemaPreflight);
h.controller.handleMessage(workflow.bridgeContract.events.schemaPreflight, {
requestId: pending.request.requestId,
validatedAt: retrievedAt,
validatedSources: ["oracle", "mariaDb"],
themeCanMutate: true,
expertCanMutate: true,
writeQuarantined: false,
...overrides
});
}
function mutationResult(active, overrides = {}) {
return {
requestId: active.requestId,
entity: active.entity,
operation: active.operation,
identityCode: active.identityCode,
operationId: "123e4567-e89b-12d3-a456-426614174000",
committedAt: retrievedAt,
writeQuarantined: false,
...overrides
};
}
test("controller exposes the independent integration API", () => {
const h = harness();
for (const method of [
"mount", "openTheme", "openExpert", "handleMessage", "render", "setLocked"
]) assert.equal(typeof h.controller[method], "function");
});
test("theme edit starts only from a closed selection and complete preview", () => {
const h = harness();
const opened = h.controller.openTheme(themeContext());
assert.equal(opened.entity, "theme");
assert.equal(opened.mode, "edit");
assert.deepEqual(opened.draft.items, [
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
{ inputIndex: 1, itemCode: "D035720", itemName: "카카오" }
]);
assert.equal(h.posted[0].type, workflow.bridgeContract.requests.schemaPreflight);
assert.throws(() => h.controller.openTheme({ selection: themeContext().selection }), /exactly/i);
});
test("theme prefixes, 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.moveDraftItem(2, -1), true);
assert.equal(h.controller.updateDraft({ themeTitle: "AI·로봇" }), true);
assert.deepEqual(h.controller.render().draft.items, [
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
{ inputIndex: 1, itemCode: "P000660", itemName: "SK하이닉스" },
{ inputIndex: 2, itemCode: "D035720", itemName: "카카오" }
]);
assert.equal(h.controller.submit(), true);
const sent = h.posted.at(-1);
assert.equal(sent.type, workflow.bridgeContract.requests.themeReplace);
assert.equal(sent.payload.newTitle, "AI·로봇");
assert.deepEqual(sent.payload.items, h.controller.render().draft.items);
const active = h.controller.render().mutation;
h.controller.handleMessage(workflow.bridgeContract.events.mutation,
mutationResult(active, { requestId: "stale" }));
assert.deepEqual(h.controller.render().mutation, active);
h.controller.handleMessage(workflow.bridgeContract.events.mutation, mutationResult(active));
assert.equal(h.controller.render().mutation, null);
assert.equal(h.controller.render().mode, "list");
});
test("schema, catalog search and next-code reads are serialized and exactly correlated", () => {
const h = harness();
h.controller.openTheme();
assert.equal(h.posted.length, 1);
completeSchema(h);
assert.equal(h.posted.at(-1).type, workflow.bridgeContract.requests.themeList);
let pending = h.controller.render().activeRead;
h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
requestId: pending.request.requestId,
query: "",
nxtSession: "preMarket",
retrievedAt,
totalRowCount: 1,
truncated: false,
canMutate: true,
writeQuarantined: false,
results: [{
market: "krx", session: "notApplicable", themeCode: "00000001",
themeTitle: "AI", source: "oracle"
}]
});
assert.equal(h.controller.render().themeResults.length, 1);
h.controller.search("old", "afterMarket");
const old = h.controller.render().activeRead;
h.controller.search("latest", "afterMarket");
assert.equal(h.controller.render().activeRead.superseded, true);
h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
requestId: old.request.requestId,
query: "old",
nxtSession: "afterMarket",
retrievedAt,
totalRowCount: 0,
truncated: false,
canMutate: true,
writeQuarantined: false,
results: []
});
pending = h.controller.render().activeRead;
assert.equal(pending.request.query, "latest");
assert.equal(h.controller.render().themeResults.length, 1);
h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
requestId: pending.request.requestId,
query: "latest",
nxtSession: "afterMarket",
retrievedAt,
totalRowCount: 0,
truncated: false,
canMutate: true,
writeQuarantined: false,
results: []
});
assert.equal(h.controller.render().themeResults.length, 0);
assert.equal(h.controller.beginNewTheme("nxt"), true);
pending = h.controller.render().activeRead;
h.controller.handleMessage(workflow.bridgeContract.events.themeNextCode, {
requestId: pending.request.requestId,
market: "nxt",
themeCode: "00000007",
canMutate: true,
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);
});
test("expert recommendations preserve exact code, name, buy amount and order", () => {
const h = harness();
h.controller.openExpert(expertContext());
completeSchema(h);
assert.equal(h.controller.addExpertRecommendation("005930", "중복", 1), false);
assert.equal(h.controller.addExpertRecommendation("000660", "SK하이닉스", "123456"), true);
assert.equal(h.controller.moveDraftItem(2, -1), true);
assert.equal(h.controller.updateDraft({ expertName: "홍길동 위원" }), true);
assert.equal(h.controller.submit(), true);
const sent = h.posted.at(-1);
assert.equal(sent.type, workflow.bridgeContract.requests.expertReplace);
assert.equal(sent.payload.newName, "홍길동 위원");
assert.deepEqual(sent.payload.recommendations, [
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
{ playIndex: 1, stockCode: "000660", stockName: "SK하이닉스", buyAmount: 123456 },
{ playIndex: 2, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
]);
});
test("OutcomeUnknown latches a process quarantine and never retries", () => {
const h = harness();
h.controller.openExpert(expertContext());
completeSchema(h);
h.controller.updateDraft({ expertName: "홍길동 위원" });
assert.equal(h.controller.submit(), true);
const active = h.controller.render().mutation;
const writeCount = h.posted.length;
h.controller.handleMessage(workflow.bridgeContract.events.error, {
requestId: active.requestId,
entity: active.entity,
operation: active.operation,
identityCode: active.identityCode,
code: "OUTCOME_UNKNOWN",
message: "Restart and reconcile before another write.",
retryable: false,
outcomeUnknown: true,
writeQuarantined: true
});
assert.equal(h.controller.render().quarantined, true);
assert.equal(h.controller.render().mutation, null);
assert.equal(h.controller.submit(), false);
assert.equal(h.posted.length, writeCount);
});
test("a malformed same-request mutation result loses correlation and quarantines", () => {
const h = harness();
h.controller.openTheme(themeContext());
completeSchema(h);
assert.equal(h.controller.submit(), true);
const active = h.controller.render().mutation;
h.controller.handleMessage(workflow.bridgeContract.events.mutation, {
...mutationResult(active),
operationId: "not-a-uuid"
});
assert.equal(h.controller.render().quarantined, true);
assert.equal(h.controller.render().mutation, null);
});
class FakeElement {
constructor(tagName, ownerDocument) {
this.tagName = tagName.toUpperCase();
this.ownerDocument = ownerDocument;
this.children = [];
this.attributes = new Map();
this.listeners = new Map();
this.textContent = "";
this.value = "";
this.hidden = false;
this.disabled = false;
this.open = false;
}
append(...values) { values.forEach(value => this.appendChild(value)); }
appendChild(value) { this.children.push(value); return value; }
replaceChildren(...values) { this.children = []; this.append(...values); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
removeAttribute(name) { this.attributes.delete(name); }
addEventListener(name, callback) { this.listeners.set(name, callback); }
}
class FakeDocument {
constructor() {
this.head = new FakeElement("head", this);
this.body = new FakeElement("body", this);
}
createElement(tagName) { return new FakeElement(tagName, this); }
getElementById(id) {
function find(node) {
if (node.id === id) return node;
for (const child of node.children) {
const match = find(child);
if (match) return match;
}
return null;
}
return find(this.head) || find(this.body);
}
}
function findRole(node, role) {
if (node.attributes?.get("data-role") === role) return node;
for (const child of node.children || []) {
const match = findRole(child, role);
if (match) return match;
}
return null;
}
test("mount creates a dialog with text-only rendering and its own stylesheet", () => {
const h = harness();
const document = new FakeDocument();
h.controller.mount(document.body);
h.controller.openTheme(themeContext("<b>AI</b>"));
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>");
const source = fs.readFileSync(
path.resolve(__dirname, "../../Web/operator-catalog-ui.js"),
"utf8");
assert.doesNotMatch(source, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\s*\(/);
});

View File

@@ -0,0 +1,446 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const workflow = require("../../Web/operator-catalog-workflow.js");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const nativeBridge = fs.readFileSync(
path.join(repositoryRoot, "MainWindow.OperatorCatalogs.cs"),
"utf8");
const playoutBridge = fs.readFileSync(
path.join(repositoryRoot, "MainWindow.Playout.cs"),
"utf8");
const operatorGate = fs.readFileSync(
path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Playout", "OperatorMutationGate.cs"),
"utf8");
const krxSelection = Object.freeze({
market: "krx",
session: "notApplicable",
themeCode: "00000001",
themeTitle: "AI 반도체",
source: "oracle"
});
const expertSelection = Object.freeze({
expertCode: "0001",
expertName: "홍길동",
source: "oracle"
});
function themePreview(overrides = {}) {
return {
requestId: "theme-preview-1",
selection: {
market: "krx",
session: "notApplicable",
themeCode: "00000001",
title: "AI 반도체"
},
retrievedAt: "2026-07-11T23:00:00+09:00",
totalRowCount: 2,
truncated: false,
items: [
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
{ inputIndex: 3, itemCode: "D035720", itemName: "카카오" }
],
...overrides
};
}
function expertPreview(overrides = {}) {
return {
requestId: "expert-preview-1",
selection: { expertCode: "0001", expertName: "홍길동" },
retrievedAt: "2026-07-11T23:00:00+09:00",
totalRowCount: 2,
truncated: false,
items: [
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
{ playIndex: 4, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
],
...overrides
};
}
test("bridge contract pins every independent request and event name", () => {
assert.deepEqual(workflow.bridgeContract.requests, {
themeList: "request-theme-catalog-list",
expertList: "request-expert-catalog-list",
schemaPreflight: "request-operator-catalog-schema",
themeNextCode: "request-theme-catalog-next-code",
expertNextCode: "request-expert-catalog-next-code",
themeCreate: "create-theme-catalog",
themeReplace: "replace-theme-catalog",
themeDelete: "delete-theme-catalog",
expertCreate: "create-expert-catalog",
expertReplace: "replace-expert-catalog",
expertDelete: "delete-expert-catalog"
});
assert.deepEqual(workflow.bridgeContract.events, {
themeList: "theme-catalog-list-results",
expertList: "expert-catalog-list-results",
schemaPreflight: "operator-catalog-schema-results",
themeNextCode: "theme-catalog-next-code-results",
expertNextCode: "expert-catalog-next-code-results",
mutation: "operator-catalog-mutation-results",
error: "operator-catalog-error"
});
assert.deepEqual(workflow.limits, {
maximumThemeItems: 240,
maximumRecommendations: 100,
maximumResults: 500,
maximumQueryLength: 64
});
});
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}"`));
}
for (const eventType of Object.values(workflow.bridgeContract.events)) {
assert.match(nativeBridge, new RegExp(`"${eventType}"`));
}
assert.match(nativeBridge, /_operatorCatalogWriteGate\.WaitAsync\(0\)/);
assert.match(nativeBridge, /_playoutCommandGate\.WaitAsync\(0, requestToken\)/);
assert.match(nativeBridge, /_databaseActivityGate\.WaitAsync\(0, requestToken\)/);
assert.match(nativeBridge, /CanStartOperatorCatalogWrite\(\)/);
assert.match(nativeBridge,
/CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/);
assert.match(playoutBridge, /private bool CanStartOperatorMutation\(/);
assert.match(playoutBridge, /new OperatorMutationGateSnapshot\(/);
assert.match(operatorGate, /!snapshot\.IsPlayCompletionPending/);
assert.match(operatorGate, /snapshot\.PreparedSceneName/);
assert.match(operatorGate, /snapshot\.OnAirSceneName/);
assert.match(operatorGate, /snapshot\.PreparedCutCode/);
assert.match(operatorGate, /snapshot\.OnAirCutCode/);
assert.match(nativeBridge, /LatchOperatorCatalogWriteQuarantine/);
assert.match(nativeBridge, /OUTCOME_UNKNOWN/);
assert.match(nativeBridge, /OperatorCatalogMutationExecutor/);
assert.doesNotMatch(nativeBridge, /MaximumRetryCount|Task\.Delay|while\s*\(/);
});
test("list, schema and next-code requests are exact, normalized and bounded", () => {
assert.deepEqual(workflow.createThemeCatalogListRequest(
"theme-list-1", " AI ", "preMarket", 25), {
requestId: "theme-list-1",
query: "AI",
nxtSession: "preMarket",
maximumResults: 25
});
assert.deepEqual(workflow.createExpertCatalogListRequest("expert-list-1", " 홍 "), {
requestId: "expert-list-1",
query: "홍"
});
assert.deepEqual(workflow.createSchemaPreflightRequest("schema-1"), {
requestId: "schema-1"
});
assert.deepEqual(workflow.createThemeNextCodeRequest("next-1", "nxt"), {
requestId: "next-1",
market: "nxt"
});
assert.deepEqual(workflow.createExpertNextCodeRequest("next-2"), {
requestId: "next-2"
});
assert.throws(() => workflow.createThemeCatalogListRequest("bad id", "", "preMarket"), /request id/);
assert.throws(() => workflow.createThemeCatalogListRequest("safe", "", "none"), /session/);
assert.throws(() => workflow.createExpertCatalogListRequest("safe", "bad\u0000query"), /query/);
assert.throws(() => workflow.createExpertCatalogListRequest("safe", "", 501), /limited/);
});
test("theme selection plus complete preview becomes an editable explicit identity and reindexed draft", () => {
const edit = workflow.createThemeEditDraft(krxSelection, themePreview());
assert.deepEqual(edit, {
expectedIdentity: {
market: "krx",
session: "notApplicable",
themeCode: "00000001",
themeTitle: "AI 반도체"
},
draft: {
market: "krx",
themeCode: "00000001",
themeTitle: "AI 반도체",
items: [
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
{ inputIndex: 1, itemCode: "D035720", itemName: "카카오" }
]
}
});
edit.draft.themeTitle = "AI·로봇";
edit.draft.items.reverse();
edit.draft.items.forEach((item, index) => { item.inputIndex = index; });
const request = workflow.createThemeReplaceRequest("theme-replace-1", edit);
assert.deepEqual(request, {
requestId: "theme-replace-1",
expectedIdentity: {
market: "krx",
session: "notApplicable",
themeCode: "00000001",
themeTitle: "AI 반도체"
},
newTitle: "AI·로봇",
items: [
{ inputIndex: 0, itemCode: "D035720", itemName: "카카오" },
{ inputIndex: 1, itemCode: "P005930", itemName: "삼성전자" }
]
});
});
test("theme edit conversion rejects truncated, stale, duplicate and market-mismatched previews", () => {
assert.throws(() => workflow.createThemeEditDraft(
krxSelection,
themePreview({ truncated: true })), /complete/);
assert.throws(() => workflow.createThemeEditDraft(
krxSelection,
themePreview({ selection: { ...themePreview().selection, themeCode: "00000002" } })), /correlated/);
assert.throws(() => workflow.createThemeEditDraft(
krxSelection,
themePreview({
items: [
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
{ inputIndex: 1, itemCode: "D035720", itemName: "삼성전자" }
]
})), /complete/);
assert.equal(workflow.normalizeThemeDraft({
market: "nxt",
themeCode: "00000001",
themeTitle: "로봇(NXT)",
items: []
}), null);
assert.equal(workflow.normalizeThemeDraft({
market: "krx",
themeCode: "00000001",
themeTitle: "AI",
items: [{ inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" }]
}), null);
});
test("new theme drafts require a valid title before create and delete keeps full identity", () => {
const draft = workflow.createBlankThemeDraft("nxt", "00000003");
assert.deepEqual(draft, {
market: "nxt",
themeCode: "00000003",
themeTitle: "",
items: []
});
assert.throws(() => workflow.createThemeCreateRequest("create-1", draft), /complete/);
draft.themeTitle = "로봇";
draft.items.push({ inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" });
assert.deepEqual(workflow.createThemeCreateRequest("create-1", draft), {
requestId: "create-1",
draft
});
assert.deepEqual(workflow.createThemeDeleteRequest(
"delete-1",
{ market: "nxt", session: "afterMarket", themeCode: "00000003", themeTitle: "로봇" }), {
requestId: "delete-1",
identity: {
market: "nxt",
session: "afterMarket",
themeCode: "00000003",
themeTitle: "로봇"
}
});
});
test("expert selection plus preview becomes editable and preserves buy amounts while reindexing", () => {
const edit = workflow.createExpertEditDraft(expertSelection, expertPreview());
assert.deepEqual(edit, {
expectedIdentity: { expertCode: "0001", expertName: "홍길동" },
draft: {
expertCode: "0001",
expertName: "홍길동",
recommendations: [
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
{ playIndex: 1, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
]
}
});
edit.draft.expertName = "홍길동 위원";
assert.deepEqual(workflow.createExpertReplaceRequest("expert-replace-1", edit), {
requestId: "expert-replace-1",
expectedIdentity: { expertCode: "0001", expertName: "홍길동" },
newName: "홍길동 위원",
recommendations: edit.draft.recommendations
});
assert.throws(() => workflow.createExpertEditDraft(
expertSelection,
expertPreview({ truncated: true })), /complete/);
assert.equal(workflow.normalizeExpertDraft({
expertCode: "0001",
expertName: "홍길동",
recommendations: [
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
{ playIndex: 1, stockCode: "005930", stockName: "다른이름", buyAmount: 1 }
]
}), null);
});
test("new expert create and delete requests carry exact draft and identity only", () => {
const draft = workflow.createBlankExpertDraft("0002");
assert.throws(() => workflow.createExpertCreateRequest("expert-create-1", draft), /complete/);
draft.expertName = "김전문";
draft.recommendations.push({
playIndex: 0,
stockCode: "005930",
stockName: "삼성전자",
buyAmount: 71000
});
assert.deepEqual(workflow.createExpertCreateRequest("expert-create-1", draft), {
requestId: "expert-create-1",
draft
});
assert.deepEqual(workflow.createExpertDeleteRequest(
"expert-delete-1",
{ expertCode: "0002", expertName: "김전문" }), {
requestId: "expert-delete-1",
identity: { expertCode: "0002", expertName: "김전문" }
});
assert.throws(() => workflow.createExpertCreateRequest("safe", {
...draft,
recommendations: [{ ...draft.recommendations[0], buyAmount: 1.5 }]
}), /complete/);
});
test("read responses reject stale correlation, ambiguous identities and quarantined mutation flags", () => {
const themeRequest = workflow.createThemeCatalogListRequest("theme-list-1", "", "preMarket", 10);
const themeResult = {
requestId: "theme-list-1",
query: "",
nxtSession: "preMarket",
retrievedAt: "2026-07-11T23:00:00+09:00",
totalRowCount: 1,
truncated: false,
canMutate: true,
writeQuarantined: false,
results: [krxSelection]
};
assert.ok(workflow.normalizeThemeListResult(themeResult, themeRequest));
assert.equal(workflow.normalizeThemeListResult({ ...themeResult, requestId: "stale" }, themeRequest), null);
assert.equal(workflow.normalizeThemeListResult({
...themeResult,
canMutate: true,
writeQuarantined: true
}, themeRequest), null);
const expertRequest = workflow.createExpertCatalogListRequest("expert-list-1", "", 10);
const expertResult = {
requestId: "expert-list-1",
query: "",
retrievedAt: "2026-07-11T23:00:00+09:00",
totalRowCount: 2,
truncated: false,
canMutate: true,
writeQuarantined: false,
results: [
expertSelection,
{ expertCode: "0002", expertName: "김전문", source: "oracle" }
]
};
assert.ok(workflow.normalizeExpertListResult(expertResult, expertRequest));
assert.equal(workflow.normalizeExpertListResult({
...expertResult,
results: [expertSelection, { expertCode: "0002", expertName: "홍길동", source: "oracle" }]
}, expertRequest), null);
});
test("schema and next-code results are exact and correlated", () => {
const schemaRequest = workflow.createSchemaPreflightRequest("schema-1");
assert.deepEqual(workflow.normalizeSchemaResult({
requestId: "schema-1",
validatedAt: "2026-07-11T23:00:00+09:00",
validatedSources: ["oracle", "mariaDb"],
themeCanMutate: true,
expertCanMutate: true,
writeQuarantined: false
}, schemaRequest).validatedSources, ["oracle", "mariaDb"]);
const themeNextRequest = workflow.createThemeNextCodeRequest("next-1", "krx");
assert.ok(workflow.normalizeThemeNextCodeResult({
requestId: "next-1",
market: "krx",
themeCode: "00000003",
canMutate: true,
writeQuarantined: false
}, themeNextRequest));
assert.equal(workflow.normalizeThemeNextCodeResult({
requestId: "next-1",
market: "nxt",
themeCode: "00000003",
canMutate: true,
writeQuarantined: false
}, themeNextRequest), null);
assert.ok(workflow.normalizeExpertNextCodeResult({
requestId: "next-2",
expertCode: "0007",
canMutate: false,
writeQuarantined: true
}, workflow.createExpertNextCodeRequest("next-2")));
});
test("mutation coordinator permits one correlated write and latches process quarantine on unknown outcome", () => {
const request = workflow.createThemeCreateRequest("create-1", {
market: "krx",
themeCode: "00000003",
themeTitle: "로봇",
items: []
});
const coordinator = workflow.createMutationCoordinator();
assert.deepEqual(coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), {
requestId: "create-1",
entity: "theme",
operation: "create",
identityCode: "00000003"
});
assert.throws(() => coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), /already active/);
assert.equal(coordinator.acceptResult({
requestId: "stale",
entity: "theme",
operation: "create",
identityCode: "00000003",
operationId: "123e4567-e89b-12d3-a456-426614174000",
committedAt: "2026-07-11T23:00:00+09:00",
writeQuarantined: false
}), null);
assert.ok(coordinator.getState().active);
const error = coordinator.acceptError({
requestId: "create-1",
entity: "theme",
operation: "create",
identityCode: "00000003",
code: "OUTCOME_UNKNOWN",
message: "Restart and reconcile before another write.",
retryable: false,
outcomeUnknown: true,
writeQuarantined: true
});
assert.ok(error);
assert.deepEqual(coordinator.getState(), { active: null, quarantined: true });
assert.throws(() => coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), /quarantined/);
});
test("known rollback clears the active coordinator without inventing an automatic retry", () => {
const request = workflow.createExpertDeleteRequest(
"delete-1",
{ expertCode: "0001", expertName: "홍길동" });
const coordinator = workflow.createMutationCoordinator();
coordinator.begin(workflow.bridgeContract.requests.expertDelete, request);
assert.ok(coordinator.acceptError({
requestId: "delete-1",
entity: "expert",
operation: "delete",
identityCode: "0001",
code: "CONFLICT",
message: "Refresh before a new edit.",
retryable: false,
outcomeUnknown: false,
writeQuarantined: false
}));
assert.deepEqual(coordinator.getState(), { active: null, quarantined: false });
coordinator.begin(workflow.bridgeContract.requests.expertDelete, request);
coordinator.loseCorrelation();
assert.deepEqual(coordinator.getState(), { active: null, quarantined: true });
});

View File

@@ -0,0 +1,156 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fixed = require("../../Web/legacy-fixed-workflow.js");
const industry = require("../../Web/industry-workflow.js");
const stock = require("../../Web/operator-workflow.js");
const comparison = require("../../Web/comparison-workflow.js");
const now = new Date("2026-07-11T12:00:00+09:00");
const kospiStock = Object.freeze({
market: "kospi",
stockName: "삼성전자",
displayName: "삼성전자",
stockCode: "005930",
isNxt: false
});
const primaryIndustry = Object.freeze({
market: "kospi",
code: "001",
name: "전기전자"
});
const secondaryIndustry = Object.freeze({
market: "kospi",
code: "002",
name: "금융업"
});
const kosdaqPrimaryIndustry = Object.freeze({
market: "kosdaq",
code: "101",
name: "IT"
});
const kosdaqSecondaryIndustry = Object.freeze({
market: "kosdaq",
code: "102",
name: "제약"
});
const comparisonPair = Object.freeze([
Object.freeze({ kind: "krx-stock", market: "kospi", stockName: "삼성전자", stockCode: "005930" }),
Object.freeze({ kind: "krx-stock", market: "kospi", stockName: "SK하이닉스", stockCode: "000660" })
]);
test("the original operator command matrix has exactly 412 visible slots", () => {
assert.equal(fixed.actions.length, 328);
assert.equal(industry.cuts.length * industry.markets.length, 44);
assert.equal(stock.stockCuts.length, 31);
assert.equal(comparison.actions.length, 9);
assert.equal(
fixed.actions.length + industry.cuts.length * industry.markets.length +
stock.stockCuts.length + comparison.actions.length,
412);
assert.equal(new Set(fixed.actions.map(value => value.id)).size, 328);
assert.equal(new Set(industry.cuts.map(value => value.id)).size, 22);
assert.equal(new Set(stock.stockCuts.map(value => value.id)).size, 31);
assert.equal(new Set(comparison.actions.map(value => value.id)).size, 9);
});
test("all 322 non-manual fixed slots create and strictly round-trip", () => {
const available = fixed.actions.filter(value => value.available);
assert.equal(available.length, 322);
for (const [index, action] of available.entries()) {
const created = fixed.createFixedPlaylistEntry(action.id, {
id: `fixed-matrix-${index + 1}`,
fadeDuration: 6,
now,
ma5: true,
ma20: true
});
const restored = fixed.restoreFixedPlaylistEntry(created, { now });
assert.ok(restored, `${action.id} ${action.label}`);
assert.equal(restored.builderKey, action.builderKey);
assert.equal(restored.code, action.code);
}
});
test("the six fixed manual placeholders resolve only through their dedicated editors", () => {
const manual = fixed.actions.filter(value => !value.available);
assert.deepEqual(
manual.map(value => [value.id, value.label, value.builderKey, value.code]),
[
["fixed-245", "개인 순매도 상위(수동)", "s5025", "5025"],
["fixed-246", "외국인 순매도 상위(수동)", "s5025", "5025"],
["fixed-247", "기관 순매도 상위(수동)", "s5025", "5025"],
["fixed-262", "VI 발동(수동)", "s5074", "5074"],
["fixed-293", "VI 발동(수동)", "s5074", "5074"],
["fixed-320", "VI 발동(수동)", "s5074", "5074"]
]);
for (const action of manual) {
assert.throws(() => fixed.createFixedPlaylistEntry(action.id, {
id: `blocked-${action.id}`,
fadeDuration: 6,
now
}), /manual|수동|입력/i);
}
});
test("all 44 industry slots create and strictly round-trip with explicit selections", () => {
let ordinal = 0;
for (const market of industry.markets) {
const selected = market === "kospi" ? primaryIndustry : kosdaqPrimaryIndustry;
const pair = market === "kospi"
? [primaryIndustry, secondaryIndustry]
: [kosdaqPrimaryIndustry, kosdaqSecondaryIndustry];
for (const cut of industry.cuts) {
ordinal += 1;
const created = industry.createIndustryPlaylistEntry(market, cut.id, {
id: `industry-matrix-${ordinal}`,
fadeDuration: 6,
selected,
pair,
now,
ma5: true,
ma20: true
});
const restored = industry.restoreIndustryPlaylistEntry(created, { now });
assert.ok(restored, `${market} ${cut.id}`);
assert.equal(restored.builderKey, created.builderKey);
assert.equal(restored.code, created.code);
}
}
assert.equal(ordinal, 44);
});
test("all 31 stock and nine comparison slots create and strictly round-trip", () => {
for (const [index, cut] of stock.stockCuts.entries()) {
const created = stock.createStockPlaylistEntry(kospiStock, cut.id, {
id: `stock-matrix-${index + 1}`,
fadeDuration: 6,
ma5: true,
ma20: true
});
assert.ok(stock.restoreStockPlaylistEntry(created), cut.id);
}
for (const [index, action] of comparison.actions.entries()) {
const created = comparison.createComparisonPlaylistEntry(
action.id,
comparisonPair,
{ id: `comparison-matrix-${index + 1}`, fadeDuration: 6 });
assert.ok(comparison.restoreComparisonPlaylistEntry(created), action.id);
}
});
test("the four GraphE buttons remain outside the 412 slots and map to exact INPUT tables", () => {
assert.deepEqual(
stock.manualStockActions.map(value => [value.id, value.builderKey, value.prerequisiteTable]),
[
["manual-revenue-mix", "s5076", "INPUT_PIE"],
["manual-growth-metrics", "s5079", "INPUT_GROW"],
["manual-sales", "s5080", "INPUT_SELL"],
["manual-operating-profit", "s5081", "INPUT_PROFIT"]
]);
});

View File

@@ -0,0 +1,371 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const manualFinancialUiModule = require("../../Web/manual-financial-ui.js");
const manualFinancialWorkflow = require("../../Web/manual-financial-workflow.js");
const manualListsUiModule = require("../../Web/manual-lists-ui.js");
const manualListsWorkflow = require("../../Web/manual-lists-workflow.js");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const read = relativePath => fs.readFileSync(path.join(repositoryRoot, relativePath), "utf8");
const app = read("Web/app.js");
const index = read("Web/index.html");
const manualFinancialUi = read("Web/manual-financial-ui.js");
const mainWindow = read("MainWindow.xaml.cs");
const manualFinancialBridge = read("MainWindow.ManualFinancial.cs");
const manualListsBridge = read("MainWindow.ManualLists.cs");
const namedPlaylistBridge = read("MainWindow.NamedPlaylists.cs");
const operatorCatalogBridge = read("MainWindow.OperatorCatalogs.cs");
const playoutBridge = read("MainWindow.Playout.cs");
const operatorMutationGate = read("src/MBN_STOCK_WEBVIEW.Core/Playout/OperatorMutationGate.cs");
function functionBody(source, name, nextName) {
const start = source.indexOf(`function ${name}(`);
assert.ok(start >= 0, `${name} must exist`);
const end = nextName ? source.indexOf(`function ${nextName}(`, start + 1) : source.length;
assert.ok(end > start, `${name} must precede ${nextName || "the end of the file"}`);
return source.slice(start, end);
}
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);
}
function assertOrdered(source, values) {
let previous = -1;
for (const value of values) {
const offset = source.indexOf(value);
assert.ok(offset >= 0, `${value} must be loaded`);
assert.ok(offset > previous, `${value} must preserve dependency order`);
assert.equal(source.indexOf(value, offset + value.length), -1, `${value} must be loaded once`);
previous = offset;
}
}
test("operator styles and scripts load once in dependency order before app.js", () => {
assertOrdered(index, [
'href="manual-lists-ui.css"',
'href="manual-financial-ui.css"',
'href="operator-catalog-ui.css"'
]);
assertOrdered(index, [
'src="manual-lists-workflow.js"',
'src="manual-financial-workflow.js"',
'src="operator-catalog-workflow.js"',
'src="manual-lists-ui.js"',
'src="manual-financial-ui.js"',
'src="operator-catalog-ui.js"',
'src="app.js"'
]);
});
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);
const fixedManual = functionBody(app, "openFixedManualAction", "addFixedAction");
assert.match(fixedManual,
/action\.builderKey === "s5025"[\s\S]*manualListsUi\?\.openNetSell\(action\.selection\?\.groupCode\)/u);
assert.match(fixedManual,
/action\.builderKey === "s5074"[\s\S]*action\.selection\?\.groupCode === "PAGED_VI"[\s\S]*manualListsUi\?\.openVi\(\)/u);
const stockRender = functionBody(app, "renderStockWorkflow", "requestStockSearch");
assert.match(stockRender, /for \(const action of operatorWorkflow\.manualStockActions\)/u);
assert.match(stockRender, /button\.disabled = !search\.selected \|\| operatorUiLocked\(\)/u);
assert.match(stockRender, /manualFinancialUi\?\.open\(action\)/u);
assert.match(app,
/themeCatalogManageButton\.addEventListener\("click", \(\) => \{[\s\S]*operatorCatalogUi\?\.openTheme\(context \|\| undefined\)/u);
assert.match(app,
/expertCatalogManageButton\.addEventListener\("click", \(\) => \{[\s\S]*operatorCatalogUi\?\.openExpert\(context \|\| undefined\)/u);
assert.match(app, /themeCatalogManageButton\.disabled = operatorUiLocked\(\)/u);
assert.match(app, /expertCatalogManageButton\.disabled = operatorUiLocked\(\)/u);
});
test("first-routing consumes only messages owned by an operator controller", () => {
const nativeHandler = functionBody(app, "handleNativeMessage", "bindEvents");
const financialOffset = nativeHandler.indexOf("manualFinancialUi?.handleMessage");
const listsOffset = nativeHandler.indexOf("manualListsUi?.handleMessage");
const catalogOffset = nativeHandler.indexOf("operatorCatalogUi?.handleMessage");
const switchOffset = nativeHandler.indexOf("switch (message.type)");
assert.ok(financialOffset >= 0 && financialOffset < listsOffset);
assert.ok(listsOffset < catalogOffset && catalogOffset < switchOffset);
let id = 0;
const financial = manualFinancialUiModule.createController({
postNative() {},
appendPlaylistEntry() { return true; },
isLocked() { return false; },
getSelectedStock() { return null; },
showToast() {},
addLog() {},
createId() { id += 1; return `test-${id}`; }
});
const unownedFinancialTypes = [
manualFinancialWorkflow.bridgeContract.listResultType,
manualFinancialWorkflow.bridgeContract.listErrorType,
manualFinancialWorkflow.bridgeContract.loadResultType,
manualFinancialWorkflow.bridgeContract.loadErrorType,
"stock-search-results",
"stock-search-error",
manualFinancialWorkflow.bridgeContract.selectionResultType,
manualFinancialWorkflow.bridgeContract.selectionErrorType,
manualFinancialWorkflow.bridgeContract.mutationResultType,
manualFinancialWorkflow.bridgeContract.mutationErrorType,
manualFinancialWorkflow.bridgeContract.requestErrorType
];
for (const type of unownedFinancialTypes) {
assert.equal(financial.handleMessage(type, { requestId: "not-owned" }), false, type);
}
const lists = manualListsUiModule.createController({
postNative() {},
appendPlaylistEntry() { return true; },
isLocked() { return false; },
showToast() {},
addLog() {},
createId() { id += 1; return `test-${id}`; }
});
for (const type of [
manualListsWorkflow.bridgeContract.statusResultType,
manualListsWorkflow.bridgeContract.netSellDataType,
manualListsWorkflow.bridgeContract.netSellSaveResultType,
manualListsWorkflow.bridgeContract.viDataType,
manualListsWorkflow.bridgeContract.viSaveResultType,
manualListsWorkflow.bridgeContract.errorType,
"stock-search-results",
"stock-search-error"
]) {
assert.equal(lists.handleMessage(type, { requestId: "not-owned" }), false, type);
}
});
test("operator locks and trusted append checks cover every migrated manual surface", () => {
const lock = functionBody(app, "operatorUiLocked", "selectedStockForManualFinancial");
assert.match(lock, /isPlaylistSnapshotLocked\(\)/u);
assert.match(lock, /namedPlaylistBusy\(\)/u);
assert.match(lock, /state\.manualFinancialRestore\.entries\.size > 0/u);
const renderPlayout = functionBody(app, "renderPlayout", "clearPlayoutError");
for (const controller of ["manualListsUi", "manualFinancialUi", "operatorCatalogUi"]) {
assert.match(renderPlayout, new RegExp(`${controller}\\?\\.setLocked\\(operatorLocked\\)`));
}
const append = functionBody(app, "appendTrustedOperatorEntry", "isPlaylistSnapshotLocked");
assert.match(append, /if \(operatorUiLocked\(\)\) throw/u);
assert.match(append, /manualListsWorkflow\.restorePlaylistEntry\(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);
assert.match(append, /state\.playlist\.push\(entry\)/u);
assert.match(manualFinancialUi,
/if \(!workflow\.isPlaylistEntryPlayable\(entry\)\) throw new TypeError\("Untrusted entry\."\)/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);
assert.match(normalizeStored, /options\.manualFinancialRestores\.push\(pendingManualFinancialEntry\)/u);
assert.match(normalizeStored,
/operatorInputBuilderKeys\.has\(String\(item\.builderKey \|\| ""\)\) \|\| item\.operator !== undefined[\s\S]*return \[\]/u);
const stockRestore = functionBody(
app,
"handleManualFinancialRestoreStockResults",
"handleManualFinancialRestoreStockError");
assert.match(stockRestore, /response\.query\s*[!=]==?\s*record\.request\.query|record\.request\.query\s*[!=]==?\s*response\.query/u);
assert.match(stockRestore, /response\.truncated/u);
assert.match(stockRestore, /response\.totalRowCount[\s\S]*record\.request\.maximumResults/u);
assert.match(stockRestore,
/const expectedMarket = [\s\S]*record\.pending\.verifiedStock\.market/u);
assert.match(stockRestore,
/const expectedCode = [\s\S]*record\.pending\.verifiedStock\.code/u);
assert.match(stockRestore,
/verifyStockForRecord\([\s\S]*expectedMarket,[\s\S]*expectedCode/u);
assert.match(stockRestore,
/record\.restoreKind === "named-financial"[\s\S]*namedMatch\.length === 1/u);
const prepareBlock = functionBody(app, "manualFinancialPrepareBlocked", "loadPlaylist");
assert.match(prepareBlock, /state\.manualFinancialRestore\.entries\.size > 0/u);
assert.match(prepareBlock, /manualFinancialWorkflow\.isPlaylistEntryPlayable\(item\)/u);
assert.match(app,
/prepareButton\.disabled = [^;]*manualFinancialBlocked/u);
const requestCommand = functionBody(app, "requestPlayoutCommand", "requestPlayoutStatus");
assert.match(requestCommand,
/if \(command === "prepare"\)[\s\S]*if \(manualFinancialPrepareBlocked\(\)\)[\s\S]*return false/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);
assert.match(mainWindow, /case "request-manual-operator-data-status"/u);
assert.match(mainWindow, /case "save-manual-net-sell-data"/u);
assert.match(mainWindow, /case "save-vi-manual-list"/u);
assert.match(mainWindow, /InvalidateOperatorCatalogBrowserRequests\(\)/u);
assert.match(mainWindow, /InvalidateManualOperatorBrowserRequests\(\)/u);
assert.match(mainWindow, /InvalidateManualFinancialBrowserRequests\(\)/u);
assert.match(manualFinancialBridge, /_playoutCommandGate\.WaitAsync\(0,/u);
assert.match(manualFinancialBridge, /_databaseActivityGate\.WaitAsync\(0,/u);
assert.match(operatorCatalogBridge, /_playoutCommandGate\.WaitAsync\(0,/u);
assert.match(operatorCatalogBridge, /_databaseActivityGate\.WaitAsync\(0,/u);
assert.match(manualListsBridge, /TryEnterManualMutationGatesAsync/u);
assert.match(manualListsBridge, /_playoutCommandGate\.WaitAsync\(0\)/u);
});
test("native operator writes share one fail-closed idle gate at their dispatch boundary", () => {
const gateStart = playoutBridge.indexOf("private bool CanStartOperatorMutation(");
const gateEnd = playoutBridge.indexOf("private void ShutdownPlayoutRuntime(", gateStart);
assert.ok(gateStart >= 0 && gateEnd > gateStart);
const gate = playoutBridge.slice(gateStart, gateEnd);
assert.match(gate, /familyWriteQuarantined/u);
assert.match(gate, /_lifetimeCancellation\.IsCancellationRequested/u);
assert.match(gate, /_playoutCommandInFlight/u);
assert.match(gate, /_playoutShutdownStarted/u);
assert.match(gate, /IsBrowserCorrelationQuarantined\(\)/u);
assert.match(gate, /IsEngineAvailable: engine is not null/u);
assert.match(gate, /IsWorkflowAvailable: workflow is not null/u);
assert.match(gate, /IsCommandAvailable: status\?\.IsCommandAvailable \?\? false/u);
assert.match(gate, /IsPlayCompletionPending: status\?\.IsPlayCompletionPending \?\? true/u);
assert.match(gate, /PreparedSceneName: status\?\.PreparedSceneName/u);
assert.match(gate, /OnAirSceneName: status\?\.OnAirSceneName/u);
assert.match(gate, /PreparedCutCode: workflowState\?\.PreparedCutCode/u);
assert.match(gate, /OnAirCutCode: workflowState\?\.OnAirCutCode/u);
assert.match(gate, /IsRefreshActive: refreshStatus\.IsActive/u);
assert.match(gate, /IsRefreshTaskRunning: _legacyRefreshTask is \{ IsCompleted: false \}/u);
assert.match(gate, /OperatorMutationGate\.CanStart\(snapshot\)/u);
assert.match(operatorMutationGate, /snapshot is not null/u);
assert.match(operatorMutationGate, /snapshot\.IsEngineAvailable/u);
assert.match(operatorMutationGate, /snapshot\.IsWorkflowAvailable/u);
assert.match(manualFinancialBridge,
/CanStartOperatorMutation\(IsManualFinancialWriteQuarantined\(\)\)/u);
assert.ok((manualFinancialBridge.match(/CanStartManualFinancialWrite\(\)/gu) || []).length >= 3);
assert.match(operatorCatalogBridge,
/CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/u);
assert.ok((operatorCatalogBridge.match(/CanStartOperatorCatalogWrite\(\)/gu) || []).length >= 2);
assert.match(manualListsBridge,
/CanStartOperatorMutation\(IsManualOperatorWriteQuarantined\(\)\)/u);
assert.ok((manualListsBridge.match(
/CanStartOperatorMutation\(IsManualOperatorWriteQuarantined\(\)\)/gu) || []).length >= 3);
for (const write of [
sliceBetween(
manualListsBridge,
"private async Task HandleManualNetSellWriteAsync(",
"private async Task HandleViManualListReadAsync("),
sliceBetween(
manualListsBridge,
"private async Task HandleViManualListWriteAsync(",
"private async Task<bool> TryEnterManualMutationGatesAsync(")
]) {
assert.match(write,
/TryEnterManualMutationGatesAsync\([\s\S]*Interlocked\.Exchange\(ref _manualOperatorWriteInFlight, 1\)[\s\S]*await (?:store\.)?Write/su);
}
});
test("an unknown write outcome globally quarantines GraphE, ThemeA/EList, FSell/VI and named playlists", () => {
assert.match(playoutBridge,
/private int _operatorMutationQuarantined;[\s\S]*private string\? _operatorMutationQuarantineMessage;/u);
assert.match(playoutBridge,
/private bool IsOperatorMutationQuarantined\(\) =>[\s\S]*_operatorMutationQuarantined/u);
assert.match(playoutBridge,
/private void LatchOperatorMutationQuarantine\(string message\)[\s\S]*Interlocked\.CompareExchange\(ref _operatorMutationQuarantined, 1, 0\)/u);
for (const [surface, source, familyCheck, familyLatch] of [
["GraphE", manualFinancialBridge,
"IsManualFinancialWriteQuarantined", "LatchManualFinancialWriteQuarantine"],
["ThemeA/EList", operatorCatalogBridge,
"IsOperatorCatalogWriteQuarantined", "LatchOperatorCatalogWriteQuarantine"],
["FSell/VI", manualListsBridge,
"IsManualOperatorWriteQuarantined", "QuarantineManualOperatorWrites"],
["named playlist", namedPlaylistBridge,
"IsNamedPlaylistWriteQuarantined", "LatchNamedPlaylistWriteQuarantine"]
]) {
assert.match(source, new RegExp(
`private bool ${familyCheck}\\(\\) =>[\\s\\S]{0,180}IsOperatorMutationQuarantined\\(\\)`, "u"),
`${surface} must observe the process-wide quarantine`);
assert.match(source, new RegExp(
`private void ${familyLatch}\\(string message\\)[\\s\\S]{0,260}LatchOperatorMutationQuarantine\\(message\\)`, "u"),
`${surface} must latch the process-wide quarantine`);
}
});
test("malformed operator payloads retain a family-specific fail-closed fallback", () => {
assert.match(manualFinancialBridge,
/TryHandleManualFinancialRequest\(string\? requestType, JsonElement payload\)[\s\S]*case "create-manual-financial-record"[\s\S]*return true/u);
assert.match(manualFinancialBridge,
/private static string SafeManualFinancialRequestId\(JsonElement payload\) =>[\s\S]*payload\.ValueKind == JsonValueKind\.Object/u);
assert.match(operatorCatalogBridge,
/TryHandleOperatorCatalogRequest\(string\? requestType, JsonElement payload\)[\s\S]*case "create-theme-catalog"[\s\S]*case "create-expert-catalog"[\s\S]*return true/u);
assert.match(operatorCatalogBridge,
/private static string SafeOperatorCatalogRequestId\(JsonElement payload\)[\s\S]*payload\.ValueKind == JsonValueKind\.Object/u);
for (const [parser, nextParser, readToken] of [
["TryParseAudienceRequest", "SafeManualOperatorRequestId", 'GetString(payload, "audience")'],
["TryParseManualNetSellWrite", "TryParseViWrite", 'GetString(payload, "audience")'],
["TryParseViWrite", "TryGetViExpectedVersion", "TryGetViExpectedVersion(payload"]
]) {
const body = sliceBetween(
manualListsBridge,
`private static bool ${parser}(`,
`private static ${nextParser === "SafeManualOperatorRequestId" ? "string" : "bool"} ${nextParser}(`);
assert.match(body, /requestId = SafeManualOperatorRequestId\(payload\)/u, parser);
const objectGuard = body.indexOf("payload.ValueKind");
const firstPropertyRead = body.indexOf(readToken);
assert.ok(objectGuard >= 0 && firstPropertyRead > objectGuard,
`${parser} must check object kind before reading a property`);
}
assert.ok((manualListsBridge.match(/"INVALID_REQUEST"/gu) || []).length >= 5);
const viVersion = sliceBetween(
manualListsBridge,
"private static bool TryGetViExpectedVersion(",
"private static bool TryGetManualValue(");
assert.ok(viVersion.indexOf("payload.ValueKind") <
viVersion.indexOf('payload.TryGetProperty("expectedVersion"'));
assert.match(mainWindow,
/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", () => {
const closed = sliceBetween(mainWindow, "private void OnClosed(", "private sealed record WebRequest(");
assert.match(closed, /ShutdownManualOperatorDataRuntime\(\)/u);
const shutdown = sliceBetween(
manualListsBridge,
"private void ShutdownManualOperatorDataRuntime(",
"private async Task DisposeManualOperatorStoresWhenIdleAsync(");
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);
const dispose = sliceBetween(
manualListsBridge,
"private async Task DisposeManualOperatorStoresWhenIdleAsync(",
"private void InvalidateManualOperatorBrowserRequests(");
assert.match(dispose, /await _manualOperatorDataGate\.WaitAsync\(\)/u);
assert.match(dispose, /viStore\?\.Dispose\(\)/u);
assert.match(dispose, /netSellStore\?\.Dispose\(\)/u);
assert.match(dispose, /_manualOperatorDataGate\.Release\(\)/u);
});
test("GraphE async UI boundaries retain the dispatcher context before posting WebView replies", () => {
assert.doesNotMatch(manualFinancialBridge,
/operationBody\(requestToken\)\.ConfigureAwait\(false\)/u);
assert.doesNotMatch(manualFinancialBridge,
/preflight\(requestToken\)\.ConfigureAwait\(false\)/u);
assert.doesNotMatch(manualFinancialBridge,
/operationBody\(service,\s*requestToken\)\.ConfigureAwait\(false\)/u);
});

View File

@@ -0,0 +1,326 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/operator-workflow.js");
const legacyLabels = [
"1열판기본_예상체결가",
"1열판기본_현재가",
"1열판기본_시간외단일가",
"1열판상세_시가",
"1열판상세_액면가",
"1열판상세_PBR",
"1열판상세_거래량",
"수익률그래프_5일",
"수익률그래프_20일",
"수익률그래프_60일",
"수익률그래프_120일",
"수익률그래프_240일",
"캔들그래프_일봉",
"캔들그래프_5일",
"캔들그래프_20일",
"캔들그래프_60일",
"캔들그래프_120일",
"캔들그래프_240일",
"캔들그래프(거래량)_일봉",
"캔들그래프(거래량)_5일",
"캔들그래프(거래량)_20일",
"캔들그래프(거래량)_60일",
"캔들그래프(거래량)_120일",
"캔들그래프(거래량)_240일",
"캔들그래프(예상체결가)_5일",
"캔들그래프(예상체결가)_20일",
"캔들그래프(예상체결가)_60일",
"캔들그래프(예상체결가)_120일",
"캔들그래프(예상체결가)_240일",
"호가창_표그래프",
"거래원_표그래프"
];
const kospiStock = Object.freeze({
market: "kospi",
stockName: "테스트전자",
displayName: "테스트전자",
stockCode: "A123456",
isNxt: false
});
test("stock catalog is pinned to the 31-row runtime 종목.ini workflow", () => {
assert.deepEqual(workflow.runtimeStockAsset, {
relativePath: "bin/Debug/Res/종목.ini",
sha256: "45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20"
});
assert.equal(workflow.stockCuts.length, 31);
assert.deepEqual(workflow.stockCuts.map(cut => cut.label), legacyLabels);
assert.equal(new Set(workflow.stockCuts.map(cut => cut.id)).size, 31);
assert.equal(workflow.stockCuts.some(cut => cut.label === "-"), false);
for (const cut of workflow.stockCuts) {
assert.equal(typeof cut.id, "string");
assert.equal(typeof cut.section, "string");
assert.equal(typeof cut.group, "string");
assert.equal(typeof cut.detail, "string");
assert.match(cut.builderKey, /^s\d+$/);
assert.match(cut.alias, /^[A-Z]?\d+$/);
assert.equal(cut.cutCode, cut.alias);
assert.equal(cut.aliases.includes(cut.alias), true);
}
});
test("native stock search rows normalize to a typed stock without sample fallbacks", () => {
assert.deepEqual(
workflow.normalizeStockResult({
market: "kospi",
source: "oracle",
name: "실제조회종목",
code: "005930"
}),
{
market: "kospi",
stockName: "실제조회종목",
displayName: "실제조회종목",
stockCode: "005930",
isNxt: false
});
assert.deepEqual(
workflow.normalizeStockResult({
market: "nxt-kosdaq",
source: "mariaDb",
name: "NXT조회종목",
code: "123456"
}),
{
market: "kosdaq",
stockName: "NXT조회종목",
displayName: "NXT조회종목(NXT)",
stockCode: "123456",
isNxt: true
});
assert.deepEqual(
workflow.normalizeStockResult({
market: "코스피_NXT",
stockName: "표시종목(NXT)",
displayName: "표시종목(NXT)",
stockCode: "A000001",
isNxt: true
}),
{
market: "kospi",
stockName: "표시종목",
displayName: "표시종목(NXT)",
stockCode: "A000001",
isNxt: true
});
assert.equal(workflow.normalizeStockResult(null), null);
assert.equal(workflow.normalizeStockResult({}), null);
assert.equal(workflow.normalizeStockResult({ market: "kospi", name: "", code: "005930" }), null);
assert.equal(workflow.normalizeStockResult({ market: "overseas", name: "종목", code: "005930" }), null);
assert.equal(workflow.normalizeStockResult({ market: "kospi", name: "종목", code: "../cut" }), null);
assert.equal(workflow.normalizeStockResult({ market: "nxt-kospi", name: "종목", code: "005930", isNxt: false }), null);
});
test("NXT selection permits only 1열판기본_현재가", () => {
const nxt = {
market: "nxt-kospi",
stockName: "NXT테스트",
displayName: "NXT테스트(NXT)",
stockCode: "123456",
isNxt: true
};
for (const cut of workflow.stockCuts) {
assert.equal(
workflow.isStockCutAllowed(nxt, cut.id),
cut.id === "basic-current",
cut.label);
}
assert.equal(workflow.isStockCutAllowed(kospiStock, "not-a-cut"), false);
});
test("every regular stock cut maps to its closed builder, alias and LegacySceneSelection", () => {
const expected = [
["basic-expected", "s5001", "5001", "KOSPI", "1열판기본", "EXPECTED_OPENING"],
["basic-current", "s5001", "5001", "KOSPI", "1열판기본", "CURRENT"],
["basic-after-hours", "s5001", "5001", "KOSPI", "1열판기본", "AFTER_HOURS_SINGLE_PRICE"],
["detail-open", "s5006", "5006", "KOSPI_STOCK", "1열판상세", "시가"],
["detail-face-value", "s5011", "5011", "KOSPI_STOCK", "1열판상세", "FaceValue"],
["detail-pbr", "s5011", "5011", "KOSPI_STOCK", "1열판상세", "Valuation"],
["detail-volume", "s5011", "5011", "KOSPI_STOCK", "1열판상세", "Volume"],
["yield-5d", "s5086", "5086", "KOSPI", "수익률그래프", "5일"],
["yield-20d", "s5086", "5086", "KOSPI", "수익률그래프", "20일"],
["yield-60d", "s5086", "5086", "KOSPI", "수익률그래프", "60일"],
["yield-120d", "s5086", "5086", "KOSPI", "수익률그래프", "120일"],
["yield-240d", "s5086", "5086", "KOSPI", "수익률그래프", "240일"],
["candle-daily", "s8010", "8035", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-5d", "s8010", "8061", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-20d", "s8010", "8040", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-60d", "s8010", "8046", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-120d", "s8010", "8051", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-240d", "s8010", "8056", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-volume-daily", "s8010", "8035", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-5d", "s8010", "8061", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-20d", "s8010", "8040", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-60d", "s8010", "8046", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-120d", "s8010", "8051", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-240d", "s8010", "8056", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-expected-5d", "s8010", "8061", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["candle-expected-20d", "s8010", "8040", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["candle-expected-60d", "s8010", "8046", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["candle-expected-120d", "s8010", "8051", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["candle-expected-240d", "s8010", "8056", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["order-book", "s8003", "8003", "KOSPI", "ORDER_BOOK", "TABLE_GRAPH"],
["traders", "s5037", "5037", "KOSPI", "TRADER", "TABLE_GRAPH"]
];
assert.equal(expected.length, 31);
for (const [id, builderKey, code, groupCode, graphicType, subtype] of expected) {
const entry = workflow.createStockPlaylistEntry(kospiStock, id, {
id: `entry-${id}`,
ma5: true,
ma20: true,
fadeDuration: 8
});
assert.equal(entry.builderKey, builderKey, id);
assert.equal(entry.code, code, id);
assert.equal(entry.selection.groupCode, groupCode, id);
assert.equal(entry.selection.subject, "테스트전자", id);
assert.equal(entry.selection.graphicType, graphicType, id);
assert.equal(entry.selection.subtype, subtype, id);
assert.equal(entry.selection.dataCode, "A123456", id);
assert.equal(entry.category, "stock", id);
assert.equal(entry.market, "kospi", id);
assert.equal(entry.enabled, true, id);
assert.equal(entry.fadeDuration, 8, id);
assert.equal(entry.operator.cutId, id, id);
assert.equal(entry.operator.source, "legacy-stock-workflow", id);
}
});
test("moving-average flags are ordered and apply only to candle cuts", () => {
const ma20 = workflow.createStockPlaylistEntry(kospiStock, "candle-20d", {
id: "ma20",
ma5: false,
ma20: true
});
assert.equal(ma20.selection.graphicType, "MA20");
assert.deepEqual(ma20.operator.movingAverages, { ma5: false, ma20: true });
const none = workflow.createStockPlaylistEntry(kospiStock, "candle-5d", { id: "none" });
assert.equal(none.selection.graphicType, "");
const nonCandle = workflow.createStockPlaylistEntry(kospiStock, "basic-current", {
id: "quote",
ma5: true,
ma20: true
});
assert.equal(nonCandle.selection.graphicType, "1열판기본");
assert.deepEqual(nonCandle.operator.movingAverages, { ma5: false, ma20: false });
});
test("NXT current quote uses N5001 and the native NXT market selection", () => {
const entry = workflow.createStockPlaylistEntry({
market: "nxt-kosdaq",
stockName: "NXT테스트",
displayName: "NXT테스트",
stockCode: "654321",
isNxt: true
}, "basic-current", { id: "nxt-current" });
assert.equal(entry.builderKey, "s5001");
assert.equal(entry.code, "N5001");
assert.equal(entry.market, "kosdaq");
assert.equal(entry.title, "NXT테스트(NXT)");
assert.deepEqual(entry.selection, {
groupCode: "NXT_KOSDAQ",
subject: "NXT테스트(NXT)",
dataCode: "654321",
graphicType: "1열판기본",
subtype: "CURRENT"
});
assert.throws(
() => workflow.createStockPlaylistEntry(entry.operator.stock, "order-book", { id: "bad" }),
/NXT stocks support only/);
});
test("entry construction fails closed and never supplies a sample stock", () => {
assert.throws(
() => workflow.createStockPlaylistEntry({}, "basic-current", { id: "missing-stock" }),
/valid selected stock/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "missing-cut", { id: "missing-cut" }),
/unknown/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "basic-current", {}),
/entry id/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "unsafe id" }),
/entry id/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "fade", fadeDuration: 61 }),
/Fade duration/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "flags", ma5: "yes" }),
/Moving-average flags/);
const entry = workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "default-fade" });
assert.equal(entry.fadeDuration, 6);
assert.equal(entry.stockName, "테스트전자");
});
test("stored operator stock entries restore only when every trusted mapping still matches", () => {
const stored = workflow.createStockPlaylistEntry(kospiStock, "candle-volume-60d", {
id: "stored-candle",
ma5: true,
ma20: false,
fadeDuration: 9
});
stored.enabled = false;
const restored = workflow.restoreStockPlaylistEntry(JSON.parse(JSON.stringify(stored)));
assert.ok(restored);
assert.equal(restored.enabled, false);
assert.equal(restored.code, "8046");
assert.equal(restored.detail, "캔들그래프(거래량)_60일");
assert.equal(restored.selection.graphicType, "MA5");
for (const tamper of [
value => { value.builderKey = "s5001"; },
value => { value.code = "5001"; },
value => { value.selection.subject = "다른종목"; },
value => { value.operator.cutId = "basic-current"; },
value => { value.operator.source = "unknown"; },
value => { value.operator.legacyLabel = "변조"; },
value => { value.enabled = "false"; }
]) {
const candidate = JSON.parse(JSON.stringify(stored));
tamper(candidate);
assert.equal(workflow.restoreStockPlaylistEntry(candidate), null);
}
assert.equal(workflow.restoreStockPlaylistEntry(null), null);
});
test("four legacy manual-data shortcuts are explicit unavailable prerequisites", () => {
assert.deepEqual(
workflow.manualStockActions.map(action => [
action.label,
action.builderKey,
action.alias,
action.prerequisiteTable
]),
[
["주요매출 구성", "s5076", "5076", "INPUT_PIE"],
["성장성 지표", "s5079", "5079", "INPUT_GROW"],
["매출액", "s5080", "5080", "INPUT_SELL"],
["영업이익", "s5081", "5081", "INPUT_PROFIT"]
]);
for (const action of workflow.manualStockActions) {
assert.equal(action.available, false);
assert.equal(action.autoAdd, false);
assert.equal(action.status, "unavailable");
assert.equal(action.prerequisites.length, 3);
assert.match(action.prerequisites[0], new RegExp(action.prerequisiteTable));
}
});

View File

@@ -0,0 +1,377 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/overseas-workflow.js");
const industry = Object.freeze({
koreanName: "Philadelphia Semiconductor",
inputName: "US Semiconductor Index",
symbol: "NAS@SOX",
nationCode: "US",
fdtc: "0"
});
const usStock = Object.freeze({
inputName: "NVIDIA",
symbol: "NAS@NVDA",
nationCode: "US",
fdtc: "1"
});
const twStock = Object.freeze({
inputName: "Taiwan Semiconductor",
symbol: "TWS@2330",
nationCode: "TW",
fdtc: "1"
});
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function industryResponse(overrides = {}) {
return {
requestId: "overseas-industry-1",
query: "",
retrievedAt: "2026-07-11T12:34:56+09:00",
totalRowCount: 2,
truncated: false,
results: [
{
koreanName: "Alpha Industry",
inputName: "ALPHA_INDEX",
symbol: "NAS@ALPHA",
nationCode: "US",
fdtc: "0"
},
{
koreanName: "Beta Industry",
inputName: "BETA_INDEX",
symbol: "NAS@BETA",
nationCode: "US",
fdtc: "0"
}
],
...overrides
};
}
function stockResponse(overrides = {}) {
return {
requestId: "overseas-stock-1",
query: "",
retrievedAt: "2026-07-11T12:34:56+09:00",
totalRowCount: 2,
truncated: false,
results: [
{ inputName: "Apple", symbol: "NAS@AAPL", nationCode: "US", fdtc: "1" },
{ inputName: "Taiwan Semiconductor", symbol: "TWS@2330", nationCode: "TW", fdtc: "1" }
],
...overrides
};
}
test("UC5 bridge contracts expose one industry action and six stock actions", () => {
assert.deepEqual(workflow.bridgeContracts.industry, {
requestType: "search-overseas-industries",
resultType: "overseas-industry-results",
errorType: "overseas-industry-error",
defaultMaximumResults: 100,
maximumResults: 500,
maximumQueryLength: 64
});
assert.deepEqual(workflow.bridgeContracts.stock, {
requestType: "search-world-stocks",
resultType: "world-stock-search-results",
errorType: "world-stock-search-error",
defaultMaximumResults: 100,
maximumResults: 500,
maximumQueryLength: 64
});
assert.deepEqual(workflow.industryActions.map(value => [value.id, value.code]), [
["industry-current", "5001"]
]);
assert.deepEqual(workflow.stockActions.map(value => [value.id, value.code, value.periodDays]), [
["stock-current", "5001", null],
["stock-candle-5d", "8061", 5],
["stock-candle-20d", "8040", 20],
["stock-candle-60d", "8046", 60],
["stock-candle-120d", "8051", 120],
["stock-candle-240d", "8056", 240]
]);
assert.deepEqual(workflow.sourceContract.industry, {
fdtc: "0", nationCodes: ["US"], lookupField: "F_KNAM", currentCutCode: "5001"
});
assert.deepEqual(workflow.sourceContract.stock.candleCutCodes, [
"8061", "8040", "8046", "8051", "8056"
]);
});
test("industry and stock search requests normalize blank initial lists and exact limits", () => {
assert.deepEqual(
workflow.createIndustrySearchRequest("industry-request", " "),
{ requestId: "industry-request", query: "" });
assert.deepEqual(
workflow.createStockSearchRequest("stock-request", " NVDA ", 200),
{ requestId: "stock-request", query: "NVDA", maximumResults: 200 });
for (const create of [workflow.createIndustrySearchRequest, workflow.createStockSearchRequest]) {
assert.throws(() => create("bad id", ""), /safe/i);
assert.throws(() => create("request", "bad\nquery"), /safe/i);
assert.throws(() => create("request", "x".repeat(65)), /64/);
assert.throws(() => create("request", "", 0), /1 through 500/);
assert.throws(() => create("request", "", 501), /1 through 500/);
assert.throws(() => create("request", "", "100"), /1 through 500/);
}
});
test("native identities require exact name, symbol, nation and FDTC fields", () => {
assert.deepEqual(workflow.normalizeIndustry(industry), industry);
assert.deepEqual(workflow.normalizeStock(usStock), usStock);
assert.deepEqual(workflow.normalizeStock(twStock), twStock);
assert.equal(workflow.normalizeIndustry({ ...industry, nationCode: "TW" }), null);
assert.equal(workflow.normalizeIndustry({ ...industry, fdtc: "1" }), null);
assert.equal(workflow.normalizeIndustry({ ...industry, koreanName: " Industry" }), null);
assert.equal(workflow.normalizeIndustry({ ...industry, inputName: "A|B" }), null);
assert.equal(workflow.normalizeStock({ ...usStock, nationCode: "JP" }), null);
assert.equal(workflow.normalizeStock({ ...usStock, fdtc: 1 }), null);
assert.equal(workflow.normalizeStock({ ...usStock, symbol: "../../NVDA" }), null);
assert.equal(workflow.normalizeStock({ ...usStock, inputName: "NV\u200BIDIA" }), null);
assert.equal(workflow.normalizeStock({ ...usStock, koreanName: "extra" }), null);
});
test("industry responses are request-bound, ordered and deeply normalized", () => {
const request = workflow.createIndustrySearchRequest("overseas-industry-1", "", 2);
const normalized = workflow.normalizeIndustrySearchResponse(industryResponse(), request);
assert.ok(normalized);
assert.deepEqual(normalized, industryResponse());
assert.equal(Object.isFrozen(normalized), true);
assert.equal(Object.isFrozen(normalized.results), true);
assert.equal(Object.isFrozen(normalized.results[0]), true);
assert.equal(workflow.normalizeIndustrySearchResponse(
industryResponse({ requestId: "stale" }), request), null);
assert.equal(workflow.normalizeIndustrySearchResponse(
industryResponse({ totalRowCount: 1 }), request), null);
assert.equal(workflow.normalizeIndustrySearchResponse(
industryResponse({ retrievedAt: "invalid" }), request), null);
assert.equal(workflow.normalizeIndustrySearchResponse(
{ ...industryResponse(), extra: true }, request), null);
assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({
results: [...industryResponse().results].reverse()
}), request), null);
const overDefault = Array.from({ length: 101 }, (_, index) => {
const key = String(index).padStart(3, "0");
return {
koreanName: `Industry ${key}`,
inputName: `INDEX_${key}`,
symbol: `NAS@I${key}`,
nationCode: "US",
fdtc: "0"
};
});
assert.equal(workflow.normalizeIndustrySearchResponse({
...industryResponse(),
totalRowCount: overDefault.length,
results: overDefault
}, workflow.createIndustrySearchRequest("overseas-industry-1", "")), null);
});
test("industry responses reject every ambiguous downstream lookup key", () => {
for (const key of ["koreanName", "inputName", "symbol"]) {
const rows = clone(industryResponse().results);
rows[1][key] = rows[0][key];
assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({ results: rows })), null);
}
const wrongScope = clone(industryResponse());
wrongScope.results[1].fdtc = "1";
assert.equal(workflow.normalizeIndustrySearchResponse(wrongScope), null);
});
test("stock responses preserve US/TW identity and reject duplicate input-name lookups", () => {
const request = workflow.createStockSearchRequest("overseas-stock-1", "", 2);
assert.ok(workflow.normalizeStockSearchResponse(stockResponse(), request));
const duplicateName = clone(stockResponse());
duplicateName.results[1].inputName = duplicateName.results[0].inputName;
assert.equal(workflow.normalizeStockSearchResponse(duplicateName), null);
const sameSymbol = clone(stockResponse());
sameSymbol.results[1].symbol = sameSymbol.results[0].symbol;
assert.ok(workflow.normalizeStockSearchResponse(sameSymbol));
const wrongNation = clone(stockResponse());
wrongNation.results[1].nationCode = "JP";
assert.equal(workflow.normalizeStockSearchResponse(wrongNation), null);
assert.equal(workflow.normalizeStockSearchResponse(stockResponse({
results: [...stockResponse().results].reverse()
})), null);
});
test("bridge errors use exact payloads and reject stale request correlation", () => {
const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI");
const industryError = {
requestId: "industry-error-1",
query: "AI",
message: "Industry lookup failed."
};
assert.deepEqual(
workflow.normalizeIndustrySearchError(industryError, industryRequest),
industryError);
assert.equal(workflow.normalizeIndustrySearchError({
...industryError, requestId: "stale"
}, industryRequest), null);
assert.equal(workflow.normalizeIndustrySearchError({
...industryError, extra: true
}, industryRequest), null);
const stockRequest = workflow.createStockSearchRequest("stock-error-1", "NVDA");
const stockError = { requestId: "stock-error-1", query: "NVDA", message: "Failed." };
assert.deepEqual(workflow.normalizeStockSearchError(stockError, stockRequest), stockError);
assert.equal(workflow.normalizeStockSearchError({
...stockError, message: "bad\nmessage"
}, stockRequest), null);
});
test("US overseas industries expose only the 5001 current one-column mapping", () => {
assert.equal(workflow.isActionAllowed("industry", "industry-current", industry), true);
assert.equal(workflow.isActionAllowed("industry", "stock-candle-5d", industry), false);
const mapping = workflow.buildOverseasIndustrySelection(industry);
assert.equal(mapping.builderKey, "s5001");
assert.equal(mapping.code, "5001");
assert.deepEqual(mapping.aliases, ["5001"]);
assert.deepEqual(mapping.selection, {
groupCode: "FOREIGN_INDUSTRY",
subject: "Philadelphia Semiconductor",
graphicType: "1\uC5F4\uD310\uAE30\uBCF8",
subtype: "CURRENT",
dataCode: "US Semiconductor Index|NAS@SOX|US|0"
});
assert.throws(() => workflow.buildOverseasIndustrySelection({
...industry, nationCode: "TW"
}), /valid/i);
});
test("US/TW stocks expose 5001 current and the five exact UC5 candle aliases", () => {
const current = workflow.buildOverseasStockSelection(usStock, "stock-current", {
ma5: true,
ma20: true
});
assert.equal(current.builderKey, "s5001");
assert.deepEqual(current.selection, {
groupCode: "FOREIGN_STOCK",
subject: "NVIDIA",
graphicType: "1\uC5F4\uD310\uAE30\uBCF8",
subtype: "CURRENT",
dataCode: "NAS@NVDA|US|1"
});
assert.deepEqual(current.movingAverages, { ma5: false, ma20: false });
for (const action of workflow.stockActions.filter(value => value.kind === "candle")) {
const mapping = workflow.buildOverseasStockSelection(twStock, action.id, {
ma5: true,
ma20: false
});
assert.equal(mapping.builderKey, "s8010");
assert.equal(mapping.code, action.code);
assert.deepEqual(mapping.aliases, [action.code]);
assert.deepEqual(mapping.selection, {
groupCode: "FOREIGN_STOCK",
subject: "Taiwan Semiconductor",
graphicType: "MA5",
subtype: "PRICE",
dataCode: "TWS@2330|TW|1"
});
}
assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-candle-intraday"), /unknown/i);
assert.throws(() => workflow.buildOverseasStockSelection(usStock, "industry-current"), /unknown/i);
assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-current", {
ma5: "true"
}), /boolean/i);
});
test("playlist entries preserve typed FDTC identity and runtime candle bindings", () => {
const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, {
id: "industry-entry",
fadeDuration: 7
});
assert.equal(industryEntry.category, "plate");
assert.equal(industryEntry.symbol, "NAS@SOX");
assert.equal(industryEntry.nationCode, "US");
assert.equal(industryEntry.fdtc, "0");
assert.equal(industryEntry.operator.kind, "industry");
assert.equal(industryEntry.operator.schemaVersion, 1);
const candleEntry = workflow.createOverseasStockPlaylistEntry(
twStock,
"stock-candle-240d",
{ id: "tw-candle", fadeDuration: 8, ma5: true, ma20: true });
assert.equal(candleEntry.builderKey, "s8010");
assert.equal(candleEntry.code, "8056");
assert.equal(candleEntry.category, "chart");
assert.equal(candleEntry.selection.dataCode, "TWS@2330|TW|1");
assert.equal(candleEntry.selection.graphicType, "MA5,MA20");
assert.equal(candleEntry.operator.kind, "stock");
assert.deepEqual(candleEntry.operator.identity, twStock);
});
test("trusted restore reconstructs entries and rejects identity or mapping tampering", () => {
const original = workflow.createOverseasStockPlaylistEntry(
usStock,
"stock-candle-120d",
{ id: "stored-overseas", fadeDuration: 9, ma5: true, ma20: false });
original.enabled = false;
const restored = workflow.restoreOverseasPlaylistEntry(clone(original));
assert.ok(restored);
assert.equal(restored.enabled, false);
const tampers = [
value => { value.builderKey = "s5001"; },
value => { value.code = "8040"; },
value => { value.selection.groupCode = "FOREIGN_INDEX"; },
value => { value.selection.subject = "Other"; },
value => { value.selection.dataCode = "NAS@NVDA|TW|1"; },
value => { value.selection.subtype = "VOLUME"; },
value => { value.symbol = "NAS@OTHER"; },
value => { value.nationCode = "TW"; },
value => { value.fdtc = "0"; },
value => { value.operator.actionId = "stock-current"; },
value => { value.operator.schemaVersion = 2; },
value => { value.operator.identity.symbol = "NAS@OTHER"; },
value => { value.operator.identity.nationCode = "TW"; },
value => { value.operator.identity.fdtc = "0"; },
value => { value.operator.movingAverages.ma5 = false; },
value => { value.enabled = "false"; }
];
for (const tamper of tampers) {
const candidate = clone(original);
tamper(candidate);
assert.equal(workflow.restoreOverseasPlaylistEntry(candidate), null);
}
const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, {
id: "stored-industry"
});
assert.ok(workflow.restoreOverseasPlaylistEntry(clone(industryEntry)));
const tamperedIndustry = clone(industryEntry);
tamperedIndustry.operator.identity.koreanName = "Other Industry";
assert.equal(workflow.restoreOverseasPlaylistEntry(tamperedIndustry), null);
assert.equal(workflow.restoreOverseasPlaylistEntry(null), null);
});
test("playlist option validation rejects unsafe ids, fades and identities", () => {
assert.throws(() => workflow.createOverseasIndustryPlaylistEntry(industry, {
id: "bad id"
}), /safe/i);
assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", {
id: "fade", fadeDuration: 61
}), /0 through 60/i);
assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", {
id: "flags", ma20: 1
}), /boolean/i);
assert.throws(() => workflow.createOverseasStockPlaylistEntry(
{ ...usStock, nationCode: "JP" },
"stock-current",
{ id: "invalid" }), /valid/i);
});

View File

@@ -48,6 +48,32 @@ test("TAKE IN and TAKE OUT preserve the native state prerequisites", () => {
null);
});
test("legacy F8 chooses exactly one safe PREPARE or TAKE IN action", () => {
assert.equal(
safety.legacyTakeInShortcutAction(ready({ takeInAllowed: true })),
"prepare-then-take-in");
assert.equal(
safety.legacyTakeInShortcutAction(ready({
takeInAllowed: true,
engineState: "PREPARED",
preparedCode: "5001"
})),
"take-in");
assert.equal(
safety.legacyTakeInShortcutAction(ready({
takeInAllowed: true,
engineState: "PROGRAM",
onAirCode: "5001"
})),
"next-required");
assert.equal(
safety.legacyTakeInShortcutAction(ready({ takeInAllowed: false })),
"take-in-locked");
assert.equal(
safety.legacyTakeInShortcutAction(ready({ takeInAllowed: true, pending: true })),
"blocked");
});
test("Refresh faults block mutations but preserve emergency TAKE OUT", () => {
const faulted = ready({
refreshFaulted: true,

View File

@@ -0,0 +1,242 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/theme-workflow.js");
const krxTheme = Object.freeze({
market: "krx",
themeCode: "00000001",
title: "AI 반도체",
displayTitle: "AI 반도체",
session: null,
itemCount: 17
});
const nxtPreTheme = Object.freeze({
market: "nxt",
themeCode: "00000002",
title: "로봇",
displayTitle: "로봇(NXT)",
session: "PRE_MARKET",
itemCount: 121
});
const nxtAfterTheme = Object.freeze({
...nxtPreTheme,
session: "AFTER_MARKET"
});
test("theme workflow exposes the UC4 and closed resolver contract", () => {
assert.deepEqual(workflow.sourceContract, {
originalControl: "Control/UC4.cs",
krxResolverGroup: "PAGED_THEME",
nxtResolverGroup: "PAGED_NXT_THEME",
maximumPageCount: 20,
maximumPreviewItems: 240
});
assert.deepEqual(workflow.themeSorts, [
{ id: "INPUT_ORDER", label: "입력순" },
{ id: "GAIN_DESC", label: "상승률순" },
{ id: "GAIN_ASC", label: "하락률순" }
]);
assert.deepEqual(workflow.nxtSessions.map(value => value.id), ["PRE_MARKET", "AFTER_MARKET"]);
assert.deepEqual(workflow.actions.map(value => [value.id, value.builderKey, value.code, value.pageSize]), [
["five-row-current", "s5074", "5074", 5],
["five-row-expected", "s5074", "5074", 5],
["six-row-current", "s5077", "5077", 6],
["twelve-row-current", "s5088", "5088", 12]
]);
});
test("KRX and NXT database identities normalize without inferring market from a suffix", () => {
assert.deepEqual(workflow.normalizeTheme({
Market: "Krx", ThemeCode: "00000001", ThemeTitle: "AI 반도체", Session: "NotApplicable",
previewItemCount: 17
}), krxTheme);
assert.deepEqual(workflow.normalizeTheme({
market: "NXT", themeCode: "00000002", title: "로봇", session: "PreMarket", items: new Array(121)
}), nxtPreTheme);
assert.deepEqual(workflow.normalizeTheme({
market: "nxt", code: "00000002", themeTitle: "로봇", session: "after-market", itemCount: 121
}), nxtAfterTheme);
assert.equal(workflow.normalizeTheme({
market: "nxt", themeCode: "00000002", title: "로봇(NXT)", session: "PRE_MARKET"
}), null);
assert.equal(workflow.normalizeTheme({
market: "krx", themeCode: "00000001", title: " AI 반도체 "
}), null);
});
test("typed theme validation reports clear market, code, session and preview failures", () => {
assert.equal(workflow.getCompatibility("five-row-current", {
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"
}, "INPUT_ORDER").reason, "invalid-theme-code");
assert.equal(workflow.getCompatibility("five-row-current", {
market: "krx", themeCode: " 00000001 ", title: "AI"
}, "INPUT_ORDER").reason, "invalid-theme-code");
assert.equal(workflow.getCompatibility("five-row-current", {
market: "nxt", themeCode: "00000001", title: "AI"
}, "INPUT_ORDER").reason, "nxt-session-required");
assert.equal(workflow.getCompatibility("five-row-current", {
market: "krx", themeCode: "00000001", title: "AI", session: "PRE_MARKET"
}, "INPUT_ORDER").reason, "krx-session-must-be-empty");
assert.equal(workflow.getCompatibility("five-row-current", {
...krxTheme, itemCount: 241
}, "INPUT_ORDER").reason, "invalid-theme-item-count");
assert.equal(workflow.getCompatibility("five-row-current", krxTheme, "RANDOM").reason,
"invalid-theme-sort");
});
test("only three current row sizes and KRX five-row expected are compatible", () => {
for (const actionId of ["five-row-current", "six-row-current", "twelve-row-current"]) {
for (const sort of workflow.themeSorts) {
assert.equal(workflow.isActionAllowed(actionId, krxTheme, sort.id), true);
assert.equal(workflow.isActionAllowed(actionId, nxtPreTheme, sort.id), true);
assert.equal(workflow.isActionAllowed(actionId, nxtAfterTheme, sort.id), true);
}
}
assert.equal(workflow.isActionAllowed("five-row-expected", krxTheme, "INPUT_ORDER"), true);
assert.equal(workflow.getCompatibility("five-row-expected", nxtPreTheme, "INPUT_ORDER").reason,
"expected-theme-is-krx-only");
assert.equal(workflow.getCompatibility("six-row-expected", krxTheme, "INPUT_ORDER").reason,
"unknown-action");
assert.equal(workflow.getCompatibility("five-row-current", { ...krxTheme, itemCount: 0 }, "INPUT_ORDER").reason,
"theme-preview-is-empty");
});
test("KRX current and expected selections map sort and value kind to separate fields", () => {
const current = workflow.buildThemeSelection("five-row-current", krxTheme, "GAIN_DESC");
assert.equal(current.builderKey, "s5074");
assert.equal(current.code, "5074");
assert.deepEqual(current.aliases, ["5074"]);
assert.deepEqual(current.selection, {
groupCode: "PAGED_THEME",
subject: "AI 반도체",
graphicType: "GAIN_DESC",
subtype: "CURRENT",
dataCode: "00000001"
});
const expected = workflow.buildThemeSelection("five-row-expected", krxTheme, "GAIN_ASC");
assert.equal(expected.builderKey, "s5074");
assert.deepEqual(expected.selection, {
groupCode: "PAGED_THEME",
subject: "AI 반도체",
graphicType: "GAIN_ASC",
subtype: "EXPECTED",
dataCode: "00000001"
});
});
test("NXT selections map explicit session to GraphicType and sort to Subtype", () => {
const five = workflow.buildThemeSelection("five-row-current", nxtPreTheme, "INPUT_ORDER");
assert.deepEqual(five.selection, {
groupCode: "PAGED_NXT_THEME",
subject: "로봇(NXT)",
graphicType: "PRE_MARKET",
subtype: "INPUT_ORDER",
dataCode: "00000002"
});
const six = workflow.buildThemeSelection("six-row-current", nxtAfterTheme, "GAIN_DESC");
assert.equal(six.builderKey, "s5077");
assert.equal(six.code, "5077");
assert.equal(six.selection.graphicType, "AFTER_MARKET");
assert.equal(six.selection.subtype, "GAIN_DESC");
const twelve = workflow.buildThemeSelection("twelve-row-current", nxtAfterTheme, "GAIN_ASC");
assert.equal(twelve.builderKey, "s5088");
assert.equal(twelve.code, "5088");
assert.deepEqual(twelve.aliases, ["5088"]);
assert.equal(twelve.selection.subtype, "GAIN_ASC");
});
test("item-count previews calculate 5, 6 and 12 row pages within the 20-page boundary", () => {
assert.deepEqual(workflow.calculatePagePreview("five-row-current", { ...krxTheme, itemCount: 17 }), {
itemCount: 17, accessibleItemCount: 17, pageSize: 5,
pageCount: 4, maximumPageCount: 20, isTruncated: false
});
assert.deepEqual(workflow.calculatePagePreview("five-row-current", { ...krxTheme, itemCount: 101 }), {
itemCount: 101, accessibleItemCount: 100, pageSize: 5,
pageCount: 20, maximumPageCount: 20, isTruncated: true
});
assert.deepEqual(workflow.calculatePagePreview("six-row-current", { ...krxTheme, itemCount: 120 }), {
itemCount: 120, accessibleItemCount: 120, pageSize: 6,
pageCount: 20, maximumPageCount: 20, isTruncated: false
});
assert.deepEqual(workflow.calculatePagePreview("twelve-row-current", { ...krxTheme, itemCount: 240 }), {
itemCount: 240, accessibleItemCount: 240, pageSize: 12,
pageCount: 20, maximumPageCount: 20, isTruncated: false
});
assert.deepEqual(workflow.calculatePagePreview("five-row-current", {
market: "krx", themeCode: "00000001", title: "AI 반도체"
}), {
itemCount: null, accessibleItemCount: null, pageSize: 5,
pageCount: null, maximumPageCount: 20, isTruncated: false
});
});
test("playlist entries preserve the alias, preview and explicit theme identity", () => {
const entry = workflow.createThemePlaylistEntry("six-row-current", nxtAfterTheme, "GAIN_DESC", {
id: "nxt-theme", fadeDuration: 4
});
assert.equal(entry.builderKey, "s5077");
assert.equal(entry.code, "5077");
assert.equal(entry.title, "로봇(NXT)");
assert.equal(entry.detail, "6종목 현재가 · 상승률순");
assert.equal(entry.pageSize, 6);
assert.equal(entry.pagePreview.pageCount, 20);
assert.equal(entry.pagePreview.isTruncated, true);
assert.equal(entry.fadeDuration, 4);
assert.deepEqual(entry.operator.theme, nxtAfterTheme);
assert.equal(entry.operator.resolverGroup, "PAGED_NXT_THEME");
});
test("trusted theme restore reconstructs fields and rejects action, identity and preview tampering", () => {
const original = workflow.createThemePlaylistEntry("five-row-expected", krxTheme, "GAIN_ASC", {
id: "stored-theme", fadeDuration: 8
});
assert.ok(workflow.restoreThemePlaylistEntry(original));
assert.equal(workflow.restoreThemePlaylistEntry({ ...original, enabled: false }).enabled, false);
assert.equal(workflow.restoreThemePlaylistEntry({ ...original, code: "5088" }), null);
assert.equal(workflow.restoreThemePlaylistEntry({
...original,
selection: { ...original.selection, subtype: "CURRENT" }
}), null);
assert.equal(workflow.restoreThemePlaylistEntry({
...original,
pagePreview: { ...original.pagePreview, pageCount: 20 }
}), null);
assert.equal(workflow.restoreThemePlaylistEntry({
...original,
operator: { ...original.operator, schemaVersion: 2 }
}), null);
assert.equal(workflow.restoreThemePlaylistEntry({
...original,
operator: { ...original.operator, theme: { ...original.operator.theme, themeCode: "00000003" } }
}), null);
assert.equal(workflow.restoreThemePlaylistEntry({
...original,
operator: { ...original.operator, resolverGroup: "PAGED_NXT_THEME" }
}), null);
});
test("unknown actions, unsafe ids, invalid fades and unsupported known-empty themes cannot be created", () => {
assert.throws(() => workflow.createThemePlaylistEntry("unknown", krxTheme, "INPUT_ORDER", {
id: "unknown"
}), /unknown/i);
assert.throws(() => workflow.createThemePlaylistEntry("five-row-current", krxTheme, "INPUT_ORDER", {
id: "bad id"
}), /safe theme playlist id/i);
assert.throws(() => workflow.createThemePlaylistEntry("five-row-current", krxTheme, "INPUT_ORDER", {
id: "bad-fade", fadeDuration: 61
}), /0 through 60/i);
assert.throws(() => workflow.createThemePlaylistEntry(
"five-row-current", { ...krxTheme, itemCount: 0 }, "INPUT_ORDER", { id: "empty" }),
/theme-preview-is-empty/i);
assert.throws(() => workflow.createThemePlaylistEntry(
"five-row-expected", nxtPreTheme, "INPUT_ORDER", { id: "nxt-expected" }),
/expected-theme-is-krx-only/i);
});

View File

@@ -0,0 +1,272 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/trading-halt-workflow.js");
const kospi = Object.freeze({
market: "kospi",
stockCode: "005930",
displayName: "삼성전자"
});
const kosdaq = Object.freeze({
market: "kosdaq",
stockCode: "A123456",
displayName: "코스닥정지종목"
});
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function validResponse(overrides = {}) {
return {
requestId: "halt-request-1",
query: "",
retrievedAt: "2026-07-11T12:34:56+09:00",
totalRowCount: 2,
truncated: false,
results: [
{ market: "kospi", stockCode: "000001", displayName: "가나다" },
{ market: "kosdaq", stockCode: "000002", displayName: "라마바" }
],
...overrides
};
}
test("workflow pins the UC7 bridge and two closed scene actions", () => {
assert.deepEqual(workflow.bridgeContract, {
requestType: "search-trading-halts",
resultType: "trading-halt-results",
errorType: "trading-halt-error",
defaultMaximumResults: 100,
maximumResults: 500,
maximumQueryLength: 64
});
assert.deepEqual(workflow.sourceContract, {
originalControl: "Control/UC7.cs",
currentCutCode: "5001",
candleCutCode: "8051",
candlePeriodDays: 120,
currentResolverGroups: { kospi: "KOSPI", kosdaq: "KOSDAQ" },
candleResolverGroups: { kospi: "KOSPI_STOCK", kosdaq: "KOSDAQ_STOCK" }
});
assert.deepEqual(workflow.actions.map(value => [
value.id, value.builderKey, value.code, value.kind
]), [
["current", "s5001", "5001", "current"],
["candle-120d", "s8010", "8051", "candle"]
]);
});
test("search requests match the exact native bridge payload and normalize the query", () => {
assert.deepEqual(
workflow.createTradingHaltSearchRequest("request-1", " 삼성 "),
{ requestId: "request-1", query: "삼성" });
assert.deepEqual(
workflow.createTradingHaltSearchRequest("request-2", "", 200),
{ requestId: "request-2", query: "", maximumResults: 200 });
assert.throws(() => workflow.createTradingHaltSearchRequest("bad id", ""), /safe/i);
assert.throws(() => workflow.createTradingHaltSearchRequest("request", "bad\nquery"), /safe/i);
assert.throws(() => workflow.createTradingHaltSearchRequest("request", "x".repeat(65)), /64/);
assert.throws(() => workflow.createTradingHaltSearchRequest("request", "", 0), /1 through 500/);
assert.throws(() => workflow.createTradingHaltSearchRequest("request", "", 501), /1 through 500/);
assert.throws(() => workflow.createTradingHaltSearchRequest("request", "", "100"), /1 through 500/);
});
test("native rows normalize only exact typed market, stock-code and display-name values", () => {
assert.deepEqual(workflow.normalizeTradingHalt(kospi), kospi);
assert.deepEqual(workflow.normalizeTradingHalt(kosdaq), kosdaq);
assert.equal(workflow.normalizeTradingHalt({ ...kospi, market: "KOSPI" }), null);
assert.equal(workflow.normalizeTradingHalt({ ...kospi, market: "nxt-kospi" }), null);
assert.equal(workflow.normalizeTradingHalt({ ...kospi, stockCode: "../005930" }), null);
assert.equal(workflow.normalizeTradingHalt({ ...kospi, stockCode: " 005930" }), null);
assert.equal(workflow.normalizeTradingHalt({ ...kospi, displayName: " 삼성전자" }), null);
assert.equal(workflow.normalizeTradingHalt({ ...kospi, displayName: "삼성\n전자" }), null);
assert.equal(workflow.normalizeTradingHalt({ ...kospi, displayName: "삼성\u200B전자" }), null);
assert.equal(workflow.normalizeTradingHalt({ ...kospi, code: "005930" }), null);
assert.equal(workflow.normalizeTradingHalt({ market: "kospi", code: "005930", name: "삼성전자" }), null);
});
test("search responses are request-bound, deterministically ordered and deeply normalized", () => {
const request = workflow.createTradingHaltSearchRequest("halt-request-1", "", 2);
const response = workflow.normalizeTradingHaltSearchResponse(validResponse(), request);
assert.ok(response);
assert.deepEqual(response, validResponse());
assert.equal(Object.isFrozen(response), true);
assert.equal(Object.isFrozen(response.results), true);
assert.equal(Object.isFrozen(response.results[0]), true);
assert.equal(workflow.normalizeTradingHaltSearchResponse(
validResponse({ requestId: "other-request" }), request), null);
assert.equal(workflow.normalizeTradingHaltSearchResponse(
validResponse({ query: "삼성" }), request), null);
assert.equal(workflow.normalizeTradingHaltSearchResponse(
validResponse({ totalRowCount: 1 }), request), null);
assert.equal(workflow.normalizeTradingHaltSearchResponse(
validResponse({ retrievedAt: "not-a-date" }), request), null);
assert.equal(workflow.normalizeTradingHaltSearchResponse(
{ ...validResponse(), unexpected: true }, request), null);
});
test("search responses reject duplicates, ambiguous names, invalid rows and reordered markets", () => {
const duplicateIdentity = validResponse({
results: [
{ market: "kospi", stockCode: "000001", displayName: "가나다" },
{ market: "kospi", stockCode: "000001", displayName: "라마바" }
]
});
assert.equal(workflow.normalizeTradingHaltSearchResponse(duplicateIdentity), null);
const ambiguousName = validResponse({
results: [
{ market: "kospi", stockCode: "000001", displayName: "가나다" },
{ market: "kospi", stockCode: "000002", displayName: "가나다" }
]
});
assert.equal(workflow.normalizeTradingHaltSearchResponse(ambiguousName), null);
const crossMarketSameName = validResponse({
results: [
{ market: "kospi", stockCode: "000001", displayName: "가나다" },
{ market: "kosdaq", stockCode: "000001", displayName: "가나다" }
]
});
assert.ok(workflow.normalizeTradingHaltSearchResponse(crossMarketSameName));
const reordered = validResponse({ results: [...validResponse().results].reverse() });
assert.equal(workflow.normalizeTradingHaltSearchResponse(reordered), null);
assert.equal(workflow.normalizeTradingHaltSearchResponse(validResponse({
results: [validResponse().results[0], { ...validResponse().results[1], market: "nxt-kosdaq" }]
})), 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, {
ma5: true,
ma20: true
});
assert.equal(mapping.builderKey, "s5001");
assert.equal(mapping.code, "5001");
assert.deepEqual(mapping.aliases, ["5001"]);
assert.deepEqual(mapping.selection, {
groupCode,
subject: stock.displayName,
graphicType: "1열판기본",
subtype: "HALTED",
dataCode: stock.stockCode
});
assert.deepEqual(mapping.movingAverages, { ma5: false, ma20: false });
}
});
test("8051 selections expose only the four closed moving-average flag combinations", () => {
const cases = [
[false, false, ""],
[true, false, "MA5"],
[false, true, "MA20"],
[true, true, "MA5,MA20"]
];
for (const [ma5, ma20, graphicType] of cases) {
const mapping = workflow.buildTradingHaltSelection("candle-120d", kosdaq, { ma5, ma20 });
assert.equal(mapping.builderKey, "s8010");
assert.equal(mapping.code, "8051");
assert.deepEqual(mapping.aliases, ["8051"]);
assert.deepEqual(mapping.selection, {
groupCode: "KOSDAQ_STOCK",
subject: "코스닥정지종목",
graphicType,
subtype: "TRADING_HALT",
dataCode: "A123456"
});
}
assert.throws(() => workflow.buildTradingHaltSelection("candle-120d", kospi, {
ma5: "true", ma20: false
}), /boolean/i);
});
test("playlist entries preserve the typed identity and reject unsafe options", () => {
const entry = workflow.createTradingHaltPlaylistEntry(kospi, "candle-120d", {
id: "halt-candle",
fadeDuration: 8,
ma5: true,
ma20: false
});
assert.equal(entry.builderKey, "s8010");
assert.equal(entry.code, "8051");
assert.equal(entry.category, "chart");
assert.equal(entry.market, "kospi");
assert.equal(entry.stockCode, "005930");
assert.equal(entry.displayName, "삼성전자");
assert.equal(entry.selection.graphicType, "MA5");
assert.equal(entry.operator.source, "legacy-trading-halt-workflow");
assert.equal(entry.operator.schemaVersion, 1);
assert.deepEqual(entry.operator.tradingHalt, kospi);
assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "unknown", {
id: "unknown"
}), /unknown/i);
assert.throws(() => workflow.createTradingHaltPlaylistEntry({}, "current", {
id: "invalid"
}), /valid/i);
assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "current", {
id: "bad id"
}), /safe/i);
assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "current", {
id: "fade", fadeDuration: 61
}), /0 through 60/i);
assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "current", {
id: "flags", ma20: 1
}), /boolean/i);
});
test("trusted restore reconstructs entries and rejects mapping, identity and flag tampering", () => {
const original = workflow.createTradingHaltPlaylistEntry(kosdaq, "candle-120d", {
id: "stored-halt",
fadeDuration: 9,
ma5: true,
ma20: true
});
original.enabled = false;
const restored = workflow.restoreTradingHaltPlaylistEntry(clone(original));
assert.ok(restored);
assert.equal(restored.enabled, false);
assert.equal(restored.selection.graphicType, "MA5,MA20");
const tampers = [
value => { value.builderKey = "s5001"; },
value => { value.code = "5001"; },
value => { value.selection.groupCode = "KOSPI_STOCK"; },
value => { value.selection.subject = "다른종목"; },
value => { value.selection.dataCode = "000001"; },
value => { value.selection.graphicType = "MA20,MA5"; },
value => { value.selection.subtype = "PRICE"; },
value => { value.operator.actionId = "current"; },
value => { value.operator.schemaVersion = 2; },
value => { value.operator.resolverGroup = "KOSPI_STOCK"; },
value => { value.operator.movingAverages.ma5 = false; },
value => { value.operator.tradingHalt.stockCode = "000001"; },
value => { value.enabled = "false"; }
];
for (const tamper of tampers) {
const candidate = clone(original);
tamper(candidate);
assert.equal(workflow.restoreTradingHaltPlaylistEntry(candidate), null);
}
assert.equal(workflow.restoreTradingHaltPlaylistEntry(null), null);
});
test("native error payloads are exact and bound to the active request", () => {
const request = workflow.createTradingHaltSearchRequest("halt-request-1", "삼성", 100);
const error = {
requestId: "halt-request-1",
query: "삼성",
message: "거래정지 종목을 검색하지 못했습니다."
};
assert.deepEqual(workflow.normalizeTradingHaltError(error, request), error);
assert.equal(workflow.normalizeTradingHaltError({ ...error, requestId: "old" }, request), null);
assert.equal(workflow.normalizeTradingHaltError({ ...error, message: "bad\nmessage" }, request), null);
assert.equal(workflow.normalizeTradingHaltError({ ...error, detail: "extra" }, request), null);
});