feat: complete legacy parity and modernize operator UI
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
@@ -138,11 +140,29 @@ public sealed class LegacyOperatorControllerTests
|
||||
"AI 테마",
|
||||
"GAIN_DESC",
|
||||
"CURRENT",
|
||||
"00000001"),
|
||||
"00000001",
|
||||
"AI 테마"),
|
||||
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
row.ToPlayoutEntry().Selection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeSearch_ReportsTheFiniteFiveThousandRowTruncationToTheOperator()
|
||||
{
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
themeWorkflow: new LegacyThemeWorkflow(new TruncatedThemeService()));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
|
||||
var searched = await controller.SearchThemesAsync("Theme");
|
||||
|
||||
Assert.Equal(5_000, searched.Theme!.SearchResults.Count);
|
||||
Assert.True(searched.Theme.SearchIsTruncated);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Warning, searched.StatusKind);
|
||||
Assert.Contains("5,000", searched.StatusMessage, StringComparison.Ordinal);
|
||||
Assert.Contains("검색어", searched.StatusMessage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NxtExpectedThemeAction_RemainsClickableAndShowsExactWarningWithoutAdding()
|
||||
{
|
||||
@@ -241,7 +261,8 @@ public sealed class LegacyOperatorControllerTests
|
||||
"AI 테마(NXT)",
|
||||
"PRE_MARKET",
|
||||
"INPUT_ORDER",
|
||||
"00000002"),
|
||||
"00000002",
|
||||
"AI 테마"),
|
||||
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
row.ToPlayoutEntry().Selection));
|
||||
}
|
||||
@@ -283,7 +304,7 @@ public sealed class LegacyOperatorControllerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRow()
|
||||
public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRowWithNullBuyAmount()
|
||||
{
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
@@ -299,6 +320,7 @@ public sealed class LegacyOperatorControllerTests
|
||||
var row = Assert.Single(added.Playlist);
|
||||
Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("1/2", row.PageText);
|
||||
Assert.Null(added.Expert!.Preview.Items[0].BuyAmount);
|
||||
Assert.Equal("전문가", row.MarketText);
|
||||
}
|
||||
|
||||
@@ -338,7 +360,7 @@ public sealed class LegacyOperatorControllerTests
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
|
||||
Assert.Equal(
|
||||
["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외종목", "전문가", "정지"],
|
||||
["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외시장", "전문가", "거래정지"],
|
||||
controller.Current.Tabs!.Select(tab => tab.Label));
|
||||
Assert.Equal(
|
||||
LegacyOperatorTabId.Overseas,
|
||||
@@ -417,8 +439,12 @@ public sealed class LegacyOperatorControllerTests
|
||||
var leaf = controller.ActivateFixedAction(first.Id);
|
||||
|
||||
var row = Assert.Single(leaf.Playlist);
|
||||
Assert.Equal("해외지수", row.MarketText);
|
||||
Assert.Equal(first.Label, row.StockName);
|
||||
var expected = OriginalFixedVisibleSelection(first);
|
||||
Assert.Equal(expected.GroupCode, row.MarketText);
|
||||
Assert.Equal(expected.Subject, row.StockName);
|
||||
Assert.Equal(expected.GraphicType, row.GraphicType);
|
||||
Assert.Equal(expected.Subtype, row.Subtype);
|
||||
Assert.Equal(expected.DataCode, row.StockCode);
|
||||
Assert.Equal(first.Code, row.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal(
|
||||
first.Selection.Subject,
|
||||
@@ -430,10 +456,154 @@ public sealed class LegacyOperatorControllerTests
|
||||
var section = await controller.ActivateFixedSection(0);
|
||||
Assert.Equal(1 + firstSection.Actions.Count, section.Playlist.Count);
|
||||
Assert.Equal(
|
||||
firstSection.Actions.Select(action => action.Label),
|
||||
firstSection.Actions.Select(action =>
|
||||
OriginalFixedVisibleSelection(action).Subject),
|
||||
section.Playlist.Skip(1).Select(item => item.StockName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryAvailableFixedActionUsesOriginalVisibleProjectionCanonicalPlayoutAndInitialPageN()
|
||||
{
|
||||
var catalog = LegacyFixedActionCatalog.Default;
|
||||
var localNow = new DateTimeOffset(2026, 7, 22, 10, 0, 0, TimeSpan.FromHours(9));
|
||||
var pagePlans = new RecordingPagePlanProvider();
|
||||
var availableActions = catalog.Actions
|
||||
.Where(candidate => candidate.Available)
|
||||
.ToArray();
|
||||
Assert.Equal(322, availableActions.Length);
|
||||
var visibleIdentities = new HashSet<
|
||||
(string GroupCode, string Subject, string GraphicType, string Subtype, string DataCode)>();
|
||||
|
||||
foreach (var action in availableActions)
|
||||
{
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
timeProvider: new FixedTimeProvider(localNow),
|
||||
fixedPagePlanProvider: pagePlans);
|
||||
controller.SelectTab(action.Market switch
|
||||
{
|
||||
LegacyFixedMarket.Overseas => LegacyOperatorTabId.Overseas,
|
||||
LegacyFixedMarket.Exchange => LegacyOperatorTabId.Exchange,
|
||||
LegacyFixedMarket.Index => LegacyOperatorTabId.Index,
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
});
|
||||
|
||||
var state = await controller.ActivateFixedActionAsync(action.Id);
|
||||
|
||||
var row = Assert.Single(state.Playlist);
|
||||
var expectedVisible = OriginalFixedVisibleSelection(action);
|
||||
Assert.Equal(expectedVisible.GroupCode, row.MarketText);
|
||||
Assert.Equal(expectedVisible.Subject, row.StockName);
|
||||
Assert.Equal(expectedVisible.GraphicType, row.GraphicType);
|
||||
Assert.Equal(expectedVisible.Subtype, row.Subtype);
|
||||
Assert.Equal(expectedVisible.DataCode, row.StockCode);
|
||||
Assert.Equal(action.Code switch
|
||||
{
|
||||
"5074" => "1/20",
|
||||
"5077" or "5088" => "1/3",
|
||||
_ => "1/1"
|
||||
}, row.PageText);
|
||||
Assert.Equal(action.Label, row.RawCutLabel);
|
||||
Assert.Equal(action.Code, row.SceneAlias);
|
||||
Assert.True(
|
||||
visibleIdentities.Add((
|
||||
row.MarketText,
|
||||
row.StockName,
|
||||
row.GraphicType,
|
||||
row.Subtype,
|
||||
row.StockCode)),
|
||||
$"Duplicate original fixed selection: {action.Id}");
|
||||
|
||||
var expectedPlayout = catalog.Materialize(action.Id, localNow).Selection;
|
||||
var actualPlayout = Assert.IsType<
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
row.PlayoutSelection);
|
||||
Assert.Equal(expectedPlayout.GroupCode, actualPlayout.GroupCode);
|
||||
Assert.Equal(expectedPlayout.Subject, actualPlayout.Subject);
|
||||
Assert.Equal(expectedPlayout.GraphicType, actualPlayout.GraphicType);
|
||||
Assert.Equal(expectedPlayout.Subtype, actualPlayout.Subtype);
|
||||
Assert.Equal(expectedPlayout.DataCode, actualPlayout.DataCode);
|
||||
}
|
||||
|
||||
Assert.Equal(availableActions.Length, visibleIdentities.Count);
|
||||
Assert.Equal(78, pagePlans.Entries.Count);
|
||||
Assert.Equal(
|
||||
78,
|
||||
pagePlans.Entries.Select(entry => entry.EntryId).Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FixedPagedActionPreservesOriginalOneSlashZeroForEmptyOrFailedPageRead()
|
||||
{
|
||||
var action = LegacyFixedActionCatalog.Default.Actions
|
||||
.First(candidate => candidate.Available && candidate.Code == "5074");
|
||||
|
||||
var emptyPlans = new RecordingPagePlanProvider { FiveRowItemCount = 0 };
|
||||
var emptyController = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
fixedPagePlanProvider: emptyPlans);
|
||||
emptyController.SelectTab(LegacyOperatorTabId.Index);
|
||||
var empty = await emptyController.ActivateFixedActionAsync(action.Id);
|
||||
Assert.Equal("1/0", Assert.Single(empty.Playlist).PageText);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Information, empty.StatusKind);
|
||||
|
||||
var failedPlans = new RecordingPagePlanProvider
|
||||
{
|
||||
Failure = new IOException("page read failed")
|
||||
};
|
||||
var failedController = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
fixedPagePlanProvider: failedPlans);
|
||||
failedController.SelectTab(LegacyOperatorTabId.Index);
|
||||
var failed = await failedController.ActivateFixedActionAsync(action.Id);
|
||||
Assert.Equal("1/0", Assert.Single(failed.Playlist).PageText);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Warning, failed.StatusKind);
|
||||
Assert.Null(failed.Dialog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FixedPagedActionCancellationDoesNotAppendAPlaylistRow()
|
||||
{
|
||||
var action = LegacyFixedActionCatalog.Default.Actions
|
||||
.First(candidate => candidate.Available && candidate.Code == "5077");
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
fixedPagePlanProvider: new RecordingPagePlanProvider());
|
||||
controller.SelectTab(LegacyOperatorTabId.Index);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
controller.ActivateFixedActionAsync(action.Id, cancellation.Token));
|
||||
|
||||
Assert.Empty(controller.Current.Playlist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fixed8035UsesOriginalIndexFieldsWithoutChangingCanonicalSelection()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
controller.SelectTab(LegacyOperatorTabId.Index);
|
||||
|
||||
var state = controller.ActivateFixedAction("fixed-136");
|
||||
|
||||
var row = Assert.Single(state.Playlist);
|
||||
Assert.Equal("코스피", row.MarketText);
|
||||
Assert.Equal("지수", row.StockName);
|
||||
Assert.Equal("캔들그래프", row.GraphicType);
|
||||
Assert.Equal("일봉", row.Subtype);
|
||||
Assert.Equal(string.Empty, row.StockCode);
|
||||
Assert.Equal("8035", row.SceneAlias);
|
||||
var selection = Assert.IsType<
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
row.PlayoutSelection);
|
||||
Assert.Equal("KOSPI_INDEX", selection.GroupCode);
|
||||
Assert.Equal("INDEX", selection.Subject);
|
||||
Assert.Equal(string.Empty, selection.GraphicType);
|
||||
Assert.Equal("PRICE", selection.Subtype);
|
||||
Assert.Equal(string.Empty, selection.DataCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedCatalogRejectsCrossTabAndUnavailableActionsAtomically()
|
||||
{
|
||||
@@ -949,12 +1119,27 @@ public sealed class LegacyOperatorControllerTests
|
||||
control: false,
|
||||
shift: false);
|
||||
|
||||
var empty = controller.DeleteAllPlaylistRows();
|
||||
var requested = controller.DeleteAllPlaylistRows();
|
||||
Assert.Equal("송출 리스트를 모두 삭제하겠습니까?", requested.Dialog?.Message);
|
||||
Assert.Equal("MmoneyCoder", requested.Dialog?.Caption);
|
||||
Assert.True(requested.Dialog?.IsConfirmation);
|
||||
Assert.Equal(3, requested.Playlist.Count);
|
||||
|
||||
var canceled = controller.CancelDialog();
|
||||
Assert.Null(canceled.Dialog);
|
||||
Assert.Equal(3, canceled.Playlist.Count);
|
||||
|
||||
controller.DeleteAllPlaylistRows();
|
||||
var empty = await controller.ConfirmDialogAsync();
|
||||
Assert.Null(empty.Dialog);
|
||||
Assert.Empty(empty.Playlist);
|
||||
|
||||
var unchangedRevision = empty.Revision;
|
||||
var stillEmpty = controller.DeleteAllPlaylistRows();
|
||||
Assert.Equal(unchangedRevision, stillEmpty.Revision);
|
||||
var requestedEmpty = controller.DeleteAllPlaylistRows();
|
||||
Assert.True(requestedEmpty.Dialog?.IsConfirmation);
|
||||
var stillEmpty = await controller.ConfirmDialogAsync();
|
||||
Assert.Empty(stillEmpty.Playlist);
|
||||
Assert.True(stillEmpty.Revision > unchangedRevision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -962,6 +1147,7 @@ public sealed class LegacyOperatorControllerTests
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1);
|
||||
controller.DeleteAllPlaylistRows();
|
||||
await controller.ConfirmDialogAsync();
|
||||
|
||||
controller.CutPointerDown(2, false, false, 1, 47, 10_000);
|
||||
var state = controller.DropSelectedCutsOnPlaylist();
|
||||
@@ -1082,6 +1268,61 @@ public sealed class LegacyOperatorControllerTests
|
||||
Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
|
||||
}
|
||||
|
||||
private static LegacyFixedSelection OriginalFixedVisibleSelection(
|
||||
LegacyFixedAction action)
|
||||
{
|
||||
var separator = action.Label.IndexOf('_');
|
||||
var first = separator < 0 ? action.Label : action.Label[..separator];
|
||||
var remainder = separator < 0
|
||||
? string.Empty
|
||||
: action.Label[(separator + 1)..];
|
||||
if (action.Market is LegacyFixedMarket.Overseas or LegacyFixedMarket.Exchange)
|
||||
{
|
||||
return new LegacyFixedSelection(
|
||||
action.Market == LegacyFixedMarket.Overseas ? "해외지수" : "환율",
|
||||
first,
|
||||
action.Section,
|
||||
remainder,
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
var indexGroup = action.Label.Contains("코스피_NXT", StringComparison.Ordinal)
|
||||
? "코스피_NXT"
|
||||
: action.Label.Contains("코스닥_NXT", StringComparison.Ordinal)
|
||||
? "코스닥_NXT"
|
||||
: action.Label.Contains("코스피", StringComparison.Ordinal)
|
||||
? "코스피"
|
||||
: action.Label.Contains("코스닥", StringComparison.Ordinal)
|
||||
? "코스닥"
|
||||
: "전체";
|
||||
if (action.Section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
|
||||
{
|
||||
return new LegacyFixedSelection(
|
||||
indexGroup,
|
||||
"지수",
|
||||
action.Section,
|
||||
action.Label,
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
if (string.Equals(action.Section, "매매동향", StringComparison.Ordinal))
|
||||
{
|
||||
return new LegacyFixedSelection(
|
||||
indexGroup,
|
||||
"지수",
|
||||
first,
|
||||
remainder,
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
return new LegacyFixedSelection(
|
||||
action.Section,
|
||||
"지수",
|
||||
first,
|
||||
remainder,
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
private static async Task<LegacyOperatorController> ControllerWithPlaylistAsync(
|
||||
params int[] physicalCutRows)
|
||||
{
|
||||
@@ -1114,6 +1355,49 @@ public sealed class LegacyOperatorControllerTests
|
||||
new LegacyStockIdentity(LegacyDomesticStockMarket.NxtKospi, "넥스트(NXT)", "A00001")
|
||||
]));
|
||||
|
||||
private sealed class RecordingPagePlanProvider : ILegacyScenePagePlanProvider
|
||||
{
|
||||
public List<LegacyPlaylistEntry> Entries { get; } = [];
|
||||
|
||||
public int FiveRowItemCount { get; init; } = 101;
|
||||
|
||||
public int SixRowItemCount { get; init; } = 13;
|
||||
|
||||
public int TwelveRowItemCount { get; init; } = 25;
|
||||
|
||||
public Exception? Failure { get; init; }
|
||||
|
||||
public Task<LegacyScenePagePlan> CreatePagePlanAsync(
|
||||
LegacyPlaylistEntry entry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Entries.Add(entry);
|
||||
if (Failure is not null)
|
||||
{
|
||||
return Task.FromException<LegacyScenePagePlan>(Failure);
|
||||
}
|
||||
|
||||
var (builderKey, pageSize, itemCount) = entry.CutCode switch
|
||||
{
|
||||
"5074" => ("s5074", ScenePageSize.Five, FiveRowItemCount),
|
||||
"5077" => ("s5077", ScenePageSize.Six, SixRowItemCount),
|
||||
"5088" => ("s5088", ScenePageSize.Twelve, TwelveRowItemCount),
|
||||
_ => throw new InvalidOperationException(
|
||||
"Only a paged fixed action may request an initial page plan.")
|
||||
};
|
||||
var plan = Assert.IsType<ScenePagingPlan>(
|
||||
ScenePaging.CreatePlan(itemCount, pageSize).Plan);
|
||||
return Task.FromResult(new LegacyScenePagePlan(
|
||||
builderKey,
|
||||
itemCount,
|
||||
pageSize,
|
||||
plan.TotalPages,
|
||||
plan.AccessibleItemCount,
|
||||
plan.ExcludedItemCount > 0));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeLookup : ILegacyStockLookup
|
||||
{
|
||||
private readonly LegacyStockSearchData _result;
|
||||
@@ -1216,6 +1500,43 @@ public sealed class LegacyOperatorControllerTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TruncatedThemeService : IThemeSelectionService
|
||||
{
|
||||
public Task<ThemeSearchResult> SearchAsync(
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Assert.Equal(5_000, maximumResults);
|
||||
var rows = Enumerable.Range(0, maximumResults)
|
||||
.Select(index => new ThemeSelectorItem(
|
||||
new ThemeSelectionIdentity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
index.ToString("D8", System.Globalization.CultureInfo.InvariantCulture),
|
||||
$"Theme {index}"),
|
||||
DataSourceKind.Oracle))
|
||||
.ToArray();
|
||||
return Task.FromResult(new ThemeSearchResult(
|
||||
query,
|
||||
nxtSession,
|
||||
DateTimeOffset.UtcNow,
|
||||
rows,
|
||||
IsTruncated: true));
|
||||
}
|
||||
|
||||
public Task<ThemePreviewResult> PreviewAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new ThemePreviewResult(
|
||||
identity,
|
||||
DateTimeOffset.UtcNow,
|
||||
[],
|
||||
IsTruncated: false));
|
||||
}
|
||||
|
||||
private sealed class StaticExpertService : IExpertSelectionService
|
||||
{
|
||||
private static readonly ExpertSelectionIdentity Identity = new("0001", "전문가A");
|
||||
@@ -1242,7 +1563,7 @@ public sealed class LegacyOperatorControllerTests
|
||||
index,
|
||||
index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture),
|
||||
$"추천{index}",
|
||||
10_000m + index))
|
||||
index == 1 ? null : 10_000m + index))
|
||||
.ToArray(),
|
||||
false));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user