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