feat: complete legacy UI and playout parity migration

This commit is contained in:
2026-07-15 13:11:38 +09:00
parent f14800656b
commit 6e0c275fdd
108 changed files with 43738 additions and 560 deletions

View File

@@ -0,0 +1,664 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
/// <summary>
/// C# authority for the original UC3 comparison workflow. Search results and fixed targets
/// are selected by opaque ids, saved bytes are parsed by the strict Core CP949 parser, and
/// an action id is the only caller-supplied value used to materialize a playlist draft.
/// </summary>
public sealed class LegacyComparisonWorkflowController
{
private readonly ILegacyComparisonDataService _dataService;
private readonly ILegacyComparisonPairStore _pairStore;
private List<SearchResultState> _domesticResults = [];
private List<SearchResultState> _worldResults = [];
private List<SavedPairState> _savedPairs = [];
private LegacyComparisonTarget? _firstTarget;
private LegacyComparisonTarget? _secondTarget;
private string? _selectedDomesticResultId;
private string? _selectedWorldResultId;
private string? _selectedFixedTargetId;
private string? _selectedPairId;
private string _domesticQuery = string.Empty;
private string _worldQuery = string.Empty;
private string _domesticError = string.Empty;
private string _worldError = string.Empty;
private string _statusMessage = string.Empty;
private LegacyComparisonSearchStatus _domesticStatus = LegacyComparisonSearchStatus.Idle;
private LegacyComparisonSearchStatus _worldStatus = LegacyComparisonSearchStatus.Idle;
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
private long _revision;
private long _pairSequence;
private long _draftSequence;
private int _domesticSearchVersion;
private int _worldSearchVersion;
private bool _isBusy;
public LegacyComparisonWorkflowController(
ILegacyComparisonDataService dataService,
ILegacyComparisonPairStore pairStore)
{
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
_pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
}
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
public async Task<LegacyComparisonWorkflowSnapshot> SearchDomesticAsync(
string query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = NormalizeSearchQuery(query);
var version = ++_domesticSearchVersion;
_domesticQuery = normalizedQuery;
_domesticStatus = LegacyComparisonSearchStatus.Loading;
_domesticError = string.Empty;
_domesticResults = [];
_selectedDomesticResultId = null;
Touch();
try
{
var results = await _dataService.SearchDomesticAsync(
normalizedQuery,
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
cancellationToken).ConfigureAwait(false);
if (version != _domesticSearchVersion)
{
return CreateSnapshot();
}
_domesticResults = ValidateSearchResults(
results,
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
domestic: true,
version);
_domesticStatus = LegacyComparisonSearchStatus.Ready;
_domesticError = string.Empty;
Touch();
return CreateSnapshot();
}
catch (OperationCanceledException)
{
if (version == _domesticSearchVersion)
{
_domesticStatus = LegacyComparisonSearchStatus.Idle;
_domesticError = string.Empty;
Touch();
}
throw;
}
catch
{
if (version == _domesticSearchVersion)
{
_domesticResults = [];
_domesticStatus = LegacyComparisonSearchStatus.Error;
_domesticError = "국내·NXT 종목 검색 결과를 안전하게 확인하지 못했습니다.";
Touch();
}
throw;
}
}
public async Task<LegacyComparisonWorkflowSnapshot> SearchWorldAsync(
string query,
CancellationToken cancellationToken = default)
{
// UC3's blank world query loaded the complete US/TW universe, so blank remains valid.
var normalizedQuery = NormalizeSearchQuery(query);
var version = ++_worldSearchVersion;
_worldQuery = normalizedQuery;
_worldStatus = LegacyComparisonSearchStatus.Loading;
_worldError = string.Empty;
_worldResults = [];
_selectedWorldResultId = null;
Touch();
try
{
var results = await _dataService.SearchWorldAsync(
normalizedQuery,
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
cancellationToken).ConfigureAwait(false);
if (version != _worldSearchVersion)
{
return CreateSnapshot();
}
_worldResults = ValidateSearchResults(
results,
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
domestic: false,
version);
_worldStatus = LegacyComparisonSearchStatus.Ready;
_worldError = string.Empty;
Touch();
return CreateSnapshot();
}
catch (OperationCanceledException)
{
if (version == _worldSearchVersion)
{
_worldStatus = LegacyComparisonSearchStatus.Idle;
_worldError = string.Empty;
Touch();
}
throw;
}
catch
{
if (version == _worldSearchVersion)
{
_worldResults = [];
_worldStatus = LegacyComparisonSearchStatus.Error;
_worldError = "미국·대만 종목 검색 결과를 안전하게 확인하지 못했습니다.";
Touch();
}
throw;
}
}
public LegacyComparisonWorkflowSnapshot SelectDomesticResult(string selectionId) =>
SelectSearchResult(_domesticResults, selectionId, domestic: true);
public LegacyComparisonWorkflowSnapshot SelectWorldResult(string selectionId) =>
SelectSearchResult(_worldResults, selectionId, domestic: false);
public LegacyComparisonWorkflowSnapshot SelectFixedTarget(string targetId)
{
ArgumentNullException.ThrowIfNull(targetId);
if (LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId) is not null)
{
_selectedFixedTargetId = targetId;
Touch();
}
return CreateSnapshot();
}
public LegacyComparisonWorkflowSnapshot ChooseDomesticResult(string selectionId) =>
ChooseSearchResult(_domesticResults, selectionId, domestic: true);
public LegacyComparisonWorkflowSnapshot ChooseWorldResult(string selectionId) =>
ChooseSearchResult(_worldResults, selectionId, domestic: false);
public LegacyComparisonWorkflowSnapshot ChooseFixedTarget(string targetId)
{
ArgumentNullException.ThrowIfNull(targetId);
var target = LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId);
if (target is not null)
{
_selectedFixedTargetId = targetId;
RotateDraft(target);
Touch();
}
return CreateSnapshot();
}
public LegacyComparisonWorkflowSnapshot SwapDraftTargets()
{
(_firstTarget, _secondTarget) = (_secondTarget, _firstTarget);
Touch();
return CreateSnapshot();
}
public LegacyComparisonWorkflowSnapshot ClearDraftTargets()
{
_firstTarget = null;
_secondTarget = null;
Touch();
return CreateSnapshot();
}
public async Task<LegacyComparisonWorkflowSnapshot> LoadSavedPairsAsync(
CancellationToken cancellationToken = default)
{
BeginBusy();
try
{
var source = await _pairStore.ReadCp949Async(cancellationToken).ConfigureAwait(false);
var candidate = new List<SavedPairState>();
if (source is { Length: > 0 })
{
var bytes = source.ToArray();
var rows = LegacyComparisonFileParser.Parse(bytes);
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
var firstReference = new LegacyComparisonStoredTargetReference(
row.First.Name,
row.First.Market);
var secondReference = new LegacyComparisonStoredTargetReference(
row.Second.Name,
row.Second.Market);
var first = await ResolveStoredTargetAsync(
firstReference,
cancellationToken).ConfigureAwait(false);
var second = await ResolveStoredTargetAsync(
secondReference,
cancellationToken).ConfigureAwait(false);
candidate.Add(new SavedPairState(
$"comparison-pair-{row.RowNumber:D8}",
first,
second));
}
}
_savedPairs = candidate;
_pairSequence = Math.Max(_pairSequence, candidate.Count);
_selectedPairId = candidate.FirstOrDefault()?.RowId;
_statusMessage = candidate.Count == 0
? "저장된 종목 비교쌍이 없습니다."
: $"저장된 종목 비교쌍 {candidate.Count}개를 불러왔습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
}
catch
{
_statusMessage = "CP949 종목비교.dat를 안전하게 불러오지 못했습니다.";
_statusKind = LegacyOperatorStatusKind.Error;
throw;
}
finally
{
EndBusy();
}
return CreateSnapshot();
}
public async Task<LegacyComparisonWorkflowSnapshot> AddDraftPairAsync(
CancellationToken cancellationToken = default)
{
if (_firstTarget is null || _secondTarget is null)
{
throw new InvalidOperationException("Select two comparison targets first.");
}
if (_savedPairs.Count >= LegacyComparisonFileParser.MaximumRows)
{
throw new InvalidOperationException("The saved comparison pair limit was reached.");
}
var signature = LegacyComparisonWorkflowCatalog.PairSignature(
_firstTarget,
_secondTarget);
if (_savedPairs.Any(pair => string.Equals(
LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second),
signature,
StringComparison.Ordinal)))
{
throw new InvalidOperationException("The exact comparison pair is already saved.");
}
var rowId = $"comparison-pair-{++_pairSequence:D8}";
var candidate = new List<SavedPairState>(_savedPairs)
{
new(rowId, _firstTarget, _secondTarget)
};
return await CommitPairsAsync(
candidate,
rowId,
"종목 비교쌍을 저장했습니다.",
cancellationToken).ConfigureAwait(false);
}
public LegacyComparisonWorkflowSnapshot SelectSavedPair(string rowId)
{
ArgumentNullException.ThrowIfNull(rowId);
if (_savedPairs.Any(pair => string.Equals(pair.RowId, rowId, StringComparison.Ordinal)))
{
_selectedPairId = rowId;
Touch();
}
return CreateSnapshot();
}
public async Task<LegacyComparisonWorkflowSnapshot> MoveSelectedPairAsync(
LegacyComparisonPairMoveDirection direction,
CancellationToken cancellationToken = default)
{
if (!Enum.IsDefined(direction))
{
throw new ArgumentOutOfRangeException(nameof(direction));
}
var index = SelectedPairIndex();
var destination = index + (direction == LegacyComparisonPairMoveDirection.Up ? -1 : 1);
if (index < 0 || destination < 0 || destination >= _savedPairs.Count)
{
return CreateSnapshot();
}
var candidate = new List<SavedPairState>(_savedPairs);
(candidate[index], candidate[destination]) = (candidate[destination], candidate[index]);
return await CommitPairsAsync(
candidate,
_selectedPairId,
"종목 비교쌍 순서를 변경했습니다.",
cancellationToken).ConfigureAwait(false);
}
public async Task<LegacyComparisonWorkflowSnapshot> DeleteSelectedPairAsync(
CancellationToken cancellationToken = default)
{
var index = SelectedPairIndex();
if (index < 0)
{
return CreateSnapshot();
}
var candidate = new List<SavedPairState>(_savedPairs);
candidate.RemoveAt(index);
var nextSelection = candidate.Count == 0
? null
: candidate[Math.Min(index, candidate.Count - 1)].RowId;
return await CommitPairsAsync(
candidate,
nextSelection,
"선택한 종목 비교쌍을 삭제했습니다.",
cancellationToken).ConfigureAwait(false);
}
public Task<LegacyComparisonWorkflowSnapshot> DeleteAllPairsAsync(
CancellationToken cancellationToken = default) =>
_savedPairs.Count == 0
? Task.FromResult(CreateSnapshot())
: CommitPairsAsync(
[],
null,
"종목 비교쌍을 모두 삭제했습니다.",
cancellationToken);
public LegacyGenericPlaylistDraft MaterializeSelectedAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (_isBusy)
{
throw new InvalidOperationException("The comparison workflow is busy.");
}
var index = SelectedPairIndex();
if (index < 0)
{
throw new InvalidOperationException("Select a saved comparison pair first.");
}
var pair = _savedPairs[index];
var draftId = $"comparison-draft-{++_draftSequence:D8}";
return LegacyComparisonWorkflowCatalog.Materialize(
actionId,
pair.First,
pair.Second,
draftId);
}
private LegacyComparisonWorkflowSnapshot ChooseSearchResult(
IReadOnlyList<SearchResultState> results,
string selectionId,
bool domestic)
{
ArgumentNullException.ThrowIfNull(selectionId);
var result = results.FirstOrDefault(item =>
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal));
if (result is not null)
{
if (domestic)
{
_selectedDomesticResultId = selectionId;
}
else
{
_selectedWorldResultId = selectionId;
}
RotateDraft(result.Target);
Touch();
}
return CreateSnapshot();
}
private LegacyComparisonWorkflowSnapshot SelectSearchResult(
IReadOnlyList<SearchResultState> results,
string selectionId,
bool domestic)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (results.Any(item => string.Equals(
item.SelectionId,
selectionId,
StringComparison.Ordinal)))
{
if (domestic)
{
_selectedDomesticResultId = selectionId;
}
else
{
_selectedWorldResultId = selectionId;
}
Touch();
}
return CreateSnapshot();
}
private void RotateDraft(LegacyComparisonTarget selected)
{
_secondTarget = _firstTarget;
_firstTarget = selected;
}
private async Task<LegacyComparisonTarget> ResolveStoredTargetAsync(
LegacyComparisonStoredTargetReference stored,
CancellationToken cancellationToken)
{
var fixedTarget = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(stored);
var resolved = fixedTarget ?? await _dataService.ResolveStoredTargetAsync(
stored,
cancellationToken).ConfigureAwait(false);
if (resolved is null)
{
throw new InvalidOperationException("A stored comparison target no longer resolves.");
}
resolved = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(resolved);
if (!LegacyComparisonWorkflowCatalog.MatchesStoredReference(resolved, stored))
{
throw new InvalidOperationException("A stored comparison target resolved to a different identity.");
}
return resolved;
}
private async Task<LegacyComparisonWorkflowSnapshot> CommitPairsAsync(
IReadOnlyList<SavedPairState> candidate,
string? nextSelection,
string successMessage,
CancellationToken cancellationToken)
{
BeginBusy();
try
{
var writeRows = candidate.Select((pair, index) =>
{
var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First);
var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second);
return new LegacyComparisonPairWriteRow(
index + 1,
first.Name,
first.Market,
second.Name,
second.Market);
}).ToArray();
await _pairStore.WriteCp949Async(
Array.AsReadOnly(writeRows),
cancellationToken).ConfigureAwait(false);
_savedPairs = candidate.ToList();
_selectedPairId = nextSelection;
_statusMessage = successMessage;
_statusKind = LegacyOperatorStatusKind.Information;
}
catch
{
_statusMessage = "종목 비교쌍 파일 쓰기를 완료하지 못해 변경을 적용하지 않았습니다.";
_statusKind = LegacyOperatorStatusKind.Error;
throw;
}
finally
{
EndBusy();
}
return CreateSnapshot();
}
private static List<SearchResultState> ValidateSearchResults(
IReadOnlyList<LegacyComparisonTarget>? results,
int maximumResults,
bool domestic,
int version)
{
if (results is null || results.Count > maximumResults)
{
throw new InvalidOperationException("The comparison search result count is invalid.");
}
var validated = new List<SearchResultState>(results.Count);
var targetIds = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < results.Count; index++)
{
var target = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(results[index]);
var allowedKind = domestic
? target.Kind is LegacyComparisonTargetKind.KrxStock or LegacyComparisonTargetKind.NxtStock
: target.Kind == LegacyComparisonTargetKind.WorldStock;
if (!allowedKind || !targetIds.Add(target.TargetId))
{
throw new InvalidOperationException("The comparison search result identity is invalid.");
}
validated.Add(new SearchResultState(
$"comparison-{(domestic ? "domestic" : "world")}-{version:D4}-{index + 1:D4}",
target));
}
return validated;
}
private LegacyComparisonWorkflowSnapshot CreateSnapshot()
{
var selectedPair = SelectedPairIndex() is var selectedIndex && selectedIndex >= 0
? _savedPairs[selectedIndex]
: null;
var domesticResults = _domesticResults
.Select(result => LegacyComparisonWorkflowCatalog.ToView(
result.Target,
result.SelectionId))
.ToArray();
var worldResults = _worldResults
.Select(result => LegacyComparisonWorkflowCatalog.ToView(
result.Target,
result.SelectionId))
.ToArray();
var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets
.Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId))
.ToArray();
var savedPairs = _savedPairs.Select((pair, index) =>
new LegacyComparisonSavedPairView(
pair.RowId,
index + 1,
$"{pair.First.DisplayName} ↔ {pair.Second.DisplayName}",
LegacyComparisonWorkflowCatalog.ToView(pair.First, $"{pair.RowId}-first"),
LegacyComparisonWorkflowCatalog.ToView(pair.Second, $"{pair.RowId}-second"),
string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal)))
.ToArray();
return new LegacyComparisonWorkflowSnapshot(
_revision,
_isBusy,
new LegacyComparisonSearchSnapshot(
_domesticQuery,
_domesticStatus,
Array.AsReadOnly(domesticResults),
_selectedDomesticResultId,
domesticResults.Length,
_domesticError),
new LegacyComparisonSearchSnapshot(
_worldQuery,
_worldStatus,
Array.AsReadOnly(worldResults),
_selectedWorldResultId,
worldResults.Length,
_worldError),
Array.AsReadOnly(fixedTargets),
_selectedFixedTargetId,
_firstTarget is null
? null
: LegacyComparisonWorkflowCatalog.ToView(_firstTarget, "comparison-draft-first"),
_secondTarget is null
? null
: LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"),
Array.AsReadOnly(savedPairs),
_selectedPairId,
LegacyComparisonWorkflowCatalog.CreateActionViews(
selectedPair?.First,
selectedPair?.Second),
_statusMessage,
_statusKind);
}
private int SelectedPairIndex() => _selectedPairId is null
? -1
: _savedPairs.FindIndex(pair =>
string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal));
private static string NormalizeSearchQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
var normalized = query.Trim();
if (normalized.Length > 64 ||
normalized.Any(static character => char.IsControl(character) || char.IsSurrogate(character)))
{
throw new ArgumentException("A comparison query must be at most 64 safe characters.", nameof(query));
}
return normalized;
}
private void BeginBusy()
{
if (_isBusy)
{
throw new InvalidOperationException("The comparison workflow is already busy.");
}
_isBusy = true;
Touch();
}
private void EndBusy()
{
_isBusy = false;
Touch();
}
private void Touch() => _revision++;
private sealed record SearchResultState(
string SelectionId,
LegacyComparisonTarget Target);
private sealed record SavedPairState(
string RowId,
LegacyComparisonTarget First,
LegacyComparisonTarget Second);
}