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,231 @@
using System.Globalization;
using System.Text;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
/// <summary>
/// Adapts the already parameterized domestic/world lookup services to the closed UC3
/// comparison identity model. Database identities never cross the WebView boundary.
/// </summary>
public sealed class LegacyComparisonDataService : ILegacyComparisonDataService
{
private readonly ILegacyStockLookup _domesticLookup;
private readonly IWorldStockSearchService _worldLookup;
public LegacyComparisonDataService(
ILegacyStockLookup domesticLookup,
IWorldStockSearchService worldLookup)
{
_domesticLookup = domesticLookup ?? throw new ArgumentNullException(nameof(domesticLookup));
_worldLookup = worldLookup ?? throw new ArgumentNullException(nameof(worldLookup));
}
public async Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default)
{
ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.DomesticMaximumResults);
var result = await _domesticLookup.SearchAsync(query, cancellationToken)
.ConfigureAwait(false);
var targets = new List<LegacyComparisonTarget>(
Math.Min(result.DisplayNames.Count, maximumResults));
var identities = new HashSet<string>(StringComparer.Ordinal);
foreach (var displayName in result.DisplayNames)
{
cancellationToken.ThrowIfCancellationRequested();
var identity = result.ResolveLastByDisplayName(displayName);
if (identity is null)
{
continue;
}
var target = ToComparisonTarget(identity);
if (identities.Add(target.TargetId))
{
targets.Add(target);
if (targets.Count == maximumResults)
{
break;
}
}
}
return targets.AsReadOnly();
}
public async Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default)
{
ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.WorldMaximumResults);
var result = await _worldLookup.SearchAsync(query, maximumResults, cancellationToken)
.ConfigureAwait(false);
return Array.AsReadOnly(result.Items
.Select(item => LegacyComparisonTarget.CreateWorldStock(
item.InputName,
item.Symbol,
item.NationCode))
.ToArray());
}
public async Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
LegacyComparisonStoredTargetReference target,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(target);
if (target.Market.Length == 0)
{
var world = await SearchWorldAsync(
target.Name,
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
cancellationToken).ConfigureAwait(false);
return world.FirstOrDefault(item =>
LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target));
}
var domestic = await SearchDomesticAsync(
RemoveNxtSuffix(target.Name),
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
cancellationToken).ConfigureAwait(false);
return domestic.FirstOrDefault(item =>
LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target));
}
private static LegacyComparisonTarget ToComparisonTarget(LegacyStockIdentity identity)
{
var name = RemoveNxtSuffix(identity.DisplayName);
return identity.Market switch
{
LegacyDomesticStockMarket.Kospi => LegacyComparisonTarget.CreateKrxStock(
LegacyComparisonDomesticMarket.Kospi,
name,
identity.StockCode),
LegacyDomesticStockMarket.Kosdaq => LegacyComparisonTarget.CreateKrxStock(
LegacyComparisonDomesticMarket.Kosdaq,
name,
identity.StockCode),
LegacyDomesticStockMarket.NxtKospi => LegacyComparisonTarget.CreateNxtStock(
LegacyComparisonDomesticMarket.Kospi,
name,
identity.StockCode),
LegacyDomesticStockMarket.NxtKosdaq => LegacyComparisonTarget.CreateNxtStock(
LegacyComparisonDomesticMarket.Kosdaq,
name,
identity.StockCode),
_ => throw new InvalidOperationException("The domestic stock market is invalid.")
};
}
private static string RemoveNxtSuffix(string value) =>
value.EndsWith("(NXT)", StringComparison.Ordinal)
? value[..^5]
: value;
private static void ValidateLimit(int value, int maximum)
{
if (value is < 1 || value > maximum)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
}
}
/// <summary>
/// Trusted, fixed-path CP949 store for the original 종목비교.dat shape. Reads may fall
/// back to a read-only seed, but all writes are atomic and confined to the local path.
/// </summary>
public sealed class LegacyComparisonPairFileStore : ILegacyComparisonPairStore
{
private readonly string _path;
private readonly string? _readOnlySeedPath;
public LegacyComparisonPairFileStore(string path, string? readOnlySeedPath = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
_path = Path.GetFullPath(path);
_readOnlySeedPath = string.IsNullOrWhiteSpace(readOnlySeedPath)
? null
: Path.GetFullPath(readOnlySeedPath);
}
public async Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default)
{
var source = File.Exists(_path)
? _path
: _readOnlySeedPath is not null && File.Exists(_readOnlySeedPath)
? _readOnlySeedPath
: null;
return source is null
? null
: await File.ReadAllBytesAsync(source, cancellationToken).ConfigureAwait(false);
}
public async Task WriteCp949Async(
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rows);
if (rows.Count > MMoneyCoderSharp.Data.LegacyComparisonFileParser.MaximumRows)
{
throw new ArgumentOutOfRangeException(nameof(rows));
}
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp949 = Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
var text = string.Join(
'\n',
rows.Select((row, index) => SerializeRow(row, index + 1)));
var bytes = cp949.GetBytes(text);
if (bytes.Length > 0)
{
_ = MMoneyCoderSharp.Data.LegacyComparisonFileParser.Parse(bytes);
}
var directory = Path.GetDirectoryName(_path) ??
throw new InvalidOperationException("The comparison data path has no directory.");
Directory.CreateDirectory(directory);
var temporaryPath = Path.Combine(
directory,
$".{Path.GetFileName(_path)}.{Guid.NewGuid():N}.tmp");
try
{
await File.WriteAllBytesAsync(temporaryPath, bytes, cancellationToken)
.ConfigureAwait(false);
File.Move(temporaryPath, _path, overwrite: true);
}
finally
{
if (File.Exists(temporaryPath))
{
File.Delete(temporaryPath);
}
}
}
private static string SerializeRow(LegacyComparisonPairWriteRow row, int expectedNumber)
{
ArgumentNullException.ThrowIfNull(row);
if (row.RowNumber != expectedNumber)
{
throw new InvalidOperationException("Comparison row numbers must be contiguous.");
}
return string.Join(
'^',
row.RowNumber.ToString(CultureInfo.InvariantCulture),
$"{row.FirstName},{row.SecondName}",
row.FirstMarket,
row.SecondMarket,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty);
}
}

