Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs

530 lines
18 KiB
C#

using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyIndustryComparisonSlot
{
First,
Second
}
public sealed record LegacyIndustrySelectionSnapshot(
long Revision,
IndustryMarket? Market,
IReadOnlyList<IndustrySelection> Industries,
IReadOnlyList<LegacyIndustryActionDefinition> Actions,
int? SelectedIndex,
IndustrySelection? SelectedIndustry,
IndustrySelection? FirstIndustry,
IndustrySelection? SecondIndustry,
string FirstText,
string SecondText);
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;
public const int MaximumComparisonTextLength = 128;
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 readonly LegacyIndustryActionLayout _actionLayout;
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 string _firstText = string.Empty;
private string _secondText = string.Empty;
private long _revision;
private long _loadGeneration;
public LegacyIndustrySelectionWorkflow(
IIndustrySelectionService selectionService,
TimeProvider? timeProvider = null,
LegacyIndustryActionLayout? actionLayout = null)
{
_selectionService = selectionService ??
throw new ArgumentNullException(nameof(selectionService));
_timeProvider = timeProvider ?? TimeProvider.System;
_actionLayout = actionLayout ?? LegacyIndustryActionLayout.Default;
}
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
/// <summary>
/// Recreates the observable UC2 state for one tab activation. The generation
/// increment prevents a request started by the discarded control from restoring
/// its rows after the operator has re-entered the tab.
/// </summary>
public LegacyIndustrySelectionSnapshot ResetForTabActivation(IndustryMarket market)
{
ValidateMarket(market);
Interlocked.Increment(ref _loadGeneration);
_market = market;
_industries = NoIndustries;
_actions = _actionLayout.GetActions(market);
_selectedIndex = null;
_selectedIndustry = null;
_firstIndustry = null;
_secondIndustry = null;
_firstText = string.Empty;
_secondText = string.Empty;
Touch();
return 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;
var previousFirstText = changingMarket ? string.Empty : _firstText;
var previousSecondText = changingMarket ? string.Empty : _secondText;
_market = market;
_industries = normalizedRows;
_actions = _actionLayout.GetActions(market);
_selectedIndustry = Reconcile(previousSelected, normalizedRows);
_selectedIndex = _selectedIndustry is null
? null
: FindIdentityIndex(normalizedRows, _selectedIndustry);
_firstIndustry = Reconcile(previousFirst, normalizedRows);
_secondIndustry = Reconcile(previousSecond, normalizedRows);
_firstText = previousFirstText;
_secondText = previousSecondText;
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;
_secondText = _firstText;
_firstIndustry = industry;
_firstText = industry.IndustryName;
Touch();
return CreateSnapshot();
}
public LegacyIndustrySelectionSnapshot SetComparisonText(
LegacyIndustryComparisonSlot slot,
string text)
{
ArgumentNullException.ThrowIfNull(text);
if (slot is not (LegacyIndustryComparisonSlot.First or LegacyIndustryComparisonSlot.Second))
{
throw new ArgumentOutOfRangeException(nameof(slot), slot, "The comparison slot must be 1 or 2.");
}
if (text.Length > MaximumComparisonTextLength ||
text.Any(static character =>
char.IsControl(character) || char.IsSurrogate(character) || character == '\uFFFD'))
{
throw new LegacyIndustryWorkflowException("The industry comparison text is invalid.");
}
if (slot == LegacyIndustryComparisonSlot.First)
{
if (string.Equals(_firstText, text, StringComparison.Ordinal))
{
return CreateSnapshot();
}
_firstText = text;
_firstIndustry = null;
}
else
{
if (string.Equals(_secondText, text, StringComparison.Ordinal))
{
return CreateSnapshot();
}
_secondText = text;
_secondIndustry = null;
}
Touch();
return CreateSnapshot();
}
public LegacyIndustrySelectionSnapshot SwapIndustries()
{
if (_firstText.Length == 0 && _secondText.Length == 0)
{
return CreateSnapshot();
}
(_firstIndustry, _secondIndustry) = (_secondIndustry, _firstIndustry);
(_firstText, _secondText) = (_secondText, _firstText);
Touch();
return CreateSnapshot();
}
public LegacyIndustrySelectionSnapshot ClearSelections()
{
if (_selectedIndustry is null && _firstText.Length == 0 && _secondText.Length == 0)
{
return CreateSnapshot();
}
_selectedIndex = null;
_selectedIndustry = null;
_firstIndustry = null;
_secondIndustry = null;
_firstText = string.Empty;
_secondText = string.Empty;
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 (_selectedIndustry is null ||
string.IsNullOrWhiteSpace(_firstText) ||
string.IsNullOrWhiteSpace(_secondText) ||
_firstText.Contains('-', StringComparison.Ordinal) ||
_secondText.Contains('-', StringComparison.Ordinal))
{
throw new LegacyIndustryWorkflowException(
$"The {definition.ActionId} action requires a selected industry and two delimiter-safe industries.");
}
var subject = $"{_firstText}-{_secondText}";
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 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)))
{
throw new LegacyIndustryWorkflowException(
"The industry list contains an unsafe 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,
_firstText,
_secondText);
private void Touch() => _revision++;
private sealed record IndustryProjection(
string Title,
LegacyIndustrySceneSelectionDraft Selection);
}