using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; 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", "AI 테마"), Assert.IsType( 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() { 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", "AI 테마"), 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 ExpertTabSearchPreviewAndPagedActionAddOriginalRowWithNullBuyAmount() { 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.Null(added.Expert!.Preview.Items[0].BuyAmount); 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); 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, 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 => 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(() => 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() { 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 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 requestedEmpty = controller.DeleteAllPlaylistRows(); Assert.True(requestedEmpty.Dialog?.IsConfirmation); var stillEmpty = await controller.ConfirmDialogAsync(); Assert.Empty(stillEmpty.Playlist); Assert.True(stillEmpty.Revision > unchangedRevision); } [Fact] public async Task FirstAppendAfterDeleteAllReestablishesTheVisibleF8Candidate() { var controller = await ControllerWithPlaylistAsync(0, 1); controller.DeleteAllPlaylistRows(); await controller.ConfirmDialogAsync(); 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 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 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 RecordingPagePlanProvider : ILegacyScenePagePlanProvider { public List 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 CreatePagePlanAsync( LegacyPlaylistEntry entry, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Entries.Add(entry); if (Failure is not null) { return Task.FromException(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( 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; 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 TruncatedThemeService : IThemeSelectionService { public Task 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 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"); 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}", index == 1 ? null : 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)); } }