feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
using System.Globalization;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public sealed record LegacyIndustrySelectionSnapshot(
|
||||
long Revision,
|
||||
IndustryMarket? Market,
|
||||
IReadOnlyList<IndustrySelection> Industries,
|
||||
IReadOnlyList<LegacyIndustryActionDefinition> Actions,
|
||||
int? SelectedIndex,
|
||||
IndustrySelection? SelectedIndustry,
|
||||
IndustrySelection? FirstIndustry,
|
||||
IndustrySelection? SecondIndustry);
|
||||
|
||||
public sealed record LegacyIndustrySceneSelectionDraft(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
/// <summary>
|
||||
/// Safe, generic playlist fields produced entirely from the C# catalog and the
|
||||
/// authoritative UC2 selection state. It intentionally has no caller-supplied row id.
|
||||
/// </summary>
|
||||
public sealed record LegacyIndustryPlaylistDraft(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string SceneAlias,
|
||||
int PageSize,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
IndustryMarket Market,
|
||||
LegacyIndustrySceneSelectionDraft Selection,
|
||||
string RuntimeAssetRelativePath,
|
||||
string RuntimeAssetSha256,
|
||||
bool IsEnabled,
|
||||
int FadeDuration);
|
||||
|
||||
public sealed class LegacyIndustryWorkflowException : Exception
|
||||
{
|
||||
public LegacyIndustryWorkflowException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C# authority for the legacy UC2 selector and the UC1 industry actions.
|
||||
/// </summary>
|
||||
public sealed class LegacyIndustrySelectionWorkflow
|
||||
{
|
||||
private const int DefaultFadeDuration = 6;
|
||||
|
||||
private static readonly IReadOnlyList<IndustrySelection> NoIndustries =
|
||||
Array.AsReadOnly(Array.Empty<IndustrySelection>());
|
||||
|
||||
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> NoActions =
|
||||
Array.AsReadOnly(Array.Empty<LegacyIndustryActionDefinition>());
|
||||
|
||||
private readonly IIndustrySelectionService _selectionService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private IReadOnlyList<IndustrySelection> _industries = NoIndustries;
|
||||
private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions;
|
||||
private IndustryMarket? _market;
|
||||
private int? _selectedIndex;
|
||||
private IndustrySelection? _selectedIndustry;
|
||||
private IndustrySelection? _firstIndustry;
|
||||
private IndustrySelection? _secondIndustry;
|
||||
private long _revision;
|
||||
private long _loadGeneration;
|
||||
|
||||
public LegacyIndustrySelectionWorkflow(
|
||||
IIndustrySelectionService selectionService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_selectionService = selectionService ??
|
||||
throw new ArgumentNullException(nameof(selectionService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
|
||||
|
||||
public async Task<LegacyIndustrySelectionSnapshot> LoadMarketAsync(
|
||||
IndustryMarket market,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateMarket(market);
|
||||
var generation = Interlocked.Increment(ref _loadGeneration);
|
||||
var serviceRows = await _selectionService.GetAsync(market, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// A slower previous request must never replace a newer market request.
|
||||
if (generation != Volatile.Read(ref _loadGeneration))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var normalizedRows = NormalizeRows(serviceRows, market);
|
||||
if (generation != Volatile.Read(ref _loadGeneration))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var changingMarket = _market != market;
|
||||
var previousSelected = changingMarket ? null : _selectedIndustry;
|
||||
var previousFirst = changingMarket ? null : _firstIndustry;
|
||||
var previousSecond = changingMarket ? null : _secondIndustry;
|
||||
|
||||
_market = market;
|
||||
_industries = normalizedRows;
|
||||
_actions = LegacyIndustryActionCatalog.GetActions(market);
|
||||
_selectedIndustry = Reconcile(previousSelected, normalizedRows);
|
||||
_selectedIndex = _selectedIndustry is null
|
||||
? null
|
||||
: FindIdentityIndex(normalizedRows, _selectedIndustry);
|
||||
_firstIndustry = Reconcile(previousFirst, normalizedRows);
|
||||
_secondIndustry = Reconcile(previousSecond, normalizedRows);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot SelectIndustry(int index)
|
||||
{
|
||||
if (!TryGetIndustry(index, out var industry))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedIndex = index;
|
||||
_selectedIndustry = industry;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot DoubleClickIndustry(int index)
|
||||
{
|
||||
if (!TryGetIndustry(index, out var industry))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedIndex = index;
|
||||
_selectedIndustry = industry;
|
||||
|
||||
// UC2.listbox_DoubleClick copied txt1 to txt2 before assigning the newly
|
||||
// double-clicked item to txt1.
|
||||
_secondIndustry = _firstIndustry;
|
||||
_firstIndustry = industry;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot SwapIndustries()
|
||||
{
|
||||
if (_firstIndustry is null && _secondIndustry is null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
(_firstIndustry, _secondIndustry) = (_secondIndustry, _firstIndustry);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot ClearSelections()
|
||||
{
|
||||
if (_selectedIndustry is null && _firstIndustry is null && _secondIndustry is null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedIndex = null;
|
||||
_selectedIndustry = null;
|
||||
_firstIndustry = null;
|
||||
_secondIndustry = null;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustryPlaylistDraft MaterializePlaylistDraft(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
if (_market is not { } market)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("Select an industry market first.");
|
||||
}
|
||||
|
||||
if (!LegacyIndustryActionCatalog.TryGetAction(market, actionId, out var definition) ||
|
||||
definition is null)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("The industry action is not registered.");
|
||||
}
|
||||
|
||||
var projection = ProjectSelection(definition, market);
|
||||
var asset = LegacyIndustryActionCatalog.GetRuntimeAsset(market);
|
||||
return new LegacyIndustryPlaylistDraft(
|
||||
definition.ActionId,
|
||||
definition.BuilderKey,
|
||||
definition.SceneAlias,
|
||||
definition.PageSize,
|
||||
projection.Title,
|
||||
definition.Label,
|
||||
definition.Category,
|
||||
market,
|
||||
projection.Selection,
|
||||
asset.RelativePath,
|
||||
asset.Sha256,
|
||||
IsEnabled: true,
|
||||
FadeDuration: DefaultFadeDuration);
|
||||
}
|
||||
|
||||
private IndustryProjection ProjectSelection(
|
||||
LegacyIndustryActionDefinition definition,
|
||||
IndustryMarket market)
|
||||
{
|
||||
var industryGroup = market == IndustryMarket.Kospi
|
||||
? "INDUSTRY_KOSPI"
|
||||
: "INDUSTRY_KOSDAQ";
|
||||
var marketName = market == IndustryMarket.Kospi ? "KOSPI" : "KOSDAQ";
|
||||
|
||||
return definition.Kind switch
|
||||
{
|
||||
LegacyIndustryActionKind.Single => ForSelected(
|
||||
definition,
|
||||
industryGroup,
|
||||
"1열판",
|
||||
"CURRENT"),
|
||||
LegacyIndustryActionKind.Pair => ForPair(definition, industryGroup),
|
||||
LegacyIndustryActionKind.Yield => ForSelected(
|
||||
definition,
|
||||
industryGroup,
|
||||
"YIELD_GRAPH",
|
||||
definition.YieldPeriod!),
|
||||
LegacyIndustryActionKind.Candle => ForSelected(
|
||||
definition,
|
||||
market == IndustryMarket.Kospi ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY",
|
||||
string.Empty,
|
||||
definition.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE"),
|
||||
LegacyIndustryActionKind.FiveRow => new IndustryProjection(
|
||||
$"{MarketLabel(market)} {definition.Label}",
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
market == IndustryMarket.Kospi
|
||||
? "PAGED_DOMESTIC_KOSPI"
|
||||
: "PAGED_DOMESTIC_KOSDAQ",
|
||||
"INDEX",
|
||||
string.Empty,
|
||||
"INDUSTRY",
|
||||
CurrentLocalDate())),
|
||||
LegacyIndustryActionKind.Square => new IndustryProjection(
|
||||
$"{MarketLabel(market)} {definition.Label}",
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
industryGroup,
|
||||
marketName,
|
||||
"네모그래프",
|
||||
string.Empty,
|
||||
string.Empty)),
|
||||
LegacyIndustryActionKind.Sector => new IndustryProjection(
|
||||
$"{MarketLabel(market)} {definition.Label}",
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
industryGroup,
|
||||
marketName,
|
||||
"SECTOR_INDEX",
|
||||
string.Empty,
|
||||
string.Empty)),
|
||||
_ => throw new LegacyIndustryWorkflowException("The industry action kind is unsupported.")
|
||||
};
|
||||
}
|
||||
|
||||
private IndustryProjection ForSelected(
|
||||
LegacyIndustryActionDefinition definition,
|
||||
string groupCode,
|
||||
string graphicType,
|
||||
string subtype)
|
||||
{
|
||||
if (_selectedIndustry is not { } selected)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
$"The {definition.ActionId} action requires one selected industry.");
|
||||
}
|
||||
|
||||
return new IndustryProjection(
|
||||
selected.IndustryName,
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
groupCode,
|
||||
selected.IndustryName,
|
||||
graphicType,
|
||||
subtype,
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
private IndustryProjection ForPair(
|
||||
LegacyIndustryActionDefinition definition,
|
||||
string groupCode)
|
||||
{
|
||||
if (_firstIndustry is not { } first || _secondIndustry is not { } second ||
|
||||
first.IndustryName.Contains('-', StringComparison.Ordinal) ||
|
||||
second.IndustryName.Contains('-', StringComparison.Ordinal))
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
$"The {definition.ActionId} action requires two delimiter-safe industries.");
|
||||
}
|
||||
|
||||
var subject = $"{first.IndustryName}-{second.IndustryName}";
|
||||
return new IndustryProjection(
|
||||
subject,
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
groupCode,
|
||||
subject,
|
||||
"2열판",
|
||||
string.Empty,
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
private string CurrentLocalDate()
|
||||
{
|
||||
var local = _timeProvider.GetLocalNow();
|
||||
return local.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private bool TryGetIndustry(int index, out IndustrySelection industry)
|
||||
{
|
||||
if (index < 0 || index >= _industries.Count)
|
||||
{
|
||||
industry = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
industry = _industries[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IndustrySelection> NormalizeRows(
|
||||
IReadOnlyList<IndustrySelection>? rows,
|
||||
IndustryMarket market)
|
||||
{
|
||||
if (rows is null || rows.Count > LegacyIndustrySelectionService.MaximumResults)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("The industry list is invalid.");
|
||||
}
|
||||
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var normalized = new IndustrySelection[rows.Count];
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
var row = rows[index];
|
||||
if (row is null || row.Market != market)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
"The industry list contains a cross-market row.");
|
||||
}
|
||||
|
||||
var name = row.IndustryName?.Trim();
|
||||
var code = row.IndustryCode?.Trim();
|
||||
if (string.IsNullOrEmpty(name) ||
|
||||
name.Length > LegacyIndustrySelectionService.MaximumNameLength ||
|
||||
name.Any(char.IsControl) ||
|
||||
string.IsNullOrEmpty(code) ||
|
||||
code.Length > 32 ||
|
||||
code.Any(character => !char.IsAsciiLetterOrDigit(character)) ||
|
||||
!names.Add(name) ||
|
||||
!codes.Add(code))
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
"The industry list contains an unsafe or ambiguous row.");
|
||||
}
|
||||
|
||||
normalized[index] = new IndustrySelection(market, name, code);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(normalized);
|
||||
}
|
||||
|
||||
private static IndustrySelection? Reconcile(
|
||||
IndustrySelection? previous,
|
||||
IReadOnlyList<IndustrySelection> rows)
|
||||
{
|
||||
if (previous is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return rows.FirstOrDefault(row => SameIdentity(row, previous));
|
||||
}
|
||||
|
||||
private static int? FindIdentityIndex(
|
||||
IReadOnlyList<IndustrySelection> rows,
|
||||
IndustrySelection selected)
|
||||
{
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
if (SameIdentity(rows[index], selected))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool SameIdentity(IndustrySelection left, IndustrySelection right) =>
|
||||
left.Market == right.Market &&
|
||||
string.Equals(left.IndustryName, right.IndustryName, StringComparison.Ordinal) &&
|
||||
string.Equals(left.IndustryCode, right.IndustryCode, StringComparison.Ordinal);
|
||||
|
||||
private static void ValidateMarket(IndustryMarket market)
|
||||
{
|
||||
if (market is not (IndustryMarket.Kospi or IndustryMarket.Kosdaq))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(market),
|
||||
market,
|
||||
"Unsupported industry market.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string MarketLabel(IndustryMarket market) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => "코스피",
|
||||
IndustryMarket.Kosdaq => "코스닥",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, null)
|
||||
};
|
||||
|
||||
private LegacyIndustrySelectionSnapshot CreateSnapshot() => new(
|
||||
_revision,
|
||||
_market,
|
||||
_industries,
|
||||
_actions,
|
||||
_selectedIndex,
|
||||
_selectedIndustry,
|
||||
_firstIndustry,
|
||||
_secondIndustry);
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record IndustryProjection(
|
||||
string Title,
|
||||
LegacyIndustrySceneSelectionDraft Selection);
|
||||
}
|
||||
Reference in New Issue
Block a user