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,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; \}/);
});

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>

View File

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

View File

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

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.LegacyBridge\MBN_STOCK_WEBVIEW.LegacyBridge.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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