530 lines
18 KiB
C#
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);
|
|
}
|