feat: complete legacy parity and modernize operator UI

This commit is contained in:
2026-07-22 12:34:46 +09:00
parent fc4007d676
commit 939c252d23
149 changed files with 26515 additions and 1736 deletions

View File

@@ -1,4 +1,5 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
@@ -121,26 +122,112 @@ public sealed class ChartSceneDataLoadersTests
}
[Fact]
public async Task S5080_RejectsMalformedQuarterAndAmbiguousRows()
public async Task S5080_RejectsMalformedQuarterAndConflictingDuplicateRows()
{
var malformed = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
["A", "1Q_100_extra", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"]);
["A", "missing-delimiter", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"]);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5080SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_SELL"] = malformed }))
.LoadAsync(new S5080SceneDataRequest("A")));
var ambiguous = malformed.Copy();
ambiguous.Rows.Clear();
ambiguous.Rows.Add("A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6");
var ambiguous = malformed.Clone();
ambiguous.Rows.Add("A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6");
ambiguous.Rows.Add("A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_7");
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5080SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_SELL"] = ambiguous }))
.LoadAsync(new S5080SceneDataRequest("A")));
}
[Fact]
public async Task S5080_AcceptsEquivalentDuplicateRows()
{
var table = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
["A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"],
["A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"]);
var data = await new S5080SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_SELL"] = table }))
.LoadAsync(new S5080SceneDataRequest("A"));
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, data.Quarters.Select(item => item.Value));
}
[Fact]
public async Task S5080_NormalizesOnlyTrailingHorizontalTabsInQuarterLabels()
{
var accepted = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
["A", "1Q_1", "2023/12\t_159", "3Q_\t3\t", "4Q_4", "5Q_5", "6Q_6"]);
var data = await new S5080SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_SELL"] = accepted }))
.LoadAsync(new S5080SceneDataRequest("A"));
Assert.Equal("2023/12", data.Quarters[1].Quarter);
Assert.Equal(159, data.Quarters[1].Value);
Assert.Equal(3, data.Quarters[2].Value);
var setValues = new S5080SceneMutationBuilder().Build(data)
.OfType<PlayoutSetValue>()
.ToArray();
Assert.Contains(setValues, mutation =>
mutation.ObjectName == "value3_1" && mutation.Value == "3");
Assert.DoesNotContain(setValues, mutation => mutation.Value.Contains('\t'));
foreach (var invalid in new[] { "2023\t/12_159", "2023/12_\t", "2023/12_15\t9" })
{
var rejected = accepted.Copy();
rejected.Rows[0]["GUSUNG_2"] = invalid;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5080SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_SELL"] = rejected }))
.LoadAsync(new S5080SceneDataRequest("A")));
}
}
[Fact]
public async Task S5079_AcceptsEquivalentDuplicatesAndRejectsConflictingDuplicates()
{
var columns = new[]
{
"STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"
};
object?[] row = ["A", "1_2_3_4", "5_6_7_8", "9_10_11_12", "13_14_15_16", "1Q_2Q_3Q_4Q"];
var equivalent = Table(columns, row, row.ToArray());
var data = await new S5079SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_GROW"] = equivalent }))
.LoadAsync(new S5079SceneDataRequest("A"));
Assert.Equal(new double?[] { 1, 2, 3, 4 }, data.Series[0].Values);
var conflicting = equivalent.Copy();
conflicting.Rows[1]["GUSUNG_4"] = "13_14_15_17";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5079SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_GROW"] = conflicting }))
.LoadAsync(new S5079SceneDataRequest("A")));
}
[Fact]
public async Task S5080_KeepsLegacyEmptyQuarterLabelsAndIgnoresExtraTokens()
{
var table = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
["A", "_", "_20_legacy-extra", "3Q_3", "4Q_4", "5Q_5", "6Q_6"]);
var data = await new S5080SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_SELL"] = table }))
.LoadAsync(new S5080SceneDataRequest("A"));
Assert.Equal(string.Empty, data.Quarters[0].Quarter);
Assert.Equal(0, data.Quarters[0].Value);
Assert.Equal(string.Empty, data.Quarters[1].Quarter);
Assert.Equal(20, data.Quarters[1].Value);
_ = new S5080SceneMutationBuilder().Build(data);
}
[Theory]
[InlineData(InvestorFlowParticipant.Individual, "F_INVEST_CODE = '8000'")]
[InlineData(InvestorFlowParticipant.Institution, "'7000'")]

View File

