feat: migrate legacy playout workflow and scenes

This commit is contained in:
2026-07-10 23:58:45 +09:00
parent 491a740505
commit 8dae7b8e0d
128 changed files with 39177 additions and 205 deletions

View File

@@ -0,0 +1,427 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class CandleSceneBuilderTests
{
public static TheoryData<CandleCutAlias, CandlePeriod, float, string> AliasCases => new()
{
{ CandleCutAlias.Cut8035, CandlePeriod.Intraday, 0f, "EBE44FFF7523C76172C2B4D0BF718D22D42D41C8533D3CF44480D237D7CD5B8B" },
{ CandleCutAlias.Cut8061, CandlePeriod.FiveDays, 1188f, "D1511EE579F58F10552B22D44C02C94E6BD0E0F61F7DC668827B9FABF8E69682" },
{ CandleCutAlias.Cut8040, CandlePeriod.TwentyDays, 1398f, "B1CEA60310E547A2FA1847C5AEBEDE25CF5448AA257EB5A7798BD295591C555F" },
{ CandleCutAlias.Cut8046, CandlePeriod.SixtyDays, 1442f, "24CA30D09E5EAF9034A925509E0C6A06E0B9ED1F5FB5488B9F595E732330DA36" },
{ CandleCutAlias.Cut8051, CandlePeriod.OneHundredTwentyDays, 1452f, "C6F23FD7A55D2DC12CE69323226BA65E05B57ECACB77C90CD8858E49CD65D5CA" },
{ CandleCutAlias.Cut8056, CandlePeriod.TwoHundredFortyDays, 1464f, "D6ABC34111DAC4B32F8D1CA5563EAA248BA5B1C0C782B74E53A9C651BBC98D48" }
};
public static TheoryData<MarketCase> MarketCases => new()
{
new(
CandleMarketTarget.KospiIndex,
CandleChartMode.Price,
"ignored",
"\uCF54\uC2A4\uD53C \uC9C0\uC218",
"\uB2E8\uC704 : p",
"100.00"),
new(
CandleMarketTarget.KosdaqIndex,
CandleChartMode.Price,
"ignored",
"\uCF54\uC2A4\uB2E5 \uC9C0\uC218",
"\uB2E8\uC704 : p",
"100.00"),
new(
CandleMarketTarget.Kospi200Index,
CandleChartMode.Price,
"ignored",
"\uCF54\uC2A4\uD53C200 \uC9C0\uC218",
"\uB2E8\uC704 : p",
"100.00"),
new(
CandleMarketTarget.Krx100Index,
CandleChartMode.Price,
"ignored",
"KRX100 \uC9C0\uC218",
"\uB2E8\uC704 : p",
"100.00"),
new(
CandleMarketTarget.FuturesIndex,
CandleChartMode.ExpectedExecution,
"ignored",
"\uC120\uBB3C \uC608\uC0C1\uCCB4\uACB0\uC9C0\uC218",
"\uB2E8\uC704 : p",
"100.00"),
new(
CandleMarketTarget.KospiIndustry,
CandleChartMode.Price,
"Banks",
"\uCF54\uC2A4\uD53C - Banks",
"\uB2E8\uC704 : p",
"100.00"),
new(
CandleMarketTarget.KosdaqIndustry,
CandleChartMode.ExpectedExecution,
"Tech",
"Tech \uC608\uC0C1\uCCB4\uACB0",
null,
"100.00"),
new(
CandleMarketTarget.OverseasIndex,
CandleChartMode.Price,
"Dow",
"Dow",
"\uB2E8\uC704 : p",
"100.00"),
new(
CandleMarketTarget.OverseasStock,
CandleChartMode.Price,
"NVDA",
"NVDA",
"\uB2E8\uC704 : \uB2EC\uB7EC",
"100.00"),
new(
CandleMarketTarget.KospiStock,
CandleChartMode.ExpectedExecution,
"Samsung",
"Samsung \uC608\uC0C1\uCCB4\uACB0",
null,
"100"),
new(
CandleMarketTarget.KosdaqStock,
CandleChartMode.TradingHalt,
"Halt",
"Halt",
"\uB2E8\uC704 : \uC6D0",
"100")
};
[Fact]
public void Volume_candle_matches_legacy_setup_quote_crops_colors_paths_and_partial_points()
{
var data = new S8010SceneData(
CandleCutAlias.Cut8061,
CandleMarketTarget.KospiStock,
CandleChartMode.PriceWithVolume,
"Stock",
new CandleQuoteData(110, -10, -8.33, ScenePriceDirection.Down),
[
Row(1, 100, 120, 90, 110, 100, 105, null),
Row(2, 110, 125, 80, 100, 0, null, 100),
Row(3, 100, 100, 100, 100, 50, 100, 100)
],
ShowMovingAverage5: true,
ShowMovingAverage20: true);
var mutations = new S8010SceneMutationBuilder().Build(data);
var actual = mutations.Select(Describe).ToArray();
Assert.Equal(
[
"V:title=Stock", "L:ma5=", "L:ma20=",
"V:unit=\uB2E8\uC704 : \uC6D0", "V:title=Stock",
"V:price=110", "V:changePrice=-10", "V:rate=-8.33",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:down=1", "S:bg1=0", "S:bg2=0", "S:bg3=1",
"S:volrumeG=1"
], actual.Take(18));
Assert.Equal("S:volume1=0", actual[18]);
Assert.Equal("S:volume240=0", actual[257]);
Assert.Equal("S:candle1=0", actual[258]);
Assert.Equal("S:candle240=0", actual[497]);
Assert.Equal("V:date_begin=07/01", actual[498]);
Assert.Contains("C:candlebody1:170:0:0:255", actual);
Assert.Contains("C:candlebar2:15:99:189:255", actual);
Assert.Contains("C:candlebody3:95:95:95:255", actual);
Assert.Contains("K:volume2:1:0:100:0:100:10", actual);
var ma5 = Assert.Single(
mutations.OfType<PlayoutSetPathPoints>(),
path => path.ObjectName == "ma5" && path.Points.Count != 0);
Assert.Single(ma5.Points);
Assert.Empty(mutations.OfType<PlayoutSetPathPoints>()
.Last(path => path.ObjectName == "ma20").Points);
Assert.Equal(
"2EEA2C83B0D3086A894A3AE17DD8DABE9447764757B83EE4F17DE2217BD70EBD",
SnapshotHash(actual));
}
[Theory]
[MemberData(nameof(AliasCases))]
public void Every_alias_maps_to_its_period_width_dates_and_full_golden_sequence(
CandleCutAlias alias,
CandlePeriod expectedPeriod,
float expectedSecondX,
string expectedHash)
{
var rows = new[]
{
Row(1, 100, 120, 90, 110, 10, 105, 104, new TimeOnly(9, 0)),
Row(2, 110, 125, 95, 100, 20, 106, 105, new TimeOnly(9, 5))
};
var data = new S8010SceneData(
alias,
CandleMarketTarget.KospiStock,
CandleChartMode.Price,
"A",
new CandleQuoteData(100, 1, 1, ScenePriceDirection.Up),
rows,
ShowMovingAverage5: true,
ShowMovingAverage20: false);
var mutations = new S8010SceneMutationBuilder().Build(data);
var ma5 = mutations.OfType<PlayoutSetPathPoints>()
.Last(path => path.ObjectName == "ma5");
Assert.Equal(expectedPeriod, CandleCutAliasMetadata.GetPeriod(alias));
Assert.Equal(2, ma5.Points.Count);
Assert.Equal(0f, ma5.Points[0].X);
Assert.Equal(expectedSecondX, ma5.Points[1].X);
if (expectedPeriod == CandlePeriod.Intraday)
{
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "date_begin", Value: "09:00" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "date_end", Value: "15:30" });
}
else
{
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "date_begin", Value: "07/01" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "date_end", Value: "07/02" });
}
var hash = SnapshotHash(mutations.Select(Describe));
Assert.Equal(expectedHash, hash);
}
[Theory]
[MemberData(nameof(MarketCases))]
public void Market_and_chart_targets_use_closed_title_unit_and_precision_mapping(MarketCase item)
{
var data = new S8010SceneData(
CandleCutAlias.Cut8061,
item.Target,
item.Mode,
item.DisplayName,
new CandleQuoteData(100, 0, 0, ScenePriceDirection.Flat),
[Row(1, 100, 100, 100, 100, 0, null, null)],
false,
false);
var mutations = new S8010SceneMutationBuilder().Build(data);
Assert.Equal(
item.ExpectedTitle,
mutations.OfType<PlayoutSetValue>()
.Last(mutation => mutation.ObjectName == "title").Value);
if (item.ExpectedUnit is null)
{
Assert.DoesNotContain(
mutations.OfType<PlayoutSetValue>(),
mutation => mutation.ObjectName == "unit");
}
else
{
Assert.Equal(
item.ExpectedUnit,
mutations.OfType<PlayoutSetValue>()
.Last(mutation => mutation.ObjectName == "unit").Value);
}
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "price", Value: var value } &&
value == item.ExpectedPrice);
}
[Fact]
public void Equal_range_zero_volume_and_single_partial_path_are_finite()
{
var data = new S8010SceneData(
CandleCutAlias.Cut8061,
CandleMarketTarget.KospiStock,
CandleChartMode.PriceWithVolume,
"Equal",
new CandleQuoteData(100, 0, 0, ScenePriceDirection.Flat),
[Row(1, 100, 100, 100, 100, 0, 100, 100)],
true,
true);
var mutations = new S8010SceneMutationBuilder().Build(data);
Assert.All(mutations, AssertFinite);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetCropKey
{
ObjectName: "candlebody1",
Top: 0,
Bottom: 1
});
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetCropKey
{
ObjectName: "volume1",
Top: 100,
Bottom: 100
});
var ma5 = mutations.OfType<PlayoutSetPathPoints>()
.Last(path => path.ObjectName == "ma5");
var point = Assert.Single(ma5.Points);
Assert.Equal(0f, point.X);
Assert.Equal(98f, point.Y);
}
[Fact]
public void Invalid_alias_target_ohlc_volume_time_and_count_fail_closed()
{
var valid = ValidData();
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with { CutAlias = (CandleCutAlias)9999 }));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with { MarketTarget = (CandleMarketTarget)99 }));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with { ChartMode = (CandleChartMode)99 }));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with { Rows = [] }));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with
{
Rows = Enumerable.Range(1, 241)
.Select(index => Row(((index - 1) % 28) + 1, 1, 1, 1, 1, 0, null, null))
.ToArray()
}));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with
{
Rows = [Row(1, 100, 90, 95, 100, 0, null, null)]
}));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with
{
Rows = [Row(1, 100, 110, 90, 100, -1, null, null)]
}));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with
{
CutAlias = CandleCutAlias.Cut8035,
Rows = [Row(1, 100, 110, 90, 100, 0, null, null, null)]
}));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with
{
ChartMode = CandleChartMode.TradingHalt,
MarketTarget = CandleMarketTarget.KospiIndex
}));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with
{
Quote = valid.Quote with { CurrentPrice = double.NaN }
}));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with
{
Rows = [Row(1, 0, double.MaxValue, -double.MaxValue, 0, 0, null, null)]
}));
Assert.Throws<LegacySceneDataException>(() =>
new S8010SceneMutationBuilder().Build(valid with
{
Rows = [Row(1, 100, 110, 90, 100, 0, double.PositiveInfinity, null)]
}));
var properties = typeof(S8010SceneData)
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Select(property => property.Name);
Assert.DoesNotContain(properties, name =>
name is "ObjectName" or "MethodName" or "AssetPath" or "SceneFile");
}
private static S8010SceneData ValidData() => new(
CandleCutAlias.Cut8061,
CandleMarketTarget.KospiStock,
CandleChartMode.Price,
"A",
new CandleQuoteData(100, 0, 0, ScenePriceDirection.Flat),
[Row(1, 100, 110, 90, 100, 0, null, null)],
false,
false);
private static CandleRowData Row(
int day,
double open,
double high,
double low,
double close,
double? volume,
double? ma5,
double? ma20,
TimeOnly? time = null) =>
new(
new DateOnly(2026, 7, day),
time,
open,
high,
low,
close,
volume,
ma5,
ma20);
private static void AssertFinite(PlayoutMutation mutation)
{
switch (mutation)
{
case PlayoutSetPosition position:
Assert.True(float.IsFinite(position.X));
Assert.True(float.IsFinite(position.Y));
Assert.True(float.IsFinite(position.Z));
break;
case PlayoutSetCropKey crop:
Assert.True(float.IsFinite(crop.Left));
Assert.True(float.IsFinite(crop.Top));
Assert.True(float.IsFinite(crop.Right));
Assert.True(float.IsFinite(crop.Bottom));
break;
case PlayoutSetPathPoints path:
Assert.All(path.Points, point =>
{
Assert.True(float.IsFinite(point.X));
Assert.True(float.IsFinite(point.Y));
Assert.True(float.IsFinite(point.Z));
});
break;
}
}
private static string SnapshotHash(IEnumerable<string> snapshot)
{
var bytes = Encoding.UTF8.GetBytes(string.Join('\n', snapshot));
return Convert.ToHexString(SHA256.HashData(bytes));
}
private static string Describe(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
PlayoutSetFaceColor color =>
$"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}",
PlayoutSetPosition position => FormattableString.Invariant(
$"P:{position.ObjectName}:{position.X}:{position.Y}:{position.Z}:{(int)position.Components}"),
PlayoutSetCropKey crop => FormattableString.Invariant(
$"K:{crop.ObjectName}:{crop.KeyIndex}:{crop.Left}:{crop.Top}:{crop.Right}:{crop.Bottom}:{(int)crop.Edges}"),
PlayoutSetPathPoints path => "L:" + path.ObjectName + "=" + string.Join(
";",
path.Points.Select(point => FormattableString.Invariant($"{point.X},{point.Y},{point.Z}"))),
_ => mutation.GetType().Name
};
public sealed record MarketCase(
CandleMarketTarget Target,
CandleChartMode Mode,
string DisplayName,
string ExpectedTitle,
string? ExpectedUnit,
string ExpectedPrice);
}

View File

@@ -0,0 +1,458 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class CandleSceneDataLoaderTests
{
private static readonly string[] QuoteColumns =
[
"DISPLAY_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "RATE", "DIRECTION_CODE"
];
private static readonly string[] RowColumns =
[
"TRADING_DATE", "TRADING_TIME",
"OPEN_PRICE", "HIGH_PRICE", "LOW_PRICE", "CLOSE_PRICE",
"VOLUME", "MA5", "MA20"
];
public static TheoryData<CandleCutAlias, int> AliasLimits => new()
{
{ CandleCutAlias.Cut8035, 78 },
{ CandleCutAlias.Cut8061, 5 },
{ CandleCutAlias.Cut8040, 20 },
{ CandleCutAlias.Cut8046, 60 },
{ CandleCutAlias.Cut8051, 120 },
{ CandleCutAlias.Cut8056, 240 }
};
public static TheoryData<RouteCase> RouteCases => new()
{
Route(CandleMarketTarget.KospiIndex, CandleChartMode.Price,
CandleCutAlias.Cut8035, null, "FROM t_index", "FROM t_index_his_5M"),
Route(CandleMarketTarget.KospiIndex, CandleChartMode.PriceWithVolume,
CandleCutAlias.Cut8061, null, "FROM t_index", "FROM t_index_his_day", "a.F_PART_VOL VOLUME"),
Route(CandleMarketTarget.KospiIndex, CandleChartMode.ExpectedExecution,
CandleCutAlias.Cut8061, null, "FROM t_fo_index", "FROM t_index_his_day"),
Route(CandleMarketTarget.KosdaqIndex, CandleChartMode.Price,
CandleCutAlias.Cut8061, null, "FROM t_kosdaq_index", "FROM t_kosdaq_index_his_day"),
Route(CandleMarketTarget.KosdaqIndex, CandleChartMode.PriceWithVolume,
CandleCutAlias.Cut8035, null, "FROM t_kosdaq_index", "FROM t_kosdaq_index_his_5M", "a.F_PART_VOL VOLUME"),
Route(CandleMarketTarget.KosdaqIndex, CandleChartMode.ExpectedExecution,
CandleCutAlias.Cut8061, null, "FROM t_kosdaq_fo_index", "FROM t_kosdaq_index_his_day"),
Route(CandleMarketTarget.Kospi200Index, CandleChartMode.Price,
CandleCutAlias.Cut8061, null, "FROM T_200_INDEX", "FROM t_200_index_his_day"),
Route(CandleMarketTarget.Kospi200Index, CandleChartMode.PriceWithVolume,
CandleCutAlias.Cut8035, null, "FROM T_200_INDEX", "FROM t_200_index_his_5M", "a.F_PART_VOL VOLUME"),
Route(CandleMarketTarget.Kospi200Index, CandleChartMode.ExpectedExecution,
CandleCutAlias.Cut8061, null, "FROM T_200_FO_INDEX", "FROM t_200_index_his_day"),
Route(CandleMarketTarget.Krx100Index, CandleChartMode.Price,
CandleCutAlias.Cut8061, null, "FROM t_krx100_index", "FROM t_krx100_index_his_day"),
Route(CandleMarketTarget.Krx100Index, CandleChartMode.PriceWithVolume,
CandleCutAlias.Cut8035, null, "FROM t_krx100_index", "FROM t_krx100_index_his_5M", "a.F_PART_VOL VOLUME"),
Route(CandleMarketTarget.FuturesIndex, CandleChartMode.Price,
CandleCutAlias.Cut8035, null, "FROM t_sunmul_online", "FROM t_sunmul_his_5M"),
Route(CandleMarketTarget.FuturesIndex, CandleChartMode.ExpectedExecution,
CandleCutAlias.Cut8061, null, "FROM t_sunmul_online", "FROM T_SUNMUL_HIS_DAY"),
Selected(CandleMarketTarget.KospiIndustry, CandleChartMode.Price,
CandleCutAlias.Cut8035, "FROM t_index a, t_part b", "FROM t_index_his_5M"),
Selected(CandleMarketTarget.KospiIndustry, CandleChartMode.PriceWithVolume,
CandleCutAlias.Cut8061, "FROM t_index a, t_part b", "FROM t_index_his_day", "a.F_PART_VOL VOLUME"),
Selected(CandleMarketTarget.KosdaqIndustry, CandleChartMode.Price,
CandleCutAlias.Cut8061, "FROM t_kosdaq_index a, t_kosdaq_part b", "FROM t_kosdaq_index_his_day"),
Selected(CandleMarketTarget.KosdaqIndustry, CandleChartMode.PriceWithVolume,
CandleCutAlias.Cut8035, "FROM t_kosdaq_index a, t_kosdaq_part b", "FROM t_kosdaq_index_his_5M", "a.F_PART_VOL VOLUME"),
Selected(CandleMarketTarget.OverseasIndex, CandleChartMode.Price,
CandleCutAlias.Cut8061, "FROM t_world_ix_eq_master", "FROM t_world_ix_eq_master"),
Selected(CandleMarketTarget.OverseasStock, CandleChartMode.Price,
CandleCutAlias.Cut8061, "FROM t_world_ix_eq_master", "FROM t_world_ix_eq_master"),
Selected(CandleMarketTarget.KospiStock, CandleChartMode.Price,
CandleCutAlias.Cut8035, "FROM t_online1", "FROM t_online_his_5M"),
Selected(CandleMarketTarget.KospiStock, CandleChartMode.PriceWithVolume,
CandleCutAlias.Cut8061, "FROM t_online1", "FROM t_candle_history", "a.F_NET_VOL VOLUME"),
Selected(CandleMarketTarget.KospiStock, CandleChartMode.ExpectedExecution,
CandleCutAlias.Cut8061, "FROM t_online1_call", "FROM t_candle_history"),
Selected(CandleMarketTarget.KospiStock, CandleChartMode.TradingHalt,
CandleCutAlias.Cut8061, "FROM t_stop_online1", "FROM t_candle_history"),
Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.Price,
CandleCutAlias.Cut8035, "FROM t_kosdaq_online1", "FROM t_kosdaq_online_his_5M"),
Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.PriceWithVolume,
CandleCutAlias.Cut8061, "FROM t_kosdaq_online1", "FROM t_kosdaq_candle_history", "a.F_NET_VOL VOLUME"),
Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.ExpectedExecution,
CandleCutAlias.Cut8061, "FROM t_kosdaq_online1_call", "FROM t_kosdaq_candle_history"),
Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.TradingHalt,
CandleCutAlias.Cut8061, "FROM t_stop_kosdaq_online1", "FROM t_kosdaq_candle_history")
};
public static TheoryData<S8010SceneDataRequest> UnsupportedRequests => new()
{
Request((CandleCutAlias)9999, CandleMarketTarget.KospiIndex, CandleChartMode.Price, null),
Request(CandleCutAlias.Cut8061, (CandleMarketTarget)99, CandleChartMode.Price, null),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, (CandleChartMode)99, null),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, null),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, CandleChartMode.Price, "unexpected"),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.FuturesIndex, CandleChartMode.PriceWithVolume, null),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.Krx100Index, CandleChartMode.ExpectedExecution, null),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndustry, CandleChartMode.ExpectedExecution, "Industry"),
Request(CandleCutAlias.Cut8035, CandleMarketTarget.OverseasIndex, CandleChartMode.Price, "World"),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.OverseasStock, CandleChartMode.PriceWithVolume, "World"),
Request(CandleCutAlias.Cut8035, CandleMarketTarget.KospiStock, CandleChartMode.ExpectedExecution, "Stock"),
Request(CandleCutAlias.Cut8035, CandleMarketTarget.KosdaqStock, CandleChartMode.TradingHalt, "Stock"),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, CandleChartMode.TradingHalt, null),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, new string('x', 257)),
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "bad\nname")
};
[Theory]
[MemberData(nameof(RouteCases))]
public async Task Every_supported_market_and_mode_uses_bound_oracle_queries_in_legacy_order(
RouteCase item)
{
var intraday = item.Alias == CandleCutAlias.Cut8035;
var executor = new RecordingExecutor(QuoteTable(), RowsTable(intraday));
var data = await new S8010SceneDataLoader(executor).LoadAsync(
Request(item.Alias, item.Target, item.Mode, item.Selection),
CancellationToken.None);
Assert.Equal(item.Target, data.MarketTarget);
Assert.Equal(item.Mode, data.ChartMode);
Assert.Equal(0, executor.StringCalls);
Assert.Collection(
executor.Calls,
call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("S8010_CURRENT", call.TableName);
call.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains(item.CurrentFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
},
call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("S8010_ROWS", call.TableName);
call.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains(item.RowsFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
if (item.RowsMustContain is not null)
{
Assert.Contains(item.RowsMustContain, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
}
var rowLimit = Assert.Single(
call.Spec.Parameters,
parameter => parameter.Name == "rowLimit");
Assert.Equal(AliasLimit(item.Alias), rowLimit.Value);
Assert.Equal(DbType.Int32, rowLimit.DbType);
});
Assert.All(
executor.Calls.SelectMany(call => call.Spec.Parameters),
parameter => Assert.NotNull(parameter.DbType));
if (item.Selection is not null)
{
Assert.All(executor.Calls, call =>
{
Assert.DoesNotContain(item.Selection, call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(call.Spec.Parameters, parameter =>
parameter.Name == "selection" && Equals(parameter.Value, item.Selection));
});
}
Assert.All(executor.Calls, call =>
Assert.DoesNotContain("{0}", call.Spec.Sql, StringComparison.Ordinal));
}
[Theory]
[MemberData(nameof(AliasLimits))]
public async Task Every_alias_binds_its_legacy_row_bound_and_maps_time_shape(
CandleCutAlias alias,
int expectedLimit)
{
var intraday = alias == CandleCutAlias.Cut8035;
var executor = new RecordingExecutor(QuoteTable(), RowsTable(intraday));
var data = await new S8010SceneDataLoader(executor).LoadAsync(
Request(alias, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"),
CancellationToken.None);
var rowsSpec = executor.Calls[1].Spec;
var limit = Assert.Single(rowsSpec.Parameters, parameter => parameter.Name == "rowLimit");
Assert.Equal(expectedLimit, limit.Value);
Assert.Equal(intraday, data.Rows[0].TradingTime.HasValue);
Assert.Contains(
intraday ? "ORDER BY TRADING_DATE, TRADING_TIME" : "ORDER BY TRADING_DATE",
rowsSpec.Sql,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Explicit_schema_maps_typed_values_and_generates_volume_candle_mutations()
{
var quote = QuoteTable("Samsung", 110m, 10m, -8.33m, "5");
var rows = EmptyRowsTable();
rows.Rows.Add("20260701", DBNull.Value, 100m, 120m, 90m, 110m, 25m, 105m, DBNull.Value);
rows.Rows.Add(new DateTime(2026, 7, 2), DBNull.Value, 110m, 125m, 95m, 100m, 0m, 106m, 104m);
var executor = new RecordingExecutor(quote, rows);
var data = await new S8010SceneDataLoader(executor).LoadAsync(
Request(
CandleCutAlias.Cut8061,
CandleMarketTarget.KospiStock,
CandleChartMode.PriceWithVolume,
"Samsung"),
CancellationToken.None);
Assert.Equal("Samsung", data.DisplayName);
Assert.Equal(ScenePriceDirection.Down, data.Quote.Direction);
Assert.Equal(new DateOnly(2026, 7, 1), data.Rows[0].TradingDate);
Assert.Equal(25d, data.Rows[0].Volume);
Assert.Equal(104d, data.Rows[1].MovingAverage20);
var mutations = new S8010SceneMutationBuilder().Build(data);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetVisible { ObjectName: "volrumeG", IsVisible: true });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetCropKey { ObjectName: "volume1" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetFaceColor
{
ObjectName: "candlebody2",
Red: 15,
Green: 99,
Blue: 189
});
}
[Theory]
[MemberData(nameof(UnsupportedRequests))]
public async Task Unsupported_or_ambiguous_requests_fail_before_database_access(
S8010SceneDataRequest request)
{
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8010SceneDataLoader(executor).LoadAsync(request, CancellationToken.None));
Assert.Empty(executor.Calls);
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task Quote_schema_is_exact_and_fails_before_rows_query()
{
var quote = Table(
["CURRENT_PRICE", "DISPLAY_NAME", "CHANGE_PRICE", "RATE", "DIRECTION_CODE"],
[100, "Stock", 1, 1, "+"]);
var executor = new RecordingExecutor(quote, RowsTable(false));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8010SceneDataLoader(executor).LoadAsync(
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"),
CancellationToken.None));
Assert.Single(executor.Calls);
}
[Fact]
public async Task Row_schema_count_values_and_order_fail_closed_without_leaking_selection()
{
const string selection = "SENSITIVE O'Brien";
var malformedRows = EmptyRowsTable();
malformedRows.Rows.Add("20260702", DBNull.Value, 100, 110, 90, 100, DBNull.Value, DBNull.Value, DBNull.Value);
malformedRows.Rows.Add("20260701", DBNull.Value, 100, 110, 90, 100, DBNull.Value, DBNull.Value, DBNull.Value);
var executor = new RecordingExecutor(QuoteTable(), malformedRows);
var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8010SceneDataLoader(executor).LoadAsync(
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, selection),
CancellationToken.None));
Assert.DoesNotContain(selection, exception.Message, StringComparison.Ordinal);
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task More_rows_than_the_alias_bound_and_nonfinite_values_fail_closed()
{
var tooMany = EmptyRowsTable();
for (var day = 1; day <= 6; day++)
{
tooMany.Rows.Add(
$"202607{day:00}", DBNull.Value,
100, 110, 90, 100, DBNull.Value, DBNull.Value, DBNull.Value);
}
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8010SceneDataLoader(new RecordingExecutor(QuoteTable(), tooMany)).LoadAsync(
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"),
CancellationToken.None));
var nonfinite = RowsTable(false);
nonfinite.Rows[0]["HIGH_PRICE"] = double.PositiveInfinity;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8010SceneDataLoader(new RecordingExecutor(QuoteTable(), nonfinite)).LoadAsync(
Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"),
CancellationToken.None));
}
private static RouteCase Route(
CandleMarketTarget target,
CandleChartMode mode,
CandleCutAlias alias,
string? selection,
string currentFragment,
string rowsFragment,
string? rowsMustContain = null) =>
new(target, mode, alias, selection, currentFragment, rowsFragment, rowsMustContain);
private static RouteCase Selected(
CandleMarketTarget target,
CandleChartMode mode,
CandleCutAlias alias,
string currentFragment,
string rowsFragment,
string? rowsMustContain = null) =>
Route(
target,
mode,
alias,
"Sensitive O'Brien",
currentFragment,
rowsFragment,
rowsMustContain);
private static S8010SceneDataRequest Request(
CandleCutAlias alias,
CandleMarketTarget target,
CandleChartMode mode,
string? selection) =>
new(alias, target, mode, selection, true, true);
private static int AliasLimit(CandleCutAlias alias) => alias switch
{
CandleCutAlias.Cut8035 => 78,
CandleCutAlias.Cut8061 => 5,
CandleCutAlias.Cut8040 => 20,
CandleCutAlias.Cut8046 => 60,
CandleCutAlias.Cut8051 => 120,
CandleCutAlias.Cut8056 => 240,
_ => throw new InvalidOperationException()
};
private static DataTable QuoteTable(
object? displayName = null,
object? price = null,
object? change = null,
object? rate = null,
object? direction = null) =>
Table(
QuoteColumns,
[
displayName ?? "Instrument",
price ?? 100m,
change ?? 1m,
rate ?? 1m,
direction ?? "+"
]);
private static DataTable RowsTable(bool intraday)
{
var table = EmptyRowsTable();
table.Rows.Add(
"20260710",
intraday ? "090000" : DBNull.Value,
100m,
110m,
90m,
105m,
10m,
101m,
99m);
return table;
}
private static DataTable EmptyRowsTable() => Table(RowColumns);
private static DataTable Table(string[] columns, object?[]? row = null)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(object));
}
if (row is not null)
{
table.Rows.Add(row);
}
return table;
}
public sealed record RouteCase(
CandleMarketTarget Target,
CandleChartMode Mode,
CandleCutAlias Alias,
string? Selection,
string CurrentFragment,
string RowsFragment,
string? RowsMustContain);
private sealed record QueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
{
private readonly Queue<DataTable> _results = new(results);
public List<QueryCall> Calls { get; } = [];
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new QueryCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("No result was configured for the query.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
}

View File

@@ -0,0 +1,238 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ChartLegacySceneRequestResolverTests
{
private readonly ChartLegacySceneRequestResolver _resolver = new();
[Theory]
[InlineData("해외지수", "다우", "미국업종", SectorIndexMarket.Dow)]
[InlineData("해외지수", "나스닥", "미국업종", SectorIndexMarket.Nasdaq)]
[InlineData("해외지수", "S&P", "미국업종", SectorIndexMarket.StandardAndPoor)]
[InlineData("업종_코스피", "코스피", "섹터지수", SectorIndexMarket.Kospi)]
[InlineData("업종_코스닥", "코스닥", "섹터지수", SectorIndexMarket.Kosdaq)]
public void S5078_maps_all_original_subject_branches(
string group,
string subject,
string graphic,
SectorIndexMarket expected)
{
var request = _resolver.CreateS5078Request(
Entry("5078", group, subject, graphic));
Assert.Equal(expected, request.Market);
}
[Theory]
[InlineData("FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", SectorIndexMarket.Dow)]
[InlineData("FOREIGN_INDEX", "NASDAQ", "US_SECTOR_INDEX", SectorIndexMarket.Nasdaq)]
[InlineData("FOREIGN_INDEX", "S&P500", "US_SECTOR_INDEX", SectorIndexMarket.StandardAndPoor)]
[InlineData("INDUSTRY_KOSPI", "KOSPI", "SECTOR_INDEX", SectorIndexMarket.Kospi)]
[InlineData("INDUSTRY_KOSDAQ", "KOSDAQ", "SECTOR_INDEX", SectorIndexMarket.Kosdaq)]
public void S5078_accepts_only_the_explicit_canonical_aliases(
string group,
string subject,
string graphic,
SectorIndexMarket expected)
{
Assert.Equal(
expected,
_resolver.CreateS5078Request(
Entry("5078", group, subject, graphic)).Market);
}
[Fact]
public void S5079_and_S5080_preserve_the_original_jongmok_stock_name()
{
var growth = _resolver.CreateS5079Request(Entry(
"5079",
"코스피",
"삼성전자",
"성장성 지표",
dataCode: "005930"));
var sales = _resolver.CreateS5080Request(Entry(
"5080",
"코스닥",
"셀트리온헬스케어",
"매출액"));
Assert.Equal("삼성전자", growth.StockName);
Assert.Equal("셀트리온헬스케어", sales.StockName);
}
[Theory]
[InlineData("전체", "지수", "개인 매매동향", "라인그래프", InvestorFlowParticipant.Individual)]
[InlineData("전체", "지수", "기관 매매동향", "라인그래프", InvestorFlowParticipant.Institution)]
[InlineData("전체", "지수", "외국인 매매동향", "라인그래프", InvestorFlowParticipant.Foreign)]
[InlineData("ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", InvestorFlowParticipant.Individual)]
[InlineData("ALL", "INDEX", "INSTITUTION_TRADING_TREND", "LINE_GRAPH", InvestorFlowParticipant.Institution)]
[InlineData("ALL", "INDEX", "FOREIGN_TRADING_TREND", "LINE_GRAPH", InvestorFlowParticipant.Foreign)]
public void S5083_maps_the_three_forCutInfo_branches(
string group,
string subject,
string graphic,
string subtype,
InvestorFlowParticipant expected)
{
var request = _resolver.CreateS5083Request(
Entry("5083", group, subject, graphic, subtype));
Assert.Equal(expected, request.Participant);
}
[Theory]
[InlineData("코스피", "지수", "코스피 매매동향", "라인그래프", InvestorFlowIndex.Kospi)]
[InlineData("코스닥", "지수", "코스닥 매매동향", "라인그래프", InvestorFlowIndex.Kosdaq)]
[InlineData("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", InvestorFlowIndex.Kospi)]
[InlineData("KOSDAQ", "INDEX", "KOSDAQ_TRADING_TREND", "LINE_GRAPH", InvestorFlowIndex.Kosdaq)]
public void S5084_maps_the_two_forCutInfo_branches_and_checks_code_consistency(
string group,
string subject,
string graphic,
string subtype,
InvestorFlowIndex expected)
{
var request = _resolver.CreateS5084Request(
Entry("5084", group, subject, graphic, subtype));
Assert.Equal(expected, request.Index);
}
[Fact]
public void Resolve_returns_a_closed_wrapper_for_every_supported_cut()
{
Assert.Equal("s5078", _resolver.Resolve(
Entry("5078", "해외지수", "다우", "미국업종")).BuilderKey);
Assert.Equal("s5079", _resolver.Resolve(
Entry("5079", "코스피", "A", "성장성 지표")).BuilderKey);
Assert.Equal("s5080", _resolver.Resolve(
Entry("5080", "코스닥", "B", "매출액")).BuilderKey);
Assert.Equal("s5083", _resolver.Resolve(
Entry("5083", "전체", "지수", "개인 매매동향", "라인그래프")).BuilderKey);
Assert.Equal("s5084", _resolver.Resolve(
Entry("5084", "코스피", "지수", "코스피 매매동향", "라인그래프")).BuilderKey);
Assert.IsType<LegacyS5078ChartLoadRequest>(_resolver.Resolve(
Entry("5078", "해외지수", "다우", "미국업종")));
Assert.IsType<LegacyS5079ChartLoadRequest>(_resolver.Resolve(
Entry("5079", "코스피", "A", "성장성 지표")));
Assert.IsType<LegacyS5080ChartLoadRequest>(_resolver.Resolve(
Entry("5080", "코스닥", "B", "매출액")));
Assert.IsType<LegacyS5083ChartLoadRequest>(_resolver.Resolve(
Entry("5083", "전체", "지수", "기관 매매동향", "라인그래프")));
Assert.IsType<LegacyS5084ChartLoadRequest>(_resolver.Resolve(
Entry("5084", "코스닥", "지수", "코스닥 매매동향", "라인그래프")));
}
[Theory]
[InlineData("업종_코스피", "다우", "섹터지수", "", "")]
[InlineData("해외지수", "코스피", "미국업종", "", "")]
[InlineData("해외지수", "DOW JONES", "미국업종", "", "")]
[InlineData("해외지수", "다우", "미국업종", "EXTRA", "")]
[InlineData("해외지수", "다우", "미국업종", "", "005930")]
public void S5078_rejects_cross_branch_and_open_ended_selectors(
string group,
string subject,
string graphic,
string subtype,
string dataCode)
{
Assert.Throws<LegacySceneDataException>(() =>
_resolver.CreateS5078Request(
Entry("5078", group, subject, graphic, subtype, dataCode)));
}
[Theory]
[InlineData("전체", "A", "성장성 지표", "", "")]
[InlineData("코스피", "A", "성장성", "", "")]
[InlineData("코스피", "A", "성장성 지표", "LINE_GRAPH", "")]
[InlineData("코스피", " A", "성장성 지표", "", "")]
[InlineData("코스피", "A", "성장성 지표", "", "0059-30")]
public void Manual_chart_requests_reject_ambiguous_market_graphic_and_code_fields(
string group,
string subject,
string graphic,
string subtype,
string dataCode)
{
Assert.Throws<LegacySceneDataException>(() =>
_resolver.CreateS5079Request(
Entry("5079", group, subject, graphic, subtype, dataCode)));
}
[Theory]
[InlineData("전체", "지수", "개인과기관 매매동향", "라인그래프")]
[InlineData("코스피", "지수", "개인 매매동향", "라인그래프")]
[InlineData("전체", "시장", "개인 매매동향", "라인그래프")]
[InlineData("전체", "지수", "개인 매매동향", "라인 그래프")]
public void S5083_rejects_legacy_contains_ambiguities(
string group,
string subject,
string graphic,
string subtype)
{
Assert.Throws<LegacySceneDataException>(() =>
_resolver.CreateS5083Request(
Entry("5083", group, subject, graphic, subtype)));
}
[Theory]
[InlineData("코스피", "코스닥 매매동향")]
[InlineData("코스닥", "코스피 매매동향")]
[InlineData("전체", "코스피 매매동향")]
[InlineData("코스피", "코스피와코스닥 매매동향")]
public void S5084_rejects_mismatched_code_and_forCutInfo(
string group,
string graphic)
{
Assert.Throws<LegacySceneDataException>(() =>
_resolver.CreateS5084Request(
Entry("5084", group, "지수", graphic, "라인그래프")));
}
[Fact]
public void Cut_specific_APIs_reject_wrong_cut_missing_selection_and_unsafe_fields()
{
Assert.Throws<LegacySceneDataException>(() =>
_resolver.CreateS5078Request(
Entry("5079", "해외지수", "다우", "미국업종")));
Assert.Throws<LegacySceneDataException>(() =>
_resolver.CreateS5079Request(new LegacyPlaylistEntry("entry", "5079")));
Assert.Throws<LegacySceneDataException>(() =>
_resolver.Resolve(Entry("9999", "", "", "")));
Assert.Throws<LegacySceneDataException>(() =>
_resolver.CreateS5080Request(
Entry("5080", "코스피", "A\r\n", "매출액")));
Assert.Throws<LegacySceneDataException>(() =>
_resolver.CreateS5080Request(
Entry("5080", "코스피", new string('A', 257), "매출액")));
}
[Fact]
public void Resolver_errors_do_not_echo_selected_values()
{
const string sensitive = "SENSITIVE-STOCK-SELECTION";
var exception = Assert.Throws<LegacySceneDataException>(() =>
_resolver.CreateS5079Request(
Entry("5079", "UNKNOWN", sensitive, "성장성 지표")));
Assert.DoesNotContain(sensitive, exception.Message, StringComparison.Ordinal);
}
private static LegacyPlaylistEntry Entry(
string cutCode,
string group,
string subject,
string graphic,
string subtype = "",
string dataCode = "") => new(
"entry",
cutCode,
Selection: new LegacySceneSelection(
group,
subject,
graphic,
subtype,
dataCode));
}

View File

@@ -0,0 +1,490 @@
using System.Globalization;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ChartSceneBuilders5078To5084Tests
{
[Fact]
public void S5078_matches_complete_legacy_visibility_value_and_scale_order()
{
var data = new S5078SceneData(
SectorIndexMarket.Kospi,
SectorRows(
("A", 100, SectorBarVariant.Secondary),
("B", -50, SectorBarVariant.Primary),
("C", 0, SectorBarVariant.Secondary),
("D", 25, SectorBarVariant.Secondary),
("E", -25, SectorBarVariant.Primary),
("F", 10, SectorBarVariant.Secondary),
("G", -10, SectorBarVariant.Primary),
("H", 5, SectorBarVariant.Secondary),
("I", -5, SectorBarVariant.Primary),
("J", 50, SectorBarVariant.Secondary)));
var actual = new S5078SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"V:title=코스피 섹터지수",
"S:bar1_1=0", "S:value1_1=0", "S:bar1_2=0", "S:value1_2=0",
"S:bar2_1=0", "S:value2_1=0", "S:bar2_2=0", "S:value2_2=0",
"S:bar3_1=0", "S:value3_1=0", "S:bar3_2=0", "S:value3_2=0",
"S:bar4_1=0", "S:value4_1=0", "S:bar4_2=0", "S:value4_2=0",
"S:bar5_1=0", "S:value5_1=0", "S:bar5_2=0", "S:value5_2=0",
"S:bar6_1=0", "S:value6_1=0", "S:bar6_2=0", "S:value6_2=0",
"S:bar7_1=0", "S:value7_1=0", "S:bar7_2=0", "S:value7_2=0",
"S:bar8_1=0", "S:value8_1=0", "S:bar8_2=0", "S:value8_2=0",
"S:bar9_1=0", "S:value9_1=0", "S:bar9_2=0", "S:value9_2=0",
"S:bar10_1=0", "S:value10_1=0", "S:bar10_2=0", "S:value10_2=0",
"V:category1=A", "S:bar1_2=1", "S:value1_2=1",
"Z:bar1_2:1:0:0:1", "V:value1_2=100.00",
"V:category2=B", "S:bar2_1=1", "S:value2_1=1",
"Z:bar2_1:-0.5:0:0:1", "V:value2_1=-50.00",
"V:category3=C", "S:bar3_2=1", "S:value3_2=1",
"Z:bar3_2:0:0:0:1", "V:value3_2=0.00",
"V:category4=D", "S:bar4_2=1", "S:value4_2=1",
"Z:bar4_2:0.25:0:0:1", "V:value4_2=25.00",
"V:category5=E", "S:bar5_1=1", "S:value5_1=1",
"Z:bar5_1:-0.25:0:0:1", "V:value5_1=-25.00",
"V:category6=F", "S:bar6_2=1", "S:value6_2=1",
"Z:bar6_2:0.1:0:0:1", "V:value6_2=10.00",
"V:category7=G", "S:bar7_1=1", "S:value7_1=1",
"Z:bar7_1:-0.1:0:0:1", "V:value7_1=-10.00",
"V:category8=H", "S:bar8_2=1", "S:value8_2=1",
"Z:bar8_2:0.05:0:0:1", "V:value8_2=5.00",
"V:category9=I", "S:bar9_1=1", "S:value9_1=1",
"Z:bar9_1:-0.05:0:0:1", "V:value9_1=-5.00",
"V:category10=J", "S:bar10_2=1", "S:value10_2=1",
"Z:bar10_2:0.5:0:0:1", "V:value10_2=50.00"
], actual);
}
[Fact]
public void S5078_zero_and_us_primary_values_are_finite_and_use_original_sign_factor()
{
var zeroRows = Enumerable.Range(1, 10)
.Select(index => new SectorIndexBarData(
"Z" + index,
0,
SectorBarVariant.Primary))
.ToArray();
var zeros = new S5078SceneMutationBuilder().Build(new S5078SceneData(
SectorIndexMarket.Kosdaq,
zeroRows));
AssertFinite(zeros);
Assert.All(zeros.OfType<PlayoutSetScale>(), scale => Assert.Equal(0, scale.X));
var usRows = zeroRows.ToArray();
usRows[0] = new SectorIndexBarData("MAX", 100, SectorBarVariant.Secondary);
usRows[1] = new SectorIndexBarData("NEG", -50, SectorBarVariant.Primary);
var us = new S5078SceneMutationBuilder().Build(new S5078SceneData(
SectorIndexMarket.Dow,
usRows));
Assert.Contains("Z:bar2_1:0.5:0:0:1", us.Select(Describe));
}
[Fact]
public void S5078_rejects_empty_nonfinite_and_unmapped_data()
{
var builder = new S5078SceneMutationBuilder();
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5078SceneData(
SectorIndexMarket.Kospi,
[])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5078SceneData(
SectorIndexMarket.Kospi,
SectorRows(("bad", double.PositiveInfinity, SectorBarVariant.Primary)))));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5078SceneData(
(SectorIndexMarket)99,
SectorRows())));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5078SceneData(
SectorIndexMarket.Kospi,
SectorRows(("bad", 1, (SectorBarVariant)99)))));
}
[Fact]
public void S5079_matches_complete_legacy_axis_visibility_path_and_date_order()
{
var data = new S5079SceneData(
"A사",
[
new(GrowthMetricPath.First, [0d, 25d, 50d, 100d]),
new(GrowthMetricPath.Second, [-100d, -50d, 0d, 50d]),
new(GrowthMetricPath.Third, [null, 0d, 25d, 75d]),
new(GrowthMetricPath.Fourth, [100d, 100d, 100d, 100d])
],
["2025Q1", "2025Q2", "2025Q3", "2025Q4"]);
var actual = new S5079SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"V:title=A사 성장성 지표",
"V:value1=-60", "V:value2=-20", "V:value3=20", "V:value4=60", "V:value5=100",
"S:path1=0", "S:path1=1", "S:path1=1", "S:path1=1", "S:path1=1",
"L:path1=0,0,0|368,36,0|740,72,0|1110,144,0",
"S:path2=0", "S:path2=1", "S:path2=1", "S:path2=1", "S:path2=1",
"L:path2=0,-144,0|368,-72,0|740,0,0|1110,72,0",
"L:path3=0,0,0|368,0,0|740,36,0|1110,108,0", "S:path3=0",
"S:path4=0", "S:path4=1", "S:path4=1", "S:path4=1", "S:path4=1",
"L:path4=0,144,0|368,144,0|740,144,0|1110,144,0",
"V:date1=2025Q1", "V:date2=2025Q2", "V:date3=2025Q3", "V:date4=2025Q4"
], actual);
}
[Fact]
public void S5079_equal_and_empty_tokens_normalize_to_finite_center_points()
{
var equal = new S5079SceneMutationBuilder().Build(new S5079SceneData(
"A사",
Enum.GetValues<GrowthMetricPath>()
.Select(path => new GrowthMetricSeriesData(path, [5d, 5d, 5d, 5d]))
.ToArray(),
["", "", "", ""]));
AssertFinite(equal);
Assert.All(
equal.OfType<PlayoutSetPathPoints>().SelectMany(path => path.Points),
point => Assert.Equal(0, point.Y));
var emptyTokens = new S5079SceneMutationBuilder().Build(new S5079SceneData(
"A사",
Enum.GetValues<GrowthMetricPath>()
.Select(path => new GrowthMetricSeriesData(path, [null, null, null, null]))
.ToArray(),
["", "", "", ""]));
AssertFinite(emptyTokens);
Assert.All(
emptyTokens.OfType<PlayoutSetPathPoints>().SelectMany(path => path.Points),
point => Assert.Equal(0, point.Y));
}
[Fact]
public void S5079_rejects_empty_duplicate_nonfinite_and_unmapped_series()
{
var builder = new S5079SceneMutationBuilder();
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5079SceneData(
"A사", [], ["1", "2", "3", "4"])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5079SceneData(
"A사",
[
new(GrowthMetricPath.First, [1d, 2d, 3d, 4d]),
new(GrowthMetricPath.First, [1d, 2d, 3d, 4d]),
new(GrowthMetricPath.Third, [1d, 2d, 3d, 4d]),
new(GrowthMetricPath.Fourth, [1d, 2d, 3d, 4d])
],
["1", "2", "3", "4"])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5079SceneData(
"A사",
[
new(GrowthMetricPath.First, [double.NaN, 2d, 3d, 4d]),
new(GrowthMetricPath.Second, [1d, 2d, 3d, 4d]),
new(GrowthMetricPath.Third, [1d, 2d, 3d, 4d]),
new(GrowthMetricPath.Fourth, [1d, 2d, 3d, 4d])
],
["1", "2", "3", "4"])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5079SceneData(
"A사",
[
new((GrowthMetricPath)99, [1d, 2d, 3d, 4d]),
new(GrowthMetricPath.Second, [1d, 2d, 3d, 4d]),
new(GrowthMetricPath.Third, [1d, 2d, 3d, 4d]),
new(GrowthMetricPath.Fourth, [1d, 2d, 3d, 4d])
],
["1", "2", "3", "4"])));
}
[Fact]
public void S5080_matches_complete_legacy_quarter_group_center_and_scale_order()
{
var data = new S5080SceneData(
"A사",
[
new("1Q", 100), new("2Q", -50), new("3Q", 25),
new("4Q", 0), new("5Q", 50), new("6Q", -25)
]);
var actual = new S5080SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"V:title=A사 매출액",
"V:quarter1=1Q", "V:quarter2=2Q", "V:quarter3=3Q",
"V:quarter4=4Q", "V:quarter5=5Q", "V:quarter6=6Q",
"S:barG1_1=0", "S:barG1_2=0", "S:barG1_1=1", "V:value1_1=100",
"S:barG2_1=0", "S:barG2_2=0", "S:barG2_2=1", "V:value2_2=-50",
"S:barG3_1=0", "S:barG3_2=0", "S:barG3_1=1", "V:value3_1=25",
"S:barG4_1=0", "S:barG4_2=0", "S:barG4_1=1", "V:value4_1=0",
"S:barG5_1=0", "S:barG5_2=0", "S:barG5_1=1", "V:value5_1=50",
"S:barG6_1=0", "S:barG6_2=0", "S:barG6_2=1", "V:value6_2=-25",
"K:centerbar:0:0:-131.66667:0:2",
"Z:bar1_1:0:1.2:0:2", "Z:bar2_2:0:0.6:0:2",
"Z:bar3_1:0:0.3:0:2", "Z:bar4_1:0:0:0:2",
"Z:bar5_1:0:0.6:0:2", "Z:bar6_2:0:0.3:0:2"
], actual);
}
[Fact]
public void S5080_zero_positive_negative_and_equal_values_stay_finite()
{
var cases = new[]
{
(Value: 0, Center: -250f, Scale: 0f),
(Value: 10, Center: -240f, Scale: 1.8f),
(Value: -10, Center: 115f, Scale: 1.8f)
};
foreach (var item in cases)
{
var mutations = new S5080SceneMutationBuilder().Build(new S5080SceneData(
"A사",
Enumerable.Range(1, 6)
.Select(index => new QuarterlySalesData(index + "Q", item.Value))
.ToArray()));
AssertFinite(mutations);
Assert.Equal(item.Center, Assert.Single(mutations.OfType<PlayoutSetPositionKey>()).Y);
Assert.All(mutations.OfType<PlayoutSetScale>(), scale => Assert.Equal(item.Scale, scale.Y));
}
Assert.Throws<LegacySceneDataException>(() =>
new S5080SceneMutationBuilder().Build(new S5080SceneData("A사", [])));
}
[Fact]
public void S5083_matches_complete_legacy_summary_baseline_and_path_order()
{
var data = new S5083SceneData(
InvestorFlowParticipant.Individual,
[
new(InvestorFlowMarket.Kospi, 1000),
new(InvestorFlowMarket.Kosdaq, -500),
new(InvestorFlowMarket.Kospi200, 0)
],
[
new(InvestorFlowMarket.Kospi, -100, new TimeOnly(9, 0)),
new(InvestorFlowMarket.Kosdaq, 0, new TimeOnly(9, 0)),
new(InvestorFlowMarket.Kospi200, 100, new TimeOnly(9, 0)),
new(InvestorFlowMarket.Kospi, 100, new TimeOnly(15, 0)),
new(InvestorFlowMarket.Kosdaq, -50, new TimeOnly(15, 10)),
new(InvestorFlowMarket.Kospi200, 50, new TimeOnly(15, 30))
]);
var actual = new S5083SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"V:title=개인 매매동향",
"V:price1=1,000", "V:price2=-500", "V:price3=0",
"P:baseline:0:43:0:2",
"L:path1=0,-5,0|0,-138,0|1212.26,128,0",
"L:path2=0,-5,0|606.13,-5,0|1212.26,-71.5,0",
"L:path3=0,-5,0|606.13,128,0|1212.26,61.5,0"
], actual);
}
[Fact]
public void S5083_zero_single_points_and_preopen_time_use_explicit_finite_normalization()
{
var data = new S5083SceneData(
InvestorFlowParticipant.Foreign,
[
new(InvestorFlowMarket.Kospi, 0),
new(InvestorFlowMarket.Kosdaq, 0),
new(InvestorFlowMarket.Kospi200, 0)
],
[
new(InvestorFlowMarket.Kospi, 0, new TimeOnly(8, 0)),
new(InvestorFlowMarket.Kosdaq, 0, new TimeOnly(8, 0)),
new(InvestorFlowMarket.Kospi200, 0, new TimeOnly(8, 0))
]);
var mutations = new S5083SceneMutationBuilder().Build(data);
AssertFinite(mutations);
Assert.Equal(43, Assert.Single(mutations.OfType<PlayoutSetPosition>()).Y);
Assert.All(
mutations.OfType<PlayoutSetPathPoints>().SelectMany(path => path.Points),
point =>
{
Assert.Equal(0, point.X);
Assert.Equal(-5, point.Y);
});
}
[Fact]
public void S5083_rejects_empty_missing_duplicate_and_unmapped_targets()
{
var summary = new InvestorFlowSummaryData[]
{
new(InvestorFlowMarket.Kospi, 0),
new(InvestorFlowMarket.Kosdaq, 0),
new(InvestorFlowMarket.Kospi200, 0)
};
var points = new InvestorFlowTrendPoint[]
{
new(InvestorFlowMarket.Kospi, 0, new TimeOnly(9, 0)),
new(InvestorFlowMarket.Kosdaq, 0, new TimeOnly(9, 0)),
new(InvestorFlowMarket.Kospi200, 0, new TimeOnly(9, 0))
};
var builder = new S5083SceneMutationBuilder();
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5083SceneData(
InvestorFlowParticipant.Individual, summary, [])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5083SceneData(
InvestorFlowParticipant.Individual,
[summary[0], summary[0], summary[2]],
points)));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5083SceneData(
InvestorFlowParticipant.Individual,
summary,
[points[0], points[0], points[2]])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5083SceneData(
(InvestorFlowParticipant)99,
summary,
points)));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5083SceneData(
InvestorFlowParticipant.Individual,
summary,
[points[0], points[1], new((InvestorFlowMarket)99, 0, new TimeOnly(9, 0))])));
}
[Fact]
public void S5084_matches_complete_legacy_totals_baseline_and_path_order()
{
var data = new S5084SceneData(
InvestorFlowIndex.Kospi,
new InvestorFlowTotalsData(1000, -500, 250),
[
new(-100, 0, 100, new TimeOnly(9, 0)),
new(100, -50, 50, new TimeOnly(15, 30))
]);
var actual = new S5084SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"V:title=코스피 매매동향",
"V:price1=1,000", "V:price2=-500", "V:price3=250",
"P:baseline:0:43:0:2",
"L:path1=0,-5,0|606.13,-138,0|1212.26,128,0",
"L:path2=0,-5,0|606.13,-5,0|1212.26,-71.5,0",
"L:path3=0,-5,0|606.13,128,0|1212.26,61.5,0"
], actual);
}
[Fact]
public void S5084_zero_single_row_is_finite_and_empty_or_unmapped_data_fails_closed()
{
var builder = new S5084SceneMutationBuilder();
var zero = builder.Build(new S5084SceneData(
InvestorFlowIndex.Kosdaq,
new InvestorFlowTotalsData(0, 0, 0),
[new(0, 0, 0, new TimeOnly(15, 30))]));
AssertFinite(zero);
Assert.Equal(43, Assert.Single(zero.OfType<PlayoutSetPosition>()).Y);
Assert.All(
zero.OfType<PlayoutSetPathPoints>(),
path =>
{
Assert.Equal(-5, path.Points[0].Y);
Assert.Equal(1212.26f, path.Points[1].X);
Assert.Equal(-5, path.Points[1].Y);
});
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5084SceneData(
InvestorFlowIndex.Kospi,
new InvestorFlowTotalsData(0, 0, 0),
[])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5084SceneData(
(InvestorFlowIndex)99,
new InvestorFlowTotalsData(0, 0, 0),
[new(0, 0, 0, new TimeOnly(15, 30))])));
}
[Fact]
public void Builders_expose_the_closed_scene_keys()
{
Assert.Equal("s5078", new S5078SceneMutationBuilder().BuilderKey);
Assert.Equal("s5079", new S5079SceneMutationBuilder().BuilderKey);
Assert.Equal("s5080", new S5080SceneMutationBuilder().BuilderKey);
Assert.Equal("s5083", new S5083SceneMutationBuilder().BuilderKey);
Assert.Equal("s5084", new S5084SceneMutationBuilder().BuilderKey);
}
private static SectorIndexBarData[] SectorRows(
params (string Category, double Value, SectorBarVariant Variant)[] overrides)
{
var rows = Enumerable.Range(1, 10)
.Select(index => new SectorIndexBarData(
"R" + index,
0,
SectorBarVariant.Secondary))
.ToArray();
for (var index = 0; index < overrides.Length; index++)
{
rows[index] = new SectorIndexBarData(
overrides[index].Category,
overrides[index].Value,
overrides[index].Variant);
}
return rows;
}
private static void AssertFinite(IEnumerable<PlayoutMutation> mutations)
{
foreach (var mutation in mutations)
{
switch (mutation)
{
case PlayoutSetPosition position:
Assert.True(float.IsFinite(position.X));
Assert.True(float.IsFinite(position.Y));
Assert.True(float.IsFinite(position.Z));
break;
case PlayoutSetPositionKey positionKey:
Assert.True(float.IsFinite(positionKey.X));
Assert.True(float.IsFinite(positionKey.Y));
Assert.True(float.IsFinite(positionKey.Z));
break;
case PlayoutSetScale scale:
Assert.True(float.IsFinite(scale.X));
Assert.True(float.IsFinite(scale.Y));
Assert.True(float.IsFinite(scale.Z));
break;
case PlayoutSetPathPoints path:
Assert.All(path.Points, point =>
{
Assert.True(float.IsFinite(point.X));
Assert.True(float.IsFinite(point.Y));
Assert.True(float.IsFinite(point.Z));
});
break;
case PlayoutSetValue value:
Assert.DoesNotContain("NaN", value.Value, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Infinity", value.Value, StringComparison.OrdinalIgnoreCase);
break;
}
}
}
private static string Describe(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
PlayoutSetPosition position => FormattableString.Invariant(
$"P:{position.ObjectName}:{position.X}:{position.Y}:{position.Z}:{(int)position.Components}"),
PlayoutSetPositionKey key => FormattableString.Invariant(
$"K:{key.ObjectName}:{key.KeyIndex}:{key.X}:{key.Y}:{key.Z}:{(int)key.Components}"),
PlayoutSetScale scale => FormattableString.Invariant(
$"Z:{scale.ObjectName}:{scale.X}:{scale.Y}:{scale.Z}:{(int)scale.Components}"),
PlayoutSetPathPoints path => "L:" + path.ObjectName + "=" + string.Join(
"|",
path.Points.Select(point => FormattableString.Invariant(
$"{point.X},{point.Y},{point.Z}"))),
_ => mutation.GetType().Name
};
}

View File

@@ -0,0 +1,372 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ChartSceneDataLoadersTests
{
[Theory]
[InlineData(SectorIndexMarket.Dow, "DJI@DJINET")]
[InlineData(SectorIndexMarket.Nasdaq, "NAS@CXBT")]
[InlineData(SectorIndexMarket.StandardAndPoor, "SPI@S5ENRS")]
[InlineData(SectorIndexMarket.Kospi, "FROM t_index")]
[InlineData(SectorIndexMarket.Kosdaq, "FROM t_kosdaq_index")]
public async Task S5078_CoversEveryClosedLegacyQueryBranch(
SectorIndexMarket market,
string queryMarker)
{
var executor = new RecordingExecutor(
new Dictionary<string, DataTable>
{
["S5078_SECTOR_INDEX"] = SectorTable()
});
var data = await new S5078SceneDataLoader(executor).LoadAsync(
new S5078SceneDataRequest(market),
CancellationToken.None);
Assert.Equal(market, data.Market);
Assert.Equal(10, data.Rows.Count);
Assert.Equal(SectorBarVariant.Primary, data.Rows[0].Variant);
Assert.Equal(SectorBarVariant.Secondary, data.Rows[1].Variant);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Empty(call.Spec.Parameters);
Assert.Contains(queryMarker, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.NotEmpty(new S5078SceneMutationBuilder().Build(data));
}
[Fact]
public async Task S5078_RequiresExactlyTenRowsAndTheExactSchema()
{
var tooMany = SectorTable(11);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5078SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["S5078_SECTOR_INDEX"] = tooMany }))
.LoadAsync(new S5078SceneDataRequest(SectorIndexMarket.Kospi)));
var wrongSchema = SectorTable();
wrongSchema.Columns[4].ColumnName = "UNEXPECTED";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5078SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["S5078_SECTOR_INDEX"] = wrongSchema }))
.LoadAsync(new S5078SceneDataRequest(SectorIndexMarket.Kospi)));
}
[Fact]
public async Task S5079_UsesOneOracleParameterAndMapsAllFourSeriesAndDates()
{
var table = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"],
["삼성전자", "1_2_3_4", "-1_-2_-3_-4", "_0_1_2", "5.5_6.5_7.5_8.5", "2025Q1_2025Q2_2025Q3_2025Q4"]);
var executor = new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_GROW"] = table });
var data = await new S5079SceneDataLoader(executor).LoadAsync(
new S5079SceneDataRequest("삼성전자"));
Assert.Equal("삼성전자", data.StockName);
Assert.Equal(new double?[] { 1, 2, 3, 4 }, data.Series[0].Values);
Assert.Null(data.Series[2].Values[0]);
Assert.Equal(new[] { "2025Q1", "2025Q2", "2025Q3", "2025Q4" }, data.Dates);
var call = Assert.Single(executor.Calls);
Assert.Equal("INPUT_GROW", call.TableName);
Assert.Contains("WHERE STOCK_NAME = :stockName", call.Spec.Sql, StringComparison.Ordinal);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("stockName", parameter.Name);
Assert.Equal("삼성전자", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
Assert.NotEmpty(new S5079SceneMutationBuilder().Build(data));
}
[Theory]
[InlineData("1_2_3", "2025Q1_2025Q2_2025Q3_2025Q4")]
[InlineData("1_2_bad_4", "2025Q1_2025Q2_2025Q3_2025Q4")]
[InlineData("1_2_3_4", "2025Q1_2025Q2_2025Q3")]
public async Task S5079_FailsClosedForMalformedLegacyTokens(
string series,
string dates)
{
var table = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"],
["A", series, "1_2_3_4", "1_2_3_4", "1_2_3_4", dates]);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5079SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_GROW"] = table }))
.LoadAsync(new S5079SceneDataRequest("A")));
}
[Fact]
public async Task S5080_UsesOneOracleParameterAndMapsSixTypedQuarters()
{
var table = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
["A", "1Q_100", "2Q_-50", "3Q_", "4Q_1000", "5Q_25", "6Q_-2"]);
var executor = new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_SELL"] = table });
var data = await new S5080SceneDataLoader(executor).LoadAsync(
new S5080SceneDataRequest("A"));
Assert.Equal(new[] { "1Q", "2Q", "3Q", "4Q", "5Q", "6Q" },
data.Quarters.Select(item => item.Quarter));
Assert.Equal(new[] { 100, -50, 0, 1000, 25, -2 },
data.Quarters.Select(item => item.Value));
var call = Assert.Single(executor.Calls);
Assert.Equal("INPUT_SELL", call.TableName);
Assert.Single(call.Spec.Parameters);
Assert.NotEmpty(new S5080SceneMutationBuilder().Build(data));
}
[Fact]
public async Task S5080_RejectsMalformedQuarterAndAmbiguousRows()
{
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"]);
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");
ambiguous.Rows.Add("A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6");
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5080SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_SELL"] = ambiguous }))
.LoadAsync(new S5080SceneDataRequest("A")));
}
[Theory]
[InlineData(InvestorFlowParticipant.Individual, "F_INVEST_CODE = '8000'")]
[InlineData(InvestorFlowParticipant.Institution, "'7000'")]
[InlineData(InvestorFlowParticipant.Foreign, "'9000', '9001'")]
public async Task S5083_CoversEveryParticipantSummaryAndTrendBranch(
InvestorFlowParticipant participant,
string queryMarker)
{
var executor = new RecordingExecutor(new Dictionary<string, DataTable>
{
["S5083_SUMMARY"] = ParticipantSummaryTable(),
["S5083_TREND"] = ParticipantTrendTable()
});
var data = await new S5083SceneDataLoader(executor).LoadAsync(
new S5083SceneDataRequest(participant));
Assert.Equal(participant, data.Participant);
Assert.Equal(
new[] { InvestorFlowMarket.Kospi, InvestorFlowMarket.Kosdaq, InvestorFlowMarket.Kospi200 },
data.Summary.Select(item => item.Market));
Assert.Equal(new TimeOnly(15, 30), data.TrendPoints[^1].DataTime);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Empty(call.Spec.Parameters);
Assert.Contains(queryMarker, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
});
Assert.NotEmpty(new S5083SceneMutationBuilder().Build(data));
}
[Fact]
public async Task S5083_RejectsDuplicateOrUnknownMarketsAndInvalidTimes()
{
var summary = ParticipantSummaryTable();
summary.Rows[1]["MARKET"] = "코스피";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5083SceneDataLoader(new RecordingExecutor(new Dictionary<string, DataTable>
{
["S5083_SUMMARY"] = summary,
["S5083_TREND"] = ParticipantTrendTable()
})).LoadAsync(new S5083SceneDataRequest(InvestorFlowParticipant.Individual)));
var trend = ParticipantTrendTable();
trend.Rows[2]["DATA_TIME"] = "202607109999";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5083SceneDataLoader(new RecordingExecutor(new Dictionary<string, DataTable>
{
["S5083_SUMMARY"] = ParticipantSummaryTable(),
["S5083_TREND"] = trend
})).LoadAsync(new S5083SceneDataRequest(InvestorFlowParticipant.Foreign)));
}
[Theory]
[InlineData(InvestorFlowIndex.Kospi, "FROM t_invest")]
[InlineData(InvestorFlowIndex.Kosdaq, "FROM t_kosdaq_invest")]
public async Task S5084_CoversBothIndexSummaryAndTrendBranches(
InvestorFlowIndex index,
string queryMarker)
{
var executor = new RecordingExecutor(new Dictionary<string, DataTable>
{
["S5084_SUMMARY"] = IndexSummaryTable(),
["S5084_TREND"] = IndexTrendTable()
});
var data = await new S5084SceneDataLoader(executor).LoadAsync(
new S5084SceneDataRequest(index));
Assert.Equal(index, data.Index);
Assert.Equal(new InvestorFlowTotalsData(100, -50, 25), data.Totals);
Assert.Equal(2, data.TrendPoints.Count);
Assert.Equal(new TimeOnly(15, 30), data.TrendPoints[^1].DataTime);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
Assert.Empty(call.Spec.Parameters);
Assert.Contains(queryMarker, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
});
Assert.NotEmpty(new S5084SceneMutationBuilder().Build(data));
}
[Fact]
public async Task S5084_RejectsEmptyTrendAndFractionalIntegerValues()
{
var emptyTrend = IndexTrendTable();
emptyTrend.Rows.Clear();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5084SceneDataLoader(new RecordingExecutor(new Dictionary<string, DataTable>
{
["S5084_SUMMARY"] = IndexSummaryTable(),
["S5084_TREND"] = emptyTrend
})).LoadAsync(new S5084SceneDataRequest(InvestorFlowIndex.Kospi)));
var fractional = IndexSummaryTable();
fractional.Rows[0]["INDIVIDUAL"] = 1.5m;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5084SceneDataLoader(new RecordingExecutor(new Dictionary<string, DataTable>
{
["S5084_SUMMARY"] = fractional,
["S5084_TREND"] = IndexTrendTable()
})).LoadAsync(new S5084SceneDataRequest(InvestorFlowIndex.Kosdaq)));
}
[Fact]
public async Task ClosedRequestsFailBeforeAnyDatabaseCall()
{
var executor = new RecordingExecutor(new Dictionary<string, DataTable>());
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5078SceneDataLoader(executor).LoadAsync(
new S5078SceneDataRequest((SectorIndexMarket)99)));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5083SceneDataLoader(executor).LoadAsync(
new S5083SceneDataRequest((InvestorFlowParticipant)99)));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5084SceneDataLoader(executor).LoadAsync(
new S5084SceneDataRequest((InvestorFlowIndex)99)));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5079SceneDataLoader(executor).LoadAsync(
new S5079SceneDataRequest("\r\n")));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ParameterizedLoaderErrorsDoNotExposeTheSelection()
{
const string selection = "SENSITIVE-STOCK-SELECTION";
var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5079SceneDataLoader(new RecordingExecutor(
new Dictionary<string, DataTable> { ["INPUT_GROW"] = new DataTable() }))
.LoadAsync(new S5079SceneDataRequest(selection)));
Assert.DoesNotContain(selection, exception.Message, StringComparison.Ordinal);
}
private static DataTable SectorTable(int count = 10)
{
var table = Table(["CATEGORY", "PART_INDEX", "CHG_TYPE", "PART_CHANGE", "RATE"]);
for (var index = 0; index < count; index++)
{
table.Rows.Add(
"Sector" + index,
1000 + index,
index == 0 ? "-" : "+",
index,
index - 5d);
}
return table;
}
private static DataTable ParticipantSummaryTable() => Table(
["MARKET", "AMOUNT"],
["코스피", 100],
["코스닥", -50],
["코스피200", 25]);
private static DataTable ParticipantTrendTable() => Table(
["MARKET", "DATA_TIME", "PRICE"],
["코스피", "202607100900", -10],
["코스닥", "202607101000", 0],
["코스피200", "202607101530", 10]);
private static DataTable IndexSummaryTable() => Table(
["INDIVIDUAL", "FOREIGN", "INSTITUTION"],
[100, -50, 25]);
private static DataTable IndexTrendTable() => Table(
["DATA_TIME", "INDIVIDUAL", "FOREIGN", "INSTITUTION"],
["202607100900", -10, 0, 10],
["202607101530", 10, -5, 5]);
private static DataTable Table(string[] columns, params object?[][] rows)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(object));
}
foreach (var row in rows)
{
table.Rows.Add(row);
}
return table;
}
private sealed record QueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(
IReadOnlyDictionary<string, DataTable> results) : IDataQueryExecutor
{
public List<QueryCall> Calls { get; } = [];
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("String query fallback is forbidden in this test.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("String query fallback is forbidden in this test.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new QueryCall(source, tableName, query));
if (!results.TryGetValue(tableName, out var table))
{
throw new InvalidOperationException("The test did not configure this table.");
}
return Task.FromResult(table.Copy());
}
}
}

View File

@@ -0,0 +1,402 @@
#nullable enable
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ComparisonAndYieldLegacyRequestResolverTests
{
[Theory]
[InlineData("5026", "s5026")]
[InlineData("5087", "s5087")]
public async Task Fixed_pair_scenes_preserve_subject_order_and_use_five_days(
string cutCode,
string builderKey)
{
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(0), Count(1));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = await resolver.ResolveAsync(Entry(
cutCode,
groupCode: "ignored-code",
subject: " 첫번째 , 두번째 ",
subtype: "ignored-sub",
graphicType: "ignored-forCutInfo",
dataCode: "ignored-dataCode"));
Assert.Equal(builderKey, result.BuilderKey);
var request = result switch
{
LegacyS5026SceneLoadRequest routed => routed.Request,
LegacyS5087SceneLoadRequest routed => routed.Request,
_ => throw new Xunit.Sdk.XunitException("Unexpected routed request type.")
};
Assert.Equal(ComparisonGraphPeriod.FiveDays, request.Period);
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "첫번째"),
request.First);
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "두번째"),
request.Second);
AssertStockLookupOrder(executor, "첫번째", "두번째");
}
[Theory]
[InlineData("일봉", ComparisonGraphPeriod.Daily)]
[InlineData("5일", ComparisonGraphPeriod.FiveDays)]
[InlineData("1개월", ComparisonGraphPeriod.OneMonth)]
[InlineData("3개월", ComparisonGraphPeriod.ThreeMonths)]
[InlineData("6개월", ComparisonGraphPeriod.SixMonths)]
[InlineData("12개월", ComparisonGraphPeriod.TwelveMonths)]
public async Task S5029_maps_every_original_period(
string subtype,
ComparisonGraphPeriod expected)
{
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(0), Count(1));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5029SceneLoadRequest>(
await resolver.ResolveAsync(Entry("5029", "", "A,B", subtype)));
Assert.Equal(expected, result.Request.Period);
Assert.Equal("A", result.Request.First.StockName);
Assert.Equal("B", result.Request.Second.StockName);
}
[Theory]
[InlineData("5일", YieldGraphPeriod.FiveDays)]
[InlineData("20일", YieldGraphPeriod.TwentyDays)]
[InlineData("60일", YieldGraphPeriod.SixtyDays)]
[InlineData("120일", YieldGraphPeriod.OneHundredTwentyDays)]
[InlineData("240일", YieldGraphPeriod.TwoHundredFortyDays)]
public async Task S5086_maps_every_original_yield_period(
string subtype,
YieldGraphPeriod expected)
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
await resolver.ResolveAsync(Entry("5086", "환율", "원달러", subtype)));
Assert.Equal(expected, result.Request.Period);
Assert.Equal(YieldGraphInstrumentKind.WonDollar, result.Request.Instrument.Kind);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("오늘 원달러", YieldGraphInstrumentKind.WonDollar)]
[InlineData("오늘 원엔", YieldGraphInstrumentKind.WonYen)]
[InlineData("오늘 원위엔", YieldGraphInstrumentKind.WonYuan)]
[InlineData("오늘 원유로", YieldGraphInstrumentKind.WonEuro)]
public async Task S5086_preserves_original_contains_based_fx_routing(
string subject,
YieldGraphInstrumentKind expected)
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
await resolver.ResolveAsync(Entry("5086", "unused", subject, "5일")));
Assert.Equal(expected, result.Request.Instrument.Kind);
Assert.Null(result.Request.Instrument.Name);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("코스피", YieldGraphInstrumentKind.KospiIndex)]
[InlineData("코스닥", YieldGraphInstrumentKind.KosdaqIndex)]
[InlineData("코스피200", YieldGraphInstrumentKind.Kospi200Index)]
[InlineData("KRX100", YieldGraphInstrumentKind.Krx100Index)]
public async Task S5086_uses_group_code_only_for_the_original_index_branch(
string groupCode,
YieldGraphInstrumentKind expected)
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
await resolver.ResolveAsync(Entry(
"5086",
groupCode,
"지수",
"20일",
graphicType: "must-not-route",
dataCode: "must-not-route")));
Assert.Equal(expected, result.Request.Instrument.Kind);
Assert.Null(result.Request.Instrument.Name);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("코스피업종", YieldGraphInstrumentKind.KospiIndustry)]
[InlineData("코스닥업종", YieldGraphInstrumentKind.KosdaqIndustry)]
[InlineData("기타업종", YieldGraphInstrumentKind.KosdaqIndustry)]
public async Task S5086_preserves_original_industry_branch_and_kosdaq_fallback(
string groupCode,
YieldGraphInstrumentKind expected)
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
await resolver.ResolveAsync(Entry("5086", groupCode, "반도체", "60일")));
Assert.Equal(expected, result.Request.Instrument.Kind);
Assert.Equal("반도체", result.Request.Instrument.Name);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task S5086_stock_uses_kosdaq_later_table_precedence()
{
var executor = new RecordingExecutor(Count(1), Count(1));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
await resolver.ResolveAsync(Entry("5086", "종목", "중복이름", "120일")));
Assert.Equal(YieldGraphInstrumentKind.KosdaqStock, result.Request.Instrument.Kind);
Assert.Equal("중복이름", result.Request.Instrument.Name);
AssertStockLookupOrder(executor, "중복이름");
}
[Theory]
[InlineData("원달러", YieldGraphInstrumentKind.WonDollar)]
[InlineData("원엔", YieldGraphInstrumentKind.WonYen)]
[InlineData("원위엔", YieldGraphInstrumentKind.WonYuan)]
[InlineData("원유로", YieldGraphInstrumentKind.WonEuro)]
public async Task S50860_accepts_only_the_quote_capable_fx_branches(
string subject,
YieldGraphInstrumentKind expected)
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS50860SceneLoadRequest>(
await resolver.ResolveAsync(Entry("50860", "ignored", subject, "240일")));
Assert.Equal(YieldGraphPeriod.TwoHundredFortyDays, result.Request.Period);
Assert.Equal(expected, result.Request.Instrument.Kind);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("코스피", "지수")]
[InlineData("코스피업종", "반도체")]
[InlineData("종목", "삼성전자")]
public async Task S50860_rejects_non_fx_before_any_database_lookup(
string groupCode,
string subject)
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveAsync(Entry("50860", groupCode, subject, "5일")));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task Stock_lookups_are_read_only_parameterized_oracle_queries()
{
const string firstName = "A' OR '1'='1";
const string secondName = "B; DELETE FROM T_STOCK";
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(0), Count(1));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
_ = await resolver.ResolveAsync(Entry(
"5026",
"",
firstName + "," + secondName,
""));
AssertStockLookupOrder(executor, firstName, secondName);
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Contains("SELECT COUNT(*)", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("INSERT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("UPDATE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DELETE FROM", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("stockName", parameter.Name);
Assert.Equal(DbType.String, parameter.DbType);
Assert.DoesNotContain(
Assert.IsType<string>(parameter.Value),
call.Spec.Sql,
StringComparison.Ordinal);
});
Assert.Contains("FROM T_STOCK ", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM T_KOSDAQ_STOCK ", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
}
[Theory]
[InlineData("9999", "", "A,B", "5일")]
[InlineData("5029", "", "A,B", "주봉")]
[InlineData("5029", "", "A", "5일")]
[InlineData("5029", "", "A,B,C", "5일")]
[InlineData("5086", "UNKNOWN", "지수", "5일")]
[InlineData("5086", "환율", "원달러", "1일")]
public async Task Unsupported_closed_selections_fail_before_database_access(
string cutCode,
string groupCode,
string subject,
string subtype)
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveAsync(Entry(cutCode, groupCode, subject, subtype)));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task Missing_selection_fails_before_database_access()
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveAsync(new LegacyPlaylistEntry("entry", "5026")));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task Unknown_stock_fails_after_both_original_market_lookups()
{
var executor = new RecordingExecutor(Count(0), Count(0));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveAsync(Entry("5086", "종목", "없는종목", "5일")));
AssertStockLookupOrder(executor, "없는종목");
}
public static IEnumerable<object[]> InvalidStockMasterResults()
{
var wrongColumn = new DataTable();
wrongColumn.Columns.Add("COUNT", typeof(int));
wrongColumn.Rows.Add(1);
yield return [wrongColumn];
var noRows = new DataTable();
noRows.Columns.Add("MATCH_COUNT", typeof(int));
yield return [noRows];
yield return [Count(2)];
yield return [Count(0.5m)];
var nullCount = new DataTable();
nullCount.Columns.Add("MATCH_COUNT", typeof(object));
nullCount.Rows.Add(DBNull.Value);
yield return [nullCount];
}
[Theory]
[MemberData(nameof(InvalidStockMasterResults))]
public async Task Invalid_stock_master_results_fail_closed(DataTable result)
{
var executor = new RecordingExecutor(result);
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveAsync(Entry("5026", "", "A,B", "")));
Assert.Single(executor.Calls);
}
private static LegacyPlaylistEntry Entry(
string cutCode,
string groupCode,
string subject,
string subtype,
string graphicType = "",
string dataCode = "") => new(
"entry",
cutCode,
Selection: new LegacySceneSelection(
groupCode,
subject,
graphicType,
subtype,
dataCode));
private static DataTable Count(object value)
{
var table = new DataTable();
table.Columns.Add("MATCH_COUNT", value.GetType());
table.Rows.Add(value);
return table;
}
private static void AssertStockLookupOrder(
RecordingExecutor executor,
params string[] stockNames)
{
Assert.Equal(stockNames.Length * 2, executor.Calls.Count);
for (var index = 0; index < stockNames.Length; index++)
{
var kospi = executor.Calls[index * 2];
var kosdaq = executor.Calls[(index * 2) + 1];
Assert.Equal("SCENE_COMPARISON_RESOLVE_KOSPI_STOCK", kospi.TableName);
Assert.Equal("SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK", kosdaq.TableName);
Assert.Equal(stockNames[index], Assert.Single(kospi.Spec.Parameters).Value);
Assert.Equal(stockNames[index], Assert.Single(kosdaq.Spec.Parameters).Value);
}
}
private sealed record RecordedCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
{
private readonly Queue<DataTable> _results = new(results);
public List<RecordedCall> Calls { get; } = [];
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("String SQL fallback is forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("String SQL fallback is forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new RecordedCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("No fake query result remains.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
}

View File

@@ -0,0 +1,597 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ComparisonAndYieldSceneBuildersTests
{
[Fact]
public void S5026_matches_legacy_header_direction_and_two_candle_series_order()
{
var actual = new S5026SceneMutationBuilder().Build(new S5026SceneData(
ComparisonGraphPeriod.FiveDays,
[CandleSeries(ComparisonSeriesTarget.Second), CandleSeries(ComparisonSeriesTarget.First)]))
.Select(Describe)
.ToArray();
var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5026)
.Concat(ExpectedCandleSeries(1))
.Concat(ExpectedCandleSeries(2));
Assert.Equal(expected, actual);
}
[Fact]
public void S5026_equal_range_is_finite_and_missing_or_nonfinite_candles_fail_closed()
{
var equal = Enumerable.Range(1, 5)
.Select(index => new ComparisonCandlePoint(
new DateOnly(2026, 1, index),
100,
100,
100,
100))
.ToArray();
var first = CandleSeries(ComparisonSeriesTarget.First) with { Candles = equal };
var second = CandleSeries(ComparisonSeriesTarget.Second) with { Candles = equal };
var builder = new S5026SceneMutationBuilder();
var mutations = builder.Build(new S5026SceneData(
ComparisonGraphPeriod.FiveDays,
[first, second]));
AssertFinite(mutations);
Assert.All(
mutations.OfType<PlayoutSetCropKey>().Where(item => item.ObjectName.Contains("candlebar")),
crop =>
{
Assert.Equal(0, crop.Top);
Assert.Equal(0, crop.Bottom);
});
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5026SceneData(
ComparisonGraphPeriod.FiveDays,
[first with { Candles = equal.Take(4).ToArray() }, second])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5026SceneData(
ComparisonGraphPeriod.FiveDays,
[
first with
{
Candles = equal.Select((item, index) => index == 0
? item with { High = double.NaN }
: item).ToArray()
},
second
])));
}
[Fact]
public void S5029_matches_legacy_two_series_dates_tags_baselines_and_shapes()
{
var actual = new S5029SceneMutationBuilder().Build(new S5029SceneData(
ComparisonGraphPeriod.OneMonth,
[YieldSeries(ComparisonSeriesTarget.First), YieldSeries(ComparisonSeriesTarget.Second)]))
.Select(Describe)
.ToArray();
var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5029)
.Concat(ExpectedYieldSeries(1, 812f, 100f))
.Concat(ExpectedYieldSeries(2, 812f, -50f));
Assert.Equal(expected, actual);
}
[Fact]
public void S5087_preserves_its_distinct_hide_order_width_and_baseline_offset()
{
var actual = new S5087SceneMutationBuilder().Build(new S5087SceneData(
ComparisonGraphPeriod.FiveDays,
[YieldSeries(ComparisonSeriesTarget.Second), YieldSeries(ComparisonSeriesTarget.First)]))
.Select(Describe)
.ToArray();
var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5087)
.Concat(ExpectedYieldSeries(1, 700f, -20f))
.Concat(ExpectedYieldSeries(2, 700f, -170f));
Assert.Equal(expected, actual);
}
[Fact]
public void Comparison_yield_one_point_and_equal_values_correct_source_denominators_to_finite_zero()
{
var first = YieldSeries(ComparisonSeriesTarget.First) with
{
Points = [new(new DateOnly(2026, 1, 1), 10)]
};
var second = YieldSeries(ComparisonSeriesTarget.Second) with
{
Points = [new(new DateOnly(2026, 1, 1), -10)]
};
var mutations = new S5029SceneMutationBuilder().Build(new S5029SceneData(
ComparisonGraphPeriod.Daily,
[first, second]));
AssertFinite(mutations);
Assert.Equal(-50, mutations.OfType<PlayoutSetPosition>()
.Single(item => item.ObjectName == "baseline1").Y);
Assert.All(
mutations.OfType<PlayoutSetPathShapePoints>().SelectMany(item => item.Points),
point => Assert.True(float.IsFinite(point.X) && float.IsFinite(point.Y)));
Assert.Throws<LegacySceneDataException>(() =>
new S5029SceneMutationBuilder().Build(new S5029SceneData(
ComparisonGraphPeriod.Daily,
[first with { Points = [] }, second])));
}
[Fact]
public void Comparison_yield_sign_crossing_adds_the_same_finite_zero_intersection_to_both_shapes()
{
var crossing = YieldSeries(ComparisonSeriesTarget.First) with
{
Points =
[
new(new DateOnly(2026, 1, 1), 100),
new(new DateOnly(2026, 1, 2), 0),
new(new DateOnly(2026, 1, 3), 150),
new(new DateOnly(2026, 1, 4), 50),
new(new DateOnly(2026, 1, 5), 100)
]
};
var mutations = new S5029SceneMutationBuilder().Build(new S5029SceneData(
ComparisonGraphPeriod.FiveDays,
[crossing, YieldSeries(ComparisonSeriesTarget.Second)]));
var plus = mutations.OfType<PlayoutSetPathShapePoints>()
.Single(item => item.ObjectName == "plusGraph1");
var minus = mutations.OfType<PlayoutSetPathShapePoints>()
.Single(item => item.ObjectName == "minusGraph1");
Assert.Contains(plus.Points, point => point.X == 304.5f && point.Y == 0);
Assert.Contains(minus.Points, point => point.X == 304.5f && point.Y == 0);
AssertFinite(mutations);
}
[Fact]
public void S5086_matches_legacy_fx_header_quote_dates_tags_and_graph_order()
{
var actual = new S5086SceneMutationBuilder().Build(new S5086SceneData(
YieldGraphPeriod.TwentyDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar),
SingleYieldPoints()))
.Select(Describe)
.ToArray();
Assert.Equal(
[
"V:subtitle=1개월", "V:unit=단위 : 원", "V:title=원달러 수익률",
"V:date5=01/01", "V:date4=01/02", "V:date3=01/03",
"V:date2=01/04", "V:date1=01/05",
"V:price=0.00", "V:referencePrice=100.00", "V:rate=-100.00",
"S:bg1=0", "S:bg2=0", "S:bg3=1",
"P:baseline:0:150:0:2",
"P:highTag:-647.5:0:0:1", "V:highPrice=100.00",
"P:lowTag:653.79:0:0:1", "V:lowPrice=0.00",
"P:minusGraph:-659.82:150:0:7",
"H:minusGraph=0,0,0|0,-0,0|333.25,-56.25,0|666.5,-112.5,0|999.75,-168.75,0|1333,-225,0|1333,0,0",
"P:plusGraph:-659.82:150:0:7",
"H:plusGraph=0,0,0|0,0,0|1333,0,0"
], actual);
}
[Fact]
public void S50860_matches_legacy_slash_title_quote_direction_color_and_graph_order()
{
var actual = new S50860SceneMutationBuilder().Build(new S50860SceneData(
YieldGraphPeriod.TwentyDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar),
-12.34,
1.25,
YieldQuoteDirection.Up,
SingleYieldPoints()))
.Select(Describe)
.ToArray();
Assert.Equal(
[
"V:subtitle=1개월", "V:unit=단위 : 원", "V:title=원/달러",
"V:date5=01/01", "V:date4=01/02", "V:date3=01/03",
"V:date2=01/04", "V:date1=01/05",
"V:price=0.00", "V:changePrice=12.34", "V:rate=1.25",
"S:up=0", "S:flat=0", "S:down=0", "S:up=1",
"C:bg:170:0:0:255",
"P:baseline:0:150:0:2",
"P:highTag:-647.5:0:0:1", "V:highPrice=100.00",
"P:lowTag:653.79:0:0:1", "V:lowPrice=0.00",
"P:minusGraph:-659.82:150:0:7",
"H:minusGraph=0,0,0|0,-0,0|333.25,-56.25,0|666.5,-112.5,0|999.75,-168.75,0|1333,-225,0|1333,0,0",
"P:plusGraph:-659.82:150:0:7",
"H:plusGraph=0,0,0|0,0,0|1333,0,0"
], actual);
}
[Fact]
public void S5086_missing_rows_apply_documented_forward_fill_but_first_missing_fails_closed()
{
var builder = new S5086SceneMutationBuilder();
var corrected = builder.Build(new S5086SceneData(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"),
[
new(new DateOnly(2026, 1, 1), 10),
new(new DateOnly(2026, 1, 2), null),
new(new DateOnly(2026, 1, 3), 20)
]));
AssertFinite(corrected);
Assert.Equal("V:rate=-50.00", Describe(corrected.OfType<PlayoutSetValue>()
.Single(item => item.ObjectName == "rate")));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5086SceneData(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"),
[new(new DateOnly(2026, 1, 1), null)])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5086SceneData(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"),
[new(new DateOnly(2026, 1, 1), double.NegativeInfinity)])));
}
[Fact]
public void Single_yield_zero_range_zero_reference_and_one_point_are_explicitly_finite()
{
var point = new YieldGraphPoint(new DateOnly(2026, 1, 1), 0);
var s5086 = new S5086SceneMutationBuilder().Build(new S5086SceneData(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.KosdaqIndex),
[point]));
var s50860 = new S50860SceneMutationBuilder().Build(new S50860SceneData(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.KosdaqIndex),
0,
0,
YieldQuoteDirection.Flat,
[point]));
AssertFinite(s5086);
AssertFinite(s50860);
Assert.Equal("V:rate=0.00", Describe(s5086.OfType<PlayoutSetValue>()
.Single(item => item.ObjectName == "rate")));
Assert.All(
s5086.Concat(s50860).OfType<PlayoutSetPosition>()
.Where(item => item.ObjectName == "baseline"),
position => Assert.Equal(-75, position.Y));
var signed = new S5086SceneMutationBuilder().Build(new S5086SceneData(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"),
[
new(new DateOnly(2026, 1, 1), -10),
new(new DateOnly(2026, 1, 2), 0),
new(new DateOnly(2026, 1, 3), 10)
]));
AssertFinite(signed);
}
[Fact]
public void S50860_rejects_missing_or_nonfinite_rows_and_quote_values()
{
var builder = new S50860SceneMutationBuilder();
var instrument = new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사");
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S50860SceneData(
YieldGraphPeriod.FiveDays,
instrument,
0,
0,
YieldQuoteDirection.Flat,
[new(new DateOnly(2026, 1, 1), null)])));
Assert.Throws<LegacySceneDataException>(() => builder.Build(new S50860SceneData(
YieldGraphPeriod.FiveDays,
instrument,
double.PositiveInfinity,
0,
YieldQuoteDirection.Flat,
[new(new DateOnly(2026, 1, 1), 1)])));
}
[Fact]
public void Closed_period_market_series_instrument_and_direction_mappings_reject_unknown_values()
{
Assert.Throws<LegacySceneDataException>(() =>
new S5026SceneMutationBuilder().Build(new S5026SceneData(
(ComparisonGraphPeriod)99,
[CandleSeries(ComparisonSeriesTarget.First), CandleSeries(ComparisonSeriesTarget.Second)])));
var badMarket = CandleSeries(ComparisonSeriesTarget.First) with
{
Quote = Quote(ComparisonSeriesTarget.First) with
{
Market = (ComparisonEquityMarket)99
}
};
Assert.Throws<LegacySceneDataException>(() =>
new S5026SceneMutationBuilder().Build(new S5026SceneData(
ComparisonGraphPeriod.FiveDays,
[badMarket, CandleSeries(ComparisonSeriesTarget.Second)])));
var badDirection = CandleSeries(ComparisonSeriesTarget.First) with
{
Quote = Quote(ComparisonSeriesTarget.First) with
{
Direction = (ComparisonChangeDirection)99
}
};
Assert.Throws<LegacySceneDataException>(() =>
new S5026SceneMutationBuilder().Build(new S5026SceneData(
ComparisonGraphPeriod.FiveDays,
[badDirection, CandleSeries(ComparisonSeriesTarget.Second)])));
var badSeries = YieldSeries(ComparisonSeriesTarget.First) with
{
Quote = Quote((ComparisonSeriesTarget)99)
};
Assert.Throws<LegacySceneDataException>(() =>
new S5029SceneMutationBuilder().Build(new S5029SceneData(
ComparisonGraphPeriod.FiveDays,
[badSeries, YieldSeries(ComparisonSeriesTarget.Second)])));
Assert.Throws<LegacySceneDataException>(() =>
new S5086SceneMutationBuilder().Build(new S5086SceneData(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument((YieldGraphInstrumentKind)99),
[new(new DateOnly(2026, 1, 1), 1)])));
Assert.Throws<LegacySceneDataException>(() =>
new S5086SceneMutationBuilder().Build(new S5086SceneData(
(YieldGraphPeriod)99,
new YieldGraphInstrument(YieldGraphInstrumentKind.KospiIndex),
[new(new DateOnly(2026, 1, 1), 1)])));
Assert.Throws<LegacySceneDataException>(() =>
new S50860SceneMutationBuilder().Build(new S50860SceneData(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.KospiIndex),
0,
0,
(YieldQuoteDirection)99,
[new(new DateOnly(2026, 1, 1), 1)])));
}
[Fact]
public void Builders_expose_closed_scene_keys()
{
Assert.Equal("s5026", new S5026SceneMutationBuilder().BuilderKey);
Assert.Equal("s5029", new S5029SceneMutationBuilder().BuilderKey);
Assert.Equal("s5086", new S5086SceneMutationBuilder().BuilderKey);
Assert.Equal("s50860", new S50860SceneMutationBuilder().BuilderKey);
Assert.Equal("s5087", new S5087SceneMutationBuilder().BuilderKey);
}
private static ComparisonCandleSeriesData CandleSeries(ComparisonSeriesTarget target) => new(
Quote(target),
[
new(new DateOnly(2026, 1, 1), 200, 100, 150, 180),
new(new DateOnly(2026, 1, 2), 190, 110, 180, 140),
new(new DateOnly(2026, 1, 3), 180, 120, 150, 150),
new(new DateOnly(2026, 1, 4), 170, 130, 140, 160),
new(new DateOnly(2026, 1, 5), 160, 140, 150, 145)
]);
private static ComparisonYieldSeriesData YieldSeries(ComparisonSeriesTarget target)
{
var values = target == ComparisonSeriesTarget.First
? new[] { 0, 25, 50, 75, 100 }
: new[] { 100, 75, 50, 25, 0 };
return new ComparisonYieldSeriesData(
Quote(target),
values.Select((value, index) => new ComparisonYieldPoint(
new DateOnly(2026, 1, index + 1),
value)).ToArray());
}
private static ComparisonQuoteData Quote(ComparisonSeriesTarget target) => new(
target,
target == ComparisonSeriesTarget.First
? ComparisonEquityMarket.Kospi
: ComparisonEquityMarket.Kosdaq,
target == ComparisonSeriesTarget.First ? "Alpha" : "Beta",
target == ComparisonSeriesTarget.First ? 1000 : 2000,
target == ComparisonSeriesTarget.First ? 10 : -20,
target == ComparisonSeriesTarget.First ? 1.25 : -2.5,
target == ComparisonSeriesTarget.First
? ComparisonChangeDirection.Up
: ComparisonChangeDirection.Down);
private static YieldGraphPoint[] SingleYieldPoints() =>
[
new(new DateOnly(2026, 1, 1), 0),
new(new DateOnly(2026, 1, 2), 25),
new(new DateOnly(2026, 1, 3), 50),
new(new DateOnly(2026, 1, 4), 75),
new(new DateOnly(2026, 1, 5), 100)
];
private static IEnumerable<string> ExpectedComparisonHeader(ComparisonHeaderStyle style)
{
yield return "V:title1=Alpha";
yield return "V:title2=Beta";
yield return "V:price1=1,000";
yield return "V:price2=2,000";
yield return "V:changePrice1=10";
yield return "V:changePrice2=-20";
yield return "V:rate1=1.25";
yield return "V:rate2=-2.50";
var orders = style switch
{
ComparisonHeaderStyle.S5026 => new[]
{
new[] { "up", "flat", "down" },
new[] { "up", "flat", "down" }
},
ComparisonHeaderStyle.S5029 => new[]
{
new[] { "upup", "up", "flat", "downdown", "down" },
new[] { "upup", "up", "flat", "downdown", "down" }
},
ComparisonHeaderStyle.S5087 => new[]
{
new[] { "up", "upup", "flat", "down", "downdown" },
new[] { "upup", "up", "flat", "down", "downdown" }
},
_ => throw new InvalidOperationException()
};
for (var series = 1; series <= 2; series++)
{
foreach (var name in orders[series - 1])
{
yield return $"S:{name}{series}=0";
}
}
foreach (var value in ExpectedDirection(1, "up", 1))
{
yield return value;
}
foreach (var value in ExpectedDirection(2, "down", 3))
{
yield return value;
}
}
private static IEnumerable<string> ExpectedDirection(int series, string active, int background)
{
foreach (var name in new[] { "upup", "up", "flat", "down", "downdown" })
{
yield return $"S:{name}{series}=0";
}
yield return $"S:{active}{series}=1";
for (var variant = 1; variant <= 3; variant++)
{
yield return $"S:bg{series}_{variant}={(variant == background ? 1 : 0)}";
}
}
private static IEnumerable<string> ExpectedCandleSeries(int series)
{
yield return $"V:date{series}_1=01/01";
yield return $"R:candlebar{series}_1:1:0:0:0:100:10";
yield return $"R:candlebody{series}_1:1:0:20:0:50:10";
yield return $"C:candlebody{series}_1:170:0:0:255";
yield return $"C:candlebar{series}_1:170:0:0:255";
yield return $"R:candlebar{series}_2:1:0:10:0:90:10";
yield return $"R:candlebody{series}_2:1:0:20:0:60:10";
yield return $"C:candlebody{series}_2:15:99:189:255";
yield return $"C:candlebar{series}_2:15:99:189:255";
yield return $"V:date{series}_3=01/03";
yield return $"R:candlebar{series}_3:1:0:20:0:80:10";
yield return $"R:candlebody{series}_3:1:0:49.9:0:53:10";
yield return $"C:candlebody{series}_3:95:95:95:255";
yield return $"C:candlebar{series}_3:95:95:95:255";
yield return $"R:candlebar{series}_4:1:0:30:0:70:10";
yield return $"R:candlebody{series}_4:1:0:40:0:60:10";
yield return $"C:candlebody{series}_4:170:0:0:255";
yield return $"C:candlebar{series}_4:170:0:0:255";
yield return $"V:date{series}_5=01/05";
yield return $"R:candlebar{series}_5:1:0:40:0:60:10";
yield return $"R:candlebody{series}_5:1:0:50:0:55:10";
yield return $"C:candlebody{series}_5:15:99:189:255";
yield return $"C:candlebar{series}_5:15:99:189:255";
}
private static IEnumerable<string> ExpectedYieldSeries(
int series,
float maximumX,
float baseline)
{
yield return $"V:date{series}_5=01/01";
yield return $"V:date{series}_4=01/02";
yield return $"V:date{series}_3=01/03";
yield return $"V:date{series}_2=01/04";
yield return $"V:date{series}_1=01/05";
yield return $"P:baseline{series}:0:{F(baseline)}:0:2";
var step = maximumX / 4f;
if (series == 1)
{
yield return $"P:highTag1:-383.48:0:0:1";
yield return "V:highPrice1=100";
yield return $"P:lowTag1:359.28:0:0:1";
yield return "V:lowPrice1=0";
yield return $"P:minusGraph1:0:{F(baseline)}:0:2";
yield return "H:minusGraph1=0,0,0|0,-0,0|" +
$"{F(step)},-37.5,0|{F(step * 2)},-75,0|" +
$"{F(step * 3)},-112.5,0|{F(maximumX)},-150,0|{F(maximumX)},0,0";
yield return $"P:plusGraph1:0:{F(baseline)}:0:2";
yield return $"H:plusGraph1=0,0,0|0,0,0|{F(maximumX)},0,0";
}
else
{
yield return "P:lowTag2:-383.48:0:0:1";
yield return "V:lowPrice2=0";
yield return "P:highTag2:359.28:0:0:1";
yield return "V:highPrice2=100";
yield return $"P:minusGraph2:0:{F(baseline)}:0:2";
yield return $"H:minusGraph2=0,0,0|0,-0,0|{F(maximumX)},0,0";
yield return $"P:plusGraph2:0:{F(baseline)}:0:2";
yield return "H:plusGraph2=0,0,0|0,0,0|" +
$"{F(step)},-37.5,0|{F(step * 2)},-75,0|" +
$"{F(step * 3)},-112.5,0|{F(maximumX)},-150,0|{F(maximumX)},0,0";
}
}
private static void AssertFinite(IEnumerable<PlayoutMutation> mutations)
{
foreach (var mutation in mutations)
{
switch (mutation)
{
case PlayoutSetPosition position:
Assert.True(float.IsFinite(position.X));
Assert.True(float.IsFinite(position.Y));
Assert.True(float.IsFinite(position.Z));
break;
case PlayoutSetCropKey crop:
Assert.True(float.IsFinite(crop.Left));
Assert.True(float.IsFinite(crop.Top));
Assert.True(float.IsFinite(crop.Right));
Assert.True(float.IsFinite(crop.Bottom));
break;
case PlayoutSetPathShapePoints path:
Assert.All(path.Points, point =>
{
Assert.True(float.IsFinite(point.X));
Assert.True(float.IsFinite(point.Y));
Assert.True(float.IsFinite(point.Z));
});
break;
case PlayoutSetValue value:
Assert.DoesNotContain("NaN", value.Value, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Infinity", value.Value, StringComparison.OrdinalIgnoreCase);
break;
}
}
}
private static string Describe(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
PlayoutSetFaceColor color =>
$"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}",
PlayoutSetPosition position =>
$"P:{position.ObjectName}:{F(position.X)}:{F(position.Y)}:{F(position.Z)}:{(int)position.Components}",
PlayoutSetCropKey crop =>
$"R:{crop.ObjectName}:{crop.KeyIndex}:{F(crop.Left)}:{F(crop.Top)}:{F(crop.Right)}:{F(crop.Bottom)}:{(int)crop.Edges}",
PlayoutSetPathShapePoints path => "H:" + path.ObjectName + "=" + string.Join(
"|",
path.Points.Select(point => $"{F(point.X)},{F(point.Y)},{F(point.Z)}")),
_ => mutation.GetType().Name
};
private static string F(float value) =>
value.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
private enum ComparisonHeaderStyle
{
S5026,
S5029,
S5087
}
}

View File

@@ -0,0 +1,728 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ComparisonAndYieldSceneDataLoadersTests
{
public static TheoryData<ComparisonGraphPeriod, int> ComparisonPeriods => new()
{
{ ComparisonGraphPeriod.Daily, 1 },
{ ComparisonGraphPeriod.FiveDays, 5 },
{ ComparisonGraphPeriod.OneMonth, 20 },
{ ComparisonGraphPeriod.ThreeMonths, 60 },
{ ComparisonGraphPeriod.SixMonths, 120 },
{ ComparisonGraphPeriod.TwelveMonths, 240 }
};
public static TheoryData<YieldGraphPeriod, int> YieldPeriods => new()
{
{ YieldGraphPeriod.FiveDays, 5 },
{ YieldGraphPeriod.TwentyDays, 20 },
{ YieldGraphPeriod.SixtyDays, 60 },
{ YieldGraphPeriod.OneHundredTwentyDays, 120 },
{ YieldGraphPeriod.TwoHundredFortyDays, 240 }
};
[Fact]
public async Task S5026_UsesTwoOrderedParameterizedOracleQueriesAndBuildsGoldenCandleProperties()
{
const string firstName = "First Sensitive Stock";
const string secondName = "Second Sensitive Stock";
var executor = new QueueExecutor(
CandleTable(firstName, 1000m, "2"),
CandleTable(secondName, 2000m, "5"));
var data = await new S5026SceneDataLoader(executor).LoadAsync(
PairRequest(
ComparisonGraphPeriod.FiveDays,
firstName,
ComparisonEquityMarket.Kospi,
secondName,
ComparisonEquityMarket.Kosdaq));
var mutations = new S5026SceneMutationBuilder().Build(data);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call => Assert.Equal(DataSourceKind.Oracle, call.Source));
Assert.Equal("SCENE_5026_SERIES_1", executor.Calls[0].TableName);
Assert.Equal("SCENE_5026_SERIES_2", executor.Calls[1].TableName);
Assert.Contains("FROM t_online1", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM t_kosdaq_online1", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(firstName, executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(secondName, executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.Equal(new[] { "stockName", "rowLimit" },
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Name));
Assert.Equal(new object?[] { firstName, 5 },
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value));
Assert.Equal(new[] { ComparisonSeriesTarget.First, ComparisonSeriesTarget.Second },
data.Series.Select(series => series.Quote.Series));
Assert.Equal(new[] { firstName, secondName },
data.Series.Select(series => series.Quote.StockName));
Assert.All(data.Series, series => Assert.Equal(5, series.Candles.Count));
Assert.Equal(ComparisonChangeDirection.Up, data.Series[0].Quote.Direction);
Assert.Equal(ComparisonChangeDirection.Down, data.Series[1].Quote.Direction);
Assert.Contains(new PlayoutSetValue("title1", firstName), mutations);
Assert.Contains(new PlayoutSetValue("title2", secondName), mutations);
Assert.Equal(20, mutations.OfType<PlayoutSetCropKey>().Count());
Assert.Equal(0, executor.StringCalls);
}
[Theory]
[MemberData(nameof(ComparisonPeriods))]
public async Task S5029_BindsEveryClosedPeriodLimitAndPreservesTwoSeriesOrder(
ComparisonGraphPeriod period,
int rowLimit)
{
var executor = new QueueExecutor(
ComparisonYieldTable("First", 1000m, rowLimit),
ComparisonYieldTable("Second", 2000m, rowLimit));
var data = await new S5029SceneDataLoader(executor).LoadAsync(
PairRequest(period, "First", ComparisonEquityMarket.Kospi,
"Second", ComparisonEquityMarket.Kosdaq));
var mutations = new S5029SceneMutationBuilder().Build(data);
Assert.Equal(new[] { ComparisonSeriesTarget.First, ComparisonSeriesTarget.Second },
data.Series.Select(series => series.Quote.Series));
Assert.All(data.Series, series => Assert.Equal(rowLimit, series.Points.Count));
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(rowLimit, call.Spec.Parameters.Single(p => p.Name == "rowLimit").Value);
Assert.Contains(":stockName", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(":rowLimit", call.Spec.Sql, StringComparison.Ordinal);
});
Assert.Contains("t_candle_history", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("t_kosdaq_candle_history", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("First", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("Second", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.Equal(4, mutations.OfType<PlayoutSetPathShapePoints>().Count());
AssertFinite(mutations);
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S5087_IsFiveDayOnlyAndUsesItsLoadedDtoForDistinctGraphMutations()
{
var validExecutor = new QueueExecutor(
ComparisonYieldTable("First", 1000m, 5),
ComparisonYieldTable("Second", 2000m, 5));
var request = PairRequest(
ComparisonGraphPeriod.FiveDays,
"First",
ComparisonEquityMarket.Kospi,
"Second",
ComparisonEquityMarket.Kosdaq);
var data = await new S5087SceneDataLoader(validExecutor).LoadAsync(request);
var mutations = new S5087SceneMutationBuilder().Build(data);
Assert.Equal(2, validExecutor.Calls.Count);
Assert.Contains(
mutations.OfType<PlayoutSetPathShapePoints>(),
item => item.ObjectName == "plusGraph1" && item.Points[^1].X == 700f);
AssertFinite(mutations);
var invalidExecutor = new QueueExecutor(ComparisonYieldTable("unused", 1m, 5));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5087SceneDataLoader(invalidExecutor).LoadAsync(
request with { Period = ComparisonGraphPeriod.OneMonth }));
Assert.Empty(invalidExecutor.Calls);
}
[Fact]
public async Task ComparisonLoadersFailClosedForBadSchemaCountOrderQuoteAndValues()
{
var extraColumn = CandleTable("First", 1000m, "2");
extraColumn.Columns.Add("EXTRA", typeof(object));
var wrongOrder = CandleTable("First", 1000m, "2");
wrongOrder.Rows[1]["OPEN_DAY"] = wrongOrder.Rows[0]["OPEN_DAY"];
var changedQuote = CandleTable("First", 1000m, "2");
changedQuote.Rows[1]["CURRENT_PRICE"] = 9999m;
var nonFinite = CandleTable("First", 1000m, "2");
nonFinite.Rows[0]["HIGH_VALUE"] = double.NaN;
var badDirection = CandleTable("First", 1000m, "sideways");
foreach (var invalid in new[]
{
CandleTable("First", 1000m, "2", rowCount: 4),
extraColumn,
wrongOrder,
changedQuote,
nonFinite,
badDirection
})
{
var executor = new QueueExecutor(invalid, CandleTable("Second", 2000m, "2"));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5026SceneDataLoader(executor).LoadAsync(
PairRequest(
ComparisonGraphPeriod.FiveDays,
"First",
ComparisonEquityMarket.Kospi,
"Second",
ComparisonEquityMarket.Kosdaq)));
}
}
[Fact]
public async Task ComparisonYieldRejectsFractionalIntegerPointsAndDescendingOrderViolations()
{
var fractional = ComparisonYieldTable("First", 1000m, 5);
fractional.Rows[0]["VALUE"] = 1000.5m;
var duplicateDate = ComparisonYieldTable("First", 1000m, 5);
duplicateDate.Rows[1]["DATA_DAY"] = duplicateDate.Rows[0]["DATA_DAY"];
foreach (var invalid in new[]
{
ComparisonYieldTable("First", 1000m, 4),
fractional,
duplicateDate
})
{
var executor = new QueueExecutor(invalid, ComparisonYieldTable("Second", 2000m, 5));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5029SceneDataLoader(executor).LoadAsync(
PairRequest(
ComparisonGraphPeriod.FiveDays,
"First",
ComparisonEquityMarket.Kospi,
"Second",
ComparisonEquityMarket.Kosdaq)));
}
}
[Theory]
[InlineData(YieldGraphInstrumentKind.WonDollar, null, "t_week_index", "A09")]
[InlineData(YieldGraphInstrumentKind.WonYen, null, "t_week_index", "A10")]
[InlineData(YieldGraphInstrumentKind.WonYuan, null, "t_week_index", "A90")]
[InlineData(YieldGraphInstrumentKind.WonEuro, null, "t_week_index", "A91")]
[InlineData(YieldGraphInstrumentKind.KospiIndex, null, "t_index_his_day", null)]
[InlineData(YieldGraphInstrumentKind.KosdaqIndex, null, "t_kosdaq_index_his_day", null)]
[InlineData(YieldGraphInstrumentKind.Kospi200Index, null, "t_200_index_his_day", null)]
[InlineData(YieldGraphInstrumentKind.Krx100Index, null, "t_krx100_index_his_day", null)]
[InlineData(YieldGraphInstrumentKind.KospiIndustry, "Semiconductor", "t_part", null)]
[InlineData(YieldGraphInstrumentKind.KosdaqIndustry, "Software", "t_kosdaq_part", null)]
[InlineData(YieldGraphInstrumentKind.KospiStock, "Stock A", "t_candle_history", null)]
[InlineData(YieldGraphInstrumentKind.KosdaqStock, "Stock B", "t_kosdaq_candle_history", null)]
public async Task S5086_EveryClosedInstrumentUsesAParameterizedOracleQueryAndBuildsMutations(
YieldGraphInstrumentKind kind,
string? name,
string queryEvidence,
string? inputCode)
{
var executor = new QueueExecutor(SingleYieldTable(5, _ => 100d));
var instrument = new YieldGraphInstrument(kind, name);
var data = await new S5086SceneDataLoader(executor).LoadAsync(
new YieldSceneLoadRequest(YieldGraphPeriod.FiveDays, instrument));
var mutations = new S5086SceneMutationBuilder().Build(data);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("SCENE_5086_YIELD", call.TableName);
Assert.Contains(queryEvidence, call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(":rowLimit", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(5, call.Spec.Parameters.Single(parameter => parameter.Name == "rowLimit").Value);
if (name is not null)
{
Assert.DoesNotContain(name, call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(name, call.Spec.Parameters.Single(
parameter => parameter.Name == "instrumentName").Value);
}
if (inputCode is not null)
{
Assert.DoesNotContain(inputCode, call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(inputCode, call.Spec.Parameters.Single(
parameter => parameter.Name == "inputCode").Value);
}
if (kind is YieldGraphInstrumentKind.KospiStock or YieldGraphInstrumentKind.KosdaqStock)
{
Assert.Contains("LEFT JOIN STOCK_HISTORY", call.Spec.Sql, StringComparison.Ordinal);
}
if (kind is YieldGraphInstrumentKind.KospiIndustry or YieldGraphInstrumentKind.KosdaqIndustry)
{
Assert.Contains("open_day || '1535'", call.Spec.Sql, StringComparison.Ordinal);
}
Assert.Same(instrument, data.Instrument);
Assert.Equal(5, data.Points.Count);
Assert.Contains(mutations.OfType<PlayoutSetValue>(), item => item.ObjectName == "price");
Assert.Equal(2, mutations.OfType<PlayoutSetPathShapePoints>().Count());
AssertFinite(mutations);
Assert.Equal(0, executor.StringCalls);
}
[Theory]
[MemberData(nameof(YieldPeriods))]
public async Task S5086_BindsExactFiveTwentySixtyOneTwentyTwoFortyLimits(
YieldGraphPeriod period,
int rowLimit)
{
var executor = new QueueExecutor(SingleYieldTable(rowLimit, _ => 25d));
var data = await new S5086SceneDataLoader(executor).LoadAsync(
new YieldSceneLoadRequest(
period,
new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)));
var mutations = new S5086SceneMutationBuilder().Build(data);
Assert.Equal(rowLimit, data.Points.Count);
var call = Assert.Single(executor.Calls);
Assert.Equal(rowLimit, call.Spec.Parameters.Single(
parameter => parameter.Name == "rowLimit").Value);
AssertFinite(mutations);
}
[Fact]
public async Task S5086_LaterMissingValuesReachDocumentedForwardFillAndEqualRangeStaysFinite()
{
var values = new double?[] { 100d, null, 100d, 100d, 100d };
var executor = new QueueExecutor(SingleYieldTable(5, index => values[index]));
var data = await new S5086SceneDataLoader(executor).LoadAsync(
new YieldSceneLoadRequest(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "Stock")));
var mutations = new S5086SceneMutationBuilder().Build(data);
Assert.Null(data.Points[1].Value);
AssertFinite(mutations);
Assert.All(
mutations.OfType<PlayoutSetPathShapePoints>().SelectMany(item => item.Points),
point => Assert.Equal(0f, point.Y));
}
[Fact]
public async Task S5086_FailsClosedForFirstMissingNonFiniteWrongOrderAndUnexpectedSchema()
{
var firstMissing = SingleYieldTable(5, index => index == 0 ? null : 1d);
var nonFinite = SingleYieldTable(5, index => index == 2 ? double.PositiveInfinity : 1d);
var wrongOrder = SingleYieldTable(5, _ => 1d);
wrongOrder.Rows[1]["DATA_DAY"] = wrongOrder.Rows[0]["DATA_DAY"];
var extraColumn = SingleYieldTable(5, _ => 1d);
extraColumn.Columns.Add("EXTRA", typeof(object));
foreach (var invalid in new[]
{
SingleYieldTable(4, _ => 1d),
firstMissing,
nonFinite,
wrongOrder,
extraColumn
})
{
var executor = new QueueExecutor(invalid);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5086SceneDataLoader(executor).LoadAsync(
new YieldSceneLoadRequest(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar))));
}
}
[Theory]
[InlineData(YieldGraphInstrumentKind.WonDollar, "A09", "+", YieldQuoteDirection.Up, "up")]
[InlineData(YieldGraphInstrumentKind.WonYen, "A10", "", YieldQuoteDirection.Flat, "flat")]
[InlineData(YieldGraphInstrumentKind.WonYuan, "A90", "-", YieldQuoteDirection.Down, "down")]
[InlineData(YieldGraphInstrumentKind.WonEuro, "A91", "2", YieldQuoteDirection.Up, "up")]
public async Task S50860_MapsOnlySupportedFxQuoteShapeAndGoldenDirectionMutations(
YieldGraphInstrumentKind kind,
string inputCode,
string sourceDirection,
YieldQuoteDirection expectedDirection,
string activeObject)
{
var table = QuotedFxTable(
5,
changePrice: -12.34d,
rate: 1.25d,
direction: sourceDirection);
if (sourceDirection.Length == 0)
{
table.Rows[0]["DIRECTION"] = DBNull.Value;
}
var executor = new QueueExecutor(table);
var data = await new S50860SceneDataLoader(executor).LoadAsync(
new YieldSceneLoadRequest(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(kind)));
var mutations = new S50860SceneMutationBuilder().Build(data);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("SCENE_50860_YIELD", call.TableName);
Assert.DoesNotContain(inputCode, call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(inputCode, call.Spec.Parameters.Single(
parameter => parameter.Name == "inputCode").Value);
Assert.Equal(5, call.Spec.Parameters.Single(
parameter => parameter.Name == "rowLimit").Value);
Assert.Equal(expectedDirection, data.Direction);
Assert.Equal(-12.34d, data.ChangePrice);
Assert.Equal(1.25d, data.Rate);
Assert.Contains(new PlayoutSetValue("changePrice", "12.34"), mutations);
Assert.Contains(new PlayoutSetVisible(activeObject, true), mutations);
AssertFinite(mutations);
Assert.Equal(0, executor.StringCalls);
}
[Theory]
[MemberData(nameof(YieldPeriods))]
public async Task S50860_BindsTheSameClosedPeriodLimitsAsItsFxLineSource(
YieldGraphPeriod period,
int rowLimit)
{
var executor = new QueueExecutor(QuotedFxTable(rowLimit, 1d, 0.1d, "+"));
var data = await new S50860SceneDataLoader(executor).LoadAsync(
new YieldSceneLoadRequest(
period,
new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)));
Assert.Equal(rowLimit, data.Points.Count);
Assert.Equal(rowLimit, Assert.Single(executor.Calls).Spec.Parameters.Single(
parameter => parameter.Name == "rowLimit").Value);
}
[Fact]
public async Task S50860_RejectsSourceBranchesWhoseLegacyCellShapeCannotSupplyQuoteMetadata()
{
var unsupported = Enum.GetValues<YieldGraphInstrumentKind>()
.Where(kind => kind is not (
YieldGraphInstrumentKind.WonDollar or
YieldGraphInstrumentKind.WonYen or
YieldGraphInstrumentKind.WonYuan or
YieldGraphInstrumentKind.WonEuro));
foreach (var kind in unsupported)
{
var name = kind is
YieldGraphInstrumentKind.KospiIndustry or
YieldGraphInstrumentKind.KosdaqIndustry or
YieldGraphInstrumentKind.KospiStock or
YieldGraphInstrumentKind.KosdaqStock
? "Named"
: null;
var executor = new QueueExecutor(QuotedFxTable(5, 1d, 1d, "+"));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S50860SceneDataLoader(executor).LoadAsync(
new YieldSceneLoadRequest(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(kind, name))));
Assert.Empty(executor.Calls);
}
}
[Fact]
public async Task S50860_LoadedZeroRangeAndZeroQuoteStayFinite()
{
var table = QuotedFxTable(5, 0d, 0d, "");
foreach (DataRow row in table.Rows)
{
row["VALUE"] = 0d;
}
table.Rows[0]["DIRECTION"] = DBNull.Value;
var executor = new QueueExecutor(table);
var data = await new S50860SceneDataLoader(executor).LoadAsync(
new YieldSceneLoadRequest(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)));
var mutations = new S50860SceneMutationBuilder().Build(data);
Assert.Equal(YieldQuoteDirection.Flat, data.Direction);
AssertFinite(mutations);
Assert.All(
mutations.OfType<PlayoutSetPathShapePoints>().SelectMany(item => item.Points),
point => Assert.Equal(0f, point.Y));
}
[Fact]
public async Task S50860_FailsClosedForBadCountSchemaDateValueQuoteAndDirection()
{
var extraColumn = QuotedFxTable(5, 1d, 1d, "+");
extraColumn.Columns.Add("EXTRA", typeof(object));
var duplicateDate = QuotedFxTable(5, 1d, 1d, "+");
duplicateDate.Rows[1]["DATA_DAY"] = duplicateDate.Rows[0]["DATA_DAY"];
var nullValue = QuotedFxTable(5, 1d, 1d, "+");
nullValue.Rows[2]["VALUE"] = DBNull.Value;
var nonFiniteChange = QuotedFxTable(5, 1d, 1d, "+");
nonFiniteChange.Rows[0]["CHANGE_PRICE"] = double.NaN;
var nonFiniteRate = QuotedFxTable(5, 1d, 1d, "+");
nonFiniteRate.Rows[0]["RATE"] = double.PositiveInfinity;
var badDirection = QuotedFxTable(5, 1d, 1d, "sideways");
foreach (var invalid in new[]
{
QuotedFxTable(4, 1d, 1d, "+"),
extraColumn,
duplicateDate,
nullValue,
nonFiniteChange,
nonFiniteRate,
badDirection
})
{
var executor = new QueueExecutor(invalid);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S50860SceneDataLoader(executor).LoadAsync(
new YieldSceneLoadRequest(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar))));
}
}
[Fact]
public async Task InvalidClosedRequestsFailBeforeExecutingSql()
{
var invalidPairRequests = new[]
{
PairRequest(
(ComparisonGraphPeriod)99,
"First",
ComparisonEquityMarket.Kospi,
"Second",
ComparisonEquityMarket.Kosdaq),
PairRequest(
ComparisonGraphPeriod.FiveDays,
"First",
(ComparisonEquityMarket)99,
"Second",
ComparisonEquityMarket.Kosdaq),
new ComparisonPairSceneLoadRequest(
ComparisonGraphPeriod.FiveDays,
null!,
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second"))
};
foreach (var request in invalidPairRequests)
{
var executor = new QueueExecutor(ComparisonYieldTable("unused", 1m, 5));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5029SceneDataLoader(executor).LoadAsync(request));
Assert.Empty(executor.Calls);
}
var invalidYieldRequests = new[]
{
new YieldSceneLoadRequest(
(YieldGraphPeriod)99,
new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)),
new YieldSceneLoadRequest(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument((YieldGraphInstrumentKind)99)),
new YieldSceneLoadRequest(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock)),
new YieldSceneLoadRequest(
YieldGraphPeriod.FiveDays,
new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar, "ignored"))
};
foreach (var request in invalidYieldRequests)
{
var executor = new QueueExecutor(SingleYieldTable(5, _ => 1d));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5086SceneDataLoader(executor).LoadAsync(request));
Assert.Empty(executor.Calls);
}
}
private static ComparisonPairSceneLoadRequest PairRequest(
ComparisonGraphPeriod period,
string firstName,
ComparisonEquityMarket firstMarket,
string secondName,
ComparisonEquityMarket secondMarket) => new(
period,
new ComparisonStockSelection(firstMarket, firstName),
new ComparisonStockSelection(secondMarket, secondName));
private static DataTable CandleTable(
string name,
decimal currentPrice,
string direction,
int rowCount = 5)
{
var table = Table(
"STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION",
"OPEN_DAY", "HIGH_VALUE", "LOW_VALUE", "OPEN_VALUE", "CLOSE_VALUE");
var start = new DateOnly(2026, 1, 1);
for (var index = 0; index < rowCount; index++)
{
var center = currentPrice + index;
table.Rows.Add(
name,
currentPrice,
10m,
direction is "4" or "5" ? -1.25d : 1.25d,
direction,
start.AddDays(index).ToString("yyyyMMdd"),
center + 10m,
center - 10m,
center - 5m,
center + 5m);
}
return table;
}
private static DataTable ComparisonYieldTable(
string name,
decimal currentPrice,
int rowCount)
{
var table = Table(
"STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION",
"DATA_DAY", "VALUE");
var latest = new DateOnly(2026, 12, 31);
for (var index = 0; index < rowCount; index++)
{
table.Rows.Add(
name,
currentPrice,
10m,
1.25d,
"2",
latest.AddDays(-index).ToString("yyyyMMdd"),
checked((int)currentPrice) - index);
}
return table;
}
private static DataTable SingleYieldTable(
int rowCount,
Func<int, double?> valueFactory)
{
var table = Table("DATA_DAY", "VALUE");
var latest = new DateOnly(2026, 12, 31);
for (var index = 0; index < rowCount; index++)
{
table.Rows.Add(
latest.AddDays(-index).ToString("yyyyMMdd"),
valueFactory(index) is { } value ? value : DBNull.Value);
}
return table;
}
private static DataTable QuotedFxTable(
int rowCount,
double changePrice,
double rate,
string direction)
{
var table = Table("DATA_DAY", "VALUE", "CHANGE_PRICE", "RATE", "DIRECTION");
var latest = new DateOnly(2026, 12, 31);
for (var index = 0; index < rowCount; index++)
{
table.Rows.Add(
latest.AddDays(-index).ToString("yyyyMMdd"),
1300d - index,
changePrice,
rate,
direction);
}
return table;
}
private static DataTable Table(params string[] columns)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(object));
}
return table;
}
private static void AssertFinite(IEnumerable<PlayoutMutation> mutations)
{
foreach (var position in mutations.OfType<PlayoutSetPosition>())
{
Assert.True(float.IsFinite(position.X));
Assert.True(float.IsFinite(position.Y));
Assert.True(float.IsFinite(position.Z));
}
foreach (var crop in mutations.OfType<PlayoutSetCropKey>())
{
Assert.True(float.IsFinite(crop.Left));
Assert.True(float.IsFinite(crop.Top));
Assert.True(float.IsFinite(crop.Right));
Assert.True(float.IsFinite(crop.Bottom));
}
foreach (var point in mutations
.OfType<PlayoutSetPathShapePoints>()
.SelectMany(item => item.Points))
{
Assert.True(float.IsFinite(point.X));
Assert.True(float.IsFinite(point.Y));
Assert.True(float.IsFinite(point.Z));
}
}
private sealed record QueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class QueueExecutor : IDataQueryExecutor
{
private readonly Queue<DataTable> _results;
public QueueExecutor(params DataTable[] results)
{
_results = new Queue<DataTable>(results);
}
public List<QueryCall> Calls { get; } = [];
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new QueryCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("The test did not queue a result.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
}

View File

@@ -0,0 +1,236 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class DataQuerySpecTests
{
[Fact]
public void Constructor_ValidatesAndOrdersParametersBySqlPlaceholder()
{
var code = new DataQueryParameter("code", "005930", DbType.String);
var enabled = new DataQueryParameter("enabled", true, DbType.Boolean);
var query = new DataQuerySpec(
"SELECT name FROM stocks WHERE enabled = :enabled AND code = :code",
[code, enabled]);
Assert.Equal([enabled, code], query.Parameters);
Assert.Equal("Parameterized read query", query.ToString());
Assert.Equal("Data query parameter", code.ToString());
query.ValidateFor(DataSourceKind.Oracle);
}
[Fact]
public void Constructor_AllowsMarkersInsideQuotedTextAndComments()
{
var query = new DataQuerySpec(
"""
-- @ignored
SELECT ':also_ignored', "@quoted_identifier", value
FROM sample /* :ignored_again */
WHERE code = :code
""",
[new DataQueryParameter("code", 7, DbType.Int32)]);
query.ValidateFor(DataSourceKind.Oracle);
Assert.Single(query.Parameters);
}
[Fact]
public void Constructor_AllowsSemicolonsAndKeywordsOnlyWhenTheyAreQuotedOrCommented()
{
var query = new DataQuerySpec(
"""
SELECT 'it''s; UPDATE hidden', value /* ; DELETE hidden */
FROM sample
WHERE code = :code -- ; REPLACE hidden
""",
[new DataQueryParameter("code", 7, DbType.Int32)]);
query.ValidateFor(DataSourceKind.Oracle);
Assert.Single(query.Parameters);
}
[Theory]
[InlineData("")]
[InlineData("has-dash")]
[InlineData("@prefixed")]
[InlineData(":prefixed")]
[InlineData("한글")]
[InlineData("9starts_with_digit")]
public void Parameter_RejectsUnsafeNameWithoutEchoingIt(string unsafeName)
{
var exception = Assert.Throws<ArgumentException>(
() => new DataQueryParameter(unsafeName, "value-sensitive-sentinel"));
if (unsafeName.Length > 0)
{
Assert.DoesNotContain(unsafeName, exception.Message, StringComparison.Ordinal);
}
Assert.DoesNotContain("value-sensitive-sentinel", exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("SELECT value INTO OUTFILE '/tmp/x' FROM sample")]
[InlineData("SELECT value FROM sample;")]
[InlineData("SELECT 1--1 INTO OUTFILE '/tmp/x'")]
[InlineData("SELECT 1--\u0085 INTO OUTFILE '/tmp/x'")]
[InlineData("SELECT 1 /*! INTO OUTFILE '/tmp/x' */")]
[InlineData("SELECT 1 /*M! INTO OUTFILE '/tmp/x' */")]
[InlineData("SELECT q'[; DELETE FROM sample]' FROM dual")]
[InlineData("SELECT 'c:\\temp' FROM dual")]
[InlineData("SELECT 1\u200B")]
[InlineData("SELECT 1\v")]
[InlineData("SELECT ?")]
[InlineData("SELECT 1 # provider-specific comment")]
[InlineData("WITH value AS (SELECT 1) REPLACE INTO sample VALUES (1)")]
[InlineData("WITH FUNCTION f RETURN NUMBER IS BEGIN RETURN 1; END; SELECT f FROM dual")]
[InlineData("SELECT 'unterminated FROM sample")]
[InlineData("SELECT value FROM sample /* unterminated")]
public void Constructor_RejectsUnsafeSqlWithoutEchoingIt(string unsafeSql)
{
var exception = Assert.Throws<ArgumentException>(
() => new DataQuerySpec(unsafeSql));
Assert.DoesNotContain(unsafeSql, exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("UPDATE sample SET value = :code")]
[InlineData("SELECT value FROM sample WHERE code = :missing")]
[InlineData("SELECT value FROM sample WHERE code = :code OR parent = :code")]
[InlineData("SELECT value FROM sample WHERE code = :code$tail")]
[InlineData("SELECT value FROM sample WHERE code = prefix:code")]
[InlineData("SELECT value FROM sample WHERE code = :code\u0301")]
[InlineData("SELECT value FROM sample WHERE code = :code\u00A0")]
public void Constructor_RejectsUnsafeParameterizedSqlWithoutEchoingSecrets(string unsafeSql)
{
const string parameterName = "code";
const string parameterValue = "value-sensitive-sentinel";
var exception = Assert.Throws<ArgumentException>(
() => new DataQuerySpec(
unsafeSql,
[new DataQueryParameter(parameterName, parameterValue)]));
Assert.DoesNotContain(parameterName, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(parameterValue, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(unsafeSql, exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Constructor_RejectsDuplicateSuppliedNamesCaseInsensitivelyWithoutEchoingValues()
{
const string firstValue = "first-value-sensitive-sentinel";
const string secondValue = "second-value-sensitive-sentinel";
var exception = Assert.Throws<ArgumentException>(
() => new DataQuerySpec(
"SELECT value FROM sample WHERE code = :code",
[
new DataQueryParameter("code", firstValue),
new DataQueryParameter("CODE", secondValue)
]));
Assert.DoesNotContain(firstValue, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(secondValue, exception.Message, StringComparison.Ordinal);
}
[Fact]
public void ValidateFor_RejectsMixedProviderMarkers()
{
var query = new DataQuerySpec(
"SELECT value FROM sample WHERE first = :first AND second = @second",
[
new DataQueryParameter("first", 1),
new DataQueryParameter("second", 2)
]);
Assert.Throws<ArgumentException>(() => query.ValidateFor(DataSourceKind.Oracle));
Assert.Throws<ArgumentException>(() => query.ValidateFor(DataSourceKind.MariaDb));
}
[Fact]
public void ValidateFor_RejectsWrongProviderMarkerWithoutExposingParameterName()
{
const string sensitiveName = "account_secret_name";
var query = new DataQuerySpec(
$"SELECT value FROM sample WHERE account = :{sensitiveName}",
[new DataQueryParameter(sensitiveName, "value-sensitive-sentinel")]);
var exception = Assert.Throws<ArgumentException>(
() => query.ValidateFor(DataSourceKind.MariaDb));
Assert.DoesNotContain(sensitiveName, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain("value-sensitive-sentinel", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task LegacyExecutor_DefaultSpecOverload_ForwardsEmptySpecAsynchronously()
{
var executor = new LegacyOnlyExecutor();
var query = new DataQuerySpec("SELECT 1");
using var cancellation = new CancellationTokenSource();
await ((IDataQueryExecutor)executor).ExecuteAsync(
DataSourceKind.MariaDb,
"Health",
query,
cancellation.Token);
Assert.Equal(0, executor.SynchronousCallCount);
Assert.Equal(1, executor.AsynchronousCallCount);
Assert.Equal("SELECT 1", executor.ObservedSql);
Assert.Equal(cancellation.Token, executor.ObservedCancellationToken);
}
[Fact]
public async Task LegacyExecutor_DefaultSpecOverload_FailsClosedForParameters()
{
var executor = new LegacyOnlyExecutor();
var query = new DataQuerySpec(
"SELECT value FROM sample WHERE code = @code",
[new DataQueryParameter("code", "value-sensitive-sentinel")]);
var exception = await Assert.ThrowsAsync<NotSupportedException>(
() => ((IDataQueryExecutor)executor).ExecuteAsync(
DataSourceKind.MariaDb,
"Sample",
query));
Assert.Equal(0, executor.SynchronousCallCount);
Assert.Equal(0, executor.AsynchronousCallCount);
Assert.DoesNotContain("code", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("value-sensitive-sentinel", exception.Message, StringComparison.Ordinal);
}
private sealed class LegacyOnlyExecutor : IDataQueryExecutor
{
public int SynchronousCallCount { get; private set; }
public int AsynchronousCallCount { get; private set; }
public string? ObservedSql { get; private set; }
public CancellationToken ObservedCancellationToken { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
SynchronousCallCount++;
throw new InvalidOperationException("The synchronous path must not be used.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
AsynchronousCallCount++;
ObservedSql = query;
ObservedCancellationToken = cancellationToken;
return Task.FromResult(new DataTable());
}
}
}

View File

@@ -0,0 +1,286 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class EquityPanelSceneDataLoadersTests
{
private static readonly string[] S5006Columns =
[
"STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE",
"OPEN_PRICE", "OPEN_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE"
];
private static readonly string[] S5011Columns =
[
"STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE",
"DETAIL_1", "DETAIL_2", "DETAIL_3", "DETAIL_4"
];
public static TheoryData<LegacyDomesticEquityMarket, string> Domestic5006Cases => new()
{
{ LegacyDomesticEquityMarket.Kospi, "FROM V_2 a" },
{ LegacyDomesticEquityMarket.Kosdaq, "FROM V_2_KOSDAQ a" }
};
public static TheoryData<S5011RouteCase> S5011Cases => new()
{
new(LegacyDomesticEquityMarket.Kospi, S5011DetailBranch.FaceValue,
DataSourceKind.Oracle, "FROM V_2 a", "t_online1"),
new(LegacyDomesticEquityMarket.Kosdaq, S5011DetailBranch.FaceValue,
DataSourceKind.Oracle, "FROM V_2_KOSDAQ a", "t_kosdaq_online1"),
new(LegacyDomesticEquityMarket.Kospi, S5011DetailBranch.Valuation,
DataSourceKind.Oracle, "FROM V_2 a", "a.pbr DETAIL_1"),
new(LegacyDomesticEquityMarket.Kosdaq, S5011DetailBranch.Valuation,
DataSourceKind.Oracle, "FROM V_2_KOSDAQ a", "a.pbr DETAIL_1"),
new(LegacyDomesticEquityMarket.Kospi, S5011DetailBranch.Volume,
DataSourceKind.Oracle, "FROM V_2 a", "T_CANDLE_HISTORY"),
new(LegacyDomesticEquityMarket.Kosdaq, S5011DetailBranch.Volume,
DataSourceKind.Oracle, "FROM V_2_KOSDAQ a", "T_KOSDAQ_CANDLE_HISTORY"),
new(LegacyDomesticEquityMarket.NxtKospi, S5011DetailBranch.FaceValue,
DataSourceKind.MariaDb, "FROM n_online a", "RANK() OVER"),
new(LegacyDomesticEquityMarket.NxtKosdaq, S5011DetailBranch.FaceValue,
DataSourceKind.MariaDb, "FROM n_kosdaq_online a", "RANK() OVER"),
new(LegacyDomesticEquityMarket.NxtKospi, S5011DetailBranch.Volume,
DataSourceKind.MariaDb, "FROM n_online a", "0 DETAIL_3"),
new(LegacyDomesticEquityMarket.NxtKosdaq, S5011DetailBranch.Volume,
DataSourceKind.MariaDb, "FROM n_kosdaq_online a", "0 DETAIL_3")
};
[Theory]
[MemberData(nameof(Domestic5006Cases))]
public async Task S5006_domestic_routes_oracle_by_closed_market_and_generates_mutations(
LegacyDomesticEquityMarket market,
string sourceFragment)
{
const string stockCode = "A005930";
var executor = new RecordingExecutor(S5006Table());
var data = await new S5006DomesticSceneDataLoader(executor).LoadAsync(
new S5006DomesticSceneDataRequest(market, stockCode),
CancellationToken.None);
var mutations = new S5006SceneMutationBuilder().Build(data);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("S5006_DOMESTIC", call.TableName);
Assert.Contains(sourceFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(stockCode, call.Spec.Sql, StringComparison.Ordinal);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("stockCode", parameter.Name);
Assert.Equal(stockCode, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
Assert.Equal(ScenePriceDirection.Up, data.Direction);
Assert.Equal(1000, data.CurrentPrice);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "price", Value: "1,000" });
Assert.Equal(0, executor.StringCalls);
}
[Theory]
[MemberData(nameof(S5011Cases))]
public async Task S5011_supported_source_and_branch_matrix_is_parameterized_and_typed(
S5011RouteCase item)
{
const string stockCode = "A123456";
var executor = new RecordingExecutor(S5011Table(item.Branch));
var data = await new S5011SceneDataLoader(executor).LoadAsync(
new S5011SceneDataRequest(item.Market, item.Branch, stockCode),
CancellationToken.None);
var call = Assert.Single(executor.Calls);
Assert.Equal(item.Source, call.Source);
Assert.Equal("S5011_DETAIL", call.TableName);
call.Spec.ValidateFor(item.Source);
Assert.Contains(item.SourceFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(item.BranchFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(stockCode, call.Spec.Sql, StringComparison.Ordinal);
Assert.All(call.Spec.Parameters, parameter =>
{
Assert.Equal(stockCode, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
});
Assert.Equal(item.Branch, data.Detail.Branch);
Assert.Equal(item.Market, data.Market);
Assert.Equal(0, executor.StringCalls);
var mutations = new S5011SceneMutationBuilder().Build(data);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetVisible
{
ObjectName: "mart",
IsVisible: var visible
} && visible == item.Market.ToString().StartsWith("Nxt", StringComparison.Ordinal));
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "r_value4" });
if (item.Branch == S5011DetailBranch.Volume && item.Source == DataSourceKind.Oracle)
{
Assert.Equal(
["stockCode5", "stockCode20", "stockCode"],
call.Spec.Parameters.Select(parameter => parameter.Name));
}
else
{
Assert.Single(call.Spec.Parameters);
}
}
[Theory]
[InlineData(LegacyDomesticEquityMarket.NxtKospi)]
[InlineData(LegacyDomesticEquityMarket.NxtKosdaq)]
public async Task S5011_nxt_valuation_fails_closed_instead_of_fabricating_zeroes(
LegacyDomesticEquityMarket market)
{
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5011SceneDataLoader(executor).LoadAsync(
new S5011SceneDataRequest(
market,
S5011DetailBranch.Valuation,
"005930"),
CancellationToken.None));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData(LegacyDomesticEquityMarket.NxtKospi, "005930")]
[InlineData(LegacyDomesticEquityMarket.NxtKosdaq, "005930")]
[InlineData(LegacyDomesticEquityMarket.Kospi, "bad-code")]
[InlineData(LegacyDomesticEquityMarket.Kosdaq, "")]
public async Task S5006_invalid_market_or_code_fails_before_query(
LegacyDomesticEquityMarket market,
string code)
{
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5006DomesticSceneDataLoader(executor).LoadAsync(
new S5006DomesticSceneDataRequest(market, code),
CancellationToken.None));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task Equity_loaders_require_exact_single_row_schema_and_do_not_leak_code()
{
const string stockCode = "SENSITIVE123";
var reordered = Table(
[
"DIRECTION_CODE", "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE",
"OPEN_PRICE", "OPEN_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE"
],
["2", "Stock", 1000, 10, 1, 900, -1, 1100, 10, 800, -20]);
var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5006DomesticSceneDataLoader(new RecordingExecutor(reordered)).LoadAsync(
new S5006DomesticSceneDataRequest(
LegacyDomesticEquityMarket.Kospi,
stockCode),
CancellationToken.None));
Assert.DoesNotContain(stockCode, exception.Message, StringComparison.Ordinal);
var duplicate = S5011Table(S5011DetailBranch.FaceValue);
duplicate.Rows.Add(duplicate.Rows[0].ItemArray);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5011SceneDataLoader(new RecordingExecutor(duplicate)).LoadAsync(
new S5011SceneDataRequest(
LegacyDomesticEquityMarket.Kospi,
S5011DetailBranch.FaceValue,
stockCode),
CancellationToken.None));
}
private static DataTable S5006Table() => Table(
S5006Columns,
["Stock", "2", 1000m, 50m, 5.26m, 960m, 1m, 1100m, 10m, 900m, -10m]);
private static DataTable S5011Table(S5011DetailBranch branch)
{
object?[] details = branch switch
{
S5011DetailBranch.FaceValue => [5000m, 100_000_000m, 900_000_000m, 7m],
S5011DetailBranch.Valuation => [1.2m, 8.75m, 12_345m, -321m],
S5011DetailBranch.Volume => [12_345m, 9_876_543m, 1_111m, 2_222m],
_ => throw new InvalidOperationException()
};
return Table(
S5011Columns,
["Stock(NXT)", "2", 1000m, 50m, 5.26m, .. details]);
}
private static DataTable Table(string[] columns, object?[]? row = null)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(object));
}
if (row is not null)
{
table.Rows.Add(row);
}
return table;
}
public sealed record S5011RouteCase(
LegacyDomesticEquityMarket Market,
S5011DetailBranch Branch,
DataSourceKind Source,
string SourceFragment,
string BranchFragment);
private sealed record QueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
{
private readonly Queue<DataTable> _results = new(results);
public List<QueryCall> Calls { get; } = [];
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new QueryCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("No result was configured.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
}

View File

@@ -0,0 +1,176 @@
using System.Globalization;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class FoundationalSceneBuildersTests
{
[Fact]
public void S5076_matches_legacy_value_visibility_and_circle_order()
{
var data = new S5076SceneData(
"테스트",
"2026.07",
[
new RevenueSliceData("국내", "25.0", 25),
new RevenueSliceData("해외", "75.0", 75),
null,
null,
null
]);
var actual = new S5076SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"V:title=테스트 주요매출 구성",
"V:date=2026.07 기준",
"S:circle1=0", "S:group1=0",
"S:circle2=0", "S:group2=0",
"S:circle3=0", "S:group3=0",
"S:circle4=0", "S:group4=0",
"S:circle5=0", "S:group5=0",
"S:circle1=1", "S:group1=1",
"V:word1=국내", "V:percent1=25.0",
"A:circle1:0:0:0:3", "A:circle1:1:0:90:3",
"S:circle2=1", "S:group2=1",
"V:word2=해외", "V:percent2=75.0",
"A:circle2:0:90:90:3", "A:circle2:1:90:360:3"
], actual);
}
[Fact]
public void S5081_matches_legacy_mixed_profit_sequence_and_scaling()
{
var data = new S5081SceneData(
"A사",
[
new("1Q", 100), new("2Q", -50), new("3Q", 25),
new("4Q", 0), new("5Q", 50), new("6Q", -25)
]);
var actual = new S5081SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"V:title=A사 영업이익",
"V:quarter1=1Q", "V:quarter2=2Q", "V:quarter3=3Q",
"V:quarter4=4Q", "V:quarter5=5Q", "V:quarter6=6Q",
"S:barG1_1=0", "S:barG1_2=0", "S:barG1_1=1", "V:value1_1=100",
"S:barG2_1=0", "S:barG2_2=0", "S:barG2_2=1", "V:value2_2=-50",
"S:barG3_1=0", "S:barG3_2=0", "S:barG3_1=1", "V:value3_1=25",
"S:barG4_1=0", "S:barG4_2=0", "S:barG4_1=1", "V:value4_1=0",
"S:barG5_1=0", "S:barG5_2=0", "S:barG5_1=1", "V:value5_1=50",
"S:barG6_1=0", "S:barG6_2=0", "S:barG6_2=1", "V:value6_2=-25",
"K:centerbar:0:0:-131.66667:0:2",
"Z:bar1_1:0:1.2:0:2", "Z:bar2_2:0:0.6:0:2",
"Z:bar3_1:0:0.3:0:2", "Z:bar4_1:0:0:0:2",
"Z:bar5_1:0:0.6:0:2", "Z:bar6_2:0:0.3:0:2"
], actual);
}
[Fact]
public void S5081_all_zero_data_is_finite_and_does_not_repeat_a_failed_sdk_call()
{
var data = new S5081SceneData(
"A사",
Enumerable.Range(1, 6).Select(index => new QuarterlyProfitData($"{index}Q", 0)).ToArray());
var actual = new S5081SceneMutationBuilder().Build(data);
Assert.DoesNotContain(actual.Select(Describe), value => value.Contains("NaN", StringComparison.Ordinal));
Assert.Equal("K:centerbar:0:0:-250:0:2", Describe(actual[31]));
Assert.All(actual.Skip(32), mutation => Assert.Contains(":0:0:2", Describe(mutation)));
}
[Fact]
public void S5085_maps_typed_metrics_to_the_original_objects_and_colors()
{
var actual = new S5085SceneMutationBuilder().Build(new S5085SceneData(
[
new(ProgramTradingMetric.NetBuy, 1234.5),
new(ProgramTradingMetric.Basis, -2.25),
new(ProgramTradingMetric.Futures, 0)
])).Select(Describe);
Assert.Equal(
[
"V:title1=순매수", "C:price1:171:0:0:255", "V:price1=1,234.5",
"V:title4=베이시스", "C:price4:15:99:188:255", "V:price4=-2.25",
"V:title6=선물", "C:price6:47:47:47:255", "V:price6=0"
], actual);
}
[Fact]
public void S6067_matches_legacy_market_and_institution_slot_mapping()
{
var actual = new S6067SceneMutationBuilder().Build(new S6067SceneData(
[
new(InstitutionMarket.Kospi, InstitutionKind.Securities, 1000),
new(InstitutionMarket.Kosdaq, InstitutionKind.Fund, -327),
new(InstitutionMarket.Other, InstitutionKind.Bank, 0)
])).Select(Describe);
Assert.Equal(
[
"C:row1_value1:171:0:0:255", "V:row1_value1=1,000",
"C:row2_value5:15:99:188:255", "V:row2_value5=-327",
"C:row3_value4:47:47:47:255", "V:row3_value4=0"
], actual);
}
[Fact]
public void S8067_matches_legacy_world_map_order_including_flat_background_target()
{
var actual = new S8067SceneMutationBuilder().Build(new S8067SceneData(
[
new(WorldMarket.Korea, 2765.1234, 12.5, 1.23, SceneMarketTrend.Up),
new(WorldMarket.Nasdaq, 18000, -0.0, -0.0, SceneMarketTrend.Down)
])).Select(Describe);
Assert.Equal(
[
"V:price_한국=2,765.123", "V:rate_한국=1.23",
"S:up_한국=0", "S:down_한국=0", "S:flat_한국=0",
"C:bg_한국:170:0:0:255", "S:up_한국=1",
"V:price_나스닥=18,000", "V:rate_나스닥=-0.00",
"S:up_나스닥=0", "S:down_나스닥=0", "S:flat_나스닥=0",
"C:bg:191:191:191:255", "S:flat_나스닥=1"
], actual);
}
[Fact]
public void S8086_matches_legacy_quote_values_and_color_order()
{
var actual = new S8086SceneMutationBuilder().Build(new S8086SceneData(
"WTI 유가",
CommodityUnit.DollarsPerBarrel,
78.2,
-1.1,
-1.39,
SceneMarketTrend.Down)).Select(Describe);
Assert.Equal(
[
"V:unit=단위: 달러/배럴", "V:title=WTI 유가",
"V:price=78.20", "V:changePrice=-1.10", "V:rate=-1.39",
"C:bg:14:100:188:255", "C:changePrice:14:100:188:255",
"C:rate:14:100:188:255"
], actual);
}
private static string Describe(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
PlayoutSetFaceColor color => $"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}",
PlayoutSetPositionKey key => FormattableString.Invariant(
$"K:{key.ObjectName}:{key.KeyIndex}:{key.X}:{key.Y}:{key.Z}:{(int)key.Components}"),
PlayoutSetScale scale => FormattableString.Invariant(
$"Z:{scale.ObjectName}:{scale.X}:{scale.Y}:{scale.Z}:{(int)scale.Components}"),
PlayoutSetCircleAngleKey angle => FormattableString.Invariant(
$"A:{angle.ObjectName}:{angle.KeyIndex}:{angle.Start}:{angle.End}:{(int)angle.Components}"),
_ => mutation.GetType().Name
};
}

View File

@@ -0,0 +1,351 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class GridMarketSceneDataLoadersTests
{
private static readonly string[] DailyColumns =
["TRADING_DATE", "INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"];
private static readonly string[] TotalColumns =
["INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"];
private static readonly string[] ParticipantColumns = ["PARTICIPANT_CODE", "AMOUNT"];
private static readonly string[] MetricColumns = ["METRIC_CODE", "METRIC_VALUE"];
private static readonly string[] InstitutionColumns =
["MARKET_CODE", "INSTITUTION_CODE", "AMOUNT"];
[Fact]
public async Task S5023_loads_exact_daily_and_monthly_tables_then_proves_mutations()
{
var executor = new RecordingExecutor(DailyTable(), TotalTable());
var data = await new S5023SceneDataLoader(executor).LoadAsync(
new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi),
CancellationToken.None);
var mutations = new S5023SceneMutationBuilder().Build(data);
Assert.Equal(2, executor.Calls.Count);
Assert.Equal(["SCENE_5023_DAILY", "SCENE_5023_MONTHLY"],
executor.Calls.Select(call => call.TableName));
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Empty(call.Spec.Parameters);
Assert.Contains("t_invest", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("t_kosdaq_invest", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
});
Assert.Equal(new DateOnly(2026, 7, 10), data.DailyRows[0]!.TradingDate);
Assert.Equal(-20, data.DailyRows[1]!.Amounts.Foreign);
Assert.Equal(new InvestorFlowAmounts(1500, -800, 200), data.MonthlyTotal);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "row1_value1", Value: "07/10" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "row6_value2", Value: "-800" });
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S5023_kosdaq_route_is_closed_and_never_uses_a_selector_parameter()
{
var executor = new RecordingExecutor(DailyTable(), TotalTable());
var data = await new S5023SceneDataLoader(executor).LoadAsync(
new S5023SceneLoadRequest(LegacyDomesticMarket.Kosdaq),
CancellationToken.None);
Assert.Equal(LegacyDomesticMarket.Kosdaq, data.Market);
Assert.All(executor.Calls, call =>
{
Assert.Contains("t_kosdaq_invest", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Empty(call.Spec.Parameters);
});
}
[Fact]
public async Task S5023_rejects_wrong_schema_count_order_and_fractional_integer()
{
var wrongSchema = DailyTable();
wrongSchema.Columns[0].SetOrdinal(3);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5023SceneDataLoader(new RecordingExecutor(wrongSchema, TotalTable())).LoadAsync(
new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi)));
var wrongCount = DailyTable();
wrongCount.Rows.RemoveAt(4);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5023SceneDataLoader(new RecordingExecutor(wrongCount, TotalTable())).LoadAsync(
new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi)));
var wrongOrder = DailyTable();
wrongOrder.Rows[1]["TRADING_DATE"] = "20260711";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5023SceneDataLoader(new RecordingExecutor(wrongOrder, TotalTable())).LoadAsync(
new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi)));
var fractional = TotalTable();
fractional.Rows[0]["INDIVIDUAL_AMOUNT"] = 1.25m;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5023SceneDataLoader(new RecordingExecutor(DailyTable(), fractional)).LoadAsync(
new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi)));
}
[Theory]
[InlineData(LegacyDomesticMarket.Kospi, "t_invest")]
[InlineData(LegacyDomesticMarket.Kosdaq, "t_kosdaq_invest")]
public async Task S5024_maps_three_ordered_participants_and_proves_bar_mutations(
LegacyDomesticMarket market,
string tableName)
{
var table = Table(ParticipantColumns,
["INDIVIDUAL", 100d],
["FOREIGN", -50d],
["INSTITUTION", 0d]);
var executor = new RecordingExecutor(table);
var data = await new S5024SceneDataLoader(executor).LoadAsync(
new S5024SceneLoadRequest(market),
CancellationToken.None);
var mutations = new S5024SceneMutationBuilder().Build(data);
var call = Assert.Single(executor.Calls);
Assert.Equal("SCENE_5024", call.TableName);
Assert.Contains(tableName, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Empty(call.Spec.Parameters);
Assert.Equal(
[InvestorParticipant.Individual, InvestorParticipant.Foreign, InvestorParticipant.Institution],
data.Amounts.Select(amount => amount.Participant));
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetVisible { ObjectName: "barG2_2", IsVisible: true });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "value1_1", Value: "100" });
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S5024_rejects_reordered_or_nonfinite_rows()
{
var reordered = Table(ParticipantColumns,
["FOREIGN", 1d],
["INDIVIDUAL", 2d],
["INSTITUTION", 3d]);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5024SceneDataLoader(new RecordingExecutor(reordered)).LoadAsync(
new S5024SceneLoadRequest(LegacyDomesticMarket.Kospi)));
var nonfinite = Table(ParticipantColumns,
["INDIVIDUAL", double.PositiveInfinity],
["FOREIGN", 2d],
["INSTITUTION", 3d]);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5024SceneDataLoader(new RecordingExecutor(nonfinite)).LoadAsync(
new S5024SceneLoadRequest(LegacyDomesticMarket.Kospi)));
}
[Fact]
public async Task S5085_maps_six_ordered_metrics_and_proves_value_color_mutations()
{
var executor = new RecordingExecutor(MetricTable());
var data = await new S5085SceneDataLoader(executor).LoadAsync(CancellationToken.None);
var mutations = new S5085SceneMutationBuilder().Build(data);
var call = Assert.Single(executor.Calls);
Assert.Equal("SCENE_5085", call.TableName);
Assert.Empty(call.Spec.Parameters);
Assert.Contains("WITH future_row AS", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("a.f_stock_seq = 1", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("MAX(open_day)", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal(6, data.Metrics.Count);
Assert.Equal(ProgramTradingMetric.NetBuy, data.Metrics[0].Metric);
Assert.Equal(ProgramTradingMetric.Futures, data.Metrics[^1].Metric);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "price1", Value: "1,234.5" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetFaceColor
{
ObjectName: "price2",
Red: 15,
Green: 99,
Blue: 188
});
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S5085_rejects_missing_reordered_or_nonfinite_metrics()
{
var missing = MetricTable();
missing.Rows.RemoveAt(5);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5085SceneDataLoader(new RecordingExecutor(missing)).LoadAsync());
var reordered = MetricTable();
reordered.Rows[0]["METRIC_CODE"] = "ARBITRAGE";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5085SceneDataLoader(new RecordingExecutor(reordered)).LoadAsync());
var nonfinite = MetricTable();
nonfinite.Rows[0]["METRIC_VALUE"] = double.NaN;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5085SceneDataLoader(new RecordingExecutor(nonfinite)).LoadAsync());
}
[Fact]
public async Task S6067_maps_the_exact_fifteen_row_grid_and_proves_slot_mutations()
{
var executor = new RecordingExecutor(InstitutionTable());
var data = await new S6067SceneDataLoader(executor).LoadAsync(CancellationToken.None);
var mutations = new S6067SceneMutationBuilder().Build(data);
var call = Assert.Single(executor.Calls);
Assert.Equal("SCENE_6067", call.TableName);
Assert.Empty(call.Spec.Parameters);
Assert.Equal(15, data.Rows.Count);
Assert.Equal(InstitutionMarket.Kospi, data.Rows[0].Market);
Assert.Equal(InstitutionKind.Fund, data.Rows[4].Institution);
Assert.Equal(InstitutionMarket.Other, data.Rows[10].Market);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "row1_value1", Value: "100" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "row3_value5", Value: "-305" });
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S6067_rejects_wrong_order_schema_and_fractional_values()
{
var reordered = InstitutionTable();
reordered.Rows[1]["INSTITUTION_CODE"] = "BANK";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S6067SceneDataLoader(new RecordingExecutor(reordered)).LoadAsync());
var schema = InstitutionTable();
schema.Columns[2].ColumnName = "VALUE";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S6067SceneDataLoader(new RecordingExecutor(schema)).LoadAsync());
var fractional = InstitutionTable();
fractional.Rows[0]["AMOUNT"] = 1.1m;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S6067SceneDataLoader(new RecordingExecutor(fractional)).LoadAsync());
}
[Theory]
[InlineData(99)]
public async Task Closed_market_requests_fail_before_any_query(int rawMarket)
{
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5023SceneDataLoader(executor).LoadAsync(
new S5023SceneLoadRequest((LegacyDomesticMarket)rawMarket)));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5024SceneDataLoader(executor).LoadAsync(
new S5024SceneLoadRequest((LegacyDomesticMarket)rawMarket)));
Assert.Empty(executor.Calls);
}
private static DataTable DailyTable() => Table(DailyColumns,
["20260710", 100, -10, 1],
["20260709", 200, -20, 2],
["20260708", 300, -30, 3],
["20260707", 400, -40, 4],
["20260706", 500, -50, 5]);
private static DataTable TotalTable() => Table(TotalColumns, [1500, -800, 200]);
private static DataTable MetricTable() => Table(MetricColumns,
["NET_BUY", 1234.5d],
["ARBITRAGE", -12d],
["NON_ARBITRAGE", 24d],
["BASIS", 1.25d],
["KOSPI200", 300.12d],
["FUTURES", 301.25d]);
private static DataTable InstitutionTable()
{
string[] markets = ["KOSPI", "KOSDAQ", "KOSPI200"];
string[] institutions =
["SECURITIES", "INSURANCE", "INVESTMENT_TRUST", "BANK", "FUND"];
var table = Table(InstitutionColumns);
for (var market = 0; market < markets.Length; market++)
{
for (var institution = 0; institution < institutions.Length; institution++)
{
var value = market == 2 && institution == 4
? -305
: ((market + 1) * 100) + institution;
table.Rows.Add(markets[market], institutions[institution], value);
}
}
return table;
}
private static DataTable Table(string[] columns, params object?[][] rows)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(object));
}
foreach (var row in rows)
{
table.Rows.Add(row);
}
return table;
}
private sealed record QueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
{
private readonly Queue<DataTable> _results = new(results);
public List<QueryCall> Calls { get; } = [];
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new QueryCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("No result was configured.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
}

View File

@@ -0,0 +1,146 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class GridSceneBuilder5082Tests
{
[Fact]
public void Build_matches_original_three_by_three_value_and_visibility_order()
{
var data = new S5082SceneData(
new(1000, -2000, 0),
new(-1, 2, 0),
new(0, 0, 3));
var actual = new S5082SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"V:row1_value1_1=1,000",
"V:row1_value1_2=1,000",
"V:row1_value1_3=1,000",
"S:row1_value1_1=1",
"S:row1_value1_2=0",
"S:row1_value1_3=0",
"V:row1_value2_1=-2,000",
"V:row1_value2_2=-2,000",
"V:row1_value2_3=-2,000",
"S:row1_value2_1=0",
"S:row1_value2_2=0",
"S:row1_value2_3=1",
"V:row1_value3_1=0",
"V:row1_value3_2=0",
"V:row1_value3_3=0",
"S:row1_value3_1=0",
"S:row1_value3_2=1",
"S:row1_value3_3=0",
"V:row2_value1_1=-1",
"V:row2_value1_2=-1",
"V:row2_value1_3=-1",
"S:row2_value1_1=0",
"S:row2_value1_2=0",
"S:row2_value1_3=1",
"V:row2_value2_1=2",
"V:row2_value2_2=2",
"V:row2_value2_3=2",
"S:row2_value2_1=1",
"S:row2_value2_2=0",
"S:row2_value2_3=0",
"V:row2_value3_1=0",
"V:row2_value3_2=0",
"V:row2_value3_3=0",
"S:row2_value3_1=0",
"S:row2_value3_2=1",
"S:row2_value3_3=0",
"V:row3_value1_1=0",
"V:row3_value1_2=0",
"V:row3_value1_3=0",
"S:row3_value1_1=0",
"S:row3_value1_2=1",
"S:row3_value1_3=0",
"V:row3_value2_1=0",
"V:row3_value2_2=0",
"V:row3_value2_3=0",
"S:row3_value2_1=0",
"S:row3_value2_2=1",
"S:row3_value2_3=0",
"V:row3_value3_1=3",
"V:row3_value3_2=3",
"V:row3_value3_3=3",
"S:row3_value3_1=1",
"S:row3_value3_2=0",
"S:row3_value3_3=0"
], actual);
}
[Fact]
public async Task Loader_maps_named_rows_with_a_spec_query_and_builder_consumes_them()
{
var table = new System.Data.DataTable();
table.Columns.Add("NAME", typeof(string));
table.Columns.Add("INDIVIDUAL_VALUE", typeof(decimal));
table.Columns.Add("FOREIGN_VALUE", typeof(decimal));
table.Columns.Add("INSTITUTION_VALUE", typeof(decimal));
table.Rows.Add("코스닥", -1m, 2m, 3m);
table.Rows.Add("코스피200", 4m, -5m, 6m);
table.Rows.Add("코스피", 7m, 8m, -9m);
var executor = new SpecExecutor(table);
var data = await new S5082SceneDataLoader(executor).LoadAsync();
var mutations = new S5082SceneMutationBuilder().Build(data);
Assert.Equal(new InvestorTradingValues(7, 8, -9), data.Kospi);
Assert.Equal(new InvestorTradingValues(-1, 2, 3), data.Kosdaq);
Assert.Equal(new InvestorTradingValues(4, -5, 6), data.Kospi200);
Assert.Equal(54, mutations.Count);
Assert.NotNull(executor.Spec);
Assert.Empty(executor.Spec.Parameters);
Assert.Equal(0, executor.StringCalls);
}
private static string Describe(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
_ => mutation.GetType().Name
};
private sealed class SpecExecutor(System.Data.DataTable table) : MMoneyCoderSharp.Data.IDataQueryExecutor
{
public MMoneyCoderSharp.Data.DataQuerySpec? Spec { get; private set; }
public int StringCalls { get; private set; }
public System.Data.DataTable Execute(
MMoneyCoderSharp.Data.DataSourceKind source,
string tableName,
string query)
{
StringCalls++;
throw new InvalidOperationException();
}
public Task<System.Data.DataTable> ExecuteAsync(
MMoneyCoderSharp.Data.DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException();
}
public Task<System.Data.DataTable> ExecuteAsync(
MMoneyCoderSharp.Data.DataSourceKind source,
string tableName,
MMoneyCoderSharp.Data.DataQuerySpec query,
CancellationToken cancellationToken = default)
{
Assert.Equal(MMoneyCoderSharp.Data.DataSourceKind.Oracle, source);
Assert.Equal("SCENE_5082", tableName);
Spec = query;
return Task.FromResult(table.Copy());
}
}
}

View File

@@ -0,0 +1,427 @@
using System.Data;
using System.IO;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyGridMarketSceneRequestResolverTests
{
public static TheoryData<string, string, string, string, LegacyDomesticMarket> TrendCases => new()
{
{ "5023", "코스피", "코스피 매매동향", "표그래프", LegacyDomesticMarket.Kospi },
{ "5023", "KOSDAQ", "KOSDAQ_TRADING_TREND", "TABLE_GRAPH", LegacyDomesticMarket.Kosdaq },
{ "5024", "코스피", "코스피 매매동향", "막대그래프", LegacyDomesticMarket.Kospi },
{ "5024", "KOSDAQ", "KOSDAQ_TRADING_TREND", "BAR_GRAPH", LegacyDomesticMarket.Kosdaq }
};
public static TheoryData<string, string, string, Type> FixedGridCases => new()
{
{ "5085", "프로그램 매매", "표그래프", typeof(LegacyS5085SceneLoadRequest) },
{ "5085", "PROGRAM_TRADING", "TABLE_GRAPH", typeof(LegacyS5085SceneLoadRequest) },
{ "6067", "기관 순매수 현황", "표그래프", typeof(LegacyS6067SceneLoadRequest) },
{ "6067", "INSTITUTION_NET_BUY", "TABLE_GRAPH", typeof(LegacyS6067SceneLoadRequest) }
};
[Theory]
[MemberData(nameof(TrendCases))]
public async Task Trend_cuts_map_MainForm_graphic_and_subtype_fields_without_database_calls(
string cutCode,
string groupCode,
string graphicType,
string subtype,
LegacyDomesticMarket expectedMarket)
{
var executor = new RecordingExecutor();
var resolver = new LegacyGridMarketSceneRequestResolver(executor);
var plan = await resolver.ResolveAsync(
Entry(cutCode, groupCode, "지수", graphicType, subtype));
if (cutCode == "5023")
{
Assert.Equal(
expectedMarket,
Assert.IsType<LegacyS5023SceneLoadRequest>(plan).Request.Market);
}
else
{
Assert.Equal(
expectedMarket,
Assert.IsType<LegacyS5024SceneLoadRequest>(plan).Request.Market);
}
Assert.Empty(executor.Calls);
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task Trend_cuts_reject_market_subtype_and_hidden_code_mismatches_before_database()
{
var executor = new RecordingExecutor();
var resolver = new LegacyGridMarketSceneRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
Entry("5023", "코스닥", "지수", "코스피 매매동향", "표그래프")));
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
Entry("5023", "코스피", "지수", "코스피 매매동향", "막대그래프")));
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
Entry("5024", "코스피", "지수", "코스피 매매동향", "막대그래프", "unexpected")));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("5037", "코스피", LegacyDomesticMarket.Kospi)]
[InlineData("5037", "KOSDAQ", LegacyDomesticMarket.Kosdaq)]
public async Task S5037_maps_code_and_trimmed_jongmok_to_a_typed_request(
string cutCode,
string groupCode,
LegacyDomesticMarket expectedMarket)
{
var executor = new RecordingExecutor();
var resolver = new LegacyGridMarketSceneRequestResolver(executor);
var plan = Assert.IsType<LegacyS5037SceneLoadRequest>(await resolver.ResolveAsync(
Entry(cutCode, groupCode, " 삼성전자 ", "거래원", "표그래프", "A005930")));
Assert.Equal(expectedMarket, plan.Request.Market);
Assert.Equal("삼성전자", plan.Request.StockName);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task S5037_rejects_the_legacy_arbitrary_else_market_branch()
{
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync(
Entry("5037", "전체", "삼성전자", "거래원", "표그래프")));
Assert.Empty(executor.Calls);
}
[Theory]
[MemberData(nameof(FixedGridCases))]
public async Task Fixed_grid_cuts_validate_selection_identity_and_return_no_parameter_markers(
string cutCode,
string graphicType,
string subtype,
Type expectedType)
{
var executor = new RecordingExecutor();
var plan = await new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync(
Entry(cutCode, "전체", "지수", graphicType, subtype));
Assert.IsType(expectedType, plan);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task Fixed_grid_cuts_reject_wrong_playlist_identity_before_database()
{
var executor = new RecordingExecutor();
var resolver = new LegacyGridMarketSceneRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
Entry("5085", "코스피", "지수", "프로그램 매매", "표그래프")));
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
Entry("6067", "전체", "지수", "기관 순매수 현황", "막대그래프")));
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
Entry("6067", "전체", "지수", "기관 순매수 현황", "표그래프", "path.dat")));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("개인 순매도 상위(수동)", S5025ManualAudience.Individual)]
[InlineData("FOREIGN", S5025ManualAudience.Foreign)]
[InlineData("기관 순매도 상위(수동)", S5025ManualAudience.Institution)]
public async Task S5025_maps_only_closed_audiences_when_a_native_trusted_source_is_configured(
string groupCode,
S5025ManualAudience expectedAudience)
{
var executor = new RecordingExecutor();
var source = new RecordingManualSource(ManualRows());
var resolver = new LegacyGridMarketSceneRequestResolver(executor, source);
var plan = Assert.IsType<LegacyS5025SceneLoadRequest>(await resolver.ResolveAsync(
Entry("5025", groupCode, "지수", "순매도 상위", "순매도 상위")));
Assert.Equal(expectedAudience, plan.Request.Audience);
Assert.Empty(source.Calls);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task S5025_safe_runtime_loads_and_validates_trusted_rows_before_database_or_mutations()
{
var executor = new RecordingExecutor();
var source = new RecordingManualSource(ManualRows());
var resolver = new LegacyGridMarketSceneRequestResolver(executor, source);
var entry = Entry(
"5025",
"외국인 순매도 상위(수동)",
"지수",
"순매도 상위",
"순매도 상위");
var data = await resolver.LoadTrustedS5025Async(entry, CancellationToken.None);
var mutations = new S5025SceneMutationBuilder().Build(data);
Assert.Equal(S5025ManualAudience.Foreign, Assert.Single(source.Calls));
Assert.Equal("외국인", data.Title);
Assert.Equal(5, data.Rows.Count);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "종목10", Value: "R5" });
Assert.Empty(executor.Calls);
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S5025_missing_configuration_and_path_injection_fail_before_source_database_or_com()
{
var entry = Entry(
"5025",
"개인 순매도 상위(수동)",
"지수",
"순매도 상위",
"순매도 상위");
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync(entry));
var source = new RecordingManualSource(ManualRows());
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new LegacyGridMarketSceneRequestResolver(executor, source).ResolveAsync(
entry with
{
Selection = entry.Selection! with { DataCode = "C:\\outside\\개인.dat" }
}));
Assert.Empty(source.Calls);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task S5025_missing_trusted_file_is_normalized_without_leaking_the_native_path()
{
const string secretPath = "C:\\trusted\\secret\\개인.dat";
var executor = new RecordingExecutor();
var source = new ThrowingManualSource(new FileNotFoundException("Missing " + secretPath));
var resolver = new LegacyGridMarketSceneRequestResolver(executor, source);
var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.LoadTrustedS5025Async(Entry(
"5025",
"개인 순매도 상위(수동)",
"지수",
"순매도 상위",
"순매도 상위")));
Assert.DoesNotContain(secretPath, exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task S8003_resolves_Kospi_name_to_code_with_two_parameterized_master_lookups()
{
const string stockName = "삼성전자";
const string stockCode = "005930";
var executor = new RecordingExecutor(Stock(stockCode), Stock());
var resolver = new LegacyGridMarketSceneRequestResolver(executor);
var plan = Assert.IsType<LegacyS8003SceneLoadRequest>(await resolver.ResolveAsync(
Entry("8003", "코스피", stockName, "호가창", "표그래프", stockCode)));
Assert.Equal(LegacyDomesticMarket.Kospi, plan.Request.Market);
Assert.Equal(stockCode, plan.Request.StockCode);
Assert.Equal(
["SCENE_RESOLVE_8003_KOSPI", "SCENE_RESOLVE_8003_KOSDAQ"],
executor.Calls.Select(call => call.TableName));
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.DoesNotContain(stockName, call.Spec.Sql, StringComparison.Ordinal);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("stockName", parameter.Name);
Assert.Equal(stockName, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
});
Assert.Contains("FROM T_STOCK", executor.Calls[0].Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("FROM T_KOSDAQ_STOCK", executor.Calls[1].Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S8003_preserves_MainForm_Kosdaq_later_table_precedence()
{
var executor = new RecordingExecutor(Stock("111111"), Stock("222222"));
var plan = Assert.IsType<LegacyS8003SceneLoadRequest>(await
new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync(
Entry("8003", "코스닥", "동명이인", "호가창", "표그래프", "222222")));
Assert.Equal(LegacyDomesticMarket.Kosdaq, plan.Request.Market);
Assert.Equal("222222", plan.Request.StockCode);
}
[Fact]
public async Task S8003_fails_closed_for_missing_nonunique_mismatched_or_malformed_master_results()
{
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
Resolve8003Async(new RecordingExecutor(Stock(), Stock()), "코스피", ""));
var duplicate = Stock("005930");
duplicate.Rows.Add("005930");
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
Resolve8003Async(new RecordingExecutor(duplicate, Stock()), "코스피", "005930"));
var schema = Stock("005930");
schema.Columns[0].ColumnName = "CODE";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
Resolve8003Async(new RecordingExecutor(schema, Stock()), "코스피", "005930"));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
Resolve8003Async(
new RecordingExecutor(Stock("005930"), Stock()),
"코스닥",
"005930"));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
Resolve8003Async(
new RecordingExecutor(Stock("005930"), Stock()),
"코스피",
"999999"));
}
[Fact]
public async Task Missing_selection_and_unsupported_cut_fail_before_database()
{
var executor = new RecordingExecutor();
var resolver = new LegacyGridMarketSceneRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
new LegacyPlaylistEntry("entry", "5023")));
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
Entry("9999", "전체", "지수", "프로그램 매매", "표그래프")));
Assert.Empty(executor.Calls);
}
private static Task<LegacyGridMarketSceneLoadRequest> Resolve8003Async(
RecordingExecutor executor,
string market,
string dataCode) =>
new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync(
Entry("8003", market, "삼성전자", "호가창", "표그래프", dataCode));
private static LegacyPlaylistEntry Entry(
string cutCode,
string groupCode,
string subject,
string graphicType,
string subtype,
string dataCode = "") => new(
"entry",
cutCode,
Selection: new LegacySceneSelection(
groupCode,
subject,
graphicType,
subtype,
dataCode));
private static DataTable Stock(params string[] codes)
{
var table = new DataTable();
table.Columns.Add("STOCK_CODE", typeof(object));
foreach (var code in codes)
{
table.Rows.Add(code);
}
return table;
}
private static IReadOnlyList<S5025TrustedManualRow> ManualRows() =>
[
new("L1", "1", "R1", "2"),
new("L2", "3", "R2", "4"),
new("L3", "5", "R3", "6"),
new("L4", "7", "R4", "8"),
new("L5", "9", "R5", "10")
];
private sealed record QueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
{
private readonly Queue<DataTable> _results = new(results);
public List<QueryCall> Calls { get; } = [];
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new QueryCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("No result was configured.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
private sealed class RecordingManualSource(IReadOnlyList<S5025TrustedManualRow> rows)
: IS5025TrustedManualDataSource
{
public List<S5025ManualAudience> Calls { get; } = [];
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Calls.Add(audience);
return Task.FromResult(rows);
}
}
private sealed class ThrowingManualSource(Exception exception) : IS5025TrustedManualDataSource
{
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default) =>
Task.FromException<IReadOnlyList<S5025TrustedManualRow>>(exception);
}
}

View File

@@ -0,0 +1,178 @@
#nullable enable
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyParameterizedSceneRequestResolverTests
{
[Fact]
public void S5001_maps_legacy_dispatch_fields_to_closed_requests()
{
var resolver = new LegacyParameterizedSceneRequestResolver(
new RecordingExecutor(),
new FixedTimeProvider(new DateTimeOffset(2026, 7, 10, 12, 0, 0, TimeSpan.Zero)));
var commodity = resolver.CreateS5001Request(Entry(
"5001", "원자재", "국제 금", "현재가"));
var expectedIndex = resolver.CreateS5001Request(Entry(
"5001", "코스피200", "지수", "예상지수"));
var nxt = resolver.CreateS5001Request(Entry(
"N5001", "코스피_NXT", "삼성전자(NXT)", "현재가"));
Assert.Equal(
S5001CommodityTarget.InternationalGold,
Assert.IsType<S5001CommodityLoadRequest>(commodity).Target);
var index = Assert.IsType<S5001DomesticIndexLoadRequest>(expectedIndex);
Assert.Equal(S5001DomesticIndexTarget.Kospi200, index.Target);
Assert.Equal(S5001IndexMode.Expected, index.Mode);
var stock = Assert.IsType<S5001DomesticStockLoadRequest>(nxt);
Assert.Equal(LegacyDomesticEquityMarket.NxtKospi, stock.Market);
Assert.Equal(S5001NxtBadge.PreMarket, stock.Badge);
}
[Fact]
public async Task Two_column_alias_routes_futures_to_s5032_and_normalizes_side_order()
{
var resolver = new LegacyParameterizedSceneRequestResolver(new RecordingExecutor());
var result = await resolver.ResolveTwoColumnRequestAsync(Entry(
"8018", "지수", "선물,코스닥 지수", "현재가"));
var routed = Assert.IsType<LegacyS5032LoadRequest>(result);
var request = Assert.IsType<S5032IndexAndFuturesLoadRequest>(routed.Request);
Assert.Equal(LegacyMarketQuoteTarget.Kosdaq, request.PrimaryTarget);
}
[Fact]
public async Task Current_stock_pair_uses_original_master_table_precedence_and_session_boundary()
{
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(1), Count(1));
var resolver = new LegacyParameterizedSceneRequestResolver(
executor,
new FixedTimeProvider(new DateTimeOffset(2026, 7, 10, 15, 30, 1, TimeSpan.Zero)));
var result = await resolver.ResolveTwoColumnRequestAsync(Entry(
"8018", "종목", "첫째,둘째", "현재가"));
var routed = Assert.IsType<LegacyS8018LoadRequest>(result);
var request = Assert.IsType<S8018CurrentStockPairLoadRequest>(routed.Request);
Assert.Equal(S8018StockMarket.Kospi, request.First.Market);
Assert.Equal(S8018StockMarket.Kosdaq, request.Second.Market);
Assert.Equal(S8018SessionPhase.AfterKrxClose, request.Session);
Assert.Equal(
[
"SCENE_RESOLVE_KOSPI_STOCK",
"SCENE_RESOLVE_KOSDAQ_STOCK",
"SCENE_RESOLVE_KOSPI_STOCK",
"SCENE_RESOLVE_KOSDAQ_STOCK"
],
executor.Calls.Select(call => call.TableName));
Assert.All(executor.Calls, call =>
{
var parameter = Assert.Single(call.Spec.Parameters);
var selector = Assert.IsType<string>(parameter.Value);
Assert.DoesNotContain(
selector,
call.Spec.Sql,
StringComparison.Ordinal);
});
}
[Fact]
public async Task Expected_nxt_pair_fails_before_any_database_lookup()
{
var executor = new RecordingExecutor();
var resolver = new LegacyParameterizedSceneRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveTwoColumnRequestAsync(Entry(
"8018", "종목", "A(NXT),B", "예상체결")));
Assert.Empty(executor.Calls);
}
[Fact]
public void S8001_accepts_only_the_two_original_markets()
{
var resolver = new LegacyParameterizedSceneRequestResolver(new RecordingExecutor());
Assert.Equal(
S8001Market.Kospi,
resolver.CreateS8001Request(Entry("8001", "전체", "코스피", "")).Market);
Assert.Throws<LegacySceneDataException>(() =>
resolver.CreateS8001Request(Entry("8001", "전체", "나스닥", "")));
}
private static LegacyPlaylistEntry Entry(
string cutCode,
string groupCode,
string subject,
string subtype) => new(
"entry",
cutCode,
Selection: new LegacySceneSelection(
groupCode,
subject,
"",
subtype,
""));
private static DataTable Count(int value)
{
var table = new DataTable();
table.Columns.Add("MATCH_COUNT", typeof(int));
table.Rows.Add(value);
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
{
private readonly Queue<DataTable> _results = new(results);
public List<RecordedCall> Calls { get; } = [];
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new InvalidOperationException("String SQL fallback is forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("String SQL fallback is forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new RecordedCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("No fake query result remains.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
}

View File

@@ -0,0 +1,481 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyPlayoutWorkflowTests
{
[Fact]
public async Task PrepareTakeInAndNext_UpdatesPagesBeforeMovingToNextEnabledCue()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int Count, ScenePageSize? Size)>
{
["5074"] = (6, ScenePageSize.Five),
["5001"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new("entry-1", "5074"),
new("disabled", "5006", false),
new("entry-3", "5001")
];
Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal(2, workflow.State.PageCount);
Assert.True(workflow.State.IsLastPage);
Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind);
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal("5001", workflow.State.OnAirCutCode);
Assert.Equal(
[
"Prepare:5074:p0", "Prepare:5074:p0", "TakeIn",
"Next:5074:p1", "Next:5001:p0"
], engine.Calls);
Assert.Equal(
[
"5074:0", "5074:0", "5074:1", "5001:0"
], provider.Calls);
}
[Fact]
public async Task Next_AtTheEndDoesNotWrapOrDispatchTheEngine()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5088"] = (12, ScenePageSize.Twelve)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist = [new("only", "5088")];
Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
var result = await workflow.NextAsync();
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
Assert.Equal(["Prepare:5088:p0", "Prepare:5088:p0", "TakeIn"], engine.Calls);
Assert.Equal(["5088:0", "5088:0"], provider.Calls);
}
[Fact]
public async Task PageNext_RejectsProviderThatChangesTheOnAirSceneName()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5077"] = (7, ScenePageSize.Six)
})
{
SceneNameOverride = (_, page) => page == 0 ? "5077" : "other"
};
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5077")], 0)).IsSuccess);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
await Assert.ThrowsAsync<LegacySceneDataException>(() => workflow.NextAsync());
Assert.DoesNotContain(engine.Calls, call => call == "Next:other:p1");
Assert.Equal(0, workflow.State.PageIndexZeroBased);
}
[Fact]
public async Task TakeOut_ClearsPageAndPlaylistStateOnlyAfterEngineSuccess()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
Assert.True((await workflow.TakeOutAsync(PlayoutTakeOutScope.All)).IsSuccess);
Assert.Null(workflow.State.OnAirCutCode);
Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal(LegacyWorkflowNextKind.None, workflow.State.NextKind);
Assert.Equal("TakeOut:All", engine.Calls[^1]);
}
[Fact]
public async Task Prepare_SkipsDisabledRowsLikeTheLegacyMainForm()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new("disabled", "5006", false),
new("enabled", "5001")
];
var result = await workflow.PrepareAsync(playlist, 0);
Assert.True(result.IsSuccess);
Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal("5001", workflow.State.PreparedCutCode);
Assert.Equal(["5001:0"], provider.Calls);
Assert.Equal(["Prepare:5001:p0"], engine.Calls);
}
[Fact]
public async Task Prepare_WithNoEnabledRowAtOrAfterSelection_DoesNotWrapOrDispatch()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>());
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new("enabled-before", "5001"),
new("disabled", "5006", false)
];
var result = await workflow.PrepareAsync(playlist, 1);
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
Assert.Empty(provider.Calls);
Assert.Empty(engine.Calls);
Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
}
[Fact]
public async Task Prepare_RejectsControlCharactersInLegacyLookupFieldsBeforeDataAccess()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>());
var workflow = new LegacyPlayoutWorkflow(engine, provider);
var selection = new LegacySceneSelection(
"코스피",
"종목\n이름",
"1열판 기본",
string.Empty,
"005930");
await Assert.ThrowsAsync<ArgumentException>(() => workflow.PrepareAsync(
[new LegacyPlaylistEntry("entry", "5001", Selection: selection)],
0));
Assert.Empty(provider.Calls);
Assert.Empty(engine.Calls);
}
[Fact]
public async Task Prepare_RejectsAnOutOfRangeFadeBeforeDataAccess()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>());
var workflow = new LegacyPlayoutWorkflow(engine, provider);
await Assert.ThrowsAsync<ArgumentException>(() => workflow.PrepareAsync(
[new LegacyPlaylistEntry("entry", "5001", FadeDuration: 61)],
0));
Assert.Empty(provider.Calls);
Assert.Empty(engine.Calls);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Prepare_WhenPrepareModeIsAlreadyActive_TogglesThroughLegacyStopAll(
bool takeInFirst)
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5006"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
if (takeInFirst)
{
Assert.True((await workflow.TakeInAsync()).IsSuccess);
}
var toggle = await workflow.PrepareAsync([new("other", "5006")], 0);
Assert.True(toggle.IsSuccess);
Assert.Equal(PlayoutOperation.TakeOut, toggle.Operation);
Assert.Equal("TakeOut:All", engine.Calls[^1]);
string[] expectedProviderCalls = takeInFirst
? ["5001:0", "5001:0"]
: ["5001:0"];
Assert.Equal(expectedProviderCalls, provider.Calls);
Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
Assert.Null(workflow.State.PreparedCutCode);
Assert.Null(workflow.State.OnAirCutCode);
}
[Fact]
public async Task TakeIn_RequeriesAndRepreparesTheSelectedPageBeforePlay()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
Assert.Equal(["5001:0", "5001:0"], provider.Calls);
Assert.Equal(
["Prepare:5001:p0", "Prepare:5001:p0", "TakeIn"],
engine.Calls);
Assert.Null(workflow.State.PreparedCutCode);
Assert.Equal("5001", workflow.State.OnAirCutCode);
}
[Fact]
public async Task TakeIn_WhenFreshPrepareIsOutcomeUnknown_DoesNotPlayOrReplacePreparedState()
{
var engine = new RecordingEngine();
engine.PrepareResults.Enqueue(PlayoutResultCode.Success);
engine.PrepareResults.Enqueue(PlayoutResultCode.OutcomeUnknown);
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
var before = workflow.State;
var result = await workflow.TakeInAsync();
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(before, workflow.State);
Assert.Equal(["5001:0", "5001:0"], provider.Calls);
Assert.Equal(["Prepare:5001:p0", "Prepare:5001:p0"], engine.Calls);
Assert.DoesNotContain("TakeIn", engine.Calls);
}
[Fact]
public async Task RefreshOnAir_RequeriesTheCurrentPagedWindowAndUsesGetPlayingSceneEnginePath()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (6, ScenePageSize.Five)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5074")], 0)).IsSuccess);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
var refresh = await workflow.RefreshOnAirAsync();
Assert.True(refresh.IsSuccess);
Assert.Equal(PlayoutOperation.UpdateOnAir, refresh.Operation);
Assert.Equal(["5074:0", "5074:0", "5074:0"], provider.Calls);
Assert.Equal(
["Prepare:5074:p0", "Prepare:5074:p0", "TakeIn", "UpdateOnAir:5074:p0"],
engine.Calls);
Assert.Equal(0, workflow.State.PageIndexZeroBased);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
}
[Fact]
public async Task RefreshOnAir_OutcomeUnknownPreservesTheCurrentOnAirPage()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
var before = workflow.State;
engine.UpdateOnAirResults.Enqueue(PlayoutResultCode.OutcomeUnknown);
var result = await workflow.RefreshOnAirAsync();
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(before, workflow.State);
Assert.Equal("UpdateOnAir:5001:p0", engine.Calls[^1]);
}
[Fact]
public async Task RefreshOnAir_RequiresAnOnAirSceneBeforeQueryingData()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>());
var workflow = new LegacyPlayoutWorkflow(engine, provider);
var result = await workflow.RefreshOnAirAsync();
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
Assert.Equal(PlayoutOperation.UpdateOnAir, result.Operation);
Assert.Empty(provider.Calls);
Assert.Empty(engine.Calls);
}
[Fact]
public async Task StopAllOrCutOutStatus_ClearsNativePageStateWithoutDispatchingAgain()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (6, ScenePageSize.Five)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5074")], 0)).IsSuccess);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
workflow.ObserveEngineStatus(engine.Status with
{
PreparedSceneName = null,
OnAirSceneName = null,
Sequence = 2
});
Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal(0, workflow.State.PageCount);
Assert.Null(workflow.State.OnAirCutCode);
var next = await workflow.NextAsync();
Assert.Equal(PlayoutResultCode.Rejected, next.Code);
Assert.DoesNotContain(engine.Calls, call => call.StartsWith("UpdateOnAir", StringComparison.Ordinal));
}
private sealed class RecordingProvider(
IReadOnlyDictionary<string, (int Count, ScenePageSize? Size)> definitions)
: ILegacySceneCueProvider
{
public List<string> Calls { get; } = [];
public Func<string, int, string>? SceneNameOverride { get; init; }
public Task<LegacySceneCuePage> CreatePageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Calls.Add($"{entry.CutCode}:{pageIndexZeroBased}");
var definition = definitions[entry.CutCode];
var name = SceneNameOverride?.Invoke(entry.CutCode, pageIndexZeroBased) ?? entry.CutCode;
return Task.FromResult(new LegacySceneCuePage(
new PlayoutCue(
name + ".t2s",
name,
Mutations: [new PlayoutSetValue("page", $"p{pageIndexZeroBased}")]),
definition.Count,
definition.Size));
}
}
private sealed class RecordingEngine : IPlayoutEngine
{
public List<string> Calls { get; } = [];
public Queue<PlayoutResultCode> PrepareResults { get; } = [];
public Queue<PlayoutResultCode> UpdateOnAirResults { get; } = [];
public PlayoutStatus Status { get; } = new(
PlayoutMode.DryRun,
PlayoutConnectionState.DryRunReady,
true,
false,
true,
false,
null,
null,
"test",
DateTimeOffset.UtcNow,
0);
public event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged
{
add { }
remove { }
}
public Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default) =>
Success(PlayoutOperation.Connect);
public Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default) =>
Success(PlayoutOperation.Disconnect);
public Task<PlayoutResult> PrepareAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
Calls.Add("Prepare:" + Describe(cue));
return Complete(PlayoutOperation.Prepare, PrepareResults);
}
public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default)
{
Calls.Add("TakeIn");
return Success(PlayoutOperation.TakeIn);
}
public Task<PlayoutResult> NextAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
Calls.Add("Next:" + Describe(cue));
return Success(PlayoutOperation.Next);
}
public Task<PlayoutResult> UpdateOnAirAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
Calls.Add("UpdateOnAir:" + Describe(cue));
return Complete(PlayoutOperation.UpdateOnAir, UpdateOnAirResults);
}
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default)
{
Calls.Add("TakeOut:" + scope);
return Success(PlayoutOperation.TakeOut);
}
public ValueTask QuarantineAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private static string Describe(PlayoutCue cue)
{
var page = Assert.IsType<PlayoutSetValue>(Assert.Single(cue.Mutations!)).Value;
return $"{cue.SceneName}:{page}";
}
private static Task<PlayoutResult> Success(PlayoutOperation operation) =>
Complete(operation, null);
private static Task<PlayoutResult> Complete(
PlayoutOperation operation,
Queue<PlayoutResultCode>? results)
{
var code = results is { Count: > 0 }
? results.Dequeue()
: PlayoutResultCode.Success;
return Task.FromResult(new PlayoutResult(
operation,
code,
true,
code == PlayoutResultCode.Success ? "ok" : "failed",
DateTimeOffset.UtcNow));
}
}
}

View File

@@ -0,0 +1,108 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyPriceDirectionMutationsTests
{
[Theory]
[InlineData("상한", ScenePriceDirection.LimitUp)]
[InlineData("상승", ScenePriceDirection.Up)]
[InlineData("+", ScenePriceDirection.Up)]
[InlineData("보합", ScenePriceDirection.Flat)]
[InlineData(" ", ScenePriceDirection.Flat)]
[InlineData("하락", ScenePriceDirection.Down)]
[InlineData("-", ScenePriceDirection.Down)]
[InlineData("하한", ScenePriceDirection.LimitDown)]
[InlineData("unknown", ScenePriceDirection.Unknown)]
public void Parse_matches_legacy_direction_values(
string value,
ScenePriceDirection expected)
{
Assert.Equal(expected, LegacyPriceDirectionMutations.Parse(value));
}
[Fact]
public void Build_up_variant_one_matches_original_object_order()
{
var mutations = LegacyPriceDirectionMutations.Build(ScenePriceDirection.Up, 1);
Assert.Collection(
mutations,
mutation => Visible(mutation, "upup1", false),
mutation => Visible(mutation, "up1", false),
mutation => Visible(mutation, "flat1", false),
mutation => Visible(mutation, "down1", false),
mutation => Visible(mutation, "downdown1", false),
mutation => Visible(mutation, "up1", true),
mutation => Visible(mutation, "bg1_1", true),
mutation => Visible(mutation, "changePrice1_1", true),
mutation => Visible(mutation, "wonText1_1", true),
mutation => Visible(mutation, "rate1_1", true),
mutation => Visible(mutation, "percent1_1", true),
mutation => Visible(mutation, "bg2_1", false),
mutation => Visible(mutation, "changePrice2_1", false),
mutation => Visible(mutation, "wonText2_1", false),
mutation => Visible(mutation, "rate2_1", false),
mutation => Visible(mutation, "percent2_1", false),
mutation => Visible(mutation, "bg3_1", false),
mutation => Visible(mutation, "changePrice3_1", false),
mutation => Visible(mutation, "wonText3_1", false),
mutation => Visible(mutation, "rate3_1", false),
mutation => Visible(mutation, "percent3_1", false),
mutation => Visible(mutation, "unit1_1", true),
mutation => Visible(mutation, "unit2_1", false),
mutation => Visible(mutation, "unit3_1", false),
mutation => Asset(mutation, "pattern1", @"Images\그림_빨강.png"));
}
[Fact]
public void Build_flat_variant_three_includes_original_price_switch()
{
var mutations = LegacyPriceDirectionMutations.Build(ScenePriceDirection.Flat, 3);
Assert.Collection(
mutations.Skip(24),
mutation => Visible(mutation, "price1_", false),
mutation => Visible(mutation, "price2_", true),
mutation => Visible(mutation, "price3_", false),
mutation => Asset(mutation, "pattern", @"Images\그림_검정.png"));
}
[Fact]
public void Build_unknown_only_hides_direction_objects()
{
var mutations = LegacyPriceDirectionMutations.Build(ScenePriceDirection.Unknown, 0);
Assert.Collection(
mutations,
mutation => Visible(mutation, "upup", false),
mutation => Visible(mutation, "up", false),
mutation => Visible(mutation, "flat", false),
mutation => Visible(mutation, "down", false),
mutation => Visible(mutation, "downdown", false));
}
[Theory]
[InlineData(-1)]
[InlineData(4)]
public void Build_rejects_unknown_variant_index(int value)
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
LegacyPriceDirectionMutations.Build(ScenePriceDirection.Up, value));
}
private static void Visible(PlayoutMutation mutation, string name, bool visible)
{
var actual = Assert.IsType<PlayoutSetVisible>(mutation);
Assert.Equal(name, actual.ObjectName);
Assert.Equal(visible, actual.IsVisible);
}
private static void Asset(PlayoutMutation mutation, string name, string path)
{
var actual = Assert.IsType<PlayoutSetAssetValue>(mutation);
Assert.Equal(name, actual.ObjectName);
Assert.Equal(path, actual.AssetPath);
}
}

View File

@@ -0,0 +1,295 @@
using System.Reflection;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacySceneCatalogTests
{
private const LegacySceneMutationCapability Value =
LegacySceneMutationCapability.Value;
private const LegacySceneMutationCapability Asset =
LegacySceneMutationCapability.AssetValue;
private const LegacySceneMutationCapability Visible =
LegacySceneMutationCapability.Visibility;
private const LegacySceneMutationCapability Color =
LegacySceneMutationCapability.FaceColor;
private const LegacySceneMutationCapability Position =
LegacySceneMutationCapability.Position;
private const LegacySceneMutationCapability PositionKey =
LegacySceneMutationCapability.PositionKey;
private const LegacySceneMutationCapability Scale =
LegacySceneMutationCapability.Scale;
private const LegacySceneMutationCapability Crop =
LegacySceneMutationCapability.Crop;
private const LegacySceneMutationCapability CircleAngle =
LegacySceneMutationCapability.CircleAngleKey;
private const LegacySceneMutationCapability Path =
LegacySceneMutationCapability.PathPoints;
private const LegacySceneMutationCapability PathShape =
LegacySceneMutationCapability.PathShapePoints;
private const LegacySceneMutationCapability BackgroundVideo =
LegacySceneMutationCapability.BackgroundVideo;
private static readonly ExpectedDefinition[] ExpectedDefinitions =
[
E<S5001SceneDtoMarker>("s5001", "5001", ["5001", "N5001"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Asset),
E<S5006SceneDtoMarker>("s5006", "5006", ["5006"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | BackgroundVideo),
E<S5011SceneDtoMarker>("s5011", "5011", ["5011"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color),
E<S5016SceneDtoMarker>("s5016", "5016", ["5016"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible),
E<S50160SceneDtoMarker>("s50160", "50160", ["50160"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible),
E<S5023SceneDtoMarker>("s5023", "5023", ["5023"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Color),
E<S5024SceneDtoMarker>("s5024", "5024", ["5024"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Position | Scale),
E<S5025SceneDtoMarker>("s5025", "5025", ["5025"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value),
E<S5026SceneDtoMarker>("s5026", "5026", ["5026"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | Crop),
E<S5029SceneDtoMarker>("s5029", "5029", ["5029"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Position | PathShape),
E<S5032SceneDtoMarker>("s5032", "5032", ["8018", "8032", "5032"],
LegacyScenePageSize.None,
LegacySceneReachability.ConditionalMainFormDispatch,
Value | Visible | Asset),
E<S5037SceneDtoMarker>("s5037", "5037", ["5037"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible),
E<S5074SceneDtoMarker>("s5074", "5074", ["5074"],
LegacyScenePageSize.Five, LegacySceneReachability.MainFormDispatch,
Value | Visible | Asset),
E<S5076SceneDtoMarker>("s5076", "5076", ["5076"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | CircleAngle),
E<S5077SceneDtoMarker>("s5077", "5077", ["5077"],
LegacyScenePageSize.Six, LegacySceneReachability.MainFormDispatch,
Value | Visible | Asset),
E<S5078SceneDtoMarker>("s5078", "5078", ["5078"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Scale),
E<S5079SceneDtoMarker>("s5079", "5079", ["5079"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Path),
E<S5080SceneDtoMarker>("s5080", "5080", ["5080"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | PositionKey | Scale),
E<S5081SceneDtoMarker>("s5081", "5081", ["5081"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | PositionKey | Scale),
E<S5082SceneDtoMarker>("s5082", "5082", ["5082"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible),
E<S5083SceneDtoMarker>("s5083", "5083", ["5083"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Position | Path),
E<S5084SceneDtoMarker>("s5084", "5084", ["5084"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Position | Path),
E<S5085SceneDtoMarker>("s5085", "5085", ["5085"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Color),
E<S5086SceneDtoMarker>("s5086", "5086", ["5086"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Position | PathShape),
E<S50860SceneDtoMarker>("s50860", "50860", ["50860"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | Position | PathShape),
E<S5087SceneDtoMarker>("s5087", "5087", ["5087"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Position | PathShape),
E<S5088SceneDtoMarker>("s5088", "5088", ["5088"],
LegacyScenePageSize.Twelve, LegacySceneReachability.MainFormDispatch,
Value | Visible | Asset),
E<S6001SceneDtoMarker>("s6001", "6001", ["6001"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Asset | Visible | Color),
E<S6067SceneDtoMarker>("s6067", "6067", ["6067"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Color),
E<S8001SceneDtoMarker>("s8001", "8001", ["8001", "8002"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Color),
E<S8003SceneDtoMarker>("s8003", "8003", ["8003"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | Scale),
E<S8010SceneDtoMarker>(
"s8010",
"8010",
["8035", "8061", "8040", "8046", "8051", "8056"],
LegacyScenePageSize.None,
LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | Position | Crop | Path),
E<S8018SceneDtoMarker>("s8018", "8018", ["8018", "8032", "5032"],
LegacyScenePageSize.None,
LegacySceneReachability.ConditionalMainFormDispatch,
Value | Visible | Asset),
E<S8067SceneDtoMarker>("s8067", "8067", ["8067", "5068", "5070", "5072"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color),
E<S8086SceneDtoMarker>("s8086", "8086", [],
LegacyScenePageSize.None, LegacySceneReachability.NoMainFormDispatch,
Value | Color)
];
[Fact]
public void Definitions_MatchTheReadOnly35BuilderBaselineExactly()
{
var actual = LegacySceneCatalog.Definitions;
Assert.Equal(LegacySceneCatalog.ExpectedDefinitionCount, actual.Count);
Assert.Equal(35, actual.Count);
Assert.Equal(
ExpectedDefinitions.Select(expected => expected.BuilderKey),
actual.Select(definition => definition.BuilderKey));
foreach (var expected in ExpectedDefinitions)
{
var definition = Assert.Single(
actual,
candidate => candidate.BuilderKey == expected.BuilderKey);
Assert.Equal(expected.SceneCode, definition.SceneCode);
Assert.Equal(expected.CutAliases, definition.CutAliases);
Assert.Equal(expected.PageSize, definition.PageSize);
Assert.Equal(expected.Reachability, definition.Reachability);
Assert.Equal(expected.DtoTypeMarker, definition.DtoTypeMarker);
Assert.Equal(expected.BuilderKey, definition.SourceHashKey);
Assert.Equal(expected.Capabilities, definition.MutationCapabilities);
}
}
[Fact]
public void Definitions_HaveUniqueTypedIdentityAndOnlySupportedPageSizes()
{
var definitions = LegacySceneCatalog.Definitions;
Assert.Equal(35, definitions.Select(item => item.BuilderKey).Distinct().Count());
Assert.Equal(35, definitions.Select(item => item.SceneCode).Distinct().Count());
Assert.Equal(35, definitions.Select(item => item.SourceHashKey).Distinct().Count());
Assert.Equal(35, definitions.Select(item => item.DtoTypeMarker).Distinct().Count());
Assert.All(definitions, definition =>
{
Assert.True(typeof(ILegacySceneDtoMarker).IsAssignableFrom(
definition.DtoTypeMarker));
Assert.Contains((int)definition.PageSize, new[] { 0, 5, 6, 12 });
Assert.NotEqual(
LegacySceneMutationCapability.None,
definition.MutationCapabilities);
});
Assert.Equal(
new Dictionary<string, LegacyScenePageSize>
{
["s5074"] = LegacyScenePageSize.Five,
["s5077"] = LegacyScenePageSize.Six,
["s5088"] = LegacyScenePageSize.Twelve
},
definitions
.Where(definition => definition.PageSize != LegacyScenePageSize.None)
.ToDictionary(definition => definition.BuilderKey, definition => definition.PageSize));
}
[Fact]
public void CutAliasLookup_PreservesConditionalDispatchWithoutGuessing()
{
var direct = LegacySceneCatalog.FindByCutAlias("5001");
var conditional = LegacySceneCatalog.FindByCutAlias("8032");
Assert.Equal("s5001", Assert.Single(direct).BuilderKey);
Assert.Equal(
new[] { "s5032", "s8018" },
conditional.Select(definition => definition.BuilderKey));
Assert.All(conditional, definition => Assert.Equal(
LegacySceneReachability.ConditionalMainFormDispatch,
definition.Reachability));
Assert.Empty(LegacySceneCatalog.FindByCutAlias("8086"));
Assert.Empty(LegacySceneCatalog.FindByCutAlias("../5001.t2s"));
Assert.Empty(LegacySceneCatalog.FindByCutAlias(null));
}
[Fact]
public void SceneCodeLookup_ReturnsMetadataOnlyAndKeeps8086Unreachable()
{
var definition = LegacySceneCatalog.FindBySceneCode("8086");
Assert.NotNull(definition);
Assert.Equal("s8086", definition.BuilderKey);
Assert.Equal(LegacySceneReachability.NoMainFormDispatch, definition.Reachability);
Assert.Empty(definition.CutAliases);
Assert.Null(LegacySceneCatalog.FindBySceneCode("N5001"));
Assert.Null(LegacySceneCatalog.FindBySceneCode("5001.t2s"));
Assert.Null(LegacySceneCatalog.FindBySceneCode(null));
}
[Fact]
public void PublicCatalogSurface_DoesNotExposeRawObjectMethodOrPathInput()
{
var propertyNames = typeof(LegacySceneDefinition)
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Select(property => property.Name)
.ToArray();
var disallowedPropertyNames = new[]
{
"ObjectName",
"MethodName",
"AssetPath",
"SceneFile",
"RawMutation"
};
Assert.DoesNotContain(
propertyNames,
property => disallowedPropertyNames.Contains(property, StringComparer.Ordinal));
Assert.All(LegacySceneCatalog.Definitions, definition =>
{
Assert.DoesNotContain('/', definition.SourceHashKey);
Assert.DoesNotContain('\\', definition.SourceHashKey);
Assert.All(definition.CutAliases, alias =>
Assert.All(alias, character => Assert.True(char.IsLetterOrDigit(character))));
Assert.Empty(definition.DtoTypeMarker.GetConstructors());
Assert.Empty(definition.DtoTypeMarker.GetProperties(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly));
});
}
private static ExpectedDefinition E<TDtoMarker>(
string builderKey,
string sceneCode,
string[] cutAliases,
LegacyScenePageSize pageSize,
LegacySceneReachability reachability,
LegacySceneMutationCapability capabilities)
where TDtoMarker : ILegacySceneDtoMarker =>
new(
builderKey,
sceneCode,
cutAliases,
pageSize,
reachability,
typeof(TDtoMarker),
capabilities);
private sealed record ExpectedDefinition(
string BuilderKey,
string SceneCode,
IReadOnlyList<string> CutAliases,
LegacyScenePageSize PageSize,
LegacySceneReachability Reachability,
Type DtoTypeMarker,
LegacySceneMutationCapability Capabilities);
}

View File

@@ -0,0 +1,72 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacySceneDataSourceRouterTests
{
[Fact]
public async Task Router_DispatchesOnlyTheClosedRegisteredCutAlias()
{
LegacyPlaylistEntry? received = null;
var router = new LegacySceneDataSourceRouter(
[
new LegacySceneDataSourceRoute(["5001", "N5001"], (entry, page, _) =>
{
received = entry;
Assert.Equal(0, page);
return Task.FromResult(new LegacySceneDataPage(
"s5085",
new S5085SceneData([]),
1));
})
]);
var request = new LegacyPlaylistEntry("entry", "N5001");
var result = await router.LoadPageAsync(request, 0);
Assert.Same(request, received);
Assert.Equal("s5085", result.BuilderKey);
Assert.Equal(["5001", "N5001"], router.CutCodes);
}
[Fact]
public async Task Router_RejectsUnknownCutBeforeCallingAnyLoader()
{
var calls = 0;
var router = new LegacySceneDataSourceRouter(
[
new LegacySceneDataSourceRoute(["5001"], (entry, page, _) =>
{
calls++;
return Task.FromResult(new LegacySceneDataPage(
"s5085",
new S5085SceneData([]),
1));
})
]);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
router.LoadPageAsync(new LegacyPlaylistEntry("entry", "9999"), 0));
Assert.Equal(0, calls);
}
[Fact]
public void Constructor_RejectsDuplicateOrUnsafeRoutes()
{
static Task<LegacySceneDataPage> Load(
LegacyPlaylistEntry entry,
int page,
CancellationToken token) => throw new NotSupportedException();
Assert.Throws<ArgumentException>(() => new LegacySceneDataSourceRouter(
[
new LegacySceneDataSourceRoute(["5001"], Load),
new LegacySceneDataSourceRoute(["5001"], Load)
]));
Assert.Throws<ArgumentException>(() =>
new LegacySceneDataSourceRoute(["../5001"], Load));
Assert.Throws<ArgumentException>(() =>
new LegacySceneDataSourceRoute([], Load));
}
}

View File

@@ -0,0 +1,146 @@
using System.Reflection;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacySceneMutationBuilderRegistryTests
{
[Fact]
public void Default_HasExactOneToOneParityWithThe35SceneCatalog()
{
var registry = LegacySceneMutationBuilderRegistry.Default;
var expectedKeys = LegacySceneCatalog.Definitions
.Select(definition => definition.BuilderKey)
.ToArray();
Assert.Equal(35, registry.Registrations.Count);
Assert.Equal(LegacySceneCatalog.ExpectedDefinitionCount, registry.Registrations.Count);
Assert.Equal(expectedKeys, registry.Registrations.Select(item => item.BuilderKey));
Assert.Equal(
expectedKeys.OrderBy(key => key, StringComparer.Ordinal),
registry.Registrations
.Select(item => item.BuilderKey)
.Distinct(StringComparer.Ordinal)
.OrderBy(key => key, StringComparer.Ordinal));
Assert.All(registry.Registrations, registration =>
{
Assert.True(registry.TryGetRegistration(registration.BuilderKey, out var found));
Assert.Same(registration, found);
Assert.True(typeof(ILegacySceneData).IsAssignableFrom(registration.DataType));
Assert.NotEqual(typeof(ILegacySceneData), registration.DataType);
Assert.False(registration.DataType.IsInterface);
Assert.False(registration.DataType.ContainsGenericParameters);
Assert.False(typeof(ILegacySceneDtoMarker).IsAssignableFrom(registration.DataType));
});
}
[Fact]
public void CoreAssembly_ContainsExactly35PublicParameterlessTypedBuilders()
{
var builderContract = typeof(ILegacySceneMutationBuilder<>);
var builders = typeof(LegacySceneMutationBuilderRegistry)
.Assembly
.DefinedTypes
.Where(type =>
type.IsClass &&
!type.IsAbstract &&
type.ImplementedInterfaces.Any(contract =>
contract.IsGenericType &&
contract.GetGenericTypeDefinition() == builderContract))
.ToArray();
Assert.Equal(35, builders.Length);
Assert.All(builders, builder =>
{
Assert.True(builder.IsPublic);
Assert.NotNull(builder.GetConstructor(Type.EmptyTypes));
Assert.Single(builder.ImplementedInterfaces, contract =>
contract.IsGenericType &&
contract.GetGenericTypeDefinition() == builderContract);
});
}
[Fact]
public void Build_DispatchesToTheRegisteredTypedBuilderAndReturnsAReadOnlySnapshot()
{
var registry = LegacySceneMutationBuilderRegistry.Default;
var data = new S5085SceneData(
[
new ProgramTradingMetricData(ProgramTradingMetric.NetBuy, 120.5)
]);
var expected = new S5085SceneMutationBuilder().Build(data);
var actual = registry.Build("s5085", data);
Assert.Equal(expected, actual);
var mutableView = Assert.IsAssignableFrom<IList<PlayoutMutation>>(actual);
Assert.Throws<NotSupportedException>(() => mutableView.Add(
new PlayoutSetValue("untrusted", "untrusted")));
}
[Fact]
public void Build_RejectsUnknownKeysAndDataRegisteredForAnotherScene()
{
var registry = LegacySceneMutationBuilderRegistry.Default;
var wrongData = new S6067SceneData([]);
Assert.Throws<ArgumentException>(() => registry.Build("s5085", wrongData));
Assert.Throws<ArgumentException>(() => registry.Build("s9999", wrongData));
Assert.Throws<ArgumentException>(() => registry.Build("../s5085", wrongData));
Assert.Throws<ArgumentNullException>(() => registry.Build("s5085", null!));
Assert.False(registry.TryGetRegistration("s9999", out var unknown));
Assert.Null(unknown);
Assert.False(registry.TryGetRegistration("../s5085", out var invalid));
Assert.Null(invalid);
}
[Fact]
public void PublicRegistryInputSurface_HasNoRawMethodObjectMutationOrPathControls()
{
var prohibitedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"methodName",
"objectName",
"assetPath",
"sceneFile",
"rawMutation"
};
var allowedInputTypes = new HashSet<Type>
{
typeof(string),
typeof(ILegacySceneData),
typeof(LegacySceneMutationBuilderRegistration).MakeByRefType()
};
var publicMethods = typeof(LegacySceneMutationBuilderRegistry)
.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
Assert.All(publicMethods.SelectMany(method => method.GetParameters()), parameter =>
{
Assert.DoesNotContain(parameter.Name!, prohibitedNames);
Assert.Contains(parameter.ParameterType, allowedInputTypes);
if (parameter.ParameterType == typeof(string))
{
Assert.Equal("builderKey", parameter.Name);
}
});
Assert.Equal(
new[] { "BuilderKey", "DataType" },
typeof(LegacySceneMutationBuilderRegistration)
.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
.Select(property => property.Name)
.OrderBy(name => name, StringComparer.Ordinal));
Assert.Empty(typeof(LegacySceneMutationBuilderRegistration)
.GetConstructors(BindingFlags.Instance | BindingFlags.Public));
Assert.All(LegacySceneMutationBuilderRegistry.Default.Registrations, registration =>
{
var rootDataProperties = registration.DataType
.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
Assert.DoesNotContain(rootDataProperties, property => prohibitedNames.Contains(property.Name));
});
}
}

View File

@@ -0,0 +1,51 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyScenePreviewTests
{
[Fact]
public void Preview_DescribesTypedValuesAndNeverExposesAssetPaths()
{
const string secretAsset = @"Video\operator-private-background.vrv";
var preview = LegacyScenePreviewFactory.Create(
[
new PlayoutSetValue("title", "삼성전자"),
new PlayoutSetAssetValue("logo", @"Image\005930.png"),
new PlayoutSetVisible("up", true),
new PlayoutSetFaceColor("cube", 1, 2, 3, 4),
new PlayoutSetPosition("bar", 1, 2, 3, PlayoutVectorComponents.XYZ),
new PlayoutSetPositionKey("bar", 2, 4, 5, 6, PlayoutVectorComponents.XY),
new PlayoutSetScale("bar", 7, 8, 9, PlayoutVectorComponents.XYZ),
new PlayoutSetCropKey("candle", 1, 2, 3, 4, 5, PlayoutCropEdges.All),
new PlayoutSetCircleAngleKey("pie", 3, 10, 20, PlayoutAngleComponents.All),
new PlayoutSetPathPoints("line", [new(1, 2), new(3, 4, 5)]),
new PlayoutSetPathShapePoints("shape", [new(6, 7)]),
new PlayoutSetBackgroundTexture(secretAsset),
new PlayoutSetBackgroundVideo(secretAsset, 2004, true),
new PlayoutUseBackground(true)
]);
Assert.Collection(
preview,
value => Assert.Equal(new("value", "title", "삼성전자"), value),
value => Assert.Equal(new("asset", "logo", "configured"), value),
value => Assert.Equal(new("visible", "up", "true"), value),
value => Assert.Equal(new("color", "cube", "1,2,3,4"), value),
value => Assert.Equal(new("position", "bar", "1,2,3 [XYZ]"), value),
value => Assert.Equal(new("position-key", "bar", "key=2;4,5,6 [XY]"), value),
value => Assert.Equal(new("scale", "bar", "7,8,9 [XYZ]"), value),
value => Assert.Equal(new("crop", "candle", "key=1;2,3,4,5 [All]"), value),
value => Assert.Equal(new("angle", "pie", "key=3;10,20 [All]"), value),
value => Assert.Equal(new("path", "line", "count=2;1,2,0;3,4,5"), value),
value => Assert.Equal(new("path-shape", "shape", "count=1;6,7,0"), value),
value => Assert.Equal(new("background-texture", "scene", "configured"), value),
value => Assert.Equal(
new("background-video", "scene", "configured;loop=2004;infinite=true"),
value),
value => Assert.Equal(new("background", "scene", "enabled"), value));
Assert.DoesNotContain(secretAsset, string.Join("|", preview.Select(value => value.Value)));
Assert.DoesNotContain("005930.png", string.Join("|", preview.Select(value => value.Value)));
}
}

View File

@@ -0,0 +1,47 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacySceneRefreshPolicyTests
{
public static TheoryData<string, int> OriginalMainFormIntervals => new()
{
{ "5001", 2 }, { "N5001", 2 }, { "5006", 5 }, { "5011", 5 },
{ "5016", 5 }, { "50160", 30 }, { "5023", 30 }, { "5024", 60 },
{ "5025", 30 }, { "5026", 8 }, { "5029", 7 }, { "5032", 5 },
{ "5037", 5 }, { "5074", 7 }, { "5076", 3_000_000 }, { "5077", 7 },
{ "5078", 30 }, { "5079", 3_000_000 }, { "5080", 3_000_000 },
{ "5081", 3_000_000 }, { "5082", 6 }, { "5083", 60 }, { "5084", 5 },
{ "5085", 30 }, { "5086", 7 }, { "50860", 7 }, { "5087", 7 },
{ "5088", 7 }, { "6001", 30 }, { "6067", 30 }, { "8001", 120 },
{ "8002", 120 }, { "8003", 8 }, { "8018", 5 }, { "8032", 5 },
{ "8035", 8 }, { "8040", 8 }, { "8046", 8 }, { "8051", 8 },
{ "8056", 8 }, { "8061", 8 }, { "8067", 30 }, { "5068", 30 },
{ "5070", 30 }, { "5072", 30 }
};
[Theory]
[MemberData(nameof(OriginalMainFormIntervals))]
public void EveryActiveAliasUsesOriginalMainFormInterval(string alias, int seconds)
{
Assert.True(LegacySceneRefreshPolicy.TryGetInterval(alias, out var interval));
Assert.Equal(TimeSpan.FromSeconds(seconds), interval);
}
[Fact]
public void PolicyCoversExactlyTheCatalogsActiveAliases()
{
var aliases = LegacySceneCatalog.Definitions
.SelectMany(definition => definition.CutAliases)
.Distinct(StringComparer.Ordinal)
.ToArray();
Assert.Equal(45, aliases.Length);
Assert.Equal(aliases.Length, LegacySceneRefreshPolicy.Count);
Assert.All(aliases, alias =>
Assert.True(LegacySceneRefreshPolicy.TryGetInterval(alias, out _), alias));
Assert.False(LegacySceneRefreshPolicy.TryGetInterval("8086", out _));
Assert.False(LegacySceneRefreshPolicy.TryGetInterval("../5001", out _));
Assert.Equal(TimeSpan.FromSeconds(3), LegacySceneRefreshPolicy.RecurringInterval);
}
}

View File

@@ -0,0 +1,145 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacySceneRuntimeCoverageTests
{
private static readonly string[] ExpectedAliases =
[
"5001", "5006", "5011", "5016", "50160", "5023", "5024", "5025",
"5026", "5029", "5032", "5037", "5068", "5070", "5072", "5074",
"5076", "5077", "5078", "5079", "5080", "5081", "5082", "5083",
"5084", "5085", "5086", "50860", "5087", "5088", "6001", "6067",
"8001", "8002", "8003", "8018", "8032", "8035", "8040", "8046",
"8051", "8056", "8061", "8067", "N5001"
];
[Fact]
public void Complete_router_matches_the_independent_34_builder_45_alias_baseline()
{
var router = Router(ExpectedAliases);
var report = LegacySceneRuntimeCoverage.Analyze(router);
Assert.True(report.IsComplete);
Assert.Equal(34, report.ReachableBuilderKeys.Count);
Assert.Equal(45, report.ExpectedCutCodes.Count);
Assert.Equal(ExpectedAliases, report.ExpectedCutCodes);
Assert.Equal(ExpectedAliases, report.RegisteredCutCodes);
Assert.Empty(report.MissingCutCodes);
Assert.Empty(report.UnexpectedCutCodes);
Assert.Empty(report.UncoveredBuilderKeys);
Assert.Equal(["s8086"], report.UnreachableBuilderKeys);
Assert.DoesNotContain("s8086", report.ReachableBuilderKeys);
Assert.DoesNotContain("8086", report.ExpectedCutCodes);
Assert.DoesNotContain("8010", report.ExpectedCutCodes);
var validated = LegacySceneRuntimeCoverage.Validate(router);
Assert.True(validated.IsComplete);
}
[Fact]
public void Conditional_aliases_are_owned_only_by_s5032_and_s8018()
{
var report = LegacySceneRuntimeCoverage.Validate(Router(ExpectedAliases));
Assert.Equal(
new[] { "5032", "8018", "8032" },
report.ConditionalAliasOwners.Keys.OrderBy(key => key, StringComparer.Ordinal));
Assert.All(report.ConditionalAliasOwners.Values, owners =>
Assert.Equal(new[] { "s5032", "s8018" }, owners));
Assert.Contains("s5032", report.ReachableBuilderKeys);
Assert.Contains("s8018", report.ReachableBuilderKeys);
}
[Fact]
public void Missing_direct_alias_identifies_the_single_uncovered_builder()
{
var router = Router(ExpectedAliases.Where(alias => alias != "5078"));
var report = LegacySceneRuntimeCoverage.Analyze(router);
Assert.False(report.IsComplete);
Assert.Equal(["5078"], report.MissingCutCodes);
Assert.Empty(report.UnexpectedCutCodes);
Assert.Equal(["s5078"], report.UncoveredBuilderKeys);
Assert.Throws<InvalidOperationException>(() =>
LegacySceneRuntimeCoverage.Validate(router));
}
[Fact]
public void Missing_shared_alias_marks_both_conditional_builders_uncovered()
{
var router = Router(ExpectedAliases.Where(alias => alias != "8032"));
var report = LegacySceneRuntimeCoverage.Analyze(router);
Assert.False(report.IsComplete);
Assert.Equal(["8032"], report.MissingCutCodes);
Assert.Equal(new[] { "s5032", "s8018" }, report.UncoveredBuilderKeys);
Assert.Equal(
new[] { "s5032", "s8018" },
report.ConditionalAliasOwners["8032"]);
}
[Fact]
public void Unreachable_s8086_alias_is_rejected_as_an_unexpected_runtime_route()
{
var router = Router(ExpectedAliases.Append("8086"));
var report = LegacySceneRuntimeCoverage.Analyze(router);
Assert.False(report.IsComplete);
Assert.Empty(report.MissingCutCodes);
Assert.Equal(["8086"], report.UnexpectedCutCodes);
Assert.Empty(report.UncoveredBuilderKeys);
Assert.Equal(["s8086"], report.UnreachableBuilderKeys);
Assert.Throws<InvalidOperationException>(() =>
LegacySceneRuntimeCoverage.Validate(router));
}
[Fact]
public void Unknown_extra_alias_is_rejected_even_when_all_expected_aliases_exist()
{
var router = Router(ExpectedAliases.Append("9999"));
var report = LegacySceneRuntimeCoverage.Analyze(router);
Assert.False(report.IsComplete);
Assert.Equal(["9999"], report.UnexpectedCutCodes);
Assert.Equal(46, report.RegisteredCutCodes.Count);
}
[Fact]
public void Public_expected_alias_surface_is_sorted_stable_and_read_only()
{
Assert.Equal(45, LegacySceneRuntimeCoverage.ExpectedActiveAliasCount);
Assert.Equal(34, LegacySceneRuntimeCoverage.ExpectedReachableBuilderCount);
Assert.Equal(ExpectedAliases, LegacySceneRuntimeCoverage.ExpectedCutCodes);
Assert.Equal(
ExpectedAliases.OrderBy(alias => alias, StringComparer.Ordinal),
LegacySceneRuntimeCoverage.ExpectedCutCodes);
Assert.IsAssignableFrom<IReadOnlyList<string>>(
LegacySceneRuntimeCoverage.ExpectedCutCodes);
}
[Fact]
public void Null_router_is_rejected()
{
Assert.Throws<ArgumentNullException>(() =>
LegacySceneRuntimeCoverage.Analyze(null!));
Assert.Throws<ArgumentNullException>(() =>
LegacySceneRuntimeCoverage.Validate(null!));
}
private static LegacySceneDataSourceRouter Router(IEnumerable<string> cutCodes) => new(
[
new LegacySceneDataSourceRoute(cutCodes, Load)
]);
private static Task<LegacySceneDataPage> Load(
LegacyPlaylistEntry entry,
int page,
CancellationToken cancellationToken) =>
throw new NotSupportedException();
}

View File

@@ -0,0 +1,177 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyWebWorkflowIntegrationTests
{
[Fact]
public async Task WebPrepareThenNext_UsesNativeDtoBuilderAndSameScenePageDecision()
{
var engine = new RecordingEngine();
var rows = Enumerable.Range(1, 6)
.Select(index => new PagedQuoteRowData(
"종목" + index,
index * 100,
index,
index / 10d,
ScenePriceDirection.Up))
.ToArray();
var dataSource = new LegacySceneDataSourceRouter(
[
new LegacySceneDataSourceRoute(["5074"], (entry, page, token) =>
{
token.ThrowIfCancellationRequested();
var data = new PagedQuoteSceneData(
PagedQuoteBranch.Domestic,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"상승률",
rows,
page + 1);
return Task.FromResult(new LegacySceneDataPage("s5074", data, rows.Length));
})
]);
var workflow = new LegacyPlayoutWorkflow(
engine,
new RegisteredLegacySceneCueProvider(dataSource));
using var prepareJson = JsonDocument.Parse("""
{
"requestId":"REQ_PREPARE",
"command":"prepare",
"playlist":[{
"id":"entry-1",
"code":"5074",
"enabled":true,
"selection":{
"groupCode":"KOSPI_STOCK",
"subject":"테스트",
"graphicType":"",
"subtype":"",
"dataCode":"000001"
}
}],
"selectedIndex":0
}
""");
var prepare = PlayoutBridgeProtocol.ParseWorkflowCommand(prepareJson.RootElement);
Assert.True(prepare.IsValid);
Assert.True((await workflow.PrepareAsync(
prepare.Request.Playlist!,
prepare.Request.SelectedIndexZeroBased)).IsSuccess);
Assert.Equal("entry-1", workflow.State.CurrentEntryId);
Assert.Equal("s5074", workflow.State.BuilderKey);
Assert.Equal(5, workflow.State.CurrentPageItemCount);
Assert.NotEmpty(workflow.State.PreviewFields);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
using var nextJson = JsonDocument.Parse(
"{\"requestId\":\"REQ_NEXT\",\"command\":\"next\"}");
var next = PlayoutBridgeProtocol.ParseWorkflowCommand(nextJson.RootElement);
Assert.True(next.IsValid);
Assert.Null(next.Request.Playlist);
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal(2, workflow.State.PageCount);
Assert.True(workflow.State.IsLastPage);
Assert.Equal(1, workflow.State.CurrentPageItemCount);
Assert.Contains(workflow.State.PreviewFields, field =>
field is { Kind: "value", Name: "title1", Value: "종목6" });
Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
Assert.Equal(
["prepare:5074", "prepare:5074", "take-in", "load-next:5074"],
engine.Calls);
Assert.NotNull(engine.LastCue?.Mutations);
Assert.Equal(new PlayoutUseBackground(false), engine.LastCue!.Mutations![0]);
Assert.Contains(engine.LastCue.Mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "title1", Value: "종목6" });
}
private sealed class RecordingEngine : IPlayoutEngine
{
public List<string> Calls { get; } = [];
public PlayoutCue? LastCue { get; private set; }
public PlayoutStatus Status { get; } = new(
PlayoutMode.DryRun,
PlayoutConnectionState.DryRunReady,
true,
false,
true,
false,
null,
null,
"test",
DateTimeOffset.UtcNow,
0);
public event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged
{
add { }
remove { }
}
public Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default) =>
Success(PlayoutOperation.Connect);
public Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default) =>
Success(PlayoutOperation.Disconnect);
public Task<PlayoutResult> PrepareAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
LastCue = cue;
Calls.Add("prepare:" + cue.SceneName);
return Success(PlayoutOperation.Prepare);
}
public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default)
{
Calls.Add("take-in");
return Success(PlayoutOperation.TakeIn);
}
public Task<PlayoutResult> NextAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
LastCue = cue;
Calls.Add("load-next:" + cue.SceneName);
return Success(PlayoutOperation.Next);
}
public Task<PlayoutResult> UpdateOnAirAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
LastCue = cue;
Calls.Add("page-next:" + cue.SceneName);
return Success(PlayoutOperation.UpdateOnAir);
}
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default) =>
Success(PlayoutOperation.TakeOut);
public ValueTask QuarantineAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private static Task<PlayoutResult> Success(PlayoutOperation operation) =>
Task.FromResult(new PlayoutResult(
operation,
PlayoutResultCode.Success,
true,
"ok",
DateTimeOffset.UtcNow));
}
}

View File

@@ -0,0 +1,142 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ManualSceneDataLoadersTests
{
[Fact]
public async Task S5076_UsesAParameterizedOracleQueryAndMapsExplicitDto()
{
var table = Table(
["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"],
["A사", "2026.07", "국내_25.5", "해외_74.5", "", "", ""]);
var executor = new RecordingExecutor(table);
var data = await new S5076SceneDataLoader(executor)
.LoadAsync("A사", CancellationToken.None);
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.Equal("INPUT_PIE", executor.TableName);
Assert.NotNull(executor.Spec);
Assert.Contains("WHERE STOCK_NAME = :stockName", executor.Spec.Sql, StringComparison.Ordinal);
var parameter = Assert.Single(executor.Spec.Parameters);
Assert.Equal("stockName", parameter.Name);
Assert.Equal("A사", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
Assert.Equal("A사", data.StockName);
Assert.Equal("2026.07", data.Basis);
Assert.Equal(new RevenueSliceData("국내", "25.5", 25.5), data.Slices[0]);
Assert.Equal(new RevenueSliceData("해외", "74.5", 74.5), data.Slices[1]);
Assert.Null(data.Slices[2]);
}
[Fact]
public async Task S5081_MapsSixTypedQuarterValuesWithoutStringSqlFallback()
{
var table = Table(
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
["A사", "1Q_100", "2Q_-50", "3Q_", "4Q_25", "5Q_1,000", "6Q_-2"]);
var executor = new RecordingExecutor(table);
var data = await new S5081SceneDataLoader(executor)
.LoadAsync("A사", CancellationToken.None);
Assert.Equal(
new[] { 100, -50, 0, 25, 1000, -2 },
data.Quarters.Select(item => item.Value));
Assert.Equal(
new[] { "1Q", "2Q", "3Q", "4Q", "5Q", "6Q" },
data.Quarters.Select(item => item.Quarter));
Assert.Equal(1, executor.SpecCalls);
Assert.Equal(0, executor.StringCalls);
}
[Theory]
[InlineData(0)]
[InlineData(2)]
public async Task S5076_RejectsMissingOrAmbiguousRowsBeforeMutation(int rowCount)
{
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 LoaderErrorsDoNotIncludeTheSelectedStockName()
{
const string sensitiveSelection = "SENSITIVE-SELECTION";
var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5076SceneDataLoader(new RecordingExecutor(new DataTable()))
.LoadAsync(sensitiveSelection, CancellationToken.None));
Assert.DoesNotContain(sensitiveSelection, exception.Message, StringComparison.Ordinal);
}
private static DataTable Table(string[] columns, object?[]? row = null)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(object));
}
if (row is not null)
{
table.Rows.Add(row);
}
return table;
}
private sealed class RecordingExecutor(DataTable result) : IDataQueryExecutor
{
public DataSourceKind Source { get; private set; }
public string? TableName { get; private set; }
public DataQuerySpec? Spec { get; private set; }
public int SpecCalls { get; private set; }
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String query fallback is forbidden in this test.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String query fallback is forbidden in this test.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Source = source;
TableName = tableName;
Spec = query;
SpecCalls++;
return Task.FromResult(result.Copy());
}
}
}

View File

@@ -0,0 +1,388 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class MarketPanelSceneDataLoadersTests
{
private static readonly string[] PanelColumns =
["ORDINAL", "NAME", "PART_NAME", "PRICE", "CHANGE", "RATE", "SIGNAL"];
private static readonly string[] QuoteColumns =
["TITLE", "PRICE", "CHANGE", "RATE", "SIGNAL"];
private static readonly string[] WorldColumns =
["ORDINAL", "MARKET_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE", "SIGNAL"];
public static TheoryData<PanelRouteCase> S5016Cases => new()
{
P(S5016PanelTarget.UnitedStatesIndices, "t_world_ix_eq_master", "symbol1", "DJI@DJI"),
P(S5016PanelTarget.GreaterChinaIndices, "t_world_ix_eq_master", "symbol1", "HSI@HSI"),
P(S5016PanelTarget.EuropeanIndices, "t_world_ix_eq_master", "symbol1", "LNS@FTSE100"),
P(S5016PanelTarget.AsianIndices, "t_world_ix_eq_master", "symbol1", "SHS@000001"),
P(S5016PanelTarget.UnitedStatesTreasuries, "t_world_ix_eq_master", "symbol1", "GVO@TR02Y"),
P(S5016PanelTarget.BondYields, "t_world_ix_eq_master", "symbol1", "KIR@CMAA"),
P(S5016PanelTarget.Oil, "t_world_future_master", "symbol1", "SPT@DU"),
P(S5016PanelTarget.Minerals, "t_world_future_master", "symbol1", "COM@GC"),
P(S5016PanelTarget.FoodMaterials, "t_world_future_master", "symbol1", "CBT$CORN"),
P(S5016PanelTarget.OverseasExchangeRates, "t_world_forex_master", "symbol1", "USDJPYCOMP"),
P(S5016PanelTarget.DomesticExchangeRates, "t_input_index", "symbol1", "A09"),
P(S5016PanelTarget.AgricultureWheatCorn, "t_world_future_master", "symbol1", "MK@WHEAT"),
P(S5016PanelTarget.AgricultureSoyRice, "t_world_future_master", "symbol1", "CBT@SOYBEAN"),
P(S5016PanelTarget.AgricultureCoffeeCocoaSugar, "t_world_future_master", "symbol1", "NYB@KC"),
P(S5016PanelTarget.AgricultureLivestock, "t_world_future_master", "symbol1", "MK@LC"),
P(S5016PanelTarget.RawMaterialsCopperIronNickel, "t_world_future_master", "symbol1", "LME@CDY"),
P(S5016PanelTarget.RawMaterialsGasPlatinumPalladium, "t_world_future_master", "symbol1", "NYM@NG"),
P(S5016PanelTarget.RawMaterialsLeadZincTin, "t_world_future_master", "symbol1", "MK@PDA"),
P(S5016PanelTarget.MajorKospiKosdaqKospi200, "FROM t_index", "partCode1", "001"),
P(S5016PanelTarget.MajorKospiKosdaqFutures, "FROM t_index", "partCode1", "001"),
P(S5016PanelTarget.ExpectedKospiKosdaqKospi200, "FROM t_fo_index", "partCode1", "001"),
P(S5016PanelTarget.ExpectedKospiKosdaqFutures, "FROM t_fo_index", "partCode1", "001")
};
public static TheoryData<S50160PanelTarget, string> S50160Cases => new()
{
{ S50160PanelTarget.Cotton, "MK@COTTON" },
{ S50160PanelTarget.InternationalPreciousMetals, "COM@GC" },
{ S50160PanelTarget.DomesticPreciousMetals, "MK@GC" }
};
public static TheoryData<S6001RouteCase> S6001Cases => new()
{
Q(S6001QuoteTarget.DowJones, "DJI@DJI", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.Nasdaq, "NAS@IXIC", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.StandardAndPoor500, "SPI@SPX", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.Germany, "XTR@DAX30", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.UnitedKingdom, "LNS@FTSE100", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.France, "PAS@CAC40", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.Japan, "NII@NI225", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.China, "SHS@000001", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.HongKong, "HSI@HSI", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.Taiwan, "TWS@TI01", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.Singapore, "SGI@STI", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.Thailand, "THI@SET", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.Philippines, "PHI@COMP", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.Malaysia, "IDI@JKSE", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.Indonesia, "MYI@KLSE", "t_world_ix_eq_master"),
Q(S6001QuoteTarget.DubaiCrude, "SPT@DU", "t_world_future_master"),
Q(S6001QuoteTarget.BrentCrude, "SPT@EB", "t_world_future_master"),
Q(S6001QuoteTarget.WtiCrude, "SPT@CL", "t_world_future_master"),
Q(S6001QuoteTarget.Gold, "COM@GC", "t_world_future_master")
};
public static TheoryData<S8086DiagnosticTarget, string, CommodityUnit> S8086Cases => new()
{
{ S8086DiagnosticTarget.DubaiCrude, "SPT@DU", CommodityUnit.DollarsPerBarrel },
{ S8086DiagnosticTarget.BrentCrude, "SPT@EB", CommodityUnit.DollarsPerBarrel },
{ S8086DiagnosticTarget.WtiCrude, "SPT@CL", CommodityUnit.DollarsPerBarrel },
{ S8086DiagnosticTarget.Gold, "COM@GC", CommodityUnit.DollarsPerOunce },
{ S8086DiagnosticTarget.Silver, "COM@SI", CommodityUnit.DollarsPerOunce },
{ S8086DiagnosticTarget.Copper, "LME@CDY", CommodityUnit.DollarsPerOunce }
};
[Theory]
[MemberData(nameof(S5016Cases))]
public async Task S5016_all_closed_targets_use_ordered_parameterized_three_row_queries(
PanelRouteCase item)
{
var executor = new RecordingExecutor(PanelTable(3));
var data = await new S5016SceneDataLoader(executor).LoadAsync(
item.Target,
CancellationToken.None);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("S5016_PANEL", call.TableName);
Assert.Contains(item.SourceFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("ORDER BY ORDINAL", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(call.Spec.Parameters, parameter =>
parameter.Name == item.ParameterName && Equals(parameter.Value, item.ParameterValue));
Assert.All(call.Spec.Parameters, parameter => Assert.Equal(DbType.String, parameter.DbType));
Assert.Equal(["Name1", "Name2", "Name3"], data.Quotes.Select(quote => quote.Name));
Assert.Equal(0, executor.StringCalls);
var mutations = new S5016SceneMutationBuilder().Build(data);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "title3" });
}
[Theory]
[MemberData(nameof(S50160Cases))]
public async Task S50160_targets_bind_source_symbols_and_require_two_ordered_rows(
S50160PanelTarget target,
string firstSymbol)
{
var executor = new RecordingExecutor(PanelTable(2));
var data = await new S50160SceneDataLoader(executor).LoadAsync(
target,
CancellationToken.None);
var call = Assert.Single(executor.Calls);
Assert.Equal("S50160_PANEL", call.TableName);
Assert.Contains("t_world_future_master", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(call.Spec.Parameters, parameter =>
parameter.Name == "symbol1" && Equals(parameter.Value, firstSymbol));
Assert.Equal(2, data.Quotes.Count);
Assert.NotEmpty(new S50160SceneMutationBuilder().Build(data));
}
[Theory]
[MemberData(nameof(S6001Cases))]
public async Task S6001_target_selects_one_bound_symbol_and_only_builder_owned_relative_asset(
S6001RouteCase item)
{
var executor = new RecordingExecutor(QuoteTable());
var data = await new S6001SceneDataLoader(executor).LoadAsync(
item.Target,
CancellationToken.None);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("S6001_QUOTE", call.TableName);
Assert.Contains(item.SourceFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("symbol", parameter.Name);
Assert.Equal(item.Symbol, parameter.Value);
Assert.DoesNotContain(item.Symbol, call.Spec.Sql, StringComparison.Ordinal);
var assets = new S6001SceneMutationBuilder().Build(data)
.OfType<PlayoutSetAssetValue>()
.ToArray();
Assert.Contains(assets, asset => asset.ObjectName == "movie");
Assert.All(assets, asset =>
{
Assert.False(Path.IsPathRooted(asset.AssetPath));
Assert.DoesNotContain(':', asset.AssetPath);
Assert.DoesNotContain("..", asset.AssetPath, StringComparison.Ordinal);
});
Assert.DoesNotContain(
typeof(S6001SceneDataLoader).GetMethods().SelectMany(method => method.GetParameters()),
parameterInfo => parameterInfo.Name?.Contains("path", StringComparison.OrdinalIgnoreCase) == true);
}
[Theory]
[MemberData(nameof(S8086Cases))]
public async Task S8086_diagnostic_loader_is_closed_source_only_and_has_no_cut_alias(
S8086DiagnosticTarget target,
string symbol,
CommodityUnit unit)
{
var executor = new RecordingExecutor(QuoteTable());
var data = await new S8086DiagnosticSceneDataLoader(executor).LoadAsync(
target,
CancellationToken.None);
Assert.Equal(unit, data.Unit);
var call = Assert.Single(executor.Calls);
Assert.Equal("S8086_DIAGNOSTIC", call.TableName);
Assert.Equal(symbol, Assert.Single(call.Spec.Parameters).Value);
Assert.NotEmpty(new S8086SceneMutationBuilder().Build(data));
var definition = Assert.Single(
LegacySceneCatalog.Definitions,
item => item.BuilderKey == "s8086");
Assert.Empty(definition.CutAliases);
}
[Fact]
public async Task S8067_requires_all_eleven_markets_in_fixed_order_and_generates_map_mutations()
{
var executor = new RecordingExecutor(WorldTable());
var data = await new S8067SceneDataLoader(executor).LoadAsync();
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("S8067_WORLD_MAP", call.TableName);
Assert.Equal(11, call.Spec.Parameters.Count);
Assert.Contains("UNION ALL", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("ORDER BY ORDINAL", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal(Enum.GetValues<WorldMarket>(), data.Quotes.Select(quote => quote.Market));
Assert.NotEmpty(new S8067SceneMutationBuilder().Build(data));
}
[Fact]
public async Task Panel_schemas_counts_ordinals_and_signals_fail_closed()
{
var shortPanel = PanelTable(2);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5016SceneDataLoader(new RecordingExecutor(shortPanel)).LoadAsync(
S5016PanelTarget.Oil,
CancellationToken.None));
var wrongOrder = PanelTable(3);
wrongOrder.Rows[1]["ORDINAL"] = 3;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5016SceneDataLoader(new RecordingExecutor(wrongOrder)).LoadAsync(
S5016PanelTarget.Oil,
CancellationToken.None));
var unknownSignal = QuoteTable();
unknownSignal.Rows[0]["SIGNAL"] = "UNKNOWN";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S6001SceneDataLoader(new RecordingExecutor(unknownSignal)).LoadAsync(
S6001QuoteTarget.Gold,
CancellationToken.None));
var reordered = Table(
["NAME", "ORDINAL", "PART_NAME", "PRICE", "CHANGE", "RATE", "SIGNAL"]);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S50160SceneDataLoader(new RecordingExecutor(reordered)).LoadAsync(
S50160PanelTarget.Cotton,
CancellationToken.None));
}
[Fact]
public async Task Invalid_closed_targets_fail_before_database_access()
{
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5016SceneDataLoader(executor).LoadAsync((S5016PanelTarget)999));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S50160SceneDataLoader(executor).LoadAsync((S50160PanelTarget)999));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S6001SceneDataLoader(executor).LoadAsync((S6001QuoteTarget)999));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8086DiagnosticSceneDataLoader(executor).LoadAsync((S8086DiagnosticTarget)999));
Assert.Empty(executor.Calls);
}
private static PanelRouteCase P(
S5016PanelTarget target,
string sourceFragment,
string parameterName,
string parameterValue) =>
new(target, sourceFragment, parameterName, parameterValue);
private static S6001RouteCase Q(
S6001QuoteTarget target,
string symbol,
string sourceFragment) =>
new(target, symbol, sourceFragment);
private static DataTable PanelTable(int count)
{
var table = Table(PanelColumns);
for (var index = 1; index <= count; index++)
{
table.Rows.Add(
index,
"Name" + index,
"Part" + index,
100m + index,
index == 2 ? -2m : index,
index == 2 ? -1.5m : index,
index switch { 1 => "+", 2 => "-", _ => "" });
}
return table;
}
private static DataTable QuoteTable() => Table(
QuoteColumns,
["Quote", 100.25m, -1.5m, -1.48m, "-"]);
private static DataTable WorldTable()
{
string[] codes =
[
"KOREA", "CHINA", "HONG_KONG", "JAPAN", "TAIWAN", "DOW",
"NASDAQ", "SP", "UNITED_KINGDOM", "GERMANY", "FRANCE"
];
var table = Table(WorldColumns);
for (var index = 0; index < codes.Length; index++)
{
table.Rows.Add(
index + 1,
codes[index],
100d + index,
index == 2 ? 0d : 1d,
1d,
index == 2 ? "-" : "+");
}
return table;
}
private static DataTable Table(string[] columns, object?[]? row = null)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(object));
}
if (row is not null)
{
table.Rows.Add(row);
}
return table;
}
public sealed record PanelRouteCase(
S5016PanelTarget Target,
string SourceFragment,
string ParameterName,
string ParameterValue);
public sealed record S6001RouteCase(
S6001QuoteTarget Target,
string Symbol,
string SourceFragment);
private sealed record QueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
{
private readonly Queue<DataTable> _results = new(results);
public List<QueryCall> Calls { get; } = [];
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new QueryCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("No result was configured.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
}

View File

@@ -0,0 +1,353 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class PagedQuoteSceneBuildersTests
{
public static TheoryData<HeaderCase> HeaderCases => new()
{
new(
PagedQuoteBranch.Domestic,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"Domestic",
"Domestic",
"\uB2E8\uC704 : \uC6D0",
"\uD604\uC7AC\uAC00",
null),
new(
PagedQuoteBranch.Nxt,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.PreMarket,
"NXT",
"NXT",
"\uB2E8\uC704 : \uC6D0",
"\uD604\uC7AC\uAC00",
"Images\\\uD504\uB9AC\uB9C8\uCF13.png"),
new(
PagedQuoteBranch.NxtTheme,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.ExpectedExecution,
NxtMarketSession.AfterMarket,
"Theme",
"Theme \uC608\uC0C1 \uCCB4\uACB0\uAC00",
"\uB2E8\uC704 : \uC6D0",
"\uD604\uC7AC\uAC00",
"Images\\\uC560\uD504\uD130\uB9C8\uCF13.png"),
new(
PagedQuoteBranch.Theme,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"Theme",
"Theme",
"\uB2E8\uC704 : \uC6D0",
"\uD604\uC7AC\uAC00",
null),
new(
PagedQuoteBranch.Expert,
PagedQuoteHeaderStyle.ExpertReturns,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"Expert",
"Expert",
"\uB2E8\uC704 : \uC6D0",
"\uB9E4\uC218\uAC00",
null),
new(
PagedQuoteBranch.Vi,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"VI",
"VI",
"\uB2E8\uC704 : \uC6D0",
"\uD604\uC7AC\uAC00",
null),
new(
PagedQuoteBranch.Domestic,
PagedQuoteHeaderStyle.IndustryPoints,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"Industry",
"Industry",
"\uB2E8\uC704 : p",
"\uD604\uC7AC",
null)
};
[Fact]
public void S5074_nxt_asset_direction_and_partial_clear_match_golden_order()
{
var data = Data(
count: 1,
pageNumberOneBased: 1,
branch: PagedQuoteBranch.NxtTheme,
header: PagedQuoteHeaderStyle.StandardWon,
session: NxtMarketSession.PreMarket,
title: "Theme");
var actual = new S5074SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"S:mart=0",
"S:row1=0", "S:row2=0", "S:row3=0", "S:row4=0", "S:row5=0",
"V:title=Theme", "A:mart=Images\\\uD504\uB9AC\uB9C8\uCF13.png", "S:mart=1",
"V:unit=\uB2E8\uC704 : \uC6D0", "V:column1=\uD604\uC7AC\uAC00",
"V:column2=\uB4F1\uB77D\uAC00", "V:column3=\uB4F1\uB77D\uB960",
"S:row1=1", "V:title1=Row001", "V:price1=1,001",
"V:changePrice1_1=-1", "V:changePrice1_2=-1", "V:changePrice1_3=-1",
"V:rate1=-0.25",
"S:upup1=0", "S:up1=0", "S:flat1=0", "S:down1=0", "S:downdown1=0",
"S:bg1_1=0", "S:bg1_2=0", "S:bg1_3=1",
"S:changePrice1_1=0", "S:changePrice1_2=0", "S:changePrice1_3=1",
"S:down1=1"
], actual.Take(32));
Assert.Equal(
[
"S:row2=0", "V:title2=", "V:price2=",
"V:changePrice2_1=", "V:changePrice2_2=", "V:changePrice2_3=", "V:rate2=",
"S:upup2=0", "S:up2=0", "S:flat2=0", "S:down2=0", "S:downdown2=0",
"S:bg2_1=0", "S:bg2_2=0", "S:bg2_3=0",
"S:changePrice2_1=0", "S:changePrice2_2=0", "S:changePrice2_3=0"
], actual.Skip(32).Take(18));
Assert.Equal(
"E1BC9AAD7BC6A54A871922A5C222971A94944B66099404F08F70F972AE86CED2",
SnapshotHash(actual));
}
[Fact]
public void S5077_expert_header_and_wide_direction_variants_match_golden_order()
{
var data = Data(
count: 6,
pageNumberOneBased: 1,
branch: PagedQuoteBranch.Expert,
header: PagedQuoteHeaderStyle.ExpertReturns,
session: NxtMarketSession.NotApplicable,
title: "Expert");
var actual = new S5077SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Equal(
[
"S:mart=0",
"S:row1=0", "S:row2=0", "S:row3=0", "S:row4=0", "S:row5=0", "S:row6=0",
"V:title=Expert", "S:mart=0",
"V:unit=\uB2E8\uC704 : \uC6D0", "V:column1=\uB9E4\uC218\uAC00",
"V:column2=\uD604\uC7AC\uAC00", "V:column3=\uC218\uC775\uB960",
"S:row1=1", "V:title1=Row001", "V:price1=1,001",
"V:changePrice1_1=-1", "V:changePrice1_2=-1", "V:changePrice1_3=-1",
"V:rate1_1=-0.25", "V:rate1_2=-0.25", "V:rate1_3=-0.25",
"S:upup1=0", "S:up1=0", "S:flat1=0", "S:down1=0", "S:downdown1=0",
"S:bg1_1=0", "S:bg1_2=0", "S:bg1_3=1",
"S:changePrice1_1=0", "S:changePrice1_2=0", "S:changePrice1_3=1",
"S:rate1_1=0", "S:rate1_2=0", "S:rate1_3=1",
"S:percent1_1=0", "S:percent1_2=0", "S:percent1_3=1",
"S:down1=1"
], actual.Take(40));
Assert.Equal(
"5BB1952AF0BF7AAE1CEBED9B318C7216E1A61B9D4B95E729701489AF21AC78E5",
SnapshotHash(actual));
}
[Fact]
public void S5088_page_two_uses_twelve_row_offset_and_clears_remaining_slots()
{
var data = Data(
count: 13,
pageNumberOneBased: 2,
branch: PagedQuoteBranch.Domestic,
header: PagedQuoteHeaderStyle.StandardWon,
session: NxtMarketSession.NotApplicable,
title: "Twelve");
var actual = new S5088SceneMutationBuilder().Build(data).Select(Describe).ToArray();
Assert.Contains("V:title1=Row013", actual);
Assert.DoesNotContain("V:title1=Row006", actual);
Assert.Contains("V:title2=", actual);
Assert.Equal(11, actual.Count(item =>
item.StartsWith("V:title", StringComparison.Ordinal) && item.EndsWith('=') &&
item != "V:title="));
Assert.Equal(
"65D6B6A718EC77A6CA9071273E04D177F8DDFC34D736E9902BCCA0F12D8646C0",
SnapshotHash(actual));
}
[Theory]
[InlineData("s5074", 5)]
[InlineData("s5077", 6)]
[InlineData("s5088", 12)]
public void Builders_cover_size_boundaries_partial_pages_and_twenty_page_cap(
string builderKey,
int size)
{
var sizeMinusOne = Build(builderKey, Data(size - 1, 1));
Assert.Contains(sizeMinusOne, mutation =>
mutation is PlayoutSetValue { ObjectName: var name, Value: "" } &&
name == "title" + size);
var exactSize = Build(builderKey, Data(size, 1));
Assert.DoesNotContain(exactSize, mutation =>
mutation is PlayoutSetValue { ObjectName: var name, Value: "" } &&
name.StartsWith("title", StringComparison.Ordinal));
var sizePlusOne = Build(builderKey, Data(size + 1, 2));
Assert.Contains(sizePlusOne, mutation =>
mutation is PlayoutSetValue { ObjectName: "title1", Value: var value } &&
value == $"Row{size + 1:000}");
Assert.Contains(sizePlusOne, mutation =>
mutation is PlayoutSetValue { ObjectName: "title2", Value: "" });
var pageTwenty = Build(builderKey, Data(20 * size, 20));
Assert.Contains(pageTwenty, mutation =>
mutation is PlayoutSetValue { ObjectName: "title1", Value: var value } &&
value == $"Row{((19 * size) + 1):000}");
Assert.Contains(pageTwenty, mutation =>
mutation is PlayoutSetValue { ObjectName: var name, Value: var value } &&
name == "title" + size && value == $"Row{20 * size:000}");
var capped = Build(builderKey, Data((20 * size) + 1, 20));
Assert.DoesNotContain(capped, mutation =>
mutation is PlayoutSetValue { Value: var value } &&
value == $"Row{(20 * size) + 1:000}");
Assert.Throws<LegacySceneDataException>(() =>
Build(builderKey, Data((20 * size) + 1, 21)));
}
[Theory]
[MemberData(nameof(HeaderCases))]
public void Header_branches_use_closed_title_column_unit_and_asset_mapping(HeaderCase item)
{
var actual = new S5074SceneMutationBuilder().Build(Data(
1,
1,
item.Branch,
item.Header,
item.Session,
item.Title,
item.TitleStyle));
Assert.Contains(actual, mutation =>
mutation is PlayoutSetValue { ObjectName: "title", Value: var value } &&
value == item.ExpectedTitle);
Assert.Contains(actual, mutation =>
mutation is PlayoutSetValue { ObjectName: "unit", Value: var value } &&
value == item.ExpectedUnit);
Assert.Contains(actual, mutation =>
mutation is PlayoutSetValue { ObjectName: "column1", Value: var value } &&
value == item.ExpectedColumn1);
if (item.ExpectedAsset is null)
{
Assert.DoesNotContain(actual, mutation => mutation is PlayoutSetAssetValue);
}
else
{
Assert.Contains(actual, mutation =>
mutation is PlayoutSetAssetValue { ObjectName: "mart", AssetPath: var path } &&
path == item.ExpectedAsset);
}
}
[Fact]
public void Presentation_contract_rejects_invalid_combinations_and_raw_sdk_input_surface()
{
Assert.Throws<LegacySceneDataException>(() =>
new S5074SceneMutationBuilder().Build(Data(
1, 1, PagedQuoteBranch.Nxt, PagedQuoteHeaderStyle.StandardWon,
NxtMarketSession.NotApplicable, "A")));
Assert.Throws<LegacySceneDataException>(() =>
new S5074SceneMutationBuilder().Build(Data(
1, 1, PagedQuoteBranch.Domestic, PagedQuoteHeaderStyle.StandardWon,
NxtMarketSession.PreMarket, "A")));
Assert.Throws<LegacySceneDataException>(() =>
new S5074SceneMutationBuilder().Build(Data(
1, 1, PagedQuoteBranch.Expert, PagedQuoteHeaderStyle.StandardWon,
NxtMarketSession.NotApplicable, "A")));
Assert.Throws<LegacySceneDataException>(() =>
new S5074SceneMutationBuilder().Build(Data(
1, 1, PagedQuoteBranch.Theme, PagedQuoteHeaderStyle.ExpertReturns,
NxtMarketSession.NotApplicable, "A")));
var publicProperties = typeof(PagedQuoteSceneData)
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Select(property => property.Name);
Assert.DoesNotContain(publicProperties, name =>
name is "ObjectName" or "MethodName" or "AssetPath" or "SceneFile");
}
private static PagedQuoteSceneData Data(
int count,
int pageNumberOneBased,
PagedQuoteBranch branch = PagedQuoteBranch.Domestic,
PagedQuoteHeaderStyle header = PagedQuoteHeaderStyle.StandardWon,
NxtMarketSession session = NxtMarketSession.NotApplicable,
string title = "Title",
PagedQuoteTitleStyle titleStyle = PagedQuoteTitleStyle.Exact)
{
var directions = new[]
{
ScenePriceDirection.Down,
ScenePriceDirection.Up,
ScenePriceDirection.Flat,
ScenePriceDirection.LimitDown,
ScenePriceDirection.LimitUp
};
var rows = Enumerable.Range(1, count)
.Select(index => new PagedQuoteRowData(
$"Row{index:000}",
1000 + index,
index % 2 == 0 ? index : -index,
index % 2 == 0 ? index / 4d : -index / 4d,
directions[(index - 1) % directions.Length]))
.ToArray();
return new PagedQuoteSceneData(
branch,
header,
titleStyle,
session,
title,
rows,
pageNumberOneBased);
}
private static IReadOnlyList<PlayoutMutation> Build(
string builderKey,
PagedQuoteSceneData data) => builderKey switch
{
"s5074" => new S5074SceneMutationBuilder().Build(data),
"s5077" => new S5077SceneMutationBuilder().Build(data),
"s5088" => new S5088SceneMutationBuilder().Build(data),
_ => throw new InvalidOperationException("Unknown test builder.")
};
private static string SnapshotHash(IEnumerable<string> snapshot)
{
var bytes = Encoding.UTF8.GetBytes(string.Join('\n', snapshot));
return Convert.ToHexString(SHA256.HashData(bytes));
}
private static string Describe(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
PlayoutSetAssetValue asset => $"A:{asset.ObjectName}={asset.AssetPath}",
PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
_ => mutation.GetType().Name
};
public sealed record HeaderCase(
PagedQuoteBranch Branch,
PagedQuoteHeaderStyle Header,
PagedQuoteTitleStyle TitleStyle,
NxtMarketSession Session,
string Title,
string ExpectedTitle,
string ExpectedUnit,
string ExpectedColumn1,
string? ExpectedAsset);
}

View File

@@ -0,0 +1,574 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class PagedQuoteSceneDataLoadersTests
{
public static TheoryData<string, int> SceneSizes => new()
{
{ "s5074", 5 },
{ "s5077", 6 },
{ "s5088", 12 }
};
[Fact]
public async Task DomesticIndustry_UsesParameterizedOracleAndPreservesPointDecimalsInMutations()
{
var executor = new RecordingExecutor(QuoteTable(
["Semiconductor", 1234.56m, -12.34m, -0.99m, "-"]));
var data = await new S5074SceneDataLoader(executor).LoadAsync(
new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
DomesticPagedQuoteList.Industry,
new DateOnly(2026, 7, 10)));
var mutations = new S5074SceneMutationBuilder().Build(data);
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.Equal("SCENE_5074_PAGED_QUOTES", executor.TableName);
Assert.NotNull(executor.Spec);
Assert.Contains("FROM t_index", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(":maxRows", executor.Spec.Sql, StringComparison.Ordinal);
var maxRows = Assert.Single(executor.Spec.Parameters);
Assert.Equal("maxRows", maxRows.Name);
Assert.Equal(100, maxRows.Value);
Assert.Equal(DbType.Int32, maxRows.DbType);
Assert.Equal(PagedQuoteBranch.Domestic, data.Branch);
Assert.Equal(PagedQuoteHeaderStyle.IndustryPoints, data.HeaderStyle);
Assert.Equal(PagedQuoteTitleStyle.Exact, data.TitleStyle);
Assert.Equal(NxtMarketSession.NotApplicable, data.NxtSession);
Assert.Equal("\uCF54\uC2A4\uD53C \uC5C5\uC885", data.Title);
Assert.Equal(1234.56m, data.Rows[0].PrimaryValue);
Assert.Equal(-12.34m, data.Rows[0].SecondaryValue);
Assert.Equal(ScenePriceDirection.Down, data.Rows[0].Direction);
Assert.Contains(new PlayoutSetValue("price1", "1,234.56"), mutations);
Assert.Contains(new PlayoutSetValue("changePrice1_1", "-12.34"), mutations);
Assert.Contains(new PlayoutSetValue("unit", "\uB2E8\uC704 : p"), mutations);
Assert.Equal(1, executor.SpecCalls);
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task NxtRanking_UsesParameterizedMariaAndMapsAfterMarketPresentation()
{
var executor = new RecordingExecutor(QuoteTable(
["NXT quote", 1000m, -50m, -5.0m, "5"]));
var data = await new S5077SceneDataLoader(executor).LoadAsync(
new NxtPagedQuoteLoadRequest(
PagedQuoteMarket.Kosdaq,
NxtPagedQuoteList.Volume,
NxtMarketSession.AfterMarket));
var mutations = new S5077SceneMutationBuilder().Build(data);
Assert.Equal(DataSourceKind.MariaDb, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("FROM n_kosdaq_online", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("LIMIT @maxRows", executor.Spec.Sql, StringComparison.Ordinal);
var maxRows = Assert.Single(executor.Spec.Parameters);
Assert.Equal(120, maxRows.Value);
Assert.Equal(PagedQuoteBranch.Nxt, data.Branch);
Assert.Equal(NxtMarketSession.AfterMarket, data.NxtSession);
Assert.Equal("\uCF54\uC2A4\uB2E5 \uAC70\uB798\uB7C9 \uC0C1\uC704", data.Title);
Assert.Equal(ScenePriceDirection.Down, data.Rows[0].Direction);
Assert.Contains(
new PlayoutSetAssetValue("mart", "Images\\\uC560\uD504\uD130\uB9C8\uCF13.png"),
mutations);
Assert.Contains(new PlayoutSetVisible("mart", true), mutations);
Assert.Contains(new PlayoutSetValue("price1", "1,000"), mutations);
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task ThemeExpected_UsesOracleThemeParameterAndExpectedTitleVariant()
{
var executor = new RecordingExecutor(QuoteTable(
["Alpha", 110m, 10m, 10d, "+"]));
var data = await new S5088SceneDataLoader(executor).LoadAsync(
new ThemePagedQuoteLoadRequest(
"AI",
PagedQuoteThemeSort.GainRateDescending,
PagedQuoteThemeValueKind.ExpectedExecution));
var mutations = new S5088SceneMutationBuilder().Build(data);
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("l.SB_TITLE = :themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("t_online1_call", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY q.RATE DESC", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(new[] { "themeTitle", "maxRows" },
executor.Spec.Parameters.Select(parameter => parameter.Name));
Assert.Equal("AI", executor.Spec.Parameters[0].Value);
Assert.Equal(240, executor.Spec.Parameters[1].Value);
Assert.Equal(PagedQuoteBranch.Theme, data.Branch);
Assert.Equal(PagedQuoteTitleStyle.ExpectedExecution, data.TitleStyle);
Assert.Equal(NxtMarketSession.NotApplicable, data.NxtSession);
Assert.Contains(
new PlayoutSetValue("title", "AI \uC608\uC0C1 \uCCB4\uACB0\uAC00"),
mutations);
}
[Fact]
public async Task NxtTheme_UsesMariaThemeParameterAndPreMarketSession()
{
var executor = new RecordingExecutor(QuoteTable(
["Beta", 200m, 20m, 11.11d, "2"]));
var data = await new S5074SceneDataLoader(executor).LoadAsync(
new NxtThemePagedQuoteLoadRequest(
"Robotics(NXT)",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket));
Assert.Equal(DataSourceKind.MariaDb, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("l.SB_TITLE = @themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("l.SB_MARKET = 'NXT'", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY i.SB_I_INDEX", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("Robotics", executor.Spec.Parameters[0].Value);
Assert.Equal(PagedQuoteBranch.NxtTheme, data.Branch);
Assert.Equal(NxtMarketSession.PreMarket, data.NxtSession);
Assert.Equal(PagedQuoteTitleStyle.Exact, data.TitleStyle);
Assert.Equal("Robotics", data.Title);
}
[Fact]
public async Task Expert_UsesOracleExpertParameterAndMapsReturnHeader()
{
var executor = new RecordingExecutor(QuoteTable(
["Gamma", 900m, 1000m, 11.11d, "2"]));
var data = await new S5077SceneDataLoader(executor).LoadAsync(
new ExpertPagedQuoteLoadRequest("Analyst A"));
var mutations = new S5077SceneMutationBuilder().Build(data);
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("e.EP_NAME = :expertName", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("Analyst A", executor.Spec.Parameters[0].Value);
Assert.Equal(PagedQuoteBranch.Expert, data.Branch);
Assert.Equal(PagedQuoteHeaderStyle.ExpertReturns, data.HeaderStyle);
Assert.Contains(new PlayoutSetValue("column1", "\uB9E4\uC218\uAC00"), mutations);
Assert.Contains(new PlayoutSetValue("column2", "\uD604\uC7AC\uAC00"), mutations);
Assert.Contains(new PlayoutSetValue("column3", "\uC218\uC775\uB960"), mutations);
}
[Fact]
public async Task Vi_UsesOneOracleParameterPerBoundedNameAndPreservesInputOrderContract()
{
var executor = new RecordingExecutor(QuoteTable(
["First", 100m, 1m, 1d, "2"],
["Second", 200m, 2m, 1d, "2"]));
var data = await new S5088SceneDataLoader(executor).LoadAsync(
new ViPagedQuoteLoadRequest(["First", "Second"]));
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains(":stockName0", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(":stockName1", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY r.INPUT_ORDER", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(new[] { "stockName0", "stockName1", "maxRows" },
executor.Spec.Parameters.Select(parameter => parameter.Name));
Assert.Equal(new object?[] { "First", "Second", 240 },
executor.Spec.Parameters.Select(parameter => parameter.Value));
Assert.Equal(PagedQuoteBranch.Vi, data.Branch);
Assert.Equal("VI \uBC1C\uB3D9", data.Title);
Assert.Equal(new[] { "First", "Second" }, data.Rows.Select(row => row.Name));
}
[Theory]
[MemberData(nameof(SceneSizes))]
public async Task SceneLoaders_UseExactFiveSixTwelveWindowsAndTwentyPageCap(
string scene,
int pageSize)
{
var partialExecutor = new RecordingExecutor(NumberedQuoteTable(pageSize + 1));
var partial = await LoadAsync(
scene,
partialExecutor,
new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
DomesticPagedQuoteList.GainRate,
new DateOnly(2026, 7, 10),
PageNumberOneBased: 2));
var partialMutations = Build(scene, partial);
Assert.Equal(pageSize + 1, partial.Rows.Count);
Assert.Equal(2, partial.PageNumberOneBased);
Assert.Contains(new PlayoutSetValue("title1", "Name" + pageSize), partialMutations);
Assert.Contains(new PlayoutSetVisible("row1", true), partialMutations);
Assert.Contains(new PlayoutSetVisible("row2", false), partialMutations);
var exactPageExecutor = new RecordingExecutor(NumberedQuoteTable(pageSize));
await Assert.ThrowsAsync<LegacySceneDataException>(() => LoadAsync(
scene,
exactPageExecutor,
new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
DomesticPagedQuoteList.GainRate,
new DateOnly(2026, 7, 10),
PageNumberOneBased: 2)));
var maximumRows = pageSize * ScenePaging.MaximumPageCount;
var lastExecutor = new RecordingExecutor(NumberedQuoteTable(maximumRows));
var last = await LoadAsync(
scene,
lastExecutor,
new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
DomesticPagedQuoteList.GainRate,
new DateOnly(2026, 7, 10),
PageNumberOneBased: 20));
var lastMutations = Build(scene, last);
Assert.Equal(maximumRows, last.Rows.Count);
Assert.Contains(
new PlayoutSetValue("title1", "Name" + (19 * pageSize)),
lastMutations);
Assert.Equal(maximumRows, Assert.Single(lastExecutor.Spec!.Parameters).Value);
var oversizedExecutor = new RecordingExecutor(NumberedQuoteTable(maximumRows + 1));
await Assert.ThrowsAsync<LegacySceneDataException>(() => LoadAsync(
scene,
oversizedExecutor,
new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
DomesticPagedQuoteList.GainRate,
new DateOnly(2026, 7, 10))));
}
[Theory]
[MemberData(nameof(SceneSizes))]
public async Task SceneLoaders_RejectPageTwentyOneBeforeQuery(
string scene,
int _)
{
var executor = new RecordingExecutor(NumberedQuoteTable(1));
await Assert.ThrowsAsync<LegacySceneDataException>(() => LoadAsync(
scene,
executor,
new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
DomesticPagedQuoteList.GainRate,
new DateOnly(2026, 7, 10),
PageNumberOneBased: 21)));
Assert.Equal(0, executor.SpecCalls);
}
public static TheoryData<DomesticPagedQuoteList, PagedQuoteMarket, string>
DomesticLists => new()
{
{ DomesticPagedQuoteList.ExpectedMarketCapitalization, PagedQuoteMarket.Kospi, "t_online1_call" },
{ DomesticPagedQuoteList.MarketCapitalization, PagedQuoteMarket.Kosdaq, "t_kosdaq_online1" },
{ DomesticPagedQuoteList.Industry, PagedQuoteMarket.Kospi, "t_index" },
{ DomesticPagedQuoteList.AfterHoursGainRate, PagedQuoteMarket.Kosdaq, "t_kosdaq_overtime_sise" },
{ DomesticPagedQuoteList.AfterHoursLossRate, PagedQuoteMarket.Kospi, "t_overtime_sise" },
{ DomesticPagedQuoteList.GainRate, PagedQuoteMarket.Kosdaq, "RATE DESC" },
{ DomesticPagedQuoteList.LossRate, PagedQuoteMarket.Kospi, "a.f_chg_type IN ('4', '5')" },
{ DomesticPagedQuoteList.FiftyTwoWeekHigh, PagedQuoteMarket.Kospi, "t_candle_history" },
{ DomesticPagedQuoteList.FiftyTwoWeekLow, PagedQuoteMarket.Kosdaq, "t_kosdaq_candle_history" },
{ DomesticPagedQuoteList.Kospi200, PagedQuoteMarket.Kospi, "f_kospi200_gubun" },
{ DomesticPagedQuoteList.Etf, PagedQuoteMarket.Kospi, "b.f_sect_code = 'EF'" }
};
[Theory]
[MemberData(nameof(DomesticLists))]
public async Task EveryDomesticLegacySelectionHasAClosedOracleQuery(
DomesticPagedQuoteList list,
PagedQuoteMarket market,
string queryEvidence)
{
var executor = new RecordingExecutor(NumberedQuoteTable(1));
var data = await new S5074SceneDataLoader(executor).LoadAsync(
new DomesticPagedQuoteLoadRequest(
market,
list,
new DateOnly(2026, 7, 10)));
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains(queryEvidence, executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(PagedQuoteBranch.Domestic, data.Branch);
Assert.Equal(
list == DomesticPagedQuoteList.Industry
? PagedQuoteHeaderStyle.IndustryPoints
: PagedQuoteHeaderStyle.StandardWon,
data.HeaderStyle);
Assert.Equal(0, executor.StringCalls);
}
public static TheoryData<NxtPagedQuoteList, string> NxtLists => new()
{
{ NxtPagedQuoteList.MarketCapitalization, "b.f_list_num * a.f_curr_price" },
{ NxtPagedQuoteList.AfterHoursGainRate, "FROM n_overtime" },
{ NxtPagedQuoteList.AfterHoursLossRate, "a.f_chg_type IN ('4', '5')" },
{ NxtPagedQuoteList.GainRate, "RATE DESC" },
{ NxtPagedQuoteList.LossRate, "a.f_chg_type IN ('4', '5')" },
{ NxtPagedQuoteList.Volume, "a.f_net_vol" }
};
[Theory]
[MemberData(nameof(NxtLists))]
public async Task EveryNxtLegacySelectionHasAClosedMariaQuery(
NxtPagedQuoteList list,
string queryEvidence)
{
var executor = new RecordingExecutor(NumberedQuoteTable(1));
var data = await new S5077SceneDataLoader(executor).LoadAsync(
new NxtPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
list,
NxtMarketSession.PreMarket));
Assert.Equal(DataSourceKind.MariaDb, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains(queryEvidence, executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("LIMIT @maxRows", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(PagedQuoteBranch.Nxt, data.Branch);
Assert.Equal(0, executor.StringCalls);
}
[Theory]
[InlineData(PagedQuoteThemeSort.InputOrder, "ORDER BY t.SB_I_INDEX")]
[InlineData(PagedQuoteThemeSort.GainRateDescending, "ORDER BY q.RATE DESC")]
[InlineData(PagedQuoteThemeSort.GainRateAscending, "ORDER BY q.RATE")]
public async Task ThemeSortsAreClosedAndGenerateTheLegacyOrder(
PagedQuoteThemeSort sort,
string orderBy)
{
var executor = new RecordingExecutor(NumberedQuoteTable(1));
await new S5074SceneDataLoader(executor).LoadAsync(
new ThemePagedQuoteLoadRequest(
"Theme",
sort,
PagedQuoteThemeValueKind.Current));
Assert.NotNull(executor.Spec);
Assert.Contains(orderBy, executor.Spec.Sql, StringComparison.Ordinal);
}
[Fact]
public async Task FiftyTwoWeekQueryBindsDeterministicStartEndAndCapParameters()
{
var executor = new RecordingExecutor(NumberedQuoteTable(1));
await new S5088SceneDataLoader(executor).LoadAsync(
new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
DomesticPagedQuoteList.FiftyTwoWeekHigh,
new DateOnly(2026, 7, 10)));
Assert.NotNull(executor.Spec);
Assert.Equal(new[] { "startDate", "endDate", "maxRows" },
executor.Spec.Parameters.Select(parameter => parameter.Name));
Assert.Equal(new object?[] { "20250711", "20260710", 240 },
executor.Spec.Parameters.Select(parameter => parameter.Value));
Assert.All(executor.Spec.Parameters.Take(2),
parameter => Assert.Equal(DbType.String, parameter.DbType));
}
[Fact]
public async Task LoaderFailsClosedForUnexpectedSchemaNullBadDirectionAndNonFiniteValues()
{
var extraColumn = NumberedQuoteTable(1);
extraColumn.Columns.Add("EXTRA", typeof(object));
extraColumn.Rows[0]["EXTRA"] = "unexpected";
var nullValue = NumberedQuoteTable(1);
nullValue.Rows[0]["PRIMARY_VALUE"] = DBNull.Value;
var badDirection = NumberedQuoteTable(1);
badDirection.Rows[0]["DIRECTION"] = "sideways";
var nonFinite = NumberedQuoteTable(1);
nonFinite.Rows[0]["RATE"] = double.NaN;
var fractionalWon = NumberedQuoteTable(1);
fractionalWon.Rows[0]["PRIMARY_VALUE"] = 100.5m;
foreach (var table in new[]
{
new DataTable(),
extraColumn,
nullValue,
badDirection,
nonFinite,
fractionalWon
})
{
var executor = new RecordingExecutor(table);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5074SceneDataLoader(executor).LoadAsync(
new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
DomesticPagedQuoteList.GainRate,
new DateOnly(2026, 7, 10))));
}
}
[Fact]
public void BuilderAlsoFailsClosedForFractionalWonTrustedData()
{
var data = new PagedQuoteSceneData(
PagedQuoteBranch.Domestic,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"Title",
[new PagedQuoteRowData("Name", 100.5m, 1m, 1d, ScenePriceDirection.Up)],
1);
Assert.Throws<LegacySceneDataException>(() =>
new S5074SceneMutationBuilder().Build(data));
}
[Fact]
public async Task InvalidClosedSelectionsAreRejectedBeforeAnyDatabaseCall()
{
PagedQuoteLoadRequest[] invalidRequests =
[
new DomesticPagedQuoteLoadRequest(
(PagedQuoteMarket)99,
DomesticPagedQuoteList.GainRate,
new DateOnly(2026, 7, 10)),
new DomesticPagedQuoteLoadRequest(
PagedQuoteMarket.Kosdaq,
DomesticPagedQuoteList.Kospi200,
new DateOnly(2026, 7, 10)),
new NxtPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
(NxtPagedQuoteList)99,
NxtMarketSession.PreMarket),
new NxtPagedQuoteLoadRequest(
PagedQuoteMarket.Kospi,
NxtPagedQuoteList.GainRate,
NxtMarketSession.NotApplicable),
new ThemePagedQuoteLoadRequest(
"Theme",
(PagedQuoteThemeSort)99,
PagedQuoteThemeValueKind.Current),
new ThemePagedQuoteLoadRequest(
"Theme",
PagedQuoteThemeSort.InputOrder,
(PagedQuoteThemeValueKind)99),
new NxtThemePagedQuoteLoadRequest(
"Theme",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.NotApplicable),
new ViPagedQuoteLoadRequest([])
];
foreach (var request in invalidRequests)
{
var executor = new RecordingExecutor(NumberedQuoteTable(1));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5074SceneDataLoader(executor).LoadAsync(request));
Assert.Equal(0, executor.SpecCalls);
}
}
private static Task<PagedQuoteSceneData> LoadAsync(
string scene,
IDataQueryExecutor executor,
PagedQuoteLoadRequest request) => scene switch
{
"s5074" => new S5074SceneDataLoader(executor).LoadAsync(request),
"s5077" => new S5077SceneDataLoader(executor).LoadAsync(request),
"s5088" => new S5088SceneDataLoader(executor).LoadAsync(request),
_ => throw new InvalidOperationException("Unknown test scene.")
};
private static IReadOnlyList<PlayoutMutation> Build(
string scene,
PagedQuoteSceneData data) => scene switch
{
"s5074" => new S5074SceneMutationBuilder().Build(data),
"s5077" => new S5077SceneMutationBuilder().Build(data),
"s5088" => new S5088SceneMutationBuilder().Build(data),
_ => throw new InvalidOperationException("Unknown test scene.")
};
private static DataTable NumberedQuoteTable(int count)
{
var table = QuoteTable();
for (var index = 0; index < count; index++)
{
table.Rows.Add("Name" + index, 1000m + index, 10m + index, 1d, "2");
}
return table;
}
private static DataTable QuoteTable(params object?[][] rows)
{
var table = new DataTable();
foreach (var column in new[]
{
"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; }
public string? TableName { get; private set; }
public DataQuerySpec? Spec { get; private set; }
public int SpecCalls { get; private set; }
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Source = source;
TableName = tableName;
Spec = query;
SpecCalls++;
return Task.FromResult(result.Copy());
}
}
}

View File

@@ -0,0 +1,90 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class PanelSceneDataLoadersTests
{
[Fact]
public async Task NxtLoader_UsesMariaParameterAndMapsS5006Dto()
{
var table = new DataTable();
foreach (var column in new[]
{
"STOCK_NAME", "CURR_PRICE", "CHG_TYPE", "NET_CHG", "RATE",
"INIT_PRICE", "INIT_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE"
})
{
table.Columns.Add(column, typeof(object));
}
table.Rows.Add("A사", 1000m, "상승", 50m, 5.26m, 960m, 1m, 1100m, 10m, 900m, -10m);
var executor = new RecordingExecutor(table);
var data = await new S5006NxtSceneDataLoader(executor).LoadAsync(
LegacyDomesticEquityMarket.NxtKospi,
"005930");
var mutations = new S5006SceneMutationBuilder().Build(data);
Assert.Equal(DataSourceKind.MariaDb, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("@stockCode", executor.Spec.Sql, StringComparison.Ordinal);
var parameter = Assert.Single(executor.Spec.Parameters);
Assert.Equal("stockCode", parameter.Name);
Assert.Equal("005930", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
Assert.Equal(ScenePriceDirection.Up, data.Direction);
Assert.Equal(1000, data.CurrentPrice);
Assert.NotEmpty(mutations);
Assert.Equal(0, executor.StringCalls);
}
[Theory]
[InlineData(LegacyDomesticEquityMarket.Kospi)]
[InlineData(LegacyDomesticEquityMarket.Kosdaq)]
public async Task NxtLoader_RejectsNonNxtMarketsBeforeQuery(LegacyDomesticEquityMarket market)
{
var executor = new RecordingExecutor(new DataTable());
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5006NxtSceneDataLoader(executor).LoadAsync(market, "005930"));
Assert.Null(executor.Spec);
}
private sealed class RecordingExecutor(DataTable table) : IDataQueryExecutor
{
public DataSourceKind Source { get; private set; }
public DataQuerySpec? Spec { get; private set; }
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException();
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException();
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
Source = source;
Spec = query;
return Task.FromResult(table.Copy());
}
}
}

View File

@@ -0,0 +1,487 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ParameterizedMarketSceneBuildersTests
{
[Fact]
public void Builder_keys_are_the_closed_scene_codes()
{
Assert.Equal("s5001", new S5001SceneMutationBuilder().BuilderKey);
Assert.Equal("s5032", new S5032SceneMutationBuilder().BuilderKey);
Assert.Equal("s8018", new S8018SceneMutationBuilder().BuilderKey);
Assert.Equal("s8001", new S8001SceneMutationBuilder().BuilderKey);
}
[Fact]
public void S5001_all_query_branches_are_explicit_and_emit_the_original_value_group()
{
S5001SceneData[] cases =
[
new S5001BalticDryIndexSceneData("BDI 지수", ScenePriceDirection.Up, 1_900, 12, 0.64m),
new S5001CommoditySceneData(S5001CommodityTarget.Copper, "구리", ScenePriceDirection.Down,
4.2500m, -0.1250m, -2.86m),
new S5001DomesticIndexSceneData(S5001DomesticIndexTarget.Kospi, S5001IndexMode.Current,
"코스피", ScenePriceDirection.Flat, 2_800.1m, 0, 0),
new S5001ForeignIndexSceneData(S5001ForeignIndexTarget.Nasdaq, "나스닥",
ScenePriceDirection.Up, 20_100.25m, 120.50m, 0.60m),
new S5001ExchangeRateSceneData(S5001ExchangeRateTarget.WonDollar, "원달러",
ScenePriceDirection.Down, 1_380.2m, -2.4m, 0.17m),
new S5001DomesticIndustrySceneData(LegacyDomesticEquityMarket.Kosdaq, "반도체",
ScenePriceDirection.Up, 1_234.5m, 5.5m, 0.45m),
new S5001ForeignIndustrySceneData("필라델피아 반도체", ScenePriceDirection.Flat,
5_000m, 0, 0),
new S5001ForeignStockSceneData("NVIDIA", ScenePriceDirection.Down,
190.25m, -2.15m, 1.12m),
new S5001DomesticStockSceneData(LegacyDomesticEquityMarket.Kospi,
S5001DomesticStockMode.Current, S5001NxtBadge.None, "삼성전자",
ScenePriceDirection.LimitDown, 70_000, -30_000, -30m)
];
var builder = new S5001SceneMutationBuilder();
foreach (var data in cases)
{
var actual = builder.Build(data).Select(Describe).ToArray();
Assert.Equal("S:mart=0", actual[0]);
Assert.Contains(actual, item => item.StartsWith("V:title=", StringComparison.Ordinal));
Assert.Contains(actual, item => item.StartsWith("V:price=", StringComparison.Ordinal));
Assert.Contains(actual, item => item.StartsWith("V:unitText=", StringComparison.Ordinal));
Assert.Contains(actual, item => item.StartsWith("A:pattern", StringComparison.Ordinal));
}
}
[Fact]
public void S5001_commodity_preserves_raw_decimal_scale_and_negative_magnitude_rules()
{
var actual = new S5001SceneMutationBuilder().Build(new S5001CommoditySceneData(
S5001CommodityTarget.Copper,
"구리",
ScenePriceDirection.Down,
4.2500m,
-0.1250m,
-2.86m)).Take(7).Select(Describe);
Assert.Equal(
[
"S:mart=0",
"V:title=구리",
"V:price=4.2500",
"V:changePrice3_=0.1250",
"V:rate3_=-2.86",
"V:wonText3_=",
"V:unitText=단위 : 달러"
], actual);
}
[Fact]
public void S5001_domestic_stock_modes_preserve_title_badge_and_duplicate_unit_order()
{
var builder = new S5001SceneMutationBuilder();
var expected = builder.Build(new S5001DomesticStockSceneData(
LegacyDomesticEquityMarket.Kospi,
S5001DomesticStockMode.ExpectedOpening,
S5001NxtBadge.None,
"삼성전자",
ScenePriceDirection.Down,
71_000,
-900,
1.25m)).Take(8).Select(Describe);
Assert.Equal(
[
"S:mart=0",
"V:unitText=단위 : 원",
"V:title=삼성전자 예상체결가",
"V:price=71,000",
"V:changePrice3_=900",
"V:rate3_=-1.25",
"V:wonText3_=",
"V:unitText=단위 : 원"
], expected);
var afterHours = builder.Build(new S5001DomesticStockSceneData(
LegacyDomesticEquityMarket.Kosdaq,
S5001DomesticStockMode.AfterHoursSinglePrice,
S5001NxtBadge.None,
"테스트",
ScenePriceDirection.Flat,
10_000,
0,
0)).Take(5).Select(Describe);
Assert.Equal(
[
"S:mart=0",
"V:unitText=단위 : 원",
"V:title=테스트",
"V:title=테스트 시간외단일가",
"V:price=10,000"
], afterHours);
var nxt = builder.Build(new S5001DomesticStockSceneData(
LegacyDomesticEquityMarket.NxtKospi,
S5001DomesticStockMode.Current,
S5001NxtBadge.PreMarket,
"삼성전자(NXT)",
ScenePriceDirection.Up,
72_000,
1_000,
1.41m)).Select(Describe).ToArray();
Assert.Equal("V:title=삼성전자", nxt[2]);
Assert.Equal(@"A:mart=Images\프리마켓.png", nxt[8]);
Assert.Equal("S:mart=1", nxt[9]);
AssertRelativeAssetsOnly(builder.Build(new S5001DomesticStockSceneData(
LegacyDomesticEquityMarket.NxtKosdaq,
S5001DomesticStockMode.Current,
S5001NxtBadge.AfterMarket,
"테스트(NXT)",
ScenePriceDirection.Up,
1,
1,
1)));
}
[Fact]
public void S5001_direction_signals_have_golden_active_objects_and_value_variants()
{
var cases = new[]
{
(ScenePriceDirection.Up, "V:changePrice1_=10", "S:up=1"),
(ScenePriceDirection.Flat, "V:changePrice2_=10", "S:flat=1"),
(ScenePriceDirection.Down, "V:changePrice3_=10", "S:down=1"),
(ScenePriceDirection.LimitUp, "V:changePrice1_=10", "S:upup=1"),
(ScenePriceDirection.LimitDown, "V:changePrice3_=10", "S:downdown=1")
};
var builder = new S5001SceneMutationBuilder();
foreach (var (direction, value, visible) in cases)
{
var actual = builder.Build(new S5001BalticDryIndexSceneData(
"BDI", direction, 100, 10, 1)).Select(Describe).ToArray();
Assert.Contains(value, actual);
Assert.Contains(visible, actual);
}
}
[Fact]
public void S5032_index_branch_matches_primary_then_direction_then_companion_order()
{
var actual = new S5032SceneMutationBuilder().Build(new S5032IndexAndFuturesSceneData(
LegacyMarketQuoteTarget.Kospi,
LegacyMarketQuoteTarget.Futures,
new S5032PrimaryQuote("코스피", 2_812.34m, -12.30m, 0.44m, ScenePriceDirection.Down),
new S5032CompanionQuote("KOSPI200 선물", 381.25m, -1.20m,
S5032CompanionSignal.Negative, 123_456m, 98_765_432m, 456_789m)))
.Select(Describe).ToArray();
Assert.Equal(
[
"V:title=코스피",
"V:price=2,812.34",
"V:changePrice=12.30",
"V:rate=-0.44",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:down=1"
], actual.Take(10));
var companionStart = Array.IndexOf(actual, "V:title1=KOSPI200 선물");
Assert.True(companionStart > 20);
Assert.Equal(
[
"V:title1=KOSPI200 선물",
"V:price1=381.25",
"V:changePrice1=-1.20",
"S:up1=0",
"S:upup1=0",
"S:down1=1",
"S:downdown1=0",
"S:flat1=0",
"V:r_value1=123,456",
"V:r_value2=99",
"V:r_value3=456,789"
], actual.Skip(companionStart));
}
[Fact]
public void S5032_all_branches_and_companion_signals_are_closed()
{
var builder = new S5032SceneMutationBuilder();
foreach (var signal in Enum.GetValues<S5032CompanionSignal>())
{
var actual = builder.Build(new S5032DomesticIndustryPairSceneData(
LegacyDomesticEquityMarket.Kospi,
new S5032PrimaryQuote("전기전자", 100, 1, 1, ScenePriceDirection.Up),
new S5032CompanionQuote("반도체", 200, 0, signal, 1, 2_000_000, 3)))
.Select(Describe).ToArray();
Assert.Equal("V:title=전기전자", actual[0]);
Assert.Contains("V:title1=반도체", actual);
Assert.Contains(signal switch
{
S5032CompanionSignal.Positive => "S:up1=1",
S5032CompanionSignal.Negative => "S:down1=1",
_ => "S:flat1=1"
}, actual);
}
var stocks = builder.Build(new S5032DomesticStockPairSceneData(
LegacyDomesticEquityMarket.Kospi,
LegacyDomesticEquityMarket.Kosdaq,
new S5032StockQuote("A", 1_000, 100, 10, ScenePriceDirection.LimitUp),
new S5032StockQuote("B", 2_000, -200, -10, ScenePriceDirection.LimitDown)))
.Select(Describe).ToArray();
Assert.Equal(
[
"V:title1=A", "V:title2=B", "V:price1=1,000", "V:price2=2,000",
"V:changePrice1=100", "V:changePrice2=-200", "V:rate1=10.00", "V:rate2=-10.00",
"V:wonText1=", "V:wonText2=", "V:unitText1=단위 : 원", "V:unitText2=단위 : 원"
], stocks.Take(12));
var limitUpPattern = Array.IndexOf(stocks, @"A:pattern1=Images\그림_빨강.png");
var limitUpUnit = Array.IndexOf(stocks, "S:unit1_1=1");
var limitDownPattern = Array.IndexOf(stocks, @"A:pattern2=Images\그림_파랑.png");
var limitDownUnit = Array.IndexOf(stocks, "S:unit3_2=1");
Assert.True(limitUpPattern < limitUpUnit);
Assert.True(limitDownUnit < limitDownPattern);
}
[Fact]
public void S8018_market_and_industry_pairs_repeat_three_values_in_original_side_order()
{
var builder = new S8018SceneMutationBuilder();
var market = builder.Build(new S8018MarketPairSceneData(
LegacyMarketQuoteTarget.WonDollar,
LegacyMarketQuoteTarget.Nasdaq,
new S8018MarketQuote("원달러", 1_381.2m, -2.3m, 0.17m, ScenePriceDirection.Down),
new S8018MarketQuote("나스닥", 20_100.5m, 100m, 0.50m, ScenePriceDirection.Up)))
.Select(Describe).ToArray();
Assert.Equal(
[
"S:mart1=0", "S:mart2=0", "S:end1=0", "S:end2=0",
"V:title1=원달러", "V:title2=나스닥",
"V:price1=1,381.20", "V:price2=20,100.50",
"V:changePrice1_1=2.30", "V:changePrice2_1=2.30", "V:changePrice3_1=2.30",
"V:changePrice1_2=100.00", "V:changePrice2_2=100.00", "V:changePrice3_2=100.00",
"V:rate1_1=-0.17", "V:rate2_1=-0.17", "V:rate3_1=-0.17",
"V:rate1_2=0.50", "V:rate2_2=0.50", "V:rate3_2=0.50",
"V:wonText1=", "V:wonText2=", "V:unitText1=단위 : p", "V:unitText2=단위 : p"
], market.Take(24));
var industry = builder.Build(new S8018DomesticIndustryPairSceneData(
LegacyDomesticEquityMarket.Kosdaq,
new S8018MarketQuote("반도체", 1, 0, 0, ScenePriceDirection.Flat),
new S8018MarketQuote("IT부품", 2, 0, 0, ScenePriceDirection.Flat)))
.Select(Describe).ToArray();
Assert.Equal("V:title1=코스닥 - 반도체", industry[4]);
Assert.Equal("V:title2=코스닥 - IT부품", industry[5]);
}
[Fact]
public void S8018_mixed_pair_preserves_partial_DataView_then_SetDataText_order_on_both_sides()
{
var builder = new S8018SceneMutationBuilder();
var stockFirst = builder.Build(new S8018MixedMarketAndStockSceneData(
S8018PairSide.First,
LegacyMarketQuoteTarget.Kospi,
new S8018MarketQuote("코스피", 2_800m, 10m, 0.36m, ScenePriceDirection.Up),
new S8018MixedStockQuote(LegacyDomesticEquityMarket.Kospi, "삼성전자",
70_000, -500, -0.71m, ScenePriceDirection.Down)))
.Select(Describe).ToArray();
Assert.Equal("V:title2=코스피", stockFirst[4]);
Assert.True(Array.IndexOf(stockFirst, "V:title1=삼성전자") >
Array.IndexOf(stockFirst, @"A:pattern2=Images\그림_빨강.png"));
Assert.Contains("V:unitText2=단위 : p", stockFirst);
Assert.Contains("V:unitText1=단위 : 원", stockFirst);
var stockSecond = builder.Build(new S8018MixedMarketAndStockSceneData(
S8018PairSide.Second,
LegacyMarketQuoteTarget.Kosdaq,
new S8018MarketQuote("코스닥", 900m, 0m, 0m, ScenePriceDirection.Flat),
new S8018MixedStockQuote(LegacyDomesticEquityMarket.Kosdaq, "테스트",
10_000, 0, 0, ScenePriceDirection.Flat)))
.Select(Describe).ToArray();
Assert.Equal("V:title1=코스닥", stockSecond[4]);
Assert.DoesNotContain("V:unitText1=단위 : p", stockSecond);
Assert.Contains("V:unitText2=단위 : 원", stockSecond);
}
[Fact]
public void S8018_expected_pair_interleaves_each_side_and_uses_limit_signals()
{
var actual = new S8018SceneMutationBuilder().Build(new S8018ExpectedStockPairSceneData(
new S8018ExpectedStockQuote(LegacyDomesticEquityMarket.Kospi, "A",
10_000, -3_000, 30m, ScenePriceDirection.LimitUp),
new S8018ExpectedStockQuote(LegacyDomesticEquityMarket.Kosdaq, "B",
20_000, -6_000, -30m, ScenePriceDirection.LimitDown)))
.Select(Describe).ToArray();
Assert.Equal(
[
"S:mart1=0", "S:mart2=0", "S:end1=0", "S:end2=0",
"V:title1=A 예상체결", "V:title2=B 예상체결",
"V:price1=10,000", "V:price2=20,000",
"V:changePrice1_1=3,000", "V:changePrice1_2=6,000",
"V:changePrice2_1=3,000", "V:changePrice2_2=6,000",
"V:changePrice3_1=3,000", "V:changePrice3_2=6,000"
], actual.Take(14));
Assert.Contains("S:upup1=1", actual);
Assert.Contains("S:downdown2=1", actual);
}
[Fact]
public void S8018_current_pair_matches_units_badges_raw_scale_and_both_krx_override()
{
var builder = new S8018SceneMutationBuilder();
var mixedVenue = builder.Build(new S8018CurrentStockPairSceneData(
S8018SessionPhase.AfterKrxClose,
new S8018CurrentStockQuote(S8018StockMarket.NxtKospi, "NXT 종목",
70_000m, 500m, 0.72m, ScenePriceDirection.Up),
new S8018CurrentStockQuote(S8018StockMarket.World, "NVIDIA",
190.250m, -2.150m, -1.12m, ScenePriceDirection.Down)))
.Select(Describe).ToArray();
Assert.Equal("V:unitText1=단위 : 원", mixedVenue[4]);
Assert.Equal("V:unitText2=단위 : 달러", mixedVenue[5]);
Assert.Equal(@"A:mart1=Images\NXT.png", mixedVenue[8]);
Assert.Equal("S:mart1=1", mixedVenue[9]);
Assert.Equal("S:mart2=0", mixedVenue[10]);
Assert.Equal("S:end2=0", mixedVenue[11]);
Assert.Equal("V:price1=70,000", mixedVenue[12]);
Assert.Equal("V:price2=190.250", mixedVenue[13]);
AssertRelativeAssetsOnly(builder.Build(new S8018CurrentStockPairSceneData(
S8018SessionPhase.AfterKrxClose,
new S8018CurrentStockQuote(S8018StockMarket.Kospi, "A", 1, 1, 1, ScenePriceDirection.Up),
new S8018CurrentStockQuote(S8018StockMarket.NxtKosdaq, "B", 2, 1, 1, ScenePriceDirection.Up))));
var bothKrx = builder.Build(new S8018CurrentStockPairSceneData(
S8018SessionPhase.AfterKrxClose,
new S8018CurrentStockQuote(S8018StockMarket.Kospi, "A", 1, 1, 1, ScenePriceDirection.Up),
new S8018CurrentStockQuote(S8018StockMarket.Kosdaq, "B", 2, -1, -1, ScenePriceDirection.Down)))
.Select(Describe).ToArray();
var secondEndShown = Array.IndexOf(bothKrx, "S:end2=1");
Assert.True(secondEndShown >= 0);
Assert.Equal(
["S:mart1=0", "S:mart2=0", "S:end1=0", "S:end2=0"],
bothKrx.Skip(secondEndShown + 1).Take(4));
}
[Fact]
public void S8001_uses_only_fixed_sector_targets_and_original_threshold_colors()
{
var actual = new S8001SceneMutationBuilder().Build(new S8001SceneData(
S8001Market.Kospi,
[
new S8001SectorQuote(S8001Sector.Communications, S8001SectorSignal.Positive, 0.99m),
new S8001SectorQuote(S8001Sector.Finance, S8001SectorSignal.Positive, 1.00m),
new S8001SectorQuote(S8001Sector.Construction, S8001SectorSignal.Positive, 3.00m),
new S8001SectorQuote(S8001Sector.Machinery, S8001SectorSignal.Positive, 5.00m),
new S8001SectorQuote(S8001Sector.Distribution, S8001SectorSignal.Positive, 10.00m),
new S8001SectorQuote(S8001Sector.TransportationEquipment, S8001SectorSignal.Negative, 0.99m),
new S8001SectorQuote(S8001Sector.TransportationWarehouse, S8001SectorSignal.Negative, 1.00m),
new S8001SectorQuote(S8001Sector.FoodAndBeverage, S8001SectorSignal.Negative, 3.00m),
new S8001SectorQuote(S8001Sector.SteelAndMetal, S8001SectorSignal.Negative, 5.00m),
new S8001SectorQuote(S8001Sector.MedicalPrecision, S8001SectorSignal.Negative, 10.00m),
new S8001SectorQuote(S8001Sector.TextilesAndApparel, S8001SectorSignal.Flat, 0m),
new S8001SectorQuote(S8001Sector.Chemicals, S8001SectorSignal.Positive, 1m),
new S8001SectorQuote(S8001Sector.NonMetallicMinerals, S8001SectorSignal.Negative, 1m)
])).Select(Describe).ToArray();
Assert.Contains("V:통신_등락=0.99", actual);
Assert.Contains("C:통신_큐브=206,61,59,255", actual);
Assert.Contains("C:금융_큐브=181,28,39,255", actual);
Assert.Contains("C:건설_큐브=154,28,39,255", actual);
Assert.Contains("C:기계_큐브=131,28,39,255", actual);
Assert.Contains("C:유통_큐브=174,0,0,255", actual);
Assert.Contains("V:운수장비_등락=-0.99", actual);
Assert.Contains("C:운수장비_큐브=66,128,185,255", actual);
Assert.Contains("C:운수창고_큐브=52,117,157,255", actual);
Assert.Contains("C:음식료_큐브=52,78,126,255", actual);
Assert.Contains("C:철강금속_큐브=42,61,124,255", actual);
Assert.Contains("C:의료정밀_큐브=42,47,124,255", actual);
Assert.Contains("V:섬유의복_등락=0", actual);
Assert.DoesNotContain(actual, value => value.StartsWith("C:섬유의복_큐브=", StringComparison.Ordinal));
Assert.Contains("V:화학_등락=1", actual);
Assert.Contains("V:비금속광물_등락=-1", actual);
}
[Fact]
public void S8001_sector_enum_maps_every_cut_object_for_both_markets()
{
var kospi = Enum.GetValues<S8001Sector>()
.Where(value => value is not S8001Sector.MachineryAndEquipment and
not S8001Sector.ItComponents and not S8001Sector.Transportation and
not S8001Sector.Semiconductor and not S8001Sector.Internet)
.Select(value => new S8001SectorQuote(value, S8001SectorSignal.Flat, 0m))
.ToArray();
var kosdaq = new[]
{
S8001Sector.MachineryAndEquipment,
S8001Sector.ItComponents,
S8001Sector.Transportation,
S8001Sector.PaperAndWood,
S8001Sector.Semiconductor,
S8001Sector.Internet
}.Select(value => new S8001SectorQuote(value, S8001SectorSignal.Flat, 0m)).ToArray();
Assert.Equal(15, kospi.Length);
Assert.Equal(15, new S8001SceneMutationBuilder().Build(new S8001SceneData(S8001Market.Kospi, kospi)).Count);
Assert.Equal(6, new S8001SceneMutationBuilder().Build(new S8001SceneData(S8001Market.Kosdaq, kosdaq)).Count);
}
[Fact]
public void Parameterized_DTOs_do_not_expose_raw_object_method_or_path_fields()
{
var dtoTypes = typeof(S5001SceneData).Assembly.GetTypes()
.Where(type => type.Namespace == typeof(S5001SceneData).Namespace)
.Where(type => typeof(ILegacySceneData).IsAssignableFrom(type) ||
type.Name.StartsWith("S5001", StringComparison.Ordinal) ||
type.Name.StartsWith("S5032", StringComparison.Ordinal) ||
type.Name.StartsWith("S8018", StringComparison.Ordinal) ||
type.Name.StartsWith("S8001", StringComparison.Ordinal))
.Where(type => type.Name.Contains("Data", StringComparison.Ordinal) ||
type.Name.Contains("Quote", StringComparison.Ordinal));
foreach (var property in dtoTypes.SelectMany(type => type.GetProperties()))
{
Assert.NotEqual(typeof(object), property.PropertyType);
Assert.DoesNotContain("Object", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Method", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Path", property.Name, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void Invalid_closed_enum_combinations_fail_before_emitting_unsafe_names()
{
Assert.Throws<LegacySceneDataException>(() => new S8001SceneMutationBuilder().Build(
new S8001SceneData(S8001Market.Kosdaq,
[new S8001SectorQuote(S8001Sector.Communications, S8001SectorSignal.Positive, 1m)])));
Assert.Throws<LegacySceneDataException>(() => new S5001SceneMutationBuilder().Build(
new S5001DomesticStockSceneData(LegacyDomesticEquityMarket.Kospi,
S5001DomesticStockMode.Current, S5001NxtBadge.PreMarket, "A",
ScenePriceDirection.Up, 1, 1, 1)));
Assert.Throws<LegacySceneDataException>(() => new S5032SceneMutationBuilder().Build(
new S5032IndexAndFuturesSceneData((LegacyMarketQuoteTarget)999,
LegacyMarketQuoteTarget.Futures,
new S5032PrimaryQuote("A", 1, 1, 1, ScenePriceDirection.Up),
new S5032CompanionQuote("B", 1, 1, S5032CompanionSignal.Flat, 1, 1, 1))));
}
private static void AssertRelativeAssetsOnly(IReadOnlyList<PlayoutMutation> mutations)
{
var assets = mutations.OfType<PlayoutSetAssetValue>().Select(value => value.AssetPath).ToArray();
Assert.NotEmpty(assets);
Assert.All(assets, asset =>
{
Assert.False(Path.IsPathRooted(asset));
Assert.DoesNotContain(":", asset, StringComparison.Ordinal);
Assert.DoesNotContain("MBN_STOCK_N", asset, StringComparison.OrdinalIgnoreCase);
});
}
private static string Describe(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
PlayoutSetAssetValue asset => $"A:{asset.ObjectName}={asset.AssetPath}",
PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
PlayoutSetFaceColor color =>
$"C:{color.ObjectName}={color.Red},{color.Green},{color.Blue},{color.Alpha}",
_ => mutation.ToString() ?? mutation.GetType().Name
};
}

View File

@@ -0,0 +1,618 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ParameterizedMarketSceneDataLoadersTests
{
[Fact]
public async Task S5001_every_top_level_branch_uses_a_bounded_parameterized_query_and_builds_mutations()
{
S5001SceneLoadRequest[] requests =
[
new S5001BalticDryIndexLoadRequest(),
new S5001CommodityLoadRequest(S5001CommodityTarget.Copper),
new S5001DomesticIndexLoadRequest(S5001DomesticIndexTarget.Kospi, S5001IndexMode.Current),
new S5001ForeignIndexLoadRequest(S5001ForeignIndexTarget.Nasdaq),
new S5001ExchangeRateLoadRequest(S5001ExchangeRateTarget.WonDollar),
new S5001DomesticIndustryLoadRequest(LegacyDomesticEquityMarket.Kosdaq, "반도체"),
new S5001ForeignIndustryLoadRequest("필라델피아 반도체"),
new S5001ForeignStockLoadRequest("NVIDIA"),
new S5001DomesticStockLoadRequest(
LegacyDomesticEquityMarket.Kospi,
S5001DomesticStockMode.Current,
S5001NxtBadge.None,
S5001StockTradingStatus.Regular,
"삼성전자")
];
foreach (var request in requests)
{
var executor = new RecordingExecutor(QuoteTable("테스트", 100m, "+", 1m, 1m));
var data = await new S5001SceneDataLoader(executor).LoadAsync(request);
var mutations = new S5001SceneMutationBuilder().Build(data);
Assert.Equal(request.Branch, data.Branch);
Assert.NotEmpty(mutations);
var call = Assert.Single(executor.Calls);
AssertReadOnlyBounded(call);
Assert.NotEmpty(call.Spec.Parameters);
Assert.Equal(0, executor.StringCalls);
}
}
[Fact]
public async Task S5001_stock_modes_select_fixed_source_and_parameter_marker_without_sql_text_injection()
{
const string selected = "이름' OR '1'='1";
var cases = new[]
{
(new S5001DomesticStockLoadRequest(
LegacyDomesticEquityMarket.Kospi,
S5001DomesticStockMode.ExpectedOpening,
S5001NxtBadge.None,
S5001StockTradingStatus.Regular,
selected),
DataSourceKind.Oracle, "SCENE_EXPECTED_KOSPI_STOCK", ":stockName"),
(new S5001DomesticStockLoadRequest(
LegacyDomesticEquityMarket.Kosdaq,
S5001DomesticStockMode.AfterHoursSinglePrice,
S5001NxtBadge.None,
S5001StockTradingStatus.Regular,
selected),
DataSourceKind.Oracle, "SCENE_AFTER_HOURS_KOSDAQ_STOCK", ":stockName"),
(new S5001DomesticStockLoadRequest(
LegacyDomesticEquityMarket.Kospi,
S5001DomesticStockMode.Current,
S5001NxtBadge.None,
S5001StockTradingStatus.Halted,
selected),
DataSourceKind.Oracle, "SCENE_HALTED_KOSPI_STOCK", ":stockName"),
(new S5001DomesticStockLoadRequest(
LegacyDomesticEquityMarket.NxtKospi,
S5001DomesticStockMode.Current,
S5001NxtBadge.PreMarket,
S5001StockTradingStatus.Regular,
selected + "(NXT)"),
DataSourceKind.MariaDb, "SCENE_NXT_KOSPI_STOCK", "@stockName")
};
foreach (var (request, source, tableName, placeholder) in cases)
{
var executor = new RecordingExecutor(QuoteTable("테스트", 100m, "+", 1m, 1m));
var data = await new S5001SceneDataLoader(executor).LoadAsync(request);
var call = Assert.Single(executor.Calls);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal(source, call.Source);
Assert.Equal(tableName, call.TableName);
Assert.Contains(placeholder, call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(selected, call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(selected, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
Assert.NotEmpty(new S5001SceneMutationBuilder().Build(data));
}
}
[Fact]
public async Task S5001_closed_target_sets_all_construct_valid_specs_and_generate_typed_data()
{
foreach (var target in Enum.GetValues<S5001CommodityTarget>())
{
var executor = new RecordingExecutor(QuoteTable("상품", 1.25m, "+", 0.25m, 1m));
var data = await new S5001SceneDataLoader(executor)
.LoadAsync(new S5001CommodityLoadRequest(target));
Assert.Equal(target, Assert.IsType<S5001CommoditySceneData>(data).Target);
AssertReadOnlyBounded(Assert.Single(executor.Calls));
}
foreach (var target in Enum.GetValues<S5001DomesticIndexTarget>())
{
foreach (var mode in Enum.GetValues<S5001IndexMode>())
{
var executor = new RecordingExecutor(QuoteTable("지수", 1m, "+", 1m, 1m));
var data = await new S5001SceneDataLoader(executor)
.LoadAsync(new S5001DomesticIndexLoadRequest(target, mode));
Assert.Equal(target, Assert.IsType<S5001DomesticIndexSceneData>(data).Target);
AssertReadOnlyBounded(Assert.Single(executor.Calls));
}
}
foreach (var target in Enum.GetValues<S5001ForeignIndexTarget>())
{
var executor = new RecordingExecutor(QuoteTable("해외", 1m, "-", -1m, -1m));
var data = await new S5001SceneDataLoader(executor)
.LoadAsync(new S5001ForeignIndexLoadRequest(target));
Assert.Equal(target, Assert.IsType<S5001ForeignIndexSceneData>(data).Target);
}
foreach (var target in Enum.GetValues<S5001ExchangeRateTarget>())
{
var executor = new RecordingExecutor(QuoteTable("환율", 1m, "", 0m, 0m));
var data = await new S5001SceneDataLoader(executor)
.LoadAsync(new S5001ExchangeRateLoadRequest(target));
Assert.Equal(target, Assert.IsType<S5001ExchangeRateSceneData>(data).Target);
Assert.Equal(
new[] { "displayName", "inputCode" },
Assert.Single(executor.Calls).Spec.Parameters.Select(parameter => parameter.Name));
}
}
[Fact]
public async Task S5001_nxt_industry_fails_closed_because_original_has_no_request_implementation()
{
foreach (var market in new[]
{
LegacyDomesticEquityMarket.NxtKospi,
LegacyDomesticEquityMarket.NxtKosdaq
})
{
var executor = new RecordingExecutor(QuoteTable("반도체", 100m, "+", 1m, 1m));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5001SceneDataLoader(executor).LoadAsync(
new S5001DomesticIndustryLoadRequest(market, "반도체")));
Assert.Empty(executor.Calls);
}
}
[Fact]
public async Task Remaining_closed_stock_and_nxt_query_variants_construct_provider_valid_specs()
{
var cases = new S5001SceneLoadRequest[]
{
new S5001DomesticStockLoadRequest(
LegacyDomesticEquityMarket.NxtKosdaq,
S5001DomesticStockMode.Current,
S5001NxtBadge.AfterMarket,
S5001StockTradingStatus.Regular,
"A(NXT)"),
new S5001DomesticStockLoadRequest(
LegacyDomesticEquityMarket.Kosdaq,
S5001DomesticStockMode.Current,
S5001NxtBadge.None,
S5001StockTradingStatus.Halted,
"B"),
new S5001DomesticStockLoadRequest(
LegacyDomesticEquityMarket.Kospi,
S5001DomesticStockMode.AfterHoursSinglePrice,
S5001NxtBadge.None,
S5001StockTradingStatus.Regular,
"C")
};
foreach (var request in cases)
{
var executor = new RecordingExecutor(QuoteTable("테스트", 1m, "+", 1m, 1m));
var data = await new S5001SceneDataLoader(executor).LoadAsync(request);
var call = Assert.Single(executor.Calls);
AssertReadOnlyBounded(call);
Assert.NotEmpty(new S5001SceneMutationBuilder().Build(data));
}
}
[Fact]
public async Task S5032_all_branches_preserve_query_order_and_generate_builder_mutations()
{
var indexExecutor = new RecordingExecutor(
QuoteTable("코스피", 2_800m, "+", 10m, 0.36m),
FutureTable("선물", 380m, "-", 2m, -0.52m, 100m, 200m, 3_000_000m));
var index = await new S5032SceneDataLoader(indexExecutor).LoadAsync(
new S5032IndexAndFuturesLoadRequest(LegacyMarketQuoteTarget.Kospi));
Assert.Equal(
new[] { "SCENE_KOSPI_INDEX", "SCENE_FUTURES_DETAIL" },
indexExecutor.Calls.Select(call => call.TableName));
Assert.NotEmpty(new S5032SceneMutationBuilder().Build(index));
var industryExecutor = new RecordingExecutor(
QuoteTable("기계", 100m, "+", 1m, 1m),
QuoteTable("반도체", 200m, "", 0m, 0m));
var industry = await new S5032SceneDataLoader(industryExecutor).LoadAsync(
new S5032DomesticIndustryPairLoadRequest(
LegacyDomesticEquityMarket.Kospi, "기계", "반도체"));
Assert.Equal(
new[] { "기계", "반도체" },
industryExecutor.Calls.Select(call => Assert.Single(call.Spec.Parameters).Value));
Assert.NotEmpty(new S5032SceneMutationBuilder().Build(industry));
var stockExecutor = new RecordingExecutor(
QuoteTable("A", 1_000m, "+", 100m, 10m),
QuoteTable("B", 2_000m, "-", -200m, -10m));
var stocks = await new S5032SceneDataLoader(stockExecutor).LoadAsync(
new S5032DomesticStockPairLoadRequest(
LegacyDomesticEquityMarket.Kospi, "A",
LegacyDomesticEquityMarket.Kosdaq, "B"));
Assert.Equal(
new[] { "SCENE_KOSPI_STOCK", "SCENE_KOSDAQ_STOCK" },
stockExecutor.Calls.Select(call => call.TableName));
Assert.NotEmpty(new S5032SceneMutationBuilder().Build(stocks));
Assert.All(indexExecutor.Calls.Concat(industryExecutor.Calls).Concat(stockExecutor.Calls), AssertReadOnlyBounded);
}
[Fact]
public async Task S8018_all_branches_preserve_original_first_then_second_query_order()
{
var marketExecutor = new RecordingExecutor(
QuoteTable("다우", 1m, "+", 1m, 1m),
QuoteTable("나스닥", 2m, "-", -1m, -1m));
var market = await new S8018SceneDataLoader(marketExecutor).LoadAsync(
new S8018MarketPairLoadRequest(
S8018MarketDataMode.Current,
LegacyMarketQuoteTarget.Dow,
LegacyMarketQuoteTarget.Nasdaq));
Assert.Equal(
new[] { "DJI@DJI", "NAS@IXIC" },
marketExecutor.Calls.Select(call => Assert.Single(call.Spec.Parameters).Value));
Assert.NotEmpty(new S8018SceneMutationBuilder().Build(market));
var mixedExecutor = new RecordingExecutor(
QuoteTable("삼성전자", 70_000m, "+", 500m, 0.72m),
QuoteTable("나스닥", 20_000m, "+", 100m, 0.5m));
var mixed = await new S8018SceneDataLoader(mixedExecutor).LoadAsync(
new S8018MixedMarketAndStockLoadRequest(
S8018PairSide.First,
LegacyMarketQuoteTarget.Nasdaq,
LegacyDomesticEquityMarket.Kospi,
"삼성전자"));
Assert.Equal(
new[] { "SCENE_KOSPI_STOCK", "SCENE_FOREIGN_INDEX" },
mixedExecutor.Calls.Select(call => call.TableName));
Assert.NotEmpty(new S8018SceneMutationBuilder().Build(mixed));
var expectedExecutor = new RecordingExecutor(
QuoteTable("A", 1_000m, "+", 10m, 1m),
QuoteTable("B", 2_000m, "-", 20m, -1m));
var expected = await new S8018SceneDataLoader(expectedExecutor).LoadAsync(
new S8018ExpectedStockPairLoadRequest(
LegacyDomesticEquityMarket.Kospi, "A",
LegacyDomesticEquityMarket.Kosdaq, "B"));
Assert.Equal(
new[] { "SCENE_EXPECTED_KOSPI_STOCK", "SCENE_EXPECTED_KOSDAQ_STOCK" },
expectedExecutor.Calls.Select(call => call.TableName));
Assert.NotEmpty(new S8018SceneMutationBuilder().Build(expected));
var currentExecutor = new RecordingExecutor(
QuoteTable("NXT A", 1_000m, "+", 10m, 1m),
QuoteTable("NVIDIA", 200.50m, "-", -2.25m, -1.1m));
var current = await new S8018SceneDataLoader(currentExecutor).LoadAsync(
new S8018CurrentStockPairLoadRequest(
S8018SessionPhase.AfterKrxClose,
new S8018CurrentStockSelection(S8018StockMarket.NxtKospi, "A(NXT)"),
new S8018CurrentStockSelection(S8018StockMarket.World, "NVIDIA")));
Assert.Equal(
new[] { DataSourceKind.MariaDb, DataSourceKind.Oracle },
currentExecutor.Calls.Select(call => call.Source));
Assert.Equal(
new[] { "SCENE_NXT_KOSPI_STOCK", "SCENE_FOREIGN_STOCK" },
currentExecutor.Calls.Select(call => call.TableName));
Assert.NotEmpty(new S8018SceneMutationBuilder().Build(current));
var industryExecutor = new RecordingExecutor(
QuoteTable("기계", 1m, "+", 1m, 1m),
QuoteTable("반도체", 2m, "", 0m, 0m));
var industry = await new S8018SceneDataLoader(industryExecutor).LoadAsync(
new S8018DomesticIndustryPairLoadRequest(
LegacyDomesticEquityMarket.Kosdaq, "기계", "반도체"));
Assert.NotEmpty(new S8018SceneMutationBuilder().Build(industry));
Assert.All(
marketExecutor.Calls.Concat(mixedExecutor.Calls).Concat(expectedExecutor.Calls)
.Concat(currentExecutor.Calls).Concat(industryExecutor.Calls),
AssertReadOnlyBounded);
}
[Fact]
public async Task S8018_expected_market_mode_is_closed_to_domestic_targets()
{
var executor = new RecordingExecutor(
QuoteTable("코스피 예상", 1m, "+", 1m, 1m),
QuoteTable("선물 예상", 2m, "-", 1m, -1m));
var data = await new S8018SceneDataLoader(executor).LoadAsync(
new S8018MarketPairLoadRequest(
S8018MarketDataMode.Expected,
LegacyMarketQuoteTarget.Kospi,
LegacyMarketQuoteTarget.Futures));
Assert.Equal(
new[] { "SCENE_EXPECTED_KOSPI_INDEX", "SCENE_EXPECTED_FUTURES_INDEX" },
executor.Calls.Select(call => call.TableName));
Assert.NotEmpty(new S8018SceneMutationBuilder().Build(data));
var rejected = new RecordingExecutor(QuoteTable("X", 1m, "+", 1m, 1m));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8018SceneDataLoader(rejected).LoadAsync(
new S8018MarketPairLoadRequest(
S8018MarketDataMode.Expected,
LegacyMarketQuoteTarget.Nasdaq,
LegacyMarketQuoteTarget.Kospi)));
Assert.Empty(rejected.Calls);
}
[Fact]
public async Task S8018_expected_mixed_pair_queries_market_then_stock_when_stock_is_second()
{
var executor = new RecordingExecutor(
QuoteTable("코스피 예상", 2_800m, "+", 10m, 0.36m),
QuoteTable("삼성전자", 70_000m, "-", 500m, -0.72m));
var data = await new S8018SceneDataLoader(executor).LoadAsync(
new S8018MixedMarketAndStockLoadRequest(
S8018PairSide.Second,
LegacyMarketQuoteTarget.Kospi,
LegacyDomesticEquityMarket.Kospi,
"삼성전자",
S8018MarketDataMode.Expected));
Assert.Equal(
new[] { "SCENE_EXPECTED_KOSPI_INDEX", "SCENE_EXPECTED_KOSPI_STOCK" },
executor.Calls.Select(call => call.TableName));
Assert.NotEmpty(new S8018SceneMutationBuilder().Build(data));
}
[Fact]
public async Task S8001_maps_only_closed_fixed_sector_names_and_preserves_parameter_order()
{
var names = new[]
{
"통신", "금융", "건설", "기계", "유통", "종이목재", "운수장비",
"운수창고", "음식료", "철강금속", "의료정밀", "섬유의복", "전기전자",
"화학", "비금속광물"
};
var codes = new[]
{
"020", "021", "018", "012", "016", "007", "015", "019",
"005", "011", "014", "006", "013", "008", "009"
};
var table = SectorTable(codes, names);
var executor = new RecordingExecutor(table);
var data = await new S8001SceneDataLoader(executor)
.LoadAsync(new S8001SceneLoadRequest(S8001Market.Kospi));
var mutations = new S8001SceneMutationBuilder().Build(data);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("SCENE_8001_KOSPI_SECTORS", call.TableName);
Assert.Contains("FETCH FIRST 15 ROWS ONLY", call.Spec.Sql, StringComparison.Ordinal);
Assert.Empty(call.Spec.Parameters);
Assert.All(codes, code => Assert.Contains($"'{code}'", call.Spec.Sql, StringComparison.Ordinal));
Assert.Equal(15, data.Sectors.Count);
Assert.Contains(data.Sectors, row => row.Sector == S8001Sector.Chemicals);
Assert.Contains(data.Sectors, row => row.Sector == S8001Sector.NonMetallicMinerals);
Assert.NotEmpty(mutations);
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S8001_kosdaq_requires_all_six_fixed_targets_and_builds_only_enum_names()
{
var names = new[] { "기계장비", "IT부품", "운송", "종이목재", "반도체", "인터넷" };
var executor = new RecordingExecutor(SectorTable(
["001", "002", "003", "004", "005", "006"],
names));
var data = await new S8001SceneDataLoader(executor)
.LoadAsync(new S8001SceneLoadRequest(S8001Market.Kosdaq));
var call = Assert.Single(executor.Calls);
var mutations = new S8001SceneMutationBuilder().Build(data);
Assert.Equal("SCENE_8001_KOSDAQ_SECTORS", call.TableName);
Assert.Contains("FETCH FIRST 7 ROWS ONLY", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(names, call.Spec.Parameters.Select(parameter => parameter.Value));
Assert.Equal(6, data.Sectors.Count);
Assert.Equal(6 + data.Sectors.Count(row => row.Signal != S8001SectorSignal.Flat), mutations.Count);
}
[Theory]
[InlineData(0)]
[InlineData(2)]
public async Task Single_quote_loaders_reject_missing_or_ambiguous_rows(int rowCount)
{
var table = QuoteTable("A", 1m, "+", 1m, 1m);
if (rowCount == 0)
{
table.Rows.Clear();
}
else
{
table.ImportRow(table.Rows[0]);
}
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5001SceneDataLoader(new RecordingExecutor(table)).LoadAsync(
new S5001ForeignStockLoadRequest("A")));
}
[Fact]
public async Task Loaders_fail_closed_for_schema_null_direction_and_invalid_enum_before_mutation()
{
var wrongSchema = QuoteTable("A", 1m, "+", 1m, 1m);
wrongSchema.Columns.Add("EXTRA", typeof(object));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5001SceneDataLoader(new RecordingExecutor(wrongSchema)).LoadAsync(
new S5001ForeignStockLoadRequest("A")));
var nullValue = QuoteTable("A", 1m, "+", 1m, 1m);
nullValue.Rows[0]["PRICE"] = DBNull.Value;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5001SceneDataLoader(new RecordingExecutor(nullValue)).LoadAsync(
new S5001ForeignStockLoadRequest("A")));
var unknownDirection = QuoteTable("A", 1m, "UNKNOWN", 1m, 1m);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5001SceneDataLoader(new RecordingExecutor(unknownDirection)).LoadAsync(
new S5001ForeignStockLoadRequest("A")));
var invalid = new RecordingExecutor(QuoteTable("A", 1m, "+", 1m, 1m));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5001SceneDataLoader(invalid).LoadAsync(
new S5001CommodityLoadRequest((S5001CommodityTarget)999)));
Assert.Empty(invalid.Calls);
var nullSelector = new RecordingExecutor(QuoteTable("A", 1m, "+", 1m, 1m));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5001SceneDataLoader(nullSelector).LoadAsync(
new S5001DomesticStockLoadRequest(
LegacyDomesticEquityMarket.Kospi,
S5001DomesticStockMode.Current,
S5001NxtBadge.None,
S5001StockTradingStatus.Regular,
null!)));
Assert.Empty(nullSelector.Calls);
}
[Fact]
public async Task Loader_errors_do_not_echo_selected_database_keys()
{
const string sensitive = "SENSITIVE-STOCK-SELECTION";
var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5001SceneDataLoader(new RecordingExecutor(new DataTable())).LoadAsync(
new S5001ForeignStockLoadRequest(sensitive)));
Assert.DoesNotContain(sensitive, exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Public_load_requests_expose_no_raw_sql_symbol_object_method_or_path_fields()
{
var requestTypes = typeof(S5001SceneLoadRequest).Assembly.GetTypes()
.Where(type => type.IsPublic)
.Where(type => typeof(S5001SceneLoadRequest).IsAssignableFrom(type) ||
typeof(S5032SceneLoadRequest).IsAssignableFrom(type) ||
typeof(S8018SceneLoadRequest).IsAssignableFrom(type) ||
type == typeof(S8001SceneLoadRequest) ||
type == typeof(S8018CurrentStockSelection));
foreach (var property in requestTypes.SelectMany(type => type.GetProperties()))
{
Assert.NotEqual(typeof(object), property.PropertyType);
Assert.DoesNotContain("Sql", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Query", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Symbol", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Object", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Method", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Path", property.Name, StringComparison.OrdinalIgnoreCase);
}
}
private static void AssertReadOnlyBounded(RecordedCall call)
{
call.Spec.ValidateFor(call.Source);
Assert.StartsWith("SELECT", call.Spec.Sql.TrimStart(), StringComparison.OrdinalIgnoreCase);
Assert.True(
call.Spec.Sql.Contains("FETCH FIRST 2 ROWS ONLY", StringComparison.OrdinalIgnoreCase) ||
call.Spec.Sql.Contains("LIMIT 2", StringComparison.OrdinalIgnoreCase));
}
private static DataTable QuoteTable(
string title,
decimal price,
string direction,
decimal change,
decimal rate)
{
var table = Table("TITLE", "PRICE", "DIRECTION", "NET_CHANGE", "RATE");
table.Rows.Add(title, price, direction, change, rate);
return table;
}
private static DataTable FutureTable(
string title,
decimal price,
string direction,
decimal change,
decimal rate,
decimal netVolume,
decimal outstandingVolume,
decimal netTurnover)
{
var table = Table(
"TITLE", "PRICE", "DIRECTION", "NET_CHANGE", "RATE",
"NET_VOLUME", "OUTSTANDING_VOLUME", "NET_TURNOVER");
table.Rows.Add(
title, price, direction, change, rate,
netVolume, outstandingVolume, netTurnover);
return table;
}
private static DataTable SectorTable(
IReadOnlyList<string> codes,
IReadOnlyList<string> names)
{
Assert.Equal(codes.Count, names.Count);
var table = Table("SECTOR_CODE", "SECTOR_NAME", "DIRECTION", "MAGNITUDE");
for (var index = 0; index < names.Count; index++)
{
table.Rows.Add(
codes[index],
names[index],
index % 3 == 0 ? "+" : index % 3 == 1 ? "" : "-",
index / 10m);
}
return table;
}
private static DataTable Table(params string[] columns)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(object));
}
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor : IDataQueryExecutor
{
private readonly Queue<DataTable> _results;
public RecordingExecutor(params DataTable[] results)
{
_results = new Queue<DataTable>(results);
}
public List<RecordedCall> Calls { get; } = [];
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new RecordedCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("No fake query result remains.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
}

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
@@ -84,4 +85,112 @@ public sealed class PlayoutBridgeProtocolTests
Assert.Equal(string.Empty, result.Request.RequestId);
Assert.DoesNotContain(unsafeRequestId, result.Error, StringComparison.Ordinal);
}
[Fact]
public void ParseWorkflowCommand_AcceptsPreparePlaylistAndNativeLookupFields()
{
using var document = JsonDocument.Parse("""
{
"requestId":"REQ_1",
"command":"prepare",
"playlist":[
{
"id":"entry-1",
"code":"5001",
"enabled":true,
"fadeDuration":3,
"selection":{
"groupCode":"코스피",
"subject":"삼성전자",
"graphicType":"1열판 기본",
"subtype":"현재가",
"dataCode":"005930"
}
},
{"id":"entry-2","code":"5074","enabled":false}
],
"selectedIndex":0
}
""");
var result = PlayoutBridgeProtocol.ParseWorkflowCommand(document.RootElement);
Assert.True(result.IsValid);
Assert.Equal("REQ_1", result.Request.RequestId);
Assert.Equal("prepare", result.Request.Command);
Assert.Equal(0, result.Request.SelectedIndexZeroBased);
var playlist = Assert.IsAssignableFrom<IReadOnlyList<LegacyPlaylistEntry>>(
result.Request.Playlist);
Assert.Equal(2, playlist.Count);
Assert.Equal("005930", playlist[0].Selection?.DataCode);
Assert.Equal(3, playlist[0].FadeDuration);
Assert.False(playlist[1].IsEnabled);
}
[Theory]
[InlineData("take-in")]
[InlineData("next")]
[InlineData("take-out")]
public void ParseWorkflowCommand_AcceptsStateCommandsWithoutCallerSelectedCue(string command)
{
using var document = JsonDocument.Parse(
$"{{\"requestId\":\"REQ\",\"command\":\"{command}\"}}");
var result = PlayoutBridgeProtocol.ParseWorkflowCommand(document.RootElement);
Assert.True(result.IsValid);
Assert.Null(result.Request.Playlist);
Assert.Equal(-1, result.Request.SelectedIndexZeroBased);
}
[Theory]
[InlineData("{\"requestId\":\"REQ\",\"command\":\"next\",\"playlist\":[],\"selectedIndex\":0}")]
[InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[],\"selectedIndex\":0}")]
[InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"same\",\"code\":\"5001\",\"enabled\":true},{\"id\":\"same\",\"code\":\"5006\",\"enabled\":true}],\"selectedIndex\":0}")]
[InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"fadeDuration\":61}],\"selectedIndex\":0}")]
[InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"selection\":{\"groupCode\":\"코스피\",\"subject\":\"bad\\nvalue\",\"graphicType\":\"\",\"subtype\":\"\",\"dataCode\":\"005930\"}}],\"selectedIndex\":0}")]
[InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"selection\":{\"groupCode\":\"코스피\",\"subject\":\"삼성전자\",\"graphicType\":\"\",\"subtype\":\"\",\"dataCode\":\"005930\",\"objectName\":\"title\"}}],\"selectedIndex\":0}")]
public void ParseWorkflowCommand_RejectsCueOverrideDuplicateOrUnsafeShapes(string json)
{
using var document = JsonDocument.Parse(json);
var result = PlayoutBridgeProtocol.ParseWorkflowCommand(document.RootElement);
Assert.False(result.IsValid);
Assert.Null(result.Request.Playlist);
}
[Theory]
[InlineData("prepare")]
[InlineData("take-in")]
[InlineData("next")]
[InlineData("take-out")]
public void ParseTimeoutQuarantine_AcceptsOnlyKnownTimedOutCommands(string command)
{
using var document = JsonDocument.Parse(
$"{{\"requestId\":\"REQ_1\",\"command\":\"{command}\"}}");
var result = PlayoutBridgeProtocol.ParseTimeoutQuarantine(document.RootElement);
Assert.True(result.IsValid);
Assert.Equal("REQ_1", result.Request.RequestId);
Assert.Equal(command, result.Request.Command);
Assert.Empty(result.Error);
}
[Theory]
[InlineData("{}")]
[InlineData("{\"requestId\":\"REQ\",\"command\":\"unknown\"}")]
[InlineData("{\"requestId\":\"../REQ\",\"command\":\"next\"}")]
[InlineData("{\"requestId\":\"REQ\",\"command\":\"next\",\"retry\":true}")]
[InlineData("{\"requestId\":\"REQ\",\"requestId\":\"REQ2\",\"command\":\"next\"}")]
public void ParseTimeoutQuarantine_RejectsUnknownDuplicateOrUnsafeInput(string json)
{
using var document = JsonDocument.Parse(json);
var result = PlayoutBridgeProtocol.ParseTimeoutQuarantine(document.RootElement);
Assert.False(result.IsValid);
Assert.NotEmpty(result.Error);
}
}

View File

@@ -0,0 +1,552 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ReadOnlyPanelSceneBuildersTests
{
[Fact]
public void Builder_keys_are_the_closed_scene_codes()
{
Assert.Equal("s5006", new S5006SceneMutationBuilder().BuilderKey);
Assert.Equal("s5011", new S5011SceneMutationBuilder().BuilderKey);
Assert.Equal("s5016", new S5016SceneMutationBuilder().BuilderKey);
Assert.Equal("s50160", new S50160SceneMutationBuilder().BuilderKey);
Assert.Equal("s6001", new S6001SceneMutationBuilder().BuilderKey);
}
[Fact]
public void S5006_matches_original_background_value_and_mainform_direction_order()
{
var actual = new S5006SceneMutationBuilder().Build(new S5006SceneData(
LegacyDomesticEquityMarket.NxtKospi,
"삼성전자(NXT)",
ScenePriceDirection.Down,
72_100,
-900,
-1.23m,
72_800,
-0.27m,
73_500,
0.69m,
71_900,
-1.51m)).Select(Describe);
Assert.Equal(
[
"V:title=삼성전자(NXT)",
@"B:Video\큐브배경.vrv:1:1",
"V:price=72,100",
"V:changePrice3_=-900",
"V:rate3_=-1.23",
"V:price1=72,800",
"V:ratio1=-0.27",
"V:price2=73,500",
"V:ratio2=0.69",
"V:price3=71,900",
"V:ratio3=-1.51",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:down=1",
"S:bg1_=0", "S:changePrice1_=0", "S:wonText1_=0", "S:rate1_=0", "S:percent1_=0",
"S:bg2_=0", "S:changePrice2_=0", "S:wonText2_=0", "S:rate2_=0", "S:percent2_=0",
"S:bg3_=1", "S:changePrice3_=1", "S:wonText3_=1", "S:rate3_=1", "S:percent3_=1",
@"A:pattern=Images\그림_파랑.png"
], actual);
}
[Fact]
public void S5006_replaces_absolute_legacy_background_with_relative_asset_only()
{
var mutations = new S5006SceneMutationBuilder().Build(ValidS5006());
var assets = mutations.SelectMany(mutation => mutation switch
{
PlayoutSetAssetValue asset => [asset.AssetPath],
PlayoutSetBackgroundVideo video => [video.AssetPath],
_ => Array.Empty<string>()
}).ToArray();
Assert.NotEmpty(assets);
Assert.All(assets, asset =>
{
Assert.False(Path.IsPathRooted(asset));
Assert.DoesNotContain(":", asset, StringComparison.Ordinal);
Assert.DoesNotContain("MBN_STOCK_N", asset, StringComparison.OrdinalIgnoreCase);
});
}
[Fact]
public void S5011_face_value_branch_matches_original_order_and_nxt_marker()
{
var actual = new S5011SceneMutationBuilder().Build(new S5011SceneData(
LegacyDomesticEquityMarket.NxtKosdaq,
"테스트(NXT)",
ScenePriceDirection.Up,
1_250,
25,
2.04m,
new S5011FaceValueDetail(
500,
1_234_567_890,
9_876_543_210,
3))).Select(Describe);
Assert.Equal(
[
"V:title=테스트",
"V:r_unit1=원", "V:r_unit2=억원", "V:r_unit3=억원", "V:r_unit4=위",
"V:r_title1=액면가", "V:r_title2=자본금", "V:r_title3=시가총액", "V:r_title4=시총순위",
"V:r_value1=500", "V:r_value2=12", "V:r_value3=98", "V:r_value4=3",
"V:price=1,250", "V:changePrice1_=25", "V:rate1_=2.04",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:up=1",
"S:bg1_=1", "S:changePrice1_=1", "S:wonText1_=1", "S:rate1_=1", "S:percent1_=1",
"S:bg2_=0", "S:changePrice2_=0", "S:wonText2_=0", "S:rate2_=0", "S:percent2_=0",
"S:bg3_=0", "S:changePrice3_=0", "S:wonText3_=0", "S:rate3_=0", "S:percent3_=0",
@"A:pattern=Images\그림_빨강.png",
"S:mart=1"
], actual);
}
[Fact]
public void S5011_valuation_branch_preserves_decimal_scale_and_label_order()
{
var actual = new S5011SceneMutationBuilder().Build(new S5011SceneData(
LegacyDomesticEquityMarket.Kospi,
"테스트",
ScenePriceDirection.Flat,
1_000,
0,
0,
new S5011ValuationDetail(1.20m, 8.75m, 12_345, -321)));
Assert.Equal(
[
"V:title=테스트",
"V:r_unit1=배", "V:r_unit2=배", "V:r_unit3=원", "V:r_unit4=원",
"V:r_title1=PBR", "V:r_title2=PER", "V:r_title3=BPS", "V:r_title4=EPS",
"V:r_value1=1.20", "V:r_value2=8.75", "V:r_value3=12,345", "V:r_value4=-321"
], actual.Take(13).Select(Describe));
Assert.Equal("V:changePrice2_=0", Describe(actual[14]));
Assert.Equal("S:mart=0", Describe(actual[^1]));
Assert.Equal(44, actual.Count);
}
[Fact]
public void S5011_volume_branch_preserves_integer_division_and_labels()
{
var actual = new S5011SceneMutationBuilder().Build(new S5011SceneData(
LegacyDomesticEquityMarket.Kosdaq,
"테스트",
ScenePriceDirection.Down,
900,
-100,
-10,
new S5011VolumeDetail(12_345, 9_876_543, 1_111, 2_222)));
Assert.Equal(
[
"V:title=테스트",
"V:r_unit1=주", "V:r_unit2=백만", "V:r_unit3=원", "V:r_unit4=원",
"V:r_title1=거래량", "V:r_title2=거래대금", "V:r_title3=5MA", "V:r_title4=20MA",
"V:r_value1=12,345", "V:r_value2=9", "V:r_value3=1,111", "V:r_value4=2,222"
], actual.Take(13).Select(Describe));
Assert.Equal("V:changePrice3_=-100", Describe(actual[14]));
Assert.Equal(@"A:pattern=Images\그림_파랑.png", Describe(actual[^2]));
}
[Fact]
public void S5016_matches_original_three_row_visibility_and_value_order()
{
var actual = new S5016SceneMutationBuilder().Build(new S5016SceneData(
S5016PanelTarget.OverseasExchangeRates,
[
new("KOSPI A", null, 1_234.5m, 1.23456m, 1.2m, LegacyPanelQuoteSignal.Positive),
new("B", null, 2_000m, 0m, 0m, LegacyPanelQuoteSignal.Negative),
new("C", null, 3_000m, -2.5m, 3.4m, LegacyPanelQuoteSignal.Negative)
])).Select(Describe);
Assert.Equal(
[
"V:title=해외 환율",
"S:flat1=0", "S:up1=0", "S:upup1=0", "S:downdown1=0", "S:down1=0",
"V:title1=코스피 A", "V:price1=1,234.50",
"S:bg1_1=1", "S:bg1_2=0", "S:bg1_3=0",
"S:rate1_1=1", "S:rate1_2=0", "S:rate1_3=0",
"S:changePrice1_1=1", "S:changePrice1_2=0", "S:changePrice1_3=0",
"S:percent1_1=1", "S:percent1_2=0", "S:percent1_3=0",
"S:unit1_1=1", "S:unit1_2=0", "S:unit1_3=0",
"S:up1=1", "V:changePrice1_1=1.2346", "V:rate1_1=1.20", "V:unitText=단위 : $",
"S:flat2=0", "S:up2=0", "S:upup2=0", "S:downdown2=0", "S:down2=0",
"V:title2=B", "V:price2=2,000.00",
"S:bg2_1=0", "S:bg2_2=1", "S:bg2_3=0",
"S:rate2_1=0", "S:rate2_2=1", "S:rate2_3=0",
"S:changePrice2_1=0", "S:changePrice2_2=1", "S:changePrice2_3=0",
"S:percent2_1=0", "S:percent2_2=1", "S:percent2_3=0",
"S:unit2_1=0", "S:unit2_2=1", "S:unit2_3=0",
"S:flat2=1", "V:changePrice2_2=0.0000", "V:rate2_2=0.00", "V:unitText=단위 : $",
"S:flat3=0", "S:up3=0", "S:upup3=0", "S:downdown3=0", "S:down3=0",
"V:title3=C", "V:price3=3,000.00",
"S:bg3_1=0", "S:bg3_2=0", "S:bg3_3=1",
"S:rate3_1=0", "S:rate3_2=0", "S:rate3_3=1",
"S:changePrice3_1=0", "S:changePrice3_2=0", "S:changePrice3_3=1",
"S:percent3_1=0", "S:percent3_2=0", "S:percent3_3=1",
"S:unit3_1=0", "S:unit3_2=0", "S:unit3_3=1",
"S:down3=1", "V:changePrice3_3=2.5000", "V:rate3_3=-3.40", "V:unitText=단위 : $"
], actual);
}
[Theory]
[MemberData(nameof(S5016TargetCases))]
public void S5016_target_mapping_is_closed_and_repeats_original_unit_order(
S5016PanelTarget target,
string title,
string unit)
{
var quotes = Enumerable.Range(1, 3)
.Select(index => new LegacyPanelQuoteData(
"Name" + index,
"Part" + index,
index,
0,
0,
LegacyPanelQuoteSignal.Flat))
.ToArray();
var actual = new S5016SceneMutationBuilder().Build(new S5016SceneData(target, quotes));
Assert.Equal("V:title=" + title, Describe(actual[0]));
Assert.Equal("V:unitText=" + unit, Describe(actual[26]));
Assert.Equal("V:unitText=" + unit, Describe(actual[52]));
Assert.Equal("V:unitText=" + unit, Describe(actual[78]));
}
[Fact]
public void S5016_bond_branch_uses_part_name_not_generic_name()
{
var quotes = Enumerable.Range(1, 3)
.Select(index => new LegacyPanelQuoteData(
"generic" + index,
"bond" + index,
index,
0,
0,
LegacyPanelQuoteSignal.Flat))
.ToArray();
var actual = new S5016SceneMutationBuilder().Build(new S5016SceneData(
S5016PanelTarget.BondYields,
quotes));
Assert.Equal("V:title1=bond1", Describe(actual[6]));
Assert.Equal("V:title2=bond2", Describe(actual[32]));
Assert.Equal("V:title3=bond3", Describe(actual[58]));
}
[Fact]
public void S50160_matches_original_two_row_unit_clear_and_visibility_order()
{
var actual = new S50160SceneMutationBuilder().Build(new S50160SceneData(
S50160PanelTarget.Cotton,
[
new("Cotton", null, 100m, 2m, 1.5m, LegacyPanelQuoteSignal.Positive),
new("KOSQ Cotton", null, 90m, -1m, 2m, LegacyPanelQuoteSignal.Negative)
])).Select(Describe);
Assert.Equal(
[
"V:title=농산물",
"S:flat1=0", "S:up1=0", "S:upup1=0", "S:downdown1=0", "S:down1=0",
"V:title1=Cotton", "V:price1=100.00",
"S:bg1_1=1", "S:bg1_2=0", "S:bg1_3=0",
"S:rate1_1=1", "S:rate1_2=0", "S:rate1_3=0",
"S:changePrice1_1=1", "S:changePrice1_2=0", "S:changePrice1_3=0",
"S:percent1_1=1", "S:percent1_2=0", "S:percent1_3=0",
"S:unit1_1=1", "S:unit1_2=0", "S:unit1_3=0",
"S:up1=1", "V:changePrice1_1=2.00", "V:rate1_1=1.50",
"V:unit1_1=", "V:unit1_2=", "V:unit1_3=", "V:unitText=단위 : $",
"S:flat2=0", "S:up2=0", "S:upup2=0", "S:downdown2=0", "S:down2=0",
"V:title2=코스닥 Cotton", "V:price2=90.00",
"S:bg2_1=0", "S:bg2_2=0", "S:bg2_3=1",
"S:rate2_1=0", "S:rate2_2=0", "S:rate2_3=1",
"S:changePrice2_1=0", "S:changePrice2_2=0", "S:changePrice2_3=1",
"S:percent2_1=0", "S:percent2_2=0", "S:percent2_3=1",
"S:unit2_1=0", "S:unit2_2=0", "S:unit2_3=1",
"S:down2=1", "V:changePrice2_3=1.00", "V:rate2_3=-2.00",
"V:unit2_1=", "V:unit2_2=", "V:unit2_3=", "V:unitText=단위 : $"
], actual);
}
[Theory]
[InlineData(S50160PanelTarget.Cotton, "농산물", "단위 : $")]
[InlineData(S50160PanelTarget.InternationalPreciousMetals, "원자재", "단위 : $")]
[InlineData(S50160PanelTarget.DomesticPreciousMetals, "원자재", "단위 : 원")]
public void S50160_target_mapping_is_closed(
S50160PanelTarget target,
string title,
string unit)
{
var rows = new[] { FlatQuote("A"), FlatQuote("B") };
var actual = new S50160SceneMutationBuilder().Build(new S50160SceneData(target, rows));
Assert.Equal("V:title=" + title, Describe(actual[0]));
Assert.Equal("V:unitText=" + unit, Describe(actual[29]));
Assert.Equal("V:unitText=" + unit, Describe(actual[58]));
}
[Fact]
public void S6001_index_branch_matches_original_asset_value_and_color_order()
{
var actual = new S6001SceneMutationBuilder().Build(new S6001SceneData(
S6001QuoteTarget.Nasdaq,
"나스닥",
18_000.123m,
-15.5m,
1.23m,
ScenePriceDirection.Down)).Select(Describe);
Assert.Equal(
[
"V:title=나스닥",
@"A:movie=Video\20201008_미국.vrv",
"V:price=18,000.12",
"V:changePrice=15.50",
"V:rate=-1.23",
"V:wonText=",
"V:unit=단위 : p",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:down=1",
"C:bg:15:99:189:255", "C:changePrice:15:99:189:255",
"C:wonText:15:99:189:255", "C:rate:15:99:189:255", "C:percent:15:99:189:255",
@"A:pattern=Images\그림_파랑.png"
], actual);
}
[Fact]
public void S6001_gold_branch_matches_original_asset_first_and_flat_colors()
{
var actual = new S6001SceneMutationBuilder().Build(new S6001SceneData(
S6001QuoteTarget.Gold,
"국제 금",
2_300m,
-1.1m,
-0.05m,
ScenePriceDirection.Flat)).Select(Describe);
Assert.Equal(
[
@"A:movie=images\35752913_l.jpg",
"V:unit=단위 : 달러/온스",
"V:title=국제 금",
"V:price=2,300.00",
"V:changePrice=-1.10",
"V:rate=-0.05",
"V:wonText=",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:flat=1",
"C:bg:95:95:95:255", "C:changePrice:52:52:52:255",
"C:wonText:52:52:52:255", "C:rate:52:52:52:255", "C:percent:52:52:52:255",
@"A:pattern=Images\그림_검정.png"
], actual);
}
[Theory]
[MemberData(nameof(S6001TargetCases))]
public void S6001_target_mapping_is_closed_and_never_accepts_raw_asset_paths(
S6001QuoteTarget target,
string asset,
string unit,
bool isIndex)
{
var actual = new S6001SceneMutationBuilder().Build(new S6001SceneData(
target,
"Title",
1,
0,
0,
ScenePriceDirection.Flat));
var assetMutation = Assert.IsType<PlayoutSetAssetValue>(actual[isIndex ? 1 : 0]);
Assert.Equal(asset, assetMutation.AssetPath);
Assert.Equal("V:unit=" + unit, Describe(actual[isIndex ? 6 : 1]));
Assert.False(Path.IsPathRooted(assetMutation.AssetPath));
}
[Fact]
public void Builders_fail_closed_for_unknown_or_missing_database_values()
{
Assert.Throws<LegacySceneDataException>(() =>
new S5006SceneMutationBuilder().Build(ValidS5006() with { StockName = "" }));
Assert.Throws<LegacySceneDataException>(() =>
new S5006SceneMutationBuilder().Build(ValidS5006() with
{
Market = (LegacyDomesticEquityMarket)999
}));
Assert.Throws<LegacySceneDataException>(() =>
new S5011SceneMutationBuilder().Build(new S5011SceneData(
LegacyDomesticEquityMarket.Kospi,
"Name",
ScenePriceDirection.Up,
1,
1,
1,
null!)));
Assert.Throws<LegacySceneDataException>(() =>
new S5016SceneMutationBuilder().Build(new S5016SceneData(
S5016PanelTarget.Oil,
[FlatQuote("A"), FlatQuote("B")])));
Assert.Throws<LegacySceneDataException>(() =>
new S5016SceneMutationBuilder().Build(new S5016SceneData(
S5016PanelTarget.Oil,
[
FlatQuote("A"),
FlatQuote("B"),
FlatQuote("")
])));
Assert.Throws<LegacySceneDataException>(() =>
new S5016SceneMutationBuilder().Build(new S5016SceneData(
(S5016PanelTarget)999,
[FlatQuote("A"), FlatQuote("B"), FlatQuote("C")])));
Assert.Throws<LegacySceneDataException>(() =>
new S5016SceneMutationBuilder().Build(new S5016SceneData(
S5016PanelTarget.Oil,
[
FlatQuote("A"),
FlatQuote("B"),
FlatQuote("C") with { Signal = (LegacyPanelQuoteSignal)999 }
])));
Assert.Throws<LegacySceneDataException>(() =>
new S50160SceneMutationBuilder().Build(new S50160SceneData(
(S50160PanelTarget)999,
[FlatQuote("A"), FlatQuote("B")])));
Assert.Throws<LegacySceneDataException>(() =>
new S6001SceneMutationBuilder().Build(new S6001SceneData(
S6001QuoteTarget.Gold,
"",
1,
0,
0,
ScenePriceDirection.Flat)));
Assert.Throws<LegacySceneDataException>(() =>
new S6001SceneMutationBuilder().Build(new S6001SceneData(
S6001QuoteTarget.Gold,
"Gold",
1,
0,
0,
ScenePriceDirection.Unknown)));
Assert.Throws<LegacySceneDataException>(() =>
new S6001SceneMutationBuilder().Build(new S6001SceneData(
(S6001QuoteTarget)999,
"Gold",
1,
0,
0,
ScenePriceDirection.Flat)));
}
[Fact]
public void Scene_dtos_expose_no_raw_path_object_or_method_surface()
{
Type[] dtoTypes =
[
typeof(S5006SceneData),
typeof(S5011SceneData),
typeof(S5011FaceValueDetail),
typeof(S5011ValuationDetail),
typeof(S5011VolumeDetail),
typeof(S5016SceneData),
typeof(S50160SceneData),
typeof(LegacyPanelQuoteData),
typeof(S6001SceneData)
];
foreach (var dtoType in dtoTypes)
{
Assert.DoesNotContain(dtoType.GetProperties(), property =>
property.Name.Contains("Path", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("Object", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("Method", StringComparison.OrdinalIgnoreCase) ||
typeof(PlayoutMutation).IsAssignableFrom(property.PropertyType));
}
}
public static IEnumerable<object[]> S5016TargetCases()
{
yield return [S5016PanelTarget.UnitedStatesIndices, "미국 지수", "단위 : p"];
yield return [S5016PanelTarget.GreaterChinaIndices, "중화권 지수", "단위 : p"];
yield return [S5016PanelTarget.EuropeanIndices, "유럽 지수", "단위 : p"];
yield return [S5016PanelTarget.AsianIndices, "아시아 지수", "단위 : p"];
yield return [S5016PanelTarget.UnitedStatesTreasuries, "미국 국채", "단위 : %"];
yield return [S5016PanelTarget.BondYields, "채권 금리", "단위 : %"];
yield return [S5016PanelTarget.Oil, "유가", "단위 : 배럴/달러"];
yield return [S5016PanelTarget.Minerals, "광물", "단위 : $"];
yield return [S5016PanelTarget.FoodMaterials, "식자재", "단위 : ¢"];
yield return [S5016PanelTarget.OverseasExchangeRates, "해외 환율", "단위 : $"];
yield return [S5016PanelTarget.DomesticExchangeRates, "환율", "단위 : 원"];
yield return [S5016PanelTarget.AgricultureWheatCorn, "농산물", "단위 : $"];
yield return [S5016PanelTarget.AgricultureSoyRice, "농산물", "단위 : $"];
yield return [S5016PanelTarget.AgricultureCoffeeCocoaSugar, "농산물", "단위 : $"];
yield return [S5016PanelTarget.AgricultureLivestock, "농산물", "단위 : $"];
yield return [S5016PanelTarget.RawMaterialsCopperIronNickel, "원자재", "단위 : $"];
yield return [S5016PanelTarget.RawMaterialsGasPlatinumPalladium, "원자재", "단위 : $"];
yield return [S5016PanelTarget.RawMaterialsLeadZincTin, "원자재", "단위 : $"];
yield return [S5016PanelTarget.MajorKospiKosdaqKospi200, "주요지수", "단위 : p"];
yield return [S5016PanelTarget.MajorKospiKosdaqFutures, "주요지수", "단위 : p"];
yield return [S5016PanelTarget.ExpectedKospiKosdaqKospi200, "예상체결지수", "단위 : p"];
yield return [S5016PanelTarget.ExpectedKospiKosdaqFutures, "예상체결지수", "단위 : p"];
}
public static IEnumerable<object[]> S6001TargetCases()
{
yield return [S6001QuoteTarget.DowJones, @"Video\20201008_미국.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.Nasdaq, @"Video\20201008_미국.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.StandardAndPoor500, @"Video\20201008_미국.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.Germany, @"Video\20201008_독일.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.UnitedKingdom, @"Video\20201008_영국.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.France, @"Video\20201008_프랑스.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.Japan, @"Video\20201008_일본.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.China, @"Video\20201008_중국.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.HongKong, @"Video\20201008_홍콩.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.Taiwan, @"Video\20201008_대만.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.Singapore, @"Video\20201008_싱가포르.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.Thailand, @"Video\20201008_태국.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.Philippines, @"Video\20201008_필리핀.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.Malaysia, @"Video\20201008_말레이시아.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.Indonesia, @"Video\20201008_인도네시아.vrv", "단위 : p", true];
yield return [S6001QuoteTarget.DubaiCrude, @"images\주유기merge.png", "단위 : 배럴/달러", false];
yield return [S6001QuoteTarget.BrentCrude, @"images\주유기merge.png", "단위 : 배럴/달러", false];
yield return [S6001QuoteTarget.WtiCrude, @"images\주유기merge.png", "단위 : 배럴/달러", false];
yield return [S6001QuoteTarget.Gold, @"images\35752913_l.jpg", "단위 : 달러/온스", false];
}
private static S5006SceneData ValidS5006() => new(
LegacyDomesticEquityMarket.Kospi,
"Name",
ScenePriceDirection.Flat,
1,
0,
0,
1,
0,
1,
0,
1,
0);
private static LegacyPanelQuoteData FlatQuote(string name) =>
new(name, name, 1, 0, 0, LegacyPanelQuoteSignal.Flat);
private static string Describe(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
PlayoutSetAssetValue asset => $"A:{asset.ObjectName}={asset.AssetPath}",
PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
PlayoutSetFaceColor color =>
$"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}",
PlayoutSetBackgroundVideo video =>
$"B:{video.AssetPath}:{video.LoopCount}:{(video.LoopInfinite ? 1 : 0)}",
_ => mutation.GetType().Name
};
}

View File

@@ -0,0 +1,153 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class RegisteredLegacySceneCueProviderTests
{
[Fact]
public async Task CreatePage_DefaultFadeMatchesLegacyMainFormSelectedIndexSix()
{
var provider = new RegisteredLegacySceneCueProvider(
new StubDataSource(new LegacySceneDataPage(
"s5085",
new S5085SceneData(
[
new ProgramTradingMetricData(ProgramTradingMetric.NetBuy, 0d)
]),
1)));
var page = await provider.CreatePageAsync(new("entry", "5085"), 0);
Assert.Equal(6, page.Cue.FadeDuration);
}
[Fact]
public async Task CreatePage_ComposesLegacyBackgroundThenTypedBuilderMutations()
{
var data = new S5085SceneData(
[
new ProgramTradingMetricData(ProgramTradingMetric.NetBuy, 12.5)
]);
var provider = new RegisteredLegacySceneCueProvider(
new StubDataSource(new LegacySceneDataPage("s5085", data, 1)),
options: new LegacySceneCueCompositionOptions(
3,
LegacySceneBackgroundKind.Texture,
"Video\\studio.vrv"));
var page = await provider.CreatePageAsync(new("entry", "5085", FadeDuration: 7), 0);
Assert.Equal("5085.t2s", page.Cue.SceneFile);
Assert.Equal("5085", page.Cue.SceneName);
Assert.Equal(7, page.Cue.FadeDuration);
Assert.Null(page.PageSize);
Assert.Equal(1, page.ItemCount);
Assert.Collection(
page.Cue.Mutations!.Take(2),
mutation => Assert.Equal(
new PlayoutSetBackgroundTexture("Video\\studio.vrv"),
mutation),
mutation => Assert.Equal(new PlayoutUseBackground(true), mutation));
Assert.Equal(
new S5085SceneMutationBuilder().Build(data),
page.Cue.Mutations!.Skip(2));
}
[Fact]
public async Task CreatePage_UsesCatalogPageSizeAndKeepsTheFullItemCount()
{
var data = new PagedQuoteSceneData(
PagedQuoteBranch.Domestic,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"상승률",
Enumerable.Range(1, 101)
.Select(index => new PagedQuoteRowData(
"종목" + index,
index,
index,
index,
ScenePriceDirection.Up))
.ToArray(),
PageNumberOneBased: 20);
var provider = new RegisteredLegacySceneCueProvider(
new StubDataSource(new LegacySceneDataPage("s5074", data, 101)));
var page = await provider.CreatePageAsync(new("entry", "5074"), 19);
Assert.Equal(ScenePageSize.Five, page.PageSize);
Assert.Equal(101, page.ItemCount);
Assert.Equal(new PlayoutUseBackground(false), page.Cue.Mutations![0]);
Assert.Contains(page.Cue.Mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "title1", Value: "종목96" });
}
[Theory]
[InlineData("5085", "s8086")]
[InlineData("5085", "s5001")]
[InlineData("5001", "s8018")]
public async Task CreatePage_RejectsUnreachableOrMismatchedBuilderAliases(
string cutCode,
string builderKey)
{
var data = new S5085SceneData([]);
var provider = new RegisteredLegacySceneCueProvider(
new StubDataSource(new LegacySceneDataPage(builderKey, data, 1)));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
provider.CreatePageAsync(new("entry", cutCode), 0));
}
[Theory]
[InlineData("../outside.vrv")]
[InlineData("C:\\outside.vrv")]
[InlineData("")]
public void Constructor_RejectsUnsafeBackgroundConfiguration(string path)
{
Assert.Throws<ArgumentException>(() => new RegisteredLegacySceneCueProvider(
new StubDataSource(new LegacySceneDataPage("s5085", new S5085SceneData([]), 1)),
options: new LegacySceneCueCompositionOptions(
0,
LegacySceneBackgroundKind.Video,
path)));
}
[Fact]
public async Task CreatePage_RejectsPage21BeforeBuildingOrDispatching()
{
var data = new PagedQuoteSceneData(
PagedQuoteBranch.Domestic,
PagedQuoteHeaderStyle.StandardWon,
PagedQuoteTitleStyle.Exact,
NxtMarketSession.NotApplicable,
"상승률",
Enumerable.Range(1, 101)
.Select(index => new PagedQuoteRowData(
"종목" + index,
index,
index,
index,
ScenePriceDirection.Up))
.ToArray(),
PageNumberOneBased: 21);
var provider = new RegisteredLegacySceneCueProvider(
new StubDataSource(new LegacySceneDataPage("s5074", data, 101)));
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
provider.CreatePageAsync(new("entry", "5074"), 20));
}
private sealed class StubDataSource(LegacySceneDataPage page) : ILegacySceneDataSource
{
public Task<LegacySceneDataPage> LoadPageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(page);
}
}
}

View File

@@ -0,0 +1,335 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class ScenePagingTests
{
public static TheoryData<ScenePageSize, int> SupportedPageSizes => new()
{
{ ScenePageSize.Five, 5 },
{ ScenePageSize.Six, 6 },
{ ScenePageSize.Twelve, 12 }
};
[Fact]
public void ScenePageSize_DefinesExactlyTheLegacyPageSizes()
{
var values = Enum.GetValues<ScenePageSize>()
.Select(value => (int)value)
.OrderBy(value => value)
.ToArray();
Assert.Equal([5, 6, 12], values);
Assert.Equal(20, ScenePaging.MaximumPageCount);
}
[Theory]
[MemberData(nameof(SupportedPageSizes))]
public void CreatePlan_AndWindows_CoverAllBoundaryCounts(
ScenePageSize pageSize,
int size)
{
var itemCounts = new[]
{
0,
1,
size - 1,
size,
size + 1,
(20 * size) - 1,
20 * size,
(20 * size) + 1,
21 * size,
int.MaxValue
};
foreach (var itemCount in itemCounts)
{
var result = ScenePaging.CreatePlan(itemCount, pageSize);
Assert.True(result.IsValid);
Assert.Equal(ScenePagingResultKind.Valid, result.Kind);
Assert.NotNull(result.Plan);
Assert.Empty(result.Error);
var plan = result.Plan;
var expectedUncappedPages = itemCount == 0
? 0
: ((itemCount - 1) / size) + 1;
var expectedTotalPages = Math.Min(20, expectedUncappedPages);
var expectedAccessibleItems = Math.Min(itemCount, 20 * size);
Assert.Equal(itemCount, plan.ItemCount);
Assert.Equal(pageSize, plan.PageSize);
Assert.Equal(expectedTotalPages, plan.TotalPages);
Assert.Equal(expectedAccessibleItems, plan.AccessibleItemCount);
Assert.Equal(itemCount - expectedAccessibleItems, plan.ExcludedItemCount);
for (var page = 1; page <= plan.TotalPages; page++)
{
var pageResult = ScenePaging.GetWindow(itemCount, pageSize, page);
Assert.True(pageResult.IsValid);
Assert.Equal(ScenePagingResultKind.Valid, pageResult.Kind);
Assert.NotNull(pageResult.Plan);
Assert.NotNull(pageResult.Window);
Assert.Empty(pageResult.Error);
var window = pageResult.Window;
var expectedOffset = (page - 1) * size;
var expectedCount = Math.Min(
size,
expectedAccessibleItems - expectedOffset);
var expectedRemaining =
expectedAccessibleItems - expectedOffset - expectedCount;
Assert.Equal(page, window.PageNumberOneBased);
Assert.Equal(expectedTotalPages, window.TotalPages);
Assert.Equal(expectedOffset, window.OffsetZeroBased);
Assert.Equal(expectedCount, window.ItemCount);
Assert.Equal(expectedRemaining, window.RemainingItemCount);
Assert.InRange(window.ItemCount, 1, size);
Assert.InRange(
window.OffsetZeroBased + window.ItemCount,
1,
expectedAccessibleItems);
Assert.InRange(
window.RemainingItemCount,
0,
expectedAccessibleItems);
}
if (plan.TotalPages == 0)
{
var pageResult = ScenePaging.GetWindow(itemCount, pageSize, 1);
Assert.False(pageResult.IsValid);
Assert.Equal(ScenePagingResultKind.Invalid, pageResult.Kind);
Assert.NotNull(pageResult.Plan);
Assert.Null(pageResult.Window);
Assert.NotEmpty(pageResult.Error);
}
}
}
[Theory]
[MemberData(nameof(SupportedPageSizes))]
public void GetWindow_PartialLastPage_UsesOneBasedPageAndZeroBasedOffset(
ScenePageSize pageSize,
int size)
{
var result = ScenePaging.GetWindow(size + 1, pageSize, 2);
Assert.True(result.IsValid);
Assert.NotNull(result.Window);
Assert.Equal(2, result.Window.PageNumberOneBased);
Assert.Equal(size, result.Window.OffsetZeroBased);
Assert.Equal(1, result.Window.ItemCount);
Assert.Equal(0, result.Window.RemainingItemCount);
}
[Theory]
[InlineData(-1, ScenePageSize.Five)]
[InlineData(0, (ScenePageSize)0)]
[InlineData(1, (ScenePageSize)7)]
[InlineData(1, (ScenePageSize)13)]
public void CreatePlan_RejectsNegativeCountsAndUnsupportedSizes(
int itemCount,
ScenePageSize pageSize)
{
var result = ScenePaging.CreatePlan(itemCount, pageSize);
Assert.False(result.IsValid);
Assert.Equal(ScenePagingResultKind.Invalid, result.Kind);
Assert.Null(result.Plan);
Assert.NotEmpty(result.Error);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(3)]
public void GetWindow_RejectsPagesOutsideTheOneBasedRange(int pageNumberOneBased)
{
var result = ScenePaging.GetWindow(6, ScenePageSize.Five, pageNumberOneBased);
Assert.False(result.IsValid);
Assert.Equal(ScenePagingResultKind.Invalid, result.Kind);
Assert.NotNull(result.Plan);
Assert.Null(result.Window);
Assert.NotEmpty(result.Error);
}
[Theory]
[MemberData(nameof(SupportedPageSizes))]
public void DecideNext_WhenCurrentPageIsBeforeTotal_ReturnsSameCueNextPage(
ScenePageSize pageSize,
int size)
{
ScenePlaylistCue[] playlist =
[
new("before", true),
new("current", true, pageSize, size + 1),
new("after", true)
];
var state = new SceneNextState(playlist, 1, 1);
var result = ScenePaging.DecideNext(state);
Assert.True(result.IsValid);
Assert.Equal(SceneNextDecisionKind.SameCueNextPage, result.Kind);
Assert.Equal(1, result.TargetCueIndexZeroBased);
Assert.Equal(2, result.TargetPageNumberOneBased);
Assert.NotNull(result.TargetPage);
Assert.Equal(size, result.TargetPage.OffsetZeroBased);
Assert.Equal(1, result.TargetPage.ItemCount);
Assert.Equal(0, result.TargetPage.RemainingItemCount);
Assert.Empty(result.Error);
}
[Fact]
public void DecideNext_OnLastPage_SelectsFirstEnabledCueAfterCurrent()
{
ScenePlaylistCue[] playlist =
[
new("enabled-before", true),
new("current", true, ScenePageSize.Five, 6),
new("disabled-after", false),
new("first-enabled-after", true, ScenePageSize.Six, 7),
new("later-enabled", true)
];
var state = new SceneNextState(playlist, 1, 2);
var result = ScenePaging.DecideNext(state);
Assert.True(result.IsValid);
Assert.Equal(SceneNextDecisionKind.NextEnabledCue, result.Kind);
Assert.Equal(3, result.TargetCueIndexZeroBased);
Assert.Equal(1, result.TargetPageNumberOneBased);
Assert.NotNull(result.TargetPage);
Assert.Equal(0, result.TargetPage.OffsetZeroBased);
Assert.Equal(6, result.TargetPage.ItemCount);
Assert.Equal(1, result.TargetPage.RemainingItemCount);
Assert.Empty(result.Error);
}
[Fact]
public void DecideNext_ForNonPagedCue_SelectsFirstEnabledCueAfterCurrent()
{
ScenePlaylistCue[] playlist =
[
new("current", true),
new("disabled", false),
new("next", true)
];
var result = ScenePaging.DecideNext(new SceneNextState(playlist, 0, null));
Assert.Equal(SceneNextDecisionKind.NextEnabledCue, result.Kind);
Assert.Equal(2, result.TargetCueIndexZeroBased);
Assert.Null(result.TargetPageNumberOneBased);
Assert.Null(result.TargetPage);
}
[Fact]
public void DecideNext_ForEmptyPagedCue_SelectsNextCueWithoutInventingPageZero()
{
ScenePlaylistCue[] playlist =
[
new("empty-current", true, ScenePageSize.Twelve, 0),
new("empty-next", true, ScenePageSize.Five, 0)
];
var result = ScenePaging.DecideNext(new SceneNextState(playlist, 0, null));
Assert.Equal(SceneNextDecisionKind.NextEnabledCue, result.Kind);
Assert.Equal(1, result.TargetCueIndexZeroBased);
Assert.Null(result.TargetPageNumberOneBased);
Assert.Null(result.TargetPage);
}
[Fact]
public void DecideNext_DoesNotWrapToAnEnabledCueBeforeCurrent()
{
ScenePlaylistCue[] playlist =
[
new("enabled-before", true),
new("current", true, ScenePageSize.Five, 1),
new("disabled-after", false)
];
var result = ScenePaging.DecideNext(new SceneNextState(playlist, 1, 1));
Assert.True(result.IsValid);
Assert.Equal(SceneNextDecisionKind.EndOfPlaylist, result.Kind);
Assert.Null(result.TargetCueIndexZeroBased);
Assert.Null(result.TargetPageNumberOneBased);
Assert.Null(result.TargetPage);
Assert.Empty(result.Error);
}
[Theory]
[InlineData("null-state")]
[InlineData("null-playlist")]
[InlineData("empty-playlist")]
[InlineData("negative-index")]
[InlineData("index-at-count")]
[InlineData("blank-code")]
[InlineData("non-paged-items")]
[InlineData("non-paged-current-page")]
[InlineData("negative-items")]
[InlineData("unsupported-page-size")]
[InlineData("missing-current-page")]
[InlineData("page-zero")]
[InlineData("page-after-total")]
[InlineData("empty-cue-with-page")]
[InlineData("invalid-future-cue")]
public void DecideNext_RejectsInvalidState(string scenario)
{
var validPagedCue = new ScenePlaylistCue(
"current",
true,
ScenePageSize.Five,
6);
SceneNextState? state = scenario switch
{
"null-state" => null,
"null-playlist" => new SceneNextState(null, 0, null),
"empty-playlist" => new SceneNextState([], 0, null),
"negative-index" => new SceneNextState([new("cue", true)], -1, null),
"index-at-count" => new SceneNextState([new("cue", true)], 1, null),
"blank-code" => new SceneNextState([new(" ", true)], 0, null),
"non-paged-items" => new SceneNextState([new("cue", true, null, 1)], 0, null),
"non-paged-current-page" => new SceneNextState([new("cue", true)], 0, 1),
"negative-items" => new SceneNextState(
[new("cue", true, ScenePageSize.Five, -1)],
0,
null),
"unsupported-page-size" => new SceneNextState(
[new("cue", true, (ScenePageSize)7, 1)],
0,
1),
"missing-current-page" => new SceneNextState([validPagedCue], 0, null),
"page-zero" => new SceneNextState([validPagedCue], 0, 0),
"page-after-total" => new SceneNextState([validPagedCue], 0, 3),
"empty-cue-with-page" => new SceneNextState(
[new("cue", true, ScenePageSize.Five, 0)],
0,
1),
"invalid-future-cue" => new SceneNextState(
[new("current", true), new("next", true, null, 1)],
0,
null),
_ => throw new InvalidOperationException($"Unknown scenario: {scenario}")
};
var result = ScenePaging.DecideNext(state);
Assert.False(result.IsValid);
Assert.Equal(SceneNextDecisionKind.Invalid, result.Kind);
Assert.Null(result.TargetCueIndexZeroBased);
Assert.Null(result.TargetPageNumberOneBased);
Assert.Null(result.TargetPage);
Assert.NotEmpty(result.Error);
}
}

View File

@@ -0,0 +1,282 @@
using System.Reflection;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class TabularMarketSceneBuildersTests
{
[Fact]
public void S5023_matches_daily_and_monthly_sign_order_and_clears_empty_rows()
{
var data = new S5023SceneData(
LegacyDomesticMarket.Kospi,
[
new DailyInvestorFlowData(
new DateOnly(2026, 7, 10),
new InvestorFlowAmounts(100, -200, 0)),
null,
null,
null,
null
],
new InvestorFlowAmounts(1000, -2000, 0));
var actual = new S5023SceneMutationBuilder().Build(data).Select(Describe);
Assert.Equal(
[
"V:title=\uCF54\uC2A4\uD53C \uB9E4\uB9E4\uB3D9\uD5A5",
"V:row1_value1=07/10",
"C:row1_value2:171:0:0:255", "V:row1_value2=100",
"C:row1_value3:15:99:188:255", "V:row1_value3=-200",
"C:row1_value4:47:47:47:255", "V:row1_value4=0",
"V:row2_value1=",
"C:row2_value2:47:47:47:255", "V:row2_value2=",
"C:row2_value3:47:47:47:255", "V:row2_value3=",
"C:row2_value4:47:47:47:255", "V:row2_value4=",
"V:row3_value1=",
"C:row3_value2:47:47:47:255", "V:row3_value2=",
"C:row3_value3:47:47:47:255", "V:row3_value3=",
"C:row3_value4:47:47:47:255", "V:row3_value4=",
"V:row4_value1=",
"C:row4_value2:47:47:47:255", "V:row4_value2=",
"C:row4_value3:47:47:47:255", "V:row4_value3=",
"C:row4_value4:47:47:47:255", "V:row4_value4=",
"V:row5_value1=",
"C:row5_value2:47:47:47:255", "V:row5_value2=",
"C:row5_value3:47:47:47:255", "V:row5_value3=",
"C:row5_value4:47:47:47:255", "V:row5_value4=",
"V:row6_value1=1,000", "C:row6_bg1:171:0:0:255",
"V:row6_value2=-2,000", "C:row6_bg2:15:99:189:255",
"V:row6_value3=0", "C:row6_bg3:95:95:95:255"
], actual);
}
[Fact]
public void S5024_matches_mixed_bar_center_scale_and_zero_order()
{
var data = new S5024SceneData(
LegacyDomesticMarket.Kosdaq,
[
new(InvestorParticipant.Individual, 100),
new(InvestorParticipant.Foreign, -50),
new(InvestorParticipant.Institution, 0)
]);
var actual = new S5024SceneMutationBuilder().Build(data).Select(Describe);
Assert.Equal(
[
"V:title=\uCF54\uC2A4\uB2E5 \uB9E4\uB9E4\uB3D9\uD5A5",
"S:barG1_1=0", "S:barG1_2=0",
"S:barG2_1=0", "S:barG2_2=0",
"S:barG3_1=0", "S:barG3_2=0",
"P:centerbar:0:-2:0:2",
"S:barG1_1=1", "Z:bar1_1:0:0.6666667:0:2", "V:value1_1=100",
"S:barG2_2=1", "Z:bar2_2:0:0.33333334:0:2", "V:value2_2=-50",
"S:barG3_1=1", "Z:bar3_1:0:0:0:2", "V:value3_1=0"
], actual);
}
[Fact]
public void S5024_empty_slots_remain_hidden_and_emit_no_invalid_scale()
{
var actual = new S5024SceneMutationBuilder().Build(new S5024SceneData(
LegacyDomesticMarket.Kospi,
[]));
Assert.Equal(8, actual.Count);
Assert.Equal("P:centerbar:0:-150:0:2", Describe(actual[^1]));
Assert.DoesNotContain(actual.Select(Describe), value =>
value.Contains("NaN", StringComparison.Ordinal));
}
[Fact]
public void S5025_maps_four_closed_pair_rows_and_clears_empty_slots()
{
var data = new S5025SceneData(
"\uAC1C\uC778",
[
new("\uC0BC\uC131\uC804\uC790", "1,000", "SK\uD558\uC774\uB2C9\uC2A4", "5,000"),
null,
new("A", "", "", ""),
null
]);
var actual = new S5025SceneMutationBuilder().Build(data).Select(Describe);
Assert.Equal(
[
"V:title=\uAC1C\uC778 \uC21C\uB9E4\uB3C4 \uC0C1\uC704",
"V:\uC885\uBAA91=\uC0BC\uC131\uC804\uC790", "V:\uAE08\uC5611=1,000",
"V:\uC885\uBAA92=SK\uD558\uC774\uB2C9\uC2A4", "V:\uAE08\uC5612=5,000",
"V:\uC885\uBAA93=", "V:\uAE08\uC5613=", "V:\uC885\uBAA94=", "V:\uAE08\uC5614=",
"V:\uC885\uBAA95=A", "V:\uAE08\uC5615=", "V:\uC885\uBAA96=", "V:\uAE08\uC5616=",
"V:\uC885\uBAA97=", "V:\uAE08\uC5617=", "V:\uC885\uBAA98=", "V:\uAE08\uC5618="
], actual);
}
[Fact]
public void S5037_matches_flat_quote_clear_then_buy_sell_fill_order()
{
var data = new S5037SceneData(
"A",
10_000,
0,
0,
ScenePriceDirection.Flat,
[new("BuyA", 123), new("BuyZero", 0)],
[new("SellNegative", -10)]);
var actual = new S5037SceneMutationBuilder().Build(data).Select(Describe);
Assert.Equal(
[
"V:title=A", "V:unit=\uB2E8\uC704 : \uC6D0", "V:price=10,000",
"V:changePrice=0", "V:rate=0.00",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:bg1=0", "S:bg2=1", "S:bg3=0",
"V:buy_title_1=", "V:buy_vol_1=", "V:sell_title_1=", "V:sell_vol_1=",
"V:buy_title_2=", "V:buy_vol_2=", "V:sell_title_2=", "V:sell_vol_2=",
"V:buy_title_3=", "V:buy_vol_3=", "V:sell_title_3=", "V:sell_vol_3=",
"V:buy_title_4=", "V:buy_vol_4=", "V:sell_title_4=", "V:sell_vol_4=",
"V:buy_title_5=", "V:buy_vol_5=", "V:sell_title_5=", "V:sell_vol_5=",
"V:buy_title_1=BuyA", "V:buy_vol_1=123",
"V:buy_title_2=BuyZero", "V:buy_vol_2=0",
"V:sell_title_1=SellNegative", "V:sell_vol_1=-10"
], actual);
}
[Theory]
[InlineData(ScenePriceDirection.Up, "S:up=1", "S:bg1=1")]
[InlineData(ScenePriceDirection.Down, "S:down=1", "S:bg3=1")]
[InlineData(ScenePriceDirection.Flat, null, "S:bg2=1")]
public void S5037_direction_variants_keep_original_visibility_targets(
ScenePriceDirection direction,
string? activeDirection,
string activeBackground)
{
var actual = new S5037SceneMutationBuilder().Build(new S5037SceneData(
"A", 1, 0, 0, direction, [], [])).Select(Describe).ToArray();
Assert.Equal(
["S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0"],
actual.Skip(5).Take(5));
if (activeDirection is null)
{
Assert.DoesNotContain(actual.Skip(10).Take(1), value => value.EndsWith("=1"));
}
else
{
Assert.Contains(activeDirection, actual);
}
Assert.Contains(activeBackground, actual);
}
[Fact]
public void S8003_matches_quote_price_color_quantity_and_scale_order_with_empty_levels()
{
var data = new S8003SceneData(
"A",
100,
5,
1.25,
ScenePriceDirection.Up,
[new(110, 100), new(90, 0), null],
[new(100, 50), new(120, 25), null],
100,
75);
var actual = new S8003SceneMutationBuilder().Build(data).Select(Describe);
Assert.Equal(
[
"V:title=A", "V:unit=\uB2E8\uC704 : \uC6D0", "V:price=100",
"V:changePrice=5", "V:rate=1.25",
"S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
"S:up=1", "S:bg1=1", "S:bg2=0", "S:bg3=0",
"V:price_buy_1=110", "C:price_buy_1:172:0:0:255",
"V:price_buy_2=90", "C:price_buy_2:19:73:170:255",
"V:price_buy_3=", "C:price_buy_3:50:51:50:255",
"V:buy_jan_1=100", "V:buy_jan_2=0", "V:buy_jan_3=",
"V:price_sell_1=100", "C:price_sell_1:50:51:50:255",
"V:price_sell_2=120", "C:price_sell_2:172:0:0:255",
"V:price_sell_3=", "C:price_sell_3:50:51:50:255",
"V:sell_jan_1=50", "V:sell_jan_2=25", "V:sell_jan_3=",
"V:buy_jan_total=100", "V:sell_jan_total=75",
"Z:buy_bar_1:1:0:0:1", "Z:buy_bar_2:0:0:0:1", "Z:buy_bar_3:0:0:0:1",
"Z:sell_bar_1:0.5:0:0:1", "Z:sell_bar_2:0.25:0:0:1", "Z:sell_bar_3:0:0:0:1"
], actual);
}
[Fact]
public void S8003_all_empty_levels_emit_finite_zero_scales()
{
var actual = new S8003SceneMutationBuilder().Build(new S8003SceneData(
"A",
0,
0,
0,
ScenePriceDirection.Unknown,
[null, null, null],
[null, null, null],
0,
0));
Assert.DoesNotContain(actual.Select(Describe), value =>
value.Contains("NaN", StringComparison.Ordinal));
Assert.All(actual.TakeLast(6), mutation =>
Assert.Contains(":0:0:0:1", Describe(mutation), StringComparison.Ordinal));
}
[Fact]
public void Builders_reject_unclosed_or_malformed_data_before_dynamic_names_are_possible()
{
Assert.Throws<LegacySceneDataException>(() =>
new S5023SceneMutationBuilder().Build(new S5023SceneData(
(LegacyDomesticMarket)99,
[null, null, null, null, null],
new(0, 0, 0))));
Assert.Throws<LegacySceneDataException>(() =>
new S5024SceneMutationBuilder().Build(new S5024SceneData(
LegacyDomesticMarket.Kospi,
[new((InvestorParticipant)99, 1)])));
Assert.Throws<LegacySceneDataException>(() =>
new S5025SceneMutationBuilder().Build(new S5025SceneData("A", [])));
Assert.Throws<LegacySceneDataException>(() =>
new S8003SceneMutationBuilder().Build(new S8003SceneData(
"A", 1, 0, 0, ScenePriceDirection.Flat,
[new(1, -1), null, null],
[null, null, null],
0,
0)));
var dtoTypes = new[]
{
typeof(S5023SceneData), typeof(S5024SceneData), typeof(S5025SceneData),
typeof(S5037SceneData), typeof(S8003SceneData)
};
Assert.All(dtoTypes, dtoType =>
{
Assert.True(typeof(ILegacySceneData).IsAssignableFrom(dtoType));
Assert.DoesNotContain(
dtoType.GetProperties(BindingFlags.Instance | BindingFlags.Public),
property => property.Name is "ObjectName" or "MethodName" or "AssetPath" or "SceneFile");
});
}
private static string Describe(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
PlayoutSetFaceColor color =>
$"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}",
PlayoutSetPosition position => FormattableString.Invariant(
$"P:{position.ObjectName}:{position.X}:{position.Y}:{position.Z}:{(int)position.Components}"),
PlayoutSetScale scale => FormattableString.Invariant(
$"Z:{scale.ObjectName}:{scale.X}:{scale.Y}:{scale.Z}:{(int)scale.Components}"),
_ => mutation.GetType().Name
};
}

View File

@@ -0,0 +1,306 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class TraderQuoteSceneDataLoadersTests
{
private static readonly string[] TraderQuoteColumns =
["STOCK_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "DIRECTION_CODE", "RATE"];
private static readonly string[] DealerColumns = ["DEALER_NAME", "VOLUME"];
private static readonly string[] OrderBookColumns =
[
"STOCK_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "DIRECTION_CODE", "RATE",
"SELL_PRICE_1", "SELL_PRICE_2", "SELL_PRICE_3",
"SELL_QUANTITY_1", "SELL_QUANTITY_2", "SELL_QUANTITY_3",
"BUY_PRICE_1", "BUY_PRICE_2", "BUY_PRICE_3",
"BUY_QUANTITY_1", "BUY_QUANTITY_2", "BUY_QUANTITY_3",
"TOTAL_SELL_QUANTITY", "TOTAL_BUY_QUANTITY"
];
public static TheoryData<LegacyDomesticMarket, string, string> TraderRoutes => new()
{
{ LegacyDomesticMarket.Kospi, "t_online1", "t_dealer" },
{ LegacyDomesticMarket.Kosdaq, "t_kosdaq_online1", "t_kosdaq_dealer" }
};
public static TheoryData<LegacyDomesticMarket, string> QuoteRoutes => new()
{
{ LegacyDomesticMarket.Kospi, "t_online1_call" },
{ LegacyDomesticMarket.Kosdaq, "t_kosdaq_online1_call" }
};
[Theory]
[MemberData(nameof(TraderRoutes))]
public async Task S5037_routes_closed_market_binds_name_and_proves_quote_dealer_mutations(
LegacyDomesticMarket market,
string quoteTable,
string dealerTable)
{
const string stockName = "삼성전자";
var executor = new RecordingExecutor(
TraderQuoteTable(stockName),
DealerTable("BUY"),
DealerTable("SELL"));
var data = await new S5037SceneDataLoader(executor).LoadAsync(
new S5037SceneLoadRequest(market, stockName),
CancellationToken.None);
var mutations = new S5037SceneMutationBuilder().Build(data);
Assert.Equal(3, executor.Calls.Count);
Assert.Equal(
["SCENE_5037_QUOTE", "SCENE_5037_BUY", "SCENE_5037_SELL"],
executor.Calls.Select(call => call.TableName));
Assert.Contains(quoteTable, executor.Calls[0].Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(dealerTable, executor.Calls[1].Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("f_buy_dealer_vol_1", executor.Calls[1].Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("f_sell_dealer_vol_1", executor.Calls[2].Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.DoesNotContain(stockName, call.Spec.Sql, StringComparison.Ordinal);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("stockName", parameter.Name);
Assert.Equal(stockName, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
});
Assert.Equal(stockName, data.StockName);
Assert.Equal(ScenePriceDirection.Up, data.Direction);
Assert.Equal([500L, 400L, 300L, 200L, 100L], data.BuyDealers.Select(row => row.Volume));
Assert.Equal("SELL5", data.SellDealers[^1].DealerName);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "price", Value: "70,000" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "buy_title_1", Value: "BUY1" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "sell_vol_5", Value: "100" });
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S5037_rejects_wrong_quote_name_schema_dealer_count_order_and_numeric_shape()
{
const string requested = "Requested";
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5037SceneDataLoader(new RecordingExecutor(
TraderQuoteTable("Other"), DealerTable("B"), DealerTable("S"))).LoadAsync(
new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested)));
var schema = TraderQuoteTable(requested);
schema.Columns[0].SetOrdinal(4);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5037SceneDataLoader(new RecordingExecutor(
schema, DealerTable("B"), DealerTable("S"))).LoadAsync(
new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested)));
var missing = DealerTable("B");
missing.Rows.RemoveAt(4);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5037SceneDataLoader(new RecordingExecutor(
TraderQuoteTable(requested), missing, DealerTable("S"))).LoadAsync(
new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested)));
var unordered = DealerTable("B");
unordered.Rows[2]["VOLUME"] = 900;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5037SceneDataLoader(new RecordingExecutor(
TraderQuoteTable(requested), unordered, DealerTable("S"))).LoadAsync(
new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested)));
var fractional = DealerTable("B");
fractional.Rows[0]["VOLUME"] = 1.5m;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5037SceneDataLoader(new RecordingExecutor(
TraderQuoteTable(requested), fractional, DealerTable("S"))).LoadAsync(
new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested)));
}
[Theory]
[MemberData(nameof(QuoteRoutes))]
public async Task S8003_routes_closed_market_binds_code_and_proves_order_book_mutations(
LegacyDomesticMarket market,
string callTable)
{
const string stockCode = "A005930";
var executor = new RecordingExecutor(OrderBookTable());
var data = await new S8003SceneDataLoader(executor).LoadAsync(
new S8003SceneLoadRequest(market, stockCode),
CancellationToken.None);
var mutations = new S8003SceneMutationBuilder().Build(data);
var call = Assert.Single(executor.Calls);
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("SCENE_8003", call.TableName);
Assert.Contains(callTable, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(stockCode, call.Spec.Sql, StringComparison.Ordinal);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("stockCode", parameter.Name);
Assert.Equal(stockCode, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
Assert.Equal(ScenePriceDirection.Down, data.Direction);
Assert.Equal(new OrderBookLevelData(69_900, 600), data.BuyLevels[0]);
Assert.Equal(new OrderBookLevelData(70_100, 300), data.SellLevels[0]);
Assert.Equal(1200, data.TotalBuyQuantity);
Assert.Equal(600, data.TotalSellQuantity);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "price_buy_1", Value: "69,900" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "sell_jan_total", Value: "600" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetScale { ObjectName: "buy_bar_1", X: 1f });
Assert.Equal(0, executor.StringCalls);
}
[Fact]
public async Task S8003_requires_exact_single_row_schema_finite_rate_and_integral_nonnegative_quantities()
{
var schema = OrderBookTable();
schema.Columns[0].SetOrdinal(1);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8003SceneDataLoader(new RecordingExecutor(schema)).LoadAsync(
new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930")));
var duplicate = OrderBookTable();
duplicate.Rows.Add(duplicate.Rows[0].ItemArray);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8003SceneDataLoader(new RecordingExecutor(duplicate)).LoadAsync(
new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930")));
var nonfinite = OrderBookTable();
nonfinite.Rows[0]["RATE"] = double.NegativeInfinity;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8003SceneDataLoader(new RecordingExecutor(nonfinite)).LoadAsync(
new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930")));
var fractional = OrderBookTable();
fractional.Rows[0]["BUY_QUANTITY_1"] = 1.5m;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8003SceneDataLoader(new RecordingExecutor(fractional)).LoadAsync(
new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930")));
var negative = OrderBookTable();
negative.Rows[0]["SELL_QUANTITY_1"] = -1;
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8003SceneDataLoader(new RecordingExecutor(negative)).LoadAsync(
new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930")));
}
[Theory]
[InlineData(99, "A005930")]
[InlineData(0, "bad-code")]
[InlineData(1, "")]
public async Task Invalid_S8003_requests_fail_before_query(int rawMarket, string code)
{
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S8003SceneDataLoader(executor).LoadAsync(
new S8003SceneLoadRequest((LegacyDomesticMarket)rawMarket, code)));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData(99, "Stock")]
[InlineData(0, "")]
public async Task Invalid_S5037_requests_fail_before_query(int rawMarket, string name)
{
var executor = new RecordingExecutor();
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5037SceneDataLoader(executor).LoadAsync(
new S5037SceneLoadRequest((LegacyDomesticMarket)rawMarket, name)));
Assert.Empty(executor.Calls);
}
private static DataTable TraderQuoteTable(string stockName) => Table(
TraderQuoteColumns,
[stockName, 70_000, 500, "2", 0.72d]);
private static DataTable DealerTable(string prefix) => Table(
DealerColumns,
[$"{prefix}1", 500],
[$"{prefix}2", 400],
[$"{prefix}3", 300],
[$"{prefix}4", 200],
[$"{prefix}5", 100]);
private static DataTable OrderBookTable() => Table(
OrderBookColumns,
[
"삼성전자", 70_000, -500, "5", -0.71d,
70_100, 70_200, 70_300,
300, 200, 100,
69_900, 69_800, 69_700,
600, 400, 200,
600, 1200
]);
private static DataTable Table(string[] columns, params object?[][] rows)
{
var table = new DataTable();
foreach (var column in columns)
{
table.Columns.Add(column, typeof(object));
}
foreach (var row in rows)
{
table.Rows.Add(row);
}
return table;
}
private sealed record QueryCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
{
private readonly Queue<DataTable> _results = new(results);
public List<QueryCall> Calls { get; } = [];
public int StringCalls { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
StringCalls++;
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
Calls.Add(new QueryCall(source, tableName, query));
if (_results.Count == 0)
{
throw new InvalidOperationException("No result was configured.");
}
return Task.FromResult(_results.Dequeue().Copy());
}
}
}

View File

@@ -0,0 +1,130 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class TrustedManualSceneDataLoader5025Tests
{
[Theory]
[InlineData(S5025ManualAudience.Individual, "개인")]
[InlineData(S5025ManualAudience.Foreign, "외국인")]
[InlineData(S5025ManualAudience.Institution, "기관")]
public async Task Loader_uses_only_closed_audience_and_proves_all_ten_slot_mutations(
S5025ManualAudience audience,
string expectedTitle)
{
var source = new RecordingSource(Rows());
var data = await new S5025SceneDataLoader(source).LoadAsync(
new S5025ManualSceneLoadRequest(audience),
CancellationToken.None);
var mutations = new S5025SceneMutationBuilder().Build(data);
Assert.Equal(audience, Assert.Single(source.Calls));
Assert.Equal(expectedTitle, data.Title);
Assert.Equal(5, data.Rows.Count);
Assert.Equal(21, mutations.Count);
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue
{
ObjectName: "title",
Value: var value
} && value == expectedTitle + " 순매도 상위");
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "종목1", Value: "L1" });
Assert.Contains(mutations, mutation =>
mutation is PlayoutSetValue { ObjectName: "금액10", Value: "R5A" });
}
[Fact]
public void Public_manual_contract_exposes_no_path_or_free_form_selector()
{
Assert.Equal(
[typeof(S5025ManualAudience)],
typeof(S5025ManualSceneLoadRequest).GetProperties().Select(property => property.PropertyType));
var method = Assert.Single(typeof(IS5025TrustedManualDataSource).GetMethods());
Assert.Equal("ReadAsync", method.Name);
Assert.DoesNotContain(
method.GetParameters(),
parameter => parameter.ParameterType == typeof(string));
Assert.DoesNotContain(
typeof(S5025SceneDataLoader).GetMethods(),
methodInfo => methodInfo.GetParameters().Any(parameter =>
parameter.ParameterType == typeof(string)));
}
[Theory]
[InlineData(4)]
[InlineData(6)]
public async Task Loader_requires_exactly_five_trusted_rows(int rowCount)
{
var rows = Rows().Take(rowCount).ToList();
while (rows.Count < rowCount)
{
rows.Add(new("X", "1", "Y", "2"));
}
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5025SceneDataLoader(new RecordingSource(rows)).LoadAsync(
new S5025ManualSceneLoadRequest(S5025ManualAudience.Individual)));
}
[Fact]
public async Task Loader_rejects_null_control_and_oversized_cells_before_mutations()
{
var nullRows = Rows();
nullRows[0] = new(null!, "1", "R", "2");
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5025SceneDataLoader(new RecordingSource(nullRows)).LoadAsync(
new S5025ManualSceneLoadRequest(S5025ManualAudience.Individual)));
var controlRows = Rows();
controlRows[0] = new("bad\nname", "1", "R", "2");
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5025SceneDataLoader(new RecordingSource(controlRows)).LoadAsync(
new S5025ManualSceneLoadRequest(S5025ManualAudience.Individual)));
var oversizedRows = Rows();
oversizedRows[0] = new(new string('X', 257), "1", "R", "2");
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5025SceneDataLoader(new RecordingSource(oversizedRows)).LoadAsync(
new S5025ManualSceneLoadRequest(S5025ManualAudience.Individual)));
}
[Fact]
public async Task Unknown_audience_fails_before_trusted_source_is_called()
{
var source = new RecordingSource(Rows());
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
new S5025SceneDataLoader(source).LoadAsync(
new S5025ManualSceneLoadRequest((S5025ManualAudience)99)));
Assert.Empty(source.Calls);
}
private static List<S5025TrustedManualRow> Rows() =>
[
new("L1", "L1A", "R1", "R1A"),
new("L2", "L2A", "R2", "R2A"),
new("L3", "L3A", "R3", "R3A"),
new("L4", "L4A", "R4", "R4A"),
new("L5", "L5A", "R5", "R5A")
];
private sealed class RecordingSource(IReadOnlyList<S5025TrustedManualRow> rows)
: IS5025TrustedManualDataSource
{
public List<S5025ManualAudience> Calls { get; } = [];
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Calls.Add(audience);
return Task.FromResult(rows);
}
}
}