feat: restore legacy drag events and Tornado launch integration

This commit is contained in:
2026-07-15 15:35:25 +09:00
parent 6e0c275fdd
commit 06a16e5741
32 changed files with 3096 additions and 101 deletions

View File

@@ -213,6 +213,30 @@ public sealed class LegacyOperatorControllerTests
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()
{
@@ -626,6 +650,65 @@ public sealed class LegacyOperatorControllerTests
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<LegacyOperatorController> ControllerWithPlaylistAsync(
params int[] physicalCutRows)
{

View File

@@ -59,6 +59,77 @@ public sealed class LegacyOperatorCatalogWorkflowTests
Assert.Equal(LegacyOperatorCatalogMode.List, deleted.Mode);
}
[Theory]
[InlineData(ThemeMarket.Krx)]
[InlineData(ThemeMarket.Nxt)]
public async Task ThemeA_name_check_is_read_only_and_scoped_to_the_selected_market(
ThemeMarket market)
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
var creating = await workflow.BeginCreateThemeAsync(market);
Assert.True(creating.CanCheckThemeName);
fixture.ThemePersistence.NameExists = false;
var available = await workflow.CheckThemeNameAvailabilityAsync("AI 반도체");
Assert.Equal(LegacyThemeNameAvailability.Available, available.ThemeNameAvailability);
Assert.Equal("AI 반도체", available.CheckedThemeName);
Assert.Equal("사용 가능한 테마명입니다.", available.StatusMessage);
Assert.Equal(market, fixture.ThemePersistence.LastNameCheckMarket);
Assert.Equal("AI 반도체", fixture.ThemePersistence.LastCheckedName);
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
fixture.ThemePersistence.NameExists = true;
var duplicate = await workflow.CheckThemeNameAvailabilityAsync("AI 반도체");
Assert.Equal(LegacyThemeNameAvailability.Duplicate, duplicate.ThemeNameAvailability);
Assert.Equal("이미 사용중인 테마명입니다.", duplicate.StatusMessage);
Assert.Equal(2, fixture.ThemePersistence.NameExistsCalls);
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
}
[Theory]
[InlineData(ThemeMarket.Krx)]
[InlineData(ThemeMarket.Nxt)]
public async Task ThemeA_edit_name_check_preserves_original_self_duplicate_behavior(
ThemeMarket market)
{
var fixture = new Fixture();
var item = ThemeItem(market, "AI 반도체");
fixture.ThemeSelection.SearchItems = [item];
fixture.ThemePersistence.NameExists = true;
var workflow = fixture.CreateWorkflow();
await workflow.OpenThemeForEditAsync(item.Identity);
var result = await workflow.CheckThemeNameAvailabilityAsync("AI 반도체");
Assert.Equal(LegacyThemeNameAvailability.Duplicate, result.ThemeNameAvailability);
Assert.Equal(market, fixture.ThemePersistence.LastNameCheckMarket);
Assert.Equal("AI 반도체", fixture.ThemePersistence.LastCheckedName);
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
}
[Fact]
public async Task ThemeA_name_check_reports_indeterminate_when_exact_query_fails()
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
fixture.ThemePersistence.NameExistsException = new InvalidOperationException("offline");
var result = await workflow.CheckThemeNameAvailabilityAsync("새 테마");
Assert.Equal(LegacyThemeNameAvailability.Indeterminate, result.ThemeNameAvailability);
Assert.Equal("새 테마", result.CheckedThemeName);
Assert.Equal(LegacyOperatorCatalogStatusKind.Error, result.StatusKind);
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
}
[Fact]
public async Task EList_preserves_buy_amount_and_CSharp_owned_reorder_in_one_create()
{
@@ -103,6 +174,47 @@ public sealed class LegacyOperatorCatalogWorkflowTests
});
}
[Fact]
public async Task EList_drag_reorder_moves_to_arbitrary_position_and_duplicate_is_no_op()
{
var fixture = new Fixture();
fixture.StockSearch.Items =
[
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "First", "000001"),
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "Second", "000002"),
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "Third", "000003")
];
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
await workflow.BeginCreateExpertAsync();
var stocks = await workflow.SearchStocksAsync("stock");
foreach (var stock in stocks.StockResults)
{
workflow.SelectStock(stock.ResultId);
workflow.AddSelectedStock(10_000);
}
var initial = workflow.Current.DraftRows.ToArray();
var moved = workflow.ReorderDraftRow(
initial[0].RowId,
initial[2].RowId,
LegacyDragDropPosition.After);
Assert.Equal(
[initial[1].RowId, initial[2].RowId, initial[0].RowId],
moved.DraftRows.Select(row => row.RowId));
Assert.Equal([0, 1, 2], moved.DraftRows.Select(row => row.Position));
var revision = moved.Revision;
var duplicate = workflow.ReorderDraftRow(
initial[0].RowId,
initial[2].RowId,
LegacyDragDropPosition.After);
Assert.Equal(revision, duplicate.Revision);
Assert.Equal(
moved.DraftRows.Select(row => row.RowId),
duplicate.DraftRows.Select(row => row.RowId));
}
[Fact]
public async Task Uc4_and_Uc6_edit_entries_resolve_only_the_native_selected_identity()
{
@@ -197,27 +309,29 @@ public sealed class LegacyOperatorCatalogWorkflowTests
private sealed class FakeThemeSelection : IThemeSelectionService
{
internal int SearchCalls { get; private set; }
internal int PreviewCalls { get; private set; }
internal IReadOnlyList<ThemeSelectorItem> SearchItems { get; set; } =
[
ThemeItem(ThemeMarket.Krx, "AI 반도체")
];
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new ThemeSearchResult(
CancellationToken cancellationToken = default)
{
SearchCalls++;
return Task.FromResult(new ThemeSearchResult(
query,
nxtSession,
DateTimeOffset.UtcNow,
[
new ThemeSelectorItem(
new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
"AI 반도체"),
DataSourceKind.Oracle)
],
SearchItems,
false));
}
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
@@ -270,8 +384,31 @@ public sealed class LegacyOperatorCatalogWorkflowTests
internal Exception? CreateException { get; set; }
internal int NameExistsCalls { get; private set; }
internal bool NameExists { get; set; }
internal Exception? NameExistsException { get; set; }
internal ThemeMarket? LastNameCheckMarket { get; private set; }
internal string? LastCheckedName { get; private set; }
public bool CanMutate => true;
public Task<bool> ThemeNameExistsAsync(
ThemeMarket market,
string themeTitle,
CancellationToken cancellationToken = default)
{
NameExistsCalls++;
LastNameCheckMarket = market;
LastCheckedName = themeTitle;
return NameExistsException is null
? Task.FromResult(NameExists)
: Task.FromException<bool>(NameExistsException);
}
public Task<string> SuggestNextCodeAsync(
ThemeMarket market,
CancellationToken cancellationToken = default) =>
@@ -391,4 +528,15 @@ public sealed class LegacyOperatorCatalogWorkflowTests
Guid.NewGuid(),
kind,
DateTimeOffset.UtcNow));
private static ThemeSelectorItem ThemeItem(ThemeMarket market, string title) =>
new(
new ThemeSelectionIdentity(
market,
market == ThemeMarket.Nxt
? ThemeSession.PreMarket
: ThemeSession.NotApplicable,
market == ThemeMarket.Nxt ? "N0000001" : "00000001",
title),
market == ThemeMarket.Nxt ? DataSourceKind.MariaDb : DataSourceKind.Oracle);
}