feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyNamedPlaylistOperatorIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Construction_DoesNotReadOrWriteNamedPlaylistDatabase()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence([]);
|
||||
|
||||
_ = CreateController(persistence);
|
||||
|
||||
Assert.Equal(0, persistence.ListCalls);
|
||||
Assert.Equal(0, persistence.LoadCalls);
|
||||
Assert.Equal(0, persistence.ReplaceCalls);
|
||||
Assert.Equal(0, persistence.CreateCalls);
|
||||
Assert.Equal(0, persistence.DeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_by_opaque_id_selects_and_loads_in_one_native_command()
|
||||
{
|
||||
var source = LegacyStockCutCatalog.CreatePlaylistRow(
|
||||
"source-row",
|
||||
"Samsung Electronics",
|
||||
new LegacyStockIdentity(
|
||||
LegacyDomesticStockMarket.Kospi,
|
||||
"Samsung Electronics",
|
||||
"005930"),
|
||||
LegacyStockCutCatalog.Rows[1]);
|
||||
var selection = Assert.IsType<LegacySceneSelection>(
|
||||
source.ToPlayoutEntry().Selection);
|
||||
var persistence = new OperatorNamedPlaylistPersistence(
|
||||
[
|
||||
new NamedPlaylistStoredItem(
|
||||
0,
|
||||
true,
|
||||
selection,
|
||||
new NamedPlaylistPageState(1, 1))
|
||||
]);
|
||||
var controller = CreateController(persistence);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
var definitionId = Assert.Single(
|
||||
listed.NamedPlaylist!.Definitions).DefinitionId;
|
||||
|
||||
var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId);
|
||||
|
||||
Assert.Equal(1, persistence.LoadCalls);
|
||||
Assert.Single(loaded.Playlist);
|
||||
Assert.Equal("load-named-playlist-by-id", loaded.CommandResult?.Command);
|
||||
Assert.Equal(definitionId, loaded.CommandResult?.TargetId);
|
||||
Assert.True(loaded.CommandResult?.Succeeded);
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh,
|
||||
loaded.NamedPlaylist?.DocumentFreshness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSelected_ReplacesPlaylistWithTypedOriginalSevenFields()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence(
|
||||
[
|
||||
new NamedPlaylistStoredItem(
|
||||
0,
|
||||
false,
|
||||
new LegacySceneSelection(
|
||||
"코스피",
|
||||
"삼성전자",
|
||||
"1열판기본",
|
||||
"현재가",
|
||||
"005930"),
|
||||
new NamedPlaylistPageState(1, 1))
|
||||
]);
|
||||
var controller = CreateController(persistence);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
controller.SelectNamedPlaylist(
|
||||
listed.NamedPlaylist!.Definitions.Single().DefinitionId);
|
||||
|
||||
var loaded = await controller.LoadSelectedNamedPlaylistAsync();
|
||||
|
||||
var row = Assert.Single(loaded.Playlist);
|
||||
Assert.False(row.IsEnabled);
|
||||
Assert.Equal("코스피", row.MarketText);
|
||||
Assert.Equal("삼성전자", row.StockName);
|
||||
Assert.Equal("1열판기본", row.GraphicType);
|
||||
Assert.Equal("현재가", row.Subtype);
|
||||
Assert.Equal("1/1", row.PageText);
|
||||
Assert.Equal("005930", row.StockCode);
|
||||
Assert.Equal("5001", row.SceneAlias);
|
||||
Assert.Equal("코스피", row.PlayoutSelection?.GroupCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSelected_UnknownSceneKeepsCurrentPlaylistAtomically()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence(
|
||||
[
|
||||
new NamedPlaylistStoredItem(
|
||||
0,
|
||||
true,
|
||||
new LegacySceneSelection(
|
||||
"UNKNOWN-GROUP",
|
||||
"알 수 없음",
|
||||
"UNKNOWN-GRAPHIC",
|
||||
"UNKNOWN-SUBTYPE",
|
||||
"UNKNOWN-DATA"),
|
||||
null)
|
||||
]);
|
||||
var controller = CreateController(persistence);
|
||||
var fixedAction = LegacyFixedActionCatalog.Default
|
||||
.GetActions(LegacyFixedMarket.Overseas)
|
||||
.First(action => action.Available);
|
||||
controller.ActivateFixedAction(fixedAction.Id);
|
||||
var before = controller.Current.Playlist.Single();
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
var definitionId = listed.NamedPlaylist!.Definitions.Single().DefinitionId;
|
||||
|
||||
var after = await controller.LoadNamedPlaylistByIdAsync(definitionId);
|
||||
|
||||
Assert.Equal(before.RowId, Assert.Single(after.Playlist).RowId);
|
||||
Assert.NotNull(after.Dialog);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Error, after.StatusKind);
|
||||
Assert.Equal("load-named-playlist-by-id", after.CommandResult?.Command);
|
||||
Assert.False(after.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveCurrent_WritesVisibleSevenFieldsOnceAndReadsBackFresh()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence([]);
|
||||
var controller = CreateController(persistence);
|
||||
var action = LegacyFixedActionCatalog.Default
|
||||
.GetActions(LegacyFixedMarket.Overseas)
|
||||
.First(candidate => candidate.Available);
|
||||
var added = controller.ActivateFixedAction(action.Id);
|
||||
var visible = Assert.Single(added.Playlist);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
controller.SelectNamedPlaylist(
|
||||
listed.NamedPlaylist!.Definitions.Single().DefinitionId);
|
||||
|
||||
var saved = await controller.SaveCurrentNamedPlaylistAsync();
|
||||
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
var stored = Assert.Single(persistence.LastReplaceItems!);
|
||||
Assert.Equal(0, stored.ItemIndex);
|
||||
Assert.Equal(visible.IsEnabled, stored.IsEnabled);
|
||||
Assert.Equal(visible.MarketText, stored.Selection.GroupCode);
|
||||
Assert.Equal(visible.StockName, stored.Selection.Subject);
|
||||
Assert.Equal(visible.GraphicType, stored.Selection.GraphicType);
|
||||
Assert.Equal(visible.Subtype, stored.Selection.Subtype);
|
||||
Assert.Equal(visible.StockCode, stored.Selection.DataCode);
|
||||
Assert.Equal(visible.PageText, stored.Page?.ToString());
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistMutationOutcome.CommittedFresh,
|
||||
saved.NamedPlaylist?.LastMutationOutcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_to_opaque_id_selects_and_writes_once_in_one_native_command()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence([]);
|
||||
var controller = CreateController(persistence);
|
||||
var action = LegacyFixedActionCatalog.Default
|
||||
.GetActions(LegacyFixedMarket.Overseas)
|
||||
.First(candidate => candidate.Available);
|
||||
controller.ActivateFixedAction(action.Id);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
var definitionId = Assert.Single(
|
||||
listed.NamedPlaylist!.Definitions).DefinitionId;
|
||||
|
||||
var saved = await controller.SaveCurrentNamedPlaylistToAsync(definitionId);
|
||||
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Equal("save-current-named-playlist-to", saved.CommandResult?.Command);
|
||||
Assert.Equal(definitionId, saved.CommandResult?.TargetId);
|
||||
Assert.True(saved.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
private static LegacyOperatorController CreateController(
|
||||
INamedPlaylistPersistenceService persistence) =>
|
||||
new(
|
||||
new UnusedLookup(),
|
||||
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
|
||||
|
||||
private sealed class UnusedLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new LegacyStockSearchData(query, [], []));
|
||||
}
|
||||
|
||||
private sealed class OperatorNamedPlaylistPersistence : INamedPlaylistPersistenceService
|
||||
{
|
||||
private const string ProgramCode = "00000001";
|
||||
private const string Title = "운영 재생목록";
|
||||
private List<NamedPlaylistStoredItem> _items;
|
||||
private bool _exists = true;
|
||||
|
||||
public OperatorNamedPlaylistPersistence(IEnumerable<NamedPlaylistStoredItem> items)
|
||||
{
|
||||
_items = items.Select(Clone).ToList();
|
||||
}
|
||||
|
||||
public bool CanMutate => true;
|
||||
|
||||
public int ListCalls { get; private set; }
|
||||
|
||||
public int LoadCalls { get; private set; }
|
||||
|
||||
public int ReplaceCalls { get; private set; }
|
||||
|
||||
public int CreateCalls { get; private set; }
|
||||
|
||||
public int DeleteCalls { get; private set; }
|
||||
|
||||
public IReadOnlyList<NamedPlaylistStoredItem>? LastReplaceItems { get; private set; }
|
||||
|
||||
public Task<NamedPlaylistListResult> ListAsync(
|
||||
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++ListCalls;
|
||||
IReadOnlyList<NamedPlaylistSummary> summaries = _exists
|
||||
? [new NamedPlaylistSummary(ProgramCode, Title)]
|
||||
: [];
|
||||
return Task.FromResult(new NamedPlaylistListResult(
|
||||
DateTimeOffset.UtcNow,
|
||||
summaries,
|
||||
false));
|
||||
}
|
||||
|
||||
public Task<string> SuggestNextProgramCodeAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_exists ? "00000002" : ProgramCode);
|
||||
|
||||
public Task<NamedPlaylistDocument> LoadAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++LoadCalls;
|
||||
if (!_exists || !string.Equals(programCode, ProgramCode, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromException<NamedPlaylistDocument>(
|
||||
new NamedPlaylistNotFoundException(programCode));
|
||||
}
|
||||
|
||||
return Task.FromResult(new NamedPlaylistDocument(
|
||||
new NamedPlaylistSummary(ProgramCode, Title),
|
||||
DateTimeOffset.UtcNow,
|
||||
_items.Select(Clone).ToArray()));
|
||||
}
|
||||
|
||||
public Task CreateDefinitionAsync(
|
||||
string programCode,
|
||||
string title,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++CreateCalls;
|
||||
_exists = true;
|
||||
_items = [];
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReplaceItemsAsync(
|
||||
string programCode,
|
||||
IReadOnlyList<NamedPlaylistStoredItem> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++ReplaceCalls;
|
||||
LastReplaceItems = items.Select(Clone).ToArray();
|
||||
_items = items.Select(Clone).ToList();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++DeleteCalls;
|
||||
_exists = false;
|
||||
_items = [];
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static NamedPlaylistStoredItem Clone(NamedPlaylistStoredItem item) =>
|
||||
new(
|
||||
item.ItemIndex,
|
||||
item.IsEnabled,
|
||||
item.Selection with { },
|
||||
item.Page is null
|
||||
? null
|
||||
: new NamedPlaylistPageState(
|
||||
item.Page.CurrentPage,
|
||||
item.Page.TotalPages));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyNamedPlaylistWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RefreshSelectAndLoad_ExposeOpaqueIdentityAndTypedRowsOnly()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
|
||||
var listed = await controller.RefreshAsync();
|
||||
var selected = controller.SelectDefinition(listed.Definitions.Single(
|
||||
definition => definition.Title == "마감").DefinitionId);
|
||||
var loaded = await controller.LoadSelectedAsync();
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistReadStatus.Ready, listed.ReadStatus);
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, listed.ListFreshness);
|
||||
Assert.All(listed.Definitions, definition =>
|
||||
Assert.DoesNotContain("000000", definition.DefinitionId, StringComparison.Ordinal));
|
||||
Assert.Equal("마감", selected.SelectedTitle);
|
||||
Assert.Equal("00000002", persistence.LastLoadedProgramCode);
|
||||
Assert.Collection(
|
||||
loaded.Rows,
|
||||
row =>
|
||||
{
|
||||
Assert.Equal(0, row.ItemIndex);
|
||||
Assert.True(row.IsEnabled);
|
||||
Assert.Equal("KOSDAQ", row.Selection.GroupCode);
|
||||
Assert.Equal("다온테크", row.Selection.Subject);
|
||||
Assert.Equal("1열판기본", row.Selection.GraphicType);
|
||||
Assert.Equal("현재가", row.Selection.Subtype);
|
||||
Assert.Equal("035720", row.Selection.DataCode);
|
||||
Assert.Equal("1/1", row.Page?.PageText);
|
||||
});
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyNamedPlaylistDefinitionView).GetProperties(),
|
||||
property => property.Name.Contains("ProgramCode", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyNamedPlaylistWorkflowSnapshot).GetProperties(),
|
||||
property => property.Name.Contains("ProgramCode", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyNamedPlaylistRowView).GetProperties(),
|
||||
property => property.Name.Contains("ListText", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveCurrent_SendsOneAtomicOrderedSevenFieldBatch_ThenReadsFresh()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var drafts = new[]
|
||||
{
|
||||
controller.CreateRowDraft(
|
||||
Entry("entry-a", true, "KOSPI", "삼성전자", "1열판기본", "현재가", "005930"),
|
||||
new NamedPlaylistPageState(1, 1)),
|
||||
controller.CreateRowDraft(
|
||||
Entry("entry-b", false, "PAGED_THEME", "반도체", "INPUT_ORDER", "CURRENT", "00000012"),
|
||||
new NamedPlaylistPageState(2, 3))
|
||||
};
|
||||
|
||||
var result = await controller.SaveCurrentAsync(drafts);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, result.Outcome);
|
||||
Assert.True(result.IsCommitted);
|
||||
Assert.False(result.AutomaticRetryAllowed);
|
||||
Assert.False(result.Snapshot.IsWriteInProgress);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Collection(
|
||||
persistence.LastReplaceItems!,
|
||||
item =>
|
||||
{
|
||||
Assert.Equal(0, item.ItemIndex);
|
||||
Assert.Equal(
|
||||
"1^KOSPI^삼성전자^1열판기본^현재가^1/1^005930",
|
||||
SevenFields(item));
|
||||
},
|
||||
item =>
|
||||
{
|
||||
Assert.Equal(1, item.ItemIndex);
|
||||
Assert.Equal(
|
||||
"0^PAGED_THEME^반도체^INPUT_ORDER^CURRENT^2/3^00000012",
|
||||
SevenFields(item));
|
||||
});
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, result.Snapshot.DocumentFreshness);
|
||||
Assert.Equal(["삼성전자", "반도체"], result.Snapshot.Rows.Select(row => row.Selection.Subject));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAndDelete_UseInternalSuggestedIdentityAndFreshListReadBack()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
await controller.RefreshAsync();
|
||||
var draft = controller.CreateProgramDraft("새 프로그램");
|
||||
|
||||
var created = await controller.CreateProgramAsync(draft);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, created.Outcome);
|
||||
Assert.Equal("00000003", persistence.LastCreatedProgramCode);
|
||||
Assert.Equal("새 프로그램", created.Snapshot.SelectedTitle);
|
||||
Assert.Equal(3, created.Snapshot.Definitions.Count);
|
||||
Assert.Empty(created.Snapshot.Rows);
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyNamedPlaylistProgramDraft).GetProperties(),
|
||||
property => property.Name.Contains("Code", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("00000003", draft.DraftId, StringComparison.Ordinal);
|
||||
|
||||
var deleted = await controller.DeleteSelectedAsync();
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, deleted.Outcome);
|
||||
Assert.Equal(1, persistence.DeleteCalls);
|
||||
Assert.Equal("00000003", persistence.LastDeletedProgramCode);
|
||||
Assert.Null(deleted.Snapshot.SelectedDefinitionId);
|
||||
Assert.DoesNotContain(
|
||||
deleted.Snapshot.Definitions,
|
||||
definition => string.Equals(definition.Title, "새 프로그램", StringComparison.Ordinal));
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, deleted.Snapshot.ListFreshness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownRollback_PreservesLoadedRowsAndNeverRetries()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var before = controller.Current;
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
persistence.ReplaceFailure = new NamedPlaylistMutationException(
|
||||
"The transaction rolled back.",
|
||||
outcomeUnknown: false);
|
||||
|
||||
var result = await controller.SaveCurrentAsync([draft]);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.RolledBack, result.Outcome);
|
||||
Assert.False(result.AutomaticRetryAllowed);
|
||||
Assert.False(result.Snapshot.IsWriteQuarantined);
|
||||
Assert.True(result.Snapshot.CanMutate);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Equal(
|
||||
before.Rows.Select(row => row.Selection.Subject),
|
||||
result.Snapshot.Rows.Select(row => row.Selection.Subject));
|
||||
Assert.Equal("삼성전자", persistence.StoredSubjects("00000001").Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutcomeUnknown_LatchesQuarantineAndBlocksEveryLaterWriteWithoutRetry()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
persistence.ReplaceFailure = new NamedPlaylistMutationException(
|
||||
"Commit response was lost.",
|
||||
outcomeUnknown: true);
|
||||
|
||||
var unknown = await controller.SaveCurrentAsync([draft]);
|
||||
persistence.ReplaceFailure = null;
|
||||
var blocked = await controller.SaveCurrentAsync([draft]);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.OutcomeUnknown, unknown.Outcome);
|
||||
Assert.True(unknown.Snapshot.IsWriteQuarantined);
|
||||
Assert.False(unknown.Snapshot.CanMutate);
|
||||
Assert.False(unknown.AutomaticRetryAllowed);
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.Quarantined, blocked.Outcome);
|
||||
Assert.False(blocked.AutomaticRetryAllowed);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Equal("삼성전자", persistence.StoredSubjects("00000001").Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteGate_ReturnsBusyWhileFirstAtomicSaveIsPending()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var entered = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
persistence.ReplaceEntered = entered;
|
||||
persistence.ReleaseReplace = release;
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
|
||||
var first = controller.SaveCurrentAsync([draft]);
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var concurrent = await controller.SaveCurrentAsync([draft]);
|
||||
release.SetResult();
|
||||
var completed = await first;
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.Busy, concurrent.Outcome);
|
||||
Assert.False(concurrent.AutomaticRetryAllowed);
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, completed.Outcome);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedFreshReadBack_KeepsOptimisticRowsUntilExplicitFreshLoad()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "낙관행", "1열판기본", "현재가", "654321"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
persistence.LoadFailures.Enqueue(
|
||||
new NamedPlaylistDataException("Simulated read-back failure."));
|
||||
|
||||
var optimistic = await controller.SaveCurrentAsync([draft]);
|
||||
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic,
|
||||
optimistic.Outcome);
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistFreshness.Optimistic,
|
||||
optimistic.Snapshot.DocumentFreshness);
|
||||
Assert.Equal("낙관행", Assert.Single(optimistic.Snapshot.Rows).Selection.Subject);
|
||||
Assert.False(optimistic.Snapshot.IsWriteQuarantined);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
|
||||
var fresh = await controller.LoadSelectedAsync();
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, fresh.DocumentFreshness);
|
||||
Assert.Equal("낙관행", Assert.Single(fresh.Rows).Selection.Subject);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOnlyPersistence_RejectsMutationBeforeServiceCall()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
persistence.CanMutate = false;
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
|
||||
var result = await controller.SaveCurrentAsync([draft]);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.ReadOnly, result.Outcome);
|
||||
Assert.False(result.Snapshot.CanMutate);
|
||||
Assert.Equal(0, persistence.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SnapshotsAreImmutable_AndStaleOpaqueIdsAreRejectedAfterRefresh()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
var before = await controller.RefreshAsync();
|
||||
var staleId = before.Definitions[0].DefinitionId;
|
||||
controller.SelectDefinition(staleId);
|
||||
var loaded = await controller.LoadSelectedAsync();
|
||||
|
||||
var after = await controller.RefreshAsync();
|
||||
|
||||
var definitions = Assert.IsAssignableFrom<
|
||||
ICollection<LegacyNamedPlaylistDefinitionView>>(before.Definitions);
|
||||
Assert.Throws<NotSupportedException>(() => definitions.Clear());
|
||||
var rows = Assert.IsAssignableFrom<ICollection<LegacyNamedPlaylistRowView>>(loaded.Rows);
|
||||
Assert.Throws<NotSupportedException>(() => rows.Clear());
|
||||
Assert.NotEqual(staleId, after.SelectedDefinitionId);
|
||||
Assert.Throws<KeyNotFoundException>(() => controller.SelectDefinition(staleId));
|
||||
Assert.Equal("마감", before.Definitions[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftsAreBoundToTheirNativeAuthorityInstance()
|
||||
{
|
||||
var firstPersistence = CreateSeededPersistence();
|
||||
var secondPersistence = CreateSeededPersistence();
|
||||
var first = await LoadOpeningAsync(firstPersistence);
|
||||
var second = await LoadOpeningAsync(secondPersistence);
|
||||
var foreignDraft = first.CreateRowDraft(
|
||||
Entry("foreign", true, "KOSPI", "외부", "1열판기본", "현재가", "111111"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => second.SaveCurrentAsync([foreignDraft]));
|
||||
|
||||
Assert.Equal(0, secondPersistence.ReplaceCalls);
|
||||
}
|
||||
|
||||
private static FakeNamedPlaylistPersistence CreateSeededPersistence()
|
||||
{
|
||||
var persistence = new FakeNamedPlaylistPersistence();
|
||||
persistence.Seed(
|
||||
"00000001",
|
||||
"오전",
|
||||
StoredItem(0, true, "KOSPI", "삼성전자", "1열판기본", "현재가", "005930", 1, 1));
|
||||
persistence.Seed(
|
||||
"00000002",
|
||||
"마감",
|
||||
StoredItem(0, true, "KOSDAQ", "다온테크", "1열판기본", "현재가", "035720", 1, 1));
|
||||
return persistence;
|
||||
}
|
||||
|
||||
private static async Task<LegacyNamedPlaylistWorkflowController> LoadOpeningAsync(
|
||||
FakeNamedPlaylistPersistence persistence)
|
||||
{
|
||||
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
var listed = await controller.RefreshAsync();
|
||||
controller.SelectDefinition(listed.Definitions.Single(
|
||||
definition => definition.Title == "오전").DefinitionId);
|
||||
await controller.LoadSelectedAsync();
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static LegacyPlaylistEntry Entry(
|
||||
string id,
|
||||
bool enabled,
|
||||
string groupCode,
|
||||
string subject,
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string dataCode) =>
|
||||
new(
|
||||
id,
|
||||
"5001",
|
||||
enabled,
|
||||
6,
|
||||
new LegacySceneSelection(
|
||||
groupCode,
|
||||
subject,
|
||||
graphicType,
|
||||
subtype,
|
||||
dataCode));
|
||||
|
||||
private static NamedPlaylistStoredItem StoredItem(
|
||||
int index,
|
||||
bool enabled,
|
||||
string groupCode,
|
||||
string subject,
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string dataCode,
|
||||
int currentPage,
|
||||
int totalPages) =>
|
||||
new(
|
||||
index,
|
||||
enabled,
|
||||
new LegacySceneSelection(
|
||||
groupCode,
|
||||
subject,
|
||||
graphicType,
|
||||
subtype,
|
||||
dataCode),
|
||||
new NamedPlaylistPageState(currentPage, totalPages));
|
||||
|
||||
private static string SevenFields(NamedPlaylistStoredItem item) =>
|
||||
string.Join(
|
||||
'^',
|
||||
item.IsEnabled ? "1" : "0",
|
||||
item.Selection.GroupCode,
|
||||
item.Selection.Subject,
|
||||
item.Selection.GraphicType,
|
||||
item.Selection.Subtype,
|
||||
item.Page?.ToString() ?? string.Empty,
|
||||
item.Selection.DataCode);
|
||||
|
||||
private sealed class FakeNamedPlaylistPersistence : INamedPlaylistPersistenceService
|
||||
{
|
||||
private readonly Dictionary<string, StoredProgram> _programs =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public bool CanMutate { get; set; } = true;
|
||||
|
||||
public int CreateCalls { get; private set; }
|
||||
|
||||
public int ReplaceCalls { get; private set; }
|
||||
|
||||
public int DeleteCalls { get; private set; }
|
||||
|
||||
public int LoadCalls { get; private set; }
|
||||
|
||||
public string? LastCreatedProgramCode { get; private set; }
|
||||
|
||||
public string? LastLoadedProgramCode { get; private set; }
|
||||
|
||||
public string? LastDeletedProgramCode { get; private set; }
|
||||
|
||||
public IReadOnlyList<NamedPlaylistStoredItem>? LastReplaceItems { get; private set; }
|
||||
|
||||
public Exception? CreateFailure { get; set; }
|
||||
|
||||
public Exception? ReplaceFailure { get; set; }
|
||||
|
||||
public Exception? DeleteFailure { get; set; }
|
||||
|
||||
public Queue<Exception> LoadFailures { get; } = new();
|
||||
|
||||
public TaskCompletionSource? ReplaceEntered { get; set; }
|
||||
|
||||
public TaskCompletionSource? ReleaseReplace { get; set; }
|
||||
|
||||
public void Seed(
|
||||
string programCode,
|
||||
string title,
|
||||
params NamedPlaylistStoredItem[] items) =>
|
||||
_programs.Add(
|
||||
programCode,
|
||||
new StoredProgram(title, items.Select(Clone).ToList()));
|
||||
|
||||
public IEnumerable<string> StoredSubjects(string programCode) =>
|
||||
_programs[programCode].Items.Select(item => item.Selection.Subject);
|
||||
|
||||
public Task<NamedPlaylistListResult> ListAsync(
|
||||
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var ordered = _programs
|
||||
.Select(pair => new NamedPlaylistSummary(pair.Key, pair.Value.Title))
|
||||
.OrderBy(summary => summary.Title, StringComparer.Ordinal)
|
||||
.ThenBy(summary => summary.ProgramCode, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return Task.FromResult(new NamedPlaylistListResult(
|
||||
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
|
||||
ordered.Take(maximumResults).ToArray(),
|
||||
ordered.Length > maximumResults));
|
||||
}
|
||||
|
||||
public Task<string> SuggestNextProgramCodeAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var next = _programs.Count == 0
|
||||
? 1
|
||||
: _programs.Keys.Max(code => int.Parse(
|
||||
code,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture)) + 1;
|
||||
return Task.FromResult(next.ToString("D8", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public Task<NamedPlaylistDocument> LoadAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
++LoadCalls;
|
||||
LastLoadedProgramCode = programCode;
|
||||
if (LoadFailures.TryDequeue(out var failure))
|
||||
{
|
||||
return Task.FromException<NamedPlaylistDocument>(failure);
|
||||
}
|
||||
|
||||
if (!_programs.TryGetValue(programCode, out var stored))
|
||||
{
|
||||
return Task.FromException<NamedPlaylistDocument>(
|
||||
new NamedPlaylistNotFoundException(programCode));
|
||||
}
|
||||
|
||||
return Task.FromResult(new NamedPlaylistDocument(
|
||||
new NamedPlaylistSummary(programCode, stored.Title),
|
||||
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
|
||||
stored.Items.Select(Clone).ToArray()));
|
||||
}
|
||||
|
||||
public Task CreateDefinitionAsync(
|
||||
string programCode,
|
||||
string title,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
++CreateCalls;
|
||||
LastCreatedProgramCode = programCode;
|
||||
if (CreateFailure is not null)
|
||||
{
|
||||
return Task.FromException(CreateFailure);
|
||||
}
|
||||
|
||||
if (!_programs.TryAdd(programCode, new StoredProgram(title, [])))
|
||||
{
|
||||
return Task.FromException(new NamedPlaylistMutationException(
|
||||
"Duplicate program; rolled back.",
|
||||
outcomeUnknown: false));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task ReplaceItemsAsync(
|
||||
string programCode,
|
||||
IReadOnlyList<NamedPlaylistStoredItem> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
++ReplaceCalls;
|
||||
LastReplaceItems = Array.AsReadOnly(items.Select(Clone).ToArray());
|
||||
ReplaceEntered?.TrySetResult();
|
||||
if (ReleaseReplace is not null)
|
||||
{
|
||||
await ReleaseReplace.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (ReplaceFailure is not null)
|
||||
{
|
||||
throw ReplaceFailure;
|
||||
}
|
||||
|
||||
if (!_programs.TryGetValue(programCode, out var stored))
|
||||
{
|
||||
throw new NamedPlaylistNotFoundException(programCode);
|
||||
}
|
||||
|
||||
// One assignment models the executor's transaction boundary: no partial row
|
||||
// becomes visible when an exception is raised before this point.
|
||||
_programs[programCode] = stored with
|
||||
{
|
||||
Items = items.Select(Clone).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public Task DeleteAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
++DeleteCalls;
|
||||
LastDeletedProgramCode = programCode;
|
||||
if (DeleteFailure is not null)
|
||||
{
|
||||
return Task.FromException(DeleteFailure);
|
||||
}
|
||||
|
||||
if (!_programs.Remove(programCode))
|
||||
{
|
||||
return Task.FromException(new NamedPlaylistNotFoundException(programCode));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static NamedPlaylistStoredItem Clone(NamedPlaylistStoredItem item) =>
|
||||
new(
|
||||
item.ItemIndex,
|
||||
item.IsEnabled,
|
||||
item.Selection with { },
|
||||
item.Page is null
|
||||
? null
|
||||
: new NamedPlaylistPageState(
|
||||
item.Page.CurrentPage,
|
||||
item.Page.TotalPages));
|
||||
|
||||
private sealed record StoredProgram(
|
||||
string Title,
|
||||
List<NamedPlaylistStoredItem> Items);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user