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 IndustryParentDoubleClickMaterializesDirectChildrenAtomicallyInOrder() { 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); var rejected = controller.ActivateIndustrySection(0); Assert.Empty(rejected.Playlist); controller.SelectIndustry(1, doubleClick: true); var added = controller.ActivateIndustrySection(0); Assert.Collection( added.Playlist, row => Assert.Equal("5001", row.SceneAlias), row => Assert.Equal("8018", row.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("AI 테마", row.StockName); Assert.Equal( "GAIN_DESC", Assert.IsType( row.ToPlayoutEntry().Selection).GraphicType); } [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( "MA5", Assert.IsType( candleRow.ToPlayoutEntry().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; var last = controller.SelectPlaylistBoundary(last: true); Assert.True(last.Playlist[0].IsEnabled); Assert.True(last.Playlist[2].IsSelected); var toggled = controller.ToggleActivePlaylistRowEnabled(); Assert.False(toggled.Playlist[2].IsEnabled); var first = controller.SelectPlaylistBoundary(last: false); Assert.True(first.Playlist[0].IsSelected); Assert.False(first.Playlist[2].IsSelected); } [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 MovingAverageFlagsAreCapturedInFixedCandleSelection() { 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( "MA20", Assert.IsType( Assert.Single(state.Playlist).ToPlayoutEntry().Selection).GraphicType); Assert.False(state.MovingAverages.Ma5); Assert.True(state.MovingAverages.Ma20); } [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); } [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 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 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); } [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 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); 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); } [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 PlaylistDragMovesNoncontiguousSelectionAsOneOrderedBlockAndIsIdempotent() { 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[2].RowId, initial[4].RowId, initial[1].RowId, initial[3].RowId, initial[5].RowId ], moved.Playlist.Select(row => row.RowId)); Assert.Equal( [initial[1].RowId, initial[3].RowId], moved.Playlist.Where(row => row.IsSelected).Select(row => row.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)); } [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 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 Task SearchAsync( string query, ThemeSession nxtSession, int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults, CancellationToken cancellationToken = default) => Task.FromResult( new ThemeSearchResult( query, nxtSession, DateTimeOffset.UtcNow, [new ThemeSelectorItem(_identity, DataSourceKind.Oracle)], 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)); } }