@@ -291,6 +291,20 @@ public sealed class LegacyExpertSelectionServiceTests
Assert.False(result.IsTruncated);
}
[Fact]
public async Task PreviewAsync_PreservesNullBuyAmountAsEditableIncompleteValue()
{
var executor = new RecordingExecutor(_ => PreviewTable(
("Analyst A", "0001", "005930", "Samsung", null, "0"),
("Analyst A", "0001", "035720", "Kakao", "42000", "1")));
var result = await new LegacyExpertSelectionService(executor).PreviewAsync(Identity());
Assert.Equal(2, result.Items.Count);
Assert.Null(result.Items[0].BuyAmount);
Assert.Equal(42_000m, result.Items[1].BuyAmount);
}
[Fact]
public async Task PreviewAsync_AppliesFiveByTwentyMaximumAndReportsTruncation()
{

View File

@@ -92,11 +92,83 @@ public sealed class LegacyManualFinancialScreenServiceTests
Assert.Contains("ESCAPE '!'", escapedExecutor.Spec.Sql, StringComparison.Ordinal);
}
[Theory]
[MemberData(nameof(Screens))]
public void Row_version_SQL_is_one_shared_null_tagged_length_prefixed_contract(
ManualFinancialScreenKind screen,
string table,
string builder,
string cut,
string graphic)
{
_ = (table, builder, cut, graphic);
var profile = ManualFinancialSqlProfiles.Get(screen);
var sqlShapes = new[]
{
profile.SearchSql,
profile.GetSql,
profile.RawGetSql,
profile.UpdateSql,
profile.RawUpdateSql,
profile.DeleteOneSql,
profile.RawDeleteOneSql
};
var expressions = sqlShapes.Select(ExtractRowVersionExpression).ToArray();
Assert.Single(expressions.Distinct(StringComparer.Ordinal));
var expression = expressions[0];
Assert.DoesNotContain("NVL(", expression, StringComparison.Ordinal);
Assert.DoesNotContain("CHR(0)", expression, StringComparison.Ordinal);
Assert.DoesNotContain("CHR(31)", expression, StringComparison.Ordinal);
Assert.Equal(
ManualFinancialCutContracts.Get(screen).StorageColumns.Count,
CountOccurrences(expression, "CASE WHEN "));
foreach (var column in ManualFinancialCutContracts.Get(screen).StorageColumns)
{
Assert.Contains(
$"CASE WHEN {column} IS NULL THEN 'N' ELSE 'V' || " +
$"LPAD(TO_CHAR(LENGTH({column}), 'FM9999999990'), 10, '0') || " +
$"{column} END",
expression,
StringComparison.Ordinal);
}
Assert.All(sqlShapes, sql =>
{
Assert.DoesNotContain("__ROW_VERSION_EXPRESSION__", sql, StringComparison.Ordinal);
Assert.Equal(1, CountOccurrences(sql, "RAWTOHEX(STANDARD_HASH("));
});
}
[Fact]
public void Row_version_encoding_breaks_old_NUL_and_CHR31_structural_collisions()
{
string?[] nullValue = [null, "X"];
string?[] literalNul = ["\0", "X"];
Assert.Equal(OldDelimitedEncoding(nullValue), OldDelimitedEncoding(literalNul));
AssertDistinctConcurrencyTokens(nullValue, literalNul);
string?[] delimiterInFirst = ["A\u001fB", "C"];
string?[] delimiterInSecond = ["A", "B\u001fC"];
Assert.Equal(
OldDelimitedEncoding(delimiterInFirst),
OldDelimitedEncoding(delimiterInSecond));
AssertDistinctConcurrencyTokens(delimiterInFirst, delimiterInSecond);
// Oracle folds an empty VARCHAR2 to NULL, so those two representations
// intentionally remain equivalent while literal NUL remains distinct.
Assert.Equal(
NewLengthPrefixedEncoding([null]),
NewLengthPrefixedEncoding([string.Empty]));
Assert.NotEqual(
NewLengthPrefixedEncoding([null]),
NewLengthPrefixedEncoding(["\0"]));
}
[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);
table.Rows.Add("A", "2026-06", "반도체_50.0", "서비스_-", "", "기타_25", "해외_25", Version, RowHandle(table));
var result = await new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.RevenueComposition);
@@ -114,7 +186,7 @@ public sealed class LegacyManualFinancialScreenServiceTests
{
var table = EmptyTable(ManualFinancialScreenKind.RevenueComposition);
table.Rows.Add("A", DBNull.Value, "SMOKE_100", DBNull.Value, DBNull.Value,
DBNull.Value, DBNull.Value, Version);
DBNull.Value, DBNull.Value, Version, RowHandle(table));
var result = await new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.RevenueComposition);
@@ -125,6 +197,25 @@ public sealed class LegacyManualFinancialScreenServiceTests
Assert.All(record.Slices.Skip(1), Assert.Null);
}
[Fact]
public async Task Revenue_storage_treats_legacy_underscore_slots_as_absent()
{
var table = EmptyTable(ManualFinancialScreenKind.RevenueComposition);
table.Rows.Add("A", "2026-06", "DOMESTIC_100", "_", "_ignored", "", "_",
Version, RowHandle(table));
var result = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.RevenueComposition);
var record = Assert.IsType<ManualRevenueCompositionRecord>(
Assert.Single(result.Items).Record);
Assert.Equal(new ManualRevenueSlice("DOMESTIC", "100", 100d), record.Slices[0]);
Assert.All(record.Slices.Skip(1), Assert.Null);
Assert.Equal(0, result.RejectedItemCount);
Assert.True(Assert.Single(result.RawItems).IsPlayoutReady);
}
[Fact]
public async Task Growth_storage_maps_four_named_series_and_four_periods()
{
@@ -159,28 +250,98 @@ public sealed class LegacyManualFinancialScreenServiceTests
Assert.Equal(-20, quarters[1].Value);
}
[Theory]
[InlineData(ManualFinancialScreenKind.Sales)]
[InlineData(ManualFinancialScreenKind.OperatingProfit)]
public async Task Six_quarter_storage_keeps_legacy_empty_labels_and_ignores_extra_tokens(
ManualFinancialScreenKind screen)
{
var table = Table(screen, "A");
table.Rows[0]["GUSUNG_1"] = "_";
table.Rows[0]["GUSUNG_2"] = "_20_legacy-extra";
var result = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(table))
.SearchAsync(screen);
var quarters = Assert.Single(result.Items).Record switch
{
ManualSalesRecord sales => sales.Quarters,
ManualOperatingProfitRecord profit => profit.Quarters,
_ => throw new InvalidOperationException()
};
Assert.Equal(string.Empty, quarters[0].Quarter);
Assert.Equal(0, quarters[0].Value);
Assert.Equal(string.Empty, quarters[1].Quarter);
Assert.Equal(20, quarters[1].Value);
Assert.Equal(0, result.RejectedItemCount);
}
[Fact]
public async Task Search_rejects_duplicate_or_case_ambiguous_stock_names()
public async Task Six_quarter_storage_preservesRawTabAndNormalizesOnlyTrailingLabelTab()
{
var table = Table(ManualFinancialScreenKind.Sales, "Alpha");
table.Rows[0]["GUSUNG_2"] = "2023/12\t_159";
table.Rows[0]["GUSUNG_3"] = "3Q_\t30\t";
var result = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.Sales);
var strict = Assert.IsType<ManualSalesRecord>(Assert.Single(result.Items).Record);
Assert.Equal("2023/12", strict.Quarters[1].Quarter);
Assert.Equal(159, strict.Quarters[1].Value);
Assert.Equal(30, strict.Quarters[2].Value);
Assert.Equal("2023/12\t_159", Assert.Single(result.RawItems).Record.StorageValues[1]);
Assert.Equal("3Q_\t30\t", Assert.Single(result.RawItems).Record.StorageValues[2]);
foreach (var invalid in new[] { "2023\t/12_159", "2023/12_\t", "2023/12_15\t9" })
{
var rejected = table.Copy();
rejected.Rows[0]["GUSUNG_2"] = invalid;
var rejectedResult = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(rejected))
.SearchAsync(ManualFinancialScreenKind.Sales);
Assert.Empty(rejectedResult.Items);
Assert.False(Assert.Single(rejectedResult.RawItems).IsPlayoutReady);
}
}
[Fact]
public async Task Search_preserves_duplicate_names_for_row_level_exact_validation()
{
var table = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(table, "Alpha");
AddQuarterRow(table, "ALPHA", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
await Assert.ThrowsAsync<ManualFinancialAmbiguousIdentityException>(() =>
new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.Sales));
var result = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.Sales);
Assert.Equal(["Alpha", "ALPHA"],
result.Items.Select(item => item.Record.Identity.StockName));
}
[Fact]
public async Task Exact_get_binds_name_and_rejects_missing_or_duplicate_rows()
public async Task Exact_get_bindsNameAndRejectsNonEquivalentDuplicateRows()
{
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.Collection(
missing.Spec!.Parameters,
parameter =>
{
Assert.Equal("stock_name", parameter.Name);
Assert.Equal("Alpha", parameter.Value);
},
parameter =>
{
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(LegacyManualFinancialScreenService.MaximumResults + 1, parameter.Value);
});
Assert.DoesNotContain("Alpha", missing.Spec.Sql, StringComparison.Ordinal);
var duplicate = EmptyTable(ManualFinancialScreenKind.Sales);
@@ -191,26 +352,165 @@ public sealed class LegacyManualFinancialScreenServiceTests
.GetAsync(identity));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Read_schema_and_row_version_fail_closed(bool badSchema)
[Fact]
public async Task Exact_get_collapsesEquivalentPayloadAndVersionButRejectsAnyConflict()
{
var identity = new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Alpha");
var equivalent = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(equivalent, "Alpha");
AddQuarterRow(equivalent, "Alpha");
var snapshot = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(equivalent))
.GetAsync(identity);
Assert.Equal(identity, snapshot.Record.Identity);
Assert.Equal(Version, snapshot.RowVersion);
var conflictingPayload = equivalent.Copy();
conflictingPayload.Rows[1]["GUSUNG_6"] = "6Q_61";
await Assert.ThrowsAsync<ManualFinancialAmbiguousIdentityException>(() =>
new LegacyManualFinancialScreenService(new RecordingQueryExecutor(conflictingPayload))
.GetAsync(identity));
var thirdRowConflict = equivalent.Copy();
AddQuarterRow(thirdRowConflict, "Alpha");
thirdRowConflict.Rows[2]["GUSUNG_1"] = "1Q_11";
await Assert.ThrowsAsync<ManualFinancialAmbiguousIdentityException>(() =>
new LegacyManualFinancialScreenService(new RecordingQueryExecutor(thirdRowConflict))
.GetAsync(identity));
}
[Fact]
public async Task Read_schema_fails_closed()
{
var table = Table(ManualFinancialScreenKind.Sales, "A");
if (badSchema)
{
table.Columns[0].ColumnName = "stock_name";
}
else
{
table.Rows[0]["ROW_VERSION"] = "not-a-version";
}
table.Columns[0].ColumnName = "stock_name";
await Assert.ThrowsAsync<ManualFinancialDataException>(() =>
new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.Sales));
}
[Fact]
public async Task Search_isolates_malformed_rows_and_returns_every_valid_row()
{
var table = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(table, "Alpha");
AddQuarterRow(table, "Broken");
table.Rows[1]["GUSUNG_3"] = "not-compound-storage";
AddQuarterRow(
table,
"BadVersion",
"not-a-row-version");
AddQuarterRow(
table,
"Omega",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
var result = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.Sales);
Assert.Equal(["Alpha", "Omega"],
result.Items.Select(item => item.Record.Identity.StockName));
Assert.Equal(2, result.RejectedItemCount);
Assert.Equal(["Broken", "BadVersion"], result.UnreadableStockNames);
Assert.False(result.IsTruncated);
}
[Fact]
public async Task Search_preserves_source_truncation_when_a_boundary_row_is_rejected()
{
var table = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(table, "Alpha");
AddQuarterRow(table, "Broken");
table.Rows[1]["GUSUNG_3"] = "not-compound-storage";
AddQuarterRow(table, "Omega");
var result = await new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(table))
.SearchAsync(ManualFinancialScreenKind.Sales, maximumResults: 2);
Assert.Equal(["Alpha"],
result.Items.Select(item => item.Record.Identity.StockName));
Assert.Equal(1, result.RejectedItemCount);
Assert.Equal(["Broken"], result.UnreadableStockNames);
Assert.True(result.IsTruncated);
}
[Fact]
public async Task Exact_get_keeps_malformed_selected_row_strictly_blocked()
{
var table = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(table, "Broken");
table.Rows[0]["GUSUNG_3"] = "not-compound-storage";
var service = new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(table));
await Assert.ThrowsAsync<ManualFinancialDataException>(() => service.GetAsync(
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Broken")));
}
[Fact]
public async Task Raw_search_keeps_malformed_legacy_strings_and_redacts_native_handle()
{
var table = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(table, "Broken");
table.Rows[0]["GUSUNG_1"] = "_10";
table.Rows[0]["GUSUNG_2"] = " 2Q _ 20 ";
table.Rows[0]["GUSUNG_3"] = "missing-delimiter";
var service = new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(table));
var result = await service.SearchAsync(ManualFinancialScreenKind.Sales);
var raw = Assert.Single(result.RawItems);
Assert.True(result.RawItemsComplete);
Assert.Null(raw.StrictSnapshot);
Assert.Equal("_10", raw.Record.StorageValues[0]);
Assert.Equal(" 2Q _ 20 ", raw.Record.StorageValues[1]);
Assert.Equal("Opaque manual-financial row handle", raw.Handle.ToString());
Assert.DoesNotContain("AAAAAAAA", raw.Handle.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task Raw_get_update_and_delete_use_only_opaque_row_handle_and_bound_values()
{
var table = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(table, "Broken");
table.Rows[0]["GUSUNG_1"] = "_10";
var mutation = new RecordingMutationExecutor();
var service = new LegacyManualFinancialScreenService(
new RecordingQueryExecutor(table),
mutation);
var listed = Assert.Single((await service.SearchAsync(
ManualFinancialScreenKind.Sales)).RawItems);
var fresh = await service.GetRawAsync(listed);
Assert.Equal(listed.Record.StorageValues, fresh.Record.StorageValues);
var replacementValues = fresh.Record.StorageValues.ToArray();
replacementValues[0] = " raw_\u0001value ";
var replacement = new ManualFinancialRawRecord(
fresh.Record.Identity,
replacementValues);
var updated = await service.UpdateRawAsync(fresh, replacement);
Assert.Equal(ManualFinancialMutationKind.Update, updated.Kind);
var update = Assert.Single(mutation.Transaction!.Commands);
Assert.Contains("ROWID = CHARTOROWID(:row_handle)", update.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(" raw_\u0001value ", update.Sql, StringComparison.Ordinal);
Assert.Equal(" raw_\u0001value ", update.Parameters[0].Value);
Assert.Equal("row_handle", update.Parameters[6].Name);
var deleted = await service.DeleteRawAsync(fresh);
Assert.Equal(ManualFinancialMutationKind.DeleteOne, deleted.Kind);
var delete = Assert.Single(mutation.Transaction!.Commands);
Assert.Contains("ROWID = CHARTOROWID(:row_handle)", delete.Sql, StringComparison.Ordinal);
Assert.Equal(["row_handle", "identity_stock_name", "expected_row_version"],
delete.Parameters.Select(parameter => parameter.Name));
}
[Theory]
[MemberData(nameof(Screens))]
public async Task Create_uses_bound_values_one_locked_transaction_and_exactly_one_row(
@@ -509,10 +809,10 @@ public sealed class LegacyManualFinancialScreenServiceTests
switch (screen)
{
case ManualFinancialScreenKind.RevenueComposition:
table.Rows.Add(stockName, "2026-06", "반도체_50", "서비스_25", "해외_25", "", "", Version);
table.Rows.Add(stockName, "2026-06", "반도체_50", "서비스_25", "해외_25", "", "", Version, RowHandle(table));
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);
table.Rows.Add(stockName, "1_2_3_4", "5_6__8", "9_10_11_12", "13_14_15_16", "1Q_2Q_3Q_4Q", Version, RowHandle(table));
break;
case ManualFinancialScreenKind.Sales:
case ManualFinancialScreenKind.OperatingProfit:
@@ -533,6 +833,7 @@ public sealed class LegacyManualFinancialScreenServiceTests
table.Columns.Add(column, typeof(string));
}
table.Columns.Add("ROW_VERSION", typeof(string));
table.Columns.Add("ROW_HANDLE", typeof(string));
return table;
}
@@ -548,7 +849,64 @@ public sealed class LegacyManualFinancialScreenServiceTests
"4Q_40",
"5Q_50",
"6Q_60",
version);
version,
RowHandle(table));
private static string RowHandle(DataTable table) =>
"AAAAAAAAAAAAAAAA" + (table.Rows.Count + 1).ToString("D2");
private static string ExtractRowVersionExpression(string sql)
{
const string startToken = "RAWTOHEX(STANDARD_HASH(";
const string endToken = ", 'SHA256'))";
var start = sql.IndexOf(startToken, StringComparison.Ordinal);
var end = start < 0
? -1
: sql.IndexOf(endToken, start, StringComparison.Ordinal);
Assert.True(start >= 0 && end > start, "A row-version expression is missing.");
return sql.Substring(start, end - start + endToken.Length);
}
private static int CountOccurrences(string value, string token)
{
var count = 0;
for (var index = 0;
(index = value.IndexOf(token, index, StringComparison.Ordinal)) >= 0;
index += token.Length)
{
count++;
}
return count;
}
private static string OldDelimitedEncoding(IEnumerable<string?> values) =>
string.Join('\u001f', values.Select(value => string.IsNullOrEmpty(value)
? "\0"
: value));
private static string NewLengthPrefixedEncoding(IEnumerable<string?> values) =>
string.Concat(values.Select(value => string.IsNullOrEmpty(value)
? "N"
: "V" + value.Length.ToString(
"D10",
System.Globalization.CultureInfo.InvariantCulture) + value));
private static void AssertDistinctConcurrencyTokens(
IEnumerable<string?> left,
IEnumerable<string?> right)
{
var leftEncoding = NewLengthPrefixedEncoding(left);
var rightEncoding = NewLengthPrefixedEncoding(right);
Assert.NotEqual(leftEncoding, rightEncoding);
Assert.NotEqual(
HashConcurrencyEncoding(leftEncoding),
HashConcurrencyEncoding(rightEncoding));
}
private static string HashConcurrencyEncoding(string value) =>
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(value)));
private static string QueryToken(ManualFinancialScreenKind screen) => screen switch
{

View File

@@ -295,6 +295,51 @@ public sealed class LegacyNamedPlaylistPersistenceServiceTests
1));
}
[Fact]
public async Task ReplaceItemsAsync_PreservesClosedThemeEdgeSpacesAndOneNxtTransportSuffix()
{
var mutations = new RecordingMutationExecutor(call => Result(call, [0, 1]));
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
mutations);
var item = Item(
0,
true,
"테마_NXT",
" Robotics(NXT)(NXT)",
"5단 표그래프",
"테마-현재가(입력순)",
"00000002",
1,
1);
await service.ReplaceItemsAsync("00000011", [item]);
var insert = Assert.Single(
Assert.Single(mutations.Calls).Transaction.Commands,
command => command.Kind == NamedPlaylistDbCommandKind.InsertItem);
AssertParameter(
insert.Parameters[1],
"list_text",
"1^테마_NXT^ Robotics(NXT)(NXT)^5단 표그래프^테마-현재가(입력순)^1/1^00000002",
DbType.String);
}
[Fact]
public async Task ReplaceItemsAsync_DoesNotRelaxEdgeSpacesForGenericRows()
{
var mutations = new RecordingMutationExecutor(SuccessResult);
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
mutations);
await Assert.ThrowsAsync<ArgumentException>(() => service.ReplaceItemsAsync(
"00000011",
[Item(0, true, "GENERIC", " Free form ", "G", "S", "123", 1, 1)]));
Assert.Empty(mutations.Calls);
}
[Fact]
public async Task ReplaceItemsAsync_AllowsAnEmptyAtomicReplacement()
{

View File

@@ -44,13 +44,10 @@ public sealed class LegacyOverseasIndustryIndexSearchServiceTests
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.DoesNotContain("COUNT(*) OVER", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("KNAM_COUNT", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("INPUT_NAME_COUNT", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("SYMBOL_COUNT", 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);
@@ -119,7 +116,7 @@ public sealed class LegacyOverseasIndustryIndexSearchServiceTests
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
[InlineData(2001)]
[InlineData(int.MaxValue)]
public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults)
{
@@ -218,7 +215,7 @@ public sealed class LegacyOverseasIndustryIndexSearchServiceTests
[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(
public async Task SearchAsync_PreservesRowsThatShareOnlyOneDisplayOrLookupField(
string koreanName1,
string inputName1,
string symbol1,
@@ -231,10 +228,23 @@ public sealed class LegacyOverseasIndustryIndexSearchServiceTests
(koreanName2, inputName2, symbol2)));
var service = new LegacyOverseasIndustryIndexSearchService(executor);
var exception = await Assert.ThrowsAsync<OverseasIndustryIndexSearchDataException>(
() => service.SearchAsync(""));
var result = await service.SearchAsync("");
Assert.Contains("duplicate downstream lookup key", exception.Message, StringComparison.Ordinal);
Assert.Equal(2, result.Items.Count);
Assert.NotEqual(result.Items[0], result.Items[1]);
}
[Fact]
public async Task SearchAsync_RejectsOnlyDuplicateExactIdentity()
{
var executor = new RecordingExecutor(_ => ResultTable(
("One", "ONE", "1"),
("One", "ONE", "1")));
var exception = await Assert.ThrowsAsync<OverseasIndustryIndexSearchDataException>(() =>
new LegacyOverseasIndustryIndexSearchService(executor).SearchAsync(""));
Assert.Contains("duplicate exact identity", exception.Message, StringComparison.Ordinal);
}
[Fact]

View File

@@ -96,7 +96,30 @@ public sealed class LegacyOverseasStockSearchServiceTests
Assert.True(result.IsTruncated);
var rowLimit = Assert.Single(Assert.Single(executor.Calls).Spec.Parameters,
parameter => parameter.Name == "row_limit");
Assert.Equal(LegacyOverseasStockSearchService.MaximumResults + 1, rowLimit.Value);
Assert.Equal(
LegacyOverseasStockSearchService.MaximumResults +
LegacyOverseasStockSearchService.MaximumIsolatedRows + 1,
rowLimit.Value);
}
[Fact]
public async Task CurrentUsTwPopulation_IsolatesBlankNamesWithoutLosingValidRows()
{
var valid = Enumerable.Range(0, 1_474)
.Select(index => ($"Stock {index:D4}", $"NAS@{index:D4}", "US"))
.ToArray();
var table = WorldStockTable(valid);
for (var index = 0; index < 16; index++)
{
table.Rows.Add(DBNull.Value, $"NAS@BLANK{index:D2}", "US");
}
var result = await new LegacyOverseasStockSearchService(
new RecordingExecutor(table)).SearchAsync(string.Empty);
Assert.Equal(1_474, result.Items.Count);
Assert.Equal(16, result.IsolatedInvalidRowCount);
Assert.True(result.IsTruncated);
}
private static DataTable WorldStockTable(params (string Name, string Symbol, string Nation)[] rows)

View File

@@ -63,6 +63,60 @@ public sealed class LegacyParameterizedSceneRequestResolverTests
Assert.Equal("Legacy Name", Assert.IsType<S5001ForeignStockLoadRequest>(legacy).StockName);
}
[Fact]
public void S5001_foreign_industry_binds_native_tuple_and_keeps_historical_name_only_fallback()
{
var resolver = new LegacyParameterizedSceneRequestResolver(new RecordingExecutor());
var exact = resolver.CreateS5001Request(new LegacyPlaylistEntry(
"exact-industry",
"5001",
Selection: new LegacySceneSelection(
"FOREIGN_INDUSTRY",
"Shared Korean Name",
"plate",
"CURRENT",
"US Technology|NAS@USTECH|US|0")));
var legacy = resolver.CreateS5001Request(new LegacyPlaylistEntry(
"legacy-industry",
"5001",
Selection: new LegacySceneSelection(
"FOREIGN_INDUSTRY",
"Shared Korean Name",
"plate",
"CURRENT",
string.Empty)));
var exactRequest = Assert.IsType<S5001ExactForeignIndustryLoadRequest>(exact);
Assert.Equal("Shared Korean Name", exactRequest.IndustryName);
Assert.Equal("US Technology", exactRequest.InputName);
Assert.Equal("NAS@USTECH", exactRequest.ForeignSymbol);
Assert.Equal("US", exactRequest.ForeignNationCode);
Assert.Equal(
"Shared Korean Name",
Assert.IsType<S5001ForeignIndustryLoadRequest>(legacy).IndustryName);
}
[Theory]
[InlineData("US Technology|NAS@USTECH|TW|0")]
[InlineData("US Technology|NAS@USTECH|US|1")]
[InlineData("US Technology|bad^symbol|US|0")]
[InlineData("US Technology|NAS@USTECH|US")]
public void S5001_foreign_industry_rejects_open_or_malformed_hidden_identity(string dataCode)
{
var resolver = new LegacyParameterizedSceneRequestResolver(new RecordingExecutor());
var entry = new LegacyPlaylistEntry(
"invalid-industry",
"5001",
Selection: new LegacySceneSelection(
"FOREIGN_INDUSTRY",
"Shared Korean Name",
"plate",
"CURRENT",
dataCode));
Assert.Throws<LegacySceneDataException>(() => resolver.CreateS5001Request(entry));
}
[Fact]
public async Task Two_column_alias_routes_futures_to_s5032_and_normalizes_side_order()
{

View File

@@ -220,6 +220,249 @@ public sealed class LegacyPlayoutWorkflowTests
Assert.Equal(["5088:0", "5088:0"], provider.Calls);
}
[Fact]
public async Task Next_AdoptsAnOperatorTailAppendedAfterTakeIn()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] original = [new("first", "5001")];
Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);
LegacyPlaylistEntry[] live =
[
new("first", "5001"),
new("appended", "5074")
];
var result = await workflow.NextAsync(live);
Assert.True(result.IsSuccess);
Assert.Equal("appended", workflow.State.CurrentEntryId);
Assert.Equal("5074", workflow.State.OnAirCutCode);
Assert.Equal("Next:5074:p0", engine.Calls[^1]);
}
[Fact]
public async Task Next_AdoptsDeletionOnlyFromTheTailAfterTheOnAirRow()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (1, null),
["5088"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] original =
[
new("first", "5001"),
new("removed-tail", "5074"),
new("retained-tail", "5088")
];
Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);
LegacyPlaylistEntry[] live =
[
new("first", "5001"),
new("retained-tail", "5088")
];
var result = await workflow.NextAsync(live);
Assert.True(result.IsSuccess);
Assert.Equal("retained-tail", workflow.State.CurrentEntryId);
Assert.Equal("Next:5088:p0", engine.Calls[^1]);
Assert.DoesNotContain("Next:5074:p0", engine.Calls);
}
[Fact]
public async Task Next_AdoptsProgramEnabledChangesAndSkipsTheDisabledFutureRow()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (1, null),
["5088"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] original =
[
new("first", "5001"),
new("skip", "5074"),
new("target", "5088")
];
Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);
LegacyPlaylistEntry[] live =
[
original[0] with { IsEnabled = false },
original[1] with { IsEnabled = false },
original[2]
];
var result = await workflow.NextAsync(live);
Assert.True(result.IsSuccess);
Assert.Equal("target", workflow.State.CurrentEntryId);
Assert.Equal("5088", workflow.State.OnAirCutCode);
Assert.Equal("Next:5088:p0", engine.Calls[^1]);
Assert.DoesNotContain("Next:5074:p0", engine.Calls);
}
[Fact]
public async Task Next_WhenProgramAllRowsAreDisabled_EndsWithoutDispatchingTheEngine()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] original =
[
new("first", "5001"),
new("second", "5074")
];
Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);
var callCount = engine.Calls.Count;
var result = await workflow.NextAsync(original
.Select(static entry => entry with { IsEnabled = false })
.ToArray());
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
Assert.Equal(callCount, engine.Calls.Count);
}
[Fact]
public async Task PageNext_RemainsAvailableWhenProgramAllDisablesTheCurrentPagedRow()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5077"] = (7, ScenePageSize.Six),
["5088"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] original =
[
new("paged", "5077"),
new("tail", "5088")
];
Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);
var result = await workflow.NextAsync(original
.Select(static entry => entry with { IsEnabled = false })
.ToArray());
Assert.True(result.IsSuccess);
Assert.Equal("paged", workflow.State.CurrentEntryId);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
Assert.Equal("Next:5077:p1", engine.Calls[^1]);
}
[Fact]
public async Task Next_RejectsEveryNonEnabledChangeInTheOnAirPrefix()
{
var originalSource = new MutableLegacySceneMovingAverageSelectionSource(
new LegacySceneMovingAverageSelection(true, false));
var replacementSource = new MutableLegacySceneMovingAverageSelectionSource(
new LegacySceneMovingAverageSelection(false, true));
var originalSelection = new LegacySceneSelection(
"KOSPI_STOCK",
"Samsung Electronics",
"PRICE",
string.Empty,
"005930");
var original = new LegacyPlaylistEntry(
"first",
"5001",
FadeDuration: 1,
Selection: originalSelection,
PageNavigation: LegacyPlaylistPageNavigation.Disabled,
MovingAverageSelectionSource: originalSource);
LegacyPlaylistEntry[] changedPrefixes =
[
original with { EntryId = "changed-id", IsEnabled = false },
original with { CutCode = "5074", IsEnabled = false },
original with { FadeDuration = 2, IsEnabled = false },
original with
{
Selection = originalSelection with { DataCode = "000660" },
IsEnabled = false
},
original with
{
PageNavigation = new LegacyPlaylistPageNavigation(ScenePageSize.Five, 1),
IsEnabled = false
},
original with
{
MovingAverageSelectionSource = replacementSource,
IsEnabled = false
}
];
foreach (var changedPrefix in changedPrefixes)
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] retained = [original, new("second", "5074")];
Assert.True((await workflow.TakeSelectedAsync(retained, 0)).IsSuccess);
var callCount = engine.Calls.Count;
var result = await workflow.NextAsync(
[changedPrefix, retained[1]]);
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
Assert.Equal(callCount, engine.Calls.Count);
Assert.Equal("first", workflow.State.CurrentEntryId);
Assert.Equal("5001", workflow.State.OnAirCutCode);
}
}
[Fact]
public async Task Next_RejectsAChangedOnAirPrefixWithoutDispatchingTheEngine()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] original =
[
new("first", "5001"),
new("second", "5074")
];
Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);
var callCount = engine.Calls.Count;
LegacyPlaylistEntry[] changedPrefix =
[
new("first", "5074"),
new("second", "5074")
];
var result = await workflow.NextAsync(changedPrefix);
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
Assert.Equal(callCount, engine.Calls.Count);
Assert.Equal("first", workflow.State.CurrentEntryId);
Assert.Equal("5001", workflow.State.OnAirCutCode);
}
[Fact]
public async Task PageNext_RejectsProviderThatChangesTheOnAirSceneName()
{
@@ -321,6 +564,14 @@ public sealed class LegacyPlayoutWorkflowTests
await Assert.ThrowsAsync<ArgumentException>(() => workflow.PrepareAsync(
[new LegacyPlaylistEntry("entry", "5001", Selection: selection)],
0));
var unsafeExactSelection = selection with
{
Subject = "Visible theme title",
ExactSubject = "stored\ntheme title"
};
await Assert.ThrowsAsync<ArgumentException>(() => workflow.PrepareAsync(
[new LegacyPlaylistEntry("exact-entry", "5001", Selection: unsafeExactSelection)],
0));
Assert.Empty(provider.Calls);
Assert.Empty(engine.Calls);

View File

@@ -51,6 +51,45 @@ public sealed class LegacySceneDataSourceRouterTests
Assert.Equal(0, calls);
}
[Fact]
public async Task Router_AlwaysRejectsUnresolvedNamedPlaylistEntryBeforeAnyLoader()
{
var pageCalls = 0;
var planCalls = 0;
var router = new LegacySceneDataSourceRouter(
[
new LegacySceneDataSourceRoute(
["5001"],
(entry, page, _) =>
{
pageCalls++;
return Task.FromResult(new LegacySceneDataPage(
"s5085",
new S5085SceneData([]),
1));
},
(entry, _) =>
{
planCalls++;
return Task.FromResult(new LegacySceneDataPage(
"s5085",
new S5085SceneData([]),
1));
})
]);
var unresolved = new LegacyPlaylistEntry(
"entry",
LegacyPlaylistEntry.UnresolvedCutCode);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
router.LoadPageAsync(unresolved, 0));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
router.LoadPagePlanDataAsync(unresolved));
Assert.Equal(0, pageCalls);
Assert.Equal(0, planCalls);
}
[Fact]
public void Constructor_RejectsDuplicateOrUnsafeRoutes()
{

View File

@@ -66,6 +66,81 @@ public sealed class LegacyStockSearchServiceTests
call => Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal));
}
[Fact]
public async Task SearchAsync_AcceptsFiniteAllMarketCompatibilityCeiling()
{
var executor = new RecordingExecutor(_ => StockTable());
var service = new LegacyStockSearchService(executor);
var result = await service.SearchAsync(
"삼성",
LegacyStockSearchService.MaximumResults);
Assert.False(result.IsTruncated);
Assert.Equal(10_000, LegacyStockSearchService.MaximumResults);
Assert.All(
executor.Calls,
call => Assert.Equal(
LegacyStockSearchService.MaximumResults + 1,
call.Spec.Parameters[1].Value));
}
[Fact]
public async Task OperatorCatalogSearchAllAsync_UsesBoundEmptyTermAndFiniteProviderLimits()
{
var executor = new RecordingExecutor(_ => StockTable());
var service = new LegacyOperatorCatalogStockSearchService(executor);
using var cancellation = new CancellationTokenSource();
var result = await service.SearchAllAsync(25, cancellation.Token);
Assert.Equal(string.Empty, result.Query);
Assert.False(result.IsTruncated);
Assert.Equal(4, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
call.Spec.ValidateFor(call.Source);
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
Assert.Equal(26, call.Spec.Parameters[1].Value);
Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(cancellation.Token, call.CancellationToken);
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task OperatorCatalogSearchAllAsync_TruncatesAtTenThousandValidatedRows()
{
var callIndex = 0;
var executor = new RecordingExecutor(_ =>
callIndex++ == 0
? StockTable(LegacyStockSearchService.MaximumResults + 1)
: StockTable());
var service = new LegacyOperatorCatalogStockSearchService(executor);
var result = await service.SearchAllAsync(LegacyStockSearchService.MaximumResults);
Assert.Equal(LegacyStockSearchService.MaximumResults, result.Items.Count);
Assert.True(result.IsTruncated);
Assert.All(
executor.Calls,
call => Assert.Equal(
LegacyStockSearchService.MaximumResults + 1,
call.Spec.Parameters[1].Value));
}
[Fact]
public async Task OperatorCatalogRegularSearchStillRejectsBlankInput()
{
var executor = new RecordingExecutor(_ => StockTable());
IOperatorCatalogStockSearchService service =
new LegacyOperatorCatalogStockSearchService(executor);
await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(string.Empty));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task SearchAsync_ValidatesSchemaAndReturnsDeterministicCappedOrder()
{
@@ -98,7 +173,7 @@ public sealed class LegacyStockSearchServiceTests
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
[InlineData(LegacyStockSearchService.MaximumResults + 1)]
[InlineData(int.MaxValue)]
public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults)
{
@@ -213,6 +288,17 @@ public sealed class LegacyStockSearchServiceTests
return table;
}
private static DataTable StockTable(int rowCount)
{
var table = StockTable();
for (var index = 0; index < rowCount; index++)
{
table.Rows.Add($"Stock {index:D5}", index.ToString("D6"));
}
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string TableName,

View File

@@ -5,6 +5,84 @@ namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyThemeCatalogPersistenceServiceTests
{
[Fact]
public async Task ReadStoredAsync_KrxReadsEveryStoredItemWithoutTheActiveStockJoin()
{
var table = StoredThemeTable();
table.Rows.Add(" Legacy theme ", "00000420", "KRX", "Active", "P005930", "0");
table.Rows.Add(" Legacy theme ", "00000420", "KRX", "Cross X", "X999998", "7");
table.Rows.Add(" Legacy theme ", "00000420", "KRX", "Cross T", "T999999", "7");
var query = new CatalogRecordingQueryExecutor(_ => table);
var service = new LegacyThemeCatalogPersistenceService(query);
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000420",
" Legacy theme ");
var result = await service.ReadStoredAsync(identity);
Assert.Equal(identity, result.Identity);
Assert.False(result.IsTruncated);
Assert.Equal(
["P005930", "X999998", "T999999"],
result.Items.Select(static item => item.ItemCode));
Assert.Equal([0, 7, 7], result.Items.Select(static item => item.InputIndex));
var call = Assert.Single(query.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(LegacyThemeCatalogPersistenceService.StoredKrxQueryName, call.TableName);
Assert.Contains("LEFT JOIN SB_ITEM", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("v_all_stock", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("F_MKT_HALT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal("00000420", Parameter(call.Spec, "theme_code").Value);
Assert.Equal(" Legacy theme ", Parameter(call.Spec, "theme_title").Value);
Assert.Equal(241, Parameter(call.Spec, "row_limit").Value);
}
[Fact]
public async Task ReadStoredAsync_NxtPreservesNativeSuffixAndRowsMissingFromActivePreview()
{
var table = StoredThemeTable();
table.Rows.Add("Native(NXT)", "00000008", "NXT", "Legacy KRX", "P000001", "3");
var query = new CatalogRecordingQueryExecutor(_ => table);
var service = new LegacyThemeCatalogPersistenceService(query);
var identity = new ThemeSelectionIdentity(
ThemeMarket.Nxt,
ThemeSession.AfterMarket,
"00000008",
"Native(NXT)");
var result = await service.ReadStoredAsync(identity);
Assert.Equal("Legacy KRX", Assert.Single(result.Items).ItemName);
var call = Assert.Single(query.Calls);
Assert.Equal(DataSourceKind.MariaDb, call.Source);
Assert.Equal(LegacyThemeCatalogPersistenceService.StoredNxtQueryName, call.TableName);
Assert.Contains("LEFT JOIN SB_ITEM", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("N_STOCK", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("F_STOP_GUBUN", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.All(call.Spec.Parameters, parameter =>
Assert.Contains("@" + parameter.Name, call.Spec.Sql, StringComparison.Ordinal));
}
[Fact]
public async Task ReadStoredAsync_RepresentsAnEmptyStoredThemeWithoutConfusingItWithMissingIdentity()
{
var table = StoredThemeTable();
table.Rows.Add("Empty", "00000009", "KRX", DBNull.Value, DBNull.Value, DBNull.Value);
var service = new LegacyThemeCatalogPersistenceService(
new CatalogRecordingQueryExecutor(_ => table));
var result = await service.ReadStoredAsync(new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000009",
"Empty"));
Assert.Empty(result.Items);
Assert.False(result.IsTruncated);
}
[Theory]
[InlineData(ThemeMarket.Krx, DataSourceKind.Oracle, 0, false)]
[InlineData(ThemeMarket.Krx, DataSourceKind.Oracle, 2, true)]
@@ -202,6 +280,46 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
transaction.Commands[1].AffectedRowsRule);
}
[Theory]
[InlineData(ThemeMarket.Krx, ThemeSession.NotApplicable, "X005930")]
[InlineData(ThemeMarket.Nxt, ThemeSession.PreMarket, "P005930")]
public async Task ReplaceAsync_PreservesClosedLegacyCrossMarketPrefixes(
ThemeMarket market,
ThemeSession session,
string itemCode)
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ThemeSelectionIdentity(market, session, "00000042", "Legacy");
await service.ReplaceAsync(
identity,
"Legacy",
[new ThemeCatalogItem(0, itemCode, "Stored")]);
var transaction = Assert.Single(mutation.Calls).Transaction;
Assert.Equal(itemCode, Parameter(transaction.Commands[2], "item_code").Value);
}
[Theory]
[InlineData(ThemeMarket.Krx, ThemeSession.NotApplicable, " Stored edge ")]
[InlineData(ThemeMarket.Nxt, ThemeSession.AfterMarket, "Native(NXT)")]
public async Task ReplaceAsync_PreservesAnUnchangedExactLegacyStoredTitle(
ThemeMarket market,
ThemeSession session,
string storedTitle)
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ThemeSelectionIdentity(market, session, "00000042", storedTitle);
await service.ReplaceAsync(identity, storedTitle, []);
var update = Assert.Single(mutation.Calls).Transaction.Commands[0];
Assert.Equal(storedTitle, Parameter(update, "expected_title").Value);
Assert.Equal(storedTitle, Parameter(update, "new_title").Value);
}
[Theory]
[InlineData("123")]
[InlineData("0123")]
@@ -315,6 +433,11 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
{
Items = [new ThemeCatalogItem(0, "X005930", "삼성전자")]
}));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
baseDefinition with
{
Items = [new ThemeCatalogItem(0, "P005930", " Stored edge ")]
}));
Assert.Empty(mutation.Calls);
}
@@ -381,4 +504,20 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
string name) =>
Assert.Single(command.Parameters, parameter =>
string.Equals(parameter.Name, name, StringComparison.Ordinal));
private static DataQueryParameter Parameter(DataQuerySpec query, string name) =>
Assert.Single(query.Parameters, parameter =>
string.Equals(parameter.Name, name, StringComparison.Ordinal));
private static DataTable StoredThemeTable()
{
var table = new DataTable();
table.Columns.Add("THEME_TITLE", typeof(string));
table.Columns.Add("THEME_CODE", typeof(string));
table.Columns.Add("THEME_MARKET", typeof(string));
table.Columns.Add("ITEM_NAME", typeof(string));
table.Columns.Add("ITEM_CODE", typeof(string));
table.Columns.Add("ITEM_INDEX", typeof(string));
return table;
}
}

