feat: complete legacy UI and playout parity migration

This commit is contained in:
2026-07-15 13:11:38 +09:00
parent f14800656b
commit 6e0c275fdd
108 changed files with 43738 additions and 560 deletions

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
@@ -37,6 +38,334 @@ public sealed class LegacyBridgeProtocolTests
Assert.Equal(1_400, pointer.TimestampMilliseconds);
}
[Fact]
public void ParsePlaylistSelection_PreservesModifiersAndRejectsUnsafeAuthority()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-playlist-row\",\"payload\":{\"rowId\":\"legacy-stock-00000003\",\"control\":true,\"shift\":false}}",
out var parsed,
out _));
var selection = Assert.IsType<LegacySelectPlaylistRowIntent>(parsed);
Assert.Equal("legacy-stock-00000003", selection.RowId);
Assert.True(selection.Control);
Assert.False(selection.Shift);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-playlist-row\",\"payload\":{\"rowId\":\"../row\",\"control\":false,\"shift\":false}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-playlist-row\",\"payload\":{\"rowId\":\"legacy-stock-00000003\",\"control\":false,\"shift\":false,\"rowIndex\":2}}",
out _,
out _));
}
[Fact]
public void ParseAllPlaylistEnabled_RequiresOnlyBooleanState()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-all-playlist-enabled\",\"payload\":{\"enabled\":false}}",
out var parsed,
out _));
var enabled = Assert.IsType<LegacySetAllPlaylistEnabledIntent>(parsed);
Assert.False(enabled.Enabled);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-all-playlist-enabled\",\"payload\":{\"enabled\":1}}",
out _,
out _));
}
[Fact]
public void ParseTabs_UsesClosedIdentitySet()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-tab\",\"payload\":{\"tabId\":\"kospiIndustry\"}}",
out var selection,
out _));
Assert.Equal(
LegacyOperatorTabId.KospiIndustry,
Assert.IsType<LegacySelectTabIntent>(selection).TabId);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"swap-tabs\",\"payload\":{\"source\":\"theme\",\"target\":\"exchange\"}}",
out var swap,
out _));
var parsedSwap = Assert.IsType<LegacySwapTabsIntent>(swap);
Assert.Equal(LegacyOperatorTabId.Theme, parsedSwap.Source);
Assert.Equal(LegacyOperatorTabId.Exchange, parsedSwap.Target);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-tab\",\"payload\":{\"tabId\":\"scene5001\"}}",
out _,
out _));
}
[Theory]
[InlineData("first", false)]
[InlineData("last", true)]
public void ParsePlaylistBoundary_UsesClosedValues(string boundary, bool last)
{
var json = $"{{\"type\":\"select-playlist-boundary\",\"payload\":{{\"boundary\":\"{boundary}\"}}}}";
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
Assert.Equal(last, Assert.IsType<LegacySelectPlaylistBoundaryIntent>(parsed).Last);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-playlist-boundary\",\"payload\":{\"boundary\":\"middle\"}}",
out _,
out _));
}
[Fact]
public void ParseToggleActiveEnabled_RequiresEmptyPayload()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"toggle-active-playlist-enabled\",\"payload\":{}}",
out var parsed,
out _));
Assert.IsType<LegacyToggleActivePlaylistEnabledIntent>(parsed);
}
[Fact]
public void ParseFixedCatalogIntents_DoesNotAcceptSceneAuthority()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-fixed-action\",\"payload\":{\"actionId\":\"fixed-001\"}}",
out var action,
out _));
Assert.Equal("fixed-001", Assert.IsType<LegacyActivateFixedActionIntent>(action).ActionId);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-fixed-section\",\"payload\":{\"sectionIndex\":0}}",
out var section,
out _));
Assert.Equal(0, Assert.IsType<LegacyActivateFixedSectionIntent>(section).SectionIndex);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-fixed-action\",\"payload\":{\"actionId\":\"fixed-001\",\"sceneCode\":\"5001\"}}",
out _,
out _));
}
[Fact]
public void ParseMovingAverages_RequiresBothBooleans()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-moving-averages\",\"payload\":{\"ma5\":true,\"ma20\":false}}",
out var parsed,
out _));
var intent = Assert.IsType<LegacySetMovingAveragesIntent>(parsed);
Assert.True(intent.Ma5);
Assert.False(intent.Ma20);
}
[Fact]
public void ParseIndustryIntents_AcceptsOnlyIndexAndOpaqueActionId()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-industry\",\"payload\":{\"index\":2}}",
out var selected,
out _));
var selectIntent = Assert.IsType<LegacySelectIndustryIntent>(selected);
Assert.Equal(2, selectIntent.Index);
Assert.False(selectIntent.DoubleClick);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"double-click-industry\",\"payload\":{\"index\":3}}",
out var doubled,
out _));
Assert.True(Assert.IsType<LegacySelectIndustryIntent>(doubled).DoubleClick);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-industry-action\",\"payload\":{\"actionId\":\"yield-5일\"}}",
out var action,
out _));
Assert.Equal(
"yield-5일",
Assert.IsType<LegacyActivateIndustryActionIntent>(action).ActionId);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"swap-industries\",\"payload\":{}}",
out var swap,
out _));
Assert.IsType<LegacySwapIndustriesIntent>(swap);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-industry-action\",\"payload\":{\"actionId\":\"../5001\"}}",
out _,
out _));
}
[Fact]
public void ParseThemeIntents_UsesClosedSessionSortAndOpaqueIds()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"search-themes\",\"payload\":{\"query\":\"AI\",\"nxtSession\":\"preMarket\"}}",
out var search,
out _));
Assert.Equal(
ThemeSession.PreMarket,
Assert.IsType<LegacySearchThemesIntent>(search).NxtSession);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-theme\",\"payload\":{\"resultId\":\"theme-result-001\"}}",
out var selection,
out _));
Assert.Equal(
"theme-result-001",
Assert.IsType<LegacySelectThemeIntent>(selection).ResultId);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-theme-sort\",\"payload\":{\"sortId\":\"GAIN_DESC\"}}",
out var sort,
out _));
Assert.Equal("GAIN_DESC", Assert.IsType<LegacySetThemeSortIntent>(sort).SortId);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-theme-action\",\"payload\":{\"actionId\":\"five-row-current\"}}",
out var action,
out _));
Assert.Equal(
"five-row-current",
Assert.IsType<LegacyActivateThemeActionIntent>(action).ActionId);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"search-themes\",\"payload\":{\"query\":\"AI\",\"nxtSession\":\"auto\"}}",
out _,
out _));
}
[Fact]
public void ParseExpertAndTradingHaltIntents_AreClosed()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"search-experts\",\"payload\":{\"query\":\"김\"}}",
out var expertSearch,
out _));
Assert.Equal("김", Assert.IsType<LegacySearchExpertsIntent>(expertSearch).Query);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-expert\",\"payload\":{\"selectionId\":\"expert-result-0001-0001\"}}",
out var expertSelect,
out _));
Assert.IsType<LegacySelectExpertIntent>(expertSelect);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-expert-action\",\"payload\":{\"actionId\":\"five-row-current\"}}",
out var expertAction,
out _));
Assert.IsType<LegacyActivateExpertActionIntent>(expertAction);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"search-trading-halts\",\"payload\":{\"query\":\"삼성\"}}",
out var haltSearch,
out _));
Assert.IsType<LegacySearchTradingHaltsIntent>(haltSearch);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-trading-halt\",\"payload\":{\"selectionId\":\"trading-halt-result-0001-0001\"}}",
out var haltSelect,
out _));
Assert.IsType<LegacySelectTradingHaltIntent>(haltSelect);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"toggle-trading-halt-sort\",\"payload\":{}}",
out var haltSort,
out _));
Assert.IsType<LegacyToggleTradingHaltSortIntent>(haltSort);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-trading-halt-action\",\"payload\":{\"actionId\":\"candle-120d\"}}",
out var haltAction,
out _));
Assert.IsType<LegacyActivateTradingHaltActionIntent>(haltAction);
}
[Theory]
[InlineData("up", LegacyPlaylistMoveDirection.Up)]
[InlineData("down", LegacyPlaylistMoveDirection.Down)]
public void ParsePlaylistMove_UsesStrictDirection(
string direction,
LegacyPlaylistMoveDirection expected)
{
var json = $"{{\"type\":\"move-selected-playlist-rows\",\"payload\":{{\"direction\":\"{direction}\"}}}}";
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
var move = Assert.IsType<LegacyMoveSelectedPlaylistRowsIntent>(parsed);
Assert.Equal(expected, move.Direction);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"move-selected-playlist-rows\",\"payload\":{\"direction\":\"first\"}}",
out _,
out _));
}
[Theory]
[InlineData("delete-selected-playlist-rows", typeof(LegacyDeleteSelectedPlaylistRowsIntent))]
[InlineData("delete-all-playlist-rows", typeof(LegacyDeleteAllPlaylistRowsIntent))]
public void ParsePlaylistDelete_RequiresEmptyPayload(string type, Type expectedType)
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{{}}}}",
out var parsed,
out _));
Assert.IsType(expectedType, parsed);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{{\"rowId\":\"legacy-stock-00000001\"}}}}",
out _,
out _));
}
[Fact]
public void ParseNamedPlaylistIntents_AcceptsOnlyOpaqueIdsTitlesAndEmptyCommands()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"refresh-named-playlists\",\"payload\":{}}",
out var refresh,
out _));
Assert.IsType<LegacyRefreshNamedPlaylistsIntent>(refresh);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\"}}",
out var selection,
out _));
Assert.Equal(
"named-definition-AAAAAAAAAAAB",
Assert.IsType<LegacySelectNamedPlaylistIntent>(selection).DefinitionId);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\"아침 방송 A^B\"}}",
out var create,
out _));
Assert.Equal(
"아침 방송 A^B",
Assert.IsType<LegacyCreateNamedPlaylistIntent>(create).Title);
foreach (var type in new[]
{
"load-selected-named-playlist",
"save-current-named-playlist",
"delete-selected-named-playlist"
})
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{{}}}}",
out var emptyIntent,
out _));
Assert.NotNull(emptyIntent);
}
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\",\"programCode\":\"00000001\"}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"save-current-named-playlist\",\"payload\":{\"rows\":[]}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\" padded \"}}",
out _,
out _));
}
[Theory]
[InlineData("https://legacy-parity.mbn.local/index.html", true)]
[InlineData("https://legacy-parity.mbn.local.evil/index.html", false)]
@@ -83,6 +412,94 @@ public sealed class LegacyBridgeProtocolTests
Assert.Equal(
"1열판기본_현재가",
payload.GetProperty("cutRows")[0].GetProperty("rawLabel").GetString());
Assert.False(payload.GetProperty("playlist")[0].GetProperty("isSelected").GetBoolean());
Assert.True(payload.GetProperty("isBusy").GetBoolean());
}
[Fact]
public async Task SerializedNamedPlaylist_ProjectsOnlyOpaqueListAndSafetyState()
{
var workflow = new LegacyNamedPlaylistWorkflowController(
new BridgeNamedPlaylistPersistence());
await workflow.RefreshAsync();
workflow.SelectDefinition(workflow.Current.Definitions.Single().DefinitionId);
await workflow.LoadSelectedAsync();
var snapshot = new LegacyOperatorSnapshot(
1,
string.Empty,
[],
null,
[],
[],
null,
string.Empty,
LegacyOperatorStatusKind.Neutral,
NamedPlaylist: workflow.Current);
var json = LegacyBridgeProtocol.SerializeState(snapshot);
Assert.DoesNotContain("87654321", json, StringComparison.Ordinal);
Assert.DoesNotContain("SECRET-SUBJECT", json, StringComparison.Ordinal);
Assert.DoesNotContain("SECRET-DATA", json, StringComparison.Ordinal);
Assert.DoesNotContain("programCode", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("listText", json, StringComparison.OrdinalIgnoreCase);
using var document = JsonDocument.Parse(json);
var named = document.RootElement.GetProperty("payload").GetProperty("namedPlaylist");
Assert.Equal("저녁 방송", named.GetProperty("definitions")[0]
.GetProperty("title").GetString());
Assert.Equal(1, named.GetProperty("rowCount").GetInt32());
Assert.False(named.TryGetProperty("rows", out _));
Assert.True(named.GetProperty("canLoad").GetBoolean());
}
private sealed class BridgeNamedPlaylistPersistence : INamedPlaylistPersistenceService
{
private static readonly NamedPlaylistSummary Summary = new("87654321", "저녁 방송");
public bool CanMutate => true;
public Task<NamedPlaylistListResult> ListAsync(
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
CancellationToken cancellationToken = default) =>
Task.FromResult(new NamedPlaylistListResult(
DateTimeOffset.UtcNow,
[Summary],
false));
public Task<string> SuggestNextProgramCodeAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult("87654322");
public Task<NamedPlaylistDocument> LoadAsync(
string programCode,
CancellationToken cancellationToken = default) =>
Task.FromResult(new NamedPlaylistDocument(
Summary,
DateTimeOffset.UtcNow,
[new NamedPlaylistStoredItem(
0,
true,
new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
"SECRET-GROUP",
"SECRET-SUBJECT",
"SECRET-GRAPHIC",
"SECRET-SUBTYPE",
"SECRET-DATA"),
new NamedPlaylistPageState(1, 1))]));
public Task CreateDefinitionAsync(
string programCode,
string title,
CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task ReplaceItemsAsync(
string programCode,
IReadOnlyList<NamedPlaylistStoredItem> items,
CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DeleteAsync(
string programCode,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,45 @@
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyComparisonBridgeTests
{
[Theory]
[InlineData("search-comparison-domestic", "{\"query\":\"삼성\"}", typeof(LegacySearchComparisonDomesticIntent))]
[InlineData("search-comparison-world", "{\"query\":\"APPLE\"}", typeof(LegacySearchComparisonWorldIntent))]
[InlineData("select-comparison-domestic", "{\"selectionId\":\"comparison-domestic-0001-0001\"}", typeof(LegacySelectComparisonDomesticIntent))]
[InlineData("select-comparison-world", "{\"selectionId\":\"comparison-world-0001-0001\"}", typeof(LegacySelectComparisonWorldIntent))]
[InlineData("select-comparison-fixed", "{\"targetId\":\"kospi\"}", typeof(LegacySelectComparisonFixedIntent))]
[InlineData("choose-comparison-domestic", "{\"selectionId\":\"comparison-domestic-0001-0001\"}", typeof(LegacyChooseComparisonDomesticIntent))]
[InlineData("choose-comparison-world", "{\"selectionId\":\"comparison-world-0001-0001\"}", typeof(LegacyChooseComparisonWorldIntent))]
[InlineData("choose-comparison-fixed", "{\"targetId\":\"kospi\"}", typeof(LegacyChooseComparisonFixedIntent))]
[InlineData("swap-comparison-targets", "{}", typeof(LegacySwapComparisonTargetsIntent))]
[InlineData("clear-comparison-targets", "{}", typeof(LegacyClearComparisonTargetsIntent))]
[InlineData("add-comparison-pair", "{}", typeof(LegacyAddComparisonPairIntent))]
[InlineData("select-comparison-pair", "{\"rowId\":\"comparison-pair-00000001\"}", typeof(LegacySelectComparisonPairIntent))]
[InlineData("move-comparison-pair", "{\"direction\":\"up\"}", typeof(LegacyMoveComparisonPairIntent))]
[InlineData("delete-selected-comparison-pair", "{}", typeof(LegacyDeleteSelectedComparisonPairIntent))]
[InlineData("delete-all-comparison-pairs", "{}", typeof(LegacyDeleteAllComparisonPairsIntent))]
[InlineData("activate-comparison-action", "{\"actionId\":\"comparison-candle\"}", typeof(LegacyActivateComparisonActionIntent))]
public void ComparisonIntentsAreClosed(string type, string payload, Type expected)
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{payload}}}",
out var intent,
out _));
Assert.IsType(expected, intent);
}
[Fact]
public void ComparisonIntentRejectsUnexpectedOrRawIdentityFields()
{
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"choose-comparison-domestic\",\"payload\":{\"selectionId\":\"comparison-domestic-0001-0001\",\"stockCode\":\"005930\"}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"move-comparison-pair\",\"payload\":{\"direction\":\"sideways\"}}",
out _,
out _));
}
}

