Complete legacy operator UI and playout migration
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
tests/Web/candle-options-workflow.test.cjs
Normal file
119
tests/Web/candle-options-workflow.test.cjs
Normal 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]));
|
||||
});
|
||||
266
tests/Web/comparison-workflow.test.cjs
Normal file
266
tests/Web/comparison-workflow.test.cjs
Normal 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");
|
||||
});
|
||||
310
tests/Web/expert-workflow.test.cjs
Normal file
310
tests/Web/expert-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
142
tests/Web/industry-workflow.test.cjs
Normal file
142
tests/Web/industry-workflow.test.cjs
Normal 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");
|
||||
});
|
||||
210
tests/Web/legacy-fixed-workflow.test.cjs
Normal file
210
tests/Web/legacy-fixed-workflow.test.cjs
Normal 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"));
|
||||
});
|
||||
123
tests/Web/manual-financial-bridge-integration.test.cjs
Normal file
123
tests/Web/manual-financial-bridge-integration.test.cjs
Normal 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/);
|
||||
});
|
||||
372
tests/Web/manual-financial-ui.test.cjs
Normal file
372
tests/Web/manual-financial-ui.test.cjs
Normal 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);
|
||||
});
|
||||
740
tests/Web/manual-financial-workflow.test.cjs
Normal file
740
tests/Web/manual-financial-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
56
tests/Web/manual-lists-bridge-integration.test.cjs
Normal file
56
tests/Web/manual-lists-bridge-integration.test.cjs
Normal 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);
|
||||
});
|
||||
353
tests/Web/manual-lists-ui.test.cjs
Normal file
353
tests/Web/manual-lists-ui.test.cjs
Normal 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);
|
||||
});
|
||||
297
tests/Web/manual-lists-workflow.test.cjs
Normal file
297
tests/Web/manual-lists-workflow.test.cjs
Normal 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
|
||||
}));
|
||||
});
|
||||
149
tests/Web/named-manual-restore-workflow.test.cjs
Normal file
149
tests/Web/named-manual-restore-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
93
tests/Web/named-playlist-bridge-integration.test.cjs
Normal file
93
tests/Web/named-playlist-bridge-integration.test.cjs
Normal 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\(/);
|
||||
});
|
||||
430
tests/Web/named-playlist-workflow.test.cjs
Normal file
430
tests/Web/named-playlist-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
53
tests/Web/operator-catalog-bridge-integration.test.cjs
Normal file
53
tests/Web/operator-catalog-bridge-integration.test.cjs
Normal 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\)/);
|
||||
});
|
||||
348
tests/Web/operator-catalog-ui.test.cjs
Normal file
348
tests/Web/operator-catalog-ui.test.cjs
Normal 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*\(/);
|
||||
});
|
||||
446
tests/Web/operator-catalog-workflow.test.cjs
Normal file
446
tests/Web/operator-catalog-workflow.test.cjs
Normal 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 });
|
||||
});
|
||||
156
tests/Web/operator-command-matrix.test.cjs
Normal file
156
tests/Web/operator-command-matrix.test.cjs
Normal 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"]
|
||||
]);
|
||||
});
|
||||
371
tests/Web/operator-ui-app-integration.test.cjs
Normal file
371
tests/Web/operator-ui-app-integration.test.cjs
Normal 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);
|
||||
});
|
||||
326
tests/Web/operator-workflow.test.cjs
Normal file
326
tests/Web/operator-workflow.test.cjs
Normal 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));
|
||||
}
|
||||
});
|
||||
377
tests/Web/overseas-workflow.test.cjs
Normal file
377
tests/Web/overseas-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
242
tests/Web/theme-workflow.test.cjs
Normal file
242
tests/Web/theme-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
272
tests/Web/trading-halt-workflow.test.cjs
Normal file
272
tests/Web/trading-halt-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user