View File

@@ -0,0 +1,427 @@
using System.Collections.ObjectModel;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
internal enum LegacyComparisonActionKind
{
TwoColumn,
Comparison,
Return
}
internal sealed record LegacyComparisonActionDefinition(
string ActionId,
string Label,
string Section,
LegacyComparisonActionKind Kind,
string? Mode = null,
string? BuilderKey = null,
string? CutCode = null,
string? GraphicType = null,
string? Period = null);
internal sealed record LegacyComparisonCompatibility(
bool IsAvailable,
string? Reason,
string? EffectiveMode,
string? Warning);
internal static class LegacyComparisonWorkflowCatalog
{
public const int DefaultFadeDuration = 6;
public const int DomesticMaximumResults = 200;
public const int WorldMaximumResults = 100;
private static readonly ReadOnlyCollection<LegacyComparisonTarget> FixedTargetList =
Array.AsReadOnly(new[]
{
Market("kospi", "코스피 지수", "Kospi"),
Market("kosdaq", "코스닥 지수", "Kosdaq"),
Market("kospi200", "코스피200 지수", "Kospi200"),
Market("futures", "선물 지수", "Futures"),
Market("krx100", "KRX100지수", "Krx100"),
Market("won-dollar", "환율-원달러", "WonDollar"),
Market("won-yen", "환율-원엔", "WonYen"),
Market("won-yuan", "환율-원위엔", "WonYuan"),
Market("won-euro", "환율-원유로", "WonEuro"),
Market("dow", "해외지수-다우", "Dow"),
Market("nasdaq", "해외지수-나스닥", "Nasdaq"),
Market("sp500", "해외지수-S&P", "Sp500"),
Market("germany-dax", "해외지수-독일", "GermanyDax"),
Market("united-kingdom-ftse", "해외지수-영국", "UnitedKingdomFtse"),
Market("france-cac", "해외지수-프랑스", "FranceCac"),
Market("nikkei", "해외지수-일본", "Nikkei"),
Market("hang-seng", "해외지수-홍콩", "HangSeng"),
Market("taiwan-weighted", "해외지수-대만", "TaiwanWeighted"),
Market("singapore-straits-times", "해외지수-싱가포르", "SingaporeStraitsTimes"),
Market("thailand-set", "해외지수-태국", "ThailandSet"),
Market("philippines-composite", "해외지수-필리핀", "PhilippinesComposite"),
Market("malaysia-klse", "해외지수-말레이시아", "MalaysiaKlse"),
Market("indonesia-composite", "해외지수-인도네시아", "IndonesiaComposite"),
Market("shanghai-composite", "해외지수-중국", "ShanghaiComposite")
});
private static readonly IReadOnlyDictionary<string, LegacyComparisonTarget> FixedById =
FixedTargetList.ToDictionary(static target => target.TargetId, StringComparer.Ordinal);
private static readonly IReadOnlyDictionary<string, LegacyComparisonTarget> FixedByDisplayName =
FixedTargetList.ToDictionary(static target => target.DisplayName, StringComparer.Ordinal);
private static readonly ReadOnlyCollection<LegacyComparisonActionDefinition> ActionList =
Array.AsReadOnly(new[]
{
new LegacyComparisonActionDefinition(
"two-column-expected", "2열판 예상체결", "2열판",
LegacyComparisonActionKind.TwoColumn, Mode: "EXPECTED"),
new LegacyComparisonActionDefinition(
"two-column-current", "2열판", "2열판",
LegacyComparisonActionKind.TwoColumn, Mode: "CURRENT"),
new LegacyComparisonActionDefinition(
"comparison-candle", "종목별 비교분석_캔들 그래프", "종목별 비교분석",
LegacyComparisonActionKind.Comparison,
BuilderKey: "s5026", CutCode: "5026",
GraphicType: "COMPARISON_CANDLE", Period: "FiveDays"),
new LegacyComparisonActionDefinition(
"comparison-line", "종목별 비교분석_라인 그래프", "종목별 비교분석",
LegacyComparisonActionKind.Comparison,
BuilderKey: "s5087", CutCode: "5087",
GraphicType: "COMPARISON_LINE", Period: "FiveDays"),
Return("return-5d", "5일", "FiveDays"),
Return("return-1m", "1개월", "OneMonth"),
Return("return-3m", "3개월", "ThreeMonths"),
Return("return-6m", "6개월", "SixMonths"),
Return("return-12m", "12개월", "TwelveMonths")
});
private static readonly IReadOnlyDictionary<string, LegacyComparisonActionDefinition> ActionsById =
ActionList.ToDictionary(static action => action.ActionId, StringComparer.Ordinal);
private static readonly HashSet<string> ExpectedMarketTargets =
new(StringComparer.Ordinal) { "Kospi", "Kosdaq", "Kospi200", "Krx100" };
static LegacyComparisonWorkflowCatalog()
{
if (FixedTargetList.Count != 24 || FixedById.Count != 24 ||
ActionList.Count != 9 || ActionsById.Count != 9)
{
throw new InvalidOperationException("The closed UC3 comparison catalog is invalid.");
}
}
public static IReadOnlyList<LegacyComparisonTarget> FixedTargets => FixedTargetList;
public static LegacyComparisonTarget? GetFixedTarget(string targetId) =>
targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null;
public static LegacyComparisonTarget RequireCanonicalTarget(LegacyComparisonTarget target)
{
ArgumentNullException.ThrowIfNull(target);
if (target.Kind != LegacyComparisonTargetKind.MarketTarget)
{
return target;
}
if (!FixedById.TryGetValue(target.TargetId, out var canonical) || canonical != target)
{
throw new InvalidOperationException("The data service returned an unknown market target.");
}
return canonical;
}
public static LegacyComparisonTargetView ToView(
LegacyComparisonTarget target,
string selectionId)
{
ArgumentNullException.ThrowIfNull(target);
LegacyComparisonValueGuard.RequireIdentifier(selectionId, 128, nameof(selectionId));
var metadata = target.Kind switch
{
LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!,
LegacyComparisonTargetKind.WorldStock => $"{target.Nation} · {target.Symbol}",
LegacyComparisonTargetKind.KrxStock =>
$"{MarketText(target.DomesticMarket)} KRX · {target.StockCode}",
LegacyComparisonTargetKind.NxtStock =>
$"{MarketText(target.DomesticMarket)} NXT · {target.StockCode}",
_ => throw new InvalidOperationException("The comparison target kind is invalid.")
};
return new LegacyComparisonTargetView(
selectionId,
target.DisplayName,
metadata,
target.Kind);
}
public static LegacyComparisonStoredTargetReference ToStoredReference(
LegacyComparisonTarget target)
{
ArgumentNullException.ThrowIfNull(target);
return target.Kind switch
{
LegacyComparisonTargetKind.MarketTarget =>
new LegacyComparisonStoredTargetReference(target.DisplayName, string.Empty),
LegacyComparisonTargetKind.KrxStock =>
new LegacyComparisonStoredTargetReference(
target.StockName!,
target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi
? "코스피"
: "코스닥"),
LegacyComparisonTargetKind.NxtStock =>
new LegacyComparisonStoredTargetReference(
target.DisplayName,
target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi
? "코스피_NXT"
: "코스닥_NXT"),
LegacyComparisonTargetKind.WorldStock =>
new LegacyComparisonStoredTargetReference(target.InputName!, string.Empty),
_ => throw new InvalidOperationException("The comparison target kind is invalid.")
};
}
public static bool MatchesStoredReference(
LegacyComparisonTarget target,
LegacyComparisonStoredTargetReference stored) =>
ToStoredReference(target) == stored;
public static LegacyComparisonTarget? TryResolveFixedStoredTarget(
LegacyComparisonStoredTargetReference stored) =>
stored.Market.Length == 0 &&
FixedByDisplayName.TryGetValue(stored.Name, out var target)
? target
: null;
public static IReadOnlyList<LegacyComparisonActionView> CreateActionViews(
LegacyComparisonTarget? first,
LegacyComparisonTarget? second)
{
var views = ActionList.Select(action =>
{
var compatibility = GetCompatibility(action, first, second);
return new LegacyComparisonActionView(
action.ActionId,
action.Label,
action.Section,
compatibility.IsAvailable,
compatibility.Reason);
}).ToArray();
return Array.AsReadOnly(views);
}
public static LegacyGenericPlaylistDraft Materialize(
string actionId,
LegacyComparisonTarget first,
LegacyComparisonTarget second,
string draftId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!ActionsById.TryGetValue(actionId, out var action))
{
throw new ArgumentOutOfRangeException(nameof(actionId), "The comparison action is unknown.");
}
var compatibility = GetCompatibility(action, first, second);
if (!compatibility.IsAvailable)
{
throw new InvalidOperationException(
$"The selected pair cannot use this comparison action: {compatibility.Reason}.");
}
LegacyComparisonValueGuard.RequireIdentifier(draftId, 128, nameof(draftId));
var subject = $"{SubjectToken(first)},{SubjectToken(second)}";
string builderKey;
string cutCode;
string graphicType;
string subtype;
string dataCode;
string category;
if (action.Kind == LegacyComparisonActionKind.TwoColumn)
{
var hasFutures = IsFutures(first) || IsFutures(second);
builderKey = hasFutures ? "s5032" : "s8018";
cutCode = hasFutures ? "5032" : "8032";
graphicType = "2열판";
subtype = action.Mode!;
dataCode = string.Empty;
category = "plate";
}
else
{
builderKey = action.Kind == LegacyComparisonActionKind.Return
? "s5029"
: action.BuilderKey!;
cutCode = action.Kind == LegacyComparisonActionKind.Return
? "5029"
: action.CutCode!;
graphicType = action.Kind == LegacyComparisonActionKind.Return
? "RETURN_COMPARISON"
: action.GraphicType!;
subtype = action.Period!;
dataCode = ExactKrxIdentityEnvelope(first, second);
category = "chart";
}
var selection = new LegacySceneSelection(
first.Kind == LegacyComparisonTargetKind.MarketTarget ||
second.Kind == LegacyComparisonTargetKind.MarketTarget
? "INDEX"
: "STOCK",
subject,
graphicType,
subtype,
dataCode);
return new LegacyGenericPlaylistDraft(
draftId,
builderKey,
cutCode,
Array.AsReadOnly(new[] { cutCode }),
$"{first.DisplayName} ↔ {second.DisplayName}",
action.Label,
category,
"compare",
selection,
true,
DefaultFadeDuration,
"legacy-comparison-workflow",
action.ActionId,
compatibility.EffectiveMode,
compatibility.Warning);
}
public static string PairSignature(
LegacyComparisonTarget first,
LegacyComparisonTarget second)
{
var firstStored = ToStoredReference(first);
var secondStored = ToStoredReference(second);
return string.Join(
'\u001f',
firstStored.Name,
firstStored.Market,
secondStored.Name,
secondStored.Market);
}
private static LegacyComparisonCompatibility GetCompatibility(
LegacyComparisonActionDefinition action,
LegacyComparisonTarget? first,
LegacyComparisonTarget? second)
{
if (first is null || second is null)
{
return Unavailable("pair-incomplete-or-invalid");
}
if (action.Kind is LegacyComparisonActionKind.Comparison or LegacyComparisonActionKind.Return)
{
return first.Kind == LegacyComparisonTargetKind.KrxStock &&
second.Kind == LegacyComparisonTargetKind.KrxStock
? Available("GRAPH")
: Unavailable("comparison-graphs-require-two-krx-stocks");
}
var futuresCount = (IsFutures(first) ? 1 : 0) + (IsFutures(second) ? 1 : 0);
if (futuresCount > 1)
{
return Unavailable("two-futures-targets-are-unsupported");
}
if (futuresCount == 1)
{
var counterpart = IsFutures(first) ? second : first;
if (counterpart.Kind != LegacyComparisonTargetKind.MarketTarget)
{
return Unavailable("futures-requires-a-market-target-counterpart");
}
return action.Mode == "EXPECTED"
? Available(
"CURRENT",
"The current Core routes a futures expected action through the current s5032 branch.")
: Available("CURRENT");
}
if (action.Mode == "EXPECTED")
{
var supported = IsExpectedTarget(first) && IsExpectedTarget(second);
return supported
? Available("EXPECTED")
: Unavailable(
"expected-mode-supports-only-krx-stocks-and-four-domestic-indices");
}
var hasMarket = first.Kind == LegacyComparisonTargetKind.MarketTarget ||
second.Kind == LegacyComparisonTargetKind.MarketTarget;
var hasWorld = first.Kind == LegacyComparisonTargetKind.WorldStock ||
second.Kind == LegacyComparisonTargetKind.WorldStock;
return hasMarket && hasWorld
? Unavailable("mixed-market-and-world-is-unsupported")
: Available("CURRENT");
}
private static bool IsExpectedTarget(LegacyComparisonTarget target) =>
target.Kind == LegacyComparisonTargetKind.KrxStock ||
target.Kind == LegacyComparisonTargetKind.MarketTarget &&
ExpectedMarketTargets.Contains(target.MarketTarget!);
private static bool IsFutures(LegacyComparisonTarget target) =>
target.Kind == LegacyComparisonTargetKind.MarketTarget &&
string.Equals(target.MarketTarget, "Futures", StringComparison.Ordinal);
private static string SubjectToken(LegacyComparisonTarget target) => target.Kind switch
{
LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!,
LegacyComparisonTargetKind.KrxStock => target.StockName!,
LegacyComparisonTargetKind.NxtStock => target.DisplayName,
LegacyComparisonTargetKind.WorldStock => target.InputName!,
_ => throw new InvalidOperationException("The comparison target kind is invalid.")
};
private static string ExactKrxIdentityEnvelope(
LegacyComparisonTarget first,
LegacyComparisonTarget second)
{
if (first.Kind != LegacyComparisonTargetKind.KrxStock ||
second.Kind != LegacyComparisonTargetKind.KrxStock)
{
throw new InvalidOperationException("Comparison graphs require two KRX stocks.");
}
return $"{MarketIdentity(first)}:{first.StockCode}|{MarketIdentity(second)}:{second.StockCode}";
}
private static string MarketIdentity(LegacyComparisonTarget target) =>
target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi ? "KOSPI" : "KOSDAQ";
private static string MarketText(LegacyComparisonDomesticMarket? market) => market switch
{
LegacyComparisonDomesticMarket.Kospi => "KOSPI",
LegacyComparisonDomesticMarket.Kosdaq => "KOSDAQ",
_ => throw new InvalidOperationException("The domestic comparison market is missing.")
};
private static LegacyComparisonCompatibility Available(
string effectiveMode,
string? warning = null) =>
new(true, null, effectiveMode, warning);
private static LegacyComparisonCompatibility Unavailable(string reason) =>
new(false, reason, null, null);
private static LegacyComparisonTarget Market(
string id,
string displayName,
string target) =>
LegacyComparisonTarget.CreateMarketTarget(id, displayName, target);
private static LegacyComparisonActionDefinition Return(
string id,
string label,
string period) =>
new(
id,
label,
"종목별 수익률 비교",
LegacyComparisonActionKind.Return,
Period: period);
}

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);
}