View File

@@ -0,0 +1,58 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyFixedSectionBatchBridgeTests
{
[Fact]
public void Existing_fixed_section_intent_stays_strict_and_cannot_supply_resume_authority()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-fixed-section\",\"payload\":{\"sectionIndex\":6}}",
out var parsed,
out var error), error);
Assert.Equal(6, Assert.IsType<LegacyActivateFixedSectionIntent>(parsed).SectionIndex);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-fixed-section\",\"payload\":{\"sectionIndex\":6,\"resume\":true}}",
out _,
out _));
}
[Fact]
public void State_projects_only_native_fixed_section_progress_and_no_staged_rows()
{
var snapshot = new LegacyOperatorSnapshot(
1,
string.Empty,
Array.Empty<LegacyStockResultRow>(),
null,
Array.Empty<LegacyCutViewRow>(),
Array.Empty<LegacyOperatorPlaylistRow>(),
null,
"매매동향 섹션 수동 입력 1/3",
LegacyOperatorStatusKind.Information,
FixedSectionBatch: new LegacyFixedSectionBatchView(
6,
"매매동향",
15,
12,
1,
3,
"fixed-245",
"개인 순매도 상위(수동)"));
using var document = JsonDocument.Parse(LegacyBridgeProtocol.SerializeState(snapshot));
var root = document.RootElement.GetProperty("payload");
var batch = root.GetProperty("fixedSectionBatch");
Assert.Equal(6, batch.GetProperty("sectionIndex").GetInt32());
Assert.Equal(15, batch.GetProperty("totalActionCount").GetInt32());
Assert.Equal(12, batch.GetProperty("stagedActionCount").GetInt32());
Assert.Equal("fixed-245", batch.GetProperty("pendingActionId").GetString());
Assert.Empty(root.GetProperty("playlist").EnumerateArray());
Assert.DoesNotContain("selection", batch.GetRawText(), StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("sceneAlias", batch.GetRawText(), StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,235 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyManualFinancialBridgeTests
{
[Theory]
[InlineData("revenue-composition", ManualFinancialScreenKind.RevenueComposition)]
[InlineData("growth-metrics", ManualFinancialScreenKind.GrowthMetrics)]
[InlineData("sales", ManualFinancialScreenKind.Sales)]
[InlineData("operating-profit", ManualFinancialScreenKind.OperatingProfit)]
public void Open_uses_closed_GraphE_screen_identity(
string screen,
ManualFinancialScreenKind expected)
{
var json = JsonSerializer.Serialize(new
{
type = "open-manual-financial",
payload = new { screen }
});
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
Assert.Equal(expected, Assert.IsType<LegacyOpenManualFinancialIntent>(parsed).Screen);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"open-manual-financial\",\"payload\":{\"screen\":\"INPUT_SELL\"}}",
out _,
out _));
}
[Fact]
public void Save_parses_all_typed_record_shapes_without_accepting_DB_authority()
{
AssertSaveType<ManualRevenueCompositionRecord>(
"""
{"screen":"revenue-composition","stockName":"Alpha","baseDate":"2026.07","slices":[{"label":"A","percentageText":"25"},null,null,null,null]}
""");
AssertSaveType<ManualGrowthMetricsRecord>(
"""
{"screen":"growth-metrics","stockName":"Alpha","salesGrowth":[1,2,null,4],"operatingProfitGrowth":[5,6,7,8],"netAssetGrowth":[9,10,11,12],"netIncomeGrowth":[13,14,15,16],"periods":["1Q","2Q","3Q","4Q"]}
""");
AssertSaveType<ManualSalesRecord>(
"""
{"screen":"sales","stockName":"Alpha","quarters":[{"quarter":"1Q","value":1},{"quarter":"2Q","value":2},{"quarter":"3Q","value":3},{"quarter":"4Q","value":4},{"quarter":"5Q","value":5},{"quarter":"6Q","value":6}]}
""");
AssertSaveType<ManualOperatingProfitRecord>(
"""
{"screen":"operating-profit","stockName":"Alpha","quarters":[{"quarter":"1Q","value":1},{"quarter":"2Q","value":2},{"quarter":"3Q","value":3},{"quarter":"4Q","value":4},{"quarter":"5Q","value":5},{"quarter":"6Q","value":6}]}
""");
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"""
{"type":"save-manual-financial","payload":{"record":{"screen":"sales","stockName":"Alpha","rowVersion":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","quarters":[]}}}
""",
out _,
out _));
}
[Fact]
public void CRUD_and_action_intents_accept_only_opaque_ids_or_empty_payloads()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"load-manual-financial\",\"payload\":{\"resultId\":\"mf-row-0123456789abcdef\"}}",
out var loaded,
out _));
Assert.IsType<LegacyLoadManualFinancialIntent>(loaded);
foreach (var type in new[]
{
"begin-manual-financial-create",
"delete-manual-financial",
"delete-all-manual-financial",
"close-manual-financial"
})
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{{}}}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{{\"table\":\"INPUT_SELL\"}}}}",
out _,
out _));
}
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-manual-financial-action\",\"payload\":{\"actionId\":\"sales\"}}",
out var action,
out _));
Assert.Equal(
"sales",
Assert.IsType<LegacyActivateManualFinancialActionIntent>(action).ActionId);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-manual-financial-action\",\"payload\":{\"actionId\":\"s5080\"}}",
out _,
out _));
}
[Fact]
public void GraphE_find_and_name_sort_require_strict_generation_bound_payloads()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"find-manual-financial\",\"payload\":{\"query\":\"Alpha\",\"direction\":\"down\",\"generation\":7}}",
out var find,
out _));
var parsedFind = Assert.IsType<LegacyFindManualFinancialIntent>(find);
Assert.Equal(LegacyManualFinancialFindDirection.Down, parsedFind.Direction);
Assert.Equal(7, parsedFind.Generation);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"toggle-manual-financial-name-sort\",\"payload\":{\"generation\":7}}",
out var sort,
out _));
Assert.Equal(7,
Assert.IsType<LegacyToggleManualFinancialNameSortIntent>(sort).Generation);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"find-manual-financial\",\"payload\":{\"query\":\"Alpha\",\"direction\":\"sideways\",\"generation\":7}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"toggle-manual-financial-name-sort\",\"payload\":{\"generation\":7,\"table\":\"INPUT_SELL\"}}",
out _,
out _));
}
[Fact]
public async Task Projection_exposes_editor_values_but_not_rowVersion_or_DB_scene_authority()
{
var manualService = new StaticManualService();
var workflow = new LegacyManualFinancialWorkflow(
manualService,
new StaticStockService());
var controller = new LegacyOperatorController(
new EmptyStockLookup(),
manualFinancialWorkflow: workflow);
var opened = await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.Sales);
var loaded = await controller.LoadManualFinancialAsync(
Assert.Single(opened.ManualFinancial!.Rows).ResultId);
using var document = JsonDocument.Parse(LegacyBridgeProtocol.SerializeState(loaded));
var manual = document.RootElement.GetProperty("payload").GetProperty("manualFinancial");
Assert.Equal("sales", manual.GetProperty("screen").GetString());
Assert.True(manual.GetProperty("generation").GetInt64() > 0);
Assert.Equal("ascending", manual.GetProperty("nameSortDirection").GetString());
Assert.Equal("Alpha", manual.GetProperty("selectedRecord").GetProperty("stockName").GetString());
Assert.Equal("sales", manual.GetProperty("action").GetProperty("actionId").GetString());
Assert.False(manual.TryGetProperty("rowVersion", out _));
Assert.False(manual.TryGetProperty("tableName", out _));
Assert.False(manual.TryGetProperty("builderKey", out _));
Assert.False(manual.GetProperty("stockCandidates")[0].TryGetProperty("code", out _));
}
private static void AssertSaveType<TRecord>(string recordJson)
where TRecord : ManualFinancialRecord
{
var json = $"{{\"type\":\"save-manual-financial\",\"payload\":{{\"record\":{recordJson}}}}}";
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
Assert.IsType<TRecord>(Assert.IsType<LegacySaveManualFinancialIntent>(parsed).Record);
}
private sealed class EmptyStockLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class StaticStockService : IStockSearchService
{
public Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new StockSearchResult(
query,
DateTimeOffset.UtcNow,
[new StockSearchItem(
StockMarket.Kospi,
DataSourceKind.Oracle,
"Alpha",
"000001")],
false));
}
private sealed class StaticManualService : IManualFinancialScreenService
{
private static readonly ManualFinancialSnapshot Row = new(
new ManualSalesRecord(
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Alpha"),
Enumerable.Range(1, 6)
.Select(index => new ManualQuarterValue($"{index}Q", index))
.ToArray()),
new string('A', 64));
public bool CanMutate => false;
public Task<ManualFinancialSearchResult> SearchAsync(
ManualFinancialScreenKind screen,
string query = "",
int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new ManualFinancialSearchResult(
screen,
query,
DateTimeOffset.UtcNow,
screen == ManualFinancialScreenKind.Sales ? [Row] : [],
false));
public Task<ManualFinancialSnapshot> GetAsync(
ManualFinancialIdentity identity,
CancellationToken cancellationToken = default) =>
Task.FromResult(Row);
public Task<ManualFinancialMutationReceipt> CreateAsync(
ManualFinancialRecord record,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<ManualFinancialMutationReceipt> UpdateAsync(
ManualFinancialSnapshot expected,
ManualFinancialRecord replacement,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<ManualFinancialMutationReceipt> DeleteAsync(
ManualFinancialSnapshot expected,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<ManualFinancialMutationReceipt> DeleteAllAsync(
ManualFinancialScreenKind screen,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,157 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyManualListsBridgeTests
{
[Fact]
public void OriginalOkActionHasOneClosedEmptyPayloadIntent()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"confirm-manual-list\",\"payload\":{}}",
out var intent,
out _));
Assert.IsType<LegacyConfirmManualListIntent>(intent);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"confirm-manual-list\",\"payload\":{\"retry\":true}}",
out _,
out _));
}
[Theory]
[InlineData("individual", LegacyManualListScreen.NetSell, S5025ManualAudience.Individual)]
[InlineData("foreign", LegacyManualListScreen.NetSell, S5025ManualAudience.Foreign)]
[InlineData("institution", LegacyManualListScreen.NetSell, S5025ManualAudience.Institution)]
[InlineData("vi", LegacyManualListScreen.Vi, S5025ManualAudience.Individual)]
public void Open_accepts_only_closed_screen_identifiers(
string screen,
LegacyManualListScreen expectedScreen,
S5025ManualAudience expectedAudience)
{
var json = JsonSerializer.Serialize(new
{
type = "open-manual-list",
payload = new { screen }
});
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var intent, out _));
var opened = Assert.IsType<LegacyOpenManualListIntent>(intent);
Assert.Equal(expectedScreen, opened.Screen);
Assert.Equal(expectedAudience, opened.Audience);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"open-manual-list\",\"payload\":{\"screen\":\"vi\",\"path\":\"C:\\\\Data\"}}",
out _,
out _));
}
[Fact]
public void Net_cell_accepts_one_opaque_row_and_safe_value_only()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-manual-net-sell-cell\",\"payload\":{\"rowId\":\"manual-net-row-1\",\"field\":\"leftAmount\",\"value\":\"1,000\"}}",
out var intent,
out _));
var cell = Assert.IsType<LegacySetManualNetSellCellIntent>(intent);
Assert.Equal(LegacyManualNetSellField.LeftAmount, cell.Field);
foreach (var invalid in new[]
{
"{\"rowId\":\"manual-net-row-1\",\"field\":\"leftAmount\",\"value\":\"bad^value\"}",
"{\"rowId\":\"manual-net-row-1\",\"field\":\"leftAmount\",\"value\":\"1\",\"audience\":\"FOREIGN\"}",
"{\"rowId\":\"manual-net-row-1\",\"field\":\"code\",\"value\":\"005930\"}"
})
{
Assert.False(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"set-manual-net-sell-cell\",\"payload\":{invalid}}}",
out _,
out _));
}
}
[Fact]
public void VI_mutations_accept_opaque_ids_and_never_codes_items_or_versions()
{
Assert.True(Parse("select-manual-vi-result", "{\"resultId\":\"manual-vi-result-0001\"}"));
Assert.True(Parse("add-manual-vi-result", "{\"resultId\":\"manual-vi-result-0001\"}"));
Assert.True(Parse("move-manual-vi-item", "{\"itemId\":\"manual-vi-item-0001\",\"direction\":\"up\"}"));
Assert.True(Parse("delete-manual-vi-item", "{\"itemId\":\"manual-vi-item-0001\"}"));
Assert.True(Parse("save-manual-vi", "{}"));
Assert.False(Parse("save-manual-vi", "{\"version\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"));
Assert.False(Parse("add-manual-vi-result", "{\"code\":\"005930\",\"name\":\"삼성전자\"}"));
}
[Fact]
public void State_projects_display_rows_but_not_native_codes_versions_or_paths()
{
var viItems = Enumerable.Range(1, 9)
.Select(index => new LegacyManualViItemView(
$"manual-vi-item-{index}",
index,
$"VI종목{index}"))
.ToArray();
var manual = new LegacyManualListsSnapshot(
true,
LegacyManualListScreen.Vi,
S5025ManualAudience.Individual,
true,
"READY",
null,
false,
Array.Empty<LegacyManualNetSellRowView>(),
false,
viItems,
2,
true,
"삼성",
[new LegacyManualViSearchResultView(
"manual-vi-result-1",
"삼성전자",
"코스피")],
"조회 완료",
LegacyOperatorStatusKind.Information,
true,
true)
{
SelectedSearchResultId = "manual-vi-result-1"
};
var snapshot = new LegacyOperatorSnapshot(
1,
string.Empty,
Array.Empty<LegacyStockResultRow>(),
null,
Array.Empty<LegacyCutViewRow>(),
Array.Empty<LegacyOperatorPlaylistRow>(),
null,
string.Empty,
LegacyOperatorStatusKind.Neutral,
ManualLists: manual);
var json = LegacyBridgeProtocol.SerializeState(snapshot);
using var document = JsonDocument.Parse(json);
var projected = document.RootElement.GetProperty("payload").GetProperty("manualLists");
Assert.Equal(
"manual-vi-result-1",
projected.GetProperty("selectedSearchResultId").GetString());
Assert.True(projected.GetProperty("isOpen").GetBoolean());
Assert.Equal(9, projected.GetProperty("viItems").GetArrayLength());
Assert.Equal("VI종목1", projected.GetProperty("viItems")[0].GetProperty("name").GetString());
Assert.Equal(2, projected.GetProperty("viPageCount").GetInt32());
Assert.True(projected.GetProperty("viItemsAreFresh").GetBoolean());
Assert.True(projected.GetProperty("canSave").GetBoolean());
Assert.True(projected.GetProperty("canMaterializePlaylist").GetBoolean());
Assert.False(projected.TryGetProperty("rowVersion", out _));
Assert.DoesNotContain("005930", projected.GetRawText(), StringComparison.Ordinal);
Assert.DoesNotContain("path", projected.GetRawText(), StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("version", projected.GetRawText(), StringComparison.OrdinalIgnoreCase);
}
private static bool Parse(string type, string payload) =>
LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{payload}}}",
out _,
out _);
}

