feat: complete legacy parity and modernize operator UI
This commit is contained in:
@@ -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'")]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user