View File

@@ -0,0 +1,345 @@
using System.Globalization;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyComparisonTargetKind
{
MarketTarget,
KrxStock,
NxtStock,
WorldStock
}
public enum LegacyComparisonDomesticMarket
{
Kospi,
Kosdaq
}
public enum LegacyComparisonSearchStatus
{
Idle,
Loading,
Ready,
Error
}
public enum LegacyComparisonPairMoveDirection
{
Up,
Down
}
/// <summary>
/// Validated native comparison identity. Web code never constructs this type; adapters
/// create it from trusted DB results and the controller only exposes a display projection.
/// </summary>
public sealed record LegacyComparisonTarget
{
private LegacyComparisonTarget(
LegacyComparisonTargetKind kind,
string targetId,
string displayName,
string? marketTarget = null,
LegacyComparisonDomesticMarket? domesticMarket = null,
string? stockName = null,
string? stockCode = null,
string? inputName = null,
string? symbol = null,
string? nation = null)
{
Kind = kind;
TargetId = targetId;
DisplayName = displayName;
MarketTarget = marketTarget;
DomesticMarket = domesticMarket;
StockName = stockName;
StockCode = stockCode;
InputName = inputName;
Symbol = symbol;
Nation = nation;
}
public LegacyComparisonTargetKind Kind { get; }
public string TargetId { get; }
public string DisplayName { get; }
public string? MarketTarget { get; }
public LegacyComparisonDomesticMarket? DomesticMarket { get; }
public string? StockName { get; }
public string? StockCode { get; }
public string? InputName { get; }
public string? Symbol { get; }
public string? Nation { get; }
public static LegacyComparisonTarget CreateMarketTarget(
string targetId,
string displayName,
string marketTarget)
{
LegacyComparisonValueGuard.RequireIdentifier(targetId, 64, nameof(targetId));
LegacyComparisonValueGuard.RequireText(displayName, 200, nameof(displayName));
LegacyComparisonValueGuard.RequireIdentifier(marketTarget, 64, nameof(marketTarget));
return new LegacyComparisonTarget(
LegacyComparisonTargetKind.MarketTarget,
targetId,
displayName,
marketTarget: marketTarget);
}
public static LegacyComparisonTarget CreateKrxStock(
LegacyComparisonDomesticMarket market,
string stockName,
string stockCode)
{
ValidateDomesticStock(stockName, stockCode);
var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq";
return new LegacyComparisonTarget(
LegacyComparisonTargetKind.KrxStock,
$"krx:{marketToken}:{stockCode}",
stockName,
domesticMarket: market,
stockName: stockName,
stockCode: stockCode);
}
public static LegacyComparisonTarget CreateNxtStock(
LegacyComparisonDomesticMarket market,
string stockName,
string stockCode)
{
ValidateDomesticStock(stockName, stockCode);
var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq";
return new LegacyComparisonTarget(
LegacyComparisonTargetKind.NxtStock,
$"nxt:{marketToken}:{stockCode}",
$"{stockName}(NXT)",
domesticMarket: market,
stockName: stockName,
stockCode: stockCode);
}
public static LegacyComparisonTarget CreateWorldStock(
string inputName,
string symbol,
string nation)
{
LegacyComparisonValueGuard.RequireText(inputName, 200, nameof(inputName));
if (inputName.Contains(',', StringComparison.Ordinal))
{
throw new ArgumentException("A world stock input name cannot contain a comma.", nameof(inputName));
}
LegacyComparisonValueGuard.RequireSymbol(symbol, nameof(symbol));
var normalizedNation = LegacyComparisonValueGuard.RequireNation(nation, nameof(nation));
return new LegacyComparisonTarget(
LegacyComparisonTargetKind.WorldStock,
$"world:{normalizedNation}:{symbol}",
inputName,
inputName: inputName,
symbol: symbol,
nation: normalizedNation);
}
private static void ValidateDomesticStock(string stockName, string stockCode)
{
LegacyComparisonValueGuard.RequireText(stockName, 200, nameof(stockName));
if (stockName.Contains(',', StringComparison.Ordinal) ||
stockName.Contains("(NXT)", StringComparison.Ordinal))
{
throw new ArgumentException(
"A domestic stock name cannot contain a comma or the NXT display suffix.",
nameof(stockName));
}
LegacyComparisonValueGuard.RequireStockCode(stockCode, nameof(stockCode));
}
}
public sealed record LegacyComparisonStoredTargetReference(
string Name,
string Market);
public interface ILegacyComparisonDataService
{
Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default);
Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
LegacyComparisonStoredTargetReference target,
CancellationToken cancellationToken = default);
}
/// <summary>
/// File IO stays behind this boundary. A production adapter owns the fixed path and CP949
/// encoding; no UI request can provide a path or raw serialized row.
/// </summary>
public interface ILegacyComparisonPairStore
{
Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default);
Task WriteCp949Async(
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
CancellationToken cancellationToken = default);
}
public sealed record LegacyComparisonPairWriteRow(
int RowNumber,
string FirstName,
string FirstMarket,
string SecondName,
string SecondMarket);
public sealed record LegacyComparisonTargetView(
string SelectionId,
string DisplayName,
string Metadata,
LegacyComparisonTargetKind Kind);
public sealed record LegacyComparisonSearchSnapshot(
string Query,
LegacyComparisonSearchStatus Status,
IReadOnlyList<LegacyComparisonTargetView> Results,
string? SelectedResultId,
int TotalRowCount,
string ErrorMessage);
public sealed record LegacyComparisonSavedPairView(
string RowId,
int RowNumber,
string DisplayLabel,
LegacyComparisonTargetView First,
LegacyComparisonTargetView Second,
bool IsSelected);
public sealed record LegacyComparisonActionView(
string ActionId,
string Label,
string Section,
bool IsAvailable,
string? UnavailableReason);
public sealed record LegacyComparisonWorkflowSnapshot(
long Revision,
bool IsBusy,
LegacyComparisonSearchSnapshot DomesticSearch,
LegacyComparisonSearchSnapshot WorldSearch,
IReadOnlyList<LegacyComparisonTargetView> FixedTargets,
string? SelectedFixedTargetId,
LegacyComparisonTargetView? FirstTarget,
LegacyComparisonTargetView? SecondTarget,
IReadOnlyList<LegacyComparisonSavedPairView> SavedPairs,
string? SelectedPairId,
IReadOnlyList<LegacyComparisonActionView> Actions,
string StatusMessage,
LegacyOperatorStatusKind StatusKind);
/// <summary>
/// Generic, already validated playlist materialization. Only an action id crosses the UI
/// command boundary; builder, alias, scene selection and hidden identities are produced here.
/// </summary>
public sealed record LegacyGenericPlaylistDraft(
string DraftId,
string BuilderKey,
string CutCode,
IReadOnlyList<string> Aliases,
string Title,
string Detail,
string Category,
string Market,
LegacySceneSelection Selection,
bool IsEnabled,
int FadeDuration,
string Source,
string ActionId,
string? EffectiveMode,
string? Warning)
{
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
}
internal static class LegacyComparisonValueGuard
{
public static void RequireText(string value, int maximumLength, string parameterName)
{
ArgumentNullException.ThrowIfNull(value, parameterName);
if (value.Length is <= 0 || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
value.Contains('^') ||
value.Any(static character =>
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
return char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator;
}))
{
throw new ArgumentException("A canonical comparison text value is required.", parameterName);
}
}
public static void RequireIdentifier(string value, int maximumLength, string parameterName)
{
ArgumentNullException.ThrowIfNull(value, parameterName);
if (value.Length is <= 0 || value.Length > maximumLength ||
value.Any(static character =>
!char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_'))
{
throw new ArgumentException("A safe comparison identifier is required.", parameterName);
}
}
public static void RequireStockCode(string value, string parameterName)
{
ArgumentNullException.ThrowIfNull(value, parameterName);
if (value.Length is <= 0 or > 32 ||
value.Any(static character =>
!char.IsAsciiLetterOrDigit(character) && character is not '.' and not '-' and not '_'))
{
throw new ArgumentException("A safe stock code is required.", parameterName);
}
}
public static void RequireSymbol(string value, string parameterName)
{
ArgumentNullException.ThrowIfNull(value, parameterName);
if (value.Length is <= 0 or > 64 ||
value.Any(static character =>
!char.IsAsciiLetterOrDigit(character) &&
character is not '@' and not '.' and not '_' and not ':' and not '-'))
{
throw new ArgumentException("A safe world symbol is required.", parameterName);
}
}
public static string RequireNation(string value, string parameterName)
{
ArgumentNullException.ThrowIfNull(value, parameterName);
var normalized = value.ToUpperInvariant();
if (normalized is not ("US" or "TW"))
{
throw new ArgumentException("Only the original US and TW universe is supported.", parameterName);
}
return normalized;
}
}