feat: establish legacy parity migration baseline

This commit is contained in:
2026-07-15 07:10:01 +09:00
parent e468f43886
commit f14800656b
36 changed files with 3166 additions and 0 deletions

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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>