View File

@@ -59,8 +59,9 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.Equal("THEME_SEARCH_KRX", call.TableName);
Assert.Contains("SB_MARKET = 'KRX'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("REGEXP_LIKE(s.SB_CODE, '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("d.SB_TITLE = s.SB_TITLE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("d.SB_CODE = s.SB_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("SB_TITLE = TRIM", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("d.SB_TITLE = s.SB_TITLE", 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);
@@ -72,8 +73,9 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.Equal("THEME_SEARCH_NXT", call.TableName);
Assert.Contains("SB_MARKET = 'NXT'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("s.SB_CODE REGEXP BINARY '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("BINARY d.SB_TITLE = BINARY s.SB_TITLE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("BINARY d.SB_CODE = BINARY s.SB_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("TRIM(s.SB_TITLE)", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("BINARY d.SB_TITLE = BINARY s.SB_TITLE", 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);
@@ -98,6 +100,9 @@ public sealed class LegacyThemeSelectionServiceTests
await service.SearchAsync(" ", ThemeSession.AfterMarket);
Assert.All(executor.Calls, call => Assert.Equal(string.Empty, call.Spec.Parameters[0].Value));
Assert.All(executor.Calls, call => Assert.Equal(5_001, call.Spec.Parameters[1].Value));
Assert.Equal(5_000, LegacyThemeSelectionService.DefaultMaximumResults);
Assert.Equal(5_000, LegacyThemeSelectionService.MaximumResults);
executor.Calls.Clear();
await service.SearchAsync(" a!_% ", ThemeSession.AfterMarket);
@@ -161,7 +166,7 @@ public sealed class LegacyThemeSelectionServiceTests
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
[InlineData(5001)]
public async Task SearchAsync_RejectsInvalidLimitBeforeDatabaseAccess(int limit)
{
var executor = new RecordingExecutor(_ => SearchTable());
@@ -249,18 +254,22 @@ public sealed class LegacyThemeSelectionServiceTests
}
[Fact]
public async Task SearchAsync_AllowsDuplicateTitlesWithDistinctCodes()
public async Task SearchAsync_PreservesExactLegacyEdgeSpacesAndDuplicateTitlesWithDistinctCodes()
{
var executor = new RecordingExecutor(call => call.Source == DataSourceKind.Oracle
? SearchTable(
("AI", "00000001", "KRX"),
("AI", "00000002", "KRX"))
: SearchTable());
(" AI", "00000001", "KRX"),
("AI", "00000002", "KRX"),
("AI", "00000003", "KRX"))
: SearchTable(("NXT ", "00000004", "NXT")));
var result = await new LegacyThemeSelectionService(executor)
.SearchAsync("", ThemeSession.PreMarket);
Assert.Equal(2, result.Items.Count);
Assert.Equal([" AI", "AI", "AI", "NXT "],
result.Items.Select(static item => item.Identity.ThemeTitle));
Assert.Equal(["00000001", "00000002", "00000003", "00000004"],
result.Items.Select(static item => item.Identity.ThemeCode));
}
[Fact]
@@ -388,7 +397,8 @@ public sealed class LegacyThemeSelectionServiceTests
new((ThemeMarket)99, ThemeSession.NotApplicable, "00000001", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "12", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "0000000A", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", " AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", " "),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", new string('A', 129)),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", "bad\u0000title")
};
@@ -436,6 +446,40 @@ public sealed class LegacyThemeSelectionServiceTests
() => service.PreviewAsync(KrxIdentity()));
}
[Fact]
public async Task PreviewAsync_PreservesExactStoredTitleWithEdgeSpaces()
{
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
" AI ");
var executor = new RecordingExecutor(_ => PreviewTable(
(" AI ", "00000001", "KRX", "Alpha", "P005930", "0")));
var result = await new LegacyThemeSelectionService(executor).PreviewAsync(identity);
Assert.Equal(" AI ", result.Identity.ThemeTitle);
Assert.Equal(" AI ", Assert.Single(executor.Calls).Spec.Parameters[1].Value);
}
[Fact]
public async Task PreviewAsync_NxtEmptyActiveSentinelRemainsAVisibleEmptySelection()
{
var identity = new ThemeSelectionIdentity(
ThemeMarket.Nxt,
ThemeSession.PreMarket,
"00000004",
" NXT Theme");
var executor = new RecordingExecutor(_ => PreviewTable(
(" NXT Theme", "00000004", "NXT", null, null, null)));
var result = await new LegacyThemeSelectionService(executor).PreviewAsync(identity);
Assert.Equal(identity, result.Identity);
Assert.Empty(result.Items);
}
[Fact]
public async Task PreviewAsync_RejectsPartialSentinelAndWrongMarketItemCode()
{

View File

@@ -47,7 +47,9 @@ public sealed class LegacyWorldStockSearchServiceTests
parameter =>
{
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(26, parameter.Value);
Assert.Equal(
25 + LegacyWorldStockSearchService.MaximumIsolatedRows + 1,
parameter.Value);
Assert.Equal(DbType.Int32, parameter.DbType);
});
Assert.Equal(0, executor.StringSqlCallCount);
@@ -66,10 +68,28 @@ public sealed class LegacyWorldStockSearchServiceTests
Assert.Contains("LIKE '%' || :search_term || '%' ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_AcceptsFiniteCompleteWorldCompatibilityCeiling()
{
var executor = new RecordingExecutor(_ => WorldStockTable());
var service = new LegacyWorldStockSearchService(executor);
var result = await service.SearchAsync(
string.Empty,
LegacyWorldStockSearchService.MaximumResults);
Assert.False(result.IsTruncated);
Assert.Equal(5_000, LegacyWorldStockSearchService.MaximumResults);
Assert.Equal(
LegacyWorldStockSearchService.MaximumResults +
LegacyWorldStockSearchService.MaximumIsolatedRows + 1,
Assert.Single(Assert.Single(executor.Calls).Spec.Parameters).Value);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task SearchAsync_AllowsOriginalUc5BlankInitialList(string query)
public async Task SearchAsync_AllowsOriginalUc3BlankInitialList(string query)
{
var executor = new RecordingExecutor(_ => WorldStockTable());
var service = new LegacyWorldStockSearchService(executor);
@@ -78,7 +98,8 @@ public sealed class LegacyWorldStockSearchServiceTests
Assert.Equal(string.Empty, result.Query);
var call = Assert.Single(executor.Calls);
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
Assert.DoesNotContain("search_term", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("row_limit", Assert.Single(call.Spec.Parameters).Name);
}
[Fact]
@@ -94,13 +115,15 @@ public sealed class LegacyWorldStockSearchServiceTests
Assert.True(result.IsTruncated);
Assert.Equal(2, result.Items.Count);
Assert.Equal(3, executor.Calls[0].Spec.Parameters[1].Value);
Assert.Equal(
2 + LegacyWorldStockSearchService.MaximumIsolatedRows + 1,
executor.Calls[0].Spec.Parameters[1].Value);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(501)]
[InlineData(LegacyWorldStockSearchService.MaximumResults + 1)]
[InlineData(int.MaxValue)]
public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults)
{
@@ -165,10 +188,12 @@ public sealed class LegacyWorldStockSearchServiceTests
[Fact]
public async Task SearchAsync_RejectsExtraRowsBeyondTheBoundQueryContract()
{
var executor = new RecordingExecutor(_ => WorldStockTable(
("One", "1", "US"),
("Two", "2", "US"),
("Three", "3", "US")));
var rows = Enumerable.Range(
0,
LegacyWorldStockSearchService.MaximumIsolatedRows + 3)
.Select(index => ($"Name {index}", index.ToString(), "US"))
.ToArray();
var executor = new RecordingExecutor(_ => WorldStockTable(rows));
var service = new LegacyWorldStockSearchService(executor);
await Assert.ThrowsAsync<WorldStockSearchDataException>(
@@ -178,8 +203,6 @@ public sealed class LegacyWorldStockSearchServiceTests
}
[Theory]
[InlineData(" Apple", "AAPL", "US")]
[InlineData("Apple", "AAPL ", "US")]
[InlineData("Apple\nInc", "AAPL", "US")]
[InlineData("Apple", "AA\u0000PL", "US")]
[InlineData("Apple", "AAPL", "KR")]
@@ -197,6 +220,38 @@ public sealed class LegacyWorldStockSearchServiceTests
() => service.SearchAsync("apple"));
}
[Fact]
public async Task SearchAsync_NormalizesCurrentOuterAsciiPadding()
{
var executor = new RecordingExecutor(_ => WorldStockTable(
(" Apple ", " AAPL ", " US ")));
var service = new LegacyWorldStockSearchService(executor);
var result = await service.SearchAsync("apple");
Assert.Equal(
new WorldStockSearchItem("Apple", "AAPL", "US"),
Assert.Single(result.Items));
}
[Fact]
public async Task SearchAsync_IsolatesBlankDisplayRowsWithoutRejectingValidRows()
{
var table = WorldStockTable(
("Apple", "AAPL", "US"),
("Taiwan Semiconductor", "2330", "TW"));
table.Rows.Add(DBNull.Value, "BLANK1", "US");
table.Rows.Add(" ", "BLANK2", "TW");
var executor = new RecordingExecutor(_ => table);
var service = new LegacyWorldStockSearchService(executor);
var result = await service.SearchAsync(string.Empty);
Assert.Equal(2, result.Items.Count);
Assert.Equal(2, result.IsolatedInvalidRowCount);
Assert.True(result.IsTruncated);
}
[Fact]
public async Task SearchAsync_RejectsDuplicateCompositeIdentity()
{
@@ -212,17 +267,17 @@ public sealed class LegacyWorldStockSearchServiceTests
}
[Fact]
public async Task SearchAsync_RejectsDuplicateInputNameAcrossSymbolsAndNations()
public async Task SearchAsync_AllowsDuplicateInputNameWhenExactIdentitiesDiffer()
{
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"));
var result = await service.SearchAsync("shared");
Assert.Contains("duplicate F_INPUT_NAME", exception.Message, StringComparison.Ordinal);
Assert.Equal(2, result.Items.Count);
Assert.Equal(["SHARED", "2330"], result.Items.Select(item => item.Symbol));
}
[Fact]

View File

@@ -1,4 +1,5 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
@@ -32,6 +33,20 @@ public sealed class ManualSceneDataLoadersTests
Assert.Null(data.Slices[2]);
}
[Fact]
public async Task S5076_TreatsLegacyUnderscoreSlotsAsAbsent()
{
var table = Table(
["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"],
["A", "2026.07", "DOMESTIC_100", "_", "_ignored", "", "_"]);
var data = await new S5076SceneDataLoader(new RecordingExecutor(table))
.LoadAsync("A", CancellationToken.None);
Assert.Equal(new RevenueSliceData("DOMESTIC", "100", 100d), data.Slices[0]);
Assert.All(data.Slices.Skip(1), Assert.Null);
}
[Fact]
public async Task S5081_MapsSixTypedQuarterValuesWithoutStringSqlFallback()
{
@@ -53,23 +68,100 @@ public sealed class ManualSceneDataLoadersTests
Assert.Equal(0, executor.StringCalls);
}
[Theory]
[InlineData(0)]
[InlineData(2)]
public async Task S5076_RejectsMissingOrAmbiguousRowsBeforeMutation(int rowCount)
[Fact]
public async Task S5081_KeepsLegacyEmptyQuarterLabels()
{
var table = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
["A", "_", "_20_legacy-extra", "3Q_30", "4Q_40", "5Q_50", "6Q_60"]);
var data = await new S5081SceneDataLoader(new RecordingExecutor(table))
.LoadAsync("A", CancellationToken.None);
Assert.Equal(string.Empty, data.Quarters[0].Quarter);
Assert.Equal(0, data.Quarters[0].Value);
Assert.Equal(string.Empty, data.Quarters[1].Quarter);
Assert.Equal(20, data.Quarters[1].Value);
_ = new S5081SceneMutationBuilder().Build(data);
}
[Fact]
public async Task S5081_NormalizesOnlyTrailingHorizontalTabsInQuarterLabels()
{
var accepted = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
["A", "1Q_10", "2023/12\t_159", "3Q_\t30\t", "4Q_40", "5Q_50", "6Q_60"]);
var data = await new S5081SceneDataLoader(new RecordingExecutor(accepted))
.LoadAsync("A", CancellationToken.None);
Assert.Equal("2023/12", data.Quarters[1].Quarter);
Assert.Equal(159, data.Quarters[1].Value);
Assert.Equal(30, data.Quarters[2].Value);
var setValues = new S5081SceneMutationBuilder().Build(data)
.OfType<PlayoutSetValue>()
.ToArray();
Assert.Contains(setValues, mutation =>
mutation.ObjectName == "value3_1" && mutation.Value == "30");
Assert.DoesNotContain(setValues, mutation => mutation.Value.Contains('\t'));
foreach (var invalid in new[] { "2023\t/12_159", "2023/12_\t", "2023/12_15\t9" })
{
var rejected = accepted.Copy();
rejected.Rows[0]["GUSUNG_2"] = invalid;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5081SceneDataLoader(new RecordingExecutor(rejected))
.LoadAsync("A", CancellationToken.None));
}
}
[Fact]
public async Task S5076_RejectsMissingRowsBeforeMutation()
{
var table = Table(
["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"]);
for (var index = 0; index < rowCount; index++)
{
table.Rows.Add("A사", "2026", "A_100", "", "", "", "");
}
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5076SceneDataLoader(new RecordingExecutor(table))
.LoadAsync("A사", CancellationToken.None));
}
[Fact]
public async Task S5076_AcceptsEquivalentDuplicatesAndRejectsConflictingDuplicates()
{
var table = Table(
["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"]);
table.Rows.Add("A", "2026", "A_100", "", "", "", "");
table.Rows.Add("A", "2026", "A_100", "", "", "", "");
var data = await new S5076SceneDataLoader(new RecordingExecutor(table))
.LoadAsync("A", CancellationToken.None);
Assert.Equal(new RevenueSliceData("A", "100", 100d), data.Slices[0]);
table.Rows[1]["GUSUNG_1"] = "A_99";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5076SceneDataLoader(new RecordingExecutor(table))
.LoadAsync("A", CancellationToken.None));
}
[Fact]
public async Task S5081_AcceptsEquivalentDuplicatesAndRejectsConflictingDuplicates()
{
var table = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"]);
table.Rows.Add("A", "1Q_10", "2Q_20", "3Q_30", "4Q_40", "5Q_50", "6Q_60");
table.Rows.Add("A", "1Q_10", "2Q_20", "3Q_30", "4Q_40", "5Q_50", "6Q_60");
var data = await new S5081SceneDataLoader(new RecordingExecutor(table))
.LoadAsync("A", CancellationToken.None);
Assert.Equal(new[] { 10, 20, 30, 40, 50, 60 },
data.Quarters.Select(item => item.Value));
table.Rows[1]["GUSUNG_6"] = "6Q_61";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5081SceneDataLoader(new RecordingExecutor(table))
.LoadAsync("A", CancellationToken.None));
}
[Fact]
public async Task LoaderErrorsDoNotIncludeTheSelectedStockName()
{

View File

@@ -142,11 +142,35 @@ public sealed class PagedQuoteSceneDataLoadersTests
Assert.Equal("Robotics", data.Title);
}
[Theory]
[InlineData("Robotics(NXT)", "Robotics(NXT)")]
[InlineData("Robotics(NXT)(NXT)", "Robotics(NXT)(NXT)")]
[InlineData(" Robotics (NXT)", " Robotics ")]
public async Task NativeNxtTheme_UsesExactStoredTitleWithoutSuffixOrSpaceRewriting(
string displayTitle,
string exactStoredTitle)
{
var executor = new RecordingExecutor(QuoteTable(
["Beta", 200m, 20m, 11.11d, "2"]));
var data = await new S5074SceneDataLoader(executor).LoadAsync(
new NxtThemePagedQuoteLoadRequest(
"0002",
displayTitle,
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket,
ExactThemeName: exactStoredTitle));
Assert.NotNull(executor.Spec);
Assert.Equal(exactStoredTitle, executor.Spec.Parameters[1].Value);
Assert.Equal(exactStoredTitle, data.Title);
}
[Fact]
public async Task Expert_UsesOracleExpertParameterAndMapsReturnHeader()
{
var executor = new RecordingExecutor(QuoteTable(
["Gamma", 900m, 1000m, 11.11d, "2"]));
var executor = new RecordingExecutor(ExpertQuoteTable(
["0001", "Analyst A", 0, "005930", "Gamma", 900m, 1000m, 11.11d, "2"]));
var data = await new S5077SceneDataLoader(executor).LoadAsync(
new ExpertPagedQuoteLoadRequest("0001", "Analyst A"));
@@ -156,6 +180,10 @@ public sealed class PagedQuoteSceneDataLoadersTests
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.Contains("LEFT JOIN QUOTES", executor.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("AND r.RC_BUY_AMOUNT <> 0", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("r.RC_BUY_AMOUNT IS NULL", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("q.STOCK_CODE IS NOT NULL", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("0001", executor.Spec.Parameters[0].Value);
Assert.Equal("Analyst A", executor.Spec.Parameters[1].Value);
Assert.Equal(PagedQuoteBranch.Expert, data.Branch);
@@ -165,6 +193,28 @@ public sealed class PagedQuoteSceneDataLoadersTests
"column1" or "column2" or "column3" });
}
[Fact]
public async Task ExpertPagePlanPreflight_BlocksNullBuyAmountWithExactRecommendationDetails()
{
var executor = new RecordingExecutor(ExpertQuoteTable(
["0001", "Analyst A", 0, "035720", "Kakao", 42_000m, 45_000m, 7.14d, "2"],
["0001", "Analyst A", 7, "005930", "Samsung", DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value]));
var loader = new S5074SceneDataLoader(executor);
var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
loader.LoadPagePlanAsync(new ExpertPagedQuoteLoadRequest("0001", "Analyst A")));
Assert.Contains("Analyst A", exception.Message, StringComparison.Ordinal);
Assert.Contains("0001", exception.Message, StringComparison.Ordinal);
Assert.Contains("PLAYINDEX: 7", exception.Message, StringComparison.Ordinal);
Assert.Contains("Samsung", exception.Message, StringComparison.Ordinal);
Assert.Contains("005930", exception.Message, StringComparison.Ordinal);
Assert.Contains("RC_BUY_AMOUNT", exception.Message, StringComparison.Ordinal);
Assert.Contains("매수가", exception.Message, StringComparison.Ordinal);
Assert.Contains("PREPARE", exception.Message, StringComparison.Ordinal);
Assert.Equal(1, executor.SpecCalls);
}
[Fact]
public async Task Vi_UsesOneOracleParameterPerBoundedNameAndPreservesInputOrderContract()
{
@@ -585,6 +635,26 @@ public sealed class PagedQuoteSceneDataLoadersTests
return table;
}
private static DataTable ExpertQuoteTable(params object?[][] rows)
{
var table = new DataTable();
foreach (var column in new[]
{
"EXPERT_CODE", "EXPERT_NAME", "PLAY_INDEX", "STOCK_CODE",
"NAME", "PRIMARY_VALUE", "SECONDARY_VALUE", "RATE", "DIRECTION"
})
{
table.Columns.Add(column, typeof(object));
}
foreach (var row in rows)
{
table.Rows.Add(row);
}
return table;
}
private sealed class RecordingExecutor(DataTable result) : IDataQueryExecutor
{
public DataSourceKind Source { get; private set; }

View File

@@ -152,6 +152,49 @@ public sealed class ParameterizedMarketSceneDataLoadersTests
call.Spec.Parameters.Select(parameter => parameter.Value));
}
[Fact]
public async Task Exact_foreign_industry_identity_binds_selected_tuple_despite_duplicate_display_name()
{
const string koreanName = "Shared Korean Name";
var inputName = new string('I', 160);
const string symbol = "NAS@USTECH";
var executor = new RecordingExecutor(QuoteTable(koreanName, 100m, "+", 1m, 1m));
var data = await new S5001SceneDataLoader(executor).LoadAsync(
new S5001ExactForeignIndustryLoadRequest(
koreanName,
inputName,
symbol,
"US"));
Assert.IsType<S5001ForeignIndustrySceneData>(data);
var call = Assert.Single(executor.Calls);
Assert.Equal("SCENE_FOREIGN_INDUSTRY", call.TableName);
Assert.Contains("b.f_knam = :industryName", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("b.f_input_name = :inputName", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("b.f_symb = :symbol", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("b.f_natc = :nationCode", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal(
[koreanName, inputName, symbol, "US"],
call.Spec.Parameters.Select(parameter => parameter.Value));
}
[Fact]
public async Task Historical_foreign_industry_name_only_fallback_rejects_duplicate_rows()
{
var duplicateRows = QuoteTable("Shared Korean Name", 100m, "+", 1m, 1m);
duplicateRows.ImportRow(duplicateRows.Rows[0]);
var executor = new RecordingExecutor(duplicateRows);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5001SceneDataLoader(executor).LoadAsync(
new S5001ForeignIndustryLoadRequest("Shared Korean Name")));
var call = Assert.Single(executor.Calls);
Assert.Single(call.Spec.Parameters);
Assert.DoesNotContain("f_symb = :symbol", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Closed_foreign_index_symbols_keep_global_markets_but_reject_stock_master_rows()
{