feat: advance legacy UI behavior parity

This commit is contained in:
2026-07-18 10:18:29 +09:00
parent d7ec571343
commit 1302b1b92f
71 changed files with 11244 additions and 1153 deletions

View File

@@ -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>

View File

@@ -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(

View File

@@ -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]

View File

@@ -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]

View File

@@ -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));

View File

@@ -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();

View File

@@ -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>(

View File

@@ -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(),

View File

@@ -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]

View File

@@ -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(