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,559 @@
#nullable enable
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
internal static class ProductionDevelopmentDatabaseSmokePlan
{
internal static IReadOnlyList<DevelopmentDatabaseSmokeScenario> Create(
string? configurationPath)
{
var options = new DatabaseOptionsLoader().Load(configurationPath);
// This executable never inherits the production read retry count. A
// timeout is surfaced once so the harness cannot conceal an uncertain
// state transition behind another attempt.
options.Resilience.MaximumRetryCount = 0;
options.Resilience.InitialRetryDelayMilliseconds = 0;
var runtime = DatabaseRuntime.Create(options);
var manualMutation = new OracleManualFinancialMutationExecutor(
runtime.ConnectionFactory,
options.Resilience);
var namedMutation = new OracleNamedPlaylistMutationExecutor(
runtime.ConnectionFactory,
options.Resilience);
var catalogMutation = new OperatorCatalogMutationExecutor(
runtime.ConnectionFactory,
options.Resilience);
var manual = new LegacyManualFinancialScreenService(
runtime.Executor,
manualMutation);
var named = new LegacyNamedPlaylistPersistenceService(
runtime.Executor,
namedMutation);
var themePersistence = new LegacyThemeCatalogPersistenceService(
runtime.Executor,
catalogMutation);
var themeSelection = new LegacyThemeSelectionService(runtime.Executor);
var expertPersistence = new LegacyExpertCatalogPersistenceService(
runtime.Executor,
catalogMutation);
var expertSelection = new LegacyExpertSelectionService(runtime.Executor);
var childGuard = new ExistingChildRowGuard(runtime.Executor);
var run = SmokeRunIdentity.Create(DateTimeOffset.UtcNow, Guid.NewGuid());
return
[
CreateManualScenario(
manual,
CreateManualRecord(ManualFinancialScreenKind.RevenueComposition, run.For("G_PIE"))),
CreateManualScenario(
manual,
CreateManualRecord(ManualFinancialScreenKind.GrowthMetrics, run.For("G_GROW"))),
CreateManualScenario(
manual,
CreateManualRecord(ManualFinancialScreenKind.Sales, run.For("G_SELL"))),
CreateManualScenario(
manual,
CreateManualRecord(ManualFinancialScreenKind.OperatingProfit, run.For("G_PROFIT"))),
CreateNamedPlaylistScenario(named, childGuard, run.For("PLAYLIST")),
CreateThemeScenario(
themePersistence,
themeSelection,
childGuard,
ThemeMarket.Krx,
run.For("THEME_KRX")),
CreateThemeScenario(
themePersistence,
themeSelection,
childGuard,
ThemeMarket.Nxt,
run.For("THEME_NXT")),
CreateExpertScenario(
expertPersistence,
expertSelection,
childGuard,
run.For("EXPERT"))
];
}
private static DevelopmentDatabaseSmokeScenario CreateManualScenario(
IManualFinancialScreenService service,
ManualFinancialRecord expectedRecord)
{
var identity = expectedRecord.Identity;
ManualFinancialSnapshot? freshSnapshot = null;
return new DevelopmentDatabaseSmokeScenario(
"GraphE/" + identity.Screen,
async cancellationToken =>
{
var search = await service.SearchAsync(
identity.Screen,
identity.StockName,
10,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated || search.Items.Any(item =>
string.Equals(
item.Record.Identity.StockName,
identity.StockName,
StringComparison.Ordinal)))
{
throw Collision("GraphE preflight did not prove absence.");
}
},
cancellationToken => IgnoreResultAsync(
service.CreateAsync(expectedRecord, cancellationToken)),
async cancellationToken =>
{
freshSnapshot = await service.GetAsync(identity, cancellationToken)
.ConfigureAwait(false);
VerifyManualRecord(expectedRecord, freshSnapshot);
},
save: null,
verifySave: null,
async cancellationToken =>
{
freshSnapshot ??= await service.GetAsync(identity, cancellationToken)
.ConfigureAwait(false);
await service.DeleteAsync(freshSnapshot, cancellationToken).ConfigureAwait(false);
},
cancellationToken => EnsureManualAbsentAsync(service, identity, cancellationToken),
IsOutcomeUnknown);
}
private static DevelopmentDatabaseSmokeScenario CreateNamedPlaylistScenario(
INamedPlaylistPersistenceService service,
ExistingChildRowGuard childGuard,
string title)
{
string? programCode = null;
var savedItem = new NamedPlaylistStoredItem(
0,
true,
new LegacySceneSelection(
"KOSPI",
"SMOKE",
"PLATE",
"CURRENT",
string.Empty),
new NamedPlaylistPageState(1, 1));
return new DevelopmentDatabaseSmokeScenario(
"AList/PList named playlist",
async cancellationToken =>
{
programCode = await service.SuggestNextProgramCodeAsync(cancellationToken)
.ConfigureAwait(false);
var list = await service.ListAsync(
LegacyNamedPlaylistPersistenceService.MaximumDefinitions,
cancellationToken).ConfigureAwait(false);
if (list.IsTruncated || list.Items.Any(item =>
string.Equals(item.Title, title, StringComparison.Ordinal)))
{
throw Collision("Named-playlist title absence was not proven.");
}
await EnsureNamedPlaylistAbsentAsync(
service,
programCode,
cancellationToken).ConfigureAwait(false);
await childGuard.EnsureNamedPlaylistChildrenAbsentAsync(
programCode,
cancellationToken).ConfigureAwait(false);
},
cancellationToken => service.CreateDefinitionAsync(
Required(programCode),
title,
cancellationToken),
async cancellationToken =>
{
var document = await service.LoadAsync(
Required(programCode),
cancellationToken).ConfigureAwait(false);
if (!string.Equals(
document.Definition.Title,
title,
StringComparison.Ordinal) ||
document.Items.Count != 0)
{
throw Verification("Named-playlist create readback did not match.");
}
},
cancellationToken => service.ReplaceItemsAsync(
Required(programCode),
[savedItem],
cancellationToken),
async cancellationToken =>
{
var document = await service.LoadAsync(
Required(programCode),
cancellationToken).ConfigureAwait(false);
if (!string.Equals(
document.Definition.Title,
title,
StringComparison.Ordinal) ||
document.Items.Count != 1 ||
document.Items[0] != savedItem)
{
throw Verification("Named-playlist save readback did not match.");
}
},
cancellationToken => service.DeleteAsync(
Required(programCode),
cancellationToken),
async cancellationToken =>
{
await EnsureNamedPlaylistAbsentAsync(
service,
Required(programCode),
cancellationToken).ConfigureAwait(false);
var list = await service.ListAsync(
LegacyNamedPlaylistPersistenceService.MaximumDefinitions,
cancellationToken).ConfigureAwait(false);
if (list.IsTruncated || list.Items.Any(item =>
string.Equals(item.Title, title, StringComparison.Ordinal)))
{
throw Verification("Named-playlist cleanup absence was not proven.");
}
},
IsOutcomeUnknown);
}
private static DevelopmentDatabaseSmokeScenario CreateThemeScenario(
IThemeCatalogPersistenceService persistence,
IThemeSelectionService selection,
ExistingChildRowGuard childGuard,
ThemeMarket market,
string title)
{
string? themeCode = null;
ThemeSelectionIdentity? freshIdentity = null;
var session = market == ThemeMarket.Krx
? ThemeSession.NotApplicable
: ThemeSession.PreMarket;
return new DevelopmentDatabaseSmokeScenario(
"ThemeA/" + market,
async cancellationToken =>
{
await EnsureThemeAbsentAsync(selection, title, cancellationToken)
.ConfigureAwait(false);
themeCode = await persistence.SuggestNextCodeAsync(market, cancellationToken)
.ConfigureAwait(false);
await childGuard.EnsureThemeChildrenAbsentAsync(
market,
themeCode,
cancellationToken).ConfigureAwait(false);
},
cancellationToken => IgnoreResultAsync(
persistence.CreateAsync(
new ThemeCatalogDefinition(
market,
Required(themeCode),
title,
[]),
cancellationToken)),
async cancellationToken =>
{
var search = await selection.SearchAsync(
title,
ThemeSession.PreMarket,
LegacyThemeSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated)
{
throw Verification("Theme readback was truncated.");
}
var matches = search.Items.Where(item =>
string.Equals(
item.Identity.ThemeTitle,
title,
StringComparison.Ordinal)).ToArray();
if (matches.Length != 1 ||
matches[0].Identity.Market != market ||
!string.Equals(
matches[0].Identity.ThemeCode,
themeCode,
StringComparison.Ordinal))
{
throw Verification("Theme create readback did not match.");
}
freshIdentity = matches[0].Identity with { Session = session };
var preview = await selection.PreviewAsync(
freshIdentity,
LegacyThemeSelectionService.MaximumPreviewItems,
cancellationToken).ConfigureAwait(false);
if (preview.Items.Count != 0 || preview.IsTruncated)
{
throw Verification("Theme preview readback did not match.");
}
},
save: null,
verifySave: null,
cancellationToken => IgnoreResultAsync(
persistence.DeleteAsync(
freshIdentity ?? new ThemeSelectionIdentity(
market,
session,
Required(themeCode),
title),
cancellationToken)),
cancellationToken => EnsureThemeAbsentAsync(selection, title, cancellationToken),
IsOutcomeUnknown);
}
private static DevelopmentDatabaseSmokeScenario CreateExpertScenario(
IExpertCatalogPersistenceService persistence,
IExpertSelectionService selection,
ExistingChildRowGuard childGuard,
string name)
{
string? expertCode = null;
ExpertSelectionIdentity? freshIdentity = null;
return new DevelopmentDatabaseSmokeScenario(
"EList expert catalog",
async cancellationToken =>
{
await EnsureExpertAbsentAsync(selection, name, cancellationToken)
.ConfigureAwait(false);
expertCode = await persistence.SuggestNextCodeAsync(cancellationToken)
.ConfigureAwait(false);
await childGuard.EnsureExpertChildrenAbsentAsync(
expertCode,
cancellationToken).ConfigureAwait(false);
},
cancellationToken => IgnoreResultAsync(
persistence.CreateAsync(
new ExpertCatalogDefinition(
new ExpertSelectionIdentity(Required(expertCode), name),
[]),
cancellationToken)),
async cancellationToken =>
{
var search = await selection.SearchAsync(
name,
LegacyExpertSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated)
{
throw Verification("Expert readback was truncated.");
}
var matches = search.Items.Where(item =>
string.Equals(
item.Identity.ExpertName,
name,
StringComparison.Ordinal)).ToArray();
if (matches.Length != 1 ||
!string.Equals(
matches[0].Identity.ExpertCode,
expertCode,
StringComparison.Ordinal))
{
throw Verification("Expert create readback did not match.");
}
freshIdentity = matches[0].Identity;
var preview = await selection.PreviewAsync(
freshIdentity,
LegacyExpertSelectionService.MaximumPreviewItems,
cancellationToken).ConfigureAwait(false);
if (preview.Items.Count != 0 || preview.IsTruncated)
{
throw Verification("Expert preview readback did not match.");
}
},
save: null,
verifySave: null,
cancellationToken => IgnoreResultAsync(
persistence.DeleteAsync(
freshIdentity ?? new ExpertSelectionIdentity(
Required(expertCode),
name),
cancellationToken)),
cancellationToken => EnsureExpertAbsentAsync(selection, name, cancellationToken),
IsOutcomeUnknown);
}
private static ManualFinancialRecord CreateManualRecord(
ManualFinancialScreenKind screen,
string stockName)
{
var identity = new ManualFinancialIdentity(screen, stockName);
return screen switch
{
ManualFinancialScreenKind.RevenueComposition =>
new ManualRevenueCompositionRecord(
identity,
"2026-07",
[new("SMOKE", "100", 100), null, null, null, null]),
ManualFinancialScreenKind.GrowthMetrics =>
new ManualGrowthMetricsRecord(
identity,
new(1, 2, 3, 4),
new(5, 6, 7, 8),
new(9, 10, 11, 12),
new(13, 14, 15, 16),
new("P1", "P2", "P3", "P4")),
ManualFinancialScreenKind.Sales =>
new ManualSalesRecord(identity, SixQuarters()),
ManualFinancialScreenKind.OperatingProfit =>
new ManualOperatingProfitRecord(identity, SixQuarters()),
_ => throw new ArgumentOutOfRangeException(nameof(screen))
};
}
private static IReadOnlyList<ManualQuarterValue> SixQuarters() =>
[
new("Q1", 10),
new("Q2", -20),
new("Q3", 30),
new("Q4", 40),
new("Q5", 50),
new("Q6", 60)
];
private static void VerifyManualRecord(
ManualFinancialRecord expected,
ManualFinancialSnapshot actual)
{
if (string.IsNullOrWhiteSpace(actual.RowVersion) ||
actual.Record.Identity != expected.Identity)
{
throw Verification("GraphE identity or row version did not match.");
}
var matches = (expected, actual.Record) switch
{
(ManualRevenueCompositionRecord left, ManualRevenueCompositionRecord right) =>
string.Equals(left.BaseDate, right.BaseDate, StringComparison.Ordinal) &&
left.Slices.SequenceEqual(right.Slices),
(ManualGrowthMetricsRecord left, ManualGrowthMetricsRecord right) =>
left.SalesGrowth == right.SalesGrowth &&
left.OperatingProfitGrowth == right.OperatingProfitGrowth &&
left.NetAssetGrowth == right.NetAssetGrowth &&
left.NetIncomeGrowth == right.NetIncomeGrowth &&
left.Periods == right.Periods,
(ManualSalesRecord left, ManualSalesRecord right) =>
left.Quarters.SequenceEqual(right.Quarters),
(ManualOperatingProfitRecord left, ManualOperatingProfitRecord right) =>
left.Quarters.SequenceEqual(right.Quarters),
_ => false
};
if (!matches)
{
throw Verification("GraphE create readback did not match.");
}
}
private static async Task EnsureManualAbsentAsync(
IManualFinancialScreenService service,
ManualFinancialIdentity identity,
CancellationToken cancellationToken)
{
try
{
_ = await service.GetAsync(identity, cancellationToken).ConfigureAwait(false);
}
catch (ManualFinancialNotFoundException)
{
return;
}
throw Verification("GraphE record still exists.");
}
private static async Task EnsureNamedPlaylistAbsentAsync(
INamedPlaylistPersistenceService service,
string programCode,
CancellationToken cancellationToken)
{
try
{
_ = await service.LoadAsync(programCode, cancellationToken).ConfigureAwait(false);
}
catch (NamedPlaylistNotFoundException)
{
return;
}
throw Verification("Named playlist still exists.");
}
private static async Task EnsureThemeAbsentAsync(
IThemeSelectionService selection,
string title,
CancellationToken cancellationToken)
{
var search = await selection.SearchAsync(
title,
ThemeSession.PreMarket,
LegacyThemeSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated || search.Items.Any(item =>
string.Equals(
item.Identity.ThemeTitle,
title,
StringComparison.Ordinal)))
{
throw Collision("Theme absence was not proven.");
}
}
private static async Task EnsureExpertAbsentAsync(
IExpertSelectionService selection,
string name,
CancellationToken cancellationToken)
{
var search = await selection.SearchAsync(
name,
LegacyExpertSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated || search.Items.Any(item =>
string.Equals(
item.Identity.ExpertName,
name,
StringComparison.Ordinal)))
{
throw Collision("Expert absence was not proven.");
}
}
private static async Task IgnoreResultAsync<T>(Task<T> operation)
{
_ = await operation.ConfigureAwait(false);
}
private static string Required(string? value) =>
value ?? throw new InvalidOperationException("Smoke preflight did not produce an identity.");
private static bool IsOutcomeUnknown(Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
if (exception is ManualFinancialMutationException manual && manual.OutcomeUnknown ||
exception is NamedPlaylistMutationException named && named.OutcomeUnknown ||
exception is OperatorCatalogMutationException catalog && catalog.OutcomeUnknown)
{
return true;
}
if (exception is AggregateException aggregate &&
aggregate.InnerExceptions.Any(IsOutcomeUnknown))
{
return true;
}
return exception.InnerException is not null && IsOutcomeUnknown(exception.InnerException);
}
private static InvalidOperationException Collision(string message) => new(message);
private static InvalidOperationException Verification(string message) => new(message);
}