feat: complete legacy parity and modernize operator UI

This commit is contained in:
2026-07-22 12:34:46 +09:00
parent fc4007d676
commit 939c252d23
149 changed files with 26515 additions and 1736 deletions

View File

@@ -1,3 +1,5 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
@@ -138,11 +140,29 @@ public sealed class LegacyOperatorControllerTests
"AI 테마",
"GAIN_DESC",
"CURRENT",
"00000001"),
"00000001",
"AI 테마"),
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.ToPlayoutEntry().Selection));
}
[Fact]
public async Task ThemeSearch_ReportsTheFiniteFiveThousandRowTruncationToTheOperator()
{
var controller = new LegacyOperatorController(
Lookup(),
themeWorkflow: new LegacyThemeWorkflow(new TruncatedThemeService()));
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
var searched = await controller.SearchThemesAsync("Theme");
Assert.Equal(5_000, searched.Theme!.SearchResults.Count);
Assert.True(searched.Theme.SearchIsTruncated);
Assert.Equal(LegacyOperatorStatusKind.Warning, searched.StatusKind);
Assert.Contains("5,000", searched.StatusMessage, StringComparison.Ordinal);
Assert.Contains("검색어", searched.StatusMessage, StringComparison.Ordinal);
}
[Fact]
public async Task NxtExpectedThemeAction_RemainsClickableAndShowsExactWarningWithoutAdding()
{
@@ -241,7 +261,8 @@ public sealed class LegacyOperatorControllerTests
"AI 테마(NXT)",
"PRE_MARKET",
"INPUT_ORDER",
"00000002"),
"00000002",
"AI 테마"),
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.ToPlayoutEntry().Selection));
}
@@ -283,7 +304,7 @@ public sealed class LegacyOperatorControllerTests
}
[Fact]
public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRow()
public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRowWithNullBuyAmount()
{
var controller = new LegacyOperatorController(
Lookup(),
@@ -299,6 +320,7 @@ public sealed class LegacyOperatorControllerTests
var row = Assert.Single(added.Playlist);
Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
Assert.Equal("1/2", row.PageText);
Assert.Null(added.Expert!.Preview.Items[0].BuyAmount);
Assert.Equal("전문가", row.MarketText);
}
@@ -338,7 +360,7 @@ public sealed class LegacyOperatorControllerTests
var controller = new LegacyOperatorController(Lookup());
Assert.Equal(
["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외종목", "전문가", "정지"],
["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외시장", "전문가", "거래정지"],
controller.Current.Tabs!.Select(tab => tab.Label));
Assert.Equal(
LegacyOperatorTabId.Overseas,
@@ -417,8 +439,12 @@ public sealed class LegacyOperatorControllerTests
var leaf = controller.ActivateFixedAction(first.Id);
var row = Assert.Single(leaf.Playlist);
Assert.Equal("해외지수", row.MarketText);
Assert.Equal(first.Label, row.StockName);
var expected = OriginalFixedVisibleSelection(first);
Assert.Equal(expected.GroupCode, row.MarketText);
Assert.Equal(expected.Subject, row.StockName);
Assert.Equal(expected.GraphicType, row.GraphicType);
Assert.Equal(expected.Subtype, row.Subtype);
Assert.Equal(expected.DataCode, row.StockCode);
Assert.Equal(first.Code, row.ToPlayoutEntry().CutCode);
Assert.Equal(
first.Selection.Subject,
@@ -430,10 +456,154 @@ public sealed class LegacyOperatorControllerTests
var section = await controller.ActivateFixedSection(0);
Assert.Equal(1 + firstSection.Actions.Count, section.Playlist.Count);
Assert.Equal(
firstSection.Actions.Select(action => action.Label),
firstSection.Actions.Select(action =>
OriginalFixedVisibleSelection(action).Subject),
section.Playlist.Skip(1).Select(item => item.StockName));
}
[Fact]
public async Task EveryAvailableFixedActionUsesOriginalVisibleProjectionCanonicalPlayoutAndInitialPageN()
{
var catalog = LegacyFixedActionCatalog.Default;
var localNow = new DateTimeOffset(2026, 7, 22, 10, 0, 0, TimeSpan.FromHours(9));
var pagePlans = new RecordingPagePlanProvider();
var availableActions = catalog.Actions
.Where(candidate => candidate.Available)
.ToArray();
Assert.Equal(322, availableActions.Length);
var visibleIdentities = new HashSet<
(string GroupCode, string Subject, string GraphicType, string Subtype, string DataCode)>();
foreach (var action in availableActions)
{
var controller = new LegacyOperatorController(
Lookup(),
timeProvider: new FixedTimeProvider(localNow),
fixedPagePlanProvider: pagePlans);
controller.SelectTab(action.Market switch
{
LegacyFixedMarket.Overseas => LegacyOperatorTabId.Overseas,
LegacyFixedMarket.Exchange => LegacyOperatorTabId.Exchange,
LegacyFixedMarket.Index => LegacyOperatorTabId.Index,
_ => throw new ArgumentOutOfRangeException()
});
var state = await controller.ActivateFixedActionAsync(action.Id);
var row = Assert.Single(state.Playlist);
var expectedVisible = OriginalFixedVisibleSelection(action);
Assert.Equal(expectedVisible.GroupCode, row.MarketText);
Assert.Equal(expectedVisible.Subject, row.StockName);
Assert.Equal(expectedVisible.GraphicType, row.GraphicType);
Assert.Equal(expectedVisible.Subtype, row.Subtype);
Assert.Equal(expectedVisible.DataCode, row.StockCode);
Assert.Equal(action.Code switch
{
"5074" => "1/20",
"5077" or "5088" => "1/3",
_ => "1/1"
}, row.PageText);
Assert.Equal(action.Label, row.RawCutLabel);
Assert.Equal(action.Code, row.SceneAlias);
Assert.True(
visibleIdentities.Add((
row.MarketText,
row.StockName,
row.GraphicType,
row.Subtype,
row.StockCode)),
$"Duplicate original fixed selection: {action.Id}");
var expectedPlayout = catalog.Materialize(action.Id, localNow).Selection;
var actualPlayout = Assert.IsType<
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.PlayoutSelection);
Assert.Equal(expectedPlayout.GroupCode, actualPlayout.GroupCode);
Assert.Equal(expectedPlayout.Subject, actualPlayout.Subject);
Assert.Equal(expectedPlayout.GraphicType, actualPlayout.GraphicType);
Assert.Equal(expectedPlayout.Subtype, actualPlayout.Subtype);
Assert.Equal(expectedPlayout.DataCode, actualPlayout.DataCode);
}
Assert.Equal(availableActions.Length, visibleIdentities.Count);
Assert.Equal(78, pagePlans.Entries.Count);
Assert.Equal(
78,
pagePlans.Entries.Select(entry => entry.EntryId).Distinct(StringComparer.Ordinal).Count());
}
[Fact]
public async Task FixedPagedActionPreservesOriginalOneSlashZeroForEmptyOrFailedPageRead()
{
var action = LegacyFixedActionCatalog.Default.Actions
.First(candidate => candidate.Available && candidate.Code == "5074");
var emptyPlans = new RecordingPagePlanProvider { FiveRowItemCount = 0 };
var emptyController = new LegacyOperatorController(
Lookup(),
fixedPagePlanProvider: emptyPlans);
emptyController.SelectTab(LegacyOperatorTabId.Index);
var empty = await emptyController.ActivateFixedActionAsync(action.Id);
Assert.Equal("1/0", Assert.Single(empty.Playlist).PageText);
Assert.Equal(LegacyOperatorStatusKind.Information, empty.StatusKind);
var failedPlans = new RecordingPagePlanProvider
{
Failure = new IOException("page read failed")
};
var failedController = new LegacyOperatorController(
Lookup(),
fixedPagePlanProvider: failedPlans);
failedController.SelectTab(LegacyOperatorTabId.Index);
var failed = await failedController.ActivateFixedActionAsync(action.Id);
Assert.Equal("1/0", Assert.Single(failed.Playlist).PageText);
Assert.Equal(LegacyOperatorStatusKind.Warning, failed.StatusKind);
Assert.Null(failed.Dialog);
}
[Fact]
public async Task FixedPagedActionCancellationDoesNotAppendAPlaylistRow()
{
var action = LegacyFixedActionCatalog.Default.Actions
.First(candidate => candidate.Available && candidate.Code == "5077");
var controller = new LegacyOperatorController(
Lookup(),
fixedPagePlanProvider: new RecordingPagePlanProvider());
controller.SelectTab(LegacyOperatorTabId.Index);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
controller.ActivateFixedActionAsync(action.Id, cancellation.Token));
Assert.Empty(controller.Current.Playlist);
}
[Fact]
public void Fixed8035UsesOriginalIndexFieldsWithoutChangingCanonicalSelection()
{
var controller = new LegacyOperatorController(Lookup());
controller.SelectTab(LegacyOperatorTabId.Index);
var state = controller.ActivateFixedAction("fixed-136");
var row = Assert.Single(state.Playlist);
Assert.Equal("코스피", row.MarketText);
Assert.Equal("지수", row.StockName);
Assert.Equal("캔들그래프", row.GraphicType);
Assert.Equal("일봉", row.Subtype);
Assert.Equal(string.Empty, row.StockCode);
Assert.Equal("8035", row.SceneAlias);
var selection = Assert.IsType<
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.PlayoutSelection);
Assert.Equal("KOSPI_INDEX", selection.GroupCode);
Assert.Equal("INDEX", selection.Subject);
Assert.Equal(string.Empty, selection.GraphicType);
Assert.Equal("PRICE", selection.Subtype);
Assert.Equal(string.Empty, selection.DataCode);
}
[Fact]
public void FixedCatalogRejectsCrossTabAndUnavailableActionsAtomically()
{
@@ -949,12 +1119,27 @@ public sealed class LegacyOperatorControllerTests
control: false,
shift: false);
var empty = controller.DeleteAllPlaylistRows();
var requested = controller.DeleteAllPlaylistRows();
Assert.Equal("송출 리스트를 모두 삭제하겠습니까?", requested.Dialog?.Message);
Assert.Equal("MmoneyCoder", requested.Dialog?.Caption);
Assert.True(requested.Dialog?.IsConfirmation);
Assert.Equal(3, requested.Playlist.Count);
var canceled = controller.CancelDialog();
Assert.Null(canceled.Dialog);
Assert.Equal(3, canceled.Playlist.Count);
controller.DeleteAllPlaylistRows();
var empty = await controller.ConfirmDialogAsync();
Assert.Null(empty.Dialog);
Assert.Empty(empty.Playlist);
var unchangedRevision = empty.Revision;
var stillEmpty = controller.DeleteAllPlaylistRows();
Assert.Equal(unchangedRevision, stillEmpty.Revision);
var requestedEmpty = controller.DeleteAllPlaylistRows();
Assert.True(requestedEmpty.Dialog?.IsConfirmation);
var stillEmpty = await controller.ConfirmDialogAsync();
Assert.Empty(stillEmpty.Playlist);
Assert.True(stillEmpty.Revision > unchangedRevision);
}
[Fact]
@@ -962,6 +1147,7 @@ public sealed class LegacyOperatorControllerTests
{
var controller = await ControllerWithPlaylistAsync(0, 1);
controller.DeleteAllPlaylistRows();
await controller.ConfirmDialogAsync();
controller.CutPointerDown(2, false, false, 1, 47, 10_000);
var state = controller.DropSelectedCutsOnPlaylist();
@@ -1082,6 +1268,61 @@ public sealed class LegacyOperatorControllerTests
Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
}
private static LegacyFixedSelection OriginalFixedVisibleSelection(
LegacyFixedAction action)
{
var separator = action.Label.IndexOf('_');
var first = separator < 0 ? action.Label : action.Label[..separator];
var remainder = separator < 0
? string.Empty
: action.Label[(separator + 1)..];
if (action.Market is LegacyFixedMarket.Overseas or LegacyFixedMarket.Exchange)
{
return new LegacyFixedSelection(
action.Market == LegacyFixedMarket.Overseas ? "해외지수" : "환율",
first,
action.Section,
remainder,
string.Empty);
}
var indexGroup = action.Label.Contains("코스피_NXT", StringComparison.Ordinal)
? "코스피_NXT"
: action.Label.Contains("코스닥_NXT", StringComparison.Ordinal)
? "코스닥_NXT"
: action.Label.Contains("코스피", StringComparison.Ordinal)
? "코스피"
: action.Label.Contains("코스닥", StringComparison.Ordinal)
? "코스닥"
: "전체";
if (action.Section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
{
return new LegacyFixedSelection(
indexGroup,
"지수",
action.Section,
action.Label,
string.Empty);
}
if (string.Equals(action.Section, "매매동향", StringComparison.Ordinal))
{
return new LegacyFixedSelection(
indexGroup,
"지수",
first,
remainder,
string.Empty);
}
return new LegacyFixedSelection(
action.Section,
"지수",
first,
remainder,
string.Empty);
}
private static async Task<LegacyOperatorController> ControllerWithPlaylistAsync(
params int[] physicalCutRows)
{
@@ -1114,6 +1355,49 @@ public sealed class LegacyOperatorControllerTests
new LegacyStockIdentity(LegacyDomesticStockMarket.NxtKospi, "넥스트(NXT)", "A00001")
]));
private sealed class RecordingPagePlanProvider : ILegacyScenePagePlanProvider
{
public List<LegacyPlaylistEntry> Entries { get; } = [];
public int FiveRowItemCount { get; init; } = 101;
public int SixRowItemCount { get; init; } = 13;
public int TwelveRowItemCount { get; init; } = 25;
public Exception? Failure { get; init; }
public Task<LegacyScenePagePlan> CreatePagePlanAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Entries.Add(entry);
if (Failure is not null)
{
return Task.FromException<LegacyScenePagePlan>(Failure);
}
var (builderKey, pageSize, itemCount) = entry.CutCode switch
{
"5074" => ("s5074", ScenePageSize.Five, FiveRowItemCount),
"5077" => ("s5077", ScenePageSize.Six, SixRowItemCount),
"5088" => ("s5088", ScenePageSize.Twelve, TwelveRowItemCount),
_ => throw new InvalidOperationException(
"Only a paged fixed action may request an initial page plan.")
};
var plan = Assert.IsType<ScenePagingPlan>(
ScenePaging.CreatePlan(itemCount, pageSize).Plan);
return Task.FromResult(new LegacyScenePagePlan(
builderKey,
itemCount,
pageSize,
plan.TotalPages,
plan.AccessibleItemCount,
plan.ExcludedItemCount > 0));
}
}
private sealed class FakeLookup : ILegacyStockLookup
{
private readonly LegacyStockSearchData _result;
@@ -1216,6 +1500,43 @@ public sealed class LegacyOperatorControllerTests
}
}
private sealed class TruncatedThemeService : IThemeSelectionService
{
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
Assert.Equal(5_000, maximumResults);
var rows = Enumerable.Range(0, maximumResults)
.Select(index => new ThemeSelectorItem(
new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
index.ToString("D8", System.Globalization.CultureInfo.InvariantCulture),
$"Theme {index}"),
DataSourceKind.Oracle))
.ToArray();
return Task.FromResult(new ThemeSearchResult(
query,
nxtSession,
DateTimeOffset.UtcNow,
rows,
IsTruncated: true));
}
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) =>
Task.FromResult(new ThemePreviewResult(
identity,
DateTimeOffset.UtcNow,
[],
IsTruncated: false));
}
private sealed class StaticExpertService : IExpertSelectionService
{
private static readonly ExpertSelectionIdentity Identity = new("0001", "전문가A");
@@ -1242,7 +1563,7 @@ public sealed class LegacyOperatorControllerTests
index,
index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture),
$"추천{index}",
10_000m + index))
index == 1 ? null : 10_000m + index))
.ToArray(),
false));
}