feat: advance legacy UI behavior parity
This commit is contained in:
@@ -221,6 +221,111 @@ public sealed class LegacyComparisonWorkflowTests
|
||||
Assert.False(snapshot.IsBusy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uc3_AllowsExactDuplicatePairsAndMovesOrDeletesOnlyTheSelectedStableRow()
|
||||
{
|
||||
var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] };
|
||||
var store = new FakePairStore();
|
||||
var controller = CreateController(data, store);
|
||||
var search = await controller.SearchDomesticAsync(string.Empty);
|
||||
controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId);
|
||||
controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId);
|
||||
|
||||
var firstAdd = await controller.AddDraftPairAsync();
|
||||
var firstRowId = Assert.Single(firstAdd.SavedPairs).RowId;
|
||||
var secondAdd = await controller.AddDraftPairAsync();
|
||||
var secondRowId = secondAdd.SavedPairs[1].RowId;
|
||||
|
||||
Assert.NotEqual(firstRowId, secondRowId);
|
||||
Assert.Equal(
|
||||
new[] { firstRowId, secondRowId },
|
||||
secondAdd.SavedPairs.Select(pair => pair.RowId));
|
||||
Assert.All(
|
||||
secondAdd.SavedPairs,
|
||||
pair => Assert.Equal("SK하이닉스 ↔ 삼성전자", pair.DisplayLabel));
|
||||
Assert.Collection(
|
||||
store.Writes[^1],
|
||||
row => Assert.Equal(
|
||||
new LegacyComparisonPairWriteRow(
|
||||
1,
|
||||
"SK하이닉스",
|
||||
"코스피",
|
||||
"삼성전자",
|
||||
"코스피"),
|
||||
row),
|
||||
row => Assert.Equal(
|
||||
new LegacyComparisonPairWriteRow(
|
||||
2,
|
||||
"SK하이닉스",
|
||||
"코스피",
|
||||
"삼성전자",
|
||||
"코스피"),
|
||||
row));
|
||||
|
||||
controller.SelectSavedPair(secondRowId);
|
||||
var moved = await controller.MoveSelectedPairAsync(
|
||||
LegacyComparisonPairMoveDirection.Up);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { secondRowId, firstRowId },
|
||||
moved.SavedPairs.Select(pair => pair.RowId));
|
||||
Assert.Equal(secondRowId, moved.SelectedPairId);
|
||||
Assert.True(moved.SavedPairs[0].IsSelected);
|
||||
Assert.False(moved.SavedPairs[1].IsSelected);
|
||||
|
||||
var deleted = await controller.DeleteSelectedPairAsync();
|
||||
|
||||
var remaining = Assert.Single(deleted.SavedPairs);
|
||||
Assert.Equal(firstRowId, remaining.RowId);
|
||||
Assert.True(remaining.IsSelected);
|
||||
Assert.Equal(firstRowId, deleted.SelectedPairId);
|
||||
Assert.Single(store.Writes[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uc3_DuplicatePairSaveRoundTripPreservesPhysicalRowOrder()
|
||||
{
|
||||
var data = new FakeDataService();
|
||||
data.Resolve(Samsung, "삼성전자", "코스피");
|
||||
data.Resolve(SkHynix, "SK하이닉스", "코스피");
|
||||
data.Resolve(Apple, "APPLE", string.Empty);
|
||||
var store = new FakePairStore
|
||||
{
|
||||
Bytes = EncodeCp949(
|
||||
("삼성전자", "코스피", "SK하이닉스", "코스피"),
|
||||
("삼성전자", "코스피", "SK하이닉스", "코스피"),
|
||||
("코스피 지수", string.Empty, "APPLE", string.Empty))
|
||||
};
|
||||
var controller = CreateController(data, store);
|
||||
var loaded = await controller.LoadSavedPairsAsync();
|
||||
var thirdRowId = loaded.SavedPairs[2].RowId;
|
||||
controller.SelectSavedPair(thirdRowId);
|
||||
|
||||
var moved = await controller.MoveSelectedPairAsync(
|
||||
LegacyComparisonPairMoveDirection.Up);
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"삼성전자 ↔ SK하이닉스",
|
||||
"코스피 지수 ↔ APPLE",
|
||||
"삼성전자 ↔ SK하이닉스"
|
||||
},
|
||||
moved.SavedPairs.Select(pair => pair.DisplayLabel));
|
||||
Assert.Equal(new[] { 1, 2, 3 }, store.Writes[^1].Select(row => row.RowNumber));
|
||||
|
||||
var reloadedStore = new FakePairStore
|
||||
{
|
||||
Bytes = EncodeCp949(store.Writes[^1])
|
||||
};
|
||||
var reloaded = await CreateController(data, reloadedStore).LoadSavedPairsAsync();
|
||||
|
||||
Assert.Equal(
|
||||
moved.SavedPairs.Select(pair => pair.DisplayLabel),
|
||||
reloaded.SavedPairs.Select(pair => pair.DisplayLabel));
|
||||
Assert.Equal(3, reloaded.SavedPairs.Select(pair => pair.RowId).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavedPairMoveAndDelete_PreserveActiveRowAndPersistPhysicalOrder()
|
||||
{
|
||||
@@ -424,6 +529,13 @@ public sealed class LegacyComparisonWorkflowTests
|
||||
return encoding.GetBytes(string.Join("\r\n", lines) + "\r\n");
|
||||
}
|
||||
|
||||
private static byte[] EncodeCp949(IReadOnlyList<LegacyComparisonPairWriteRow> rows) =>
|
||||
EncodeCp949(rows.Select(row => (
|
||||
row.FirstName,
|
||||
row.FirstMarket,
|
||||
row.SecondName,
|
||||
row.SecondMarket)).ToArray());
|
||||
|
||||
private sealed class FakeDataService : ILegacyComparisonDataService
|
||||
{
|
||||
private readonly Dictionary<LegacyComparisonStoredTargetReference, LegacyComparisonTarget>
|
||||
|
||||
@@ -59,6 +59,108 @@ public sealed class LegacyOperatorComparisonIntegrationTests
|
||||
row => Assert.Equal("5087", row.SceneAlias));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAllComparisonPairs_NoPreservesRowsAndPerformsNoWrite()
|
||||
{
|
||||
var (controller, store) = await CreateControllerWithSavedPairAsync();
|
||||
var writesBeforeRequest = store.Writes.Count;
|
||||
|
||||
var requested = await controller.DeleteComparisonPairAsync(all: true);
|
||||
|
||||
Assert.Equal("모두 삭제하겠습니까?", requested.Dialog?.Message);
|
||||
Assert.Equal("MmoneyCoder", requested.Dialog?.Caption);
|
||||
Assert.True(requested.Dialog?.IsConfirmation);
|
||||
Assert.Single(requested.Comparison!.SavedPairs);
|
||||
Assert.Equal(writesBeforeRequest, store.Writes.Count);
|
||||
|
||||
var dismissed = controller.DismissDialog();
|
||||
Assert.True(dismissed.Dialog?.IsConfirmation);
|
||||
Assert.Equal(writesBeforeRequest, store.Writes.Count);
|
||||
|
||||
var canceled = controller.CancelDialog();
|
||||
|
||||
Assert.Null(canceled.Dialog);
|
||||
Assert.Single(canceled.Comparison!.SavedPairs);
|
||||
Assert.Equal(writesBeforeRequest, store.Writes.Count);
|
||||
|
||||
await controller.ConfirmDialogAsync();
|
||||
Assert.Equal(writesBeforeRequest, store.Writes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAllComparisonPairs_YesConsumesConfirmationBeforeSingleWrite()
|
||||
{
|
||||
var (controller, store) = await CreateControllerWithSavedPairAsync();
|
||||
var writesBeforeRequest = store.Writes.Count;
|
||||
await controller.DeleteComparisonPairAsync(all: true);
|
||||
|
||||
var confirmed = await controller.ConfirmDialogAsync();
|
||||
|
||||
Assert.Null(confirmed.Dialog);
|
||||
Assert.Empty(confirmed.Comparison!.SavedPairs);
|
||||
Assert.Equal(writesBeforeRequest + 1, store.Writes.Count);
|
||||
Assert.Empty(store.Writes[^1]);
|
||||
|
||||
await controller.ConfirmDialogAsync();
|
||||
Assert.Equal(writesBeforeRequest + 1, store.Writes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAllComparisonPairs_ChangedListInvalidatesPendingConfirmation()
|
||||
{
|
||||
var (controller, store) = await CreateControllerWithSavedPairAsync();
|
||||
var writesBeforeRequest = store.Writes.Count;
|
||||
var requested = await controller.DeleteComparisonPairAsync(all: true);
|
||||
var rowId = Assert.Single(requested.Comparison!.SavedPairs).RowId;
|
||||
controller.SelectComparisonPair(rowId);
|
||||
await controller.DeleteComparisonPairAsync(all: true);
|
||||
|
||||
var rejected = await controller.ConfirmDialogAsync();
|
||||
|
||||
Assert.Single(rejected.Comparison!.SavedPairs);
|
||||
Assert.Equal(writesBeforeRequest, store.Writes.Count);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Error, rejected.StatusKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAllComparisonPairs_EmptyListStillPromptsAndYesDoesNotWrite()
|
||||
{
|
||||
var store = new MemoryComparisonPairStore();
|
||||
var controller = new LegacyOperatorController(
|
||||
new EmptyStockLookup(),
|
||||
comparisonWorkflow: new LegacyComparisonWorkflowController(
|
||||
new StaticComparisonDataService(),
|
||||
store));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
|
||||
|
||||
var requested = await controller.DeleteComparisonPairAsync(all: true);
|
||||
|
||||
Assert.Equal("모두 삭제하겠습니까?", requested.Dialog?.Message);
|
||||
Assert.True(requested.Dialog?.IsConfirmation);
|
||||
var confirmed = await controller.ConfirmDialogAsync();
|
||||
Assert.Empty(confirmed.Comparison!.SavedPairs);
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
private static async Task<(LegacyOperatorController Controller,
|
||||
MemoryComparisonPairStore Store)> CreateControllerWithSavedPairAsync()
|
||||
{
|
||||
var store = new MemoryComparisonPairStore();
|
||||
var controller = new LegacyOperatorController(
|
||||
new EmptyStockLookup(),
|
||||
comparisonWorkflow: new LegacyComparisonWorkflowController(
|
||||
new StaticComparisonDataService(),
|
||||
store));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
|
||||
var searched = await controller.SearchComparisonDomesticAsync("반도체");
|
||||
controller.ChooseComparisonDomesticResult(
|
||||
searched.Comparison!.DomesticSearch.Results[0].SelectionId);
|
||||
controller.ChooseComparisonDomesticResult(
|
||||
searched.Comparison.DomesticSearch.Results[1].SelectionId);
|
||||
await controller.AddComparisonPairAsync();
|
||||
return (controller, store);
|
||||
}
|
||||
|
||||
private sealed class EmptyStockLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
|
||||
@@ -85,7 +85,7 @@ public sealed class LegacyExpertWorkflowTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyRecommendationPreview_RemainsVisibleButCannotCreateCut()
|
||||
public async Task EmptyRecommendationPreview_PreservesOriginalOneOverZeroCut()
|
||||
{
|
||||
var service = new DelegateExpertService
|
||||
{
|
||||
@@ -102,10 +102,12 @@ public sealed class LegacyExpertWorkflowTests
|
||||
Assert.Equal(LegacyExpertRequestStatus.Ready, selected.Preview.Status);
|
||||
Assert.Empty(selected.Preview.Items);
|
||||
Assert.Equal(0, selected.Preview.PageCount);
|
||||
Assert.False(Assert.Single(selected.Actions).IsAvailable);
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => controller.MaterializeSelectedAction(
|
||||
LegacyExpertWorkflowController.ActionId));
|
||||
Assert.True(Assert.Single(selected.Actions).IsAvailable);
|
||||
var draft = controller.MaterializeSelectedAction(
|
||||
LegacyExpertWorkflowController.ActionId);
|
||||
Assert.Equal(0, draft.ItemCount);
|
||||
Assert.Equal(0, draft.PageCount);
|
||||
Assert.Equal("1/0", draft.PageText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class LegacyIndustrySelectionWorkflowTests
|
||||
Assert.Null(state.SelectedIndustry);
|
||||
Assert.Null(state.FirstIndustry);
|
||||
Assert.Null(state.SecondIndustry);
|
||||
Assert.Equal(string.Empty, state.FirstText);
|
||||
Assert.Equal(string.Empty, state.SecondText);
|
||||
|
||||
var rows = Assert.IsAssignableFrom<IList<IndustrySelection>>(state.Industries);
|
||||
Assert.Throws<NotSupportedException>(() => rows.RemoveAt(0));
|
||||
@@ -29,6 +31,26 @@ public sealed class LegacyIndustrySelectionWorkflowTests
|
||||
Assert.Throws<NotSupportedException>(() => actions.RemoveAt(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMarketPreservesDuplicateRowsAndSelectionUsesTheClickedIndex()
|
||||
{
|
||||
var workflow = new LegacyIndustrySelectionWorkflow(new QueueIndustryService(
|
||||
[
|
||||
new IndustrySelection(IndustryMarket.Kospi, "동명이 업종", "013"),
|
||||
new IndustrySelection(IndustryMarket.Kospi, "동명이 업종", "014"),
|
||||
new IndustrySelection(IndustryMarket.Kospi, "다른 업종", "013")
|
||||
]));
|
||||
|
||||
var loaded = await workflow.LoadMarketAsync(IndustryMarket.Kospi);
|
||||
var selected = workflow.SelectIndustry(1);
|
||||
|
||||
Assert.Equal(3, loaded.Industries.Count);
|
||||
Assert.Equal(["013", "014", "013"],
|
||||
loaded.Industries.Select(static row => row.IndustryCode));
|
||||
Assert.Equal(1, selected.SelectedIndex);
|
||||
Assert.Equal("014", selected.SelectedIndustry?.IndustryCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDoubleClickRotatesFirstIntoSecondBeforeAssigningNewFirst()
|
||||
{
|
||||
@@ -62,10 +84,76 @@ public sealed class LegacyIndustrySelectionWorkflowTests
|
||||
var swapped = workflow.SwapIndustries();
|
||||
Assert.Null(swapped.FirstIndustry);
|
||||
Assert.Equal("전기전자", swapped.SecondIndustry?.IndustryName);
|
||||
Assert.Equal(string.Empty, swapped.FirstText);
|
||||
Assert.Equal("전기전자", swapped.SecondText);
|
||||
|
||||
var restored = workflow.SwapIndustries();
|
||||
Assert.Equal("전기전자", restored.FirstIndustry?.IndustryName);
|
||||
Assert.Null(restored.SecondIndustry);
|
||||
Assert.Equal("전기전자", restored.FirstText);
|
||||
Assert.Equal(string.Empty, restored.SecondText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditableComparisonTextRemainsCSharpAuthoritativeAndAffectsOnlyPairAction()
|
||||
{
|
||||
var workflow = await KospiWorkflowAsync();
|
||||
workflow.SelectIndustry(0);
|
||||
|
||||
var first = workflow.SetComparisonText(
|
||||
LegacyIndustryComparisonSlot.First,
|
||||
"직접 입력 업종");
|
||||
var second = workflow.SetComparisonText(
|
||||
LegacyIndustryComparisonSlot.Second,
|
||||
"또 다른 업종");
|
||||
|
||||
Assert.Null(first.FirstIndustry);
|
||||
Assert.Equal("직접 입력 업종", second.FirstText);
|
||||
Assert.Equal("또 다른 업종", second.SecondText);
|
||||
Assert.Equal("직접 입력 업종-또 다른 업종",
|
||||
workflow.MaterializePlaylistDraft("two-column").Selection.Subject);
|
||||
Assert.Equal("전기전자",
|
||||
workflow.MaterializePlaylistDraft("one-column").Selection.Subject);
|
||||
|
||||
var swapped = workflow.SwapIndustries();
|
||||
Assert.Equal("또 다른 업종", swapped.FirstText);
|
||||
Assert.Equal("직접 입력 업종", swapped.SecondText);
|
||||
|
||||
workflow.SetComparisonText(LegacyIndustryComparisonSlot.First, "수동 보존값");
|
||||
var rotated = workflow.DoubleClickIndustry(2);
|
||||
Assert.Equal("화학", rotated.FirstText);
|
||||
Assert.Equal("수동 보존값", rotated.SecondText);
|
||||
Assert.Equal("화학", rotated.FirstIndustry?.IndustryName);
|
||||
Assert.Null(rotated.SecondIndustry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditableComparisonTextValidatesAtBridgeCompatibleBoundary()
|
||||
{
|
||||
var workflow = await KospiWorkflowAsync();
|
||||
workflow.SelectIndustry(0);
|
||||
|
||||
var maximum = new string('가', LegacyIndustrySelectionWorkflow.MaximumComparisonTextLength);
|
||||
Assert.Equal(maximum, workflow.SetComparisonText(
|
||||
LegacyIndustryComparisonSlot.First,
|
||||
maximum).FirstText);
|
||||
Assert.Throws<LegacyIndustryWorkflowException>(() => workflow.SetComparisonText(
|
||||
LegacyIndustryComparisonSlot.First,
|
||||
maximum + "가"));
|
||||
Assert.Throws<LegacyIndustryWorkflowException>(() => workflow.SetComparisonText(
|
||||
LegacyIndustryComparisonSlot.First,
|
||||
"업종\n이름"));
|
||||
Assert.Throws<LegacyIndustryWorkflowException>(() => workflow.SetComparisonText(
|
||||
LegacyIndustryComparisonSlot.First,
|
||||
"업종\ud83d\ude00"));
|
||||
|
||||
workflow.SetComparisonText(LegacyIndustryComparisonSlot.First, "공백 아님");
|
||||
workflow.SetComparisonText(LegacyIndustryComparisonSlot.Second, " ");
|
||||
Assert.Throws<LegacyIndustryWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("two-column"));
|
||||
workflow.SetComparisonText(LegacyIndustryComparisonSlot.Second, "하이-픈");
|
||||
Assert.Throws<LegacyIndustryWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("two-column"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -86,6 +174,8 @@ public sealed class LegacyIndustrySelectionWorkflowTests
|
||||
Assert.Equal("운수장비", reconciled.SelectedIndustry?.IndustryName);
|
||||
Assert.Equal("운수장비", reconciled.FirstIndustry?.IndustryName);
|
||||
Assert.Equal("전기전자", reconciled.SecondIndustry?.IndustryName);
|
||||
Assert.Equal("운수장비", reconciled.FirstText);
|
||||
Assert.Equal("전기전자", reconciled.SecondText);
|
||||
|
||||
var changed = await workflow.LoadMarketAsync(IndustryMarket.Kosdaq);
|
||||
Assert.Equal(IndustryMarket.Kosdaq, changed.Market);
|
||||
@@ -93,6 +183,8 @@ public sealed class LegacyIndustrySelectionWorkflowTests
|
||||
Assert.Null(changed.SelectedIndustry);
|
||||
Assert.Null(changed.FirstIndustry);
|
||||
Assert.Null(changed.SecondIndustry);
|
||||
Assert.Equal(string.Empty, changed.FirstText);
|
||||
Assert.Equal(string.Empty, changed.SecondText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -39,7 +39,39 @@ public sealed class LegacyOperatorControllerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IndustryParentDoubleClickMaterializesDirectChildrenAtomicallyInOrder()
|
||||
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(
|
||||
@@ -53,16 +85,15 @@ public sealed class LegacyOperatorControllerTests
|
||||
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));
|
||||
|
||||
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]
|
||||
@@ -96,11 +127,123 @@ public sealed class LegacyOperatorControllerTests
|
||||
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(
|
||||
"GAIN_DESC",
|
||||
new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
||||
"PAGED_THEME",
|
||||
"AI 테마",
|
||||
"GAIN_DESC",
|
||||
"CURRENT",
|
||||
"00000001"),
|
||||
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
row.ToPlayoutEntry().Selection).GraphicType);
|
||||
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<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
row.ToPlayoutEntry().Selection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -962,6 +1105,19 @@ public sealed class LegacyOperatorControllerTests
|
||||
}
|
||||
}
|
||||
|
||||
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<IndustrySelection> _rows;
|
||||
@@ -991,17 +1147,30 @@ public sealed class LegacyOperatorControllerTests
|
||||
|
||||
public int PreviewCalls { get; private set; }
|
||||
|
||||
public List<ThemeSession> SearchSessions { get; } = [];
|
||||
|
||||
public Task<ThemeSearchResult> SearchAsync(
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
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, DataSourceKind.Oracle)],
|
||||
[new ThemeSelectorItem(
|
||||
identity,
|
||||
identity.Market == ThemeMarket.Krx
|
||||
? DataSourceKind.Oracle
|
||||
: DataSourceKind.MariaDb)],
|
||||
false));
|
||||
}
|
||||
|
||||
public Task<ThemePreviewResult> PreviewAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
@@ -1011,7 +1180,7 @@ public sealed class LegacyOperatorControllerTests
|
||||
PreviewCalls++;
|
||||
return Task.FromResult(
|
||||
new ThemePreviewResult(
|
||||
_identity,
|
||||
identity,
|
||||
DateTimeOffset.UtcNow,
|
||||
_items,
|
||||
false));
|
||||
|
||||
@@ -32,10 +32,10 @@ public sealed class LegacyOverseasOperatorControllerTests
|
||||
entry.Selection);
|
||||
|
||||
Assert.Equal("legacy-overseas-00000001", row.RowId);
|
||||
Assert.Equal("FOREIGN_INDEX", row.MarketText);
|
||||
Assert.Equal("나스닥 캔들 20일", row.StockName);
|
||||
Assert.Equal("해외지수", row.MarketText);
|
||||
Assert.Equal("나스닥", row.StockName);
|
||||
Assert.Equal("캔들그래프", row.GraphicType);
|
||||
Assert.Equal("PRICE", row.Subtype);
|
||||
Assert.Equal("20일", row.Subtype);
|
||||
Assert.Equal("1/1", row.PageText);
|
||||
Assert.Equal("8040", entry.CutCode);
|
||||
Assert.Equal("FOREIGN_INDEX", selection.GroupCode);
|
||||
@@ -86,6 +86,32 @@ public sealed class LegacyOverseasOperatorControllerTests
|
||||
controller.CreatePlayoutRequest().Playlist[1].Selection!.GraphicType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonUsStockKeepsOneExactIdentityThroughBothActualUc5SceneAliases()
|
||||
{
|
||||
var controller = CreateController(
|
||||
stocks: [new("London Stock", "LNS@LON", "GB")]);
|
||||
controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
|
||||
|
||||
// UC5's first inserted result is immediately the active RowMode row.
|
||||
var searched = await controller.SearchOverseasStocksAsync("london");
|
||||
Assert.Equal("London Stock", searched.Overseas!.Stock.Selected?.InputName);
|
||||
|
||||
var oneColumn = controller.ActivateOverseasAction("stock-current");
|
||||
var candle = controller.ActivateOverseasAction("stock-candle-5d");
|
||||
var rows = candle.Playlist;
|
||||
|
||||
Assert.Equal("5001", rows[0].ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("8061", rows[1].ToPlayoutEntry().CutCode);
|
||||
Assert.All(rows, row =>
|
||||
{
|
||||
Assert.Equal("FOREIGN_STOCK", row.PlayoutSelection?.GroupCode);
|
||||
Assert.Equal("London Stock", row.PlayoutSelection?.Subject);
|
||||
Assert.Equal("LNS@LON|GB|1", row.PlayoutSelection?.DataCode);
|
||||
});
|
||||
Assert.Single(oneColumn.Playlist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OverseasActionsRejectInactiveTabAndStaleSearchIds()
|
||||
{
|
||||
@@ -103,7 +129,7 @@ public sealed class LegacyOverseasOperatorControllerTests
|
||||
|
||||
var stale = controller.SelectOverseasIndustry(staleId);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Error, stale.StatusKind);
|
||||
Assert.Null(stale.Overseas!.Industry.Selected);
|
||||
Assert.Equal("반도체", stale.Overseas!.Industry.Selected?.KoreanName);
|
||||
}
|
||||
|
||||
private static LegacyOperatorController CreateController(
|
||||
@@ -147,11 +173,11 @@ public sealed class LegacyOverseasOperatorControllerTests
|
||||
}
|
||||
|
||||
private sealed class StaticStockService(
|
||||
IReadOnlyList<WorldStockSearchItem> items) : IWorldStockSearchService
|
||||
IReadOnlyList<WorldStockSearchItem> items) : IOverseasStockSearchService
|
||||
{
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -9,7 +9,7 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
new(2026, 7, 15, 9, 30, 0, TimeSpan.FromHours(9));
|
||||
|
||||
[Fact]
|
||||
public void InitialSnapshotIsTypedImmutableAndContainsClosedCatalogs()
|
||||
public void InitialSnapshotIsTypedImmutableAndKeepsFarPointFirstFixedRowActive()
|
||||
{
|
||||
var workflow = CreateWorkflow();
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
Assert.Equal(0, snapshot.Revision);
|
||||
Assert.Equal(10, snapshot.FixedIndexTargets.Count);
|
||||
Assert.Equal(12, snapshot.Actions.Count);
|
||||
Assert.Null(snapshot.SelectedFixedIndex);
|
||||
Assert.Equal("dow-jones", snapshot.SelectedFixedIndex?.TargetId);
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Industry.Status);
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Stock.Status);
|
||||
Assert.Empty(snapshot.Industry.Results);
|
||||
@@ -68,6 +68,8 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
Assert.Equal(
|
||||
["overseas-stock-00000001-0001", "overseas-stock-00000001-0002"],
|
||||
stockState.Stock.Results.Select(row => row.SelectionId));
|
||||
Assert.Equal("Alpha", industryState.Industry.Selected?.KoreanName);
|
||||
Assert.Equal("Apple", stockState.Stock.Selected?.InputName);
|
||||
Assert.Equal(2, workflow.Current.Industry.ResultCount);
|
||||
Assert.Equal(2, workflow.Current.Stock.ResultCount);
|
||||
|
||||
@@ -81,6 +83,28 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
Assert.Throws<NotSupportedException>(() => stockRows.RemoveAt(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstInsertedSearchRowsBecomeTheActiveSourcesAtTheFullCompatibilityBound()
|
||||
{
|
||||
var industry = new RecordingIndustryService(
|
||||
[new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")]);
|
||||
var stock = new RecordingStockService(
|
||||
[new WorldStockSearchItem("Apple", "NAS@AAPL", "US")]);
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
|
||||
|
||||
var industries = await workflow.SearchIndustriesAsync("alpha");
|
||||
var stocks = await workflow.SearchStocksAsync("apple");
|
||||
|
||||
Assert.Equal("Alpha", industries.Industry.Selected?.KoreanName);
|
||||
Assert.Equal("Apple", stocks.Stock.Selected?.InputName);
|
||||
Assert.Equal(
|
||||
[("alpha", LegacyOverseasSelectionWorkflow.DefaultMaximumResults)],
|
||||
industry.Calls);
|
||||
Assert.Equal(
|
||||
[("apple", LegacyOverseasStockSearchService.MaximumResults)],
|
||||
stock.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectionUsesCurrentOpaqueIdsAndARefreshInvalidatesOldIds()
|
||||
{
|
||||
@@ -108,8 +132,8 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
await workflow.SearchIndustriesAsync("next");
|
||||
await workflow.SearchStocksAsync("next");
|
||||
|
||||
Assert.Null(workflow.Current.Industry.Selected);
|
||||
Assert.Null(workflow.Current.Stock.Selected);
|
||||
Assert.Equal("Beta", workflow.Current.Industry.Selected?.KoreanName);
|
||||
Assert.Equal("Taiwan Semi", workflow.Current.Stock.Selected?.InputName);
|
||||
Assert.Throws<KeyNotFoundException>(() => workflow.SelectIndustry(oldIndustryId));
|
||||
Assert.Throws<KeyNotFoundException>(() => workflow.SelectStock(oldStockId));
|
||||
Assert.StartsWith(
|
||||
@@ -145,7 +169,7 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
Assert.Equal([pair.Value.Alias], draft.Aliases);
|
||||
Assert.Equal(0, draft.PageSize);
|
||||
Assert.Equal("1/1", draft.InitialPageText);
|
||||
Assert.Equal($"독일 DAX 30 캔들 {pair.Value.Days}일", draft.Title);
|
||||
Assert.Equal("독일 DAX 30", draft.Title);
|
||||
Assert.Equal("chart", draft.Category);
|
||||
Assert.Equal("overseas", draft.Market);
|
||||
Assert.Equal(LegacyOverseasActionSource.FixedIndex, draft.Source);
|
||||
@@ -160,6 +184,10 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
Assert.Equal(new LegacyOverseasMovingAverageSelection(true, false), draft.MovingAverages);
|
||||
Assert.True(draft.IsEnabled);
|
||||
Assert.Equal(6, draft.FadeDuration);
|
||||
Assert.Equal(
|
||||
new LegacyOverseasPlaylistVisibleFields(
|
||||
"해외지수", "독일 DAX 30", "캔들그래프", $"{pair.Value.Days}일", "1/1"),
|
||||
draft.VisibleFields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +217,10 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
"US Semiconductor|NAS@SOX|US|0"),
|
||||
draft.Selection);
|
||||
Assert.Equal(default, draft.MovingAverages);
|
||||
Assert.Equal(
|
||||
new LegacyOverseasPlaylistVisibleFields(
|
||||
"해외업종", "반도체", "1열판기본", string.Empty, "1/1"),
|
||||
draft.VisibleFields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -213,6 +245,10 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
"TWS@2330|TW|1"),
|
||||
current.Selection);
|
||||
Assert.Equal(default, current.MovingAverages);
|
||||
Assert.Equal(
|
||||
new LegacyOverseasPlaylistVisibleFields(
|
||||
"해외종목", "Taiwan Semi", "1열판기본", string.Empty, "1/1"),
|
||||
current.VisibleFields);
|
||||
|
||||
var expectedAliases = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -232,6 +268,11 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
Assert.Equal("PRICE", candle.Selection.Subtype);
|
||||
Assert.Equal("TWS@2330|TW|1", candle.Selection.DataCode);
|
||||
Assert.Equal(new LegacyOverseasMovingAverageSelection(true, true), candle.MovingAverages);
|
||||
Assert.Equal(
|
||||
new LegacyOverseasPlaylistVisibleFields(
|
||||
"해외종목", "Taiwan Semi", "캔들그래프",
|
||||
$"{LegacyOverseasActionCatalog.GetAction(pair.Key).PeriodDays}일", "1/1"),
|
||||
candle.VisibleFields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +302,9 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
{
|
||||
var workflow = CreateWorkflow();
|
||||
|
||||
Assert.Throws<LegacyOverseasWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("index-candle-5d"));
|
||||
Assert.Equal(
|
||||
"DJI@DJI",
|
||||
workflow.MaterializePlaylistDraft("index-candle-5d").Selection.DataCode.Split('|')[0]);
|
||||
Assert.Throws<LegacyOverseasWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("industry-current"));
|
||||
Assert.Throws<LegacyOverseasWorkflowException>(() =>
|
||||
@@ -280,6 +322,38 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
Assert.Equal(typeof(string), parameter.ParameterType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonUsTwSearchHitMaterializesAnExactAllNationIdentity()
|
||||
{
|
||||
var workflow = CreateWorkflow(stocks: [new("London Stock", "LNS@LON", "GB")]);
|
||||
var searched = await workflow.SearchStocksAsync("london");
|
||||
workflow.SelectStock(Assert.Single(searched.Stock.Results).SelectionId);
|
||||
|
||||
var availability = workflow.GetActionAvailability("stock-current");
|
||||
|
||||
Assert.True(availability.IsAvailable);
|
||||
Assert.Null(availability.Reason);
|
||||
var current = workflow.MaterializePlaylistDraft("stock-current");
|
||||
var candle = workflow.MaterializePlaylistDraft("stock-candle-5d");
|
||||
Assert.Equal("LNS@LON|GB|1", current.Selection.DataCode);
|
||||
Assert.Equal("LNS@LON|GB|1", candle.Selection.DataCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThreeLetterNationRowsRemainVisibleButFailClosedBeforePlaylistMaterialization()
|
||||
{
|
||||
var workflow = CreateWorkflow(stocks: [new("Unsupported", "LNS@UNSUPPORTED", "GBR")]);
|
||||
|
||||
var searched = await workflow.SearchStocksAsync("unsupported");
|
||||
|
||||
Assert.Equal("Unsupported", searched.Stock.Selected?.InputName);
|
||||
var availability = workflow.GetActionAvailability("stock-current");
|
||||
Assert.False(availability.IsAvailable);
|
||||
Assert.Equal("선택한 해외종목의 국가 코드는 두 글자여야 합니다.", availability.Reason);
|
||||
Assert.Throws<LegacyOverseasWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("stock-current"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowerIndustryResponseCannotReplaceOrInvalidateNewerState()
|
||||
{
|
||||
@@ -361,7 +435,7 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
{
|
||||
var invalid = StockResult(
|
||||
"query",
|
||||
new WorldStockSearchItem("London", "LNS@BAD", "GB"));
|
||||
new WorldStockSearchItem("London", "LNS@BAD", "gB"));
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(
|
||||
new RecordingIndustryService([]),
|
||||
new QueueStockService(invalid));
|
||||
@@ -465,13 +539,13 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
}
|
||||
|
||||
private sealed class RecordingStockService(
|
||||
IReadOnlyList<WorldStockSearchItem> items) : IWorldStockSearchService
|
||||
IReadOnlyList<WorldStockSearchItem> items) : IOverseasStockSearchService
|
||||
{
|
||||
public List<(string Query, int MaximumResults)> Calls { get; } = [];
|
||||
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -500,13 +574,13 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
}
|
||||
|
||||
private sealed class QueueStockService(
|
||||
params WorldStockSearchResult[] results) : IWorldStockSearchService
|
||||
params WorldStockSearchResult[] results) : IOverseasStockSearchService
|
||||
{
|
||||
private readonly Queue<WorldStockSearchResult> _results = new(results);
|
||||
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -534,14 +608,14 @@ public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
_pending[query].SetResult(result);
|
||||
}
|
||||
|
||||
private sealed class ControlledStockService : IWorldStockSearchService
|
||||
private sealed class ControlledStockService : IOverseasStockSearchService
|
||||
{
|
||||
private readonly Dictionary<string, TaskCompletionSource<WorldStockSearchResult>> _pending =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = new TaskCompletionSource<WorldStockSearchResult>(
|
||||
|
||||
@@ -16,7 +16,11 @@ public sealed class LegacyTabActivationParityTests
|
||||
|
||||
var first = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
Assert.Equal(string.Empty, service.Queries.Single());
|
||||
Assert.Single(first.Theme!.SearchResults);
|
||||
var firstResultId = Assert.Single(first.Theme!.SearchResults).ResultId;
|
||||
controller.SetThemeSort("GAIN_DESC");
|
||||
var selected = await controller.SelectThemeAsync(firstResultId);
|
||||
Assert.Equal(LegacyThemeSort.GainRateDescending, selected.Theme!.Sort);
|
||||
Assert.NotNull(selected.Theme.SelectedTheme);
|
||||
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
Assert.Single(service.Queries);
|
||||
@@ -29,6 +33,10 @@ public sealed class LegacyTabActivationParityTests
|
||||
query => Assert.Equal(string.Empty, query),
|
||||
query => Assert.Equal(string.Empty, query));
|
||||
Assert.Single(second.Theme!.SearchResults);
|
||||
Assert.Equal(LegacyThemeSort.InputOrder, second.Theme.Sort);
|
||||
Assert.Null(second.Theme.SelectedResultId);
|
||||
Assert.Null(second.Theme.SelectedTheme);
|
||||
Assert.Empty(second.Theme.PreviewItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -213,7 +221,7 @@ public sealed class LegacyTabActivationParityTests
|
||||
controller.SelectTab(LegacyOperatorTabId.Index);
|
||||
var reentered = controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
|
||||
|
||||
Assert.Null(reentered.Overseas!.SelectedFixedIndex);
|
||||
Assert.Equal("dow-jones", reentered.Overseas!.SelectedFixedIndex?.TargetId);
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Idle, reentered.Overseas.Industry.Status);
|
||||
Assert.Equal(string.Empty, reentered.Overseas.Industry.Query);
|
||||
Assert.Empty(reentered.Overseas.Industry.Results);
|
||||
@@ -493,11 +501,11 @@ public sealed class LegacyTabActivationParityTests
|
||||
false));
|
||||
}
|
||||
|
||||
private sealed class StaticOverseasStockService : IWorldStockSearchService
|
||||
private sealed class StaticOverseasStockService : IOverseasStockSearchService
|
||||
{
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new WorldStockSearchResult(
|
||||
query.Trim(),
|
||||
|
||||
@@ -79,17 +79,59 @@ public sealed class LegacyThemeWorkflowTests
|
||||
workflow.CreateInitial(ThemeSession.NotApplicable));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, ThemeSession.PreMarket)]
|
||||
[InlineData(12, ThemeSession.PreMarket)]
|
||||
[InlineData(13, ThemeSession.AfterMarket)]
|
||||
[InlineData(23, ThemeSession.AfterMarket)]
|
||||
public void CurrentNxtSession_UsesTheOriginalNativeLocalHourBoundary(
|
||||
int hour,
|
||||
ThemeSession expected)
|
||||
{
|
||||
var workflow = new LegacyThemeWorkflow(
|
||||
new FakeThemeSelectionService(),
|
||||
() => new DateTimeOffset(2026, 7, 18, hour, 0, 0, TimeSpan.FromHours(9)));
|
||||
|
||||
Assert.Equal(expected, workflow.CurrentNxtSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_NormalizesQueryPreservesExplicitIdentityAndOrdersRows()
|
||||
public async Task ResetForTabActivation_RestoresTheNewUc4Defaults()
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var workflow = new LegacyThemeWorkflow(
|
||||
ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]));
|
||||
var selected = workflow.SelectSort(
|
||||
await SearchAndSelectAsync(workflow, ThemeSession.AfterMarket),
|
||||
"GAIN_DESC");
|
||||
|
||||
var reset = workflow.ResetForTabActivation(selected);
|
||||
|
||||
Assert.Equal(selected.Revision + 1, reset.Revision);
|
||||
Assert.Equal(string.Empty, reset.Query);
|
||||
Assert.Equal(ThemeSession.PreMarket, reset.NxtSession);
|
||||
Assert.Equal(LegacyThemeSort.InputOrder, reset.Sort);
|
||||
Assert.Empty(reset.SearchResults);
|
||||
Assert.Null(reset.SelectedResultId);
|
||||
Assert.Null(reset.SelectedTheme);
|
||||
Assert.Empty(reset.PreviewItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_NormalizesQueryAndPreservesProviderOrderWithinKrxThenNxt()
|
||||
{
|
||||
var service = new FakeThemeSelectionService
|
||||
{
|
||||
SearchHandler = (query, session) => SearchResult(
|
||||
query,
|
||||
session,
|
||||
Selector(ThemeMarket.Nxt, session, "Robotics", "00000003"),
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Zulu", "00000002"),
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Alpha", "00000001"))
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Alpha", "00000001"),
|
||||
Selector(ThemeMarket.Nxt, session, "Robotics", "00000003"))
|
||||
};
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var initial = workflow.CreateInitial();
|
||||
@@ -110,12 +152,12 @@ public sealed class LegacyThemeWorkflowTests
|
||||
row =>
|
||||
{
|
||||
Assert.Equal("theme-result-001", row.ResultId);
|
||||
Assert.Equal("Alpha", row.DisplayTitle);
|
||||
Assert.Equal("Zulu", row.DisplayTitle);
|
||||
Assert.Equal(ThemeMarket.Krx, row.Market);
|
||||
Assert.Equal(ThemeSession.NotApplicable, row.Session);
|
||||
Assert.Equal(DataSourceKind.Oracle, row.Source);
|
||||
},
|
||||
row => Assert.Equal("Zulu", row.DisplayTitle),
|
||||
row => Assert.Equal("Alpha", row.DisplayTitle),
|
||||
row =>
|
||||
{
|
||||
Assert.Equal("theme-result-003", row.ResultId);
|
||||
@@ -195,8 +237,30 @@ public sealed class LegacyThemeWorkflowTests
|
||||
session,
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"),
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000002"));
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
|
||||
var duplicateTitles = await workflow.SearchAsync(
|
||||
snapshot,
|
||||
"AI",
|
||||
ThemeSession.PreMarket);
|
||||
Assert.Equal(2, duplicateTitles.SearchResults.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_RejectsProviderRowsThatWouldRequireReordering()
|
||||
{
|
||||
var service = new FakeThemeSelectionService
|
||||
{
|
||||
SearchHandler = (query, session) => SearchResult(
|
||||
query,
|
||||
session,
|
||||
Selector(ThemeMarket.Nxt, session, "NXT first", "00000002"),
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "KRX later", "00000001"))
|
||||
};
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SearchAsync(workflow.CreateInitial(), "", ThemeSession.PreMarket));
|
||||
|
||||
Assert.Contains("search provider order", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -279,6 +343,38 @@ public sealed class LegacyThemeWorkflowTests
|
||||
Assert.Single(selected.PreviewItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_RetainsOnlyACorrelatedSelectionAndPreview()
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var selected = workflow.SelectSort(await SearchAndSelectAsync(workflow), "GAIN_DESC");
|
||||
|
||||
var refreshed = await workflow.SearchAsync(
|
||||
selected,
|
||||
"AI",
|
||||
ThemeSession.AfterMarket);
|
||||
|
||||
Assert.Equal(
|
||||
new LegacySelectedThemeSummary(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
1,
|
||||
false),
|
||||
refreshed.SelectedTheme);
|
||||
Assert.NotNull(refreshed.SelectedResultId);
|
||||
Assert.Single(refreshed.PreviewItems);
|
||||
Assert.Equal("삼성전자", refreshed.PreviewItems[0].ItemName);
|
||||
Assert.Equal(LegacyThemeSort.GainRateDescending, refreshed.Sort);
|
||||
Assert.Single(service.PreviewCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectAsync_RejectsUnknownResultWithoutCallingPreview()
|
||||
{
|
||||
@@ -398,7 +494,18 @@ public sealed class LegacyThemeWorkflowTests
|
||||
Assert.Equal(action.Code, draft.Code);
|
||||
Assert.Equal(action.Aliases, draft.Aliases);
|
||||
Assert.Equal("AI", draft.Title);
|
||||
Assert.Equal($"{action.Label} · {sortLabel}", draft.Detail);
|
||||
var expectedGraphicType = action.Id switch
|
||||
{
|
||||
"five-row-current" or "five-row-expected" => "5단 표그래프",
|
||||
"six-row-current" => "6종목 현재가",
|
||||
"twelve-row-current" => "12종목 현재가",
|
||||
_ => throw new InvalidOperationException()
|
||||
};
|
||||
var expectedSubtype =
|
||||
$"테마-{(action.ValueKind == LegacyThemeValueKind.Expected ? "예상체결가" : "현재가")}({sortLabel})";
|
||||
Assert.Equal(expectedGraphicType, draft.PlaylistGraphicType);
|
||||
Assert.Equal(expectedSubtype, draft.PlaylistSubtype);
|
||||
Assert.Equal(expectedSubtype, draft.Detail);
|
||||
Assert.Equal("plate", draft.Category);
|
||||
Assert.Equal("theme", draft.Market);
|
||||
Assert.Equal("PAGED_THEME", draft.Selection.GroupCode);
|
||||
@@ -418,7 +525,7 @@ public sealed class LegacyThemeWorkflowTests
|
||||
[InlineData("INPUT_ORDER")]
|
||||
[InlineData("GAIN_DESC")]
|
||||
[InlineData("GAIN_ASC")]
|
||||
public async Task MaterializePlaylistDraft_NxtKeepsSessionAndBlocksExpected(
|
||||
public async Task MaterializePlaylistDraft_NxtKeepsSessionAndExpectedClickShowsOriginalWarning(
|
||||
string sortId)
|
||||
{
|
||||
var identity = Identity(
|
||||
@@ -427,7 +534,9 @@ public sealed class LegacyThemeWorkflowTests
|
||||
"Robotics",
|
||||
"00000002");
|
||||
var service = ServiceWithTheme(identity, CreateItems(7, ThemeMarket.Nxt));
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var workflow = new LegacyThemeWorkflow(
|
||||
service,
|
||||
() => new DateTimeOffset(2026, 7, 18, 14, 0, 0, TimeSpan.FromHours(9)));
|
||||
var selected = workflow.SelectSort(
|
||||
await SearchAndSelectAsync(workflow, ThemeSession.AfterMarket),
|
||||
sortId);
|
||||
@@ -451,11 +560,38 @@ public sealed class LegacyThemeWorkflowTests
|
||||
}
|
||||
|
||||
var expectedState = workflow.GetActionState(selected, "five-row-expected");
|
||||
Assert.False(expectedState.IsAvailable);
|
||||
Assert.Equal("expected-theme-is-krx-only", expectedState.UnavailableReason);
|
||||
Assert.True(expectedState.IsAvailable);
|
||||
Assert.Null(expectedState.UnavailableReason);
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.MaterializePlaylistDraft(selected, "five-row-expected"));
|
||||
Assert.Contains("expected-price", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Equal("NXT는 데이터가 없습니다.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaterializePlaylistDraft_NxtReevaluatesTheNativeHourAtActionTime()
|
||||
{
|
||||
var localNow = new DateTimeOffset(
|
||||
2026,
|
||||
7,
|
||||
18,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
TimeSpan.FromHours(9));
|
||||
var identity = Identity(
|
||||
ThemeMarket.Nxt,
|
||||
ThemeSession.PreMarket,
|
||||
"Robotics",
|
||||
"00000002");
|
||||
var workflow = new LegacyThemeWorkflow(
|
||||
ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Nxt)),
|
||||
() => localNow);
|
||||
var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket);
|
||||
localNow = localNow.AddHours(4);
|
||||
|
||||
var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current");
|
||||
|
||||
Assert.Equal("AFTER_MARKET", draft.Selection.GraphicType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -131,68 +131,102 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EList_preserves_buy_amount_and_CSharp_owned_reorder_in_one_create()
|
||||
public async Task ThemeA_create_save_repeats_the_duplicate_check_and_does_not_write()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
|
||||
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
|
||||
fixture.ThemePersistence.NameExists = true;
|
||||
|
||||
var before = workflow.Current;
|
||||
var result = await workflow.SaveAsync("중복 테마");
|
||||
|
||||
Assert.Equal(before.Revision + 1, result.Revision);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Create, result.Mode);
|
||||
Assert.Equal(LegacyThemeNameAvailability.Duplicate, result.ThemeNameAvailability);
|
||||
Assert.Equal("이미 사용중인 테마명입니다.", result.StatusMessage);
|
||||
Assert.Equal(1, fixture.ThemePersistence.NameExistsCalls);
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
Assert.Equal(0, fixture.IdentityValidation.ThemeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeA_duplicate_preflight_stays_inside_the_correlation_gate()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
fixture.ThemePersistence.NameExistsHandler = async () =>
|
||||
{
|
||||
entered.SetResult();
|
||||
return await release.Task;
|
||||
};
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
|
||||
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
|
||||
|
||||
var save = workflow.SaveAsync("상관관계 변경 테마");
|
||||
await entered.Task;
|
||||
workflow.InvalidateBrowserCorrelation();
|
||||
release.SetResult(false);
|
||||
var result = await save;
|
||||
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
Assert.Equal(1, fixture.IdentityValidation.ThemeCalls);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Create, result.Mode);
|
||||
Assert.Contains("변경되어", result.StatusMessage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EList_create_accepts_only_a_name_and_rejects_forged_recommendation_mutations()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.StockSearch.Items =
|
||||
[
|
||||
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "삼성전자", "005930"),
|
||||
new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "카카오", "035720")
|
||||
];
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
|
||||
await workflow.BeginCreateExpertAsync();
|
||||
|
||||
var stocks = await workflow.SearchStocksAsync("주식");
|
||||
workflow.SelectStock(stocks.StockResults[0].ResultId);
|
||||
workflow.AddSelectedStock(70_000);
|
||||
workflow.SelectStock(stocks.StockResults[1].ResultId);
|
||||
var twoRows = workflow.AddSelectedStock(45_000);
|
||||
twoRows = workflow.SetDraftBuyAmount(twoRows.DraftRows[1].RowId, 46_000);
|
||||
workflow.MoveDraftRow(twoRows.DraftRows[1].RowId, LegacyOperatorCatalogMoveDirection.Up);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
workflow.SearchStocksAsync("주식"));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.AddSelectedStock(null));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.SetDraftBuyAmount("catalog-draft-forged", 70_000));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.RemoveDraftRow("catalog-draft-forged"));
|
||||
|
||||
Assert.Equal(0, fixture.ExpertPersistence.CreateCalls);
|
||||
await workflow.SaveAsync("김전문");
|
||||
|
||||
Assert.Equal(1, fixture.ExpertPersistence.CreateCalls);
|
||||
Assert.Equal(1, fixture.IdentityValidation.ExpertCalls);
|
||||
Assert.Equal(0, fixture.IdentityValidation.ExpertCalls);
|
||||
var definition = Assert.IsType<ExpertCatalogDefinition>(
|
||||
fixture.ExpertPersistence.LastCreated);
|
||||
Assert.Collection(
|
||||
definition.Recommendations,
|
||||
row =>
|
||||
{
|
||||
Assert.Equal("035720", row.StockCode);
|
||||
Assert.Equal(46_000, row.BuyAmount);
|
||||
Assert.Equal(0, row.PlayIndex);
|
||||
},
|
||||
row =>
|
||||
{
|
||||
Assert.Equal("005930", row.StockCode);
|
||||
Assert.Equal(70_000, row.BuyAmount);
|
||||
Assert.Equal(1, row.PlayIndex);
|
||||
});
|
||||
Assert.Empty(definition.Recommendations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EList_drag_reorder_moves_to_arbitrary_position_and_duplicate_is_no_op()
|
||||
public async Task EList_edit_adds_blank_duplicate_rows_then_reorders_by_opaque_row_id()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.ExpertSelection.PreviewItems =
|
||||
[
|
||||
new ExpertRecommendationPreview(0, "000001", "First", 10_000),
|
||||
new ExpertRecommendationPreview(1, "000002", "Second", 20_000),
|
||||
new ExpertRecommendationPreview(2, "000003", "Third", 30_000)
|
||||
];
|
||||
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")
|
||||
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "First", "000001")
|
||||
];
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
|
||||
await workflow.BeginCreateExpertAsync();
|
||||
await workflow.OpenExpertForEditAsync(new ExpertSelectionIdentity("0001", "이전문"));
|
||||
var stocks = await workflow.SearchStocksAsync("stock");
|
||||
foreach (var stock in stocks.StockResults)
|
||||
{
|
||||
workflow.SelectStock(stock.ResultId);
|
||||
workflow.AddSelectedStock(10_000);
|
||||
}
|
||||
workflow.SelectStock(Assert.Single(stocks.StockResults).ResultId);
|
||||
var withDuplicate = workflow.AddSelectedStock(null);
|
||||
Assert.Equal(4, withDuplicate.DraftRows.Count);
|
||||
Assert.Null(withDuplicate.DraftRows[3].BuyAmount);
|
||||
workflow.SetDraftBuyAmount(withDuplicate.DraftRows[3].RowId, 40_000);
|
||||
|
||||
var initial = workflow.Current.DraftRows.ToArray();
|
||||
var moved = workflow.ReorderDraftRow(
|
||||
@@ -200,9 +234,9 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
initial[2].RowId,
|
||||
LegacyDragDropPosition.After);
|
||||
Assert.Equal(
|
||||
[initial[1].RowId, initial[2].RowId, initial[0].RowId],
|
||||
[initial[1].RowId, initial[2].RowId, initial[0].RowId, initial[3].RowId],
|
||||
moved.DraftRows.Select(row => row.RowId));
|
||||
Assert.Equal([0, 1, 2], moved.DraftRows.Select(row => row.Position));
|
||||
Assert.Equal([0, 1, 2, 3], moved.DraftRows.Select(row => row.Position));
|
||||
|
||||
var revision = moved.Revision;
|
||||
var duplicate = workflow.ReorderDraftRow(
|
||||
@@ -215,6 +249,234 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
duplicate.DraftRows.Select(row => row.RowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uc6_edit_save_with_zero_recommendations_is_a_completely_silent_no_op()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.ExpertSelection.PreviewItems = [];
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
var editing = await workflow.OpenExpertForEditAsync(
|
||||
new ExpertSelectionIdentity("0001", "이전문"));
|
||||
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Edit, editing.Mode);
|
||||
Assert.Empty(editing.DraftRows);
|
||||
var result = await workflow.SaveAsync(string.Empty);
|
||||
|
||||
Assert.Equal(editing.Revision, result.Revision);
|
||||
Assert.Equal(editing.StatusMessage, result.StatusMessage);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Edit, result.Mode);
|
||||
Assert.Equal(0, fixture.ExpertPersistence.ReplaceCalls);
|
||||
Assert.Equal(0, fixture.IdentityValidation.ExpertCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeA_delete_all_clears_only_the_local_draft_until_save()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
|
||||
var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
|
||||
|
||||
var cleared = workflow.ClearThemeDraftRows();
|
||||
|
||||
Assert.Empty(cleared.DraftRows);
|
||||
Assert.Equal(editing.Revision + 1, cleared.Revision);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uc4ThemeAdd_OpensTheOriginalKrxDefaultAndLetsTheOperatorChooseNxt()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var controller = fixture.CreateController();
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
|
||||
var opened = await controller.BeginThemeCatalogFromUc4Async();
|
||||
|
||||
var catalog = Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
|
||||
opened.OperatorCatalog);
|
||||
Assert.True(catalog.IsOpen);
|
||||
Assert.Equal(LegacyOperatorCatalogEntity.Theme, catalog.Entity);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.List, catalog.Mode);
|
||||
Assert.Equal(ThemeMarket.Krx, catalog.NewThemeMarket);
|
||||
Assert.True(catalog.CanBeginCreate);
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
|
||||
var creating = await controller.BeginCreateThemeCatalogAsync(ThemeMarket.Nxt);
|
||||
|
||||
catalog = Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
|
||||
creating.OperatorCatalog);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Create, catalog.Mode);
|
||||
Assert.Equal(ThemeMarket.Nxt, catalog.NewThemeMarket);
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeA_edit_projects_the_selected_nxt_market_into_the_original_market_choice()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.ThemeSelection.SearchItems =
|
||||
[
|
||||
ThemeItem(ThemeMarket.Nxt, "NXT 테마")
|
||||
];
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
var identity = Assert.Single(fixture.ThemeSelection.SearchItems).Identity;
|
||||
|
||||
var editing = await workflow.OpenThemeForEditAsync(identity);
|
||||
|
||||
Assert.Equal(LegacyOperatorCatalogEntity.Theme, editing.Entity);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Edit, editing.Mode);
|
||||
Assert.Equal(ThemeMarket.Nxt, editing.NewThemeMarket);
|
||||
Assert.Equal("NXT 테마", editing.EditorName);
|
||||
Assert.True(editing.CanSave);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uc4_theme_delete_requires_native_yes_and_consumes_yes_exactly_once()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var controller = fixture.CreateController();
|
||||
await SelectThemeAsync(controller);
|
||||
|
||||
var requested = await controller.DeleteSelectedThemeCatalogAsync();
|
||||
|
||||
Assert.True(requested.Dialog?.IsConfirmation);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
|
||||
var canceled = controller.CancelDialog();
|
||||
|
||||
Assert.Null(canceled.Dialog);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
|
||||
await controller.DeleteSelectedThemeCatalogAsync();
|
||||
var confirmed = await controller.ConfirmDialogAsync();
|
||||
|
||||
Assert.Null(confirmed.Dialog);
|
||||
Assert.Equal(1, fixture.ThemePersistence.DeleteCalls);
|
||||
|
||||
await controller.ConfirmDialogAsync();
|
||||
|
||||
Assert.Equal(1, fixture.ThemePersistence.DeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uc6_expert_delete_requires_native_yes_and_consumes_yes_exactly_once()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var controller = fixture.CreateController();
|
||||
await SelectExpertAsync(controller);
|
||||
|
||||
var requested = await controller.DeleteSelectedExpertCatalogAsync();
|
||||
|
||||
Assert.True(requested.Dialog?.IsConfirmation);
|
||||
Assert.Equal(0, fixture.ExpertPersistence.DeleteCalls);
|
||||
|
||||
var canceled = controller.CancelDialog();
|
||||
|
||||
Assert.Null(canceled.Dialog);
|
||||
Assert.Equal(0, fixture.ExpertPersistence.DeleteCalls);
|
||||
|
||||
await controller.DeleteSelectedExpertCatalogAsync();
|
||||
var confirmed = await controller.ConfirmDialogAsync();
|
||||
|
||||
Assert.Null(confirmed.Dialog);
|
||||
Assert.Equal(1, fixture.ExpertPersistence.DeleteCalls);
|
||||
|
||||
await controller.ConfirmDialogAsync();
|
||||
|
||||
Assert.Equal(1, fixture.ExpertPersistence.DeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeA_delete_all_requires_native_yes_and_never_writes_to_the_database()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var controller = fixture.CreateController();
|
||||
await SelectThemeAsync(controller);
|
||||
var editing = await controller.EditSelectedThemeCatalogAsync();
|
||||
Assert.Single(Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
|
||||
editing.OperatorCatalog).DraftRows);
|
||||
|
||||
var requested = controller.ClearOperatorCatalogDraftRows();
|
||||
|
||||
Assert.True(requested.Dialog?.IsConfirmation);
|
||||
Assert.Single(Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
|
||||
requested.OperatorCatalog).DraftRows);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
|
||||
var canceled = controller.CancelDialog();
|
||||
|
||||
Assert.Null(canceled.Dialog);
|
||||
Assert.Single(Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
|
||||
canceled.OperatorCatalog).DraftRows);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
|
||||
controller.ClearOperatorCatalogDraftRows();
|
||||
var confirmed = await controller.ConfirmDialogAsync();
|
||||
|
||||
Assert.Empty(Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
|
||||
confirmed.OperatorCatalog).DraftRows);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
|
||||
await controller.ConfirmDialogAsync();
|
||||
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeA_active_draft_row_delete_selects_the_native_successor()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var controller = fixture.CreateController();
|
||||
await SelectThemeAsync(controller);
|
||||
await controller.EditSelectedThemeCatalogAsync();
|
||||
var stocks = await controller.SearchOperatorCatalogStocksAsync("stock");
|
||||
var stockId = Assert.Single(stocks.OperatorCatalog!.StockResults).ResultId;
|
||||
controller.SelectOperatorCatalogStock(stockId);
|
||||
controller.AddOperatorCatalogStock(null);
|
||||
controller.SelectOperatorCatalogStock(stockId);
|
||||
var editing = controller.AddOperatorCatalogStock(null);
|
||||
var rows = Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
|
||||
editing.OperatorCatalog).DraftRows.ToArray();
|
||||
|
||||
var selected = controller.SelectOperatorCatalogDraftRow(rows[1].RowId);
|
||||
Assert.Equal(rows[1].RowId, selected.OperatorCatalog?.ActiveDraftRowId);
|
||||
|
||||
var removed = controller.RemoveActiveOperatorCatalogDraftRow();
|
||||
|
||||
Assert.Equal([rows[0].RowId, rows[2].RowId],
|
||||
removed.OperatorCatalog?.DraftRows.Select(row => row.RowId));
|
||||
Assert.Equal(rows[2].RowId, removed.OperatorCatalog?.ActiveDraftRowId);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EList_active_draft_row_delete_selects_the_native_successor()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.ExpertSelection.PreviewItems =
|
||||
[
|
||||
new ExpertRecommendationPreview(0, "000001", "First", 10_000),
|
||||
new ExpertRecommendationPreview(1, "000002", "Second", 20_000),
|
||||
new ExpertRecommendationPreview(2, "000003", "Third", 30_000)
|
||||
];
|
||||
var controller = fixture.CreateController();
|
||||
await SelectExpertAsync(controller);
|
||||
var editing = await controller.EditSelectedExpertCatalogAsync();
|
||||
var rows = Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
|
||||
editing.OperatorCatalog).DraftRows.ToArray();
|
||||
|
||||
var selected = controller.SelectOperatorCatalogDraftRow(rows[1].RowId);
|
||||
Assert.Equal(rows[1].RowId, selected.OperatorCatalog?.ActiveDraftRowId);
|
||||
|
||||
var removed = controller.RemoveActiveOperatorCatalogDraftRow();
|
||||
|
||||
Assert.Equal([rows[0].RowId, rows[2].RowId],
|
||||
removed.OperatorCatalog?.DraftRows.Select(row => row.RowId));
|
||||
Assert.Equal(rows[2].RowId, removed.OperatorCatalog?.ActiveDraftRowId);
|
||||
Assert.Equal(0, fixture.ExpertPersistence.DeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uc4_and_Uc6_edit_entries_resolve_only_the_native_selected_identity()
|
||||
{
|
||||
@@ -232,6 +494,8 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
Assert.Equal("AI 반도체", theme.EditorName);
|
||||
Assert.True(theme.CanDelete);
|
||||
|
||||
workflow.Close();
|
||||
|
||||
var expert = await workflow.OpenExpertForEditAsync(
|
||||
new ExpertSelectionIdentity("0001", "이전문"));
|
||||
|
||||
@@ -305,6 +569,13 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
IdentityValidation,
|
||||
new FakeSchemaValidation(),
|
||||
new LegacyOperatorCatalogWriteSafetyState());
|
||||
|
||||
internal LegacyOperatorController CreateController() =>
|
||||
new(
|
||||
new FakeLegacyStockLookup(),
|
||||
themeWorkflow: new LegacyThemeWorkflow(ThemeSelection),
|
||||
expertWorkflow: new LegacyExpertWorkflowController(ExpertSelection),
|
||||
operatorCatalogWorkflow: CreateWorkflow());
|
||||
}
|
||||
|
||||
private sealed class FakeThemeSelection : IThemeSelectionService
|
||||
@@ -349,6 +620,9 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
|
||||
private sealed class FakeExpertSelection : IExpertSelectionService
|
||||
{
|
||||
internal IReadOnlyList<ExpertRecommendationPreview> PreviewItems { get; set; } =
|
||||
[new ExpertRecommendationPreview(0, "005930", "삼성전자", 70_000)];
|
||||
|
||||
public Task<ExpertSearchResult> SearchAsync(
|
||||
string query = "",
|
||||
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
|
||||
@@ -370,7 +644,7 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
Task.FromResult(new ExpertPreviewResult(
|
||||
identity,
|
||||
DateTimeOffset.UtcNow,
|
||||
[new ExpertRecommendationPreview(0, "005930", "삼성전자", 70_000)],
|
||||
PreviewItems,
|
||||
false));
|
||||
}
|
||||
|
||||
@@ -390,6 +664,8 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
|
||||
internal Exception? NameExistsException { get; set; }
|
||||
|
||||
internal Func<Task<bool>>? NameExistsHandler { get; set; }
|
||||
|
||||
internal ThemeMarket? LastNameCheckMarket { get; private set; }
|
||||
|
||||
internal string? LastCheckedName { get; private set; }
|
||||
@@ -404,6 +680,10 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
NameExistsCalls++;
|
||||
LastNameCheckMarket = market;
|
||||
LastCheckedName = themeTitle;
|
||||
if (NameExistsHandler is not null)
|
||||
{
|
||||
return NameExistsHandler();
|
||||
}
|
||||
return NameExistsException is null
|
||||
? Task.FromResult(NameExists)
|
||||
: Task.FromException<bool>(NameExistsException);
|
||||
@@ -447,6 +727,10 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
|
||||
internal ExpertCatalogDefinition? LastCreated { get; private set; }
|
||||
|
||||
internal int ReplaceCalls { get; private set; }
|
||||
|
||||
internal int DeleteCalls { get; private set; }
|
||||
|
||||
public bool CanMutate => true;
|
||||
|
||||
public Task<string> SuggestNextCodeAsync(CancellationToken cancellationToken = default) =>
|
||||
@@ -465,13 +749,27 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
ExpertSelectionIdentity expectedIdentity,
|
||||
string newName,
|
||||
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Receipt(OperatorCatalogMutationKind.ReplaceExpert);
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ReplaceCalls++;
|
||||
return Receipt(OperatorCatalogMutationKind.ReplaceExpert);
|
||||
}
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
|
||||
ExpertSelectionIdentity expectedIdentity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DeleteCalls++;
|
||||
return Receipt(OperatorCatalogMutationKind.DeleteExpert);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeLegacyStockLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Receipt(OperatorCatalogMutationKind.DeleteExpert);
|
||||
Task.FromResult(new LegacyStockSearchData(query, [], []));
|
||||
}
|
||||
|
||||
private sealed class FakeStockSearch : IStockSearchService
|
||||
@@ -529,6 +827,22 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
kind,
|
||||
DateTimeOffset.UtcNow));
|
||||
|
||||
private static async Task SelectThemeAsync(LegacyOperatorController controller)
|
||||
{
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
var searched = await controller.SearchThemesAsync("AI", ThemeSession.PreMarket);
|
||||
var resultId = Assert.Single(searched.Theme!.SearchResults).ResultId;
|
||||
await controller.SelectThemeAsync(resultId);
|
||||
}
|
||||
|
||||
private static async Task SelectExpertAsync(LegacyOperatorController controller)
|
||||
{
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
|
||||
var searched = await controller.SearchExpertsAsync("expert");
|
||||
var selectionId = Assert.Single(searched.Expert!.Search.Results).SelectionId;
|
||||
await controller.SelectExpertAsync(selectionId);
|
||||
}
|
||||
|
||||
private static ThemeSelectorItem ThemeItem(ThemeMarket market, string title) =>
|
||||
new(
|
||||
new ThemeSelectionIdentity(
|
||||
|
||||
Reference in New Issue
Block a user