using MBN_STOCK_WEBVIEW.LegacyApplication; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; public sealed class LegacyOperatorControllerTests { [Fact] public async Task IndustryTabLoadsSelectorRotatesPairAndAddsTrustedActions() { var industries = Enumerable.Range(1, 11) .Select(index => new IndustrySelection( IndustryMarket.Kospi, $"업종{index}", index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture))) .ToArray(); var workflow = new LegacyIndustrySelectionWorkflow( new StaticIndustryService(industries)); var controller = new LegacyOperatorController( Lookup(), industryWorkflow: workflow); var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry); Assert.Equal(11, loaded.Industry?.Industries.Count); controller.SelectIndustry(0, doubleClick: true); var pair = controller.SelectIndustry(1, doubleClick: true); Assert.Equal("업종2", pair.Industry?.FirstIndustry?.IndustryName); Assert.Equal("업종1", pair.Industry?.SecondIndustry?.IndustryName); var twoColumn = controller.ActivateIndustryAction("two-column"); var pairRow = Assert.Single(twoColumn.Playlist); Assert.Equal("8018", pairRow.ToPlayoutEntry().CutCode); Assert.Equal("업종2-업종1", pairRow.StockName); var paged = controller.ActivateIndustryAction("five-row"); Assert.Equal("1/3", paged.Playlist[1].PageText); Assert.Equal("5074", paged.Playlist[1].ToPlayoutEntry().CutCode); } [Fact] public async Task IndustryComparisonEditChangesOnlyTwoColumnSubjectAndNeverMutatesPlaylistByItself() { var industries = new[] { new IndustrySelection(IndustryMarket.Kospi, "전기전자", "013") }; var controller = new LegacyOperatorController( Lookup(), industryWorkflow: new LegacyIndustrySelectionWorkflow( new StaticIndustryService(industries))); await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry); controller.SelectIndustry(0, doubleClick: false); var firstEdit = controller.SetIndustryComparisonText( LegacyIndustryComparisonSlot.First, "직접 첫째"); var secondEdit = controller.SetIndustryComparisonText( LegacyIndustryComparisonSlot.Second, "직접 둘째"); Assert.Empty(firstEdit.Playlist); Assert.Empty(secondEdit.Playlist); Assert.Equal("전기전자", controller.ActivateIndustryAction("one-column").Playlist[0].StockName); var pair = controller.ActivateIndustryAction("two-column"); var pairRow = pair.Playlist[^1]; Assert.Equal("직접 첫째-직접 둘째", pairRow.StockName); Assert.Equal("직접 첫째-직접 둘째", pairRow.PlayoutSelection?.Subject); } [Fact] public async Task IndustryRootDoubleClickMatchesLegacySilentNoOpWhileChildStillAdds() { var industries = Enumerable.Range(1, 3) .Select(index => new IndustrySelection( IndustryMarket.Kospi, $"업종{index}", index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture))) .ToArray(); var controller = new LegacyOperatorController( Lookup(), industryWorkflow: new LegacyIndustrySelectionWorkflow( new StaticIndustryService(industries))); await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry); controller.SelectIndustry(0, doubleClick: true); controller.SelectIndustry(1, doubleClick: true); var rootDoubleClick = controller.ActivateIndustrySection(0); Assert.Empty(rootDoubleClick.Playlist); Assert.Null(rootDoubleClick.Dialog); var childDoubleClick = controller.ActivateIndustryAction("one-column"); Assert.Equal("5001", Assert.Single(childDoubleClick.Playlist).SceneAlias); } [Fact] public async Task ThemeTabSearchPreviewSortAndActionRemainCSharpAuthoritative() { var identity = new ThemeSelectionIdentity( ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", "AI 테마"); var service = new StaticThemeService( identity, Enumerable.Range(0, 13) .Select(index => new ThemeItemPreview( index, $"P{index + 1:000000}", $"종목{index + 1}")) .ToArray()); var controller = new LegacyOperatorController( Lookup(), themeWorkflow: new LegacyThemeWorkflow(service)); await controller.SelectTabAsync(LegacyOperatorTabId.Theme); var searched = await controller.SearchThemesAsync("AI", ThemeSession.PreMarket); var resultId = Assert.Single(searched.Theme!.SearchResults).ResultId; var selected = await controller.SelectThemeAsync(resultId); Assert.Equal(13, selected.Theme?.PreviewItems.Count); controller.SetThemeSort("GAIN_DESC"); var added = controller.ActivateThemeAction("five-row-current"); var row = Assert.Single(added.Playlist); Assert.Equal("5074", row.ToPlayoutEntry().CutCode); Assert.Equal("1/3", row.PageText); Assert.Equal("테마", row.MarketText); Assert.Equal("AI 테마", row.StockName); Assert.Equal("5단 표그래프", row.GraphicType); Assert.Equal("테마-현재가(상승률순)", row.Subtype); Assert.Equal("테마-현재가(상승률순)", row.RawCutLabel); Assert.Equal( new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( "PAGED_THEME", "AI 테마", "GAIN_DESC", "CURRENT", "00000001"), Assert.IsType( row.ToPlayoutEntry().Selection)); } [Fact] public async Task NxtExpectedThemeAction_RemainsClickableAndShowsExactWarningWithoutAdding() { var identity = new ThemeSelectionIdentity( ThemeMarket.Nxt, ThemeSession.PreMarket, "00000002", "AI 테마"); var service = new StaticThemeService( identity, [new ThemeItemPreview(0, "X005930", "삼성전자")]); var controller = new LegacyOperatorController( Lookup(), timeProvider: new FixedTimeProvider(new DateTimeOffset( 2026, 7, 18, 10, 0, 0, TimeSpan.FromHours(9))), themeWorkflow: new LegacyThemeWorkflow( service, () => new DateTimeOffset(2026, 7, 18, 10, 0, 0, TimeSpan.FromHours(9)))); await controller.SelectTabAsync(LegacyOperatorTabId.Theme); var searched = await controller.SearchThemesAsync("AI", ThemeSession.AfterMarket); Assert.Equal( [ThemeSession.PreMarket, ThemeSession.PreMarket], service.SearchSessions); var resultId = Assert.Single(searched.Theme!.SearchResults).ResultId; var selected = await controller.SelectThemeAsync(resultId); Assert.True(Assert.Single( selected.Theme!.ActionStates, state => state.ActionId == "five-row-expected").IsAvailable); var warned = controller.ActivateThemeAction("five-row-expected"); Assert.Empty(warned.Playlist); Assert.Equal("NXT는 데이터가 없습니다.", warned.Dialog?.Message); } [Theory] [InlineData(0, ThemeSession.PreMarket)] [InlineData(12, ThemeSession.PreMarket)] [InlineData(13, ThemeSession.AfterMarket)] [InlineData(23, ThemeSession.AfterMarket)] public async Task ThemeActivationAndSearch_DeriveNxtSessionFromControllerLocalTime( int localHour, ThemeSession expectedSession) { var identity = new ThemeSelectionIdentity( ThemeMarket.Nxt, ThemeSession.PreMarket, "00000002", "AI 테마"); var service = new StaticThemeService( identity, [new ThemeItemPreview(0, "X005930", "삼성전자")]); var controller = new LegacyOperatorController( Lookup(), timeProvider: new FixedTimeProvider(new DateTimeOffset( 2026, 7, 18, localHour, 0, 0, TimeSpan.FromHours(9))), themeWorkflow: new LegacyThemeWorkflow(service)); await controller.SelectTabAsync(LegacyOperatorTabId.Theme); await controller.SearchThemesAsync("AI"); Assert.Equal([expectedSession, expectedSession], service.SearchSessions); } [Fact] public async Task NxtCurrentThemeAction_ProjectsOriginalVisibleColumnsAndKeepsTypedSelection() { var identity = new ThemeSelectionIdentity( ThemeMarket.Nxt, ThemeSession.PreMarket, "00000002", "AI 테마"); var controller = new LegacyOperatorController( Lookup(), timeProvider: new FixedTimeProvider(new DateTimeOffset( 2026, 7, 18, 10, 0, 0, TimeSpan.FromHours(9))), themeWorkflow: new LegacyThemeWorkflow( new StaticThemeService( identity, [new ThemeItemPreview(0, "X005930", "삼성전자")]), () => new DateTimeOffset(2026, 7, 18, 10, 0, 0, TimeSpan.FromHours(9)))); var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.Theme); await controller.SelectThemeAsync(Assert.Single(loaded.Theme!.SearchResults).ResultId); var added = controller.ActivateThemeAction("six-row-current"); var row = Assert.Single(added.Playlist); Assert.Equal("테마_NXT", row.MarketText); Assert.Equal("AI 테마(NXT)", row.StockName); Assert.Equal("6종목 현재가", row.GraphicType); Assert.Equal("테마-현재가(입력순)", row.Subtype); Assert.Equal("테마-현재가(입력순)", row.RawCutLabel); Assert.Equal( new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( "PAGED_NXT_THEME", "AI 테마(NXT)", "PRE_MARKET", "INPUT_ORDER", "00000002"), Assert.IsType( row.ToPlayoutEntry().Selection)); } [Fact] public async Task Theme_result_double_click_awaits_preview_and_adds_fixed_current_action_once() { var identity = new ThemeSelectionIdentity( ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", "AI theme"); var service = new StaticThemeService( identity, Enumerable.Range(0, 7) .Select(index => new ThemeItemPreview( index, $"P{index + 1:000000}", $"Stock {index + 1}")) .ToArray()); var controller = new LegacyOperatorController( Lookup(), themeWorkflow: new LegacyThemeWorkflow(service)); var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.Theme); var resultId = Assert.Single(loaded.Theme!.SearchResults).ResultId; var activated = await controller.ActivateThemeResultAsync(resultId); Assert.Equal(1, service.PreviewCalls); Assert.Equal(resultId, activated.Theme?.SelectedResultId); var row = Assert.Single(activated.Playlist); Assert.Equal("5074", row.ToPlayoutEntry().CutCode); Assert.Equal("1/2", row.PageText); Assert.Equal("INPUT_ORDER", Assert.IsType< MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>( row.ToPlayoutEntry().Selection).GraphicType); Assert.Equal("activate-theme-result", activated.CommandResult?.Command); Assert.True(activated.CommandResult?.Succeeded); } [Fact] public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRow() { var controller = new LegacyOperatorController( Lookup(), expertWorkflow: new LegacyExpertWorkflowController(new StaticExpertService())); await controller.SelectTabAsync(LegacyOperatorTabId.Expert); var search = await controller.SearchExpertsAsync("전문가"); var selectionId = Assert.Single(search.Expert!.Search.Results).SelectionId; await controller.SelectExpertAsync(selectionId); var added = controller.ActivateExpertAction( LegacyExpertWorkflowController.ActionId); var row = Assert.Single(added.Playlist); Assert.Equal("5074", row.ToPlayoutEntry().CutCode); Assert.Equal("1/2", row.PageText); Assert.Equal("전문가", row.MarketText); } [Fact] public async Task TradingHaltTabAddsCurrentAndCandleWithGlobalMovingAverages() { var controller = new LegacyOperatorController( Lookup(), tradingHaltWorkflow: new LegacyTradingHaltWorkflowController( new StaticTradingHaltService())); await controller.SelectTabAsync(LegacyOperatorTabId.TradingHalt); var search = await controller.SearchTradingHaltsAsync(""); var selectionId = Assert.Single(search.TradingHalt!.Results).SelectionId; controller.SelectTradingHalt(selectionId); var current = controller.ActivateTradingHaltAction( LegacyTradingHaltWorkflowController.CurrentActionId); Assert.Equal("5001", Assert.Single(current.Playlist).ToPlayoutEntry().CutCode); controller.SetMovingAverages(ma5: true, ma20: false); var candle = controller.ActivateTradingHaltAction( LegacyTradingHaltWorkflowController.CandleActionId); var candleRow = candle.Playlist[1]; Assert.Equal("8051", candleRow.ToPlayoutEntry().CutCode); Assert.Equal( string.Empty, Assert.IsType( candleRow.ToPlayoutEntry().Selection).GraphicType); Assert.Equal( "MA5", controller.CreatePlayoutRequest().Playlist[1].Selection!.GraphicType); } [Fact] public void Tabs_StartInOriginalOrderAndSwapByIdentity() { var controller = new LegacyOperatorController(Lookup()); Assert.Equal( ["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외종목", "전문가", "정지"], controller.Current.Tabs!.Select(tab => tab.Label)); Assert.Equal( LegacyOperatorTabId.Overseas, Assert.Single(controller.Current.Tabs!, tab => tab.IsActive).Id); var selected = controller.SelectTab(LegacyOperatorTabId.Theme); Assert.Equal( LegacyOperatorTabId.Theme, Assert.Single(selected.Tabs!, tab => tab.IsActive).Id); var swapped = controller.SwapTabs( LegacyOperatorTabId.Theme, LegacyOperatorTabId.Exchange); Assert.Equal(LegacyOperatorTabId.Theme, swapped.Tabs![1].Id); Assert.Equal(LegacyOperatorTabId.Exchange, swapped.Tabs[6].Id); Assert.Equal( LegacyOperatorTabId.Theme, Assert.Single(swapped.Tabs, tab => tab.IsActive).Id); } [Fact] public async Task TabHoverSwapUsesExpectedPositionsSoDuplicateDeliveryCannotSwapBack() { var controller = new LegacyOperatorController(Lookup()); var first = await controller.SwapTabsAtExpectedPositionsAsync( LegacyOperatorTabId.Theme, LegacyOperatorTabId.Exchange, expectedSourceIndex: 6, expectedTargetIndex: 1); Assert.Equal(LegacyOperatorTabId.Theme, first.Tabs![1].Id); Assert.Equal(LegacyOperatorTabId.Exchange, first.Tabs[6].Id); var revision = first.Revision; var duplicate = await controller.SwapTabsAtExpectedPositionsAsync( LegacyOperatorTabId.Theme, LegacyOperatorTabId.Exchange, expectedSourceIndex: 6, expectedTargetIndex: 1); Assert.Equal(revision, duplicate.Revision); Assert.Equal(LegacyOperatorTabId.Theme, duplicate.Tabs![1].Id); Assert.Equal(LegacyOperatorTabId.Exchange, duplicate.Tabs[6].Id); } [Fact] public async Task PlaylistKeyboardCommandsUseActiveRowAndBoundaries() { var controller = await ControllerWithPlaylistAsync(0, 1, 2); var rows = controller.Current.Playlist; controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false); controller.SelectPlaylistRow(rows[1].RowId, control: true, shift: false); var last = controller.SelectPlaylistBoundary(last: true); Assert.True(last.Playlist[0].IsEnabled); Assert.True(last.Playlist[2].IsActive); Assert.Equal( rows.Take(2).Select(row => row.RowId), last.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); var toggled = controller.ToggleActivePlaylistRowEnabled(); Assert.False(toggled.Playlist[2].IsEnabled); var first = controller.SelectPlaylistBoundary(last: false); Assert.True(first.Playlist[0].IsActive); Assert.Equal( rows.Take(2).Select(row => row.RowId), first.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); } [Fact] public async Task FixedCatalogLeafAndSectionAreMaterializedOnlyByCSharp() { var controller = new LegacyOperatorController(Lookup()); var first = LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Overseas)[0]; var leaf = controller.ActivateFixedAction(first.Id); var row = Assert.Single(leaf.Playlist); Assert.Equal("해외지수", row.MarketText); Assert.Equal(first.Label, row.StockName); Assert.Equal(first.Code, row.ToPlayoutEntry().CutCode); Assert.Equal( first.Selection.Subject, Assert.IsType( row.ToPlayoutEntry().Selection).Subject); var firstSection = LegacyFixedActionCatalog.Default .GetSections(LegacyFixedMarket.Overseas)[0]; var section = await controller.ActivateFixedSection(0); Assert.Equal(1 + firstSection.Actions.Count, section.Playlist.Count); Assert.Equal( firstSection.Actions.Select(action => action.Label), section.Playlist.Skip(1).Select(item => item.StockName)); } [Fact] public void FixedCatalogRejectsCrossTabAndUnavailableActionsAtomically() { var controller = new LegacyOperatorController(Lookup()); var exchange = LegacyFixedActionCatalog.Default .GetActions(LegacyFixedMarket.Exchange)[0]; var crossTab = controller.ActivateFixedAction(exchange.Id); Assert.Empty(crossTab.Playlist); Assert.Equal(LegacyOperatorStatusKind.Error, crossTab.StatusKind); var unavailable = LegacyFixedActionCatalog.Default.Actions .First(action => !action.Available); controller.SelectTab(unavailable.Market switch { LegacyFixedMarket.Overseas => LegacyOperatorTabId.Overseas, LegacyFixedMarket.Exchange => LegacyOperatorTabId.Exchange, LegacyFixedMarket.Index => LegacyOperatorTabId.Index, _ => throw new InvalidOperationException() }); var blocked = controller.ActivateFixedAction(unavailable.Id); Assert.Empty(blocked.Playlist); Assert.Equal(unavailable.Prerequisite, blocked.Dialog?.Message); } [Fact] public void FixedCandleIdentityStaysStableWhileF8SamplesCurrentMovingAverages() { var controller = new LegacyOperatorController(Lookup()); var candle = LegacyFixedActionCatalog.Default .GetActions(LegacyFixedMarket.Index) .First(action => action.SupportsMovingAverages); controller.SelectTab(LegacyOperatorTabId.Index); controller.SetMovingAverages(ma5: false, ma20: true); var state = controller.ActivateFixedAction(candle.Id); Assert.Equal( string.Empty, Assert.IsType( Assert.Single(state.Playlist).ToPlayoutEntry().Selection).GraphicType); Assert.Equal( "MA20", Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType); Assert.False(state.MovingAverages.Ma5); Assert.True(state.MovingAverages.Ma20); controller.SetMovingAverages(ma5: true, ma20: false); Assert.Equal( "MA5", Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType); Assert.Equal( string.Empty, Assert.Single(controller.Current.Playlist).PlayoutSelection!.GraphicType); } [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); Assert.True(row.IsActive); Assert.False(row.IsSelected); Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [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 PointerSelectionWithoutAppendPermission_NeverAppendsAndResetsDoubleClick() { var controller = new LegacyOperatorController(Lookup()); await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); controller.SelectStock(0); controller.CutPointerDown( 1, false, false, 20, 45, 1_000, allowAppend: true); var onAirSelection = controller.CutPointerDown( 1, false, false, 20, 45, 1_200, allowAppend: false); Assert.Empty(onAirSelection.Playlist); Assert.Equal( 1, Assert.Single(onAirSelection.CutRows, row => row.IsSelected).PhysicalIndex); Assert.Equal( 1, Assert.Single(onAirSelection.CutRows, row => row.IsFocused).PhysicalIndex); var firstIdleClick = controller.CutPointerDown( 1, false, false, 20, 45, 1_400, allowAppend: true); Assert.Empty(firstIdleClick.Playlist); var secondIdleClick = controller.CutPointerDown( 1, false, false, 20, 45, 1_600, allowAppend: true); Assert.Single(secondIdleClick.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); } [Fact] public async Task PlaylistPlainControlAndControlShiftSelectionMatchMainFormRules() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5); var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray(); controller.SelectPlaylistRow(ids[1], control: false, shift: false); controller.SelectPlaylistRow(ids[3], control: true, shift: false); controller.SelectPlaylistRow(ids[3], control: true, shift: false); var state = controller.SelectPlaylistRow(ids[4], control: true, shift: true); Assert.Equal( [ids[1], ids[3], ids[4]], state.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); } [Fact] public async Task PlaylistShiftUsesPreviousActiveRowAndMovesItsRangeAnchor() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5); var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray(); controller.SelectPlaylistRow(ids[1], control: false, shift: false); var firstRange = controller.SelectPlaylistRow(ids[3], control: false, shift: true); Assert.Equal( ids[1..4], firstRange.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); var movedRange = controller.SelectPlaylistRow(ids[4], control: false, shift: true); Assert.Equal( ids[3..5], movedRange.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); } [Fact] public async Task FirstPlaylistShiftStartsAtOriginalZeroCursor() { var controller = await ControllerWithPlaylistAsync(0, 1, 2); var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray(); var state = controller.SelectPlaylistRow(ids[2], control: false, shift: true); Assert.All(state.Playlist, row => Assert.True(row.IsSelected)); } [Fact] public async Task CheckboxActivationChangesF8CandidateWithoutReplacingDeleteSelection() { var controller = await ControllerWithPlaylistAsync(0, 1, 2); var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray(); controller.SelectPlaylistRow(ids[0], control: false, shift: false); controller.SelectPlaylistRow(ids[1], control: true, shift: false); var state = controller.ActivatePlaylistRow(ids[2]); Assert.Equal( ids[..2], state.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); Assert.Equal(ids[2], Assert.Single(state.Playlist, row => row.IsActive).RowId); Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [Fact] public async Task CheckboxActivationDoesNotMoveTheFollowingShiftSelectionAnchor() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray(); controller.SelectPlaylistRow(ids[1], control: false, shift: false); controller.ActivatePlaylistRow(ids[3]); var range = controller.SelectPlaylistRow(ids[2], control: false, shift: true); Assert.Equal( ids[1..3], range.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); Assert.Equal(ids[2], Assert.Single(range.Playlist, row => row.IsActive).RowId); } [Fact] public async Task HomeAndEndChangeOnlyTheF8CandidateAndKeepProgramPageProjection() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); var rows = controller.Current.Playlist; controller.SelectPlaylistRow(rows[1].RowId, control: false, shift: false); controller.SelectPlaylistRow(rows[3].RowId, control: true, shift: false); controller.ReflectSuccessfulPlayout( rows[1].RowId, pageIndexZeroBased: 1, pageCount: 3, synchronizeOperatorSelection: false); var home = controller.SelectPlaylistBoundary(last: false); Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased); Assert.Equal("2/3", home.Playlist[1].PageText); Assert.True(home.Playlist[0].IsActive); Assert.Equal( [rows[1].RowId, rows[3].RowId], home.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); var end = controller.SelectPlaylistBoundary(last: true); Assert.Equal(3, controller.CreatePlayoutRequest().SelectedIndexZeroBased); Assert.Equal("2/3", end.Playlist[1].PageText); Assert.True(end.Playlist[3].IsActive); var shift = controller.SelectPlaylistRow( rows[2].RowId, control: false, shift: true); Assert.Equal( [rows[1].RowId, rows[2].RowId], shift.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); } [Fact] public async Task FirstAppendedRowIsAnF8CandidateButNotAnImplicitDeleteSelection() { var controller = await ControllerWithPlaylistAsync(0); var before = controller.Current; Assert.True(Assert.Single(before.Playlist).IsActive); Assert.DoesNotContain(before.Playlist, row => row.IsSelected); Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased); var after = controller.DeleteSelectedPlaylistRows(); Assert.Equal(before.Revision, after.Revision); Assert.Single(after.Playlist); } [Fact] public async Task PlaylistEnabledSupportsIndividualToggleAndAllRows() { var controller = await ControllerWithPlaylistAsync(0, 1, 2); var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray(); controller.SetPlaylistRowEnabled(ids[0], false); var toggled = controller.TogglePlaylistRowEnabled(ids[1]); Assert.False(toggled.Playlist[0].IsEnabled); Assert.False(toggled.Playlist[1].IsEnabled); Assert.True(toggled.Playlist[2].IsEnabled); var disabled = controller.SetAllPlaylistRowsEnabled(false); Assert.All(disabled.Playlist, row => Assert.False(row.IsEnabled)); var enabled = controller.SetAllPlaylistRowsEnabled(true); Assert.All(enabled.Playlist, row => Assert.True(row.IsEnabled)); } [Fact] public async Task DeleteSelectedRowsPreservesRemainingPhysicalOrderAndClearsSelection() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); var initial = controller.Current.Playlist.ToArray(); controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false); controller.SelectPlaylistRow(initial[3].RowId, control: true, shift: false); var state = controller.DeleteSelectedPlaylistRows(); Assert.Equal( [initial[0].RowId, initial[2].RowId], state.Playlist.Select(row => row.RowId)); Assert.DoesNotContain(state.Playlist, row => row.IsSelected); Assert.Equal(initial[2].RowId, Assert.Single(state.Playlist, row => row.IsActive).RowId); Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [Fact] public async Task DeletePreservesAnIndependentCheckboxCandidateByStableIdentity() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); var initial = controller.Current.Playlist.ToArray(); controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false); controller.SelectPlaylistRow(initial[1].RowId, control: true, shift: false); controller.ActivatePlaylistRow(initial[3].RowId); var state = controller.DeleteSelectedPlaylistRows(); Assert.Equal( [initial[2].RowId, initial[3].RowId], state.Playlist.Select(row => row.RowId)); Assert.DoesNotContain(state.Playlist, row => row.IsSelected); Assert.Equal(initial[3].RowId, Assert.Single(state.Playlist, row => row.IsActive).RowId); Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [Fact] public async Task DeleteActiveCandidateChoosesTheNextSurvivorThenThePreviousAtEnd() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); var initial = controller.Current.Playlist.ToArray(); controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false); var afterMiddle = controller.DeleteSelectedPlaylistRows(); Assert.Equal(initial[2].RowId, Assert.Single(afterMiddle.Playlist, row => row.IsActive).RowId); Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); controller.SelectPlaylistRow(initial[3].RowId, control: false, shift: false); var afterLast = controller.DeleteSelectedPlaylistRows(); Assert.Equal(initial[2].RowId, Assert.Single(afterLast.Playlist, row => row.IsActive).RowId); Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [Fact] public async Task ShiftSelectionAndDeleteAreNotLimitedToTheOriginalHundredRowBuffers() { var controller = new LegacyOperatorController(Lookup()); await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); controller.SelectStock(0); controller.CutPointerDown(1, false, false, 1, 24, 1_000); for (var index = 0; index < 101; index++) { controller.DropSelectedCutsOnPlaylist(); } var rows = controller.Current.Playlist; Assert.Equal(101, rows.Count); controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false); var selected = controller.SelectPlaylistRow( rows[^1].RowId, control: false, shift: true); Assert.Equal(101, selected.Playlist.Count(row => row.IsSelected)); var deleted = controller.DeleteSelectedPlaylistRows(); Assert.Empty(deleted.Playlist); Assert.Throws(() => controller.CreatePlayoutRequest()); } [Fact] public async Task DeleteAllRowsClearsPlaylistAndSelectionState() { var controller = await ControllerWithPlaylistAsync(0, 1, 2); controller.SelectPlaylistRow( controller.Current.Playlist[1].RowId, control: false, shift: false); var empty = controller.DeleteAllPlaylistRows(); Assert.Empty(empty.Playlist); var unchangedRevision = empty.Revision; var stillEmpty = controller.DeleteAllPlaylistRows(); Assert.Equal(unchangedRevision, stillEmpty.Revision); } [Fact] public async Task FirstAppendAfterDeleteAllReestablishesTheVisibleF8Candidate() { var controller = await ControllerWithPlaylistAsync(0, 1); controller.DeleteAllPlaylistRows(); controller.CutPointerDown(2, false, false, 1, 47, 10_000); var state = controller.DropSelectedCutsOnPlaylist(); var row = Assert.Single(state.Playlist); Assert.True(row.IsActive); Assert.False(row.IsSelected); Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [Fact] public async Task MoveUsesOnlyActiveRowAndCollapsesMultiSelectionToMovedRow() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); var initial = controller.Current.Playlist.ToArray(); controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false); controller.SelectPlaylistRow(initial[2].RowId, control: true, shift: false); var moved = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Up); Assert.Equal( [initial[0].RowId, initial[2].RowId, initial[1].RowId, initial[3].RowId], moved.Playlist.Select(row => row.RowId)); Assert.Equal( initial[2].RowId, Assert.Single(moved.Playlist, row => row.IsSelected).RowId); Assert.Equal(initial[2].ToPlayoutEntry(), moved.Playlist[1].ToPlayoutEntry()); Assert.Equal(initial[2].PageText, moved.Playlist[1].PageText); Assert.Equal(initial[2].RawCutLabel, moved.Playlist[1].RawCutLabel); Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); var restored = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Down); Assert.Equal(initial.Select(row => row.RowId), restored.Playlist.Select(row => row.RowId)); Assert.Equal( initial[2].RowId, Assert.Single(restored.Playlist, row => row.IsSelected).RowId); Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [Fact] public async Task MoveAtBoundaryIsNoOp() { var controller = await ControllerWithPlaylistAsync(0, 1); var first = controller.Current.Playlist[0]; controller.SelectPlaylistRow(first.RowId, control: false, shift: false); var before = controller.Current; var after = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Up); Assert.Equal(before.Revision, after.Revision); Assert.Equal( before.Playlist.Select(row => row.RowId), after.Playlist.Select(row => row.RowId)); } [Fact] public async Task PlaylistDragMovesOnlySourceRowCollapsesSelectionAndIsIdempotent() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5, 6); var initial = controller.Current.Playlist.ToArray(); controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false); controller.SelectPlaylistRow(initial[3].RowId, control: true, shift: false); var moved = controller.ReorderPlaylistRows( initial[3].RowId, initial[5].RowId, LegacyDragDropPosition.Before); Assert.Equal( [ initial[0].RowId, initial[1].RowId, initial[2].RowId, initial[4].RowId, initial[3].RowId, initial[5].RowId ], moved.Playlist.Select(row => row.RowId)); Assert.Equal( initial[3].RowId, Assert.Single(moved.Playlist, row => row.IsSelected).RowId); Assert.Equal( initial[3].RowId, Assert.Single(moved.Playlist, row => row.IsActive).RowId); var revision = moved.Revision; var duplicate = controller.ReorderPlaylistRows( initial[3].RowId, initial[5].RowId, LegacyDragDropPosition.Before); Assert.Equal(revision, duplicate.Revision); Assert.Equal( moved.Playlist.Select(row => row.RowId), duplicate.Playlist.Select(row => row.RowId)); Assert.Equal( initial[3].RowId, Assert.Single(duplicate.Playlist, row => row.IsSelected).RowId); } [Fact] public async Task PlaylistDragFromUnselectedRowMovesOnlyThatRowAndSelectsIt() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); var initial = controller.Current.Playlist.ToArray(); controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false); controller.SelectPlaylistRow(initial[1].RowId, control: true, shift: false); var moved = controller.ReorderPlaylistRows( initial[3].RowId, initial[0].RowId, LegacyDragDropPosition.Before); Assert.Equal( [initial[3].RowId, initial[0].RowId, initial[1].RowId, initial[2].RowId], moved.Playlist.Select(row => row.RowId)); Assert.Equal( initial[3].RowId, Assert.Single(moved.Playlist, row => row.IsSelected).RowId); } private static async Task ControllerWithPlaylistAsync( params int[] physicalCutRows) { var controller = new LegacyOperatorController(Lookup()); await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); controller.SelectStock(0); var timestamp = 1_000L; foreach (var physicalCutRow in physicalCutRows) { controller.CutPointerDown( physicalCutRow, control: false, shift: false, x: 1, y: physicalCutRow * 23, timestampMilliseconds: timestamp); controller.DropSelectedCutsOnPlaylist(); timestamp += 1_000; } return controller; } 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 SearchAsync( string query, CancellationToken cancellationToken = default) { CallCount++; return Task.FromResult(_result); } } private sealed class FixedTimeProvider(DateTimeOffset localNow) : TimeProvider { private static readonly TimeZoneInfo KoreaStandardTime = TimeZoneInfo.CreateCustomTimeZone( "FixedKst", TimeSpan.FromHours(9), "Fixed KST", "Fixed KST"); public override DateTimeOffset GetUtcNow() => localNow.ToUniversalTime(); public override TimeZoneInfo LocalTimeZone => KoreaStandardTime; } private sealed class StaticIndustryService : IIndustrySelectionService { private readonly IReadOnlyList _rows; public StaticIndustryService(IReadOnlyList rows) { _rows = rows; } public Task> GetAsync( IndustryMarket market, CancellationToken cancellationToken = default) => Task.FromResult(_rows); } private sealed class StaticThemeService : IThemeSelectionService { private readonly ThemeSelectionIdentity _identity; private readonly IReadOnlyList _items; public StaticThemeService( ThemeSelectionIdentity identity, IReadOnlyList items) { _identity = identity; _items = items; } public int PreviewCalls { get; private set; } public List SearchSessions { get; } = []; public Task SearchAsync( string query, ThemeSession nxtSession, int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults, CancellationToken cancellationToken = default) { SearchSessions.Add(nxtSession); var identity = _identity.Market == ThemeMarket.Nxt ? _identity with { Session = nxtSession } : _identity; return Task.FromResult( new ThemeSearchResult( query, nxtSession, DateTimeOffset.UtcNow, [new ThemeSelectorItem( identity, identity.Market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb)], false)); } public Task PreviewAsync( ThemeSelectionIdentity identity, int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems, CancellationToken cancellationToken = default) { PreviewCalls++; return Task.FromResult( new ThemePreviewResult( identity, DateTimeOffset.UtcNow, _items, false)); } } private sealed class StaticExpertService : IExpertSelectionService { private static readonly ExpertSelectionIdentity Identity = new("0001", "전문가A"); public Task SearchAsync( string query = "", int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults, CancellationToken cancellationToken = default) => Task.FromResult( new ExpertSearchResult( query.Trim(), DateTimeOffset.UtcNow, [new ExpertSelectorItem(Identity, DataSourceKind.Oracle)], false)); public Task PreviewAsync( ExpertSelectionIdentity identity, int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems, CancellationToken cancellationToken = default) => Task.FromResult( new ExpertPreviewResult( Identity, DateTimeOffset.UtcNow, Enumerable.Range(1, 6) .Select(index => new ExpertRecommendationPreview( index, index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture), $"추천{index}", 10_000m + index)) .ToArray(), false)); } private sealed class StaticTradingHaltService : ITradingHaltSelectionService { public Task SearchAsync( string query = "", int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults, CancellationToken cancellationToken = default) => Task.FromResult( new TradingHaltSelectionResult( query.Trim(), DateTimeOffset.UtcNow, [new TradingHaltSelection(TradingHaltMarket.Kospi, "005930", "삼성전자")], false)); } }