View File

@@ -0,0 +1,176 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyOperatorCatalogBridgeTests
{
[Theory]
[InlineData("theme", LegacyOperatorCatalogEntity.Theme)]
[InlineData("expert", LegacyOperatorCatalogEntity.Expert)]
public void Open_accepts_only_the_closed_ThemeA_or_EList_identity(
string entity,
LegacyOperatorCatalogEntity expected)
{
Assert.True(Parse(
JsonSerializer.Serialize(new
{
type = "open-operator-catalog",
payload = new { entity }
}),
out var parsed));
Assert.Equal(expected, Assert.IsType<LegacyOpenOperatorCatalogIntent>(parsed).Entity);
Assert.False(Parse(
"""
{"type":"open-operator-catalog","payload":{"entity":"SB_LIST"}}
""",
out _));
}
[Fact]
public void Catalog_selection_and_draft_changes_accept_only_opaque_ids()
{
Assert.IsType<LegacySelectOperatorCatalogResultIntent>(ParseRequired(
"select-operator-catalog-result",
"""{"resultId":"catalog-result-0123456789abcdef"}"""));
Assert.IsType<LegacySelectOperatorCatalogStockIntent>(ParseRequired(
"select-operator-catalog-stock",
"""{"resultId":"catalog-stock-0123456789abcdef"}"""));
Assert.IsType<LegacyMoveOperatorCatalogDraftRowIntent>(ParseRequired(
"move-operator-catalog-draft-row",
"""{"rowId":"catalog-draft-0123456789abcdef","direction":"up"}"""));
Assert.IsType<LegacyRemoveOperatorCatalogDraftRowIntent>(ParseRequired(
"remove-operator-catalog-draft-row",
"""{"rowId":"catalog-draft-0123456789abcdef"}"""));
Assert.False(Parse(
"""
{"type":"select-operator-catalog-result","payload":{"resultId":"catalog-result-0123456789abcdef","themeCode":"00000001"}}
""",
out _));
Assert.False(Parse(
"""
{"type":"select-operator-catalog-stock","payload":{"stockCode":"005930"}}
""",
out _));
}
[Fact]
public void Create_save_delete_and_add_are_closed_and_typed()
{
Assert.IsType<LegacyBeginCreateThemeCatalogIntent>(ParseRequired(
"begin-create-theme-catalog",
"""{"market":"nxt"}"""));
Assert.IsType<LegacyBeginCreateExpertCatalogIntent>(ParseRequired(
"begin-create-expert-catalog",
"{}"));
Assert.Null(Assert.IsType<LegacyAddOperatorCatalogStockIntent>(ParseRequired(
"add-operator-catalog-stock",
"""{"buyAmount":null}""")).BuyAmount);
Assert.Equal(70_000, Assert.IsType<LegacyAddOperatorCatalogStockIntent>(ParseRequired(
"add-operator-catalog-stock",
"""{"buyAmount":70000}""")).BuyAmount);
Assert.Equal("김전문", Assert.IsType<LegacySaveOperatorCatalogIntent>(ParseRequired(
"save-operator-catalog",
"""{"name":""}""")).Name);
Assert.IsType<LegacyDeleteOperatorCatalogIntent>(ParseRequired(
"delete-operator-catalog",
"{}"));
Assert.False(Parse(
"""
{"type":"save-operator-catalog","payload":{"name":"김전문","expertCode":"0001"}}
""",
out _));
Assert.False(Parse(
"""
{"type":"add-operator-catalog-stock","payload":{"buyAmount":1.5}}
""",
out _));
}
[Fact]
public void Projection_exposes_display_data_and_opaque_ids_without_DB_identity_authority()
{
var catalog = new LegacyOperatorCatalogWorkflowSnapshot(
4,
true,
LegacyOperatorCatalogEntity.Theme,
LegacyOperatorCatalogMode.Edit,
"AI",
ThemeSession.PreMarket,
ThemeMarket.Krx,
[
new LegacyOperatorCatalogResultView(
"catalog-result-0123456789abcdef",
"00000001",
"AI 반도체",
"KRX / Oracle",
true)
],
false,
"catalog-result-0123456789abcdef",
"00000001",
"AI 반도체",
[
new LegacyOperatorCatalogDraftRowView(
"catalog-draft-0123456789abcdef",
"삼성전자",
"KOSPI",
null,
0)
],
"삼성",
[
new LegacyOperatorCatalogStockResultView(
"catalog-stock-0123456789abcdef",
"삼성전자",
"KOSPI",
true,
true)
],
false,
"catalog-stock-0123456789abcdef",
true,
false,
false,
true,
true,
true,
"편집 중",
LegacyOperatorCatalogStatusKind.Information);
var snapshot = new LegacyOperatorSnapshot(
1,
string.Empty,
[],
null,
[],
[],
null,
string.Empty,
LegacyOperatorStatusKind.Neutral,
OperatorCatalog: catalog);
var json = LegacyBridgeProtocol.SerializeState(snapshot);
Assert.Contains("catalog-result-0123456789abcdef", json, StringComparison.Ordinal);
Assert.Contains("codeLabel", json, StringComparison.Ordinal);
Assert.DoesNotContain("themeCode", json, StringComparison.Ordinal);
Assert.DoesNotContain("expertCode", json, StringComparison.Ordinal);
Assert.DoesNotContain("stockCode", json, StringComparison.Ordinal);
Assert.DoesNotContain("itemCode", json, StringComparison.Ordinal);
Assert.DoesNotContain("expectedIdentity", json, StringComparison.Ordinal);
}
private static LegacyUiIntent ParseRequired(string type, string payload)
{
var json = "{\"type\":\"" + type + "\",\"payload\":" + payload + "}";
Assert.True(Parse(json, out var intent));
return Assert.IsAssignableFrom<LegacyUiIntent>(intent);
}
private static bool Parse(string json, out LegacyUiIntent? intent) =>
LegacyBridgeProtocol.TryParseIntent(json, out intent, out _);
}

