feat: advance legacy UI behavior parity

This commit is contained in:
2026-07-18 10:18:29 +09:00
parent d7ec571343
commit 1302b1b92f
71 changed files with 11244 additions and 1153 deletions

View File

@@ -295,7 +295,7 @@ public sealed class LegacyBridgeProtocolTests
}
[Fact]
public void ParseIndustryIntents_AcceptsOnlyIndexAndOpaqueActionId()
public void ParseIndustryIntents_AcceptsOnlyClosedSelectionTextAndOpaqueActionId()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-industry\",\"payload\":{\"index\":2}}",
@@ -325,22 +325,53 @@ public sealed class LegacyBridgeProtocolTests
out _));
Assert.IsType<LegacySwapIndustriesIntent>(swap);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-industry-comparison-text\",\"payload\":{\"slot\":\"first\",\"value\":\"직접 입력_%\"}}",
out var edit,
out _));
var editIntent = Assert.IsType<LegacySetIndustryComparisonTextIntent>(edit);
Assert.Equal(LegacyIndustryComparisonSlot.First, editIntent.Slot);
Assert.Equal("직접 입력_%", editIntent.Value);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-industry-comparison-text\",\"payload\":{\"slot\":\"second\",\"value\":\"\"}}",
out var clear,
out _));
Assert.Equal(string.Empty,
Assert.IsType<LegacySetIndustryComparisonTextIntent>(clear).Value);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"activate-industry-action\",\"payload\":{\"actionId\":\"../5001\"}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-industry-comparison-text\",\"payload\":{\"slot\":\"third\",\"value\":\"업종\"}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-industry-comparison-text\",\"payload\":{\"slot\":\"first\",\"value\":\"업종\\n이름\"}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"set-industry-comparison-text\",\"payload\":{\"slot\":\"first\",\"value\":\"업종\",\"actionId\":\"two-column\"}}",
out _,
out _));
var oversized = new string('가',
LegacyIndustrySelectionWorkflow.MaximumComparisonTextLength + 1);
var oversizedJson = "{\"type\":\"set-industry-comparison-text\",\"payload\":{\"slot\":\"first\",\"value\":" +
System.Text.Json.JsonSerializer.Serialize(oversized) + "}}";
Assert.False(LegacyBridgeProtocol.TryParseIntent(oversizedJson, out _, out _));
}
[Fact]
public void ParseThemeIntents_UsesClosedSessionSortAndOpaqueIds()
public void ParseThemeIntents_UsesQueryOnlySearchSortAndOpaqueIds()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"search-themes\",\"payload\":{\"query\":\"AI\",\"nxtSession\":\"preMarket\"}}",
"{\"type\":\"search-themes\",\"payload\":{\"query\":\"AI\"}}",
out var search,
out _));
Assert.Equal(
ThemeSession.PreMarket,
Assert.IsType<LegacySearchThemesIntent>(search).NxtSession);
Assert.Equal("AI", Assert.IsType<LegacySearchThemesIntent>(search).Query);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-theme\",\"payload\":{\"resultId\":\"theme-result-001\"}}",
@@ -365,7 +396,7 @@ public sealed class LegacyBridgeProtocolTests
Assert.IsType<LegacyActivateThemeActionIntent>(action).ActionId);
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"search-themes\",\"payload\":{\"query\":\"AI\",\"nxtSession\":\"auto\"}}",
"{\"type\":\"search-themes\",\"payload\":{\"query\":\"AI\",\"nxtSession\":\"preMarket\"}}",
out _,
out _));
}
@@ -531,6 +562,41 @@ public sealed class LegacyBridgeProtocolTests
Assert.Equal(expected, LegacyBridgeProtocol.IsTrustedSource(source));
}
[Fact]
public void SerializedIndustryStateProjectsAuthoritativeEditedTextNotTypedIdentityName()
{
var identity = new IndustrySelection(IndustryMarket.Kospi, "DB 업종명", "013");
var industry = new LegacyIndustrySelectionSnapshot(
3,
IndustryMarket.Kospi,
[identity],
LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kospi),
0,
identity,
identity,
null,
"직접 입력 첫째",
"직접 입력 둘째");
var snapshot = new LegacyOperatorSnapshot(
1,
string.Empty,
[],
null,
[],
[],
null,
string.Empty,
LegacyOperatorStatusKind.Neutral,
Industry: industry);
using var document = JsonDocument.Parse(LegacyBridgeProtocol.SerializeState(snapshot));
var projected = document.RootElement.GetProperty("payload").GetProperty("industry");
Assert.Equal("직접 입력 첫째", projected.GetProperty("firstName").GetString());
Assert.Equal("직접 입력 둘째", projected.GetProperty("secondName").GetString());
Assert.NotEqual("DB 업종명", projected.GetProperty("firstName").GetString());
}
[Fact]
public void SerializedState_DoesNotExposeHiddenDbOrSceneAuthority()
{

View File

@@ -48,6 +48,15 @@ public sealed class LegacyOperatorCatalogBridgeTests
Assert.IsType<LegacyRemoveOperatorCatalogDraftRowIntent>(ParseRequired(
"remove-operator-catalog-draft-row",
"""{"rowId":"catalog-draft-0123456789abcdef"}"""));
Assert.IsType<LegacySelectOperatorCatalogDraftRowIntent>(ParseRequired(
"select-operator-catalog-draft-row",
"""{"rowId":"catalog-draft-0123456789abcdef"}"""));
Assert.IsType<LegacyRemoveActiveOperatorCatalogDraftRowIntent>(ParseRequired(
"remove-active-operator-catalog-draft-row",
"{}"));
Assert.IsType<LegacyClearOperatorCatalogDraftRowsIntent>(ParseRequired(
"clear-operator-catalog-draft-rows",
"{}"));
Assert.False(Parse(
"""
@@ -64,10 +73,25 @@ public sealed class LegacyOperatorCatalogBridgeTests
{"type":"reorder-operator-catalog-draft-row","payload":{"sourceRowId":"catalog-draft-0123456789abcdef","targetRowId":"catalog-draft-fedcba9876543210","position":"after","playIndex":1}}
""",
out _));
Assert.False(Parse(
"""
{"type":"select-operator-catalog-draft-row","payload":{"rowId":"catalog-draft-0123456789abcdef","stockCode":"005930"}}
""",
out _));
Assert.False(Parse(
"""
{"type":"remove-active-operator-catalog-draft-row","payload":{"rowId":"catalog-draft-0123456789abcdef"}}
""",
out _));
Assert.False(Parse(
"""
{"type":"clear-operator-catalog-draft-rows","payload":{"themeCode":"00000001"}}
""",
out _));
}
[Fact]
public void Create_save_delete_and_add_are_closed_and_typed()
public void Create_save_and_add_are_closed_and_typed_while_child_delete_is_removed()
{
Assert.IsType<LegacyBeginCreateThemeCatalogIntent>(ParseRequired(
"begin-create-theme-catalog",
@@ -88,9 +112,14 @@ public sealed class LegacyOperatorCatalogBridgeTests
Assert.Equal("김전문", Assert.IsType<LegacySaveOperatorCatalogIntent>(ParseRequired(
"save-operator-catalog",
"""{"name":""}""")).Name);
Assert.IsType<LegacyDeleteOperatorCatalogIntent>(ParseRequired(
"delete-operator-catalog",
"{}"));
Assert.Equal(string.Empty, Assert.IsType<LegacySaveOperatorCatalogIntent>(ParseRequired(
"save-operator-catalog",
"""{"name":""}""")).Name);
Assert.False(Parse(
"""
{"type":"delete-operator-catalog","payload":{}}
""",
out _));
Assert.False(Parse(
"""
@@ -114,6 +143,77 @@ public sealed class LegacyOperatorCatalogBridgeTests
out _));
}
[Fact]
public void Confirmation_commands_accept_only_an_empty_payload_and_never_accept_trusted_identity()
{
Assert.IsType<LegacyConfirmNativeDialogIntent>(ParseRequired(
"confirm-native-dialog",
"{}"));
Assert.IsType<LegacyCancelNativeDialogIntent>(ParseRequired(
"cancel-native-dialog",
"{}"));
Assert.False(Parse(
"""{"type":"confirm-operator-catalog-confirmation","payload":{}}""",
out _));
Assert.False(Parse(
"""{"type":"cancel-operator-catalog-confirmation","payload":{}}""",
out _));
foreach (var type in new[]
{
"confirm-native-dialog",
"cancel-native-dialog"
})
{
foreach (var payload in new[]
{
"""{"themeCode":"00000001"}""",
"""{"expertCode":"0001"}""",
"""{"rowId":"catalog-draft-0123456789abcdef"}""",
"""{"comparisonPairId":"comparison-pair-00000001"}""",
"""{"expectedIdentity":{"code":"00000001"}}"""
})
{
Assert.False(Parse(
"{\"type\":\"" + type + "\",\"payload\":" + payload + "}",
out _));
}
}
}
[Fact]
public void Confirmation_dialog_projection_exposes_caption_and_kind_without_trusted_identity()
{
var snapshot = new LegacyOperatorSnapshot(
7,
string.Empty,
[],
null,
[],
[],
new LegacyDialogState(
"선택한 테마를 삭제하겠습니까?",
"MmoneyCoder",
IsConfirmation: true),
string.Empty,
LegacyOperatorStatusKind.Neutral);
using var document = JsonDocument.Parse(LegacyBridgeProtocol.SerializeState(snapshot));
var dialog = document.RootElement.GetProperty("payload").GetProperty("dialog");
Assert.Equal("선택한 테마를 삭제하겠습니까?", dialog.GetProperty("message").GetString());
Assert.Equal("MmoneyCoder", dialog.GetProperty("caption").GetString());
Assert.True(dialog.GetProperty("isConfirmation").GetBoolean());
Assert.Equal(3, dialog.EnumerateObject().Count());
foreach (var forbidden in new[]
{
"themeCode", "expertCode", "stockCode", "rowId", "expectedIdentity"
})
{
Assert.False(dialog.TryGetProperty(forbidden, out _));
}
}
[Fact]
public void Projection_exposes_display_data_and_opaque_ids_without_DB_identity_authority()
{
@@ -147,6 +247,7 @@ public sealed class LegacyOperatorCatalogBridgeTests
null,
0)
],
"catalog-draft-0123456789abcdef",
"삼성",
[
new LegacyOperatorCatalogStockResultView(
@@ -186,6 +287,7 @@ public sealed class LegacyOperatorCatalogBridgeTests
Assert.Contains("themeNameAvailability", json, StringComparison.Ordinal);
Assert.Contains("checkedThemeName", json, StringComparison.Ordinal);
Assert.Contains("canCheckThemeName", json, StringComparison.Ordinal);
Assert.Contains("activeDraftRowId", json, StringComparison.Ordinal);
Assert.Contains("duplicate", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("themeCode", json, StringComparison.Ordinal);
Assert.DoesNotContain("expertCode", json, StringComparison.Ordinal);

View File

@@ -90,10 +90,59 @@ public sealed class LegacyOverseasBridgeTests
Assert.DoesNotContain("builderKey", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("sceneAlias", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("dataCode", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("nationCode", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("NAS@AAPL", json, StringComparison.Ordinal);
Assert.DoesNotContain("NAS@SOX", json, StringComparison.Ordinal);
}
[Fact]
public async Task ProjectionKeepsNonUsTwRowOpaqueAndReportsItAvailable()
{
var workflow = new LegacyOverseasSelectionWorkflow(
new StaticIndustryService(),
new StaticStockService([new WorldStockSearchItem("London Stock", "LNS@LON", "GB")]));
var searched = await workflow.SearchStocksAsync("London");
workflow.SelectStock(Assert.Single(searched.Stock.Results).SelectionId);
var snapshot = new LegacyOperatorSnapshot(
7, string.Empty, [], null, [], [], null, string.Empty,
LegacyOperatorStatusKind.Neutral, Overseas: workflow.Current);
using var document = JsonDocument.Parse(LegacyBridgeProtocol.SerializeState(snapshot));
var overseas = document.RootElement.GetProperty("payload").GetProperty("overseas");
var result = Assert.Single(overseas.GetProperty("stock").GetProperty("results").EnumerateArray());
Assert.Equal("London Stock", result.GetProperty("displayName").GetString());
Assert.False(result.TryGetProperty("nationCode", out _));
var action = overseas.GetProperty("actions").EnumerateArray().Single(value =>
value.GetProperty("actionId").GetString() == "stock-current");
Assert.True(action.GetProperty("isAvailable").GetBoolean());
Assert.Equal(JsonValueKind.Null, action.GetProperty("reason").ValueKind);
Assert.DoesNotContain("LNS@LON", document.RootElement.GetRawText(), StringComparison.Ordinal);
}
[Fact]
public async Task ProjectionKeepsThreeLetterNationOpaqueAndFailsClosed()
{
var workflow = new LegacyOverseasSelectionWorkflow(
new StaticIndustryService(),
new StaticStockService([new WorldStockSearchItem("Unsupported", "LNS@UNSUPPORTED", "GBR")]));
var searched = await workflow.SearchStocksAsync("unsupported");
workflow.SelectStock(Assert.Single(searched.Stock.Results).SelectionId);
var snapshot = new LegacyOperatorSnapshot(
7, string.Empty, [], null, [], [], null, string.Empty,
LegacyOperatorStatusKind.Neutral, Overseas: workflow.Current);
using var document = JsonDocument.Parse(LegacyBridgeProtocol.SerializeState(snapshot));
var overseas = document.RootElement.GetProperty("payload").GetProperty("overseas");
var action = overseas.GetProperty("actions").EnumerateArray().Single(value =>
value.GetProperty("actionId").GetString() == "stock-current");
Assert.False(action.GetProperty("isAvailable").GetBoolean());
Assert.Equal("선택한 해외종목의 국가 코드는 두 글자여야 합니다.",
action.GetProperty("reason").GetString());
Assert.DoesNotContain("GBR", document.RootElement.GetRawText(), StringComparison.Ordinal);
Assert.DoesNotContain("LNS@UNSUPPORTED", document.RootElement.GetRawText(), StringComparison.Ordinal);
}
private sealed class StaticIndustryService : IOverseasIndustryIndexSearchService
{
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
@@ -107,16 +156,17 @@ public sealed class LegacyOverseasBridgeTests
IsTruncated: false));
}
private sealed class StaticStockService : IWorldStockSearchService
private sealed class StaticStockService(
IReadOnlyList<WorldStockSearchItem>? items = null) : IOverseasStockSearchService
{
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new WorldStockSearchResult(
query,
DateTimeOffset.Parse("2026-07-15T10:00:00+09:00"),
[new WorldStockSearchItem("Apple", "NAS@AAPL", "US")],
items ?? [new WorldStockSearchItem("Apple", "NAS@AAPL", "US")],
IsTruncated: false));
}
}

View File

@@ -142,7 +142,12 @@ public sealed class LegacyPlayoutBridgeTests
CanChangeBackground: false,
Message: "준비 완료")
{
IsTakeOutCompletionPending = true
IsTakeOutCompletionPending = true,
RefreshNextAtUtc = new DateTimeOffset(
2026, 7, 17, 2, 3, 4, TimeSpan.Zero),
RefreshLastSuccessAtUtc = new DateTimeOffset(
2026, 7, 17, 1, 2, 3, TimeSpan.Zero),
RefreshFaultCode = "DATABASE_UNAVAILABLE"
};
using var document = JsonDocument.Parse(
@@ -158,6 +163,15 @@ public sealed class LegacyPlayoutBridgeTests
Assert.Equal("기본.vrv", projected.GetProperty("backgroundFileName").GetString());
Assert.Equal("entry-1", projected.GetProperty("currentEntryId").GetString());
Assert.True(projected.GetProperty("isTakeOutCompletionPending").GetBoolean());
Assert.Equal(
new DateTimeOffset(2026, 7, 17, 2, 3, 4, TimeSpan.Zero),
projected.GetProperty("refreshNextAtUtc").GetDateTimeOffset());
Assert.Equal(
new DateTimeOffset(2026, 7, 17, 1, 2, 3, TimeSpan.Zero),
projected.GetProperty("refreshLastSuccessAtUtc").GetDateTimeOffset());
Assert.Equal(
"DATABASE_UNAVAILABLE",
projected.GetProperty("refreshFaultCode").GetString());
Assert.False(projected.TryGetProperty("builderKey", out _));
}
}