feat: align legacy operator and playout behavior

This commit is contained in:
2026-07-15 21:04:05 +09:00
parent 06a16e5741
commit 83398044c6
77 changed files with 6377 additions and 391 deletions

View File

@@ -33,6 +33,7 @@ public sealed class LegacyComparisonWorkflowController
private long _draftSequence;
private int _domesticSearchVersion;
private int _worldSearchVersion;
private int _activationGeneration;
private bool _isBusy;
public LegacyComparisonWorkflowController(
@@ -45,12 +46,44 @@ public sealed class LegacyComparisonWorkflowController
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
/// <summary>
/// Recreates UC3's per-control state before its saved CP949 pairs are loaded for
/// a new tab activation. Search and pair-load generations invalidate completions
/// that belong to the discarded control instance.
/// </summary>
public LegacyComparisonWorkflowSnapshot ResetForTabActivation()
{
Interlocked.Increment(ref _activationGeneration);
Interlocked.Increment(ref _domesticSearchVersion);
Interlocked.Increment(ref _worldSearchVersion);
_domesticResults = [];
_worldResults = [];
_savedPairs = [];
_firstTarget = null;
_secondTarget = null;
_selectedDomesticResultId = null;
_selectedWorldResultId = null;
_selectedFixedTargetId = null;
_selectedPairId = null;
_domesticQuery = string.Empty;
_worldQuery = string.Empty;
_domesticError = string.Empty;
_worldError = string.Empty;
_statusMessage = string.Empty;
_domesticStatus = LegacyComparisonSearchStatus.Idle;
_worldStatus = LegacyComparisonSearchStatus.Idle;
_statusKind = LegacyOperatorStatusKind.Neutral;
_isBusy = false;
Touch();
return CreateSnapshot();
}
public async Task<LegacyComparisonWorkflowSnapshot> SearchDomesticAsync(
string query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = NormalizeSearchQuery(query);
var version = ++_domesticSearchVersion;
var version = Interlocked.Increment(ref _domesticSearchVersion);
_domesticQuery = normalizedQuery;
_domesticStatus = LegacyComparisonSearchStatus.Loading;
_domesticError = string.Empty;
@@ -64,7 +97,7 @@ public sealed class LegacyComparisonWorkflowController
normalizedQuery,
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
cancellationToken).ConfigureAwait(false);
if (version != _domesticSearchVersion)
if (version != Volatile.Read(ref _domesticSearchVersion))
{
return CreateSnapshot();
}
@@ -81,7 +114,7 @@ public sealed class LegacyComparisonWorkflowController
}
catch (OperationCanceledException)
{
if (version == _domesticSearchVersion)
if (version == Volatile.Read(ref _domesticSearchVersion))
{
_domesticStatus = LegacyComparisonSearchStatus.Idle;
_domesticError = string.Empty;
@@ -92,7 +125,7 @@ public sealed class LegacyComparisonWorkflowController
}
catch
{
if (version == _domesticSearchVersion)
if (version == Volatile.Read(ref _domesticSearchVersion))
{
_domesticResults = [];
_domesticStatus = LegacyComparisonSearchStatus.Error;
@@ -110,7 +143,7 @@ public sealed class LegacyComparisonWorkflowController
{
// UC3's blank world query loaded the complete US/TW universe, so blank remains valid.
var normalizedQuery = NormalizeSearchQuery(query);
var version = ++_worldSearchVersion;
var version = Interlocked.Increment(ref _worldSearchVersion);
_worldQuery = normalizedQuery;
_worldStatus = LegacyComparisonSearchStatus.Loading;
_worldError = string.Empty;
@@ -124,7 +157,7 @@ public sealed class LegacyComparisonWorkflowController
normalizedQuery,
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
cancellationToken).ConfigureAwait(false);
if (version != _worldSearchVersion)
if (version != Volatile.Read(ref _worldSearchVersion))
{
return CreateSnapshot();
}
@@ -141,7 +174,7 @@ public sealed class LegacyComparisonWorkflowController
}
catch (OperationCanceledException)
{
if (version == _worldSearchVersion)
if (version == Volatile.Read(ref _worldSearchVersion))
{
_worldStatus = LegacyComparisonSearchStatus.Idle;
_worldError = string.Empty;
@@ -152,7 +185,7 @@ public sealed class LegacyComparisonWorkflowController
}
catch
{
if (version == _worldSearchVersion)
if (version == Volatile.Read(ref _worldSearchVersion))
{
_worldResults = [];
_worldStatus = LegacyComparisonSearchStatus.Error;
@@ -220,6 +253,7 @@ public sealed class LegacyComparisonWorkflowController
public async Task<LegacyComparisonWorkflowSnapshot> LoadSavedPairsAsync(
CancellationToken cancellationToken = default)
{
var activationGeneration = Volatile.Read(ref _activationGeneration);
BeginBusy();
try
{
@@ -251,6 +285,11 @@ public sealed class LegacyComparisonWorkflowController
}
}
if (activationGeneration != Volatile.Read(ref _activationGeneration))
{
return CreateSnapshot();
}
_savedPairs = candidate;
_pairSequence = Math.Max(_pairSequence, candidate.Count);
_selectedPairId = candidate.FirstOrDefault()?.RowId;
@@ -261,13 +300,21 @@ public sealed class LegacyComparisonWorkflowController
}
catch
{
if (activationGeneration != Volatile.Read(ref _activationGeneration))
{
return CreateSnapshot();
}
_statusMessage = "CP949 종목비교.dat를 안전하게 불러오지 못했습니다.";
_statusKind = LegacyOperatorStatusKind.Error;
throw;
}
finally
{
EndBusy();
if (activationGeneration == Volatile.Read(ref _activationGeneration))
{
EndBusy();
}
}
return CreateSnapshot();

View File

@@ -104,7 +104,6 @@ public sealed class LegacyFixedAction
public sealed record LegacyMaterializedFixedAction(
LegacyFixedAction Action,
LegacyFixedSelection Selection,
LegacyMovingAverageSelection MovingAverages,
LegacyFixedRuntimeAsset RuntimeAsset);
public sealed class LegacyFixedSection
@@ -262,8 +261,7 @@ public sealed class LegacyFixedActionCatalog
public LegacyMaterializedFixedAction Materialize(
string actionId,
DateTimeOffset localNow,
LegacyMovingAverageSelection movingAverages = default)
DateTimeOffset localNow)
{
var action = GetAction(actionId);
if (!action.Available)
@@ -271,14 +269,9 @@ public sealed class LegacyFixedActionCatalog
throw new InvalidOperationException(action.Prerequisite);
}
var trustedMovingAverages = action.SupportsMovingAverages
? movingAverages
: default;
var graphicType = action.DynamicNxtSession
? localNow.Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET"
: action.SupportsMovingAverages
? MovingAverageText(trustedMovingAverages)
: action.Selection.GraphicType;
: action.Selection.GraphicType;
var dataCode = action.DynamicDate
? localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
: action.Selection.DataCode;
@@ -291,7 +284,6 @@ public sealed class LegacyFixedActionCatalog
return new LegacyMaterializedFixedAction(
action,
selection,
trustedMovingAverages,
RuntimeAssets[action.Market]);
}
@@ -602,21 +594,6 @@ public sealed class LegacyFixedActionCatalog
}
}
private static string MovingAverageText(LegacyMovingAverageSelection movingAverages)
{
if (movingAverages.Ma5 && movingAverages.Ma20)
{
return "MA5,MA20";
}
if (movingAverages.Ma5)
{
return "MA5";
}
return movingAverages.Ma20 ? "MA20" : string.Empty;
}
private static LegacyFixedMarket ParseMarket(string value) => value switch
{
"overseas" => LegacyFixedMarket.Overseas,

View File

@@ -82,6 +82,26 @@ public sealed class LegacyIndustrySelectionWorkflow
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
/// <summary>
/// Recreates the observable UC2 state for one tab activation. The generation
/// increment prevents a request started by the discarded control from restoring
/// its rows after the operator has re-entered the tab.
/// </summary>
public LegacyIndustrySelectionSnapshot ResetForTabActivation(IndustryMarket market)
{
ValidateMarket(market);
Interlocked.Increment(ref _loadGeneration);
_market = market;
_industries = NoIndustries;
_actions = LegacyIndustryActionCatalog.GetActions(market);
_selectedIndex = null;
_selectedIndustry = null;
_firstIndustry = null;
_secondIndustry = null;
Touch();
return CreateSnapshot();
}
public async Task<LegacyIndustrySelectionSnapshot> LoadMarketAsync(
IndustryMarket market,
CancellationToken cancellationToken = default)

View File

@@ -53,4 +53,7 @@ public sealed record LegacyOperatorPlayoutSnapshot(
bool BackgroundEnabled,
string BackgroundFileName,
bool CanChangeBackground,
string Message);
string Message)
{
public bool IsTakeOutCompletionPending { get; init; }
}

