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++;
|
||||
}
|
||||
Reference in New Issue
Block a user