feat: complete legacy UI and playout parity migration

This commit is contained in:
2026-07-15 13:11:38 +09:00
parent f14800656b
commit 6e0c275fdd
108 changed files with 43738 additions and 560 deletions

View File

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

View File

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