feat: migrate legacy playout workflow and scenes
This commit is contained in:
427
tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs
Normal file
427
tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs
Normal 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);
|
||||
}
|
||||
458
tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs
Normal file
458
tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
372
tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneDataLoadersTests.cs
Normal file
372
tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneDataLoadersTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
236
tests/MBN_STOCK_WEBVIEW.Core.Tests/DataQuerySpecTests.cs
Normal file
236
tests/MBN_STOCK_WEBVIEW.Core.Tests/DataQuerySpecTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
146
tests/MBN_STOCK_WEBVIEW.Core.Tests/GridSceneBuilder5082Tests.cs
Normal file
146
tests/MBN_STOCK_WEBVIEW.Core.Tests/GridSceneBuilder5082Tests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
481
tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs
Normal file
481
tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
295
tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneCatalogTests.cs
Normal file
295
tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneCatalogTests.cs
Normal 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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
335
tests/MBN_STOCK_WEBVIEW.Core.Tests/ScenePagingTests.cs
Normal file
335
tests/MBN_STOCK_WEBVIEW.Core.Tests/ScenePagingTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user