feat: establish legacy parity migration baseline

This commit is contained in:
2026-07-15 07:10:01 +09:00
parent e468f43886
commit f14800656b
36 changed files with 3166 additions and 0 deletions

View File

@@ -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++;
}