View File

@@ -273,6 +273,41 @@ public sealed class LegacyOverseasSelectionWorkflow
}
}
/// <summary>
/// Recreates UC5's per-control search and selection state while retaining the
/// MainForm-owned moving-average checkboxes. Incrementing both generations makes
/// results from the discarded control stale before they can commit.
/// </summary>
public LegacyOverseasWorkflowSnapshot ResetForTabActivation()
{
lock (_sync)
{
checked
{
_industryGeneration++;
_stockGeneration++;
}
_selectedFixedIndex = null;
_industryStatus = LegacyOverseasSearchStatus.Idle;
_industryQuery = string.Empty;
_industryRetrievedAt = null;
_industryResults = NoIndustryResults;
_industryIsTruncated = false;
_selectedIndustry = null;
_industryError = string.Empty;
_stockStatus = LegacyOverseasSearchStatus.Idle;
_stockQuery = string.Empty;
_stockRetrievedAt = null;
_stockResults = NoStockResults;
_stockIsTruncated = false;
_selectedStock = null;
_stockError = string.Empty;
Touch();
return CreateSnapshot();
}
}
public LegacyOverseasWorkflowSnapshot SelectFixedIndex(string targetId)
{
var target = LegacyOverseasActionCatalog.GetFixedIndexTarget(targetId);

View File

@@ -0,0 +1,355 @@
using System.Text;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyRuntimeStockCutMenuFailure
{
None,
InvalidPath,
FileNotFound,
FileUnavailable,
FileTooLarge,
InvalidEncoding,
InvalidFormat,
TooManyRows,
UnknownCut,
EmptyMenu
}
public enum LegacyRuntimeStockCutMenuSource
{
ExternalOverride,
Packaged,
BuiltIn
}
public sealed record LegacyRuntimeStockCutMenuComposition(
IReadOnlyList<LegacyCutCatalogRow> Rows,
LegacyRuntimeStockCutMenuSource Source,
LegacyRuntimeStockCutMenuFailure Failure,
string? WarningMessage)
{
public bool UsesBuiltInCatalog => Source == LegacyRuntimeStockCutMenuSource.BuiltIn;
}
public static class LegacyRuntimeStockCutMenuLoader
{
public const int MaximumRows = 50;
public const int MaximumFileBytes = 64 * 1024;
public const string StockSectionHeader = "[종목]";
public const string FallbackWarningMessage =
"런타임 컷 메뉴를 읽지 못해 내장 컷 목록을 사용합니다.";
private static readonly Encoding Cp949 = CreateCp949Encoding();
private static readonly HashSet<string> KnownCutLabels = LegacyStockCutCatalog.Rows
.Where(row => !row.IsSeparator)
.Select(row => row.RawLabel)
.ToHashSet(StringComparer.Ordinal);
public static LegacyRuntimeStockCutMenuComposition ResolveFromBaseDirectory(
string? baseDirectory)
{
if (!TryCreatePackagedCandidatePath(baseDirectory, out var path))
{
return CreateFallbackComposition(
LegacyRuntimeStockCutMenuFailure.InvalidPath);
}
if (TryLoad(path, out var rows, out var failure))
{
return CreateRuntimeComposition(
rows,
LegacyRuntimeStockCutMenuSource.Packaged);
}
return CreateFallbackComposition(failure);
}
public static LegacyRuntimeStockCutMenuComposition ResolveFromCandidates(
string? externalOverridePath,
string? packagedBaseDirectory)
{
if (externalOverridePath is not null)
{
if (!TryNormalizeExternalOverridePath(
externalOverridePath,
out var normalizedExternalPath))
{
return CreateFallbackComposition(
LegacyRuntimeStockCutMenuFailure.InvalidPath);
}
if (TryLoad(
normalizedExternalPath,
out var externalRows,
out var externalFailure))
{
return CreateRuntimeComposition(
externalRows,
LegacyRuntimeStockCutMenuSource.ExternalOverride);
}
if (externalFailure != LegacyRuntimeStockCutMenuFailure.FileNotFound)
{
return CreateFallbackComposition(externalFailure);
}
}
return ResolveFromBaseDirectory(packagedBaseDirectory);
}
public static bool TryLoad(
string? path,
out IReadOnlyList<LegacyCutCatalogRow> rows,
out LegacyRuntimeStockCutMenuFailure failure)
{
rows = Array.Empty<LegacyCutCatalogRow>();
if (string.IsNullOrWhiteSpace(path) ||
path.Any(char.IsControl) ||
!Path.IsPathFullyQualified(path))
{
failure = LegacyRuntimeStockCutMenuFailure.InvalidPath;
return false;
}
try
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
FileOptions.SequentialScan);
if (stream.Length > MaximumFileBytes)
{
failure = LegacyRuntimeStockCutMenuFailure.FileTooLarge;
return false;
}
var bytes = new byte[(int)stream.Length];
stream.ReadExactly(bytes);
return TryParse(bytes, out rows, out failure);
}
catch (Exception exception) when (
exception is FileNotFoundException or
DirectoryNotFoundException)
{
failure = LegacyRuntimeStockCutMenuFailure.FileNotFound;
return false;
}
catch (Exception exception) when (
exception is IOException or
UnauthorizedAccessException or
NotSupportedException or
ArgumentException or
System.Security.SecurityException)
{
failure = LegacyRuntimeStockCutMenuFailure.FileUnavailable;
return false;
}
}
public static bool TryParse(
ReadOnlySpan<byte> cp949Bytes,
out IReadOnlyList<LegacyCutCatalogRow> rows,
out LegacyRuntimeStockCutMenuFailure failure)
{
rows = Array.Empty<LegacyCutCatalogRow>();
if (cp949Bytes.Length > MaximumFileBytes)
{
failure = LegacyRuntimeStockCutMenuFailure.FileTooLarge;
return false;
}
string text;
try
{
text = Cp949.GetString(cp949Bytes);
}
catch (DecoderFallbackException)
{
failure = LegacyRuntimeStockCutMenuFailure.InvalidEncoding;
return false;
}
return TryParseDecodedText(text, out rows, out failure);
}
private static bool TryParseDecodedText(
string text,
out IReadOnlyList<LegacyCutCatalogRow> rows,
out LegacyRuntimeStockCutMenuFailure failure)
{
rows = Array.Empty<LegacyCutCatalogRow>();
var parsedRows = new List<LegacyCutCatalogRow>();
var sawSection = false;
var displayOrdinal = 0;
using var reader = new StringReader(text);
while (reader.ReadLine() is { } sourceLine)
{
var line = sourceLine.Trim();
if (line.Length == 0)
{
continue;
}
if (!sawSection)
{
if (!string.Equals(line, StockSectionHeader, StringComparison.Ordinal))
{
failure = LegacyRuntimeStockCutMenuFailure.InvalidFormat;
return false;
}
sawSection = true;
continue;
}
if (line.StartsWith("[", StringComparison.Ordinal))
{
failure = LegacyRuntimeStockCutMenuFailure.InvalidFormat;
return false;
}
if (parsedRows.Count >= MaximumRows)
{
failure = LegacyRuntimeStockCutMenuFailure.TooManyRows;
return false;
}
var isSeparator = string.Equals(line, "-", StringComparison.Ordinal);
if (!isSeparator && !KnownCutLabels.Contains(line))
{
failure = LegacyRuntimeStockCutMenuFailure.UnknownCut;
return false;
}
parsedRows.Add(new LegacyCutCatalogRow(
parsedRows.Count,
isSeparator ? null : ++displayOrdinal,
isSeparator ? string.Empty : line,
isSeparator));
}
if (!sawSection)
{
failure = LegacyRuntimeStockCutMenuFailure.InvalidFormat;
return false;
}
if (displayOrdinal == 0)
{
failure = LegacyRuntimeStockCutMenuFailure.EmptyMenu;
return false;
}
rows = parsedRows.AsReadOnly();
failure = LegacyRuntimeStockCutMenuFailure.None;
return true;
}
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private static LegacyRuntimeStockCutMenuComposition CreateFallbackComposition(
LegacyRuntimeStockCutMenuFailure failure) =>
new(
LegacyStockCutCatalog.Rows,
LegacyRuntimeStockCutMenuSource.BuiltIn,
failure,
FallbackWarningMessage);
private static LegacyRuntimeStockCutMenuComposition CreateRuntimeComposition(
IReadOnlyList<LegacyCutCatalogRow> rows,
LegacyRuntimeStockCutMenuSource source) =>
new(
rows,
source,
LegacyRuntimeStockCutMenuFailure.None,
WarningMessage: null);
private static bool TryCreatePackagedCandidatePath(
string? baseDirectory,
out string path)
{
path = string.Empty;
if (string.IsNullOrWhiteSpace(baseDirectory) ||
baseDirectory.Any(char.IsControl) ||
!Path.IsPathFullyQualified(baseDirectory))
{
return false;
}
try
{
path = Path.Combine(
Path.GetFullPath(baseDirectory),
"Res",
"종목.ini");
return true;
}
catch (Exception exception) when (
exception is ArgumentException or
IOException or
NotSupportedException or
System.Security.SecurityException)
{
return false;
}
}
private static bool TryNormalizeExternalOverridePath(
string path,
out string normalizedPath)
{
normalizedPath = string.Empty;
if (string.IsNullOrWhiteSpace(path) ||
path.Any(char.IsControl) ||
!Path.IsPathFullyQualified(path))
{
return false;
}
try
{
normalizedPath = Path.GetFullPath(path);
var resourceDirectory = Path.GetDirectoryName(normalizedPath);
var applicationDirectory = resourceDirectory is null
? null
: Path.GetDirectoryName(resourceDirectory);
return string.Equals(
Path.GetFileName(normalizedPath),
"종목.ini",
StringComparison.OrdinalIgnoreCase) &&
string.Equals(
resourceDirectory is null
? null
: Path.GetFileName(resourceDirectory),
"Res",
StringComparison.OrdinalIgnoreCase) &&
string.Equals(
applicationDirectory is null
? null
: Path.GetFileName(applicationDirectory),
"MBN_STOCK_WEBVIEW",
StringComparison.OrdinalIgnoreCase);
}
catch (Exception exception) when (
exception is ArgumentException or
IOException or
NotSupportedException or
System.Security.SecurityException)
{
normalizedPath = string.Empty;
return false;
}
}
}