View File

@@ -0,0 +1,122 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyOverseasBridgeTests
{
[Theory]
[InlineData(
"{\"type\":\"select-overseas-fixed-index\",\"payload\":{\"targetId\":\"nasdaq\"}}",
typeof(LegacySelectOverseasFixedIndexIntent))]
[InlineData(
"{\"type\":\"search-overseas-industries\",\"payload\":{\"query\":\"반도체\"}}",
typeof(LegacySearchOverseasIndustriesIntent))]
[InlineData(
"{\"type\":\"search-overseas-stocks\",\"payload\":{\"query\":\"Apple\"}}",
typeof(LegacySearchOverseasStocksIntent))]
[InlineData(
"{\"type\":\"select-overseas-industry\",\"payload\":{\"selectionId\":\"overseas-industry-00000001-0001\"}}",
typeof(LegacySelectOverseasIndustryIntent))]
[InlineData(
"{\"type\":\"select-overseas-stock\",\"payload\":{\"selectionId\":\"overseas-stock-00000001-0001\"}}",
typeof(LegacySelectOverseasStockIntent))]
[InlineData(
"{\"type\":\"activate-overseas-action\",\"payload\":{\"actionId\":\"stock-candle-20d\"}}",
typeof(LegacyActivateOverseasActionIntent))]
public void ParsesStrictOverseasIntents(string json, Type expectedType)
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var intent, out var error));
Assert.Null(error);
Assert.IsType(expectedType, intent);
}
[Theory]
[InlineData("{\"type\":\"select-overseas-fixed-index\",\"payload\":{\"targetId\":\"not-registered\"}}")]
[InlineData("{\"type\":\"activate-overseas-action\",\"payload\":{\"actionId\":\"8061\"}}")]
[InlineData("{\"type\":\"select-overseas-stock\",\"payload\":{\"selectionId\":\"../../stock\"}}")]
[InlineData("{\"type\":\"search-overseas-industries\",\"payload\":{\"query\":\"bad\\u0001query\"}}")]
[InlineData("{\"type\":\"search-overseas-stocks\",\"payload\":{\"query\":\"Apple\",\"symbol\":\"NAS@AAPL\"}}")]
[InlineData("{\"type\":\"activate-overseas-action\",\"payload\":{\"actionId\":12}}")]
public void RejectsUnknownMalformedAndAuthorityBearingOverseasIntents(string json)
{
Assert.False(LegacyBridgeProtocol.TryParseIntent(json, out var intent, out var error));
Assert.Null(intent);
Assert.NotNull(error);
}
[Fact]
public async Task ProjectionExposesDisplayStateAndOpaqueIdsButNoSceneAuthority()
{
var workflow = new LegacyOverseasSelectionWorkflow(
new StaticIndustryService(),
new StaticStockService());
workflow.SelectFixedIndex("nasdaq");
var industries = await workflow.SearchIndustriesAsync("반도체");
workflow.SelectIndustry(industries.Industry.Results[0].SelectionId);
var stocks = await workflow.SearchStocksAsync("Apple");
workflow.SelectStock(stocks.Stock.Results[0].SelectionId);
var snapshot = new LegacyOperatorSnapshot(
7,
string.Empty,
[],
null,
[],
[],
null,
string.Empty,
LegacyOperatorStatusKind.Neutral,
Overseas: workflow.Current);
var json = LegacyBridgeProtocol.SerializeState(snapshot);
using var document = JsonDocument.Parse(json);
var overseas = document.RootElement.GetProperty("payload").GetProperty("overseas");
Assert.Equal(10, overseas.GetProperty("fixedIndexTargets").GetArrayLength());
Assert.Equal("nasdaq", overseas.GetProperty("selectedFixedIndexId").GetString());
Assert.Equal("나스닥", overseas.GetProperty("selectedFixedIndexName").GetString());
Assert.Equal(
"overseas-industry-00000001-0001",
overseas.GetProperty("industry").GetProperty("selectedId").GetString());
Assert.Equal(
"overseas-stock-00000001-0001",
overseas.GetProperty("stock").GetProperty("selectedId").GetString());
Assert.Equal(12, overseas.GetProperty("actions").GetArrayLength());
Assert.All(
overseas.GetProperty("actions").EnumerateArray(),
action => Assert.True(action.GetProperty("isAvailable").GetBoolean()));
Assert.DoesNotContain("builderKey", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("sceneAlias", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("dataCode", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("NAS@AAPL", json, StringComparison.Ordinal);
Assert.DoesNotContain("NAS@SOX", json, StringComparison.Ordinal);
}
private sealed class StaticIndustryService : IOverseasIndustryIndexSearchService
{
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new OverseasIndustryIndexSearchResult(
query,
DateTimeOffset.Parse("2026-07-15T10:00:00+09:00"),
[new OverseasIndustryIndexItem("반도체", "US Semiconductor", "NAS@SOX")],
IsTruncated: false));
}
private sealed class StaticStockService : IWorldStockSearchService
{
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new WorldStockSearchResult(
query,
DateTimeOffset.Parse("2026-07-15T10:00:00+09:00"),
[new WorldStockSearchItem("Apple", "NAS@AAPL", "US")],
IsTruncated: false));
}
}

