feat: complete legacy parity and modernize operator UI

This commit is contained in:
2026-07-22 12:34:46 +09:00
parent fc4007d676
commit 939c252d23
149 changed files with 26515 additions and 1736 deletions

View File

@@ -1,3 +1,4 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
@@ -77,7 +78,10 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
string actionId)
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
var controller = CreateController(
persistence,
new FixedTimeProvider(
new DateTimeOffset(2026, 7, 22, 10, 0, 0, TimeSpan.FromHours(9))));
var action = LegacyFixedActionCatalog.Default.GetAction(actionId);
controller.SelectTab(action.Market switch
{
@@ -102,6 +106,106 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Assert.Equal(before.Selection, after.Selection);
}
[Fact]
public async Task SaveFixed8035PersistsOriginalIndexSevenFields()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
controller.SelectTab(LegacyOperatorTabId.Index);
controller.ActivateFixedAction("fixed-136");
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
controller.SelectNamedPlaylist(definitionId);
var saved = await controller.SaveCurrentNamedPlaylistAsync();
Assert.Equal(
LegacyNamedPlaylistMutationOutcome.CommittedFresh,
saved.NamedPlaylist?.LastMutationOutcome);
var stored = Assert.Single(persistence.LastReplaceItems!);
Assert.True(stored.IsEnabled);
Assert.Equal("코스피", stored.Selection.GroupCode);
Assert.Equal("지수", stored.Selection.Subject);
Assert.Equal("캔들그래프", stored.Selection.GraphicType);
Assert.Equal("일봉", stored.Selection.Subtype);
Assert.Equal(string.Empty, stored.Selection.DataCode);
Assert.Equal(new NamedPlaylistPageState(1, 1), stored.Page);
}
[Fact]
public async Task SaveFixedPagedActionPersistsInitialDatabasePageNBeforePlayout()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(
persistence,
fixedPagePlanProvider: new FixedSixRowPagePlanProvider());
controller.SelectTab(LegacyOperatorTabId.Index);
var action = LegacyFixedActionCatalog.Default.Actions
.First(candidate => candidate.Available && candidate.Code == "5077");
var added = await controller.ActivateFixedActionAsync(action.Id);
Assert.Equal("1/3", Assert.Single(added.Playlist).PageText);
var listed = await controller.RefreshNamedPlaylistsAsync();
controller.SelectNamedPlaylist(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
await controller.SaveCurrentNamedPlaylistAsync();
Assert.Equal(
new NamedPlaylistPageState(1, 3),
Assert.Single(persistence.LastReplaceItems!).Page);
}
[Theory]
[InlineData("코스피", "지수", "캔들그래프", "일봉")]
[InlineData("주요지수", "캔들그래프_일봉", "코스피", "코스피")]
public async Task LoadFixed8035RestoresOriginalAndCurrentStoredShapes(
string groupCode,
string subject,
string graphicType,
string subtype)
{
var persistence = new OperatorNamedPlaylistPersistence(
[
new NamedPlaylistStoredItem(
0,
true,
new LegacySceneSelection(
groupCode,
subject,
graphicType,
subtype,
string.Empty),
new NamedPlaylistPageState(1, 1))
]);
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
var loaded = await controller.LoadNamedPlaylistByIdAsync(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
Assert.True(loaded.CommandResult?.Succeeded);
Assert.Null(loaded.Dialog);
var row = Assert.Single(loaded.Playlist);
Assert.True(row.IsSceneResolved);
Assert.Equal(groupCode, row.MarketText);
Assert.Equal(subject, row.StockName);
Assert.Equal(graphicType, row.GraphicType);
Assert.Equal(subtype, row.Subtype);
Assert.Equal("1/1", row.PageText);
Assert.Equal(string.Empty, row.StockCode);
Assert.Equal("8035", row.SceneAlias);
Assert.Equal(
new LegacySceneSelection(
"KOSPI_INDEX",
"INDEX",
string.Empty,
"PRICE",
string.Empty),
row.PlayoutSelection);
}
[Fact]
public async Task LoadSelected_ReplacesPlaylistWithTypedOriginalSevenFields()
{
@@ -141,7 +245,7 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
}
[Fact]
public async Task LoadSelected_UnknownSceneKeepsCurrentPlaylistAtomically()
public async Task LoadSelected_UnknownSceneLoadsRawRowAndDefersOnlyThatRowsPlayoutCheck()
{
var persistence = new OperatorNamedPlaylistPersistence(
[
@@ -167,12 +271,55 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
var after = await controller.LoadNamedPlaylistByIdAsync(definitionId);
Assert.Equal(before.RowId, Assert.Single(after.Playlist).RowId);
Assert.NotNull(after.Dialog);
Assert.Contains("1행", after.Dialog.Message, StringComparison.Ordinal);
Assert.Equal(LegacyOperatorStatusKind.Error, after.StatusKind);
var row = Assert.Single(after.Playlist);
Assert.NotEqual(before.RowId, row.RowId);
Assert.Equal("UNKNOWN-GROUP", row.MarketText);
Assert.Equal("알 수 없음", row.StockName);
Assert.Equal("UNKNOWN-GRAPHIC", row.GraphicType);
Assert.Equal("UNKNOWN-SUBTYPE", row.Subtype);
Assert.Equal("UNKNOWN-DATA", row.StockCode);
Assert.Equal(LegacyPlaylistEntry.UnresolvedCutCode, row.SceneAlias);
Assert.False(row.IsSceneResolved);
Assert.Null(after.Dialog);
Assert.Equal(LegacyOperatorStatusKind.Warning, after.StatusKind);
Assert.Equal("load-named-playlist-by-id", after.CommandResult?.Command);
Assert.False(after.CommandResult?.Succeeded);
Assert.True(after.CommandResult?.Succeeded);
Assert.Equal(
LegacyPlaylistEntry.UnresolvedCutCode,
Assert.Single(controller.CreatePlayoutRequest().Playlist).CutCode);
}
[Fact]
public async Task LoadSelected_RestoresOriginalFixedIndexPairFirstRow()
{
var persistence = new OperatorNamedPlaylistPersistence(
[
new NamedPlaylistStoredItem(
0,
true,
new LegacySceneSelection(
"지수",
"코스피 지수,코스닥 지수",
"2열판",
string.Empty,
string.Empty),
new NamedPlaylistPageState(1, 1))
]);
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
var loaded = await controller.LoadNamedPlaylistByIdAsync(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
var row = Assert.Single(loaded.Playlist);
Assert.True(loaded.CommandResult?.Succeeded);
Assert.Null(loaded.Dialog);
Assert.True(row.IsSceneResolved);
Assert.Equal("8032", row.SceneAlias);
Assert.Equal("INDEX", row.PlayoutSelection?.GroupCode);
Assert.Equal("Kospi,Kosdaq", row.PlayoutSelection?.Subject);
Assert.Equal("2열판", row.PlayoutSelection?.GraphicType);
Assert.Equal("CURRENT", row.PlayoutSelection?.Subtype);
}
[Theory]
@@ -488,6 +635,59 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
saved.NamedPlaylist?.LastMutationOutcome);
}
[Theory]
[InlineData(ThemeMarket.Krx, " Edge Theme ", " Edge Theme ")]
[InlineData(ThemeMarket.Nxt, "Robotics(NXT)", "Robotics(NXT)(NXT)")]
[InlineData(ThemeMarket.Nxt, "Robotics(NXT)(NXT)", "Robotics(NXT)(NXT)(NXT)")]
[InlineData(ThemeMarket.Nxt, " Edge Theme ", " Edge Theme (NXT)")]
public async Task Native_theme_schedule_save_fresh_load_retains_exact_database_title(
ThemeMarket market,
string exactTitle,
string expectedStoredSubject)
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var identity = new ThemeSelectionIdentity(
market,
market == ThemeMarket.Nxt
? ThemeSession.PreMarket
: ThemeSession.NotApplicable,
"00000002",
exactTitle);
var time = new DateTimeOffset(2026, 7, 21, 10, 0, 0, TimeSpan.FromHours(9));
var controller = new LegacyOperatorController(
new UnusedLookup(),
timeProvider: new FixedTimeProvider(time),
themeWorkflow: new LegacyThemeWorkflow(
new ExactThemeService(identity),
() => time),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
var theme = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
await controller.SelectThemeAsync(Assert.Single(theme.Theme!.SearchResults).ResultId);
controller.ActivateThemeAction("five-row-current");
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId;
controller.SelectNamedPlaylist(definitionId);
await controller.SaveCurrentNamedPlaylistAsync();
var stored = Assert.Single(persistence.LastReplaceItems!);
Assert.Equal(expectedStoredSubject, stored.Selection.Subject);
Assert.Equal(market == ThemeMarket.Nxt ? "테마_NXT" : "테마", stored.Selection.GroupCode);
Assert.Equal("00000002", stored.Selection.DataCode);
var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId);
var runtimeSelection = Assert.Single(
controller.CreatePlayoutRequest().Playlist).Selection!;
Assert.True(loaded.CommandResult?.Succeeded);
Assert.Equal(
market == ThemeMarket.Nxt ? "PAGED_NXT_THEME" : "PAGED_THEME",
runtimeSelection.GroupCode);
Assert.Equal(expectedStoredSubject, runtimeSelection.Subject);
Assert.Equal(exactTitle, runtimeSelection.ExactSubject);
Assert.Equal("00000002", runtimeSelection.DataCode);
}
[Fact]
public async Task NamedPlaylistIdentityDoesNotChangeWhenMovingAveragesChange()
{
@@ -546,6 +746,30 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Assert.Equal("save-current-named-playlist-to", saved.CommandResult?.Command);
Assert.Equal(definitionId, saved.CommandResult?.TargetId);
Assert.True(saved.CommandResult?.Succeeded);
Assert.Null(saved.Dialog);
}
[Fact]
public async Task Confirm_button_save_projects_the_original_saved_MessageBox()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
var saved = await controller.SaveCurrentNamedPlaylistToAsync(
definitionId,
showSavedAlert: true);
Assert.Equal(1, persistence.ReplaceCalls);
Assert.True(saved.CommandResult?.Succeeded);
Assert.Equal("저장되었습니다", saved.Dialog?.Message);
Assert.Equal("MmoneyCoder", saved.Dialog?.Caption);
Assert.False(saved.Dialog?.IsConfirmation);
var dismissed = controller.DismissDialog();
Assert.Null(dismissed.Dialog);
}
[Fact]
@@ -696,10 +920,32 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
}
private static LegacyOperatorController CreateController(
INamedPlaylistPersistenceService persistence) =>
INamedPlaylistPersistenceService persistence,
TimeProvider? timeProvider = null,
ILegacyScenePagePlanProvider? fixedPagePlanProvider = null) =>
new(
new UnusedLookup(),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
timeProvider: timeProvider,
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence),
fixedPagePlanProvider: fixedPagePlanProvider);
private sealed class FixedSixRowPagePlanProvider : ILegacyScenePagePlanProvider
{
public Task<LegacyScenePagePlan> CreatePagePlanAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Assert.Equal("5077", entry.CutCode);
return Task.FromResult(new LegacyScenePagePlan(
"s5077",
17,
ScenePageSize.Six,
3,
17,
false));
}
}
private sealed class UnusedLookup : ILegacyStockLookup
{
@@ -709,6 +955,63 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class FixedTimeProvider(DateTimeOffset localNow) : TimeProvider
{
private static readonly TimeZoneInfo KoreaStandardTime = TimeZoneInfo.CreateCustomTimeZone(
"NamedThemeFixedKst",
TimeSpan.FromHours(9),
"Named theme fixed KST",
"Named theme fixed KST");
public override DateTimeOffset GetUtcNow() => localNow.ToUniversalTime();
public override TimeZoneInfo LocalTimeZone => KoreaStandardTime;
}
private sealed class ExactThemeService : IThemeSelectionService
{
private readonly ThemeSelectionIdentity _identity;
public ExactThemeService(ThemeSelectionIdentity identity)
{
_identity = identity;
}
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var identity = _identity.Market == ThemeMarket.Nxt
? _identity with { Session = nxtSession }
: _identity;
return Task.FromResult(new ThemeSearchResult(
query,
nxtSession,
DateTimeOffset.UtcNow,
[new ThemeSelectorItem(
identity,
identity.Market == ThemeMarket.Krx
? DataSourceKind.Oracle
: DataSourceKind.MariaDb)],
false));
}
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) =>
Task.FromResult(new ThemePreviewResult(
identity,
DateTimeOffset.UtcNow,
[new ThemeItemPreview(
0,
identity.Market == ThemeMarket.Krx ? "P005930" : "X005930",
"Samsung Electronics")],
false));
}
private sealed class OperatorNamedPlaylistPersistence : INamedPlaylistPersistenceService
{
private const string ProgramCode = "00000001";