feat: establish legacy parity migration baseline
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacySearchTrigger
|
||||
{
|
||||
Button,
|
||||
Enter
|
||||
}
|
||||
|
||||
public enum LegacyOperatorStatusKind
|
||||
{
|
||||
Neutral,
|
||||
Information,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed record LegacyStockResultRow(int Index, string DisplayName);
|
||||
|
||||
public sealed record LegacyCutViewRow(
|
||||
int PhysicalIndex,
|
||||
int? DisplayOrdinal,
|
||||
string RawLabel,
|
||||
bool IsSeparator,
|
||||
bool IsSelected);
|
||||
|
||||
public sealed record LegacyDialogState(string Message);
|
||||
|
||||
public sealed record LegacyOperatorSnapshot(
|
||||
long Revision,
|
||||
string SearchText,
|
||||
IReadOnlyList<LegacyStockResultRow> SearchResults,
|
||||
int? SelectedStockIndex,
|
||||
IReadOnlyList<LegacyCutViewRow> CutRows,
|
||||
IReadOnlyList<LegacyOperatorPlaylistRow> Playlist,
|
||||
LegacyDialogState? Dialog,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind);
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative C# state for the first legacy operator vertical slice. The WebView sends
|
||||
/// input intent only; it never derives stock identity, cut aliases or playlist fields.
|
||||
/// </summary>
|
||||
public sealed class LegacyOperatorController
|
||||
{
|
||||
private const long DoubleClickWindowMilliseconds = 500;
|
||||
|
||||
private readonly ILegacyStockLookup _stockLookup;
|
||||
private readonly SortedSet<int> _legacyPointerRows = [];
|
||||
private readonly SortedSet<int> _nativeSelectedCutRows = [];
|
||||
private readonly List<LegacyOperatorPlaylistRow> _playlist = [];
|
||||
private Queue<int>? _pendingDropCutRows;
|
||||
private LegacyStockSearchData _searchData = new(string.Empty, [], []);
|
||||
private int? _selectedStockIndex;
|
||||
private int? _cutAnchorIndex;
|
||||
private int _nativeSelectionAnchorIndex;
|
||||
private int? _previousPointerRow;
|
||||
private int _previousPointerX;
|
||||
private int _previousPointerY;
|
||||
private long? _previousPointerTimestamp;
|
||||
private long _revision;
|
||||
private long _playlistSequence;
|
||||
private string _searchText = string.Empty;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
private LegacyDialogState? _dialog;
|
||||
|
||||
public LegacyOperatorController(ILegacyStockLookup stockLookup)
|
||||
{
|
||||
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
|
||||
}
|
||||
|
||||
public LegacyOperatorSnapshot Current => CreateSnapshot();
|
||||
|
||||
public async Task<LegacyOperatorSnapshot> SearchStocksAsync(
|
||||
string text,
|
||||
LegacySearchTrigger trigger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
_searchText = text;
|
||||
|
||||
// button9_Click returned before calling searchStock only for an exactly empty string.
|
||||
// txtSearch_KeyPress did not have that guard, so blank Enter still queries all rows.
|
||||
if (trigger == LegacySearchTrigger.Button && text.Length == 0)
|
||||
{
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_searchData = await _stockLookup.SearchAsync(text, cancellationToken).ConfigureAwait(false);
|
||||
_selectedStockIndex = null;
|
||||
_statusMessage = string.Empty;
|
||||
_statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
_dialog = null;
|
||||
_pendingDropCutRows = null;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyOperatorSnapshot SelectStock(int resultIndex)
|
||||
{
|
||||
if (resultIndex < 0 || resultIndex >= _searchData.DisplayNames.Count)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedStockIndex = resultIndex;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyOperatorSnapshot CutPointerDown(
|
||||
int physicalIndex,
|
||||
bool control,
|
||||
bool shift,
|
||||
int x,
|
||||
int y,
|
||||
long timestampMilliseconds)
|
||||
{
|
||||
if (_selectedStockIndex is null || !IsCutIndex(physicalIndex))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var gap = _previousPointerTimestamp is long previousTimestamp
|
||||
? timestampMilliseconds - previousTimestamp
|
||||
: long.MaxValue;
|
||||
if (_previousPointerTimestamp is not null &&
|
||||
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
|
||||
_previousPointerRow == physicalIndex &&
|
||||
_previousPointerX == x &&
|
||||
_previousPointerY == y &&
|
||||
_legacyPointerRows.Count == 1)
|
||||
{
|
||||
TryAppendCut(_legacyPointerRows.Min);
|
||||
_previousPointerTimestamp = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_previousPointerTimestamp = timestampMilliseconds;
|
||||
}
|
||||
|
||||
_previousPointerRow = physicalIndex;
|
||||
_previousPointerX = x;
|
||||
_previousPointerY = y;
|
||||
|
||||
ApplyLegacyPointerSelection(physicalIndex, control, shift);
|
||||
ApplyNativeListSelection(physicalIndex, control, shift);
|
||||
|
||||
_cutAnchorIndex = physicalIndex;
|
||||
_nativeSelectionAnchorIndex = physicalIndex;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyOperatorSnapshot DropSelectedCutsOnPlaylist()
|
||||
{
|
||||
if (_selectedStockIndex is null || _dialog is not null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_pendingDropCutRows = new Queue<int>(_nativeSelectedCutRows);
|
||||
ContinuePendingDrop();
|
||||
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rowId);
|
||||
var index = _playlist.FindIndex(row =>
|
||||
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
|
||||
if (index >= 0)
|
||||
{
|
||||
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyOperatorSnapshot DismissDialog()
|
||||
{
|
||||
if (_dialog is not null)
|
||||
{
|
||||
_dialog = null;
|
||||
ContinuePendingDrop();
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyOperatorSnapshot ReportError(string message)
|
||||
{
|
||||
_statusMessage = message ?? string.Empty;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
private void ContinuePendingDrop()
|
||||
{
|
||||
while (_pendingDropCutRows is { Count: > 0 })
|
||||
{
|
||||
var physicalIndex = _pendingDropCutRows.Dequeue();
|
||||
if (!TryAppendCut(physicalIndex))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_pendingDropCutRows = null;
|
||||
}
|
||||
|
||||
private bool TryAppendCut(int physicalIndex)
|
||||
{
|
||||
if (_selectedStockIndex is not int stockIndex ||
|
||||
stockIndex < 0 || stockIndex >= _searchData.DisplayNames.Count ||
|
||||
!IsCutIndex(physicalIndex))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var cut = LegacyStockCutCatalog.Rows[physicalIndex];
|
||||
var stockName = _searchData.DisplayNames[stockIndex];
|
||||
var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal);
|
||||
if (isNxt && !string.Equals(
|
||||
cut.RawLabel,
|
||||
LegacyStockCutCatalog.NxtAllowedLabel,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
_dialog = new LegacyDialogState("NXT는 제공하지 않습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cut.IsSeparator)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var identity = _searchData.ResolveLastByDisplayName(stockName);
|
||||
var rowId = $"legacy-stock-{++_playlistSequence:D8}";
|
||||
_playlist.Add(LegacyStockCutCatalog.CreatePlaylistRow(
|
||||
rowId,
|
||||
stockName,
|
||||
identity,
|
||||
cut));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ApplyLegacyPointerSelection(int physicalIndex, bool control, bool shift)
|
||||
{
|
||||
if (control && shift)
|
||||
{
|
||||
AddLegacyRangeFromAnchor(physicalIndex);
|
||||
}
|
||||
else if (control)
|
||||
{
|
||||
if (!_legacyPointerRows.Add(physicalIndex))
|
||||
{
|
||||
_legacyPointerRows.Remove(physicalIndex);
|
||||
}
|
||||
}
|
||||
else if (shift)
|
||||
{
|
||||
_legacyPointerRows.Clear();
|
||||
AddLegacyRangeFromAnchor(physicalIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
_legacyPointerRows.Clear();
|
||||
_legacyPointerRows.Add(physicalIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift)
|
||||
{
|
||||
if (control && shift)
|
||||
{
|
||||
AddInclusiveRange(
|
||||
_nativeSelectedCutRows,
|
||||
_nativeSelectionAnchorIndex,
|
||||
physicalIndex);
|
||||
}
|
||||
else if (control)
|
||||
{
|
||||
if (!_nativeSelectedCutRows.Add(physicalIndex))
|
||||
{
|
||||
_nativeSelectedCutRows.Remove(physicalIndex);
|
||||
}
|
||||
}
|
||||
else if (shift)
|
||||
{
|
||||
_nativeSelectedCutRows.Clear();
|
||||
AddInclusiveRange(
|
||||
_nativeSelectedCutRows,
|
||||
_nativeSelectionAnchorIndex,
|
||||
physicalIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
_nativeSelectedCutRows.Clear();
|
||||
_nativeSelectedCutRows.Add(physicalIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLegacyRangeFromAnchor(int physicalIndex)
|
||||
{
|
||||
// MainForm used beforeRow = -1 and skipped the range when the previous
|
||||
// and current rows were equal. The sentinel also affected its double-click count.
|
||||
var anchor = _cutAnchorIndex ?? -1;
|
||||
if (anchor == physicalIndex)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var first = Math.Min(anchor, physicalIndex);
|
||||
var last = Math.Max(anchor, physicalIndex);
|
||||
for (var index = first; index <= last; index++)
|
||||
{
|
||||
_legacyPointerRows.Add(index);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddInclusiveRange(
|
||||
ISet<int> target,
|
||||
int anchor,
|
||||
int physicalIndex)
|
||||
{
|
||||
var first = Math.Min(anchor, physicalIndex);
|
||||
var last = Math.Max(anchor, physicalIndex);
|
||||
for (var index = first; index <= last; index++)
|
||||
{
|
||||
target.Add(index);
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyOperatorSnapshot CreateSnapshot()
|
||||
{
|
||||
var searchRows = _searchData.DisplayNames
|
||||
.Select((name, index) => new LegacyStockResultRow(index, name))
|
||||
.ToArray();
|
||||
var cutRows = LegacyStockCutCatalog.Rows
|
||||
.Select(row => new LegacyCutViewRow(
|
||||
row.PhysicalIndex,
|
||||
row.DisplayOrdinal,
|
||||
row.RawLabel,
|
||||
row.IsSeparator,
|
||||
_nativeSelectedCutRows.Contains(row.PhysicalIndex)))
|
||||
.ToArray();
|
||||
|
||||
return new LegacyOperatorSnapshot(
|
||||
_revision,
|
||||
_searchText,
|
||||
Array.AsReadOnly(searchRows),
|
||||
_selectedStockIndex,
|
||||
Array.AsReadOnly(cutRows),
|
||||
Array.AsReadOnly(_playlist.ToArray()),
|
||||
_dialog,
|
||||
_statusMessage,
|
||||
_statusKind);
|
||||
}
|
||||
|
||||
private static bool IsCutIndex(int physicalIndex) =>
|
||||
physicalIndex >= 0 && physicalIndex < LegacyStockCutCatalog.Rows.Count;
|
||||
|
||||
private void Touch() => _revision++;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility search for the operator screen. Unlike the modern lookup it intentionally
|
||||
/// keeps blank-enter, LIKE wildcard, provider ordering and unlimited-result semantics.
|
||||
/// Values remain bound parameters so parity does not reintroduce SQL concatenation.
|
||||
/// </summary>
|
||||
public sealed class LegacyParityStockSearchService : ILegacyStockLookup
|
||||
{
|
||||
public const int LegacyTextBoxMaximumLength = 32_767;
|
||||
|
||||
private static readonly SearchProfile[] Profiles =
|
||||
[
|
||||
new(
|
||||
LegacyDomesticStockMarket.Kospi,
|
||||
DataSourceKind.Oracle,
|
||||
"LEGACY_PARITY_KOSPI",
|
||||
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
|
||||
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'"),
|
||||
new(
|
||||
LegacyDomesticStockMarket.Kosdaq,
|
||||
DataSourceKind.Oracle,
|
||||
"LEGACY_PARITY_KOSDAQ",
|
||||
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
|
||||
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N'"),
|
||||
new(
|
||||
LegacyDomesticStockMarket.NxtKospi,
|
||||
DataSourceKind.MariaDb,
|
||||
"LEGACY_PARITY_NXT_KOSPI",
|
||||
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_STOCK a, N_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
|
||||
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'"),
|
||||
new(
|
||||
LegacyDomesticStockMarket.NxtKosdaq,
|
||||
DataSourceKind.MariaDb,
|
||||
"LEGACY_PARITY_NXT_KOSDAQ",
|
||||
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK a, N_KOSDAQ_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
|
||||
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK WHERE F_STOP_GUBUN = 'N'")
|
||||
];
|
||||
|
||||
private readonly IDataQueryExecutor _executor;
|
||||
private readonly SemaphoreSlim _masterLoadGate = new(1, 1);
|
||||
private IReadOnlyList<LegacyStockIdentity>? _masterCatalog;
|
||||
|
||||
public LegacyParityStockSearchService(IDataQueryExecutor executor)
|
||||
{
|
||||
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||
}
|
||||
|
||||
public async Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (query.Length > LegacyTextBoxMaximumLength)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(query),
|
||||
$"The legacy search text cannot exceed {LegacyTextBoxMaximumLength} characters.");
|
||||
}
|
||||
|
||||
var masterCatalog = await GetMasterCatalogAsync(cancellationToken).ConfigureAwait(false);
|
||||
var searchTerm = query.ToUpper(CultureInfo.CurrentCulture);
|
||||
var names = new List<string>();
|
||||
|
||||
foreach (var profile in Profiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var parameter = new DataQueryParameter("search_term", searchTerm, DbType.String);
|
||||
var spec = new DataQuerySpec(profile.SearchSql, [parameter]);
|
||||
var table = await _executor
|
||||
.ExecuteAsync(profile.Source, profile.SearchTableName, spec, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AppendNames(table, profile.SearchTableName, names);
|
||||
}
|
||||
|
||||
return new LegacyStockSearchData(query, names, masterCatalog);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<LegacyStockIdentity>> GetMasterCatalogAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cached = Volatile.Read(ref _masterCatalog);
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
await _masterLoadGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
cached = Volatile.Read(ref _masterCatalog);
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var items = new List<LegacyStockIdentity>();
|
||||
foreach (var profile in Profiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var table = await _executor
|
||||
.ExecuteAsync(
|
||||
profile.Source,
|
||||
$"{profile.SearchTableName}_MASTER",
|
||||
new DataQuerySpec(profile.MasterSql),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AppendMaster(table, profile, items);
|
||||
}
|
||||
|
||||
cached = Array.AsReadOnly(items.ToArray());
|
||||
Volatile.Write(ref _masterCatalog, cached);
|
||||
return cached;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_masterLoadGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendNames(DataTable? table, string sourceName, ICollection<string> target)
|
||||
{
|
||||
ValidateTable(table, sourceName);
|
||||
foreach (DataRow row in table!.Rows)
|
||||
{
|
||||
target.Add(Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendMaster(
|
||||
DataTable? table,
|
||||
SearchProfile profile,
|
||||
ICollection<LegacyStockIdentity> target)
|
||||
{
|
||||
ValidateTable(table, $"{profile.SearchTableName}_MASTER");
|
||||
foreach (DataRow row in table!.Rows)
|
||||
{
|
||||
target.Add(new LegacyStockIdentity(
|
||||
profile.Market,
|
||||
Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty,
|
||||
Convert.ToString(row[1], CultureInfo.CurrentCulture) ?? string.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTable(DataTable? table, string sourceName)
|
||||
{
|
||||
if (table is null || table.Columns.Count < 2)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Legacy stock data from {sourceName} does not contain two columns.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SearchProfile(
|
||||
LegacyDomesticStockMarket Market,
|
||||
DataSourceKind Source,
|
||||
string SearchTableName,
|
||||
string SearchSql,
|
||||
string MasterSql);
|
||||
}
|
||||
197
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
Normal file
197
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public sealed record LegacyCutCatalogRow(
|
||||
int PhysicalIndex,
|
||||
int? DisplayOrdinal,
|
||||
string RawLabel,
|
||||
bool IsSeparator);
|
||||
|
||||
public sealed record LegacyOperatorPlaylistRow(
|
||||
string RowId,
|
||||
bool IsEnabled,
|
||||
string MarketText,
|
||||
string StockName,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string PageText,
|
||||
string StockCode,
|
||||
string RawCutLabel,
|
||||
int FadeDuration)
|
||||
{
|
||||
public LegacyPlaylistEntry ToPlayoutEntry()
|
||||
{
|
||||
var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal);
|
||||
var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt);
|
||||
return new LegacyPlaylistEntry(
|
||||
RowId,
|
||||
cutCode,
|
||||
IsEnabled,
|
||||
FadeDuration,
|
||||
new LegacySceneSelection(
|
||||
MarketText,
|
||||
StockName,
|
||||
GraphicType,
|
||||
Subtype,
|
||||
StockCode));
|
||||
}
|
||||
}
|
||||
|
||||
public static class LegacyStockCutCatalog
|
||||
{
|
||||
public const string RuntimeAssetSha256 =
|
||||
"45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20";
|
||||
public const string NxtAllowedLabel = "1열판기본_현재가";
|
||||
|
||||
private static readonly string?[] RuntimeRows =
|
||||
[
|
||||
"1열판기본_예상체결가",
|
||||
"1열판기본_현재가",
|
||||
"1열판기본_시간외단일가",
|
||||
null,
|
||||
"1열판상세_시가",
|
||||
"1열판상세_액면가",
|
||||
"1열판상세_PBR",
|
||||
"1열판상세_거래량",
|
||||
null,
|
||||
"수익률그래프_5일",
|
||||
"수익률그래프_20일",
|
||||
"수익률그래프_60일",
|
||||
"수익률그래프_120일",
|
||||
"수익률그래프_240일",
|
||||
"캔들그래프_일봉",
|
||||
"캔들그래프_5일",
|
||||
"캔들그래프_20일",
|
||||
"캔들그래프_60일",
|
||||
"캔들그래프_120일",
|
||||
"캔들그래프_240일",
|
||||
"캔들그래프(거래량)_일봉",
|
||||
"캔들그래프(거래량)_5일",
|
||||
"캔들그래프(거래량)_20일",
|
||||
"캔들그래프(거래량)_60일",
|
||||
"캔들그래프(거래량)_120일",
|
||||
"캔들그래프(거래량)_240일",
|
||||
"캔들그래프(예상체결가)_5일",
|
||||
"캔들그래프(예상체결가)_20일",
|
||||
"캔들그래프(예상체결가)_60일",
|
||||
"캔들그래프(예상체결가)_120일",
|
||||
"캔들그래프(예상체결가)_240일",
|
||||
"호가창_표그래프",
|
||||
"거래원_표그래프"
|
||||
];
|
||||
|
||||
public static IReadOnlyList<LegacyCutCatalogRow> Rows { get; } = CreateRows();
|
||||
|
||||
public static string ResolveCutAlias(string rawLabel, bool isNxt)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(rawLabel);
|
||||
if (isNxt)
|
||||
{
|
||||
if (!string.Equals(rawLabel, NxtAllowedLabel, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("NXT는 1열판기본_현재가만 지원합니다.");
|
||||
}
|
||||
|
||||
return "N5001";
|
||||
}
|
||||
|
||||
if (rawLabel.StartsWith("1열판기본_", StringComparison.Ordinal))
|
||||
{
|
||||
return "5001";
|
||||
}
|
||||
|
||||
if (string.Equals(rawLabel, "1열판상세_시가", StringComparison.Ordinal))
|
||||
{
|
||||
return "5006";
|
||||
}
|
||||
|
||||
if (rawLabel.StartsWith("1열판상세_", StringComparison.Ordinal))
|
||||
{
|
||||
return "5011";
|
||||
}
|
||||
|
||||
if (rawLabel.StartsWith("수익률그래프_", StringComparison.Ordinal))
|
||||
{
|
||||
return "5086";
|
||||
}
|
||||
|
||||
if (rawLabel.StartsWith("캔들그래프", StringComparison.Ordinal))
|
||||
{
|
||||
var split = rawLabel.LastIndexOf('_');
|
||||
var period = split >= 0 ? rawLabel[(split + 1)..] : string.Empty;
|
||||
return period switch
|
||||
{
|
||||
"일봉" => "8035",
|
||||
"5일" => "8061",
|
||||
"20일" => "8040",
|
||||
"60일" => "8046",
|
||||
"120일" => "8051",
|
||||
"240일" => "8056",
|
||||
_ => throw UnknownCut(rawLabel)
|
||||
};
|
||||
}
|
||||
|
||||
return rawLabel switch
|
||||
{
|
||||
"호가창_표그래프" => "8003",
|
||||
"거래원_표그래프" => "5037",
|
||||
_ => throw UnknownCut(rawLabel)
|
||||
};
|
||||
}
|
||||
|
||||
public static LegacyOperatorPlaylistRow CreatePlaylistRow(
|
||||
string rowId,
|
||||
string stockName,
|
||||
LegacyStockIdentity? identity,
|
||||
LegacyCutCatalogRow cut,
|
||||
int fadeDuration = 6)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(rowId);
|
||||
ArgumentNullException.ThrowIfNull(stockName);
|
||||
ArgumentNullException.ThrowIfNull(cut);
|
||||
if (cut.IsSeparator || string.IsNullOrEmpty(cut.RawLabel))
|
||||
{
|
||||
throw new ArgumentException("A separator cannot become a playlist row.", nameof(cut));
|
||||
}
|
||||
|
||||
var split = cut.RawLabel.IndexOf('_');
|
||||
if (split <= 0 || split == cut.RawLabel.Length - 1)
|
||||
{
|
||||
throw UnknownCut(cut.RawLabel);
|
||||
}
|
||||
|
||||
return new LegacyOperatorPlaylistRow(
|
||||
rowId,
|
||||
true,
|
||||
identity?.MarketText ?? string.Empty,
|
||||
stockName,
|
||||
cut.RawLabel[..split],
|
||||
cut.RawLabel[(split + 1)..],
|
||||
"1/1",
|
||||
identity?.StockCode ?? string.Empty,
|
||||
cut.RawLabel,
|
||||
fadeDuration);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyCutCatalogRow> CreateRows()
|
||||
{
|
||||
var displayOrdinal = 0;
|
||||
var rows = new LegacyCutCatalogRow[RuntimeRows.Length];
|
||||
for (var index = 0; index < RuntimeRows.Length; index++)
|
||||
{
|
||||
var label = RuntimeRows[index];
|
||||
var separator = label is null;
|
||||
rows[index] = new LegacyCutCatalogRow(
|
||||
index,
|
||||
separator ? null : ++displayOrdinal,
|
||||
label ?? string.Empty,
|
||||
separator);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(rows);
|
||||
}
|
||||
|
||||
private static InvalidOperationException UnknownCut(string label) =>
|
||||
new($"The legacy stock cut is unknown: {label}");
|
||||
}
|
||||
77
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockModels.cs
Normal file
77
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockModels.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyDomesticStockMarket
|
||||
{
|
||||
Kospi,
|
||||
Kosdaq,
|
||||
NxtKospi,
|
||||
NxtKosdaq
|
||||
}
|
||||
|
||||
public sealed record LegacyStockIdentity(
|
||||
LegacyDomesticStockMarket Market,
|
||||
string DisplayName,
|
||||
string StockCode)
|
||||
{
|
||||
public bool IsNxt => Market is
|
||||
LegacyDomesticStockMarket.NxtKospi or LegacyDomesticStockMarket.NxtKosdaq;
|
||||
|
||||
public string MarketText => Market switch
|
||||
{
|
||||
LegacyDomesticStockMarket.Kospi => "코스피",
|
||||
LegacyDomesticStockMarket.Kosdaq => "코스닥",
|
||||
LegacyDomesticStockMarket.NxtKospi => "코스피_NXT",
|
||||
LegacyDomesticStockMarket.NxtKosdaq => "코스닥_NXT",
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class LegacyStockSearchData
|
||||
{
|
||||
private readonly LegacyStockIdentity[] _masterCatalog;
|
||||
|
||||
public LegacyStockSearchData(
|
||||
string query,
|
||||
IEnumerable<string> displayNames,
|
||||
IEnumerable<LegacyStockIdentity> masterCatalog)
|
||||
{
|
||||
Query = query ?? throw new ArgumentNullException(nameof(query));
|
||||
ArgumentNullException.ThrowIfNull(displayNames);
|
||||
ArgumentNullException.ThrowIfNull(masterCatalog);
|
||||
|
||||
DisplayNames = Array.AsReadOnly(displayNames.ToArray());
|
||||
_masterCatalog = masterCatalog.ToArray();
|
||||
MasterCatalog = Array.AsReadOnly(_masterCatalog);
|
||||
}
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public IReadOnlyList<string> DisplayNames { get; }
|
||||
|
||||
public IReadOnlyList<LegacyStockIdentity> MasterCatalog { get; }
|
||||
|
||||
/// <summary>
|
||||
/// MainForm.jongmokCode scanned all four cached master tables without breaking.
|
||||
/// Keeping the final match therefore reproduces its duplicate-name behaviour.
|
||||
/// </summary>
|
||||
public LegacyStockIdentity? ResolveLastByDisplayName(string displayName)
|
||||
{
|
||||
LegacyStockIdentity? match = null;
|
||||
foreach (var item in _masterCatalog)
|
||||
{
|
||||
if (string.Equals(item.DisplayName, displayName, StringComparison.Ordinal))
|
||||
{
|
||||
match = item;
|
||||
}
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
public interface ILegacyStockLookup
|
||||
{
|
||||
Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>MBN_STOCK_WEBVIEW.LegacyApplication</RootNamespace>
|
||||
<AssemblyName>MBN_STOCK_WEBVIEW.LegacyApplication</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user