View File

@@ -0,0 +1,143 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyPlayoutBridgeTests
{
[Theory]
[InlineData("prepare-playout", LegacyOperatorPlayoutCommand.Prepare)]
[InlineData("take-in", LegacyOperatorPlayoutCommand.TakeIn)]
[InlineData("next-playout", LegacyOperatorPlayoutCommand.Next)]
[InlineData("take-out", LegacyOperatorPlayoutCommand.TakeOut)]
public void CommandIntents_ParseClosedEmptyPayload(
string type,
LegacyOperatorPlayoutCommand expected)
{
var parsed = LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"" + type + "\",\"payload\":{}}",
out var intent,
out var error);
Assert.True(parsed, error);
var command = Assert.IsType<LegacyExecutePlayoutIntent>(intent);
Assert.Equal(expected, command.Command);
}
[Theory]
[InlineData("prepare-playout")]
[InlineData("take-in")]
[InlineData("next-playout")]
[InlineData("take-out")]
[InlineData("toggle-background")]
[InlineData("choose-background")]
public void EmptyPlayoutIntents_RejectInjectedProperties(string type)
{
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"" + type +
"\",\"payload\":{\"scene\":\"5001\"}}",
out _,
out _));
}
[Fact]
public void CompositionIntents_ParseOnlyTrustedShape()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"""{"type":"set-fade-duration","payload":{"duration":6}}""",
out var fade,
out var fadeError), fadeError);
Assert.Equal(6, Assert.IsType<LegacySetFadeDurationIntent>(fade).Duration);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"""{"type":"toggle-background","payload":{}}""",
out var toggle,
out var toggleError), toggleError);
Assert.IsType<LegacyToggleBackgroundIntent>(toggle);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"""{"type":"choose-background","payload":{}}""",
out var choose,
out var chooseError), chooseError);
Assert.IsType<LegacyChooseBackgroundIntent>(choose);
}
[Theory]
[InlineData("-1")]
[InlineData("61")]
[InlineData("1.5")]
[InlineData("\"6\"")]
[InlineData("null")]
public void FadeDuration_RejectsNonCanonicalValues(string value)
{
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-fade-duration\",\"payload\":{\"duration\":" +
value + "}}",
out _,
out _));
}
[Fact]
public void SerializedState_ProjectsAuthoritativePlayoutState()
{
var snapshot = new LegacyOperatorSnapshot(
1,
string.Empty,
[],
null,
[],
[],
null,
string.Empty,
LegacyOperatorStatusKind.Neutral);
var playout = new LegacyOperatorPlayoutSnapshot(
Mode: PlayoutMode.DryRun,
ConnectionState: PlayoutConnectionState.DryRunReady,
Phase: LegacyOperatorPlayoutPhase.Prepared,
IsProcessRunning: true,
IsConnected: true,
IsCommandAvailable: true,
LiveTakeInAllowed: true,
IsPlayCompletionPending: false,
OutcomeUnknown: false,
PreparedCode: "5001",
OnAirCode: null,
CurrentCueIndexZeroBased: 0,
CurrentEntryId: "entry-1",
BuilderKey: "s5001",
PageIndexZeroBased: 0,
PageCount: 2,
PageSize: 5,
ItemCount: 8,
CurrentPageItemCount: 5,
IsLastPage: false,
NextKind: LegacyWorkflowNextKind.PageNext,
IsBusy: false,
RefreshActive: false,
RefreshCompletedCount: 0,
RefreshMaximumCount: 1,
RefreshLimitReached: false,
FadeDuration: 6,
BackgroundEnabled: true,
BackgroundFileName: "기본.vrv",
CanChangeBackground: false,
Message: "준비 완료");
using var document = JsonDocument.Parse(
LegacyBridgeProtocol.SerializeState(snapshot, playout: playout));
var projected = document.RootElement.GetProperty("payload").GetProperty("playout");
Assert.Equal("dryRun", projected.GetProperty("mode").GetString());
Assert.Equal("dryRunReady", projected.GetProperty("connectionState").GetString());
Assert.Equal("prepared", projected.GetProperty("phase").GetString());
Assert.Equal("5001", projected.GetProperty("preparedCode").GetString());
Assert.Equal("pageNext", projected.GetProperty("nextKind").GetString());
Assert.Equal(2, projected.GetProperty("pageCount").GetInt32());
Assert.Equal("기본.vrv", projected.GetProperty("backgroundFileName").GetString());
Assert.False(projected.TryGetProperty("currentEntryId", out _));
Assert.False(projected.TryGetProperty("builderKey", out _));
}
}