View File

@@ -1,3 +1,4 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -21,7 +22,8 @@ public sealed record LegacyOperatorPlaylistRow(
int FadeDuration,
bool IsSelected = false,
string SceneAlias = "",
LegacySceneSelection? PlayoutSelection = null)
LegacySceneSelection? PlayoutSelection = null,
bool IsActive = false)
{
public LegacyPlaylistEntry ToPlayoutEntry()
{
@@ -40,7 +42,47 @@ public sealed record LegacyOperatorPlaylistRow(
StockName,
GraphicType,
Subtype,
StockCode));
StockCode),
CreatePageNavigation());
}
private LegacyPlaylistPageNavigation CreatePageNavigation()
{
var pageSize = GraphicType switch
{
"5단 표그래프" => ScenePageSize.Five,
"6종목 현재가" => ScenePageSize.Six,
"12종목 현재가" => ScenePageSize.Twelve,
_ => (ScenePageSize?)null
};
if (!pageSize.HasValue)
{
// MainForm's PageN branches compared the visible FarPoint column text
// exactly. A scene alias that happens to resolve to a paged builder is still
// an ordinary playlist row when that column uses any other spelling.
return LegacyPlaylistPageNavigation.Disabled;
}
var separator = PageText.IndexOf('/');
if (separator <= 0 || separator != PageText.LastIndexOf('/') ||
!int.TryParse(
PageText.AsSpan(0, separator),
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out var currentPage) ||
!int.TryParse(
PageText.AsSpan(separator + 1),
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out var totalPages) ||
totalPages is < 1 or > ScenePaging.MaximumPageCount ||
currentPage < 1 || currentPage > totalPages)
{
throw new InvalidOperationException(
"A PageN playlist row must use current/total with a total from 1 through 20.");
}
return new LegacyPlaylistPageNavigation(pageSize, totalPages);
}
}

View File

@@ -119,6 +119,33 @@ public sealed class LegacyTradingHaltWorkflowController
}
}
/// <summary>
/// Recreates UC7's per-control query, result, selection and column-sort state.
/// MainForm-owned moving-average values remain in force across tab activation.
/// </summary>
public LegacyTradingHaltWorkflowSnapshot ResetForTabActivation()
{
lock (_gate)
{
checked
{
_searchGeneration++;
}
_results = [];
_selected = null;
_query = string.Empty;
_errorMessage = string.Empty;
_statusMessage = string.Empty;
_status = LegacyTradingHaltRequestStatus.Idle;
_nameSort = LegacyTradingHaltNameSort.None;
_statusKind = LegacyOperatorStatusKind.Neutral;
_isTruncated = false;
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyTradingHaltWorkflowSnapshot> SearchAsync(
string query,
CancellationToken cancellationToken = default)