feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user