View File

@@ -0,0 +1,86 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyRowDoubleClickBridgeTests
{
[Theory]
[InlineData(
"activate-manual-financial-result",
"resultId",
"mf-row-0123456789abcdef",
typeof(LegacyActivateManualFinancialResultIntent))]
[InlineData(
"activate-theme-result",
"resultId",
"theme-result-001",
typeof(LegacyActivateThemeResultIntent))]
[InlineData(
"load-named-playlist-by-id",
"definitionId",
"named-definition-AAAAAAAAAAAB",
typeof(LegacyLoadNamedPlaylistByIdIntent))]
[InlineData(
"save-current-named-playlist-to",
"definitionId",
"named-definition-AAAAAAAAAAAB",
typeof(LegacySaveCurrentNamedPlaylistToIntent))]
public void Atomic_row_commands_accept_one_opaque_identity_only(
string command,
string property,
string value,
Type expectedType)
{
var json = JsonSerializer.Serialize(new
{
type = command,
payload = new Dictionary<string, string> { [property] = value }
});
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
Assert.IsType(expectedType, parsed);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{command}\",\"payload\":{{\"{property}\":\"{value}\",\"programCode\":\"00000001\"}}}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{command}\",\"payload\":{{\"{property}\":\"../unsafe\"}}}}",
out _,
out _));
}
[Fact]
public void Projection_exposes_only_closed_command_receipt_and_opaque_target()
{
var snapshot = new LegacyOperatorSnapshot(
1,
string.Empty,
[],
null,
[],
[],
null,
string.Empty,
LegacyOperatorStatusKind.Information,
CommandResult: new LegacyOperatorCommandResult(
7,
"load-named-playlist-by-id",
"named-definition-AAAAAAAAAAAB",
true));
using var document = JsonDocument.Parse(
LegacyBridgeProtocol.SerializeState(snapshot));
var result = document.RootElement.GetProperty("payload")
.GetProperty("commandResult");
Assert.Equal(7, result.GetProperty("sequence").GetInt64());
Assert.Equal("load-named-playlist-by-id",
result.GetProperty("command").GetString());
Assert.Equal("named-definition-AAAAAAAAAAAB",
result.GetProperty("targetId").GetString());
Assert.True(result.GetProperty("succeeded").GetBoolean());
Assert.DoesNotContain("programCode", result.GetRawText(),
StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,29 @@
using MBN_STOCK_WEBVIEW.LegacyBridge;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacySectionBatchBridgeTests
{
[Theory]
[InlineData("activate-industry-section", typeof(LegacyActivateIndustrySectionIntent))]
[InlineData("activate-comparison-section", typeof(LegacyActivateComparisonSectionIntent))]
public void SectionBatchIntentAcceptsOnlyOneBoundedOpaqueIndex(
string type,
Type expectedType)
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{{\"sectionIndex\":1}}}}",
out var intent,
out _));
Assert.IsType(expectedType, intent);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{{\"sectionIndex\":64}}}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"{type}\",\"payload\":{{\"sectionIndex\":1,\"actions\":[]}}}}",
out _,
out _));
}
}

View File

@@ -0,0 +1,36 @@
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyTabActivationBridgeTests
{
[Theory]
[InlineData("theme", LegacyOperatorTabId.Theme)]
[InlineData("expert", LegacyOperatorTabId.Expert)]
public void SelectTab_CarriesOnlyClosedTabIdentity(
string tabId,
LegacyOperatorTabId expected)
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
$"{{\"type\":\"select-tab\",\"payload\":{{\"tabId\":\"{tabId}\"}}}}",
out var parsed,
out _));
Assert.Equal(expected, Assert.IsType<LegacySelectTabIntent>(parsed).TabId);
}
[Theory]
[InlineData("theme", "query", "")]
[InlineData("theme", "nxtSession", "preMarket")]
[InlineData("expert", "query", "")]
public void SelectTab_RejectsWebSuppliedInitialLoadParameters(
string tabId,
string extraName,
string extraValue)
{
var json = "{\"type\":\"select-tab\",\"payload\":{" +
$"\"tabId\":\"{tabId}\",\"{extraName}\":\"{extraValue}\"}}}}";
Assert.False(LegacyBridgeProtocol.TryParseIntent(json, out _, out _));
}
}

