using System.Data; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; public sealed class LegacyManualFinancialOperatorControllerTests { [Fact] public async Task GraphE_open_warns_when_invalid_legacy_rows_were_isolated() { var service = new FakeManualService(Sales("Alpha", 1)) { RejectedItemCount = 2 }; var opened = await Controller(service).OpenManualFinancialAsync( ManualFinancialScreenKind.Sales); Assert.Single(opened.ManualFinancial!.Rows); Assert.Equal(2, opened.ManualFinancial.ListRejectedItemCount); Assert.Equal(LegacyOperatorStatusKind.Warning, opened.StatusKind); Assert.Contains("2행", opened.StatusMessage, StringComparison.Ordinal); } [Fact] public async Task Strict_ready_GraphE_load_clears_previous_raw_row_warning() { var service = new LegacyManualFinancialScreenService( new RawGraphEQueryExecutor()); var controller = Controller(service); var opened = await controller.OpenManualFinancialAsync( ManualFinancialScreenKind.Sales); var rawRow = opened.ManualFinancial!.Rows.Single(row => !row.IsPlayoutReady); var readyRow = opened.ManualFinancial.Rows.Single(row => row.IsPlayoutReady); var rawLoaded = await controller.LoadManualFinancialAsync(rawRow.ResultId); Assert.False(rawLoaded.ManualFinancial!.IsPlayoutReady); Assert.Equal(LegacyOperatorStatusKind.Warning, rawLoaded.StatusKind); Assert.Contains("원문", rawLoaded.StatusMessage, StringComparison.Ordinal); var readyLoaded = await controller.LoadManualFinancialAsync(readyRow.ResultId); Assert.True(readyLoaded.ManualFinancial!.IsPlayoutReady); Assert.Equal(LegacyOperatorStatusKind.Information, readyLoaded.StatusKind); Assert.Contains("Alpha", readyLoaded.StatusMessage, StringComparison.Ordinal); Assert.DoesNotContain("원문", readyLoaded.StatusMessage, StringComparison.Ordinal); } [Fact] public async Task Unreadable_row_failure_clears_previous_detail_and_playout_authority() { var service = new FakeManualService(Sales("Alpha", 1)) { RejectedItemCount = 1, UnreadableStockNames = ["Broken"], GetOverride = identity => identity.StockName == "Broken" ? throw new ManualFinancialDataException("Unreadable legacy row.") : Sales("Alpha", 1) }; var controller = Controller(service); var opened = await controller.OpenManualFinancialAsync( ManualFinancialScreenKind.Sales); var valid = opened.ManualFinancial!.Rows.Single(row => row.IsDataReadable); var unreadable = opened.ManualFinancial.Rows.Single(row => !row.IsDataReadable); var loaded = await controller.LoadManualFinancialAsync(valid.ResultId); Assert.True(loaded.ManualFinancial!.CanMaterializePlaylist); var rejected = await controller.LoadManualFinancialAsync(unreadable.ResultId); var failed = rejected.ManualFinancial!; Assert.Equal(unreadable.ResultId, failed.SelectedRowId); Assert.Null(failed.SelectedRecord); Assert.False(failed.CanSave); Assert.False(failed.CanDelete); Assert.False(failed.CanMaterializePlaylist); Assert.NotNull(rejected.Dialog); var activated = await controller.ActivateManualFinancialResultAsync( unreadable.ResultId); Assert.Empty(activated.Playlist); Assert.False(activated.CommandResult?.Succeeded); Assert.Equal(unreadable.ResultId, activated.CommandResult?.TargetId); } [Fact] public async Task Stale_result_handle_clears_previous_detail_before_it_is_rejected() { var service = new FakeManualService(Sales("Alpha", 1)); var controller = Controller(service); var opened = await controller.OpenManualFinancialAsync( ManualFinancialScreenKind.Sales); var valid = Assert.Single(opened.ManualFinancial!.Rows); var loaded = await controller.LoadManualFinancialAsync(valid.ResultId); Assert.True(loaded.ManualFinancial!.CanMaterializePlaylist); var rejected = await controller.LoadManualFinancialAsync("stale-result-id"); Assert.Null(rejected.ManualFinancial!.SelectedRowId); Assert.Null(rejected.ManualFinancial.SelectedRecord); Assert.False(rejected.ManualFinancial.CanSave); Assert.False(rejected.ManualFinancial.CanDelete); Assert.False(rejected.ManualFinancial.CanMaterializePlaylist); Assert.NotNull(rejected.Dialog); Assert.Equal(1, service.GetCalls); } [Fact] public async Task GraphE_result_double_click_fresh_loads_appends_once_and_closes_on_success() { var service = new FakeManualService(Sales("Alpha", 1)); var controller = Controller(service); var opened = await controller.OpenManualFinancialAsync( ManualFinancialScreenKind.Sales); var resultId = Assert.Single(opened.ManualFinancial!.Rows).ResultId; var activated = await controller.ActivateManualFinancialResultAsync(resultId); Assert.Null(activated.ManualFinancial); Assert.Equal(1, service.GetCalls); Assert.Equal(0, service.MutationCalls); Assert.Equal("5080", Assert.Single(activated.Playlist).ToPlayoutEntry().CutCode); Assert.Equal("activate-manual-financial-result", activated.CommandResult?.Command); Assert.Equal(resultId, activated.CommandResult?.TargetId); Assert.True(activated.CommandResult?.Succeeded); } [Fact] public async Task GraphE_result_double_click_rejects_identity_change_without_append_or_close() { var service = new FakeManualService(Sales("Alpha", 1)) { GetOverride = _ => Sales("Beta", 2) }; var controller = Controller(service); var opened = await controller.OpenManualFinancialAsync( ManualFinancialScreenKind.Sales); var resultId = Assert.Single(opened.ManualFinancial!.Rows).ResultId; var rejected = await controller.ActivateManualFinancialResultAsync(resultId); Assert.NotNull(rejected.ManualFinancial); Assert.Empty(rejected.Playlist); Assert.Equal(1, service.GetCalls); Assert.Equal(0, service.MutationCalls); Assert.NotNull(rejected.Dialog); Assert.False(rejected.CommandResult?.Succeeded); } [Fact] public async Task Existing_GraphE_row_loads_verifies_and_adds_CSharp_owned_playlist_row() { var service = new FakeManualService(Sales("Alpha", 1)); var controller = Controller(service); var opened = await controller.OpenManualFinancialAsync( ManualFinancialScreenKind.Sales); var rowId = Assert.Single(opened.ManualFinancial!.Rows).ResultId; var loaded = await controller.LoadManualFinancialAsync(rowId); Assert.True(loaded.ManualFinancial!.CanMaterializePlaylist); Assert.Equal(0, service.MutationCalls); var added = controller.ActivateManualFinancialAction("sales"); var playlist = Assert.Single(added.Playlist); var entry = playlist.ToPlayoutEntry(); Assert.Equal("5080", entry.CutCode); var selection = Assert.IsType(entry.Selection); Assert.Equal("Alpha", selection.Subject); Assert.Equal("SALES", selection.GraphicType); Assert.Equal("1/1", playlist.PageText); } [Fact] public async Task GraphE_find_loads_fresh_detail_and_stale_sort_generation_opens_modal() { var service = new FakeManualService(Sales("Alpha", 1)); var controller = Controller(service); var opened = await controller.OpenManualFinancialAsync( ManualFinancialScreenKind.Sales); var generation = opened.ManualFinancial!.Revision; var found = await controller.FindManualFinancialAsync( "Alpha", LegacyManualFinancialFindDirection.Down, generation); Assert.Equal("Alpha", found.ManualFinancial!.SelectedRecord!.Identity.StockName); Assert.Equal(1, service.GetCalls); var sorted = controller.ToggleManualFinancialNameSort( found.ManualFinancial.Revision); var stale = controller.ToggleManualFinancialNameSort( found.ManualFinancial.Revision); Assert.NotNull(stale.Dialog); Assert.Equal( sorted.ManualFinancial!.NameSortDirection, stale.ManualFinancial!.NameSortDirection); } [Fact] public async Task Create_does_not_write_until_explicit_save_and_fresh_readback_completes() { var service = new FakeManualService(); var controller = Controller(service); await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.OperatingProfit); controller.BeginManualFinancialCreate(); var searched = await controller.SearchManualFinancialStocksAsync("Alpha"); controller.SelectManualFinancialStock( Assert.Single(searched.ManualFinancial!.StockCandidates).ResultId); Assert.Equal(0, service.MutationCalls); var saved = await controller.SaveManualFinancialAsync(Profit("Alpha", 10)); Assert.Equal(1, service.MutationCalls); Assert.Equal(1, service.GetCalls); Assert.Equal( LegacyManualFinancialWorkflowStatus.WriteCommitted, saved.ManualFinancial!.Status); Assert.True(saved.ManualFinancial.CanMaterializePlaylist); Assert.Equal("저장되었습니다", saved.Dialog?.Message); Assert.Equal("MmoneyCoder", saved.Dialog?.Caption); Assert.False(saved.Dialog?.IsConfirmation); Assert.DoesNotContain("password", saved.StatusMessage, StringComparison.OrdinalIgnoreCase); var dismissed = controller.DismissDialog(); Assert.Null(dismissed.Dialog); Assert.NotNull(dismissed.ManualFinancial); } [Fact] public async Task Empty_GraphE_stock_confirm_is_a_quiet_no_op() { var service = new FakeManualService(); var controller = Controller(service); var opened = await controller.OpenManualFinancialAsync( ManualFinancialScreenKind.OperatingProfit); var creating = controller.BeginManualFinancialCreate(); var unchanged = await controller.SearchManualFinancialStocksAsync(" "); Assert.Equal(creating.Revision, unchanged.Revision); Assert.Equal(creating.ManualFinancial!.Revision, unchanged.ManualFinancial!.Revision); Assert.Empty(unchanged.ManualFinancial.StockCandidates); Assert.Null(unchanged.Dialog); Assert.Equal(0, service.MutationCalls); Assert.NotEqual(opened.Revision, unchanged.Revision); var noSelection = controller.SelectManualFinancialStock(string.Empty); Assert.Equal(unchanged.Revision, noSelection.Revision); Assert.Null(noSelection.Dialog); } [Fact] public async Task Known_GraphE_save_rejection_uses_specific_sanitized_dialog_and_keeps_editor() { const string safeMessage = "The create precondition failed and the Oracle transaction was rolled back."; var service = new FakeManualService { MutationException = new ManualFinancialMutationRejectedException( ManualFinancialMutationRejectionReason.DuplicateIdentity, safeMessage) }; var controller = Controller(service); await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.OperatingProfit); controller.BeginManualFinancialCreate(); var searched = await controller.SearchManualFinancialStocksAsync("Alpha"); controller.SelectManualFinancialStock( Assert.Single(searched.ManualFinancial!.StockCandidates).ResultId); var rejected = await controller.SaveManualFinancialAsync(Profit("Alpha", 10)); Assert.Equal(1, service.MutationCalls); Assert.Equal( LegacyManualFinancialWorkflowStatus.WriteRejected, rejected.ManualFinancial!.Status); Assert.Equal(safeMessage, rejected.StatusMessage); Assert.Equal(safeMessage, rejected.Dialog?.Message); Assert.Equal("MmoneyCoder", rejected.Dialog?.Caption); Assert.DoesNotContain("DB연결", rejected.Dialog!.Message, StringComparison.Ordinal); var dismissed = controller.DismissDialog(); Assert.Null(dismissed.Dialog); Assert.NotNull(dismissed.ManualFinancial); Assert.True(dismissed.ManualFinancial.CanSave); } [Fact] public async Task Malformed_GraphE_editor_data_is_not_misreported_as_a_database_outage() { var service = new FakeManualService(); var controller = Controller(service); await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.OperatingProfit); controller.BeginManualFinancialCreate(); var searched = await controller.SearchManualFinancialStocksAsync("Alpha"); controller.SelectManualFinancialStock( Assert.Single(searched.ManualFinancial!.StockCandidates).ResultId); var rejected = await controller.SaveManualFinancialAsync( Assert.IsType(Sales("Alpha", 1).Record)); Assert.NotNull(rejected.ManualFinancial); Assert.NotNull(rejected.Dialog); Assert.DoesNotContain("DB연결", rejected.Dialog!.Message, StringComparison.Ordinal); Assert.Equal(0, service.MutationCalls); } [Fact] public async Task Delete_and_delete_all_require_MmoneyCoder_confirmation_before_writing() { var service = new FakeManualService(Sales("Alpha", 1)); var controller = Controller(service); var opened = await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.Sales); await controller.LoadManualFinancialAsync( Assert.Single(opened.ManualFinancial!.Rows).ResultId); Assert.Equal(0, service.MutationCalls); var deleteRequested = await controller.DeleteManualFinancialAsync(all: false); Assert.Equal("선택한 종목을 삭제하겠습니까?", deleteRequested.Dialog?.Message); Assert.Equal("MmoneyCoder", deleteRequested.Dialog?.Caption); Assert.True(deleteRequested.Dialog?.IsConfirmation); Assert.Equal(0, service.MutationCalls); var canceled = controller.CancelDialog(); Assert.Null(canceled.Dialog); Assert.Equal(0, service.MutationCalls); Assert.Single(canceled.ManualFinancial!.Rows); await controller.DeleteManualFinancialAsync(all: false); var deleted = await controller.ConfirmDialogAsync(); Assert.Equal(1, service.MutationCalls); Assert.Empty(deleted.ManualFinancial!.Rows); var allRequested = await controller.DeleteManualFinancialAsync(all: true); Assert.Equal("모든 종목을 삭제하겠습니까?", allRequested.Dialog?.Message); Assert.True(allRequested.Dialog?.IsConfirmation); Assert.Equal(1, service.MutationCalls); var all = await controller.ConfirmDialogAsync(); Assert.Equal(2, service.MutationCalls); Assert.Empty(all.ManualFinancial!.Rows); } [Fact] public async Task Delete_without_a_GraphE_row_selection_remains_a_silent_no_op() { var service = new FakeManualService(Sales("Alpha", 1)); var controller = Controller(service); await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.Sales); var result = await controller.DeleteManualFinancialAsync(all: false); Assert.Null(result.Dialog); Assert.Equal(0, service.MutationCalls); Assert.Single(result.ManualFinancial!.Rows); } private static LegacyOperatorController Controller(IManualFinancialScreenService service) { var workflow = new LegacyManualFinancialWorkflow( service, new FakeStockService(), new LegacyManualFinancialWriteSafetyState()); return new LegacyOperatorController( new EmptyStockLookup(), manualFinancialWorkflow: workflow); } private static ManualFinancialSnapshot Sales(string stockName, int version) => new( new ManualSalesRecord( new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName), Enumerable.Range(1, 6) .Select(index => new ManualQuarterValue($"{index}Q", index)) .ToArray()), Version(version)); private static ManualOperatingProfitRecord Profit(string stockName, int offset) => new( new ManualFinancialIdentity( ManualFinancialScreenKind.OperatingProfit, stockName), Enumerable.Range(1, 6) .Select(index => new ManualQuarterValue($"{index}Q", offset + index)) .ToArray()); private static string Version(int value) => value.ToString("X64"); private sealed class EmptyStockLookup : ILegacyStockLookup { public Task SearchAsync( string query, CancellationToken cancellationToken = default) => Task.FromResult(new LegacyStockSearchData(query, [], [])); } private sealed class FakeStockService : IStockSearchService { public Task SearchAsync( string query, int maximumResults = LegacyStockSearchService.DefaultMaximumResults, CancellationToken cancellationToken = default) => Task.FromResult(new StockSearchResult( query, DateTimeOffset.UtcNow, [new StockSearchItem( StockMarket.Kospi, DataSourceKind.Oracle, "Alpha", "000001")], false)); } private sealed class FakeManualService : IManualFinancialScreenService { private readonly Dictionary<(ManualFinancialScreenKind, string), ManualFinancialSnapshot> _rows = new(); private int _version = 10; internal FakeManualService(params ManualFinancialSnapshot[] rows) { foreach (var row in rows) { _rows[(row.Record.Identity.Screen, row.Record.Identity.StockName)] = row; } } public bool CanMutate => true; internal int MutationCalls { get; private set; } internal int GetCalls { get; private set; } internal ManualFinancialMutationException? MutationException { get; init; } internal int RejectedItemCount { get; init; } internal IReadOnlyList UnreadableStockNames { get; init; } = Array.Empty(); internal Func? GetOverride { get; init; } public Task SearchAsync( ManualFinancialScreenKind screen, string query = "", int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults, CancellationToken cancellationToken = default) { var rows = _rows.Values .Where(row => row.Record.Identity.Screen == screen) .Where(row => query.Length == 0 || row.Record.Identity.StockName.Contains( query, StringComparison.OrdinalIgnoreCase)) .Take(maximumResults) .ToArray(); return Task.FromResult(new ManualFinancialSearchResult( screen, query, DateTimeOffset.UtcNow, rows, false) { RejectedItemCount = RejectedItemCount, UnreadableStockNames = UnreadableStockNames }); } public Task GetAsync( ManualFinancialIdentity identity, CancellationToken cancellationToken = default) { GetCalls++; return Task.FromResult( GetOverride?.Invoke(identity) ?? _rows[(identity.Screen, identity.StockName)]); } public Task CreateAsync( ManualFinancialRecord record, CancellationToken cancellationToken = default) { MutationCalls++; if (MutationException is not null) { throw MutationException; } _rows[(record.Identity.Screen, record.Identity.StockName)] = new ManualFinancialSnapshot(record, Version(++_version)); return Task.FromResult(Receipt( ManualFinancialMutationKind.Create, record.Identity.Screen, record.Identity.StockName, 1)); } public Task UpdateAsync( ManualFinancialSnapshot expected, ManualFinancialRecord replacement, CancellationToken cancellationToken = default) { MutationCalls++; if (MutationException is not null) { throw MutationException; } _rows[(replacement.Identity.Screen, replacement.Identity.StockName)] = new ManualFinancialSnapshot(replacement, Version(++_version)); return Task.FromResult(Receipt( ManualFinancialMutationKind.Update, replacement.Identity.Screen, replacement.Identity.StockName, 1)); } public Task DeleteAsync( ManualFinancialSnapshot expected, CancellationToken cancellationToken = default) { MutationCalls++; _rows.Remove(( expected.Record.Identity.Screen, expected.Record.Identity.StockName)); return Task.FromResult(Receipt( ManualFinancialMutationKind.DeleteOne, expected.Record.Identity.Screen, expected.Record.Identity.StockName, 1)); } public Task DeleteAllAsync( ManualFinancialScreenKind screen, CancellationToken cancellationToken = default) { MutationCalls++; var keys = _rows.Keys.Where(key => key.Item1 == screen).ToArray(); foreach (var key in keys) { _rows.Remove(key); } return Task.FromResult(Receipt( ManualFinancialMutationKind.DeleteAll, screen, string.Empty, keys.Length)); } private static ManualFinancialMutationReceipt Receipt( ManualFinancialMutationKind kind, ManualFinancialScreenKind screen, string stockName, int affectedRows) => new( Guid.NewGuid(), kind, screen, stockName, DateTimeOffset.UtcNow, affectedRows); } private sealed class RawGraphEQueryExecutor : IDataQueryExecutor { private readonly DataTable _rows = CreateRows(); public DataTable Execute(DataSourceKind source, string tableName, string query) => throw new NotSupportedException(); public Task ExecuteAsync( DataSourceKind source, string tableName, string query, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ExecuteAsync( DataSourceKind source, string tableName, DataQuerySpec query, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (!string.Equals( tableName, "MANUAL_FINANCIAL_SELL_RAW_GET", StringComparison.Ordinal)) { return Task.FromResult(_rows.Copy()); } var stockName = Assert.IsType(query.Parameters.Single( parameter => string.Equals( parameter.Name, "stock_name", StringComparison.OrdinalIgnoreCase)).Value); var result = _rows.Clone(); var row = Assert.Single(_rows.AsEnumerable().Where(candidate => string.Equals( candidate.Field("STOCK_NAME"), stockName, StringComparison.Ordinal))); result.ImportRow(row); return Task.FromResult(result); } private static DataTable CreateRows() { var table = new DataTable(); foreach (var column in ManualFinancialCutContracts .Get(ManualFinancialScreenKind.Sales) .StorageColumns) { table.Columns.Add(column, typeof(string)); } table.Columns.Add("ROW_VERSION", typeof(string)); table.Columns.Add("ROW_HANDLE", typeof(string)); table.Rows.Add( "Alpha", "1Q_10", "2Q_20", "3Q_30", "4Q_40", "5Q_50", "6Q_60", Version(1), "AAAAAAAAAAAAAAAA01"); table.Rows.Add( "Broken", "1Q_not-a-number", "2Q_20", "3Q_30", "4Q_40", "5Q_50", "6Q_60", Version(2), "AAAAAAAAAAAAAAAA02"); return table; } } }