feat: establish legacy parity migration baseline
This commit is contained in:
70
tests/LegacyParityWeb/thin-client.test.cjs
Normal file
70
tests/LegacyParityWeb/thin-client.test.cjs
Normal file
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const webRoot = path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web");
|
||||
const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8");
|
||||
const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8");
|
||||
|
||||
test("legacy shell exposes the original stock, cut, playlist and playout controls", () => {
|
||||
for (const id of [
|
||||
"stock-search",
|
||||
"stock-search-button",
|
||||
"stock-results",
|
||||
"cut-list",
|
||||
"playlist-drop-zone",
|
||||
"playlist-rows",
|
||||
"legacy-dialog"
|
||||
]) {
|
||||
assert.match(markup, new RegExp("id=\\\"" + id + "\\\""));
|
||||
}
|
||||
assert.match(markup, /TAKE IN/);
|
||||
assert.match(markup, /TAKE OUT/);
|
||||
assert.match(markup, /NEXT/);
|
||||
assert.match(markup, /class="connection-state"[^>]*>미연결<\/div>/);
|
||||
assert.match(markup, /class="active" type="button" disabled>해외<\/button>/);
|
||||
assert.match(markup, /type="button" disabled>정지<\/button>/);
|
||||
});
|
||||
|
||||
test("web layer sends intent and does not own scene, DB identity or playlist state", () => {
|
||||
assert.doesNotMatch(app, /(?:sceneCode|cutCode|stockCode|dataCode|builderKey)/);
|
||||
assert.doesNotMatch(app, /(?:localStorage|sessionStorage|playlist\.push|fetch\s*\()/);
|
||||
assert.doesNotMatch(app, /\b(?:5001|N5001|5006|5011|5086|8035|8061|8040|8046|8051|8056|8003|5037)\b/);
|
||||
});
|
||||
|
||||
test("legacy double click is represented by pointer-down facts, not browser dblclick policy", () => {
|
||||
assert.match(app, /cut-pointer-down/);
|
||||
assert.match(app, /timestampMilliseconds/);
|
||||
assert.match(app, /event\.ctrlKey/);
|
||||
assert.match(app, /event\.shiftKey/);
|
||||
assert.doesNotMatch(app, /dblclick/);
|
||||
});
|
||||
|
||||
test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => {
|
||||
assert.match(app, /dataset\.physicalIndex/);
|
||||
assert.match(app, /function createCutElement/);
|
||||
assert.doesNotMatch(app, /cutList\.replaceChildren/);
|
||||
});
|
||||
|
||||
test("state rendering preserves stock and playlist focus like native controls", () => {
|
||||
assert.match(app, /dataset\.resultIndex/);
|
||||
assert.match(app, /dataset\.rowId/);
|
||||
assert.doesNotMatch(app, /stockResults\.replaceChildren/);
|
||||
assert.doesNotMatch(app, /playlistRows\.replaceChildren/);
|
||||
});
|
||||
|
||||
test("separator rows preserve the original drag gesture", () => {
|
||||
assert.match(app, /element\.draggable = true/);
|
||||
assert.doesNotMatch(app, /draggable = !row\.isSeparator/);
|
||||
});
|
||||
|
||||
test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => {
|
||||
const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8");
|
||||
assert.match(app, /state\.isBusy === true/);
|
||||
assert.match(app, /classList\.toggle\("busy"/);
|
||||
assert.match(styles, /body\.busy \.operator-shell \{ pointer-events: none; \}/);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
|
||||
|
||||
public sealed class LegacyBridgeProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseSearch_PreservesBlankEnterAndRejectsExtraAuthority()
|
||||
{
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"search-stocks\",\"payload\":{\"text\":\"\",\"trigger\":\"enter\"}}",
|
||||
out var parsed,
|
||||
out var error));
|
||||
Assert.Null(error);
|
||||
var search = Assert.IsType<LegacySearchStocksIntent>(parsed);
|
||||
Assert.Equal(string.Empty, search.Text);
|
||||
Assert.Equal(LegacySearchTrigger.Enter, search.Trigger);
|
||||
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"search-stocks\",\"payload\":{\"text\":\"삼성\",\"trigger\":\"button\",\"sceneCode\":\"5001\"}}",
|
||||
out _,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePointer_RequiresExactBoundedInput()
|
||||
{
|
||||
const string json =
|
||||
"{\"type\":\"cut-pointer-down\",\"payload\":{\"physicalIndex\":1,\"control\":false,\"shift\":true,\"x\":20,\"y\":45,\"timestampMilliseconds\":1400}}";
|
||||
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
|
||||
var pointer = Assert.IsType<LegacyCutPointerDownIntent>(parsed);
|
||||
Assert.Equal(1, pointer.PhysicalIndex);
|
||||
Assert.True(pointer.Shift);
|
||||
Assert.Equal(1_400, pointer.TimestampMilliseconds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://legacy-parity.mbn.local/index.html", true)]
|
||||
[InlineData("https://legacy-parity.mbn.local.evil/index.html", false)]
|
||||
[InlineData("http://legacy-parity.mbn.local/index.html", false)]
|
||||
[InlineData("file:///C:/index.html", false)]
|
||||
public void TrustedSource_IsExactHttpsOrigin(string source, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, LegacyBridgeProtocol.IsTrustedSource(source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializedState_DoesNotExposeHiddenDbOrSceneAuthority()
|
||||
{
|
||||
var playlistRow = LegacyStockCutCatalog.CreatePlaylistRow(
|
||||
"legacy-stock-00000001",
|
||||
"삼성전자",
|
||||
new LegacyStockIdentity(LegacyDomesticStockMarket.Kospi, "삼성전자", "005930"),
|
||||
LegacyStockCutCatalog.Rows[1]);
|
||||
var snapshot = new LegacyOperatorSnapshot(
|
||||
1,
|
||||
"삼성",
|
||||
[new LegacyStockResultRow(0, "삼성전자")],
|
||||
0,
|
||||
[new LegacyCutViewRow(1, 2, "1열판기본_현재가", false, true)],
|
||||
[playlistRow],
|
||||
null,
|
||||
string.Empty,
|
||||
LegacyOperatorStatusKind.Neutral);
|
||||
|
||||
var json = LegacyBridgeProtocol.SerializeState(snapshot, isBusy: true);
|
||||
|
||||
Assert.DoesNotContain("005930", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("5001", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("rawCutLabel", json, StringComparison.Ordinal);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var payload = document.RootElement.GetProperty("payload");
|
||||
Assert.Equal(
|
||||
"삼성전자",
|
||||
payload.GetProperty("searchResults")[0].GetProperty("displayName").GetString());
|
||||
Assert.Equal(
|
||||
"삼성전자",
|
||||
payload.GetProperty("playlist")[0].GetProperty("stockName").GetString());
|
||||
Assert.Equal(
|
||||
"1열판기본_현재가",
|
||||
payload.GetProperty("cutRows")[0].GetProperty("rawLabel").GetString());
|
||||
Assert.True(payload.GetProperty("isBusy").GetBoolean());
|
||||
}
|
||||
}
|
||||
@@ -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.LegacyBridge\MBN_STOCK_WEBVIEW.LegacyBridge.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
1
tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/GlobalUsings.cs
Normal file
1
tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||||
|
||||
public sealed class LegacyWebContractTests
|
||||
{
|
||||
private static readonly string RepositoryRoot = FindRepositoryRoot();
|
||||
private static readonly string WebRoot = Path.Combine(
|
||||
RepositoryRoot,
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp",
|
||||
"Web");
|
||||
private static readonly string App = File.ReadAllText(Path.Combine(WebRoot, "app.js"));
|
||||
private static readonly string Markup = File.ReadAllText(Path.Combine(WebRoot, "index.html"));
|
||||
private static readonly string Styles = File.ReadAllText(Path.Combine(WebRoot, "styles.css"));
|
||||
|
||||
[Fact]
|
||||
public void ShellExposesOriginalStockCutPlaylistAndPlayoutControls()
|
||||
{
|
||||
foreach (var id in new[]
|
||||
{
|
||||
"stock-search",
|
||||
"stock-search-button",
|
||||
"stock-results",
|
||||
"cut-list",
|
||||
"playlist-drop-zone",
|
||||
"playlist-rows",
|
||||
"legacy-dialog"
|
||||
})
|
||||
{
|
||||
Assert.Contains($"id=\"{id}\"", Markup, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.Contains("TAKE IN", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("TAKE OUT", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("NEXT", Markup, StringComparison.Ordinal);
|
||||
Assert.Matches("class=\"connection-state\"[^>]*>미연결</div>", Markup);
|
||||
Assert.Contains(
|
||||
"class=\"active\" type=\"button\" disabled>해외</button>",
|
||||
Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"type=\"button\" disabled>정지</button>",
|
||||
Markup,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebLayerDoesNotOwnSceneDatabaseIdentityOrPlaylistState()
|
||||
{
|
||||
Assert.DoesNotMatch(
|
||||
new Regex("(?:sceneCode|cutCode|stockCode|dataCode|builderKey)"),
|
||||
App);
|
||||
Assert.DoesNotMatch(
|
||||
new Regex("(?:localStorage|sessionStorage|playlist\\.push|fetch\\s*\\()"),
|
||||
App);
|
||||
Assert.DoesNotMatch(
|
||||
new Regex("\\b(?:5001|N5001|5006|5011|5086|8035|8061|8040|8046|8051|8056|8003|5037)\\b"),
|
||||
App);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleClickUsesPointerFactsInsteadOfBrowserPolicy()
|
||||
{
|
||||
Assert.Contains("cut-pointer-down", App, StringComparison.Ordinal);
|
||||
Assert.Contains("timestampMilliseconds", App, StringComparison.Ordinal);
|
||||
Assert.Contains("event.ctrlKey", App, StringComparison.Ordinal);
|
||||
Assert.Contains("event.shiftKey", App, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("dblclick", App, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutRowsAreReconciledInPlace()
|
||||
{
|
||||
Assert.Contains("dataset.physicalIndex", App, StringComparison.Ordinal);
|
||||
Assert.Contains("function createCutElement", App, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("cutList.replaceChildren", App, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StockAndPlaylistRowsPreserveNativeControlFocus()
|
||||
{
|
||||
Assert.Contains("dataset.resultIndex", App, StringComparison.Ordinal);
|
||||
Assert.Contains("dataset.rowId", App, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("stockResults.replaceChildren", App, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("playlistRows.replaceChildren", App, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeparatorRowsPreserveOriginalDragGesture()
|
||||
{
|
||||
Assert.Contains("element.draggable = true", App, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("draggable = !row.isSeparator", App, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BusySnapshotFreezesControlsWithoutQueueingInput()
|
||||
{
|
||||
Assert.Contains("state.isBusy === true", App, StringComparison.Ordinal);
|
||||
Assert.Contains("classList.toggle(\"busy\"", App, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"body.busy .operator-shell { pointer-events: none; }",
|
||||
Styles,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("The repository root could not be located.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user