View File

@@ -0,0 +1,86 @@
using System.Text.Json;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyUc4Uc6CatalogBridgeTests
{
[Theory]
[InlineData("begin-uc4-theme-catalog", typeof(LegacyBeginUc4ThemeCatalogIntent))]
[InlineData("edit-uc4-selected-theme", typeof(LegacyEditUc4SelectedThemeIntent))]
[InlineData("delete-uc4-selected-theme", typeof(LegacyDeleteUc4SelectedThemeIntent))]
[InlineData("begin-uc6-expert-catalog", typeof(LegacyBeginUc6ExpertCatalogIntent))]
[InlineData("edit-uc6-selected-expert", typeof(LegacyEditUc6SelectedExpertIntent))]
[InlineData("delete-uc6-selected-expert", typeof(LegacyDeleteUc6SelectedExpertIntent))]
public void Original_Uc4_Uc6_entries_accept_only_an_empty_payload(
string command,
Type expectedType)
{
var valid = JsonSerializer.Serialize(new
{
type = command,
payload = new { }
});
Assert.True(LegacyBridgeProtocol.TryParseIntent(
valid,
out var parsed,
out _));
Assert.IsType(expectedType, parsed);
var invalid = JsonSerializer.Serialize(new
{
type = command,
payload = new { themeCode = "00000001" }
});
Assert.False(LegacyBridgeProtocol.TryParseIntent(
invalid,
out _,
out _));
}
[Fact]
public void Recommendation_amount_edit_accepts_one_opaque_row_and_web_safe_integer()
{
var valid = JsonSerializer.Serialize(new
{
type = "set-operator-catalog-draft-buy-amount",
payload = new { rowId = "catalog-draft-0123456789abcdef", buyAmount = 71_000 }
});
Assert.True(LegacyBridgeProtocol.TryParseIntent(valid, out var parsed, out _));
var amount = Assert.IsType<LegacySetOperatorCatalogDraftBuyAmountIntent>(parsed);
Assert.Equal(71_000, amount.BuyAmount);
foreach (var invalid in new[]
{
"{\"type\":\"set-operator-catalog-draft-buy-amount\",\"payload\":{\"rowId\":\"../unsafe\",\"buyAmount\":71000}}",
"{\"type\":\"set-operator-catalog-draft-buy-amount\",\"payload\":{\"rowId\":\"catalog-draft-0123456789abcdef\",\"buyAmount\":0}}",
"{\"type\":\"set-operator-catalog-draft-buy-amount\",\"payload\":{\"rowId\":\"catalog-draft-0123456789abcdef\",\"buyAmount\":71000,\"expertCode\":\"0001\"}}"
})
{
Assert.False(LegacyBridgeProtocol.TryParseIntent(invalid, out _, out _));
}
}
[Theory]
[InlineData("null", null)]
[InlineData("71000", 71_000)]
public void Stock_double_click_accepts_only_opaque_result_and_optional_amount(
string amountJson,
int? expected)
{
var json = JsonSerializer.Serialize(new
{
type = "activate-operator-catalog-stock",
payload = new
{
resultId = "catalog-stock-0123456789abcdef",
buyAmount = amountJson == "null" ? (decimal?)null : decimal.Parse(amountJson)
}
});
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
var activation = Assert.IsType<LegacyActivateOperatorCatalogStockIntent>(parsed);
Assert.Equal(expected.HasValue ? Convert.ToDecimal(expected.Value) : null,
activation.BuyAmount);
}
}