feat: establish legacy parity migration baseline
This commit is contained in:
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,221 @@
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyOperatorControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EmptyButtonIsNoOpButEmptyEnterSearches()
|
||||
{
|
||||
var lookup = Lookup();
|
||||
var controller = new LegacyOperatorController(lookup);
|
||||
|
||||
await controller.SearchStocksAsync(string.Empty, LegacySearchTrigger.Button);
|
||||
Assert.Equal(0, lookup.CallCount);
|
||||
|
||||
await controller.SearchStocksAsync(string.Empty, LegacySearchTrigger.Enter);
|
||||
Assert.Equal(1, lookup.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactPointerDoubleClick_AppendsOneLegacyRow()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(0);
|
||||
|
||||
controller.CutPointerDown(1, false, false, 20, 45, 1_000);
|
||||
var state = controller.CutPointerDown(1, false, false, 20, 45, 1_400);
|
||||
|
||||
var row = Assert.Single(state.Playlist);
|
||||
Assert.Equal("코스피", row.MarketText);
|
||||
Assert.Equal("삼성전자", row.StockName);
|
||||
Assert.Equal("1열판기본", row.GraphicType);
|
||||
Assert.Equal("현재가", row.Subtype);
|
||||
Assert.Equal("005930", row.StockCode);
|
||||
Assert.Equal("5001", row.ToPlayoutEntry().CutCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactPointerDoubleClick_AllowsZeroBasedWebTimestamp()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(0);
|
||||
|
||||
controller.CutPointerDown(1, false, false, 20, 45, 0);
|
||||
var state = controller.CutPointerDown(1, false, false, 20, 45, 499);
|
||||
|
||||
Assert.Single(state.Playlist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DifferentPointerPosition_DoesNotCountAsLegacyDoubleClick()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(0);
|
||||
|
||||
controller.CutPointerDown(1, false, false, 20, 45, 1_000);
|
||||
var state = controller.CutPointerDown(1, false, false, 21, 45, 1_200);
|
||||
|
||||
Assert.Empty(state.Playlist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeSelectionAndLegacyDoubleClickRowsStaySeparateAtShiftEdges()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(0);
|
||||
|
||||
var firstShift = controller.CutPointerDown(2, false, true, 20, 69, 1_000);
|
||||
Assert.Equal(
|
||||
[0, 1, 2],
|
||||
firstShift.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
|
||||
|
||||
var noDoubleClick = controller.CutPointerDown(2, false, false, 20, 69, 1_400);
|
||||
Assert.Empty(noDoubleClick.Playlist);
|
||||
|
||||
var sameRowShift = controller.CutPointerDown(2, false, true, 21, 69, 2_000);
|
||||
Assert.Equal(
|
||||
[2],
|
||||
sameRowShift.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
|
||||
|
||||
var stillNoDoubleClick = controller.CutPointerDown(2, false, false, 21, 69, 2_400);
|
||||
Assert.Empty(stillNoDoubleClick.Playlist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ControlAndShiftSelection_DropsInPhysicalOrderAndKeepsDuplicates()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(0);
|
||||
controller.CutPointerDown(0, false, false, 1, 1, 1_000);
|
||||
controller.CutPointerDown(2, true, false, 1, 47, 2_000);
|
||||
controller.CutPointerDown(1, true, true, 1, 24, 3_000);
|
||||
|
||||
var first = controller.DropSelectedCutsOnPlaylist();
|
||||
var second = controller.DropSelectedCutsOnPlaylist();
|
||||
|
||||
Assert.Equal(3, first.Playlist.Count);
|
||||
Assert.Equal(6, second.Playlist.Count);
|
||||
Assert.Equal(
|
||||
["예상체결가", "현재가", "시간외단일가"],
|
||||
first.Playlist.Select(row => row.Subtype));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SameRowShiftStillDropsTheNativeListViewSelection()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(0);
|
||||
controller.CutPointerDown(1, false, false, 1, 24, 1_000);
|
||||
controller.CutPointerDown(1, false, true, 2, 24, 2_000);
|
||||
|
||||
var state = controller.DropSelectedCutsOnPlaylist();
|
||||
|
||||
Assert.Equal("현재가", Assert.Single(state.Playlist).Subtype);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NxtOnlyAllowsCurrentAndShowsOriginalWarning()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(1);
|
||||
controller.CutPointerDown(0, false, false, 1, 1, 1_000);
|
||||
var warning = controller.DropSelectedCutsOnPlaylist();
|
||||
|
||||
Assert.Empty(warning.Playlist);
|
||||
Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message);
|
||||
|
||||
controller.DismissDialog();
|
||||
controller.CutPointerDown(1, false, false, 1, 24, 2_000);
|
||||
var accepted = controller.DropSelectedCutsOnPlaylist();
|
||||
Assert.Equal("N5001", Assert.Single(accepted.Playlist).ToPlayoutEntry().CutCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NxtMultiSelection_PausesAtEachOriginalModalBeforeContinuing()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(1);
|
||||
controller.CutPointerDown(0, false, false, 1, 1, 1_000);
|
||||
controller.CutPointerDown(1, true, false, 1, 24, 2_000);
|
||||
controller.CutPointerDown(2, true, false, 1, 47, 3_000);
|
||||
|
||||
var firstWarning = controller.DropSelectedCutsOnPlaylist();
|
||||
Assert.Empty(firstWarning.Playlist);
|
||||
Assert.Equal("NXT는 제공하지 않습니다.", firstWarning.Dialog?.Message);
|
||||
|
||||
var secondWarning = controller.DismissDialog();
|
||||
Assert.Equal("N5001", Assert.Single(secondWarning.Playlist).ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("NXT는 제공하지 않습니다.", secondWarning.Dialog?.Message);
|
||||
|
||||
var finished = controller.DismissDialog();
|
||||
Assert.Single(finished.Playlist);
|
||||
Assert.Null(finished.Dialog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NxtSeparatorStillShowsOriginalWarning()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(1);
|
||||
controller.CutPointerDown(3, false, false, 1, 70, 1_000);
|
||||
|
||||
var warning = controller.DropSelectedCutsOnPlaylist();
|
||||
|
||||
Assert.Empty(warning.Playlist);
|
||||
Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewSearchClearsStockSelectionButNotCutCatalogOrPlaylist()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(0);
|
||||
controller.CutPointerDown(1, false, false, 1, 24, 1_000);
|
||||
controller.DropSelectedCutsOnPlaylist();
|
||||
|
||||
var state = await controller.SearchStocksAsync("다시", LegacySearchTrigger.Button);
|
||||
|
||||
Assert.Null(state.SelectedStockIndex);
|
||||
Assert.Equal(33, state.CutRows.Count);
|
||||
Assert.Single(state.Playlist);
|
||||
}
|
||||
|
||||
private static FakeLookup Lookup() => new(new LegacyStockSearchData(
|
||||
"query",
|
||||
["삼성전자", "넥스트(NXT)"],
|
||||
[
|
||||
new LegacyStockIdentity(LegacyDomesticStockMarket.Kospi, "삼성전자", "005930"),
|
||||
new LegacyStockIdentity(LegacyDomesticStockMarket.NxtKospi, "넥스트(NXT)", "A00001")
|
||||
]));
|
||||
|
||||
private sealed class FakeLookup : ILegacyStockLookup
|
||||
{
|
||||
private readonly LegacyStockSearchData _result;
|
||||
|
||||
public FakeLookup(LegacyStockSearchData result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Data;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyParityStockSearchServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Search_PreservesProviderAndRowOrderWithoutLimitOrSort()
|
||||
{
|
||||
var executor = new RecordingExecutor(new Dictionary<string, DataTable>
|
||||
{
|
||||
["LEGACY_PARITY_KOSPI_MASTER"] = Table(("중복", "000001")),
|
||||
["LEGACY_PARITY_KOSDAQ_MASTER"] = Table(("중복", "100001")),
|
||||
["LEGACY_PARITY_NXT_KOSPI_MASTER"] = Table(("중복(NXT)", "200001")),
|
||||
["LEGACY_PARITY_NXT_KOSDAQ_MASTER"] = Table(("중복", "300001")),
|
||||
["LEGACY_PARITY_KOSPI"] = Table(("가-2", "000002"), ("가-1", "000001")),
|
||||
["LEGACY_PARITY_KOSDAQ"] = Table(("나", "100001")),
|
||||
["LEGACY_PARITY_NXT_KOSPI"] = Table(("다(NXT)", "200001")),
|
||||
["LEGACY_PARITY_NXT_KOSDAQ"] = Table(("라(NXT)", "300001"))
|
||||
});
|
||||
var service = new LegacyParityStockSearchService(executor);
|
||||
|
||||
var result = await service.SearchAsync(" a%_ ");
|
||||
|
||||
Assert.Equal(["가-2", "가-1", "나", "다(NXT)", "라(NXT)"], result.DisplayNames);
|
||||
Assert.Equal("300001", result.ResolveLastByDisplayName("중복")?.StockCode);
|
||||
Assert.Equal(LegacyDomesticStockMarket.NxtKosdaq,
|
||||
result.ResolveLastByDisplayName("중복")?.Market);
|
||||
Assert.Equal(8, executor.Calls.Count);
|
||||
|
||||
foreach (var call in executor.Calls.Skip(4))
|
||||
{
|
||||
Assert.DoesNotContain("ORDER BY", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("LIMIT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("ROWNUM", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Single(call.Spec.Parameters);
|
||||
Assert.Equal(" A%_ ", call.Spec.Parameters[0].Value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptySearch_IsAllowedAndMasterSnapshotIsCached()
|
||||
{
|
||||
var tables = new Dictionary<string, DataTable>();
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"LEGACY_PARITY_KOSPI", "LEGACY_PARITY_KOSDAQ",
|
||||
"LEGACY_PARITY_NXT_KOSPI", "LEGACY_PARITY_NXT_KOSDAQ"
|
||||
})
|
||||
{
|
||||
tables[name] = Table();
|
||||
tables[$"{name}_MASTER"] = Table();
|
||||
}
|
||||
|
||||
var executor = new RecordingExecutor(tables);
|
||||
var service = new LegacyParityStockSearchService(executor);
|
||||
|
||||
await service.SearchAsync(string.Empty);
|
||||
await service.SearchAsync(string.Empty);
|
||||
|
||||
Assert.Equal(12, executor.Calls.Count);
|
||||
Assert.Equal(4, executor.Calls.Count(call => call.TableName.EndsWith("_MASTER", StringComparison.Ordinal)));
|
||||
Assert.All(executor.Calls.Where(call => !call.TableName.EndsWith("_MASTER", StringComparison.Ordinal)),
|
||||
call => Assert.Equal(string.Empty, call.Spec.Parameters[0].Value));
|
||||
}
|
||||
|
||||
private static DataTable Table(params (string Name, string Code)[] rows)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("STOCK_NAME", typeof(string));
|
||||
table.Columns.Add("STOCK_CODE", typeof(string));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(row.Name, row.Code);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private sealed class RecordingExecutor : IDataQueryExecutor
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, DataTable> _tables;
|
||||
|
||||
public RecordingExecutor(IReadOnlyDictionary<string, DataTable> tables)
|
||||
{
|
||||
_tables = tables;
|
||||
}
|
||||
|
||||
public List<Call> Calls { get; } = [];
|
||||
|
||||
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
DataQuerySpec query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls.Add(new Call(source, tableName, query));
|
||||
return Task.FromResult(_tables[tableName].Copy());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Call(DataSourceKind Source, string TableName, DataQuerySpec Spec);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyStockCutCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rows_MatchRuntimeAssetPhysicalLayout()
|
||||
{
|
||||
Assert.Equal(33, LegacyStockCutCatalog.Rows.Count);
|
||||
Assert.Equal(31, LegacyStockCutCatalog.Rows.Count(row => !row.IsSeparator));
|
||||
Assert.Equal([3, 8], LegacyStockCutCatalog.Rows
|
||||
.Where(row => row.IsSeparator)
|
||||
.Select(row => row.PhysicalIndex));
|
||||
Assert.Equal("1열판기본_시간외단일가", LegacyStockCutCatalog.Rows[2].RawLabel);
|
||||
Assert.Equal(31, LegacyStockCutCatalog.Rows[^1].DisplayOrdinal);
|
||||
Assert.Equal(
|
||||
"45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20",
|
||||
LegacyStockCutCatalog.RuntimeAssetSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryActiveCut_ResolvesToRegisteredAlias()
|
||||
{
|
||||
foreach (var row in LegacyStockCutCatalog.Rows.Where(row => !row.IsSeparator))
|
||||
{
|
||||
var alias = LegacyStockCutCatalog.ResolveCutAlias(row.RawLabel, isNxt: false);
|
||||
Assert.NotEmpty(LegacySceneCatalog.FindByCutAlias(alias));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1열판기본_현재가", "N5001")]
|
||||
public void NxtCurrent_UsesN5001(string label, string expected)
|
||||
{
|
||||
Assert.Equal(expected, LegacyStockCutCatalog.ResolveCutAlias(label, isNxt: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaylistRow_PreservesLegacySevenFieldsAndDefersAliasResolution()
|
||||
{
|
||||
var stock = new LegacyStockIdentity(
|
||||
LegacyDomesticStockMarket.Kospi,
|
||||
"삼성전자",
|
||||
"005930");
|
||||
var row = LegacyStockCutCatalog.CreatePlaylistRow(
|
||||
"row-1",
|
||||
stock.DisplayName,
|
||||
stock,
|
||||
LegacyStockCutCatalog.Rows[1]);
|
||||
|
||||
Assert.True(row.IsEnabled);
|
||||
Assert.Equal("코스피", row.MarketText);
|
||||
Assert.Equal("삼성전자", row.StockName);
|
||||
Assert.Equal("1열판기본", row.GraphicType);
|
||||
Assert.Equal("현재가", row.Subtype);
|
||||
Assert.Equal("1/1", row.PageText);
|
||||
Assert.Equal("005930", row.StockCode);
|
||||
|
||||
var playout = row.ToPlayoutEntry();
|
||||
Assert.Equal("5001", playout.CutCode);
|
||||
Assert.Equal("005930", playout.Selection?.DataCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.LegacyApplication\MBN_STOCK_WEBVIEW.LegacyApplication.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user