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

@@ -1054,13 +1054,14 @@ internal static class ManualFinancialValidation
new ManualRevenueCompositionRecord(
identity,
Text(
RequiredString(row, "BASE_DATE"),
OptionalOracleString(row, "BASE_DATE"),
MaximumTextLength,
allowEmpty: true,
allowUnderscore: true,
nameof(row)),
Enumerable.Range(1, 5)
.Select(index => ParseSlice(RequiredString(row, "GUSUNG_" + index)))
.Select(index => ParseSlice(
OptionalOracleString(row, "GUSUNG_" + index)))
.ToArray()),
ManualFinancialScreenKind.GrowthMetrics => ParseGrowth(identity, row),
ManualFinancialScreenKind.Sales =>
@@ -1324,6 +1325,23 @@ internal static class ManualFinancialValidation
return value;
}
private static string OptionalOracleString(DataRow row, string columnName)
{
if (!row.Table.Columns.Contains(columnName))
{
throw new ArgumentException("A manual-financial database value is invalid.", nameof(row));
}
return row[columnName] switch
{
string value => value,
DBNull => string.Empty,
_ => throw new ArgumentException(
"A manual-financial database value is invalid.",
nameof(row))
};
}
private static string RowVersion(string value, string parameterName)
{
ArgumentNullException.ThrowIfNull(value, parameterName);

View File

@@ -293,6 +293,72 @@ public sealed class LegacyPlayoutWorkflow
}
}
/// <summary>
/// Reproduces the visible MainForm TAKE IN/F8 path from idle: ONAirMode builds and
/// prepares the selected scene, then Play starts that exact prepared instance. The
/// page DTO is therefore loaded once and the workflow gate covers both engine calls.
/// This is intentionally separate from <see cref="TakeInAsync"/>, which preserves the
/// legacy explicit-PREPARE path and refreshes its page before play.
/// </summary>
public async Task<PlayoutResult> PrepareAndTakeInAsync(
IReadOnlyList<LegacyPlaylistEntry> playlist,
int cueIndexZeroBased,
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_prepared is not null || _onAir is not null)
{
return Rejected(
PlayoutOperation.TakeIn,
"One-click TAKE IN can start only while the playout workflow is idle.");
}
var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased);
var selectedIndex = FindNextEnabledCue(snapshot, cueIndexZeroBased);
if (selectedIndex is null)
{
return Rejected(
PlayoutOperation.Prepare,
"There is no enabled playlist entry at or after the selection.");
}
var page = await CreatePageAsync(
snapshot,
selectedIndex.Value,
pageIndexZeroBased: 0,
cancellationToken).ConfigureAwait(false);
var prepare = await _engine.PrepareAsync(page.Page.Cue, cancellationToken)
.ConfigureAwait(false);
if (!prepare.IsSuccess)
{
return prepare;
}
// Once PREPARE succeeds the engine owns this scene. Keep that prepared
// state if TAKE IN fails or its outcome is unknown; never reload or retry.
_playlist = snapshot;
_prepared = page;
PublishState();
var takeIn = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
if (takeIn.IsSuccess)
{
_onAir = page;
_prepared = null;
PublishState();
}
return takeIn;
}
finally
{
ApplyRequestedEngineClear();
_gate.Release();
}
}
public async Task<PlayoutResult> TakeInAsync(
CancellationToken cancellationToken = default)
{

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,558 @@
using System.Collections.ObjectModel;
using System.Globalization;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyExpertRequestStatus
{
Idle,
Loading,
Ready,
Error
}
public sealed record LegacyExpertResultView(
string SelectionId,
string ExpertCode,
string ExpertName);
public sealed record LegacyExpertRecommendationView(
int PlayIndex,
string StockCode,
string StockName,
decimal BuyAmount);
public sealed record LegacyExpertSearchSnapshot(
string Query,
LegacyExpertRequestStatus Status,
IReadOnlyList<LegacyExpertResultView> Results,
bool IsTruncated,
string ErrorMessage);
public sealed record LegacyExpertPreviewSnapshot(
LegacyExpertRequestStatus Status,
IReadOnlyList<LegacyExpertRecommendationView> Items,
bool IsTruncated,
int PageSize,
int PageCount,
int MaximumPageCount,
string ErrorMessage);
public sealed record LegacyExpertActionView(
string ActionId,
string Label,
bool IsAvailable);
public sealed record LegacyExpertWorkflowSnapshot(
long Revision,
LegacyExpertSearchSnapshot Search,
LegacyExpertResultView? SelectedExpert,
LegacyExpertPreviewSnapshot Preview,
IReadOnlyList<LegacyExpertActionView> Actions,
string StatusMessage,
LegacyOperatorStatusKind StatusKind);
public sealed record LegacyExpertPlaylistDraft(
string DraftId,
string BuilderKey,
string CutCode,
IReadOnlyList<string> Aliases,
string Title,
string Detail,
string Category,
string Source,
string ExpertCode,
string ExpertName,
int ItemCount,
bool PreviewTruncated,
int PageSize,
int PageCount,
int MaximumPageCount,
string PageText,
LegacySceneSelection Selection,
bool IsEnabled,
int FadeDuration,
string ActionId)
{
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
}
/// <summary>
/// Native authority for UC6's read-only expert selector and recommendation preview.
/// EList CRUD is intentionally outside this workflow.
/// </summary>
public sealed class LegacyExpertWorkflowController
{
public const string ActionId = "five-row-current";
public const int PageSize = 5;
public const int MaximumPageCount = 20;
public const int MaximumAccessibleItems = PageSize * MaximumPageCount;
private const int DefaultFadeDuration = 6;
private const string ActionLabel = "전문가 추천 · 5단 표그래프 · 현재가";
private static readonly ReadOnlyCollection<string> Aliases =
Array.AsReadOnly(new[] { "5074" });
private readonly object _gate = new();
private readonly IExpertSelectionService _service;
private List<ExpertState> _searchResults = [];
private ExpertState? _selected;
private List<ExpertRecommendationPreview> _previewItems = [];
private string _query = string.Empty;
private string _searchError = string.Empty;
private string _previewError = string.Empty;
private string _statusMessage = string.Empty;
private LegacyExpertRequestStatus _searchStatus = LegacyExpertRequestStatus.Idle;
private LegacyExpertRequestStatus _previewStatus = LegacyExpertRequestStatus.Idle;
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
private bool _searchTruncated;
private bool _previewTruncated;
private int _searchGeneration;
private int _previewGeneration;
private long _revision;
private long _draftSequence;
public LegacyExpertWorkflowController(IExpertSelectionService service)
{
_service = service ?? throw new ArgumentNullException(nameof(service));
}
public LegacyExpertWorkflowSnapshot Current
{
get
{
lock (_gate)
{
return CreateSnapshot();
}
}
}
public async Task<LegacyExpertWorkflowSnapshot> SearchAsync(
string query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = NormalizeQuery(query);
int generation;
lock (_gate)
{
generation = ++_searchGeneration;
_previewGeneration++;
_query = normalizedQuery;
_searchResults = [];
_selected = null;
ResetPreview();
_searchStatus = LegacyExpertRequestStatus.Loading;
_searchError = string.Empty;
Touch();
}
try
{
var result = await _service.SearchAsync(
normalizedQuery,
LegacyExpertSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
lock (_gate)
{
if (generation != _searchGeneration)
{
return CreateSnapshot();
}
_searchResults = ValidateSearchResult(result, normalizedQuery, generation);
_searchTruncated = result.IsTruncated;
_searchStatus = LegacyExpertRequestStatus.Ready;
_searchError = string.Empty;
_statusMessage = $"전문가 {_searchResults.Count}명을 조회했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
}
catch (OperationCanceledException)
{
lock (_gate)
{
if (generation == _searchGeneration)
{
_searchStatus = LegacyExpertRequestStatus.Idle;
_searchError = string.Empty;
Touch();
}
}
throw;
}
catch
{
lock (_gate)
{
if (generation == _searchGeneration)
{
_searchResults = [];
_searchStatus = LegacyExpertRequestStatus.Error;
_searchError = "전문가 조회 결과를 안전하게 확인하지 못했습니다.";
_statusMessage = _searchError;
_statusKind = LegacyOperatorStatusKind.Error;
Touch();
}
}
throw;
}
}
public async Task<LegacyExpertWorkflowSnapshot> SelectExpertAsync(
string selectionId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(selectionId);
ExpertState selected;
int generation;
lock (_gate)
{
selected = _searchResults.FirstOrDefault(item =>
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ??
throw new ArgumentOutOfRangeException(
nameof(selectionId),
"The expert selection id is not in the current result set.");
_selected = selected;
generation = ++_previewGeneration;
_previewItems = [];
_previewTruncated = false;
_previewStatus = LegacyExpertRequestStatus.Loading;
_previewError = string.Empty;
Touch();
}
try
{
var result = await _service.PreviewAsync(
selected.Identity,
LegacyExpertSelectionService.MaximumPreviewItems,
cancellationToken).ConfigureAwait(false);
lock (_gate)
{
if (generation != _previewGeneration ||
_selected?.Identity != selected.Identity)
{
return CreateSnapshot();
}
_previewItems = ValidatePreviewResult(result, selected.Identity);
_previewTruncated = result.IsTruncated;
_previewStatus = LegacyExpertRequestStatus.Ready;
_previewError = string.Empty;
_statusMessage = $"{selected.Identity.ExpertName} 추천 종목 {_previewItems.Count}개를 조회했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
}
catch (OperationCanceledException)
{
lock (_gate)
{
if (generation == _previewGeneration)
{
_previewStatus = LegacyExpertRequestStatus.Idle;
_previewError = string.Empty;
Touch();
}
}
throw;
}
catch
{
lock (_gate)
{
if (generation == _previewGeneration)
{
_previewItems = [];
_previewTruncated = false;
_previewStatus = LegacyExpertRequestStatus.Error;
_previewError = "추천 종목 preview를 안전하게 확인하지 못했습니다.";
_statusMessage = _previewError;
_statusKind = LegacyOperatorStatusKind.Error;
Touch();
}
}
throw;
}
}
public LegacyExpertPlaylistDraft MaterializeSelectedAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
lock (_gate)
{
if (!string.Equals(actionId, ActionId, StringComparison.Ordinal))
{
throw new ArgumentOutOfRangeException(nameof(actionId));
}
if (_selected is null || _previewStatus != LegacyExpertRequestStatus.Ready)
{
throw new InvalidOperationException("Select and preview an expert first.");
}
var itemCount = _previewItems.Count;
if (itemCount is <= 0 or > MaximumAccessibleItems)
{
throw new InvalidOperationException(
"An expert cut requires one through 100 accessible recommendation rows.");
}
var pageCount = checked((itemCount + PageSize - 1) / PageSize);
if (pageCount is <= 0 or > MaximumPageCount)
{
throw new InvalidOperationException("The expert PageN plan exceeds 20 pages.");
}
var identity = _selected.Identity;
var selection = new LegacySceneSelection(
"PAGED_EXPERT",
identity.ExpertName,
"INPUT_ORDER",
"CURRENT",
identity.ExpertCode);
return new LegacyExpertPlaylistDraft(
$"expert-draft-{++_draftSequence:D8}",
"s5074",
"5074",
Aliases,
identity.ExpertName,
ActionLabel,
"expert",
"legacy-expert-workflow",
identity.ExpertCode,
identity.ExpertName,
itemCount,
_previewTruncated,
PageSize,
pageCount,
MaximumPageCount,
$"1/{pageCount}",
selection,
true,
DefaultFadeDuration,
ActionId);
}
}
private LegacyExpertWorkflowSnapshot CreateSnapshot()
{
var searchRows = _searchResults.Select(ToView).ToArray();
var previewRows = _previewItems.Select(item =>
new LegacyExpertRecommendationView(
item.PlayIndex,
item.StockCode,
item.StockName,
item.BuyAmount)).ToArray();
var pageCount = previewRows.Length == 0
? 0
: (previewRows.Length + PageSize - 1) / PageSize;
var actionAvailable = _selected is not null &&
_previewStatus == LegacyExpertRequestStatus.Ready &&
previewRows.Length is > 0 and <= MaximumAccessibleItems &&
pageCount <= MaximumPageCount;
return new LegacyExpertWorkflowSnapshot(
_revision,
new LegacyExpertSearchSnapshot(
_query,
_searchStatus,
Array.AsReadOnly(searchRows),
_searchTruncated,
_searchError),
_selected is null ? null : ToView(_selected),
new LegacyExpertPreviewSnapshot(
_previewStatus,
Array.AsReadOnly(previewRows),
_previewTruncated,
PageSize,
pageCount,
MaximumPageCount,
_previewError),
Array.AsReadOnly(new[]
{
new LegacyExpertActionView(ActionId, ActionLabel, actionAvailable)
}),
_statusMessage,
_statusKind);
}
private static List<ExpertState> ValidateSearchResult(
ExpertSearchResult result,
string expectedQuery,
int generation)
{
ArgumentNullException.ThrowIfNull(result);
if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
result.Items is null ||
result.Items.Count > LegacyExpertSelectionService.MaximumResults ||
result.IsTruncated &&
result.Items.Count != LegacyExpertSelectionService.MaximumResults)
{
throw new ExpertSelectionDataException("The expert search response correlation is invalid.");
}
var states = new List<ExpertState>(result.Items.Count);
var codes = new HashSet<string>(StringComparer.Ordinal);
var names = new HashSet<string>(StringComparer.Ordinal);
ExpertSelectionIdentity? previous = null;
for (var index = 0; index < result.Items.Count; index++)
{
var item = result.Items[index] ??
throw new ExpertSelectionDataException("The expert search row is missing.");
if (item.Source != DataSourceKind.Oracle)
{
throw new ExpertSelectionDataException("Only Oracle expert identities are selectable.");
}
ValidateExpertIdentity(item.Identity);
if (!codes.Add(item.Identity.ExpertCode) || !names.Add(item.Identity.ExpertName) ||
previous is not null && CompareIdentities(previous, item.Identity) > 0)
{
throw new ExpertSelectionDataException(
"Expert identities must be unique and sorted by name then code.");
}
states.Add(new ExpertState(
$"expert-result-{generation:D4}-{index + 1:D4}",
item.Identity));
previous = item.Identity;
}
return states;
}
private static List<ExpertRecommendationPreview> ValidatePreviewResult(
ExpertPreviewResult result,
ExpertSelectionIdentity expectedIdentity)
{
ArgumentNullException.ThrowIfNull(result);
ValidateExpertIdentity(result.Identity);
if (result.Identity != expectedIdentity || result.Items is null ||
result.Items.Count > MaximumAccessibleItems ||
result.IsTruncated && result.Items.Count != MaximumAccessibleItems)
{
throw new ExpertSelectionDataException("The expert preview response correlation is invalid.");
}
var items = new List<ExpertRecommendationPreview>(result.Items.Count);
var indexes = new HashSet<int>();
var codes = new HashSet<string>(StringComparer.Ordinal);
var names = new HashSet<string>(StringComparer.Ordinal);
var previousIndex = -1;
foreach (var item in result.Items)
{
if (item is null || item.PlayIndex is < 0 or > 9_999 ||
item.PlayIndex <= previousIndex ||
!IsAsciiAlphaNumeric(item.StockCode, 32) ||
!IsCanonicalText(item.StockName, 200) ||
item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m ||
decimal.Truncate(item.BuyAmount) != item.BuyAmount ||
!indexes.Add(item.PlayIndex) ||
!codes.Add(item.StockCode) ||
!names.Add(item.StockName))
{
throw new ExpertSelectionDataException(
"The expert preview contains an invalid or duplicate recommendation.");
}
items.Add(item);
previousIndex = item.PlayIndex;
}
return items;
}
private static void ValidateExpertIdentity(ExpertSelectionIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
if (identity.ExpertCode.Length != 4 ||
identity.ExpertCode.Any(static character => !char.IsAsciiDigit(character)) ||
!IsCanonicalText(identity.ExpertName, 128))
{
throw new ExpertSelectionDataException("The expert identity is invalid.");
}
}
private static int CompareIdentities(
ExpertSelectionIdentity left,
ExpertSelectionIdentity right)
{
var name = string.Compare(left.ExpertName, right.ExpertName, StringComparison.Ordinal);
return name != 0
? name
: string.Compare(left.ExpertCode, right.ExpertCode, StringComparison.Ordinal);
}
private static LegacyExpertResultView ToView(ExpertState state) =>
new(state.SelectionId, state.Identity.ExpertCode, state.Identity.ExpertName);
private static string NormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsSafeText(query))
{
throw new ArgumentException("The expert query contains an unsafe character.", nameof(query));
}
var normalized = query.Trim();
if (normalized.Length > LegacyExpertSelectionService.MaximumQueryLength ||
!IsSafeText(normalized))
{
throw new ArgumentException("The expert query exceeds 64 safe characters.", nameof(query));
}
return normalized;
}
private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) =>
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
value.All(static character => char.IsAsciiLetterOrDigit(character));
private static bool IsCanonicalText(string? value, int maximumLength) =>
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value);
private static bool IsSafeText(string value)
{
foreach (var character in value)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private void ResetPreview()
{
_previewItems = [];
_previewTruncated = false;
_previewStatus = LegacyExpertRequestStatus.Idle;
_previewError = string.Empty;
}
private void Touch() => _revision++;
private sealed record ExpertState(
string SelectionId,
ExpertSelectionIdentity Identity);
}

View File

@@ -0,0 +1,809 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyFixedMarket
{
Overseas,
Exchange,
Index
}
public enum LegacyFixedCategory
{
Plate,
Market,
Chart
}
public sealed record LegacyFixedSelection(
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string DataCode);
public readonly record struct LegacyMovingAverageSelection(bool Ma5, bool Ma20);
public sealed record LegacyFixedRuntimeAsset(
LegacyFixedMarket Market,
string RelativePath,
string Sha256);
public sealed class LegacyFixedAction
{
internal LegacyFixedAction(
string id,
LegacyFixedMarket market,
string section,
string label,
string detail,
LegacyFixedCategory category,
string builderKey,
string code,
IReadOnlyList<string> aliases,
bool available,
string prerequisite,
LegacyFixedSelection selection,
bool dynamicDate,
bool dynamicNxtSession)
{
Id = id;
Market = market;
Section = section;
Label = label;
Detail = detail;
Category = category;
BuilderKey = builderKey;
Code = code;
Aliases = aliases;
Available = available;
Prerequisite = prerequisite;
Selection = selection;
DynamicDate = dynamicDate;
DynamicNxtSession = dynamicNxtSession;
}
public string Id { get; }
public LegacyFixedMarket Market { get; }
public string Section { get; }
public string Label { get; }
public string Detail { get; }
public LegacyFixedCategory Category { get; }
public string BuilderKey { get; }
public string Code { get; }
public IReadOnlyList<string> Aliases { get; }
public bool Available { get; }
public string Prerequisite { get; }
public LegacyFixedSelection Selection { get; }
public bool DynamicDate { get; }
public bool DynamicNxtSession { get; }
public bool SupportsMovingAverages =>
string.Equals(BuilderKey, "s8010", StringComparison.Ordinal);
}
public sealed record LegacyMaterializedFixedAction(
LegacyFixedAction Action,
LegacyFixedSelection Selection,
LegacyMovingAverageSelection MovingAverages,
LegacyFixedRuntimeAsset RuntimeAsset);
public sealed class LegacyFixedSection
{
internal LegacyFixedSection(
LegacyFixedMarket market,
string name,
IReadOnlyList<LegacyFixedAction> actions)
{
Market = market;
Name = name;
Actions = actions;
}
public LegacyFixedMarket Market { get; }
public string Name { get; }
public IReadOnlyList<LegacyFixedAction> Actions { get; }
}
/// <summary>
/// C#-authoritative catalog for the three fixed legacy UC1 tabs. The embedded JSON is a
/// generated build asset; callers can select only by the opaque action id and cannot provide
/// builder, cut alias, or scene-selection values.
/// </summary>
public sealed class LegacyFixedActionCatalog
{
public const int ExpectedActionCount = 328;
public const int ExpectedOverseasCount = 78;
public const int ExpectedExchangeCount = 46;
public const int ExpectedIndexCount = 204;
public const int ExpectedAvailableCount = 322;
public const string ExpectedDocumentSha256 =
"E56FB9DE8247672022EE2F324AF4C37179EC58E399758425AD4C29DAF1BDDC56";
public const string ExpectedGeneratorSourceSha256 =
"31D1B0F81A53FF47700D179E45BEFCA55342D11EAD3A58E67F6A926607A874B5";
private const string ResourceName =
"MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json";
private const string ExpectedGeneratedFrom = "Web/legacy-fixed-workflow.js";
private static readonly JsonSerializerOptions StrictJsonOptions = new()
{
AllowTrailingCommas = false,
PropertyNameCaseInsensitive = false,
ReadCommentHandling = JsonCommentHandling.Disallow,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
};
private static readonly Lazy<LegacyFixedActionCatalog> EmbeddedCatalog =
new(LoadEmbedded, LazyThreadSafetyMode.ExecutionAndPublication);
private readonly IReadOnlyDictionary<string, LegacyFixedAction> _byId;
private readonly IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>>
_byMarket;
private readonly IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedSection>>
_sectionsByMarket;
private readonly IReadOnlyDictionary<(LegacyFixedMarket Market, string Section),
IReadOnlyList<LegacyFixedAction>> _bySection;
private LegacyFixedActionCatalog(
IReadOnlyList<LegacyFixedAction> actions,
IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset> runtimeAssets,
string documentSha256,
string generatorSourceSha256)
{
Actions = actions;
RuntimeAssets = runtimeAssets;
DocumentSha256 = documentSha256;
GeneratorSourceSha256 = generatorSourceSha256;
_byId = new ReadOnlyDictionary<string, LegacyFixedAction>(
actions.ToDictionary(action => action.Id, StringComparer.Ordinal));
_byMarket = new ReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>>(
Enum.GetValues<LegacyFixedMarket>().ToDictionary(
market => market,
market => ReadOnly(actions.Where(action => action.Market == market))));
var sectionsByMarket = new Dictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedSection>>();
var bySection = new Dictionary<(LegacyFixedMarket Market, string Section),
IReadOnlyList<LegacyFixedAction>>();
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
{
var sections = _byMarket[market]
.GroupBy(action => action.Section, StringComparer.Ordinal)
.Select(group =>
{
var sectionActions = ReadOnly(group);
bySection.Add((market, group.Key), sectionActions);
return new LegacyFixedSection(market, group.Key, sectionActions);
});
sectionsByMarket.Add(market, ReadOnly(sections));
}
_sectionsByMarket =
new ReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedSection>>(
sectionsByMarket);
_bySection = new ReadOnlyDictionary<(LegacyFixedMarket Market, string Section),
IReadOnlyList<LegacyFixedAction>>(bySection);
}
public static LegacyFixedActionCatalog Default => EmbeddedCatalog.Value;
public IReadOnlyList<LegacyFixedAction> Actions { get; }
public IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset> RuntimeAssets { get; }
public string DocumentSha256 { get; }
public string GeneratorSourceSha256 { get; }
public IReadOnlyList<LegacyFixedAction> GetActions(LegacyFixedMarket market)
{
RequireMarket(market);
return _byMarket[market];
}
public IReadOnlyList<LegacyFixedSection> GetSections(LegacyFixedMarket market)
{
RequireMarket(market);
return _sectionsByMarket[market];
}
public IReadOnlyList<LegacyFixedAction> GetActions(
LegacyFixedMarket market,
string section)
{
RequireMarket(market);
ArgumentException.ThrowIfNullOrWhiteSpace(section);
return _bySection.TryGetValue((market, section), out var actions)
? actions
: Array.Empty<LegacyFixedAction>();
}
public LegacyFixedAction? FindAction(string? actionId) =>
actionId is not null && _byId.TryGetValue(actionId, out var action)
? action
: null;
public LegacyFixedAction GetAction(string actionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
return _byId.TryGetValue(actionId, out var action)
? action
: throw new KeyNotFoundException($"Unknown fixed legacy action id '{actionId}'.");
}
public LegacyFixedRuntimeAsset GetRuntimeAsset(LegacyFixedMarket market)
{
RequireMarket(market);
return RuntimeAssets[market];
}
public LegacyMaterializedFixedAction Materialize(
string actionId,
DateTimeOffset localNow,
LegacyMovingAverageSelection movingAverages = default)
{
var action = GetAction(actionId);
if (!action.Available)
{
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;
var dataCode = action.DynamicDate
? localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
: action.Selection.DataCode;
var selection = action.Selection with
{
GraphicType = graphicType,
DataCode = dataCode
};
return new LegacyMaterializedFixedAction(
action,
selection,
trustedMovingAverages,
RuntimeAssets[action.Market]);
}
internal static LegacyFixedActionCatalog LoadForValidation(
ReadOnlyMemory<byte> utf8Json,
string? expectedDocumentSha256 = null) =>
Load(utf8Json.ToArray(), expectedDocumentSha256);
private static LegacyFixedActionCatalog LoadEmbedded()
{
using var stream = typeof(LegacyFixedActionCatalog).Assembly
.GetManifestResourceStream(ResourceName)
?? throw new InvalidDataException(
$"The embedded fixed legacy catalog '{ResourceName}' is missing.");
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
return Load(buffer.ToArray(), ExpectedDocumentSha256);
}
private static LegacyFixedActionCatalog Load(byte[] utf8Json, string? expectedDocumentSha256)
{
ArgumentNullException.ThrowIfNull(utf8Json);
if (utf8Json.Length == 0)
{
throw new InvalidDataException("The fixed legacy catalog is empty.");
}
CatalogDocumentDto document;
try
{
using var parsed = JsonDocument.Parse(utf8Json, new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow
});
RejectDuplicateProperties(parsed.RootElement, "$root");
document = JsonSerializer.Deserialize<CatalogDocumentDto>(utf8Json, StrictJsonOptions)
?? throw new InvalidDataException("The fixed legacy catalog root is null.");
}
catch (JsonException exception)
{
throw new InvalidDataException("The fixed legacy catalog JSON schema is invalid.", exception);
}
var documentHash = Convert.ToHexString(SHA256.HashData(utf8Json));
if (expectedDocumentSha256 is not null &&
!string.Equals(documentHash, expectedDocumentSha256, StringComparison.Ordinal))
{
throw new InvalidDataException(
$"The fixed legacy catalog hash is invalid. Expected {expectedDocumentSha256}; actual {documentHash}.");
}
ValidateDocumentMetadata(document);
var runtimeAssets = CreateRuntimeAssets(document.RuntimeAssets);
var actions = CreateActions(document.Actions);
ValidateCatalogInvariants(actions);
return new LegacyFixedActionCatalog(
ReadOnly(actions),
runtimeAssets,
documentHash,
document.SourceSha256);
}
private static void ValidateDocumentMetadata(CatalogDocumentDto document)
{
if (document.SchemaVersion != 1)
{
throw Invalid("schemaVersion must be 1");
}
if (!string.Equals(document.GeneratedFrom, ExpectedGeneratedFrom, StringComparison.Ordinal))
{
throw Invalid($"generatedFrom must be '{ExpectedGeneratedFrom}'");
}
if (!string.Equals(
document.SourceSha256,
ExpectedGeneratorSourceSha256,
StringComparison.Ordinal))
{
throw Invalid("the generator source hash is not approved");
}
if (document.RuntimeAssets is null)
{
throw Invalid("runtimeAssets is required");
}
if (document.Actions is null)
{
throw Invalid("actions is required");
}
}
private static IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset>
CreateRuntimeAssets(RuntimeAssetsDto runtimeAssets)
{
var assets = new Dictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset>
{
[LegacyFixedMarket.Overseas] = ValidateRuntimeAsset(
LegacyFixedMarket.Overseas,
runtimeAssets.Overseas,
"bin/Debug/Res/해외.ini",
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
[LegacyFixedMarket.Exchange] = ValidateRuntimeAsset(
LegacyFixedMarket.Exchange,
runtimeAssets.Exchange,
"bin/Debug/Res/환율.ini",
"7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
[LegacyFixedMarket.Index] = ValidateRuntimeAsset(
LegacyFixedMarket.Index,
runtimeAssets.Index,
"bin/Debug/Res/지수.ini",
"E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
};
return new ReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset>(assets);
}
private static LegacyFixedRuntimeAsset ValidateRuntimeAsset(
LegacyFixedMarket market,
RuntimeAssetDto asset,
string expectedPath,
string expectedSha256)
{
if (asset is null ||
!string.Equals(asset.RelativePath, expectedPath, StringComparison.Ordinal) ||
!string.Equals(asset.Sha256, expectedSha256, StringComparison.Ordinal))
{
throw Invalid($"runtime asset metadata for '{MarketText(market)}' is invalid");
}
RequireSha256(asset.Sha256, $"runtimeAssets.{MarketText(market)}.sha256");
return new LegacyFixedRuntimeAsset(market, asset.RelativePath, asset.Sha256);
}
private static LegacyFixedAction[] CreateActions(IReadOnlyList<ActionDto> source)
{
if (source.Count != ExpectedActionCount)
{
throw Invalid($"exactly {ExpectedActionCount} actions are required");
}
var actions = new LegacyFixedAction[source.Count];
for (var index = 0; index < source.Count; index++)
{
var dto = source[index]
?? throw Invalid($"action at index {index} is null");
var expectedId = $"fixed-{index + 1:000}";
if (!string.Equals(dto.Id, expectedId, StringComparison.Ordinal))
{
throw Invalid($"action id at index {index} must be '{expectedId}'");
}
var market = ParseMarket(dto.Market);
var category = ParseCategory(dto.Category);
var section = RequireText(dto.Section, $"{expectedId}.section");
var label = RequireText(dto.Label, $"{expectedId}.label");
var detail = RequireText(dto.Detail, $"{expectedId}.detail");
var builderKey = RequireText(dto.BuilderKey, $"{expectedId}.builderKey");
var code = RequireText(dto.Code, $"{expectedId}.code");
ValidateSceneIdentity(expectedId, builderKey, code, dto.Aliases);
var selection = ValidateSelection(expectedId, dto);
var prerequisite = RequireText(
dto.Prerequisite,
$"{expectedId}.prerequisite",
allowEmpty: true,
maximumLength: 512);
if (dto.Available != string.IsNullOrEmpty(prerequisite))
{
throw Invalid($"{expectedId} availability and prerequisite do not agree");
}
actions[index] = new LegacyFixedAction(
expectedId,
market,
section,
label,
detail,
category,
builderKey,
code,
Array.AsReadOnly(dto.Aliases.ToArray()),
dto.Available,
prerequisite,
selection,
dto.DynamicDate,
dto.DynamicNxtSession);
}
return actions;
}
private static void ValidateSceneIdentity(
string actionId,
string builderKey,
string code,
IReadOnlyList<string> aliases)
{
if (aliases is null)
{
throw Invalid($"{actionId}.aliases is required");
}
var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate =>
string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal))
?? throw Invalid($"{actionId} has unknown builder '{builderKey}'");
if (!definition.CutAliases.Contains(code, StringComparer.Ordinal))
{
throw Invalid($"{actionId} code '{code}' is not an alias of '{builderKey}'");
}
if (aliases.Count != 1 || !string.Equals(aliases[0], code, StringComparison.Ordinal))
{
throw Invalid($"{actionId} must declare exactly its code as its sole alias");
}
var aliasMatches = LegacySceneCatalog.FindByCutAlias(code);
if (!aliasMatches.Any(candidate =>
string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal)))
{
throw Invalid($"{actionId} alias '{code}' does not resolve to '{builderKey}'");
}
}
private static LegacyFixedSelection ValidateSelection(string actionId, ActionDto action)
{
var source = action.Selection
?? throw Invalid($"{actionId}.selection is required");
var selection = new LegacyFixedSelection(
RequireText(source.GroupCode, $"{actionId}.selection.groupCode"),
RequireText(source.Subject, $"{actionId}.selection.subject", allowEmpty: true),
RequireText(source.GraphicType, $"{actionId}.selection.graphicType", allowEmpty: true),
RequireText(source.Subtype, $"{actionId}.selection.subtype", allowEmpty: true),
RequireText(source.DataCode, $"{actionId}.selection.dataCode", allowEmpty: true));
if (action.DynamicDate && action.DynamicNxtSession)
{
throw Invalid($"{actionId} cannot have two dynamic selection fields");
}
if (action.DynamicDate !=
string.Equals(selection.DataCode, "$TODAY", StringComparison.Ordinal))
{
throw Invalid($"{actionId} has an invalid dynamic-date declaration");
}
if (action.DynamicNxtSession !=
string.Equals(selection.GraphicType, "$NXT_SESSION", StringComparison.Ordinal))
{
throw Invalid($"{actionId} has an invalid NXT-session declaration");
}
if (string.Equals(action.BuilderKey, "s8010", StringComparison.Ordinal) &&
!string.IsNullOrEmpty(selection.GraphicType))
{
throw Invalid($"{actionId} moving-average flags must be materialized by C#");
}
var values = new[]
{
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode
};
if (values.Any(value => value.Contains('$', StringComparison.Ordinal)) &&
!action.DynamicDate && !action.DynamicNxtSession)
{
throw Invalid($"{actionId} contains an unapproved selection placeholder");
}
return selection;
}
private static void ValidateCatalogInvariants(IReadOnlyList<LegacyFixedAction> actions)
{
RequireCount(actions, LegacyFixedMarket.Overseas, ExpectedOverseasCount);
RequireCount(actions, LegacyFixedMarket.Exchange, ExpectedExchangeCount);
RequireCount(actions, LegacyFixedMarket.Index, ExpectedIndexCount);
if (actions.Count(action => action.Available) != ExpectedAvailableCount)
{
throw Invalid($"exactly {ExpectedAvailableCount} actions must be available");
}
if (actions.Take(ExpectedOverseasCount).Any(action =>
action.Market != LegacyFixedMarket.Overseas) ||
actions.Skip(ExpectedOverseasCount).Take(ExpectedExchangeCount).Any(action =>
action.Market != LegacyFixedMarket.Exchange) ||
actions.Skip(ExpectedOverseasCount + ExpectedExchangeCount).Any(action =>
action.Market != LegacyFixedMarket.Index))
{
throw Invalid("market action ranges are not in the approved legacy order");
}
}
private static void RequireCount(
IEnumerable<LegacyFixedAction> actions,
LegacyFixedMarket market,
int expected)
{
var actual = actions.Count(action => action.Market == market);
if (actual != expected)
{
throw Invalid(
$"market '{MarketText(market)}' must contain {expected} actions, not {actual}");
}
}
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,
"exchange" => LegacyFixedMarket.Exchange,
"index" => LegacyFixedMarket.Index,
_ => throw Invalid($"unknown market '{value}'")
};
private static LegacyFixedCategory ParseCategory(string value) => value switch
{
"plate" => LegacyFixedCategory.Plate,
"market" => LegacyFixedCategory.Market,
"chart" => LegacyFixedCategory.Chart,
_ => throw Invalid($"unknown category '{value}'")
};
private static string MarketText(LegacyFixedMarket market) => market switch
{
LegacyFixedMarket.Overseas => "overseas",
LegacyFixedMarket.Exchange => "exchange",
LegacyFixedMarket.Index => "index",
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.")
};
private static void RequireMarket(LegacyFixedMarket market)
{
if (!Enum.IsDefined(market))
{
throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.");
}
}
private static string RequireText(
string value,
string field,
bool allowEmpty = false,
int maximumLength = 256)
{
if (value is null || (!allowEmpty && value.Length == 0) ||
value.Length > maximumLength || value.Any(char.IsControl) ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal))
{
throw Invalid($"{field} is invalid");
}
return value;
}
private static void RequireSha256(string value, string field)
{
if (value is null || value.Length != 64 || value.Any(character =>
!(char.IsAsciiDigit(character) || character is >= 'A' and <= 'F')))
{
throw Invalid($"{field} is not an uppercase SHA-256 value");
}
}
private static void RejectDuplicateProperties(JsonElement element, string path)
{
if (element.ValueKind == JsonValueKind.Object)
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (!names.Add(property.Name))
{
throw new JsonException($"Duplicate property '{property.Name}' at {path}.");
}
RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
var index = 0;
foreach (var item in element.EnumerateArray())
{
RejectDuplicateProperties(item, $"{path}[{index++}]");
}
}
}
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> source) =>
Array.AsReadOnly(source.ToArray());
private static InvalidDataException Invalid(string message) =>
new($"The fixed legacy catalog is invalid: {message}.");
private sealed class CatalogDocumentDto
{
[JsonPropertyName("schemaVersion")]
public required int SchemaVersion { get; init; }
[JsonPropertyName("generatedFrom")]
public required string GeneratedFrom { get; init; }
[JsonPropertyName("sourceSha256")]
public required string SourceSha256 { get; init; }
[JsonPropertyName("runtimeAssets")]
public required RuntimeAssetsDto RuntimeAssets { get; init; }
[JsonPropertyName("actions")]
public required List<ActionDto> Actions { get; init; }
}
private sealed class RuntimeAssetsDto
{
[JsonPropertyName("overseas")]
public required RuntimeAssetDto Overseas { get; init; }
[JsonPropertyName("exchange")]
public required RuntimeAssetDto Exchange { get; init; }
[JsonPropertyName("index")]
public required RuntimeAssetDto Index { get; init; }
}
private sealed class RuntimeAssetDto
{
[JsonPropertyName("relativePath")]
public required string RelativePath { get; init; }
[JsonPropertyName("sha256")]
public required string Sha256 { get; init; }
}
private sealed class ActionDto
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("market")]
public required string Market { get; init; }
[JsonPropertyName("section")]
public required string Section { get; init; }
[JsonPropertyName("label")]
public required string Label { get; init; }
[JsonPropertyName("detail")]
public required string Detail { get; init; }
[JsonPropertyName("category")]
public required string Category { get; init; }
[JsonPropertyName("builderKey")]
public required string BuilderKey { get; init; }
[JsonPropertyName("code")]
public required string Code { get; init; }
[JsonPropertyName("aliases")]
public required List<string> Aliases { get; init; }
[JsonPropertyName("available")]
public required bool Available { get; init; }
[JsonPropertyName("prerequisite")]
public required string Prerequisite { get; init; }
[JsonPropertyName("selection")]
public required SelectionDto Selection { get; init; }
[JsonPropertyName("dynamicDate")]
public required bool DynamicDate { get; init; }
[JsonPropertyName("dynamicNxtSession")]
public required bool DynamicNxtSession { get; init; }
}
private sealed class SelectionDto
{
[JsonPropertyName("groupCode")]
public required string GroupCode { get; init; }
[JsonPropertyName("subject")]
public required string Subject { get; init; }
[JsonPropertyName("graphicType")]
public required string GraphicType { get; init; }
[JsonPropertyName("subtype")]
public required string Subtype { get; init; }
[JsonPropertyName("dataCode")]
public required string DataCode { get; init; }
}
}

View File

@@ -0,0 +1,267 @@
using MMoneyCoderSharp.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyIndustryActionKind
{
Single,
Pair,
Yield,
Candle,
FiveRow,
Square,
Sector
}
public enum LegacyIndustryCandleMode
{
Price,
Volume
}
public sealed record LegacyIndustryRuntimeAsset(
IndustryMarket Market,
string RelativePath,
string Sha256);
public sealed record LegacyIndustryActionDefinition(
IndustryMarket Market,
string ActionId,
string Label,
string Section,
string Category,
LegacyIndustryActionKind Kind,
string BuilderKey,
string SceneAlias,
int PageSize,
string? YieldPeriod = null,
LegacyIndustryCandleMode? CandleMode = null);
/// <summary>
/// Closed projection of the two runtime industry INI files. The WebView supplies an
/// action id only; market-specific aliases and page sizes remain authoritative in C#.
/// </summary>
public static class LegacyIndustryActionCatalog
{
public const int ActionsPerMarket = 22;
private static readonly LegacyIndustryRuntimeAsset KospiAsset = new(
IndustryMarket.Kospi,
"bin/Debug/Res/업종_코스피.ini",
"F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52");
private static readonly LegacyIndustryRuntimeAsset KosdaqAsset = new(
IndustryMarket.Kosdaq,
"bin/Debug/Res/업종_코스닥.ini",
"F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4");
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> KospiActions =
CreateActions(IndustryMarket.Kospi);
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> KosdaqActions =
CreateActions(IndustryMarket.Kosdaq);
private static readonly IReadOnlyDictionary<string, LegacyIndustryActionDefinition>
KospiActionsById = CreateLookup(KospiActions);
private static readonly IReadOnlyDictionary<string, LegacyIndustryActionDefinition>
KosdaqActionsById = CreateLookup(KosdaqActions);
public static LegacyIndustryRuntimeAsset GetRuntimeAsset(IndustryMarket market) => market switch
{
IndustryMarket.Kospi => KospiAsset,
IndustryMarket.Kosdaq => KosdaqAsset,
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
};
public static IReadOnlyList<LegacyIndustryActionDefinition> GetActions(
IndustryMarket market) => market switch
{
IndustryMarket.Kospi => KospiActions,
IndustryMarket.Kosdaq => KosdaqActions,
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
};
public static bool TryGetAction(
IndustryMarket market,
string actionId,
out LegacyIndustryActionDefinition? definition)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsSafeActionId(actionId))
{
definition = null;
return false;
}
return LookupFor(market).TryGetValue(actionId, out definition);
}
public static LegacyIndustryActionDefinition GetRequiredAction(
IndustryMarket market,
string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
return IsSafeActionId(actionId) &&
LookupFor(market).TryGetValue(actionId, out var definition)
? definition
: throw new KeyNotFoundException("The industry action id is not registered.");
}
private static IReadOnlyDictionary<string, LegacyIndustryActionDefinition> LookupFor(
IndustryMarket market) => market switch
{
IndustryMarket.Kospi => KospiActionsById,
IndustryMarket.Kosdaq => KosdaqActionsById,
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
};
private static bool IsSafeActionId(string actionId) =>
actionId.Length is > 0 and <= 64 &&
!actionId.Any(char.IsControl) &&
string.Equals(actionId, actionId.Trim(), StringComparison.Ordinal);
private static IReadOnlyList<LegacyIndustryActionDefinition> CreateActions(
IndustryMarket market)
{
var pairAlias = market == IndustryMarket.Kospi ? "8018" : "8032";
var squareAlias = market == IndustryMarket.Kospi ? "8001" : "8002";
var actions = new List<LegacyIndustryActionDefinition>(ActionsPerMarket)
{
Action(market, "one-column", "1열판", "업종 판", "plate",
LegacyIndustryActionKind.Single, "s5001", "5001"),
Action(market, "two-column", "2열판", "업종 판", "plate",
LegacyIndustryActionKind.Pair, "s8018", pairAlias)
};
AddYield(actions, market, "5일", "FiveDays");
AddYield(actions, market, "20일", "TwentyDays");
AddYield(actions, market, "60일", "SixtyDays");
AddYield(actions, market, "120일", "OneHundredTwentyDays");
AddYield(actions, market, "240일", "TwoHundredFortyDays");
AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Price);
AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Price);
AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Price);
AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Price);
AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Price);
AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Price);
AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Volume);
AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Volume);
AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Volume);
AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Volume);
AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Volume);
AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Volume);
actions.Add(Action(market, "five-row", "5단 표그래프", "시장 전체", "plate",
LegacyIndustryActionKind.FiveRow, "s5074", "5074", pageSize: 5));
actions.Add(Action(market, "square", "네모그래프", "시장 전체", "plate",
LegacyIndustryActionKind.Square, "s8001", squareAlias));
actions.Add(Action(market, "sector", "섹터지수", "시장 전체", "plate",
LegacyIndustryActionKind.Sector, "s5078", "5078"));
Validate(actions, market);
return Array.AsReadOnly(actions.ToArray());
}
private static void AddYield(
ICollection<LegacyIndustryActionDefinition> actions,
IndustryMarket market,
string periodLabel,
string period)
{
actions.Add(Action(
market,
$"yield-{periodLabel}",
$"수익률그래프_{periodLabel}",
"수익률 그래프",
"chart",
LegacyIndustryActionKind.Yield,
"s5086",
"5086",
yieldPeriod: period));
}
private static void AddCandle(
ICollection<LegacyIndustryActionDefinition> actions,
IndustryMarket market,
string periodLabel,
string sceneAlias,
LegacyIndustryCandleMode mode)
{
var volume = mode == LegacyIndustryCandleMode.Volume;
actions.Add(Action(
market,
$"candle-{(volume ? "volume-" : string.Empty)}{periodLabel}",
$"캔들그래프{(volume ? "()" : string.Empty)}_{periodLabel}",
volume ? "캔들 그래프 · 거래량" : "캔들 그래프",
"chart",
LegacyIndustryActionKind.Candle,
"s8010",
sceneAlias,
candleMode: mode));
}
private static LegacyIndustryActionDefinition Action(
IndustryMarket market,
string actionId,
string label,
string section,
string category,
LegacyIndustryActionKind kind,
string builderKey,
string sceneAlias,
int pageSize = 0,
string? yieldPeriod = null,
LegacyIndustryCandleMode? candleMode = null) => new(
market,
actionId,
label,
section,
category,
kind,
builderKey,
sceneAlias,
pageSize,
yieldPeriod,
candleMode);
private static IReadOnlyDictionary<string, LegacyIndustryActionDefinition> CreateLookup(
IReadOnlyList<LegacyIndustryActionDefinition> actions) =>
new System.Collections.ObjectModel.ReadOnlyDictionary<
string,
LegacyIndustryActionDefinition>(actions.ToDictionary(
action => action.ActionId,
StringComparer.Ordinal));
private static void Validate(
IReadOnlyCollection<LegacyIndustryActionDefinition> actions,
IndustryMarket market)
{
if (actions.Count != ActionsPerMarket ||
actions.Select(action => action.ActionId).Distinct(StringComparer.Ordinal).Count() !=
ActionsPerMarket ||
actions.Any(action => action.Market != market) ||
actions.Any(action => action.PageSize is not (0 or 5)) ||
actions.Any(action =>
(action.Kind == LegacyIndustryActionKind.FiveRow) != (action.PageSize == 5)) ||
actions.Any(action => action.Kind == LegacyIndustryActionKind.Yield &&
string.IsNullOrEmpty(action.YieldPeriod)) ||
actions.Any(action => action.Kind != LegacyIndustryActionKind.Yield &&
action.YieldPeriod is not null) ||
actions.Any(action => action.Kind == LegacyIndustryActionKind.Candle &&
action.CandleMode is null) ||
actions.Any(action => action.Kind != LegacyIndustryActionKind.Candle &&
action.CandleMode is not null) ||
actions.Any(action => !IsSafeActionId(action.ActionId)) ||
actions.Any(action => !LegacySceneCatalog.FindByCutAlias(action.SceneAlias)
.Any(scene =>
string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) &&
(int)scene.PageSize == action.PageSize)))
{
throw new InvalidOperationException("The legacy industry action catalog is invalid.");
}
}
}

View File

@@ -0,0 +1,442 @@
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public sealed record LegacyIndustrySelectionSnapshot(
long Revision,
IndustryMarket? Market,
IReadOnlyList<IndustrySelection> Industries,
IReadOnlyList<LegacyIndustryActionDefinition> Actions,
int? SelectedIndex,
IndustrySelection? SelectedIndustry,
IndustrySelection? FirstIndustry,
IndustrySelection? SecondIndustry);
public sealed record LegacyIndustrySceneSelectionDraft(
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string DataCode);
/// <summary>
/// Safe, generic playlist fields produced entirely from the C# catalog and the
/// authoritative UC2 selection state. It intentionally has no caller-supplied row id.
/// </summary>
public sealed record LegacyIndustryPlaylistDraft(
string ActionId,
string BuilderKey,
string SceneAlias,
int PageSize,
string Title,
string Detail,
string Category,
IndustryMarket Market,
LegacyIndustrySceneSelectionDraft Selection,
string RuntimeAssetRelativePath,
string RuntimeAssetSha256,
bool IsEnabled,
int FadeDuration);
public sealed class LegacyIndustryWorkflowException : Exception
{
public LegacyIndustryWorkflowException(string message) : base(message)
{
}
}
/// <summary>
/// C# authority for the legacy UC2 selector and the UC1 industry actions.
/// </summary>
public sealed class LegacyIndustrySelectionWorkflow
{
private const int DefaultFadeDuration = 6;
private static readonly IReadOnlyList<IndustrySelection> NoIndustries =
Array.AsReadOnly(Array.Empty<IndustrySelection>());
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> NoActions =
Array.AsReadOnly(Array.Empty<LegacyIndustryActionDefinition>());
private readonly IIndustrySelectionService _selectionService;
private readonly TimeProvider _timeProvider;
private IReadOnlyList<IndustrySelection> _industries = NoIndustries;
private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions;
private IndustryMarket? _market;
private int? _selectedIndex;
private IndustrySelection? _selectedIndustry;
private IndustrySelection? _firstIndustry;
private IndustrySelection? _secondIndustry;
private long _revision;
private long _loadGeneration;
public LegacyIndustrySelectionWorkflow(
IIndustrySelectionService selectionService,
TimeProvider? timeProvider = null)
{
_selectionService = selectionService ??
throw new ArgumentNullException(nameof(selectionService));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
public async Task<LegacyIndustrySelectionSnapshot> LoadMarketAsync(
IndustryMarket market,
CancellationToken cancellationToken = default)
{
ValidateMarket(market);
var generation = Interlocked.Increment(ref _loadGeneration);
var serviceRows = await _selectionService.GetAsync(market, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
// A slower previous request must never replace a newer market request.
if (generation != Volatile.Read(ref _loadGeneration))
{
return CreateSnapshot();
}
var normalizedRows = NormalizeRows(serviceRows, market);
if (generation != Volatile.Read(ref _loadGeneration))
{
return CreateSnapshot();
}
var changingMarket = _market != market;
var previousSelected = changingMarket ? null : _selectedIndustry;
var previousFirst = changingMarket ? null : _firstIndustry;
var previousSecond = changingMarket ? null : _secondIndustry;
_market = market;
_industries = normalizedRows;
_actions = LegacyIndustryActionCatalog.GetActions(market);
_selectedIndustry = Reconcile(previousSelected, normalizedRows);
_selectedIndex = _selectedIndustry is null
? null
: FindIdentityIndex(normalizedRows, _selectedIndustry);
_firstIndustry = Reconcile(previousFirst, normalizedRows);
_secondIndustry = Reconcile(previousSecond, normalizedRows);
Touch();
return CreateSnapshot();
}
public LegacyIndustrySelectionSnapshot SelectIndustry(int index)
{
if (!TryGetIndustry(index, out var industry))
{
return CreateSnapshot();
}
_selectedIndex = index;
_selectedIndustry = industry;
Touch();
return CreateSnapshot();
}
public LegacyIndustrySelectionSnapshot DoubleClickIndustry(int index)
{
if (!TryGetIndustry(index, out var industry))
{
return CreateSnapshot();
}
_selectedIndex = index;
_selectedIndustry = industry;
// UC2.listbox_DoubleClick copied txt1 to txt2 before assigning the newly
// double-clicked item to txt1.
_secondIndustry = _firstIndustry;
_firstIndustry = industry;
Touch();
return CreateSnapshot();
}
public LegacyIndustrySelectionSnapshot SwapIndustries()
{
if (_firstIndustry is null && _secondIndustry is null)
{
return CreateSnapshot();
}
(_firstIndustry, _secondIndustry) = (_secondIndustry, _firstIndustry);
Touch();
return CreateSnapshot();
}
public LegacyIndustrySelectionSnapshot ClearSelections()
{
if (_selectedIndustry is null && _firstIndustry is null && _secondIndustry is null)
{
return CreateSnapshot();
}
_selectedIndex = null;
_selectedIndustry = null;
_firstIndustry = null;
_secondIndustry = null;
Touch();
return CreateSnapshot();
}
public LegacyIndustryPlaylistDraft MaterializePlaylistDraft(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (_market is not { } market)
{
throw new LegacyIndustryWorkflowException("Select an industry market first.");
}
if (!LegacyIndustryActionCatalog.TryGetAction(market, actionId, out var definition) ||
definition is null)
{
throw new LegacyIndustryWorkflowException("The industry action is not registered.");
}
var projection = ProjectSelection(definition, market);
var asset = LegacyIndustryActionCatalog.GetRuntimeAsset(market);
return new LegacyIndustryPlaylistDraft(
definition.ActionId,
definition.BuilderKey,
definition.SceneAlias,
definition.PageSize,
projection.Title,
definition.Label,
definition.Category,
market,
projection.Selection,
asset.RelativePath,
asset.Sha256,
IsEnabled: true,
FadeDuration: DefaultFadeDuration);
}
private IndustryProjection ProjectSelection(
LegacyIndustryActionDefinition definition,
IndustryMarket market)
{
var industryGroup = market == IndustryMarket.Kospi
? "INDUSTRY_KOSPI"
: "INDUSTRY_KOSDAQ";
var marketName = market == IndustryMarket.Kospi ? "KOSPI" : "KOSDAQ";
return definition.Kind switch
{
LegacyIndustryActionKind.Single => ForSelected(
definition,
industryGroup,
"1열판",
"CURRENT"),
LegacyIndustryActionKind.Pair => ForPair(definition, industryGroup),
LegacyIndustryActionKind.Yield => ForSelected(
definition,
industryGroup,
"YIELD_GRAPH",
definition.YieldPeriod!),
LegacyIndustryActionKind.Candle => ForSelected(
definition,
market == IndustryMarket.Kospi ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY",
string.Empty,
definition.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE"),
LegacyIndustryActionKind.FiveRow => new IndustryProjection(
$"{MarketLabel(market)} {definition.Label}",
new LegacyIndustrySceneSelectionDraft(
market == IndustryMarket.Kospi
? "PAGED_DOMESTIC_KOSPI"
: "PAGED_DOMESTIC_KOSDAQ",
"INDEX",
string.Empty,
"INDUSTRY",
CurrentLocalDate())),
LegacyIndustryActionKind.Square => new IndustryProjection(
$"{MarketLabel(market)} {definition.Label}",
new LegacyIndustrySceneSelectionDraft(
industryGroup,
marketName,
"네모그래프",
string.Empty,
string.Empty)),
LegacyIndustryActionKind.Sector => new IndustryProjection(
$"{MarketLabel(market)} {definition.Label}",
new LegacyIndustrySceneSelectionDraft(
industryGroup,
marketName,
"SECTOR_INDEX",
string.Empty,
string.Empty)),
_ => throw new LegacyIndustryWorkflowException("The industry action kind is unsupported.")
};
}
private IndustryProjection ForSelected(
LegacyIndustryActionDefinition definition,
string groupCode,
string graphicType,
string subtype)
{
if (_selectedIndustry is not { } selected)
{
throw new LegacyIndustryWorkflowException(
$"The {definition.ActionId} action requires one selected industry.");
}
return new IndustryProjection(
selected.IndustryName,
new LegacyIndustrySceneSelectionDraft(
groupCode,
selected.IndustryName,
graphicType,
subtype,
string.Empty));
}
private IndustryProjection ForPair(
LegacyIndustryActionDefinition definition,
string groupCode)
{
if (_firstIndustry is not { } first || _secondIndustry is not { } second ||
first.IndustryName.Contains('-', StringComparison.Ordinal) ||
second.IndustryName.Contains('-', StringComparison.Ordinal))
{
throw new LegacyIndustryWorkflowException(
$"The {definition.ActionId} action requires two delimiter-safe industries.");
}
var subject = $"{first.IndustryName}-{second.IndustryName}";
return new IndustryProjection(
subject,
new LegacyIndustrySceneSelectionDraft(
groupCode,
subject,
"2열판",
string.Empty,
string.Empty));
}
private string CurrentLocalDate()
{
var local = _timeProvider.GetLocalNow();
return local.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
private bool TryGetIndustry(int index, out IndustrySelection industry)
{
if (index < 0 || index >= _industries.Count)
{
industry = null!;
return false;
}
industry = _industries[index];
return true;
}
private static IReadOnlyList<IndustrySelection> NormalizeRows(
IReadOnlyList<IndustrySelection>? rows,
IndustryMarket market)
{
if (rows is null || rows.Count > LegacyIndustrySelectionService.MaximumResults)
{
throw new LegacyIndustryWorkflowException("The industry list is invalid.");
}
var names = new HashSet<string>(StringComparer.Ordinal);
var codes = new HashSet<string>(StringComparer.Ordinal);
var normalized = new IndustrySelection[rows.Count];
for (var index = 0; index < rows.Count; index++)
{
var row = rows[index];
if (row is null || row.Market != market)
{
throw new LegacyIndustryWorkflowException(
"The industry list contains a cross-market row.");
}
var name = row.IndustryName?.Trim();
var code = row.IndustryCode?.Trim();
if (string.IsNullOrEmpty(name) ||
name.Length > LegacyIndustrySelectionService.MaximumNameLength ||
name.Any(char.IsControl) ||
string.IsNullOrEmpty(code) ||
code.Length > 32 ||
code.Any(character => !char.IsAsciiLetterOrDigit(character)) ||
!names.Add(name) ||
!codes.Add(code))
{
throw new LegacyIndustryWorkflowException(
"The industry list contains an unsafe or ambiguous row.");
}
normalized[index] = new IndustrySelection(market, name, code);
}
return Array.AsReadOnly(normalized);
}
private static IndustrySelection? Reconcile(
IndustrySelection? previous,
IReadOnlyList<IndustrySelection> rows)
{
if (previous is null)
{
return null;
}
return rows.FirstOrDefault(row => SameIdentity(row, previous));
}
private static int? FindIdentityIndex(
IReadOnlyList<IndustrySelection> rows,
IndustrySelection selected)
{
for (var index = 0; index < rows.Count; index++)
{
if (SameIdentity(rows[index], selected))
{
return index;
}
}
return null;
}
private static bool SameIdentity(IndustrySelection left, IndustrySelection right) =>
left.Market == right.Market &&
string.Equals(left.IndustryName, right.IndustryName, StringComparison.Ordinal) &&
string.Equals(left.IndustryCode, right.IndustryCode, StringComparison.Ordinal);
private static void ValidateMarket(IndustryMarket market)
{
if (market is not (IndustryMarket.Kospi or IndustryMarket.Kosdaq))
{
throw new ArgumentOutOfRangeException(
nameof(market),
market,
"Unsupported industry market.");
}
}
private static string MarketLabel(IndustryMarket market) => market switch
{
IndustryMarket.Kospi => "코스피",
IndustryMarket.Kosdaq => "코스닥",
_ => throw new ArgumentOutOfRangeException(nameof(market), market, null)
};
private LegacyIndustrySelectionSnapshot CreateSnapshot() => new(
_revision,
_market,
_industries,
_actions,
_selectedIndex,
_selectedIndustry,
_firstIndustry,
_secondIndustry);
private void Touch() => _revision++;
private sealed record IndustryProjection(
string Title,
LegacyIndustrySceneSelectionDraft Selection);
}

View File

@@ -0,0 +1,56 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyOperatorPlayoutPhase
{
Idle,
Prepared,
Program
}
public enum LegacyOperatorPlayoutCommand
{
Prepare,
TakeIn,
Next,
TakeOut
}
public sealed record LegacyOperatorPlayoutRequest(
IReadOnlyList<LegacyPlaylistEntry> Playlist,
int SelectedIndexZeroBased);
public sealed record LegacyOperatorPlayoutSnapshot(
PlayoutMode Mode,
PlayoutConnectionState ConnectionState,
LegacyOperatorPlayoutPhase Phase,
bool IsProcessRunning,
bool IsConnected,
bool IsCommandAvailable,
bool LiveTakeInAllowed,
bool IsPlayCompletionPending,
bool OutcomeUnknown,
string? PreparedCode,
string? OnAirCode,
int CurrentCueIndexZeroBased,
string? CurrentEntryId,
string? BuilderKey,
int PageIndexZeroBased,
int PageCount,
int PageSize,
int ItemCount,
int CurrentPageItemCount,
bool IsLastPage,
LegacyWorkflowNextKind NextKind,
bool IsBusy,
bool RefreshActive,
int RefreshCompletedCount,
int? RefreshMaximumCount,
bool RefreshLimitReached,
int FadeDuration,
bool BackgroundEnabled,
string BackgroundFileName,
bool CanChangeBackground,
string Message);

View File

@@ -0,0 +1,283 @@
using System.Collections.ObjectModel;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyOverseasActionSource
{
FixedIndex,
Industry,
Stock
}
public enum LegacyOverseasActionKind
{
Current,
Candle
}
public sealed class LegacyOverseasFixedIndexTarget
{
internal LegacyOverseasFixedIndexTarget(
string targetId,
string displayName,
string inputName,
string symbol,
string nationCode)
{
TargetId = targetId;
DisplayName = displayName;
InputName = inputName;
Symbol = symbol;
NationCode = nationCode;
}
public string TargetId { get; }
public string DisplayName { get; }
public string InputName { get; }
public string Symbol { get; }
public string NationCode { get; }
public string ForeignDomesticCode => "0";
}
public sealed class LegacyOverseasActionDefinition
{
internal LegacyOverseasActionDefinition(
string actionId,
string label,
LegacyOverseasActionSource source,
LegacyOverseasActionKind kind,
string builderKey,
string sceneAlias,
int? periodDays)
{
ActionId = actionId;
Label = label;
Source = source;
Kind = kind;
BuilderKey = builderKey;
SceneAlias = sceneAlias;
Aliases = Array.AsReadOnly([sceneAlias]);
PeriodDays = periodDays;
}
public string ActionId { get; }
public string Label { get; }
public LegacyOverseasActionSource Source { get; }
public LegacyOverseasActionKind Kind { get; }
public string BuilderKey { get; }
public string SceneAlias { get; }
public IReadOnlyList<string> Aliases { get; }
public int PageSize => 0;
public int? PeriodDays { get; }
public bool SupportsMovingAverages => Kind == LegacyOverseasActionKind.Candle;
}
/// <summary>
/// Closed UC5 metadata. The original control exposed ten fixed world indices and twelve
/// buttons: five index candles, one US-industry current plate, and six US/TW stock actions.
/// Scene identities never come from Web input.
/// </summary>
public static class LegacyOverseasActionCatalog
{
public const int FixedIndexTargetCount = 10;
public const int ActionCount = 12;
private static readonly IReadOnlyList<LegacyOverseasFixedIndexTarget> RegisteredTargets =
Array.AsReadOnly<LegacyOverseasFixedIndexTarget>(
[
Target("dow-jones", "다우존스", "DJI@DJI", "US"),
Target("nasdaq", "나스닥", "NAS@IXIC", "US"),
Target("sp500", "S&P500", "SPI@SPX", "US"),
Target("germany-dax-30", "독일 DAX 30", "XTR@DAX30", "DE"),
Target("united-kingdom-ftse-100", "영국 FTSE 100", "LNS@FTSE100", "GB"),
Target("france-cac-40", "프랑스 CAC 40", "PAS@CAC40", "FR"),
Target("japan-nikkei", "일본 니케이", "NII@NI225", "JP"),
Target("china-shanghai-composite", "중국 상해종합", "SHS@000001", "CN"),
Target("hong-kong-hang-seng", "홍콩 항셍", "HSI@HSI", "HK"),
Target("taiwan-weighted", "대만 가권", "TWS@TI01", "TW")
]);
private static readonly IReadOnlyList<LegacyOverseasActionDefinition> RegisteredActions =
Array.AsReadOnly(CreateActions());
private static readonly IReadOnlyDictionary<string, LegacyOverseasFixedIndexTarget>
TargetsById = new ReadOnlyDictionary<string, LegacyOverseasFixedIndexTarget>(
RegisteredTargets.ToDictionary(target => target.TargetId, StringComparer.Ordinal));
private static readonly IReadOnlyDictionary<string, LegacyOverseasActionDefinition>
ActionsById = new ReadOnlyDictionary<string, LegacyOverseasActionDefinition>(
RegisteredActions.ToDictionary(action => action.ActionId, StringComparer.Ordinal));
static LegacyOverseasActionCatalog()
{
ValidateTargets();
ValidateActions();
}
public static IReadOnlyList<LegacyOverseasFixedIndexTarget> FixedIndexTargets =>
RegisteredTargets;
public static IReadOnlyList<LegacyOverseasActionDefinition> Actions => RegisteredActions;
public static IReadOnlyList<LegacyOverseasActionDefinition> GetActions(
LegacyOverseasActionSource source)
{
if (!Enum.IsDefined(source))
{
throw new ArgumentOutOfRangeException(nameof(source), source, "Unknown overseas source.");
}
return Array.AsReadOnly(
RegisteredActions.Where(action => action.Source == source).ToArray());
}
public static LegacyOverseasFixedIndexTarget? FindFixedIndexTarget(string? targetId) =>
targetId is not null && TargetsById.TryGetValue(targetId, out var target)
? target
: null;
public static LegacyOverseasFixedIndexTarget GetFixedIndexTarget(string targetId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
return TargetsById.TryGetValue(targetId, out var target)
? target
: throw new KeyNotFoundException("The overseas fixed-index target is not registered.");
}
public static LegacyOverseasActionDefinition? FindAction(string? actionId) =>
actionId is not null && ActionsById.TryGetValue(actionId, out var action)
? action
: null;
public static LegacyOverseasActionDefinition GetAction(string actionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
return ActionsById.TryGetValue(actionId, out var action)
? action
: throw new KeyNotFoundException("The overseas action is not registered.");
}
private static LegacyOverseasFixedIndexTarget Target(
string targetId,
string inputName,
string symbol,
string nationCode) =>
new(targetId, inputName, inputName, symbol, nationCode);
private static LegacyOverseasActionDefinition[] CreateActions() =>
[
Candle("index-candle-5d", "해외지수 캔들 5일", LegacyOverseasActionSource.FixedIndex, 5, "8061"),
Candle("index-candle-20d", "해외지수 캔들 20일", LegacyOverseasActionSource.FixedIndex, 20, "8040"),
Candle("index-candle-60d", "해외지수 캔들 60일", LegacyOverseasActionSource.FixedIndex, 60, "8046"),
Candle("index-candle-120d", "해외지수 캔들 120일", LegacyOverseasActionSource.FixedIndex, 120, "8051"),
Candle("index-candle-240d", "해외지수 캔들 240일", LegacyOverseasActionSource.FixedIndex, 240, "8056"),
new(
"industry-current",
"미국 해외업종 현재가 1열판",
LegacyOverseasActionSource.Industry,
LegacyOverseasActionKind.Current,
"s5001",
"5001",
periodDays: null),
new(
"stock-current",
"해외종목 현재가 1열판",
LegacyOverseasActionSource.Stock,
LegacyOverseasActionKind.Current,
"s5001",
"5001",
periodDays: null),
Candle("stock-candle-5d", "해외종목 캔들 5일", LegacyOverseasActionSource.Stock, 5, "8061"),
Candle("stock-candle-20d", "해외종목 캔들 20일", LegacyOverseasActionSource.Stock, 20, "8040"),
Candle("stock-candle-60d", "해외종목 캔들 60일", LegacyOverseasActionSource.Stock, 60, "8046"),
Candle("stock-candle-120d", "해외종목 캔들 120일", LegacyOverseasActionSource.Stock, 120, "8051"),
Candle("stock-candle-240d", "해외종목 캔들 240일", LegacyOverseasActionSource.Stock, 240, "8056")
];
private static LegacyOverseasActionDefinition Candle(
string actionId,
string label,
LegacyOverseasActionSource source,
int periodDays,
string sceneAlias) =>
new(
actionId,
label,
source,
LegacyOverseasActionKind.Candle,
"s8010",
sceneAlias,
periodDays);
private static void ValidateTargets()
{
if (RegisteredTargets.Count != FixedIndexTargetCount ||
RegisteredTargets.Select(target => target.TargetId)
.Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
RegisteredTargets.Select(target => target.DisplayName)
.Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
RegisteredTargets.Select(target => target.Symbol)
.Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
RegisteredTargets.Any(target =>
!IsSafeId(target.TargetId) ||
!IsSafeCanonicalText(target.DisplayName, 200) ||
!string.Equals(target.DisplayName, target.InputName, StringComparison.Ordinal) ||
!IsSafeSymbol(target.Symbol) ||
target.NationCode.Length != 2 ||
target.NationCode.Any(character => character is < 'A' or > 'Z')))
{
throw new InvalidOperationException("The legacy overseas fixed-index catalog is invalid.");
}
}
private static void ValidateActions()
{
if (RegisteredActions.Count != ActionCount ||
RegisteredActions.Select(action => action.ActionId)
.Distinct(StringComparer.Ordinal).Count() != ActionCount ||
RegisteredActions.Any(action => !IsSafeId(action.ActionId)) ||
RegisteredActions.Any(action => action.PageSize != 0) ||
RegisteredActions.Any(action =>
action.Kind == LegacyOverseasActionKind.Candle != action.PeriodDays.HasValue) ||
RegisteredActions.Any(action =>
action.Source == LegacyOverseasActionSource.Industry &&
action.Kind != LegacyOverseasActionKind.Current) ||
RegisteredActions.Any(action =>
!LegacySceneCatalog.FindByCutAlias(action.SceneAlias).Any(scene =>
string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) &&
(int)scene.PageSize == action.PageSize)))
{
throw new InvalidOperationException("The legacy overseas action catalog is invalid.");
}
}
private static bool IsSafeId(string value) =>
value.Length is > 0 and <= 64 &&
value.All(character => char.IsAsciiLetterOrDigit(character) || character == '-');
private static bool IsSafeCanonicalText(string value, int maximumLength) =>
value.Length is > 0 &&
value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
!value.Any(character => char.IsControl(character) || char.IsSurrogate(character));
private static bool IsSafeSymbol(string value) =>
IsSafeCanonicalText(value, 64) &&
value.All(character =>
char.IsAsciiLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-');
}

View File

@@ -0,0 +1,897 @@
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyOverseasSearchStatus
{
Idle,
Loading,
Ready,
Error
}
public readonly record struct LegacyOverseasMovingAverageSelection(bool Ma5, bool Ma20);
public sealed class LegacyOverseasIndustrySearchRow
{
internal LegacyOverseasIndustrySearchRow(
string selectionId,
OverseasIndustryIndexItem identity)
{
SelectionId = selectionId;
Identity = identity;
}
public string SelectionId { get; }
public string KoreanName => Identity.KoreanName;
public string InputName => Identity.InputName;
public string Symbol => Identity.Symbol;
public string NationCode => "US";
public string ForeignDomesticCode => "0";
internal OverseasIndustryIndexItem Identity { get; }
}
public sealed class LegacyOverseasStockSearchRow
{
internal LegacyOverseasStockSearchRow(
string selectionId,
WorldStockSearchItem identity)
{
SelectionId = selectionId;
Identity = identity;
}
public string SelectionId { get; }
public string InputName => Identity.InputName;
public string Symbol => Identity.Symbol;
public string NationCode => Identity.NationCode;
public string ForeignDomesticCode => "1";
internal WorldStockSearchItem Identity { get; }
}
public sealed class LegacyOverseasIndustrySearchSnapshot
{
internal LegacyOverseasIndustrySearchSnapshot(
LegacyOverseasSearchStatus status,
string query,
DateTimeOffset? retrievedAt,
IReadOnlyList<LegacyOverseasIndustrySearchRow> results,
bool isTruncated,
LegacyOverseasIndustrySearchRow? selected,
string error)
{
Status = status;
Query = query;
RetrievedAt = retrievedAt;
Results = results;
IsTruncated = isTruncated;
Selected = selected;
Error = error;
}
public LegacyOverseasSearchStatus Status { get; }
public string Query { get; }
public DateTimeOffset? RetrievedAt { get; }
public IReadOnlyList<LegacyOverseasIndustrySearchRow> Results { get; }
public int ResultCount => Results.Count;
public bool IsTruncated { get; }
public LegacyOverseasIndustrySearchRow? Selected { get; }
public string? SelectedId => Selected?.SelectionId;
public string Error { get; }
}
public sealed class LegacyOverseasStockSearchSnapshot
{
internal LegacyOverseasStockSearchSnapshot(
LegacyOverseasSearchStatus status,
string query,
DateTimeOffset? retrievedAt,
IReadOnlyList<LegacyOverseasStockSearchRow> results,
bool isTruncated,
LegacyOverseasStockSearchRow? selected,
string error)
{
Status = status;
Query = query;
RetrievedAt = retrievedAt;
Results = results;
IsTruncated = isTruncated;
Selected = selected;
Error = error;
}
public LegacyOverseasSearchStatus Status { get; }
public string Query { get; }
public DateTimeOffset? RetrievedAt { get; }
public IReadOnlyList<LegacyOverseasStockSearchRow> Results { get; }
public int ResultCount => Results.Count;
public bool IsTruncated { get; }
public LegacyOverseasStockSearchRow? Selected { get; }
public string? SelectedId => Selected?.SelectionId;
public string Error { get; }
}
public sealed class LegacyOverseasWorkflowSnapshot
{
internal LegacyOverseasWorkflowSnapshot(
long revision,
IReadOnlyList<LegacyOverseasFixedIndexTarget> fixedIndexTargets,
LegacyOverseasFixedIndexTarget? selectedFixedIndex,
LegacyOverseasIndustrySearchSnapshot industry,
LegacyOverseasStockSearchSnapshot stock,
LegacyOverseasMovingAverageSelection movingAverages,
IReadOnlyList<LegacyOverseasActionDefinition> actions)
{
Revision = revision;
FixedIndexTargets = fixedIndexTargets;
SelectedFixedIndex = selectedFixedIndex;
Industry = industry;
Stock = stock;
MovingAverages = movingAverages;
Actions = actions;
}
public long Revision { get; }
public IReadOnlyList<LegacyOverseasFixedIndexTarget> FixedIndexTargets { get; }
public LegacyOverseasFixedIndexTarget? SelectedFixedIndex { get; }
public LegacyOverseasIndustrySearchSnapshot Industry { get; }
public LegacyOverseasStockSearchSnapshot Stock { get; }
public LegacyOverseasMovingAverageSelection MovingAverages { get; }
public IReadOnlyList<LegacyOverseasActionDefinition> Actions { get; }
}
public sealed record LegacyOverseasPlaylistSelection(
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string DataCode);
public sealed record LegacyOverseasPlaylistDraft(
string ActionId,
string BuilderKey,
string SceneAlias,
IReadOnlyList<string> Aliases,
int PageSize,
string InitialPageText,
string Title,
string Detail,
string Category,
string Market,
LegacyOverseasActionSource Source,
LegacyOverseasPlaylistSelection Selection,
LegacyOverseasMovingAverageSelection MovingAverages,
bool IsEnabled,
int FadeDuration);
public sealed class LegacyOverseasWorkflowException : Exception
{
public LegacyOverseasWorkflowException(string message)
: base(message)
{
}
}
/// <summary>
/// C# authority for the UC5 overseas selector. Search services return identities, while Web
/// receives generation-bound selection ids and closed action ids only. A slower response can
/// never replace the state established by a newer request of the same source.
/// </summary>
public sealed class LegacyOverseasSelectionWorkflow
{
public const int DefaultMaximumResults = 100;
public const int MaximumResults = 500;
public const int MaximumQueryLength = 64;
private const int DefaultFadeDuration = 6;
private const string InitialPageText = "1/1";
private const string IndustryError = "미국 해외업종 검색 결과를 안전하게 확인하지 못했습니다.";
private const string StockError = "미국·대만 해외종목 검색 결과를 안전하게 확인하지 못했습니다.";
private static readonly IReadOnlyList<LegacyOverseasIndustrySearchRow> NoIndustryResults =
Array.AsReadOnly(Array.Empty<LegacyOverseasIndustrySearchRow>());
private static readonly IReadOnlyList<LegacyOverseasStockSearchRow> NoStockResults =
Array.AsReadOnly(Array.Empty<LegacyOverseasStockSearchRow>());
private readonly IOverseasIndustryIndexSearchService _industrySearchService;
private readonly IWorldStockSearchService _stockSearchService;
private readonly object _sync = new();
private LegacyOverseasFixedIndexTarget? _selectedFixedIndex;
private LegacyOverseasSearchStatus _industryStatus;
private string _industryQuery = string.Empty;
private DateTimeOffset? _industryRetrievedAt;
private IReadOnlyList<LegacyOverseasIndustrySearchRow> _industryResults = NoIndustryResults;
private bool _industryIsTruncated;
private LegacyOverseasIndustrySearchRow? _selectedIndustry;
private string _industryError = string.Empty;
private long _industryGeneration;
private LegacyOverseasSearchStatus _stockStatus;
private string _stockQuery = string.Empty;
private DateTimeOffset? _stockRetrievedAt;
private IReadOnlyList<LegacyOverseasStockSearchRow> _stockResults = NoStockResults;
private bool _stockIsTruncated;
private LegacyOverseasStockSearchRow? _selectedStock;
private string _stockError = string.Empty;
private long _stockGeneration;
private LegacyOverseasMovingAverageSelection _movingAverages;
private long _revision;
public LegacyOverseasSelectionWorkflow(
IOverseasIndustryIndexSearchService industrySearchService,
IWorldStockSearchService stockSearchService)
{
_industrySearchService = industrySearchService ??
throw new ArgumentNullException(nameof(industrySearchService));
_stockSearchService = stockSearchService ??
throw new ArgumentNullException(nameof(stockSearchService));
}
public LegacyOverseasWorkflowSnapshot Current
{
get
{
lock (_sync)
{
return CreateSnapshot();
}
}
}
public LegacyOverseasWorkflowSnapshot SelectFixedIndex(string targetId)
{
var target = LegacyOverseasActionCatalog.GetFixedIndexTarget(targetId);
lock (_sync)
{
if (!ReferenceEquals(_selectedFixedIndex, target))
{
_selectedFixedIndex = target;
Touch();
}
return CreateSnapshot();
}
}
public async Task<LegacyOverseasWorkflowSnapshot> SearchIndustriesAsync(
string query,
int maximumResults = DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var normalizedQuery = NormalizeQuery(query);
ValidateMaximumResults(maximumResults);
cancellationToken.ThrowIfCancellationRequested();
long generation;
lock (_sync)
{
generation = checked(++_industryGeneration);
_industryStatus = LegacyOverseasSearchStatus.Loading;
_industryQuery = normalizedQuery;
_industryRetrievedAt = null;
_industryResults = NoIndustryResults;
_industryIsTruncated = false;
_selectedIndustry = null;
_industryError = string.Empty;
Touch();
}
try
{
var result = await _industrySearchService.SearchAsync(
normalizedQuery,
maximumResults,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
lock (_sync)
{
if (generation != _industryGeneration)
{
return CreateSnapshot();
}
}
var validated = ValidateIndustryResult(
result,
normalizedQuery,
maximumResults,
generation);
lock (_sync)
{
if (generation != _industryGeneration)
{
return CreateSnapshot();
}
_industryStatus = LegacyOverseasSearchStatus.Ready;
_industryRetrievedAt = result.RetrievedAt;
_industryResults = validated;
_industryIsTruncated = result.IsTruncated;
_industryError = string.Empty;
Touch();
return CreateSnapshot();
}
}
catch (OperationCanceledException)
{
lock (_sync)
{
if (generation == _industryGeneration)
{
_industryStatus = LegacyOverseasSearchStatus.Idle;
_industryError = string.Empty;
Touch();
}
}
throw;
}
catch
{
lock (_sync)
{
if (generation != _industryGeneration)
{
return CreateSnapshot();
}
_industryStatus = LegacyOverseasSearchStatus.Error;
_industryRetrievedAt = null;
_industryResults = NoIndustryResults;
_industryIsTruncated = false;
_selectedIndustry = null;
_industryError = IndustryError;
Touch();
}
throw;
}
}
public async Task<LegacyOverseasWorkflowSnapshot> SearchStocksAsync(
string query,
int maximumResults = DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var normalizedQuery = NormalizeQuery(query);
ValidateMaximumResults(maximumResults);
cancellationToken.ThrowIfCancellationRequested();
long generation;
lock (_sync)
{
generation = checked(++_stockGeneration);
_stockStatus = LegacyOverseasSearchStatus.Loading;
_stockQuery = normalizedQuery;
_stockRetrievedAt = null;
_stockResults = NoStockResults;
_stockIsTruncated = false;
_selectedStock = null;
_stockError = string.Empty;
Touch();
}
try
{
var result = await _stockSearchService.SearchAsync(
normalizedQuery,
maximumResults,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
lock (_sync)
{
if (generation != _stockGeneration)
{
return CreateSnapshot();
}
}
var validated = ValidateStockResult(
result,
normalizedQuery,
maximumResults,
generation);
lock (_sync)
{
if (generation != _stockGeneration)
{
return CreateSnapshot();
}
_stockStatus = LegacyOverseasSearchStatus.Ready;
_stockRetrievedAt = result.RetrievedAt;
_stockResults = validated;
_stockIsTruncated = result.IsTruncated;
_stockError = string.Empty;
Touch();
return CreateSnapshot();
}
}
catch (OperationCanceledException)
{
lock (_sync)
{
if (generation == _stockGeneration)
{
_stockStatus = LegacyOverseasSearchStatus.Idle;
_stockError = string.Empty;
Touch();
}
}
throw;
}
catch
{
lock (_sync)
{
if (generation != _stockGeneration)
{
return CreateSnapshot();
}
_stockStatus = LegacyOverseasSearchStatus.Error;
_stockRetrievedAt = null;
_stockResults = NoStockResults;
_stockIsTruncated = false;
_selectedStock = null;
_stockError = StockError;
Touch();
}
throw;
}
}
public LegacyOverseasWorkflowSnapshot SelectIndustry(string selectionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
lock (_sync)
{
var selected = _industryResults.FirstOrDefault(row =>
string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
?? throw new KeyNotFoundException("The overseas-industry result is no longer current.");
if (!ReferenceEquals(_selectedIndustry, selected))
{
_selectedIndustry = selected;
Touch();
}
return CreateSnapshot();
}
}
public LegacyOverseasWorkflowSnapshot SelectStock(string selectionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
lock (_sync)
{
var selected = _stockResults.FirstOrDefault(row =>
string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
?? throw new KeyNotFoundException("The overseas-stock result is no longer current.");
if (!ReferenceEquals(_selectedStock, selected))
{
_selectedStock = selected;
Touch();
}
return CreateSnapshot();
}
}
public LegacyOverseasWorkflowSnapshot ClearFixedIndexSelection()
{
lock (_sync)
{
if (_selectedFixedIndex is not null)
{
_selectedFixedIndex = null;
Touch();
}
return CreateSnapshot();
}
}
public LegacyOverseasWorkflowSnapshot ClearIndustrySelection()
{
lock (_sync)
{
if (_selectedIndustry is not null)
{
_selectedIndustry = null;
Touch();
}
return CreateSnapshot();
}
}
public LegacyOverseasWorkflowSnapshot ClearStockSelection()
{
lock (_sync)
{
if (_selectedStock is not null)
{
_selectedStock = null;
Touch();
}
return CreateSnapshot();
}
}
public LegacyOverseasWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20)
{
var value = new LegacyOverseasMovingAverageSelection(ma5, ma20);
lock (_sync)
{
if (_movingAverages != value)
{
_movingAverages = value;
Touch();
}
return CreateSnapshot();
}
}
/// <summary>
/// Builds all playlist and scene-selection fields from current native state. The caller can
/// select only a closed action id; it cannot supply aliases, data-code identities, paging,
/// moving-average text, or fade values.
/// </summary>
public LegacyOverseasPlaylistDraft MaterializePlaylistDraft(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
var action = LegacyOverseasActionCatalog.FindAction(actionId)
?? throw new LegacyOverseasWorkflowException("The overseas action is not registered.");
lock (_sync)
{
return action.Source switch
{
LegacyOverseasActionSource.FixedIndex => MaterializeFixedIndex(action),
LegacyOverseasActionSource.Industry => MaterializeIndustry(action),
LegacyOverseasActionSource.Stock => MaterializeStock(action),
_ => throw new LegacyOverseasWorkflowException("The overseas action source is invalid.")
};
}
}
private LegacyOverseasPlaylistDraft MaterializeFixedIndex(
LegacyOverseasActionDefinition action)
{
var target = _selectedFixedIndex ??
throw new LegacyOverseasWorkflowException("Select a fixed overseas index first.");
var movingAverages = MovingAveragesFor(action);
return CreateDraft(
action,
$"{target.DisplayName} 캔들 {action.PeriodDays}일",
"chart",
new LegacyOverseasPlaylistSelection(
"FOREIGN_INDEX",
target.InputName,
MovingAverageText(movingAverages),
"PRICE",
$"{target.Symbol}|{target.NationCode}|{target.ForeignDomesticCode}"),
movingAverages);
}
private LegacyOverseasPlaylistDraft MaterializeIndustry(
LegacyOverseasActionDefinition action)
{
var industry = _selectedIndustry ??
throw new LegacyOverseasWorkflowException("Select a US overseas industry first.");
return CreateDraft(
action,
industry.KoreanName,
"plate",
new LegacyOverseasPlaylistSelection(
"FOREIGN_INDUSTRY",
industry.KoreanName,
"1열판기본",
"CURRENT",
$"{industry.InputName}|{industry.Symbol}|{industry.NationCode}|{industry.ForeignDomesticCode}"),
default);
}
private LegacyOverseasPlaylistDraft MaterializeStock(
LegacyOverseasActionDefinition action)
{
var stock = _selectedStock ??
throw new LegacyOverseasWorkflowException("Select a US/TW overseas stock first.");
var movingAverages = MovingAveragesFor(action);
var current = action.Kind == LegacyOverseasActionKind.Current;
return CreateDraft(
action,
stock.InputName,
current ? "stock" : "chart",
new LegacyOverseasPlaylistSelection(
"FOREIGN_STOCK",
stock.InputName,
current ? "1열판기본" : MovingAverageText(movingAverages),
current ? "CURRENT" : "PRICE",
$"{stock.Symbol}|{stock.NationCode}|{stock.ForeignDomesticCode}"),
movingAverages);
}
private static LegacyOverseasPlaylistDraft CreateDraft(
LegacyOverseasActionDefinition action,
string title,
string category,
LegacyOverseasPlaylistSelection selection,
LegacyOverseasMovingAverageSelection movingAverages) =>
new(
action.ActionId,
action.BuilderKey,
action.SceneAlias,
action.Aliases,
action.PageSize,
InitialPageText,
title,
action.Label,
category,
"overseas",
action.Source,
selection,
movingAverages,
IsEnabled: true,
FadeDuration: DefaultFadeDuration);
private LegacyOverseasMovingAverageSelection MovingAveragesFor(
LegacyOverseasActionDefinition action) =>
action.SupportsMovingAverages ? _movingAverages : default;
private static string MovingAverageText(LegacyOverseasMovingAverageSelection selection) =>
selection switch
{
{ Ma5: true, Ma20: true } => "MA5,MA20",
{ Ma5: true } => "MA5",
{ Ma20: true } => "MA20",
_ => string.Empty
};
private LegacyOverseasWorkflowSnapshot CreateSnapshot() =>
new(
_revision,
LegacyOverseasActionCatalog.FixedIndexTargets,
_selectedFixedIndex,
new LegacyOverseasIndustrySearchSnapshot(
_industryStatus,
_industryQuery,
_industryRetrievedAt,
_industryResults,
_industryIsTruncated,
_selectedIndustry,
_industryError),
new LegacyOverseasStockSearchSnapshot(
_stockStatus,
_stockQuery,
_stockRetrievedAt,
_stockResults,
_stockIsTruncated,
_selectedStock,
_stockError),
_movingAverages,
LegacyOverseasActionCatalog.Actions);
private static IReadOnlyList<LegacyOverseasIndustrySearchRow> ValidateIndustryResult(
OverseasIndustryIndexSearchResult? result,
string expectedQuery,
int maximumResults,
long generation)
{
if (result is null || result.Items is null || result.RetrievedAt == default ||
!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
{
throw InvalidData("industry search envelope");
}
var items = result.Items.ToArray();
if (items.Length > maximumResults)
{
throw InvalidData("industry result bound");
}
var koreanNames = new HashSet<string>(StringComparer.Ordinal);
var inputNames = new HashSet<string>(StringComparer.Ordinal);
var symbols = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < items.Length; index++)
{
var item = items[index] ?? throw InvalidData("industry identity");
if (!IsSafeName(item.KoreanName) ||
!IsSafeName(item.InputName) ||
!IsSafeSymbol(item.Symbol) ||
!koreanNames.Add(item.KoreanName) ||
!inputNames.Add(item.InputName) ||
!symbols.Add(item.Symbol) ||
index > 0 && CompareIndustry(items[index - 1], item) > 0)
{
throw InvalidData("industry identity or ordering");
}
}
var rows = items.Select((item, index) =>
new LegacyOverseasIndustrySearchRow(
$"overseas-industry-{generation:D8}-{index + 1:D4}",
item));
return Array.AsReadOnly(rows.ToArray());
}
private static IReadOnlyList<LegacyOverseasStockSearchRow> ValidateStockResult(
WorldStockSearchResult? result,
string expectedQuery,
int maximumResults,
long generation)
{
if (result is null || result.Items is null || result.RetrievedAt == default ||
!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
{
throw InvalidData("stock search envelope");
}
var items = result.Items.ToArray();
if (items.Length > maximumResults)
{
throw InvalidData("stock result bound");
}
var inputNames = new HashSet<string>(StringComparer.Ordinal);
var identities = new HashSet<WorldStockSearchItem>();
for (var index = 0; index < items.Length; index++)
{
var item = items[index] ?? throw InvalidData("stock identity");
if (!IsSafeName(item.InputName) ||
!IsSafeSymbol(item.Symbol) ||
item.NationCode is not ("US" or "TW") ||
!inputNames.Add(item.InputName) ||
!identities.Add(item) ||
index > 0 && CompareStock(items[index - 1], item) > 0)
{
throw InvalidData("stock identity or ordering");
}
}
var rows = items.Select((item, index) =>
new LegacyOverseasStockSearchRow(
$"overseas-stock-{generation:D8}-{index + 1:D4}",
item));
return Array.AsReadOnly(rows.ToArray());
}
private static int CompareIndustry(
OverseasIndustryIndexItem left,
OverseasIndustryIndexItem right)
{
var comparison = CompareUpper(left.KoreanName, right.KoreanName);
if (comparison != 0)
{
return comparison;
}
comparison = string.Compare(left.InputName, right.InputName, StringComparison.Ordinal);
return comparison != 0
? comparison
: string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
}
private static int CompareStock(WorldStockSearchItem left, WorldStockSearchItem right)
{
var comparison = CompareUpper(left.InputName, right.InputName);
if (comparison != 0)
{
return comparison;
}
comparison = string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
return comparison != 0
? comparison
: string.Compare(left.NationCode, right.NationCode, StringComparison.Ordinal);
}
private static int CompareUpper(string left, string right) =>
string.Compare(left.ToUpperInvariant(), right.ToUpperInvariant(), StringComparison.Ordinal);
private static string NormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsSafeText(query))
{
throw new ArgumentException("The overseas search query is invalid.", nameof(query));
}
var normalized = query.Trim();
if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized))
{
throw new ArgumentException(
$"The overseas search query must contain at most {MaximumQueryLength} safe characters.",
nameof(query));
}
return normalized;
}
private static void ValidateMaximumResults(int maximumResults)
{
if (maximumResults is < 1 or > MaximumResults)
{
throw new ArgumentOutOfRangeException(
nameof(maximumResults),
$"Overseas results must be limited from 1 through {MaximumResults}.");
}
}
private static bool IsSafeName(string value) =>
value.Length is > 0 and <= 200 &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
!value.Contains('|', StringComparison.Ordinal) &&
IsSafeText(value);
private static bool IsSafeSymbol(string value) =>
value.Length is > 0 and <= 64 &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
IsSafeText(value) &&
value.All(character =>
char.IsLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-');
private static bool IsSafeText(string value)
{
foreach (var character in value)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private static LegacyOverseasWorkflowException InvalidData(string detail) =>
new($"The overseas selector returned an invalid {detail}.");
private void Touch() => _revision = checked(_revision + 1);
}

View File

@@ -18,18 +18,24 @@ public sealed record LegacyOperatorPlaylistRow(
string PageText,
string StockCode,
string RawCutLabel,
int FadeDuration)
int FadeDuration,
bool IsSelected = false,
string SceneAlias = "",
LegacySceneSelection? PlayoutSelection = null)
{
public LegacyPlaylistEntry ToPlayoutEntry()
{
var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal);
var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt);
var cutCode = SceneAlias.Length > 0
? SceneAlias
: LegacyStockCutCatalog.ResolveCutAlias(
RawCutLabel,
StockName.Contains("(NXT)", StringComparison.Ordinal));
return new LegacyPlaylistEntry(
RowId,
cutCode,
IsEnabled,
FadeDuration,
new LegacySceneSelection(
PlayoutSelection ?? new LegacySceneSelection(
MarketText,
StockName,
GraphicType,

View File

@@ -0,0 +1,822 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyThemeSort
{
InputOrder,
GainRateDescending,
GainRateAscending
}
public enum LegacyThemeValueKind
{
Current,
Expected
}
public sealed record LegacyThemeSortDefinition(
string Id,
string Label,
LegacyThemeSort Value);
public sealed record LegacyThemeActionState(
string ActionId,
bool IsAvailable,
string? UnavailableReason);
public sealed class LegacyThemeActionDefinition
{
internal LegacyThemeActionDefinition(
string id,
string label,
string builderKey,
string code,
int pageSize,
LegacyThemeValueKind valueKind)
{
Id = id;
Label = label;
BuilderKey = builderKey;
Code = code;
Aliases = Array.AsReadOnly([code]);
PageSize = pageSize;
ValueKind = valueKind;
}
public string Id { get; }
public string Label { get; }
public string BuilderKey { get; }
public string Code { get; }
public IReadOnlyList<string> Aliases { get; }
public int PageSize { get; }
public LegacyThemeValueKind ValueKind { get; }
}
public sealed class LegacyThemeSearchRow
{
internal LegacyThemeSearchRow(
string resultId,
ThemeSelectionIdentity identity,
DataSourceKind source)
{
ResultId = resultId;
Identity = identity;
Market = identity.Market;
Session = identity.Session;
DisplayTitle = identity.Market == ThemeMarket.Nxt
? $"{identity.ThemeTitle}(NXT)"
: identity.ThemeTitle;
Source = source;
}
public string ResultId { get; }
public ThemeMarket Market { get; }
public ThemeSession Session { get; }
public string DisplayTitle { get; }
public DataSourceKind Source { get; }
internal ThemeSelectionIdentity Identity { get; }
}
public sealed class LegacyThemePreviewRow
{
internal LegacyThemePreviewRow(ThemeItemPreview item)
{
InputIndex = item.InputIndex;
ItemName = item.ItemName;
ItemCode = item.ItemCode;
}
public int InputIndex { get; }
public string ItemName { get; }
internal string ItemCode { get; }
}
public sealed record LegacySelectedThemeSummary(
ThemeMarket Market,
ThemeSession Session,
string DisplayTitle,
int PreviewItemCount,
bool PreviewIsTruncated);
public sealed class LegacyThemeWorkflowSnapshot
{
internal LegacyThemeWorkflowSnapshot(
object authority,
long revision,
string query,
ThemeSession nxtSession,
LegacyThemeSort sort,
IEnumerable<LegacyThemeSearchRow> searchResults,
bool searchIsTruncated,
string? selectedResultId,
ThemeSelectionIdentity? selectedIdentity,
IEnumerable<LegacyThemePreviewRow> previewItems,
bool previewIsTruncated)
{
Authority = authority;
Revision = revision;
Query = query;
NxtSession = nxtSession;
Sort = sort;
SearchResults = ReadOnly(searchResults);
SearchIsTruncated = searchIsTruncated;
SelectedResultId = selectedResultId;
SelectedIdentity = selectedIdentity;
PreviewItems = ReadOnly(previewItems);
PreviewIsTruncated = previewIsTruncated;
SelectedTheme = selectedIdentity is null
? null
: new LegacySelectedThemeSummary(
selectedIdentity.Market,
selectedIdentity.Session,
selectedIdentity.Market == ThemeMarket.Nxt
? $"{selectedIdentity.ThemeTitle}(NXT)"
: selectedIdentity.ThemeTitle,
PreviewItems.Count,
previewIsTruncated);
ActionStates = LegacyThemeWorkflow.CreateActionStates(
selectedIdentity,
PreviewItems.Count);
}
public long Revision { get; }
public string Query { get; }
public ThemeSession NxtSession { get; }
public LegacyThemeSort Sort { get; }
public string SortId => LegacyThemeWorkflow.SortId(Sort);
public IReadOnlyList<LegacyThemeSearchRow> SearchResults { get; }
public bool SearchIsTruncated { get; }
public string? SelectedResultId { get; }
public LegacySelectedThemeSummary? SelectedTheme { get; }
public IReadOnlyList<LegacyThemePreviewRow> PreviewItems { get; }
public bool PreviewIsTruncated { get; }
public IReadOnlyList<LegacyThemeActionState> ActionStates { get; }
internal object Authority { get; }
internal ThemeSelectionIdentity? SelectedIdentity { get; }
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> source) =>
Array.AsReadOnly(source.ToArray());
}
public sealed record LegacyThemePlaylistSelection(
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string DataCode);
public sealed record LegacyThemePagePlan(
int PreviewItemCount,
int AccessibleItemCount,
int PageSize,
int PageCount,
int MaximumPageCount,
bool IsTruncated,
string InitialPageText);
public sealed record LegacyThemePlaylistDraft(
string ActionId,
string BuilderKey,
string Code,
IReadOnlyList<string> Aliases,
string Title,
string Detail,
string Category,
string Market,
LegacyThemePlaylistSelection Selection,
LegacyThemeValueKind ValueKind,
LegacyThemeSort Sort,
int PageSize,
LegacyThemePagePlan PagePlan);
public sealed class LegacyThemeWorkflowDataException : Exception
{
public LegacyThemeWorkflowDataException(string message)
: base(message)
{
}
}
/// <summary>
/// C#-authoritative migration of the read-only UC4 theme selector. Search and preview data
/// come only from <see cref="IThemeSelectionService"/>; Web callers retain opaque result and
/// action ids and never provide scene codes, resolver groups, theme identities, or PageN data.
/// </summary>
public sealed class LegacyThemeWorkflow
{
public const int MaximumSearchResults = 100;
public const int MaximumPreviewItems = 240;
public const int MaximumPageCount = 20;
private static readonly IReadOnlyList<LegacyThemeActionDefinition> RegisteredActions =
CreateActions();
private static readonly IReadOnlyList<LegacyThemeSortDefinition> RegisteredSorts =
Array.AsReadOnly<LegacyThemeSortDefinition>(
[
new("INPUT_ORDER", "입력순", LegacyThemeSort.InputOrder),
new("GAIN_DESC", "상승률순", LegacyThemeSort.GainRateDescending),
new("GAIN_ASC", "하락률순", LegacyThemeSort.GainRateAscending)
]);
private static readonly IReadOnlyDictionary<string, LegacyThemeActionDefinition> ActionsById =
new ReadOnlyDictionary<string, LegacyThemeActionDefinition>(
RegisteredActions.ToDictionary(action => action.Id, StringComparer.Ordinal));
private readonly IThemeSelectionService _selectionService;
private readonly object _authority = new();
static LegacyThemeWorkflow()
{
ValidateActions(RegisteredActions);
}
public LegacyThemeWorkflow(IThemeSelectionService selectionService)
{
_selectionService = selectionService ??
throw new ArgumentNullException(nameof(selectionService));
}
public static IReadOnlyList<LegacyThemeActionDefinition> Actions => RegisteredActions;
public static IReadOnlyList<LegacyThemeSortDefinition> Sorts => RegisteredSorts;
public LegacyThemeWorkflowSnapshot CreateInitial(
ThemeSession nxtSession = ThemeSession.PreMarket)
{
RequireNxtSession(nxtSession);
return NewSnapshot(
revision: 0,
query: string.Empty,
nxtSession,
LegacyThemeSort.InputOrder,
[],
searchIsTruncated: false,
selectedResultId: null,
selectedIdentity: null,
previewItems: [],
previewIsTruncated: false);
}
public async Task<LegacyThemeWorkflowSnapshot> SearchAsync(
LegacyThemeWorkflowSnapshot snapshot,
string query,
ThemeSession nxtSession,
CancellationToken cancellationToken = default)
{
RequireSnapshot(snapshot);
var normalizedQuery = NormalizeQuery(query);
RequireNxtSession(nxtSession);
cancellationToken.ThrowIfCancellationRequested();
var result = await _selectionService.SearchAsync(
normalizedQuery,
nxtSession,
MaximumSearchResults,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var rows = ValidateSearchResult(result, normalizedQuery, nxtSession);
return NewSnapshot(
checked(snapshot.Revision + 1),
normalizedQuery,
nxtSession,
snapshot.Sort,
rows,
result.IsTruncated,
selectedResultId: null,
selectedIdentity: null,
previewItems: [],
previewIsTruncated: false);
}
public async Task<LegacyThemeWorkflowSnapshot> SelectAsync(
LegacyThemeWorkflowSnapshot snapshot,
string resultId,
CancellationToken cancellationToken = default)
{
RequireSnapshot(snapshot);
ArgumentException.ThrowIfNullOrWhiteSpace(resultId);
var selected = snapshot.SearchResults.FirstOrDefault(row =>
string.Equals(row.ResultId, resultId, StringComparison.Ordinal))
?? throw new KeyNotFoundException($"Unknown theme result id '{resultId}'.");
cancellationToken.ThrowIfCancellationRequested();
var preview = await _selectionService.PreviewAsync(
selected.Identity,
MaximumPreviewItems,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var previewRows = ValidatePreviewResult(preview, selected.Identity);
return NewSnapshot(
checked(snapshot.Revision + 1),
snapshot.Query,
snapshot.NxtSession,
snapshot.Sort,
snapshot.SearchResults,
snapshot.SearchIsTruncated,
selected.ResultId,
selected.Identity,
previewRows,
preview.IsTruncated);
}
public LegacyThemeWorkflowSnapshot SelectSort(
LegacyThemeWorkflowSnapshot snapshot,
string sortId)
{
RequireSnapshot(snapshot);
var sort = ParseSort(sortId);
return NewSnapshot(
checked(snapshot.Revision + 1),
snapshot.Query,
snapshot.NxtSession,
sort,
snapshot.SearchResults,
snapshot.SearchIsTruncated,
snapshot.SelectedResultId,
snapshot.SelectedIdentity,
snapshot.PreviewItems,
snapshot.PreviewIsTruncated);
}
/// <summary>
/// Materializes from a trusted snapshot and a closed action id only. All K3D-facing values,
/// theme identity fields, sort values, and PageN calculations are rebuilt in this method.
/// </summary>
public LegacyThemePlaylistDraft MaterializePlaylistDraft(
LegacyThemeWorkflowSnapshot snapshot,
string actionId)
{
RequireSnapshot(snapshot);
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
var action = ActionsById.TryGetValue(actionId, out var found)
? found
: throw new KeyNotFoundException($"Unknown theme action id '{actionId}'.");
var identity = snapshot.SelectedIdentity
?? throw new InvalidOperationException("A theme must be selected and previewed first.");
if (snapshot.PreviewItems.Count == 0)
{
throw new InvalidOperationException("An empty theme preview cannot be added to the playlist.");
}
if (action.ValueKind == LegacyThemeValueKind.Expected &&
identity.Market == ThemeMarket.Nxt)
{
throw new InvalidOperationException("NXT themes do not provide expected-price data.");
}
var sortId = SortId(snapshot.Sort);
var valueKind = ValueKindId(action.ValueKind);
var displayTitle = identity.Market == ThemeMarket.Nxt
? $"{identity.ThemeTitle}(NXT)"
: identity.ThemeTitle;
var selection = identity.Market == ThemeMarket.Krx
? new LegacyThemePlaylistSelection(
"PAGED_THEME",
identity.ThemeTitle,
sortId,
valueKind,
identity.ThemeCode)
: new LegacyThemePlaylistSelection(
"PAGED_NXT_THEME",
displayTitle,
SessionId(identity.Session),
sortId,
identity.ThemeCode);
var maximumItems = checked(action.PageSize * MaximumPageCount);
var accessibleItems = Math.Min(snapshot.PreviewItems.Count, maximumItems);
var pageCount = (accessibleItems + action.PageSize - 1) / action.PageSize;
var pagePlan = new LegacyThemePagePlan(
snapshot.PreviewItems.Count,
accessibleItems,
action.PageSize,
pageCount,
MaximumPageCount,
snapshot.PreviewIsTruncated || snapshot.PreviewItems.Count > maximumItems,
$"1/{pageCount.ToString(CultureInfo.InvariantCulture)}");
return new LegacyThemePlaylistDraft(
action.Id,
action.BuilderKey,
action.Code,
action.Aliases,
displayTitle,
$"{action.Label} · {SortLabel(snapshot.Sort)}",
"plate",
"theme",
selection,
action.ValueKind,
snapshot.Sort,
action.PageSize,
pagePlan);
}
public static LegacyThemeActionDefinition? FindAction(string? actionId) =>
actionId is not null && ActionsById.TryGetValue(actionId, out var action)
? action
: null;
public LegacyThemeActionState GetActionState(
LegacyThemeWorkflowSnapshot snapshot,
string actionId)
{
RequireSnapshot(snapshot);
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
return snapshot.ActionStates.FirstOrDefault(state =>
string.Equals(state.ActionId, actionId, StringComparison.Ordinal))
?? throw new KeyNotFoundException($"Unknown theme action id '{actionId}'.");
}
public static string SortId(LegacyThemeSort sort) => sort switch
{
LegacyThemeSort.InputOrder => "INPUT_ORDER",
LegacyThemeSort.GainRateDescending => "GAIN_DESC",
LegacyThemeSort.GainRateAscending => "GAIN_ASC",
_ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.")
};
public static string SortLabel(LegacyThemeSort sort) => sort switch
{
LegacyThemeSort.InputOrder => "입력순",
LegacyThemeSort.GainRateDescending => "상승률순",
LegacyThemeSort.GainRateAscending => "하락률순",
_ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.")
};
private LegacyThemeWorkflowSnapshot NewSnapshot(
long revision,
string query,
ThemeSession nxtSession,
LegacyThemeSort sort,
IEnumerable<LegacyThemeSearchRow> searchResults,
bool searchIsTruncated,
string? selectedResultId,
ThemeSelectionIdentity? selectedIdentity,
IEnumerable<LegacyThemePreviewRow> previewItems,
bool previewIsTruncated) =>
new(
_authority,
revision,
query,
nxtSession,
sort,
searchResults,
searchIsTruncated,
selectedResultId,
selectedIdentity,
previewItems,
previewIsTruncated);
private void RequireSnapshot(LegacyThemeWorkflowSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
if (!ReferenceEquals(snapshot.Authority, _authority))
{
throw new ArgumentException(
"The theme snapshot belongs to another workflow instance.",
nameof(snapshot));
}
}
private static IReadOnlyList<LegacyThemeSearchRow> ValidateSearchResult(
ThemeSearchResult result,
string query,
ThemeSession nxtSession)
{
if (result is null || result.Items is null ||
!string.Equals(result.Query, query, StringComparison.Ordinal) ||
result.NxtSession != nxtSession)
{
throw InvalidData("search envelope");
}
var serviceItems = result.Items.ToArray();
if (serviceItems.Length > MaximumSearchResults ||
result.IsTruncated && serviceItems.Length != MaximumSearchResults)
{
throw InvalidData("search envelope");
}
var titles = new Dictionary<ThemeMarket, HashSet<string>>
{
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
};
var codes = new Dictionary<ThemeMarket, HashSet<string>>
{
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
};
foreach (var item in serviceItems)
{
if (item is null)
{
throw InvalidData("search item");
}
ValidateIdentity(item.Identity, nxtSession);
var expectedSource = item.Identity.Market == ThemeMarket.Krx
? DataSourceKind.Oracle
: DataSourceKind.MariaDb;
if (item.Source != expectedSource ||
!titles[item.Identity.Market].Add(item.Identity.ThemeTitle) ||
!codes[item.Identity.Market].Add(item.Identity.ThemeCode))
{
throw InvalidData("search identity or provider");
}
}
var ordered = serviceItems
.OrderBy(item => item.Identity.Market)
.ThenBy(item => item.Identity.ThemeTitle, StringComparer.Ordinal)
.ThenBy(item => item.Identity.ThemeCode, StringComparer.Ordinal)
.ToArray();
var rows = new LegacyThemeSearchRow[ordered.Length];
for (var index = 0; index < ordered.Length; index++)
{
rows[index] = new LegacyThemeSearchRow(
$"theme-result-{index + 1:000}",
ordered[index].Identity,
ordered[index].Source);
}
return Array.AsReadOnly(rows);
}
private static IReadOnlyList<LegacyThemePreviewRow> ValidatePreviewResult(
ThemePreviewResult result,
ThemeSelectionIdentity identity)
{
if (result is null || result.Items is null || result.Identity != identity)
{
throw InvalidData("preview envelope");
}
var serviceItems = result.Items.ToArray();
if (serviceItems.Length > MaximumPreviewItems ||
result.IsTruncated && serviceItems.Length != MaximumPreviewItems)
{
throw InvalidData("preview envelope");
}
var itemCodes = new HashSet<string>(StringComparer.Ordinal);
var indexes = new HashSet<int>();
var previousIndex = -1;
var rows = new LegacyThemePreviewRow[serviceItems.Length];
for (var index = 0; index < serviceItems.Length; index++)
{
var item = serviceItems[index]
?? throw InvalidData("preview item");
if (item.InputIndex is < 0 or > 9_999 || item.InputIndex <= previousIndex ||
!indexes.Add(item.InputIndex) || !itemCodes.Add(item.ItemCode) ||
!IsItemCodeForMarket(item.ItemCode, identity.Market) ||
!IsCanonicalText(item.ItemName, 200))
{
throw InvalidData("preview item identity");
}
previousIndex = item.InputIndex;
rows[index] = new LegacyThemePreviewRow(item);
}
return Array.AsReadOnly(rows);
}
private static void ValidateIdentity(
ThemeSelectionIdentity identity,
ThemeSession requestedNxtSession)
{
if (identity is null || !Enum.IsDefined(identity.Market) ||
!Enum.IsDefined(identity.Session) ||
identity.Market == ThemeMarket.Krx &&
identity.Session != ThemeSession.NotApplicable ||
identity.Market == ThemeMarket.Nxt && identity.Session != requestedNxtSession ||
!IsThemeCode(identity.ThemeCode) ||
!IsCanonicalText(identity.ThemeTitle, 128) ||
identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal))
{
throw InvalidData("theme identity");
}
}
private static string NormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsSafeText(query))
{
throw new ArgumentException("The theme search query is invalid.", nameof(query));
}
var normalized = query.Trim();
if (normalized.Length > LegacyThemeSelectionService.MaximumQueryLength ||
!IsSafeText(normalized))
{
throw new ArgumentException(
$"A theme search query must contain at most {LegacyThemeSelectionService.MaximumQueryLength} visible characters.",
nameof(query));
}
return normalized;
}
private static LegacyThemeSort ParseSort(string sortId)
{
ArgumentNullException.ThrowIfNull(sortId);
return sortId switch
{
"INPUT_ORDER" => LegacyThemeSort.InputOrder,
"GAIN_DESC" => LegacyThemeSort.GainRateDescending,
"GAIN_ASC" => LegacyThemeSort.GainRateAscending,
_ => throw new ArgumentOutOfRangeException(
nameof(sortId),
sortId,
"Unknown theme sort id.")
};
}
private static string SessionId(ThemeSession session) => session switch
{
ThemeSession.PreMarket => "PRE_MARKET",
ThemeSession.AfterMarket => "AFTER_MARKET",
_ => throw new ArgumentOutOfRangeException(
nameof(session),
session,
"NXT requires an explicit session.")
};
private static string ValueKindId(LegacyThemeValueKind valueKind) => valueKind switch
{
LegacyThemeValueKind.Current => "CURRENT",
LegacyThemeValueKind.Expected => "EXPECTED",
_ => throw new ArgumentOutOfRangeException(
nameof(valueKind),
valueKind,
"Unknown theme value kind.")
};
private static void RequireNxtSession(ThemeSession nxtSession)
{
if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
{
throw new ArgumentOutOfRangeException(
nameof(nxtSession),
nxtSession,
"An explicit NXT session is required.");
}
}
private static bool IsThemeCode(string value) =>
value is not null && value.Length is >= 3 and <= 8 && value.All(char.IsAsciiDigit);
private static bool IsItemCodeForMarket(string value, ThemeMarket market)
{
if (value is null || value.Length is < 2 or > 13 || !value.All(char.IsAsciiLetterOrDigit))
{
return false;
}
return market switch
{
ThemeMarket.Krx => value[0] is 'P' or 'D',
ThemeMarket.Nxt => value[0] is 'X' or 'T',
_ => false
};
}
private static bool IsCanonicalText(string value, int maximumLength) =>
value is not null && value.Length is >= 1 &&
value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
IsSafeText(value);
private static bool IsSafeText(string value)
{
foreach (var rune in value.EnumerateRunes())
{
var category = Rune.GetUnicodeCategory(rune);
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private static IReadOnlyList<LegacyThemeActionDefinition> CreateActions() =>
Array.AsReadOnly(
[
new LegacyThemeActionDefinition(
"five-row-current",
"5단 표그래프 · 현재가",
"s5074",
"5074",
5,
LegacyThemeValueKind.Current),
new LegacyThemeActionDefinition(
"five-row-expected",
"5단 표그래프 · 예상체결가",
"s5074",
"5074",
5,
LegacyThemeValueKind.Expected),
new LegacyThemeActionDefinition(
"six-row-current",
"6종목 현재가",
"s5077",
"5077",
6,
LegacyThemeValueKind.Current),
new LegacyThemeActionDefinition(
"twelve-row-current",
"12종목 현재가",
"s5088",
"5088",
12,
LegacyThemeValueKind.Current)
]);
internal static IReadOnlyList<LegacyThemeActionState> CreateActionStates(
ThemeSelectionIdentity? selectedIdentity,
int previewItemCount)
{
var states = new LegacyThemeActionState[RegisteredActions.Count];
for (var index = 0; index < RegisteredActions.Count; index++)
{
var action = RegisteredActions[index];
var reason = selectedIdentity is null
? "theme-not-selected"
: previewItemCount == 0
? "theme-preview-is-empty"
: action.ValueKind == LegacyThemeValueKind.Expected &&
selectedIdentity.Market == ThemeMarket.Nxt
? "expected-theme-is-krx-only"
: null;
states[index] = new LegacyThemeActionState(
action.Id,
reason is null,
reason);
}
return Array.AsReadOnly(states);
}
private static void ValidateActions(
IReadOnlyCollection<LegacyThemeActionDefinition> actions)
{
if (actions.Count != 4 ||
actions.Select(action => action.Id).Distinct(StringComparer.Ordinal).Count() != 4)
{
throw new InvalidOperationException("The UC4 action catalog must contain four unique actions.");
}
foreach (var action in actions)
{
var scene = LegacySceneCatalog.Definitions.Single(definition =>
string.Equals(definition.BuilderKey, action.BuilderKey, StringComparison.Ordinal));
if (!scene.CutAliases.Contains(action.Code, StringComparer.Ordinal) ||
action.Aliases.Count != 1 ||
!string.Equals(action.Aliases[0], action.Code, StringComparison.Ordinal) ||
(int)scene.PageSize != action.PageSize)
{
throw new InvalidOperationException(
$"Theme action '{action.Id}' does not match the Core scene catalog.");
}
}
}
private static LegacyThemeWorkflowDataException InvalidData(string detail) =>
new($"The injected theme selection service returned invalid {detail} data.");
}

View File

@@ -14,4 +14,9 @@
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Catalog\LegacyFixedActions.json"
LogicalName="MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,974 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyManualListScreen
{
NetSell,
Vi
}
public enum LegacyManualNetSellField
{
LeftName,
LeftAmount,
RightName,
RightAmount
}
public enum LegacyManualListMoveDirection
{
Up,
Down
}
public sealed record LegacyManualListStoreAvailability(
bool IsAvailable,
string Code,
string? Message);
public sealed record LegacyManualViStoredItem(string Code, string Name);
public sealed record LegacyManualViStoreSnapshot(
IReadOnlyList<LegacyManualViStoredItem> Items,
string RowVersion);
public sealed record LegacyManualViWriteReceipt(
LegacyManualViStoreSnapshot Snapshot,
bool Persisted);
public sealed record LegacyManualImportReceipt(
int MarkerVersion,
string SourceSha256,
DateTimeOffset ImportedAt,
int NetSellRowCount,
int ViItemCount);
public sealed class LegacyManualImportStoreException : Exception
{
public LegacyManualImportStoreException(string code, bool outcomeUnknown)
: base("The legacy manual-data import did not complete.")
{
Code = code;
OutcomeUnknown = outcomeUnknown;
}
public string Code { get; }
public bool OutcomeUnknown { get; }
}
/// <summary>
/// Native-only boundary for the operator-owned CP949 files. Implementations choose
/// every path and file name; the WebView can only select a closed screen or opaque row.
/// </summary>
public interface ILegacyManualListStore
{
LegacyManualListStoreAvailability Availability { get; }
Task<IReadOnlyList<S5025TrustedManualRow>> ReadNetSellAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default);
Task WriteNetSellAsync(
S5025ManualAudience audience,
IReadOnlyList<S5025TrustedManualRow> rows,
CancellationToken cancellationToken = default);
Task<LegacyManualViStoreSnapshot> ReadViAsync(
CancellationToken cancellationToken = default);
Task<LegacyManualViWriteReceipt> WriteViAsync(
IReadOnlyList<LegacyManualViStoredItem> items,
CancellationToken cancellationToken = default);
Task<LegacyManualImportReceipt> ImportAsync(
CancellationToken cancellationToken = default);
}
public sealed record LegacyManualNetSellRowView(
string RowId,
string LeftName,
string LeftAmount,
string RightName,
string RightAmount);
public sealed record LegacyManualViItemView(
string ItemId,
int Index,
string Name);
public sealed record LegacyManualViSearchResultView(
string ResultId,
string DisplayName,
string MarketText);
public sealed record LegacyManualListsSnapshot(
bool IsOpen,
LegacyManualListScreen? Screen,
S5025ManualAudience Audience,
bool IsAvailable,
string AvailabilityCode,
string? AvailabilityMessage,
bool WritesQuarantined,
IReadOnlyList<LegacyManualNetSellRowView> NetRows,
bool NetRowsAreFresh,
IReadOnlyList<LegacyManualViItemView> ViItems,
int ViPageCount,
bool ViItemsAreFresh,
string SearchQuery,
IReadOnlyList<LegacyManualViSearchResultView> SearchResults,
string StatusMessage,
LegacyOperatorStatusKind StatusKind,
bool CanSave,
bool CanMaterializePlaylist)
{
public string? SelectedSearchResultId { get; init; }
}
public sealed record LegacyManualListPlaylistDraft(
string SceneAlias,
string MarketText,
string StockName,
string GraphicType,
string Subtype,
string PageText,
string RawCutLabel,
int FadeDuration,
LegacySceneSelection Selection);
/// <summary>
/// Authoritative FSell/VIList workflow. Draft rows, stock identities, file versions,
/// save verification and playlist materialization remain in C#; browser messages are
/// limited to input intent and opaque identifiers.
/// </summary>
public sealed class LegacyManualListsWorkflow
{
public const int NetSellRowCount = 5;
public const int MaximumViItems = 100;
public const int ViPageSize = 5;
public const int MaximumPageCount = 20;
public const int MaximumNetSellValueLength = 256;
public const int MaximumSearchLength = 100;
private const string ViSnapshotSubject = "@MBN_VI_SNAPSHOT_V1";
private const string OutcomeUnknownMessage =
"저장 결과를 확인할 수 없어 수동 목록 쓰기를 격리했습니다. 앱을 다시 시작하고 파일을 확인하세요.";
private readonly ILegacyManualListStore _store;
private readonly ILegacyStockLookup _stockLookup;
private readonly List<S5025TrustedManualRow> _netRows = CreateEmptyNetRows();
private readonly List<ViDraftItem> _viItems = [];
private readonly List<ViSearchResult> _searchResults = [];
private LegacyManualListScreen? _screen;
private S5025ManualAudience _audience = S5025ManualAudience.Individual;
private bool _isOpen;
private bool _netRowsAreFresh;
private bool _viItemsAreFresh;
private bool _writesQuarantined;
private string? _viVersion;
private string _searchQuery = string.Empty;
private string? _selectedSearchResultId;
private string _statusMessage = string.Empty;
private LegacyOperatorStatusKind _statusKind;
private long _opaqueSequence;
public LegacyManualListsWorkflow(
ILegacyManualListStore store,
ILegacyStockLookup stockLookup)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
}
public LegacyManualListsSnapshot Current => CreateSnapshot();
public async Task<LegacyManualListsSnapshot> OpenNetSellAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default)
{
if (!Enum.IsDefined(audience))
{
throw new ArgumentOutOfRangeException(nameof(audience));
}
_isOpen = true;
_screen = LegacyManualListScreen.NetSell;
_audience = audience;
_searchResults.Clear();
_searchQuery = string.Empty;
_selectedSearchResultId = null;
await ReadNetSellAsync(cancellationToken).ConfigureAwait(false);
return Current;
}
public async Task<LegacyManualListsSnapshot> OpenViAsync(
CancellationToken cancellationToken = default)
{
_isOpen = true;
_screen = LegacyManualListScreen.Vi;
_searchResults.Clear();
_searchQuery = string.Empty;
_selectedSearchResultId = null;
await ReadViAsync(cancellationToken).ConfigureAwait(false);
return Current;
}
public LegacyManualListsSnapshot Close()
{
_isOpen = false;
_screen = null;
_searchResults.Clear();
_searchQuery = string.Empty;
_selectedSearchResultId = null;
SetStatus(string.Empty, LegacyOperatorStatusKind.Neutral);
return Current;
}
public async Task<LegacyManualListsSnapshot> RefreshAsync(
CancellationToken cancellationToken = default)
{
if (_screen == LegacyManualListScreen.NetSell)
{
await ReadNetSellAsync(cancellationToken).ConfigureAwait(false);
}
else if (_screen == LegacyManualListScreen.Vi)
{
await ReadViAsync(cancellationToken).ConfigureAwait(false);
}
return Current;
}
public LegacyManualListsSnapshot SetNetSellCell(
string rowId,
LegacyManualNetSellField field,
string value)
{
ArgumentNullException.ThrowIfNull(rowId);
ArgumentNullException.ThrowIfNull(value);
if (_screen != LegacyManualListScreen.NetSell ||
!TryParseNetRowId(rowId, out var index) ||
!IsSafeNetValue(value) ||
!Enum.IsDefined(field))
{
SetStatus("수동 순매도 입력값을 확인하세요.", LegacyOperatorStatusKind.Warning);
return Current;
}
var row = _netRows[index];
_netRows[index] = field switch
{
LegacyManualNetSellField.LeftName => row with { LeftName = value },
LegacyManualNetSellField.LeftAmount => row with { LeftAmount = value },
LegacyManualNetSellField.RightName => row with { RightName = value },
LegacyManualNetSellField.RightAmount => row with { RightAmount = value },
_ => row
};
_netRowsAreFresh = false;
SetStatus("변경 내용을 저장한 뒤 재조회해야 컷을 추가할 수 있습니다.",
LegacyOperatorStatusKind.Information);
return Current;
}
public async Task<LegacyManualListsSnapshot> SaveNetSellAsync(
CancellationToken cancellationToken = default)
{
if (!CanWrite(LegacyManualListScreen.NetSell))
{
return Current;
}
var expected = _netRows.ToArray();
try
{
await _store.WriteNetSellAsync(_audience, expected, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return await RecoverNetSellWriteAsync(expected, cancellationToken)
.ConfigureAwait(false);
}
return await VerifyNetSellWriteAsync(expected, recovered: false, cancellationToken)
.ConfigureAwait(false);
}
public async Task<LegacyManualListsSnapshot> SearchViAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
_searchQuery = query.Trim();
_searchResults.Clear();
_selectedSearchResultId = null;
if (_screen != LegacyManualListScreen.Vi ||
_searchQuery.Length == 0 ||
_searchQuery.Length > MaximumSearchLength)
{
SetStatus("검색할 종목명을 입력하세요.", LegacyOperatorStatusKind.Warning);
return Current;
}
try
{
var data = await _stockLookup.SearchAsync(_searchQuery, cancellationToken)
.ConfigureAwait(false);
foreach (var displayName in data.DisplayNames.Take(200))
{
var identity = data.ResolveLastByDisplayName(displayName);
if (identity is not null)
{
_searchResults.Add(new ViSearchResult(
NextOpaqueId("manual-vi-result"),
identity));
}
}
SetStatus(
_searchResults.Count == 0
? "검색 결과가 없습니다."
: $"{_searchResults.Count}개 종목을 찾았습니다.",
LegacyOperatorStatusKind.Information);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
SetStatus("종목 검색을 완료하지 못했습니다.", LegacyOperatorStatusKind.Error);
}
return Current;
}
public LegacyManualListsSnapshot SelectViSearchResult(string resultId)
{
ArgumentNullException.ThrowIfNull(resultId);
if (_screen == LegacyManualListScreen.Vi && _searchResults.Any(item =>
string.Equals(item.ResultId, resultId, StringComparison.Ordinal)))
{
_selectedSearchResultId = resultId;
}
else
{
SetStatus(
"The selected stock is no longer in the current VI search generation.",
LegacyOperatorStatusKind.Warning);
}
return Current;
}
public LegacyManualListsSnapshot AddViSearchResult(string resultId)
{
ArgumentNullException.ThrowIfNull(resultId);
var result = _searchResults.SingleOrDefault(item =>
string.Equals(item.ResultId, resultId, StringComparison.Ordinal));
if (_screen != LegacyManualListScreen.Vi ||
result is null ||
_viItems.Count >= MaximumViItems)
{
SetStatus("선택한 종목을 VI 목록에 추가할 수 없습니다.",
LegacyOperatorStatusKind.Warning);
return Current;
}
_selectedSearchResultId = resultId;
var prefix = result.Identity.Market is
LegacyDomesticStockMarket.Kospi or LegacyDomesticStockMarket.NxtKospi
? "P"
: "D";
var item = new LegacyManualViStoredItem(
prefix + result.Identity.StockCode,
result.Identity.DisplayName);
if (!IsValidViItem(item))
{
SetStatus("선택한 종목 정보가 VI 파일 형식에 맞지 않습니다.",
LegacyOperatorStatusKind.Warning);
return Current;
}
_viItems.Add(new ViDraftItem(NextOpaqueId("manual-vi-item"), item));
_viItemsAreFresh = false;
SetStatus("VI 목록을 저장한 뒤 재조회해야 컷을 추가할 수 있습니다.",
LegacyOperatorStatusKind.Information);
return Current;
}
public LegacyManualListsSnapshot MoveViItem(
string itemId,
LegacyManualListMoveDirection direction)
{
var index = FindViItem(itemId);
var target = direction == LegacyManualListMoveDirection.Up ? index - 1 : index + 1;
if (_screen != LegacyManualListScreen.Vi ||
index < 0 || target < 0 || target >= _viItems.Count ||
!Enum.IsDefined(direction))
{
return Current;
}
(_viItems[index], _viItems[target]) = (_viItems[target], _viItems[index]);
_viItemsAreFresh = false;
SetStatus("VI 순서를 변경했습니다. 저장 후 재조회하세요.",
LegacyOperatorStatusKind.Information);
return Current;
}
public LegacyManualListsSnapshot DeleteViItem(string itemId)
{
var index = FindViItem(itemId);
if (_screen == LegacyManualListScreen.Vi && index >= 0)
{
_viItems.RemoveAt(index);
_viItemsAreFresh = false;
SetStatus("VI 종목을 삭제했습니다. 저장 후 재조회하세요.",
LegacyOperatorStatusKind.Information);
}
return Current;
}
public LegacyManualListsSnapshot DeleteAllViItems()
{
if (_screen == LegacyManualListScreen.Vi && _viItems.Count > 0)
{
_viItems.Clear();
_viItemsAreFresh = false;
SetStatus("VI 목록을 비웠습니다. 원본과 같이 빈 목록 저장은 기존 파일을 유지합니다.",
LegacyOperatorStatusKind.Information);
}
return Current;
}
public async Task<LegacyManualListsSnapshot> SaveViAsync(
CancellationToken cancellationToken = default)
{
if (!CanWrite(LegacyManualListScreen.Vi) ||
!TrustedViManualReference.IsRowVersion(_viVersion))
{
return Current;
}
var expectedVersion = _viVersion;
var expectedItems = _viItems.Select(item => item.Stored).ToArray();
LegacyManualViStoreSnapshot preflight;
try
{
preflight = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
SetStatus("VI 저장 전 파일 버전을 확인하지 못했습니다. 저장하지 않았습니다.",
LegacyOperatorStatusKind.Error);
return Current;
}
if (!string.Equals(preflight.RowVersion, expectedVersion, StringComparison.Ordinal))
{
SetStatus("VI 파일이 다른 작업에서 변경되었습니다. 재조회 후 다시 편집하세요.",
LegacyOperatorStatusKind.Warning);
return Current;
}
LegacyManualViWriteReceipt receipt;
try
{
receipt = await _store.WriteViAsync(expectedItems, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return await RecoverViWriteAsync(expectedItems, cancellationToken)
.ConfigureAwait(false);
}
return await VerifyViWriteAsync(
expectedItems,
receipt,
recovered: false,
cancellationToken).ConfigureAwait(false);
}
public async Task<LegacyManualListsSnapshot> ImportAsync(
CancellationToken cancellationToken = default)
{
if (!_store.Availability.IsAvailable || _writesQuarantined)
{
return Current;
}
try
{
var receipt = await _store.ImportAsync(cancellationToken).ConfigureAwait(false);
SetStatus(
$"원본 수동 데이터를 가져왔습니다. 순매도 {receipt.NetSellRowCount}행, VI {receipt.ViItemCount}종목.",
LegacyOperatorStatusKind.Information);
return await RefreshAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (LegacyManualImportStoreException exception)
{
if (exception.OutcomeUnknown)
{
QuarantineWrites();
}
else
{
SetStatus(ImportFailureMessage(exception.Code), LegacyOperatorStatusKind.Warning);
}
}
catch
{
SetStatus("원본 수동 데이터 가져오기를 완료하지 못했습니다.",
LegacyOperatorStatusKind.Error);
}
return Current;
}
public LegacyManualListPlaylistDraft CreateNetSellPlaylistDraft()
{
if (_screen != LegacyManualListScreen.NetSell || !_netRowsAreFresh)
{
throw new InvalidOperationException(
"The net-sell rows must be saved and re-read before materialization.");
}
var (groupCode, label) = _audience switch
{
S5025ManualAudience.Individual => ("INDIVIDUAL", "개인"),
S5025ManualAudience.Foreign => ("FOREIGN", "외국인"),
S5025ManualAudience.Institution => ("INSTITUTION", "기관"),
_ => throw new InvalidOperationException("The manual audience is invalid.")
};
return new LegacyManualListPlaylistDraft(
"5025",
"지수",
$"{label} 순매도 상위(수동)",
"순매도 상위",
"순매도 상위",
"1/1",
$"{label} 순매도 상위(수동)",
6,
new LegacySceneSelection(
groupCode,
"INDEX",
"MANUAL_NET_SELL",
"MANUAL_NET_SELL",
string.Empty));
}
public LegacyManualListPlaylistDraft CreateViPlaylistDraft()
{
if (_screen != LegacyManualListScreen.Vi ||
!_viItemsAreFresh ||
_viItems.Count == 0 ||
!TrustedViManualReference.IsRowVersion(_viVersion))
{
throw new InvalidOperationException(
"The VI rows must be saved and re-read before materialization.");
}
var names = string.Join(",", _viItems.Select(item => item.Stored.Name));
var pageCount = (_viItems.Count + ViPageSize - 1) / ViPageSize;
var rowVersion = _viVersion ?? throw new InvalidOperationException(
"The trusted VI row version is unavailable.");
return new LegacyManualListPlaylistDraft(
"5074",
"지수",
names,
"5단 표그래프",
"VI 발동(수동)",
$"1/{pageCount}",
"5단 표그래프_VI 발동(수동)",
6,
new LegacySceneSelection(
"PAGED_VI",
ViSnapshotSubject,
"INPUT_ORDER",
"CURRENT",
rowVersion));
}
private async Task ReadNetSellAsync(CancellationToken cancellationToken)
{
_netRowsAreFresh = false;
if (!EnsureAvailable())
{
return;
}
try
{
var rows = await _store.ReadNetSellAsync(_audience, cancellationToken)
.ConfigureAwait(false);
if (rows.Count != NetSellRowCount || rows.Any(row => !IsValidNetRow(row)))
{
throw new InvalidDataException("The FSell file shape is invalid.");
}
_netRows.Clear();
_netRows.AddRange(rows);
_netRowsAreFresh = true;
SetStatus("수동 순매도 5행을 조회했습니다.", LegacyOperatorStatusKind.Information);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
_netRows.Clear();
_netRows.AddRange(CreateEmptyNetRows());
SetStatus("수동 순매도 파일을 읽지 못했습니다. 필요하면 원본 가져오기를 실행하세요.",
LegacyOperatorStatusKind.Error);
}
}
private async Task ReadViAsync(CancellationToken cancellationToken)
{
_viItemsAreFresh = false;
_viVersion = null;
if (!EnsureAvailable())
{
return;
}
try
{
var snapshot = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
SetViSnapshot(snapshot);
SetStatus($"VI 수동 목록 {snapshot.Items.Count}종목을 조회했습니다.",
LegacyOperatorStatusKind.Information);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
_viItems.Clear();
SetStatus("VI 수동 목록 파일을 읽지 못했습니다. 필요하면 원본 가져오기를 실행하세요.",
LegacyOperatorStatusKind.Error);
}
}
private async Task<LegacyManualListsSnapshot> RecoverNetSellWriteAsync(
IReadOnlyList<S5025TrustedManualRow> expected,
CancellationToken cancellationToken)
{
try
{
var actual = await _store.ReadNetSellAsync(_audience, cancellationToken)
.ConfigureAwait(false);
if (actual.SequenceEqual(expected))
{
SetNetRows(actual);
SetStatus("저장 응답은 실패했지만 재조회로 동일한 5행을 확인했습니다.",
LegacyOperatorStatusKind.Warning);
return Current;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
}
QuarantineWrites();
return Current;
}
private async Task<LegacyManualListsSnapshot> VerifyNetSellWriteAsync(
IReadOnlyList<S5025TrustedManualRow> expected,
bool recovered,
CancellationToken cancellationToken)
{
try
{
var actual = await _store.ReadNetSellAsync(_audience, cancellationToken)
.ConfigureAwait(false);
if (actual.SequenceEqual(expected))
{
SetNetRows(actual);
SetStatus(
recovered
? "재조회로 수동 순매도 저장을 확인했습니다."
: "저장 후 재조회한 5행이 입력값과 일치합니다.",
LegacyOperatorStatusKind.Information);
return Current;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
}
QuarantineWrites();
return Current;
}
private async Task<LegacyManualListsSnapshot> RecoverViWriteAsync(
IReadOnlyList<LegacyManualViStoredItem> expected,
CancellationToken cancellationToken)
{
try
{
var actual = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
if (expected.Count > 0 && actual.Items.SequenceEqual(expected))
{
SetViSnapshot(actual);
SetStatus("저장 응답은 실패했지만 재조회로 같은 VI 목록을 확인했습니다.",
LegacyOperatorStatusKind.Warning);
return Current;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
}
QuarantineWrites();
return Current;
}
private async Task<LegacyManualListsSnapshot> VerifyViWriteAsync(
IReadOnlyList<LegacyManualViStoredItem> expected,
LegacyManualViWriteReceipt receipt,
bool recovered,
CancellationToken cancellationToken)
{
try
{
var actual = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
var receiptMatches = actual.Items.SequenceEqual(receipt.Snapshot.Items) &&
string.Equals(actual.RowVersion, receipt.Snapshot.RowVersion,
StringComparison.Ordinal);
var expectedMatches = !receipt.Persisted || actual.Items.SequenceEqual(expected);
if (receiptMatches && expectedMatches &&
TrustedViManualReference.IsRowVersion(actual.RowVersion))
{
SetViSnapshot(actual);
SetStatus(
receipt.Persisted
? recovered
? "재조회로 VI 저장을 확인했습니다."
: "VI 저장 후 재조회한 목록과 버전이 일치합니다."
: "빈 목록은 원본 동작과 같이 파일을 덮어쓰지 않아 기존 목록을 다시 표시합니다.",
LegacyOperatorStatusKind.Information);
return Current;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
}
QuarantineWrites();
return Current;
}
private bool CanWrite(LegacyManualListScreen requiredScreen)
{
if (_screen != requiredScreen || !_isOpen || !EnsureAvailable() || _writesQuarantined)
{
return false;
}
return true;
}
private bool EnsureAvailable()
{
if (_store.Availability.IsAvailable)
{
return true;
}
SetStatus(
_store.Availability.Message ?? "수동 목록 저장소를 사용할 수 없습니다.",
LegacyOperatorStatusKind.Error);
return false;
}
private void SetNetRows(IEnumerable<S5025TrustedManualRow> rows)
{
_netRows.Clear();
_netRows.AddRange(rows);
_netRowsAreFresh = true;
}
private void SetViSnapshot(LegacyManualViStoreSnapshot snapshot)
{
if (snapshot.Items.Count > MaximumViItems ||
snapshot.Items.Any(item => !IsValidViItem(item)) ||
!TrustedViManualReference.IsRowVersion(snapshot.RowVersion))
{
throw new InvalidDataException("The VI snapshot is invalid.");
}
_viItems.Clear();
_viItems.AddRange(snapshot.Items.Select(item =>
new ViDraftItem(NextOpaqueId("manual-vi-item"), item)));
_viVersion = snapshot.RowVersion;
_viItemsAreFresh = true;
}
private LegacyManualListsSnapshot CreateSnapshot()
{
var availability = _store.Availability;
var netRows = _netRows.Select((row, index) => new LegacyManualNetSellRowView(
NetRowId(index),
row.LeftName,
row.LeftAmount,
row.RightName,
row.RightAmount)).ToArray();
var viItems = _viItems.Select((item, index) => new LegacyManualViItemView(
item.ItemId,
index + 1,
item.Stored.Name)).ToArray();
var search = _searchResults.Select(item => new LegacyManualViSearchResultView(
item.ResultId,
item.Identity.DisplayName,
item.Identity.MarketText)).ToArray();
var canSave = _isOpen && availability.IsAvailable && !_writesQuarantined &&
(_screen == LegacyManualListScreen.NetSell ||
(_screen == LegacyManualListScreen.Vi &&
TrustedViManualReference.IsRowVersion(_viVersion)));
var canMaterialize = _screen switch
{
LegacyManualListScreen.NetSell => _netRowsAreFresh,
LegacyManualListScreen.Vi => _viItemsAreFresh && _viItems.Count > 0 &&
TrustedViManualReference.IsRowVersion(_viVersion),
_ => false
};
return new LegacyManualListsSnapshot(
_isOpen,
_screen,
_audience,
availability.IsAvailable,
availability.Code,
availability.Message,
_writesQuarantined,
Array.AsReadOnly(netRows),
_netRowsAreFresh,
Array.AsReadOnly(viItems),
(_viItems.Count + ViPageSize - 1) / ViPageSize,
_viItemsAreFresh,
_searchQuery,
Array.AsReadOnly(search),
_statusMessage,
_statusKind,
canSave,
canMaterialize)
{
SelectedSearchResultId = _selectedSearchResultId
};
}
private static List<S5025TrustedManualRow> CreateEmptyNetRows() =>
Enumerable.Range(0, NetSellRowCount)
.Select(_ => new S5025TrustedManualRow(string.Empty, string.Empty, string.Empty, string.Empty))
.ToList();
private static bool IsValidNetRow(S5025TrustedManualRow row) =>
row is not null &&
IsSafeNetValue(row.LeftName) &&
IsSafeNetValue(row.LeftAmount) &&
IsSafeNetValue(row.RightName) &&
IsSafeNetValue(row.RightAmount);
private static bool IsSafeNetValue(string? value) =>
value is not null &&
value.Length <= MaximumNetSellValueLength &&
!value.Contains('^') &&
!value.Any(character => char.IsControl(character) || character == '\uFFFD');
private static bool IsValidViItem(LegacyManualViStoredItem item) =>
item is not null &&
IsSafeViText(item.Code, 64) &&
IsSafeViText(item.Name, 200) &&
!item.Name.Contains(',');
private static bool IsSafeViText(string? value, int maximumLength) =>
value is { Length: > 0 } &&
value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
!value.Contains('^') &&
!value.Any(character => char.IsControl(character) || character == '\uFFFD');
private static string NetRowId(int index) => $"manual-net-row-{index + 1}";
private static bool TryParseNetRowId(string rowId, out int index)
{
index = Array.FindIndex(
Enumerable.Range(0, NetSellRowCount).Select(NetRowId).ToArray(),
value => string.Equals(value, rowId, StringComparison.Ordinal));
return index >= 0;
}
private int FindViItem(string itemId) => _viItems.FindIndex(item =>
string.Equals(item.ItemId, itemId, StringComparison.Ordinal));
private string NextOpaqueId(string prefix) => $"{prefix}-{++_opaqueSequence:D8}";
private void SetStatus(string message, LegacyOperatorStatusKind kind)
{
_statusMessage = message;
_statusKind = kind;
}
private void QuarantineWrites()
{
_writesQuarantined = true;
_netRowsAreFresh = false;
_viItemsAreFresh = false;
SetStatus(OutcomeUnknownMessage, LegacyOperatorStatusKind.Error);
}
private static string ImportFailureMessage(string code) => code switch
{
"SOURCE_UNAVAILABLE" => "원본 수동 데이터 파일을 찾을 수 없습니다.",
"SOURCE_INVALID" => "원본 수동 데이터 파일 형식이 올바르지 않습니다.",
"ALREADY_IMPORTED" => "원본 수동 데이터는 이미 한 번 가져왔습니다.",
"DESTINATION_NOT_EMPTY" => "현재 저장소에 파일이 있어 원본 데이터를 덮어쓰지 않았습니다.",
"INTERRUPTED_IMPORT" => "이전 가져오기 작업이 중단된 상태입니다. 파일을 먼저 확인하세요.",
_ => "원본 수동 데이터 가져오기를 완료하지 못했습니다."
};
private sealed record ViDraftItem(string ItemId, LegacyManualViStoredItem Stored);
private sealed record ViSearchResult(string ResultId, LegacyStockIdentity Identity);
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.LegacyApplication.Tests")]

View File

@@ -0,0 +1,458 @@
using System.Globalization;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyTradingHaltRequestStatus
{
Idle,
Loading,
Ready,
Error
}
public enum LegacyTradingHaltNameSort
{
None,
Descending,
Ascending
}
public sealed record LegacyTradingHaltResultView(
string SelectionId,
string Market,
string StockCode,
string DisplayName,
bool IsSelected);
public sealed record LegacyTradingHaltActionView(
string ActionId,
string Label,
bool IsAvailable);
public sealed record LegacyTradingHaltWorkflowSnapshot(
long Revision,
string Query,
LegacyTradingHaltRequestStatus Status,
IReadOnlyList<LegacyTradingHaltResultView> Results,
bool IsTruncated,
LegacyTradingHaltResultView? Selected,
LegacyTradingHaltNameSort NameSort,
bool Ma5,
bool Ma20,
IReadOnlyList<LegacyTradingHaltActionView> Actions,
string ErrorMessage,
string StatusMessage,
LegacyOperatorStatusKind StatusKind);
public sealed record LegacyTradingHaltPlaylistDraft(
string DraftId,
string BuilderKey,
string CutCode,
IReadOnlyList<string> Aliases,
string Title,
string Detail,
string Category,
string Market,
string StockCode,
string DisplayName,
LegacySceneSelection Selection,
bool Ma5,
bool Ma20,
int? CandlePeriodDays,
string PageText,
bool IsEnabled,
int FadeDuration,
string Source,
string ActionId)
{
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
}
/// <summary>
/// Native authority for UC7's combined KOSPI/KOSDAQ trading-halt selector.
/// The Web selects an opaque row and a closed action id; stock identity and scene fields
/// always come from the validated Core service result.
/// </summary>
public sealed class LegacyTradingHaltWorkflowController
{
public const string CurrentActionId = "current";
public const string CandleActionId = "candle-120d";
public const int CandlePeriodDays = 120;
private const int DefaultFadeDuration = 6;
private const string CurrentLabel = "거래정지-현재가";
private const string CandleLabel = "캔들그래프-거래정지 · 120일";
private readonly object _gate = new();
private readonly ITradingHaltSelectionService _service;
private List<HaltState> _results = [];
private HaltState? _selected;
private string _query = string.Empty;
private string _errorMessage = string.Empty;
private string _statusMessage = string.Empty;
private LegacyTradingHaltRequestStatus _status = LegacyTradingHaltRequestStatus.Idle;
private LegacyTradingHaltNameSort _nameSort;
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
private bool _isTruncated;
private bool _ma5;
private bool _ma20;
private int _searchGeneration;
private long _revision;
private long _draftSequence;
public LegacyTradingHaltWorkflowController(ITradingHaltSelectionService service)
{
_service = service ?? throw new ArgumentNullException(nameof(service));
}
public LegacyTradingHaltWorkflowSnapshot Current
{
get
{
lock (_gate)
{
return CreateSnapshot();
}
}
}
public async Task<LegacyTradingHaltWorkflowSnapshot> SearchAsync(
string query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = NormalizeQuery(query);
int generation;
lock (_gate)
{
generation = ++_searchGeneration;
_query = normalizedQuery;
_results = [];
_selected = null;
_nameSort = LegacyTradingHaltNameSort.None;
_status = LegacyTradingHaltRequestStatus.Loading;
_isTruncated = false;
_errorMessage = string.Empty;
Touch();
}
try
{
var result = await _service.SearchAsync(
normalizedQuery,
LegacyTradingHaltSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
lock (_gate)
{
if (generation != _searchGeneration)
{
return CreateSnapshot();
}
_results = ValidateResult(result, normalizedQuery, generation);
_isTruncated = result.IsTruncated;
_status = LegacyTradingHaltRequestStatus.Ready;
_errorMessage = string.Empty;
_statusMessage = $"KOSPI·KOSDAQ 거래정지 종목 {_results.Count}개를 조회했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
}
catch (OperationCanceledException)
{
lock (_gate)
{
if (generation == _searchGeneration)
{
_status = LegacyTradingHaltRequestStatus.Idle;
_errorMessage = string.Empty;
Touch();
}
}
throw;
}
catch
{
lock (_gate)
{
if (generation == _searchGeneration)
{
_results = [];
_selected = null;
_status = LegacyTradingHaltRequestStatus.Error;
_errorMessage = "거래정지 종목 조회 결과를 안전하게 확인하지 못했습니다.";
_statusMessage = _errorMessage;
_statusKind = LegacyOperatorStatusKind.Error;
Touch();
}
}
throw;
}
}
public LegacyTradingHaltWorkflowSnapshot SelectResult(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
lock (_gate)
{
_selected = _results.FirstOrDefault(item =>
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ??
throw new ArgumentOutOfRangeException(
nameof(selectionId),
"The trading-halt selection id is not in the current result set.");
Touch();
return CreateSnapshot();
}
}
public LegacyTradingHaltWorkflowSnapshot ToggleNameSort()
{
lock (_gate)
{
_nameSort = _nameSort switch
{
LegacyTradingHaltNameSort.Descending => LegacyTradingHaltNameSort.Ascending,
_ => LegacyTradingHaltNameSort.Descending
};
Touch();
return CreateSnapshot();
}
}
public LegacyTradingHaltWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20)
{
lock (_gate)
{
_ma5 = ma5;
_ma20 = ma20;
Touch();
return CreateSnapshot();
}
}
public LegacyTradingHaltPlaylistDraft MaterializeSelectedAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
lock (_gate)
{
var selected = _selected ??
throw new InvalidOperationException("Select a trading-halt stock first.");
var isCurrent = string.Equals(actionId, CurrentActionId, StringComparison.Ordinal);
var isCandle = string.Equals(actionId, CandleActionId, StringComparison.Ordinal);
if (!isCurrent && !isCandle)
{
throw new ArgumentOutOfRangeException(nameof(actionId));
}
var item = selected.Selection;
var market = item.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq";
var builderKey = isCurrent ? "s5001" : "s8010";
var cutCode = isCurrent ? "5001" : "8051";
var graphicType = isCurrent
? "1열판기본"
: string.Join(
',',
new[]
{
_ma5 ? "MA5" : null,
_ma20 ? "MA20" : null
}.Where(static value => value is not null));
var selection = new LegacySceneSelection(
isCurrent
? item.Market == TradingHaltMarket.Kospi ? "KOSPI" : "KOSDAQ"
: item.Market == TradingHaltMarket.Kospi
? "KOSPI_STOCK"
: "KOSDAQ_STOCK",
item.DisplayName,
graphicType,
isCurrent ? "HALTED" : "TRADING_HALT",
item.StockCode);
return new LegacyTradingHaltPlaylistDraft(
$"trading-halt-draft-{++_draftSequence:D8}",
builderKey,
cutCode,
Array.AsReadOnly(new[] { cutCode }),
item.DisplayName,
isCurrent ? CurrentLabel : CandleLabel,
isCurrent ? "stock" : "chart",
market,
item.StockCode,
item.DisplayName,
selection,
isCandle && _ma5,
isCandle && _ma20,
isCandle ? CandlePeriodDays : null,
"1/1",
true,
DefaultFadeDuration,
"legacy-trading-halt-workflow",
actionId);
}
}
private LegacyTradingHaltWorkflowSnapshot CreateSnapshot()
{
var display = _results.ToArray();
if (_nameSort != LegacyTradingHaltNameSort.None)
{
var multiplier = _nameSort == LegacyTradingHaltNameSort.Ascending ? 1 : -1;
display = display
.Select((item, sourceIndex) => (item, sourceIndex))
.OrderBy(pair => pair.item.Selection.DisplayName, new DirectionComparer(multiplier))
.ThenBy(pair => pair.item.Selection.Market)
.ThenBy(pair => pair.item.Selection.StockCode, StringComparer.Ordinal)
.ThenBy(pair => pair.sourceIndex)
.Select(pair => pair.item)
.ToArray();
}
var rows = display.Select(ToView).ToArray();
var selected = _selected is null ? null : ToView(_selected);
var canAct = _selected is not null;
return new LegacyTradingHaltWorkflowSnapshot(
_revision,
_query,
_status,
Array.AsReadOnly(rows),
_isTruncated,
selected,
_nameSort,
_ma5,
_ma20,
Array.AsReadOnly(new[]
{
new LegacyTradingHaltActionView(CurrentActionId, CurrentLabel, canAct),
new LegacyTradingHaltActionView(CandleActionId, CandleLabel, canAct)
}),
_errorMessage,
_statusMessage,
_statusKind);
}
private LegacyTradingHaltResultView ToView(HaltState state) =>
new(
state.SelectionId,
state.Selection.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq",
state.Selection.StockCode,
state.Selection.DisplayName,
_selected?.Selection == state.Selection);
private static List<HaltState> ValidateResult(
TradingHaltSelectionResult result,
string expectedQuery,
int generation)
{
ArgumentNullException.ThrowIfNull(result);
if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
result.Items is null ||
result.Items.Count > LegacyTradingHaltSelectionService.MaximumResults ||
result.IsTruncated &&
result.Items.Count != LegacyTradingHaltSelectionService.MaximumResults)
{
throw new TradingHaltSelectionDataException(
"The trading-halt search response correlation is invalid.");
}
var states = new List<HaltState>(result.Items.Count);
var identities = new HashSet<(TradingHaltMarket Market, string Code)>();
var names = new HashSet<(TradingHaltMarket Market, string Name)>();
TradingHaltSelection? previous = null;
for (var index = 0; index < result.Items.Count; index++)
{
var item = result.Items[index] ??
throw new TradingHaltSelectionDataException("A trading-halt row is missing.");
if (!Enum.IsDefined(item.Market) ||
!IsAsciiAlphaNumeric(item.StockCode, 32) ||
!IsCanonicalText(item.DisplayName, 200) ||
!identities.Add((item.Market, item.StockCode)) ||
!names.Add((item.Market, item.DisplayName)) ||
previous is not null && CompareSourceOrder(previous, item) > 0)
{
throw new TradingHaltSelectionDataException(
"Trading-halt identities must be canonical, unique, and KOSPI/KOSDAQ ordered.");
}
states.Add(new HaltState(
$"trading-halt-result-{generation:D4}-{index + 1:D4}",
item));
previous = item;
}
return states;
}
private static int CompareSourceOrder(
TradingHaltSelection left,
TradingHaltSelection right)
{
var market = left.Market.CompareTo(right.Market);
if (market != 0)
{
return market;
}
var name = string.Compare(left.DisplayName, right.DisplayName, StringComparison.Ordinal);
return name != 0
? name
: string.Compare(left.StockCode, right.StockCode, StringComparison.Ordinal);
}
private static string NormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
var normalized = query.Trim();
if (query.Length > LegacyTradingHaltSelectionService.MaximumQueryLength ||
normalized.Length > LegacyTradingHaltSelectionService.MaximumQueryLength ||
!IsSafeText(query) || !IsSafeText(normalized))
{
throw new ArgumentException(
"A trading-halt query must contain at most 64 safe characters.",
nameof(query));
}
return normalized;
}
private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) =>
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
value.All(static character => char.IsAsciiLetterOrDigit(character));
private static bool IsCanonicalText(string? value, int maximumLength) =>
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value);
private static bool IsSafeText(string value)
{
foreach (var character in value)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private void Touch() => _revision++;
private sealed record HaltState(
string SelectionId,
TradingHaltSelection Selection);
private sealed class DirectionComparer(int multiplier) : IComparer<string>
{
public int Compare(string? left, string? right) =>
multiplier * string.Compare(left, right, StringComparison.Ordinal);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
using MBN_STOCK_WEBVIEW.LegacyApplication;
using System.Text.Json;
namespace MBN_STOCK_WEBVIEW.LegacyBridge;
public sealed record LegacyExecutePlayoutIntent(LegacyOperatorPlayoutCommand Command)
: LegacyUiIntent;
public sealed record LegacySetFadeDurationIntent(int Duration)
: LegacyUiIntent;
public sealed record LegacyToggleBackgroundIntent : LegacyUiIntent;
public sealed record LegacyChooseBackgroundIntent : LegacyUiIntent;
internal static class LegacyPlayoutUiIntentParser
{
public static LegacyUiIntent? Parse(string type, JsonElement payload) => type switch
{
"prepare-playout" => ParseEmpty(
payload,
static () => new LegacyExecutePlayoutIntent(
LegacyOperatorPlayoutCommand.Prepare)),
"take-in" => ParseEmpty(
payload,
static () => new LegacyExecutePlayoutIntent(
LegacyOperatorPlayoutCommand.TakeIn)),
"next-playout" => ParseEmpty(
payload,
static () => new LegacyExecutePlayoutIntent(
LegacyOperatorPlayoutCommand.Next)),
"take-out" => ParseEmpty(
payload,
static () => new LegacyExecutePlayoutIntent(
LegacyOperatorPlayoutCommand.TakeOut)),
"set-fade-duration" => ParseFadeDuration(payload),
"toggle-background" => ParseEmpty(
payload,
static () => new LegacyToggleBackgroundIntent()),
"choose-background" => ParseEmpty(
payload,
static () => new LegacyChooseBackgroundIntent()),
_ => null
};
private static LegacyUiIntent? ParseFadeDuration(JsonElement payload)
{
if (payload.ValueKind != JsonValueKind.Object ||
payload.GetRawText().Length > 64 ||
payload.EnumerateObject().Count() != 1 ||
!payload.TryGetProperty("duration", out var durationElement) ||
durationElement.ValueKind != JsonValueKind.Number ||
!durationElement.TryGetInt32(out var duration) ||
duration is < 0 or > 60)
{
return null;
}
return new LegacySetFadeDurationIntent(duration);
}
private static LegacyUiIntent? ParseEmpty(
JsonElement payload,
Func<LegacyUiIntent> factory) =>
payload.ValueKind == JsonValueKind.Object &&
!payload.EnumerateObject().Any()
? factory()
: null;
}

View File

@@ -26,6 +26,8 @@
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\LegacySceneRuntimeFactory.cs"
Link="Runtime\LegacySceneRuntimeFactory.cs" />
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
<Content Include="..\..\Assets\SplashScreen.scale-200.png" Link="Assets\SplashScreen.scale-200.png" />
<Content Include="..\..\Assets\Square150x150Logo.scale-200.png" Link="Assets\Square150x150Logo.scale-200.png" />

View File

@@ -0,0 +1,130 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml.Controls;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow
{
private AppWindow? _closeConfirmationAppWindow;
private int _closeConfirmationPending;
private int _closeConfirmed;
private void EnsureCloseConfirmationAttached()
{
if (_closeConfirmationAppWindow is not null)
{
return;
}
try
{
var windowHandle = WindowNative.GetWindowHandle(this);
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Closing += OnAppWindowClosing;
_closeConfirmationAppWindow = appWindow;
}
catch
{
// The constructor can run before the native window exists. Root.Loaded
// retries the attachment after WinUI has created it.
}
}
private async void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args)
{
if (Volatile.Read(ref _closeConfirmed) != 0)
{
return;
}
// AppWindow.Closing has no deferral. Cancel synchronously and close a second
// time only after the operator explicitly confirms. The default is Cancel,
// matching MainForm_FormClosing's default No choice.
args.Cancel = true;
if (Interlocked.CompareExchange(ref _closeConfirmationPending, 1, 0) != 0)
{
return;
}
try
{
if (Root.XamlRoot is null)
{
return;
}
var dialog = new ContentDialog
{
XamlRoot = Root.XamlRoot,
Title = "프로그램 종료",
Content = BuildCloseConfirmationMessage(),
PrimaryButtonText = "종료",
CloseButtonText = "취소",
DefaultButton = ContentDialogButton.Close
};
var result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary)
{
return;
}
Interlocked.Exchange(ref _closeConfirmed, 1);
Close();
}
catch
{
// If the confirmation surface cannot be shown, keep the application open.
}
finally
{
Interlocked.Exchange(ref _closeConfirmationPending, 0);
}
}
private string BuildCloseConfirmationMessage()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
var hasPreparedScene = !string.IsNullOrWhiteSpace(status?.PreparedSceneName) ||
!string.IsNullOrWhiteSpace(workflow?.PreparedCutCode);
var hasOnAirScene = !string.IsNullOrWhiteSpace(status?.OnAirSceneName) ||
!string.IsNullOrWhiteSpace(workflow?.OnAirCutCode);
var commandPending = Volatile.Read(ref _playoutBusy) != 0 ||
status?.IsPlayCompletionPending == true;
var outcomeUnknown = Volatile.Read(ref _playoutQuarantined) != 0 ||
status?.State == PlayoutConnectionState.OutcomeUnknown;
if (outcomeUnknown)
{
return "송출 결과를 확정할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 종료하세요. " +
"종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?";
}
if (commandPending)
{
return "송출 명령 또는 callback 처리가 진행 중입니다. 결과를 확인한 뒤 종료하는 것이 안전합니다. " +
"그래도 종료하시겠습니까?";
}
if (hasOnAirScene || hasPreparedScene)
{
return "PREPARED 또는 PROGRAM 장면이 남아 있습니다. 필요한 경우 먼저 TAKE OUT을 실행하고 " +
"PGM을 확인하세요. 종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?";
}
return "프로그램을 종료하시겠습니까?";
}
private void DetachCloseConfirmation()
{
var appWindow = Interlocked.Exchange(ref _closeConfirmationAppWindow, null);
if (appWindow is not null)
{
appWindow.Closing -= OnAppWindowClosing;
}
}
}

View File

@@ -0,0 +1,232 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow
{
private const string OperatorDataDirectoryEnvironmentVariable =
"MBN_STOCK_OPERATOR_DATA_DIRECTORY";
private S5025TrustedManualFileStore? _manualNetSellStore;
private ViTrustedManualFileStore? _manualViStore;
private LegacyManualOperatorDataImporter? _manualDataImporter;
private LegacyManualListsWorkflow CreateManualListsWorkflow(
ILegacyStockLookup stockLookup)
{
ILegacyManualListStore store;
try
{
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(
OperatorDataDirectoryEnvironmentVariable)))
{
store = new UnavailableManualListStore(
"UNSAFE_CONFIGURATION",
"외부 경로 설정은 사용하지 않습니다. 앱 전용 로컬 저장소만 사용할 수 있습니다.");
return new LegacyManualListsWorkflow(store, stockLookup);
}
var directory = TrustedManualDirectory.PreparePrivateWritableDirectory(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"OperatorData"));
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(directory);
if (!inspection.IsReady)
{
var interrupted = inspection.State ==
LegacyManualOperatorDataRuntimeState.InterruptedImport;
store = new UnavailableManualListStore(
interrupted ? "IMPORT_INTERRUPTED" : "IMPORTED_STATE_INVALID",
interrupted
? "이전 원본 데이터 가져오기가 중단되어 수동 파일을 열지 않았습니다."
: "수동 데이터 가져오기 표식과 파일 상태가 일치하지 않습니다.");
return new LegacyManualListsWorkflow(store, stockLookup);
}
_manualNetSellStore = new S5025TrustedManualFileStore(directory);
_manualViStore = new ViTrustedManualFileStore(directory);
_manualDataImporter = new LegacyManualOperatorDataImporter(
directory,
new FixedLegacyManualOperatorDataSource());
store = new TrustedManualListStore(
_manualNetSellStore,
_manualViStore,
_manualDataImporter);
}
catch
{
_manualDataImporter?.Dispose();
_manualViStore?.Dispose();
_manualNetSellStore?.Dispose();
_manualDataImporter = null;
_manualViStore = null;
_manualNetSellStore = null;
store = new UnavailableManualListStore(
"INITIALIZATION_FAILED",
"수동 순매도/VI 로컬 저장소를 초기화하지 못했습니다.");
}
return new LegacyManualListsWorkflow(store, stockLookup);
}
private void ShutdownManualListsRuntime()
{
var importer = Interlocked.Exchange(ref _manualDataImporter, null);
var viStore = Interlocked.Exchange(ref _manualViStore, null);
var netStore = Interlocked.Exchange(ref _manualNetSellStore, null);
if (importer is null && viStore is null && netStore is null)
{
return;
}
_ = Task.Run(async () =>
{
await _intentGate.WaitAsync().ConfigureAwait(false);
try
{
importer?.Dispose();
viStore?.Dispose();
netStore?.Dispose();
}
finally
{
_intentGate.Release();
}
});
}
private sealed class TrustedManualListStore : ILegacyManualListStore
{
private readonly S5025TrustedManualFileStore _netStore;
private readonly ViTrustedManualFileStore _viStore;
private readonly LegacyManualOperatorDataImporter _importer;
public TrustedManualListStore(
S5025TrustedManualFileStore netStore,
ViTrustedManualFileStore viStore,
LegacyManualOperatorDataImporter importer)
{
_netStore = netStore;
_viStore = viStore;
_importer = importer;
}
public LegacyManualListStoreAvailability Availability { get; } =
new(true, "READY", null);
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadNetSellAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default) =>
_netStore.ReadAsync(audience, cancellationToken);
public Task WriteNetSellAsync(
S5025ManualAudience audience,
IReadOnlyList<S5025TrustedManualRow> rows,
CancellationToken cancellationToken = default) =>
_netStore.WriteAsync(audience, rows, cancellationToken);
public async Task<LegacyManualViStoreSnapshot> ReadViAsync(
CancellationToken cancellationToken = default)
{
var snapshot = await _viStore.ReadSnapshotAsync(cancellationToken)
.ConfigureAwait(false);
return Map(snapshot);
}
public async Task<LegacyManualViWriteReceipt> WriteViAsync(
IReadOnlyList<LegacyManualViStoredItem> items,
CancellationToken cancellationToken = default)
{
var result = await _viStore.WriteAsync(
items.Select(item => new ViTrustedManualItem(item.Code, item.Name)).ToArray(),
cancellationToken).ConfigureAwait(false);
return new LegacyManualViWriteReceipt(Map(result.Snapshot), result.Persisted);
}
public async Task<LegacyManualImportReceipt> ImportAsync(
CancellationToken cancellationToken = default)
{
try
{
var result = await _importer.ImportAsync(cancellationToken)
.ConfigureAwait(false);
return new LegacyManualImportReceipt(
result.MarkerVersion,
result.SourceSha256,
result.ImportedAt,
result.NetSellRowCount,
result.ViItemCount);
}
catch (LegacyManualOperatorImportException exception)
{
throw new LegacyManualImportStoreException(
ImportFailureCode(exception.Failure),
exception.OutcomeUnknown);
}
}
private static LegacyManualViStoreSnapshot Map(ViTrustedManualSnapshot snapshot) =>
new(
Array.AsReadOnly(snapshot.Items.Select(item =>
new LegacyManualViStoredItem(item.Code, item.Name)).ToArray()),
snapshot.RowVersion);
private static string ImportFailureCode(LegacyManualOperatorImportFailure failure) =>
failure switch
{
LegacyManualOperatorImportFailure.SourceUnavailable => "SOURCE_UNAVAILABLE",
LegacyManualOperatorImportFailure.SourceInvalid => "SOURCE_INVALID",
LegacyManualOperatorImportFailure.AlreadyImported => "ALREADY_IMPORTED",
LegacyManualOperatorImportFailure.DestinationNotEmpty =>
"DESTINATION_NOT_EMPTY",
LegacyManualOperatorImportFailure.InterruptedImport => "INTERRUPTED_IMPORT",
LegacyManualOperatorImportFailure.ImportFailed => "IMPORT_FAILED",
LegacyManualOperatorImportFailure.RollbackFailed => "ROLLBACK_FAILED",
_ => "IMPORT_FAILED"
};
}
private sealed class UnavailableManualListStore : ILegacyManualListStore
{
private readonly string _message;
public UnavailableManualListStore(string code, string message)
{
_message = message;
Availability = new LegacyManualListStoreAvailability(false, code, message);
}
public LegacyManualListStoreAvailability Availability { get; }
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadNetSellAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default) =>
Task.FromException<IReadOnlyList<S5025TrustedManualRow>>(
new InvalidOperationException(_message));
public Task WriteNetSellAsync(
S5025ManualAudience audience,
IReadOnlyList<S5025TrustedManualRow> rows,
CancellationToken cancellationToken = default) =>
Task.FromException(new InvalidOperationException(_message));
public Task<LegacyManualViStoreSnapshot> ReadViAsync(
CancellationToken cancellationToken = default) =>
Task.FromException<LegacyManualViStoreSnapshot>(
new InvalidOperationException(_message));
public Task<LegacyManualViWriteReceipt> WriteViAsync(
IReadOnlyList<LegacyManualViStoredItem> items,
CancellationToken cancellationToken = default) =>
Task.FromException<LegacyManualViWriteReceipt>(
new InvalidOperationException(_message));
public Task<LegacyManualImportReceipt> ImportAsync(
CancellationToken cancellationToken = default) =>
Task.FromException<LegacyManualImportReceipt>(
new LegacyManualImportStoreException("UNAVAILABLE", false));
}
}

View File

@@ -0,0 +1,836 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using Microsoft.UI.Xaml;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow
{
private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
private IPlayoutEngine? _playoutEngine;
private LegacyPlayoutWorkflow? _playoutWorkflow;
private PlayoutOptions? _playoutOptions;
private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions;
private string? _playoutInitializationError;
private string? _playoutInitializationWarning;
private CancellationTokenSource? _refreshCancellation;
private Task? _refreshTask;
private int _playoutBusy;
private int _playoutQuarantined;
private int _playoutShutdownStarted;
private int _operatorMutationQuarantined;
private int _refreshCompletedCount;
private bool _refreshLimitReached;
private string _refreshMessage = string.Empty;
private void InitializePlayoutRuntime()
{
try
{
var explicitLocalConfigurationExists = File.Exists(
PlayoutOptionsLoader.DefaultPath);
_playoutOptions = PlayoutOptionsLoader.Load();
var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
_playoutOptions,
explicitLocalConfigurationExists);
var initialComposition = startupComposition.Composition;
_playoutInitializationWarning = startupComposition.HasWarning
? startupComposition.WarningMessage
: null;
_compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
initialComposition);
_playoutEngine = PlayoutEngineFactory.Create(_playoutOptions);
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
if (_databaseRuntime is not null)
{
_playoutWorkflow = global::MBN_STOCK_WEBVIEW.LegacySceneRuntimeFactory.Create(
_playoutEngine,
_databaseRuntime.Executor,
compositionOptionsSource: _compositionOptions,
trustedManualSource: _manualNetSellStore,
trustedViSource: _manualViStore);
}
}
catch (Exception exception)
{
_playoutInitializationWarning = null;
_playoutInitializationError = exception is PlayoutConfigurationException
? exception.Message
: "Tornado 송출 어댑터를 초기화하지 못했습니다.";
}
}
private async Task ConnectPlayoutAsync(CancellationToken cancellationToken)
{
var engine = _playoutEngine;
if (engine is null)
{
QueueOperatorState();
return;
}
try
{
var result = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess)
{
_playoutInitializationWarning = null;
_playoutInitializationError = result.Message;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch
{
_playoutInitializationWarning = null;
_playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다.";
}
QueueOperatorState();
}
private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
{
_playoutWorkflow?.ObserveEngineStatus(args.Current);
if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
{
StopRefreshLoop();
}
QueueOperatorState();
}
private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(
LegacyOperatorPlayoutCommand command,
CancellationToken cancellationToken)
{
var engine = _playoutEngine;
var workflow = _playoutWorkflow;
if (engine is null || workflow is null)
{
return _controller.ReportError(
_playoutInitializationError ?? "Tornado 송출 기능을 사용할 수 없습니다.");
}
if (Volatile.Read(ref _playoutQuarantined) != 0 ||
engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
{
return _controller.ReportError(
"송출 결과를 확인할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 앱을 다시 시작하세요.");
}
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("다른 송출 명령을 처리하고 있습니다.");
}
try
{
Interlocked.Exchange(ref _playoutBusy, 1);
StopRefreshLoop();
var nextKindBefore = workflow.State.NextKind;
if (command == LegacyOperatorPlayoutCommand.Next &&
engine.Status.IsPlayCompletionPending)
{
return _controller.ReportError(
"Tornado 재생 완료 이벤트를 기다리는 중입니다. NEXT를 반복하지 마세요.");
}
PlayoutResult result;
switch (command)
{
case LegacyOperatorPlayoutCommand.Prepare:
var request = _controller.CreatePlayoutRequest();
result = await workflow.PrepareAsync(
request.Playlist,
request.SelectedIndexZeroBased,
cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.TakeIn:
// MainForm.btnTake_Click performed ONAirMode (load/prepare) and
// Play in one visible F8 action. Keep the explicit PREPARE control
// for engineering checks, but preserve the original one-click path
// whenever no scene is already prepared.
if (string.IsNullOrWhiteSpace(workflow.State.PreparedCutCode))
{
var takeInRequest = _controller.CreatePlayoutRequest();
result = await workflow.PrepareAndTakeInAsync(
takeInRequest.Playlist,
takeInRequest.SelectedIndexZeroBased,
cancellationToken).ConfigureAwait(false);
}
else
{
result = await workflow.TakeInAsync(cancellationToken).ConfigureAwait(false);
}
break;
case LegacyOperatorPlayoutCommand.Next:
result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.TakeOut:
result = await workflow.TakeOutAsync(
PlayoutTakeOutScope.All,
cancellationToken).ConfigureAwait(false);
break;
default:
throw new ArgumentOutOfRangeException(nameof(command));
}
if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut)
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
return _controller.ReportError(
"송출 명령 결과가 불명확합니다. 명령을 반복하지 말고 PGM과 Network Monitoring을 확인하세요.");
}
if (!result.IsSuccess)
{
return _controller.ReportError(result.Message);
}
if (command == LegacyOperatorPlayoutCommand.TakeIn ||
command == LegacyOperatorPlayoutCommand.Next &&
nextKindBefore == LegacyWorkflowNextKind.PlaylistNext)
{
StartRefreshLoop(workflow);
}
return _controller.ReportInformation(result.Message);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return _controller.ReportError("송출 요청이 취소되었습니다.");
}
catch (DatabaseOperationException exception)
{
return _controller.ReportError(exception.Message);
}
catch (Exception exception) when (
exception is LegacySceneDataException or ArgumentException or InvalidOperationException)
{
return _controller.ReportError(exception.Message);
}
catch
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
return _controller.ReportError(
"송출 엔진 명령의 결과를 확인할 수 없습니다. 명령을 반복하지 마세요.");
}
finally
{
Interlocked.Exchange(ref _playoutBusy, 0);
_playoutCommandGate.Release();
QueueOperatorState();
}
}
private async Task<LegacyOperatorSnapshot> SetFadeDurationAsync(
int fadeDuration,
CancellationToken cancellationToken)
{
if (fadeDuration is < 0 or > 60)
{
return _controller.ReportError("Fade 값이 올바르지 않습니다.");
}
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 Fade를 변경할 수 없습니다.");
}
try
{
if (!CanChangeComposition())
{
return _controller.ReportError("Fade는 IDLE 상태에서만 변경할 수 있습니다.");
}
var source = _compositionOptions!;
source.Update(source.Current with { FadeDuration = fadeDuration });
return _controller.ReportInformation(
$"Fade {fadeDuration}을 다음 PREPARE부터 적용합니다.");
}
finally
{
_playoutCommandGate.Release();
}
}
private async Task<LegacyOperatorSnapshot> ToggleBackgroundAsync(
CancellationToken cancellationToken)
{
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
}
try
{
if (!CanChangeComposition())
{
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
}
var source = _compositionOptions!;
if (source.Current.BackgroundKind == LegacySceneBackgroundKind.None)
{
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
_playoutOptions!,
source.Current.FadeDuration));
_playoutInitializationWarning = null;
return _controller.ReportInformation(
"기본 배경을 다음 PREPARE부터 적용합니다.");
}
source.Update(new LegacySceneCueCompositionOptions(
source.Current.FadeDuration,
LegacySceneBackgroundKind.None));
return _controller.ReportInformation("배경 사용을 해제했습니다.");
}
catch (PlayoutConfigurationException exception)
{
return _controller.ReportError(exception.Message);
}
finally
{
_playoutCommandGate.Release();
}
}
private async Task<LegacyOperatorSnapshot> ChooseBackgroundAsync(
CancellationToken cancellationToken)
{
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
}
try
{
if (!CanChangeComposition())
{
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
}
// MainForm.btnback_Click assigns 배경\기본.vrv before opening the
// picker. Preserve that observable behavior when the trusted default is
// present, while still allowing the operator to choose another asset if it
// is not.
var source = _compositionOptions!;
try
{
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
_playoutOptions!,
source.Current.FadeDuration));
_playoutInitializationWarning = null;
}
catch (PlayoutConfigurationException)
{
}
var picker = new FileOpenPicker
{
SuggestedStartLocation = PickerLocationId.PicturesLibrary,
ViewMode = PickerViewMode.List
};
foreach (var extension in new[] { ".vrv", ".jpg", ".jpeg", ".png" })
{
picker.FileTypeFilter.Add(extension);
}
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this));
var selected = await picker.PickSingleFileAsync();
if (selected is null)
{
return _controller.Current;
}
if (!CanChangeComposition())
{
return _controller.ReportError(
"파일 선택 중 송출 상태가 바뀌어 배경 변경을 취소했습니다.");
}
source.Update(PlayoutSceneCompositionFactory.CreateOperatorSelection(
_playoutOptions!,
source.Current.FadeDuration,
selected.Path));
_playoutInitializationWarning = null;
return _controller.ReportInformation(
"선택한 배경을 다음 PREPARE부터 적용합니다.");
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
PlayoutConfigurationException)
{
return _controller.ReportError(
"신뢰 배경 폴더 안의 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다.");
}
finally
{
_playoutCommandGate.Release();
}
}
private bool CanChangeComposition()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
return _playoutOptions is not null && _compositionOptions is not null &&
status is not null && workflow is not null &&
Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
!status.IsPlayCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
(_refreshTask is null || _refreshTask.IsCompleted);
}
private bool CanAcceptOperatorMutation()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
if (status is null || workflow is null)
{
// A disabled or unavailable playout runtime must not prevent the operator
// from composing a playlist. PREPARE will still fail closed later.
return true;
}
return Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
Volatile.Read(ref _playoutShutdownStarted) == 0 &&
status.State != PlayoutConnectionState.OutcomeUnknown &&
!status.IsPlayCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
(_refreshTask is null || _refreshTask.IsCompleted);
}
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
LegacyDropSelectedCutsIntent or
LegacySetPlaylistEnabledIntent or
LegacySetAllPlaylistEnabledIntent or
LegacyMoveSelectedPlaylistRowsIntent or
LegacyDeleteSelectedPlaylistRowsIntent or
LegacyDeleteAllPlaylistRowsIntent or
LegacyToggleActivePlaylistEnabledIntent or
LegacyActivateFixedActionIntent or
LegacyActivateFixedSectionIntent or
LegacyActivateIndustryActionIntent or
LegacyActivateIndustrySectionIntent or
LegacyActivateOverseasActionIntent or
LegacyAddComparisonPairIntent or
LegacyMoveComparisonPairIntent or
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
LegacyActivateComparisonActionIntent or
LegacyActivateComparisonSectionIntent or
LegacyActivateThemeActionIntent or
LegacyActivateThemeResultIntent or
LegacyActivateExpertActionIntent or
LegacyActivateTradingHaltActionIntent or
LegacyBeginUc4ThemeCatalogIntent or
LegacyEditUc4SelectedThemeIntent or
LegacyDeleteUc4SelectedThemeIntent or
LegacyBeginUc6ExpertCatalogIntent or
LegacyEditUc6SelectedExpertIntent or
LegacyDeleteUc6SelectedExpertIntent or
LegacyBeginCreateThemeCatalogIntent or
LegacyBeginCreateExpertCatalogIntent or
LegacyAddOperatorCatalogStockIntent or
LegacyActivateOperatorCatalogStockIntent or
LegacyMoveOperatorCatalogDraftRowIntent or
LegacyRemoveOperatorCatalogDraftRowIntent or
LegacySetOperatorCatalogDraftBuyAmountIntent or
LegacySaveOperatorCatalogIntent or
LegacyDeleteOperatorCatalogIntent or
LegacyLoadSelectedNamedPlaylistIntent or
LegacyLoadNamedPlaylistByIdIntent or
LegacyCreateNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent or
LegacySaveManualFinancialIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacyActivateManualFinancialActionIntent or
LegacyActivateManualFinancialResultIntent or
LegacySetManualNetSellCellIntent or
LegacySaveManualNetSellIntent or
LegacyAddManualNetSellCutIntent or
LegacyAddManualViResultIntent or
LegacyMoveManualViItemIntent or
LegacyDeleteManualViItemIntent or
LegacyDeleteAllManualViItemsIntent or
LegacySaveManualViIntent or
LegacyAddManualViCutIntent or
LegacyConfirmManualListIntent or
LegacyImportManualListsIntent;
private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
LegacySetManualNetSellCellIntent or
LegacyAddManualViResultIntent or
LegacyMoveManualViItemIntent or
LegacyDeleteManualViItemIntent or
LegacyDeleteAllManualViItemsIntent or
LegacyConfirmManualListIntent or
LegacyImportManualListsIntent;
private void ObserveOperatorMutationQuarantine(LegacyOperatorSnapshot snapshot)
{
if (snapshot.ManualFinancial?.WritesQuarantined == true ||
snapshot.NamedPlaylist?.IsWriteQuarantined == true ||
snapshot.ManualLists?.WritesQuarantined == true ||
snapshot.OperatorCatalog?.WritesQuarantined == true)
{
Interlocked.Exchange(ref _operatorMutationQuarantined, 1);
}
}
private bool IsOperatorMutationQuarantined() =>
Volatile.Read(ref _operatorMutationQuarantined) != 0;
private LegacyOperatorPlayoutSnapshot CreatePlayoutSnapshot()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
var composition = _compositionOptions?.Current ??
LegacySceneCueCompositionOptions.Default;
var quarantined = Volatile.Read(ref _playoutQuarantined) != 0 ||
status?.State == PlayoutConnectionState.OutcomeUnknown;
var phase = !string.IsNullOrWhiteSpace(status?.OnAirSceneName)
? LegacyOperatorPlayoutPhase.Program
: !string.IsNullOrWhiteSpace(status?.PreparedSceneName)
? LegacyOperatorPlayoutPhase.Prepared
: LegacyOperatorPlayoutPhase.Idle;
var backgroundEnabled =
composition.BackgroundKind != LegacySceneBackgroundKind.None;
return new LegacyOperatorPlayoutSnapshot(
status?.Mode ?? PlayoutMode.Disabled,
quarantined
? PlayoutConnectionState.OutcomeUnknown
: status?.State ?? PlayoutConnectionState.Disabled,
phase,
status?.IsProcessRunning ?? false,
status?.IsConnected ?? false,
(status?.IsCommandAvailable ?? false) && !quarantined,
(status?.LiveTakeInAllowed ?? false) && !quarantined,
status?.IsPlayCompletionPending ?? false,
quarantined,
status?.PreparedSceneName,
status?.OnAirSceneName,
workflow?.CurrentCueIndexZeroBased ?? -1,
workflow?.CurrentEntryId,
workflow?.BuilderKey,
workflow?.PageIndexZeroBased ?? 0,
workflow?.PageCount ?? 0,
workflow?.PageSize ?? 0,
workflow?.ItemCount ?? 0,
workflow?.CurrentPageItemCount ?? 0,
workflow?.IsLastPage ?? true,
workflow?.NextKind ?? LegacyWorkflowNextKind.None,
Volatile.Read(ref _playoutBusy) != 0,
_refreshTask is { IsCompleted: false },
Volatile.Read(ref _refreshCompletedCount),
_playoutOptions?.MaximumAutomaticRefreshesPerTakeIn,
_refreshLimitReached,
composition.FadeDuration,
backgroundEnabled,
backgroundEnabled
? Path.GetFileName(composition.BackgroundAssetPath) ?? string.Empty
: string.Empty,
CanChangeComposition(),
quarantined
? "결과 불명확 상태입니다. 명령을 반복하지 마세요."
: _refreshMessage.Length > 0
? _refreshMessage
: _playoutInitializationWarning ?? status?.Message ??
_playoutInitializationError ??
"송출 어댑터를 사용할 수 없습니다.");
}
private void StartRefreshLoop(LegacyPlayoutWorkflow workflow)
{
StopRefreshLoop();
Interlocked.Exchange(ref _refreshCompletedCount, 0);
_refreshLimitReached = false;
_refreshMessage = string.Empty;
var cutCode = workflow.State.OnAirCutCode;
if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var firstInterval) ||
firstInterval <= TimeSpan.Zero)
{
return;
}
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
_lifetimeCancellation.Token);
_refreshCancellation = cancellation;
_refreshTask = RunRefreshLoopAsync(workflow, firstInterval, cancellation);
}
private void StopRefreshLoop()
{
var cancellation = Interlocked.Exchange(ref _refreshCancellation, null);
if (cancellation is not null)
{
try
{
cancellation.Cancel();
}
catch (ObjectDisposedException)
{
}
}
}
private async Task RunRefreshLoopAsync(
LegacyPlayoutWorkflow workflow,
TimeSpan firstInterval,
CancellationTokenSource cancellation)
{
var token = cancellation.Token;
var delay = firstInterval;
var maximum = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn;
try
{
while (!token.IsCancellationRequested)
{
var engine = _playoutEngine;
if (engine is null)
{
return;
}
if (maximum is { } cap && Volatile.Read(ref _refreshCompletedCount) >= cap)
{
if (!await WaitForPlayCompletionAsync(engine, token)
.ConfigureAwait(false))
{
_refreshMessage =
"자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. PGM을 확인하세요.";
return;
}
_refreshLimitReached = true;
_refreshMessage = $"자동 갱신 상한 {cap}회에 도달했습니다.";
return;
}
if (!await WaitForPlayCompletionAsync(engine, token)
.ConfigureAwait(false))
{
_refreshMessage = "재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다.";
return;
}
await Task.Delay(delay, token).ConfigureAwait(false);
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
try
{
Interlocked.Exchange(ref _playoutBusy, 1);
var result = await workflow.RefreshOnAirAsync(token).ConfigureAwait(false);
if (!result.IsSuccess)
{
if (result.Code is PlayoutResultCode.OutcomeUnknown or
PlayoutResultCode.TimedOut)
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
}
_refreshMessage =
"자동 장면 갱신을 중단했습니다. TAKE OUT 전 PGM을 확인하세요.";
return;
}
Interlocked.Increment(ref _refreshCompletedCount);
delay = LegacySceneRefreshPolicy.RecurringInterval;
}
finally
{
Interlocked.Exchange(ref _playoutBusy, 0);
_playoutCommandGate.Release();
QueueOperatorState();
}
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
}
catch
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
_refreshMessage = "자동 갱신 결과가 불명확합니다. 명령을 반복하지 마세요.";
}
finally
{
if (ReferenceEquals(_refreshCancellation, cancellation))
{
Interlocked.CompareExchange(ref _refreshCancellation, null, cancellation);
}
cancellation.Dispose();
QueueOperatorState();
}
}
private static async Task<bool> WaitForPlayCompletionAsync(
IPlayoutEngine engine,
CancellationToken cancellationToken)
{
if (!engine.Status.IsPlayCompletionPending)
{
return true;
}
var timeout = Math.Clamp(
engine.Status.OperationTimeoutMilliseconds,
100,
300_000);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linked.CancelAfter(timeout);
while (engine.Status.IsPlayCompletionPending)
{
var completion = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
void OnStatus(object? sender, PlayoutStatusChangedEventArgs args)
{
if (!args.Current.IsPlayCompletionPending)
{
completion.TrySetResult();
}
}
engine.StatusChanged += OnStatus;
try
{
if (!engine.Status.IsPlayCompletionPending)
{
return true;
}
await completion.Task.WaitAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return false;
}
finally
{
engine.StatusChanged -= OnStatus;
}
}
return true;
}
private async ValueTask QuarantinePlayoutAsync()
{
if (Interlocked.Exchange(ref _playoutQuarantined, 1) != 0)
{
return;
}
StopRefreshLoop();
if (_playoutEngine is not null)
{
try
{
await _playoutEngine.QuarantineAsync().ConfigureAwait(false);
}
catch
{
}
}
}
private void QueueOperatorState()
{
if (_closing || !_webViewReady)
{
return;
}
DispatcherQueue.TryEnqueue(() =>
{
if (!_closing)
{
PostState(_controller.Current);
}
});
}
private async Task ShutdownPlayoutRuntimeAsync()
{
if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
{
return;
}
StopRefreshLoop();
var refresh = _refreshTask;
if (refresh is not null)
{
try
{
await refresh.WaitAsync(TimeSpan.FromSeconds(2));
}
catch
{
}
}
var engine = _playoutEngine;
_playoutEngine = null;
_playoutWorkflow = null;
if (engine is null)
{
return;
}
engine.StatusChanged -= OnPlayoutStatusChanged;
try
{
if (Volatile.Read(ref _playoutQuarantined) == 0 &&
engine.Status.State != PlayoutConnectionState.OutcomeUnknown &&
string.IsNullOrWhiteSpace(engine.Status.OnAirSceneName) &&
!engine.Status.IsPlayCompletionPending)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await engine.DisconnectAsync(timeout.Token).ConfigureAwait(false);
}
}
catch
{
}
await engine.DisposeAsync().ConfigureAwait(false);
}
private void ShutdownPlayoutRuntime()
{
try
{
ShutdownPlayoutRuntimeAsync()
.Wait(TimeSpan.FromSeconds(8));
}
catch
{
// Window shutdown is bounded and best-effort. The engine itself keeps an
// outcome-unknown session quarantined and will not send a duplicate BYE.
}
}
}

View File

@@ -14,6 +14,7 @@ public sealed partial class MainWindow : Window
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly LegacyOperatorController _controller;
private readonly LegacyIndustrySelectionWorkflow _industryWorkflow;
private DatabaseRuntime? _databaseRuntime;
private bool _closing;
private bool _intentBusy;
@@ -22,29 +23,144 @@ public sealed partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
EnsureCloseConfirmationAttached();
ILegacyStockLookup stockLookup;
IIndustrySelectionService industrySelectionService;
IThemeSelectionService themeSelectionService;
IExpertSelectionService expertSelectionService;
ITradingHaltSelectionService tradingHaltSelectionService;
IOverseasIndustryIndexSearchService overseasIndustrySearchService;
IWorldStockSearchService worldStockSearchService;
IManualFinancialScreenService manualFinancialService;
IStockSearchService manualFinancialStockSearchService;
INamedPlaylistPersistenceService namedPlaylistPersistenceService;
IThemeCatalogPersistenceService themeCatalogPersistenceService;
IExpertCatalogPersistenceService expertCatalogPersistenceService;
IStockMasterIdentityValidationService stockMasterIdentityValidationService;
IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService;
string? initializationError = null;
try
{
_databaseRuntime = DatabaseRuntime.CreateDefault();
DataQueryExecutor.Configure(_databaseRuntime.Executor);
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
industrySelectionService = new LegacyIndustrySelectionService(
_databaseRuntime.Executor);
themeSelectionService = new LegacyThemeSelectionService(
_databaseRuntime.Executor);
expertSelectionService = new LegacyExpertSelectionService(
_databaseRuntime.Executor);
tradingHaltSelectionService = new LegacyTradingHaltSelectionService(
_databaseRuntime.Executor);
overseasIndustrySearchService = new LegacyOverseasIndustryIndexSearchService(
_databaseRuntime.Executor);
worldStockSearchService = new LegacyWorldStockSearchService(
_databaseRuntime.Executor);
manualFinancialService = new LegacyManualFinancialScreenService(
_databaseRuntime.Executor,
new OracleManualFinancialMutationExecutor(
_databaseRuntime.ConnectionFactory,
_databaseRuntime.Options.Resilience));
manualFinancialStockSearchService = new LegacyStockSearchService(
_databaseRuntime.Executor);
namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService(
_databaseRuntime.Executor,
new OracleNamedPlaylistMutationExecutor(
_databaseRuntime.ConnectionFactory,
_databaseRuntime.Options.Resilience));
var operatorCatalogMutationExecutor = new OperatorCatalogMutationExecutor(
_databaseRuntime.ConnectionFactory,
_databaseRuntime.Options.Resilience);
themeCatalogPersistenceService = new LegacyThemeCatalogPersistenceService(
_databaseRuntime.Executor,
operatorCatalogMutationExecutor);
expertCatalogPersistenceService = new LegacyExpertCatalogPersistenceService(
_databaseRuntime.Executor,
operatorCatalogMutationExecutor);
stockMasterIdentityValidationService =
new LegacyStockMasterIdentityValidationService(_databaseRuntime.Executor);
operatorCatalogSchemaValidationService =
new LegacyOperatorCatalogSchemaValidationService(_databaseRuntime.Executor);
}
catch
{
initializationError =
"데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요.";
stockLookup = new UnavailableStockLookup(initializationError);
industrySelectionService = new UnavailableIndustrySelectionService(
initializationError);
themeSelectionService = new UnavailableThemeSelectionService(
initializationError);
expertSelectionService = new UnavailableExpertSelectionService(
initializationError);
tradingHaltSelectionService = new UnavailableTradingHaltSelectionService(
initializationError);
overseasIndustrySearchService = new UnavailableOverseasIndustryIndexSearchService(
initializationError);
worldStockSearchService = new UnavailableWorldStockSearchService(
initializationError);
manualFinancialService = new UnavailableManualFinancialScreenService(
initializationError);
manualFinancialStockSearchService = new UnavailableStockSearchService(
initializationError);
namedPlaylistPersistenceService = new UnavailableNamedPlaylistPersistenceService(
initializationError);
themeCatalogPersistenceService = new UnavailableThemeCatalogPersistenceService(
initializationError);
expertCatalogPersistenceService = new UnavailableExpertCatalogPersistenceService(
initializationError);
stockMasterIdentityValidationService =
new UnavailableStockMasterIdentityValidationService(initializationError);
operatorCatalogSchemaValidationService =
new UnavailableOperatorCatalogSchemaValidationService(initializationError);
DataQueryExecutor.Reset();
}
_controller = new LegacyOperatorController(stockLookup);
_industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService);
var overseasWorkflow = new LegacyOverseasSelectionWorkflow(
overseasIndustrySearchService,
worldStockSearchService);
var comparisonStore = new LegacyComparisonPairFileStore(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"Data",
"종목비교.dat"),
Path.Combine(AppContext.BaseDirectory, "Data", "종목비교.dat"));
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
_controller = new LegacyOperatorController(
stockLookup,
industryWorkflow: _industryWorkflow,
themeWorkflow: new LegacyThemeWorkflow(themeSelectionService),
expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService),
tradingHaltWorkflow: new LegacyTradingHaltWorkflowController(
tradingHaltSelectionService),
comparisonWorkflow: new LegacyComparisonWorkflowController(
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
comparisonStore),
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
manualFinancialService,
manualFinancialStockSearchService),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(
namedPlaylistPersistenceService),
overseasWorkflow: overseasWorkflow,
manualListsWorkflow: manualListsWorkflow,
operatorCatalogWorkflow: new LegacyOperatorCatalogWorkflowController(
themeSelectionService,
expertSelectionService,
themeCatalogPersistenceService,
expertCatalogPersistenceService,
manualFinancialStockSearchService,
stockMasterIdentityValidationService,
operatorCatalogSchemaValidationService));
if (initializationError is not null)
{
_controller.ReportError(initializationError);
}
InitializePlayoutRuntime();
Root.Loaded += OnRootLoaded;
Closed += OnClosed;
}
@@ -59,10 +175,15 @@ public sealed partial class MainWindow : Window
ConfigureWindow();
await InitializeWebViewAsync();
if (!_closing)
{
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
}
}
private void ConfigureWindow()
{
EnsureCloseConfirmationAttached();
var windowHandle = WindowNative.GetWindowHandle(this);
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
var appWindow = AppWindow.GetFromWindowId(windowId);
@@ -208,15 +329,94 @@ public sealed partial class MainWindow : Window
return;
}
if (intent is LegacySearchStocksIntent searchIntent &&
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
searchIntent.Text.Length == 0))
if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined())
{
PostState(_controller.ReportError(
"이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요."));
return;
}
if (_controller.Current.FixedSectionBatch is not null &&
IsOperatorMutationIntent(intent) &&
!IsFixedSectionBatchMutationIntent(intent))
{
PostState(_controller.ReportError(
"고정 컷 섹션의 수동 입력 중에는 현재 입력 단계만 변경할 수 있습니다. 완료하거나 취소한 뒤 다른 편성 작업을 실행하세요."));
return;
}
if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation())
{
PostState(_controller.ReportError(
"PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요."));
return;
}
if ((intent is LegacySearchStocksIntent searchIntent &&
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
searchIntent.Text.Length == 0)) ||
intent is LegacySelectTabIntent or
LegacySearchThemesIntent or LegacySelectThemeIntent or
LegacyActivateThemeResultIntent or
LegacySearchExpertsIntent or LegacySelectExpertIntent or
LegacySearchTradingHaltsIntent or
LegacySearchComparisonDomesticIntent or
LegacySearchComparisonWorldIntent or
LegacySearchOverseasIndustriesIntent or
LegacySearchOverseasStocksIntent or
LegacyOpenOperatorCatalogIntent or
LegacyBeginUc4ThemeCatalogIntent or
LegacyEditUc4SelectedThemeIntent or
LegacyDeleteUc4SelectedThemeIntent or
LegacyBeginUc6ExpertCatalogIntent or
LegacyEditUc6SelectedExpertIntent or
LegacyDeleteUc6SelectedExpertIntent or
LegacySearchOperatorCatalogIntent or
LegacySelectOperatorCatalogResultIntent or
LegacyBeginCreateThemeCatalogIntent or
LegacyBeginCreateExpertCatalogIntent or
LegacySearchOperatorCatalogStocksIntent or
LegacyActivateOperatorCatalogStockIntent or
LegacySetOperatorCatalogDraftBuyAmountIntent or
LegacySaveOperatorCatalogIntent or
LegacyDeleteOperatorCatalogIntent or
LegacyAddComparisonPairIntent or
LegacyMoveComparisonPairIntent or
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
LegacyOpenManualFinancialIntent or
LegacySearchManualFinancialIntent or
LegacyFindManualFinancialIntent or
LegacyLoadManualFinancialIntent or
LegacyActivateManualFinancialResultIntent or
LegacySearchManualFinancialStocksIntent or
LegacySaveManualFinancialIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacyOpenManualListIntent or
LegacyRefreshManualListIntent or
LegacySaveManualNetSellIntent or
LegacySearchManualViIntent or
LegacySaveManualViIntent or
LegacyImportManualListsIntent or
LegacyRefreshNamedPlaylistsIntent or
LegacyLoadSelectedNamedPlaylistIntent or
LegacyLoadNamedPlaylistByIdIntent or
LegacyCreateNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent or
LegacyExecutePlayoutIntent or
LegacySetFadeDurationIntent or
LegacyToggleBackgroundIntent or
LegacyChooseBackgroundIntent)
{
_intentBusy = true;
PostState(_controller.Current);
}
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
ObserveOperatorMutationQuarantine(state);
if (!_closing)
{
_intentBusy = false;
@@ -272,6 +472,318 @@ public sealed partial class MainWindow : Window
LegacyDropSelectedCutsIntent => _controller.DropSelectedCutsOnPlaylist(),
LegacySetPlaylistEnabledIntent enabled =>
_controller.SetPlaylistRowEnabled(enabled.RowId, enabled.Enabled),
LegacySetAllPlaylistEnabledIntent enabled =>
_controller.SetAllPlaylistRowsEnabled(enabled.Enabled),
LegacySelectPlaylistRowIntent selection =>
_controller.SelectPlaylistRow(
selection.RowId,
selection.Control,
selection.Shift),
LegacyMoveSelectedPlaylistRowsIntent move =>
_controller.MoveSelectedPlaylistRows(move.Direction),
LegacyDeleteSelectedPlaylistRowsIntent =>
_controller.DeleteSelectedPlaylistRows(),
LegacyDeleteAllPlaylistRowsIntent =>
_controller.DeleteAllPlaylistRows(),
LegacyToggleActivePlaylistEnabledIntent =>
_controller.ToggleActivePlaylistRowEnabled(),
LegacySelectPlaylistBoundaryIntent boundary =>
_controller.SelectPlaylistBoundary(boundary.Last),
LegacySelectTabIntent tab =>
await _controller.SelectTabAsync(tab.TabId, cancellationToken),
LegacySwapTabsIntent tabs =>
await _controller.SwapTabsAsync(
tabs.Source,
tabs.Target,
cancellationToken),
LegacySetMovingAveragesIntent movingAverages =>
_controller.SetMovingAverages(movingAverages.Ma5, movingAverages.Ma20),
LegacyActivateFixedActionIntent action =>
await _controller.ActivateFixedActionAsync(
action.ActionId,
cancellationToken),
LegacyActivateFixedSectionIntent section =>
await _controller.ActivateFixedSection(
section.SectionIndex,
cancellationToken),
LegacySelectIndustryIntent industry =>
_controller.SelectIndustry(industry.Index, industry.DoubleClick),
LegacySwapIndustriesIntent =>
_controller.SwapIndustries(),
LegacyActivateIndustryActionIntent action =>
_controller.ActivateIndustryAction(action.ActionId),
LegacyActivateIndustrySectionIntent section =>
_controller.ActivateIndustrySection(section.SectionIndex),
LegacySelectOverseasFixedIndexIntent selection =>
_controller.SelectOverseasFixedIndex(selection.TargetId),
LegacySearchOverseasIndustriesIntent search =>
await _controller.SearchOverseasIndustriesAsync(
search.Query,
cancellationToken),
LegacySearchOverseasStocksIntent search =>
await _controller.SearchOverseasStocksAsync(
search.Query,
cancellationToken),
LegacySelectOverseasIndustryIntent selection =>
_controller.SelectOverseasIndustry(selection.SelectionId),
LegacySelectOverseasStockIntent selection =>
_controller.SelectOverseasStock(selection.SelectionId),
LegacyActivateOverseasActionIntent action =>
_controller.ActivateOverseasAction(action.ActionId),
LegacySearchComparisonDomesticIntent search =>
await _controller.SearchComparisonDomesticAsync(
search.Query,
cancellationToken),
LegacySearchComparisonWorldIntent search =>
await _controller.SearchComparisonWorldAsync(
search.Query,
cancellationToken),
LegacySelectComparisonDomesticIntent selection =>
_controller.SelectComparisonDomesticResult(selection.SelectionId),
LegacySelectComparisonWorldIntent selection =>
_controller.SelectComparisonWorldResult(selection.SelectionId),
LegacySelectComparisonFixedIntent selection =>
_controller.SelectComparisonFixedTarget(selection.TargetId),
LegacyChooseComparisonDomesticIntent selection =>
_controller.ChooseComparisonDomesticResult(selection.SelectionId),
LegacyChooseComparisonWorldIntent selection =>
_controller.ChooseComparisonWorldResult(selection.SelectionId),
LegacyChooseComparisonFixedIntent selection =>
_controller.ChooseComparisonFixedTarget(selection.TargetId),
LegacySwapComparisonTargetsIntent =>
_controller.SwapComparisonTargets(),
LegacyClearComparisonTargetsIntent =>
_controller.ClearComparisonTargets(),
LegacyAddComparisonPairIntent =>
await _controller.AddComparisonPairAsync(cancellationToken),
LegacySelectComparisonPairIntent selection =>
_controller.SelectComparisonPair(selection.RowId),
LegacyMoveComparisonPairIntent move =>
await _controller.MoveComparisonPairAsync(
move.Direction,
cancellationToken),
LegacyDeleteSelectedComparisonPairIntent =>
await _controller.DeleteComparisonPairAsync(
all: false,
cancellationToken),
LegacyDeleteAllComparisonPairsIntent =>
await _controller.DeleteComparisonPairAsync(
all: true,
cancellationToken),
LegacyActivateComparisonActionIntent action =>
_controller.ActivateComparisonAction(action.ActionId),
LegacyActivateComparisonSectionIntent section =>
_controller.ActivateComparisonSection(section.SectionIndex),
LegacySearchThemesIntent search =>
await _controller.SearchThemesAsync(
search.Query,
search.NxtSession,
cancellationToken),
LegacySelectThemeIntent selection =>
await _controller.SelectThemeAsync(
selection.ResultId,
cancellationToken),
LegacyActivateThemeResultIntent activation =>
await _controller.ActivateThemeResultAsync(
activation.ResultId,
cancellationToken),
LegacySetThemeSortIntent sort =>
_controller.SetThemeSort(sort.SortId),
LegacyActivateThemeActionIntent action =>
_controller.ActivateThemeAction(action.ActionId),
LegacySearchExpertsIntent search =>
await _controller.SearchExpertsAsync(search.Query, cancellationToken),
LegacySelectExpertIntent selection =>
await _controller.SelectExpertAsync(
selection.SelectionId,
cancellationToken),
LegacyActivateExpertActionIntent action =>
_controller.ActivateExpertAction(action.ActionId),
LegacySearchTradingHaltsIntent search =>
await _controller.SearchTradingHaltsAsync(
search.Query,
cancellationToken),
LegacySelectTradingHaltIntent selection =>
_controller.SelectTradingHalt(selection.SelectionId),
LegacyToggleTradingHaltSortIntent =>
_controller.ToggleTradingHaltSort(),
LegacyActivateTradingHaltActionIntent action =>
_controller.ActivateTradingHaltAction(action.ActionId),
LegacyOpenOperatorCatalogIntent catalog =>
await _controller.OpenOperatorCatalogAsync(
catalog.Entity,
cancellationToken),
LegacyBeginUc4ThemeCatalogIntent =>
await _controller.BeginThemeCatalogFromUc4Async(cancellationToken),
LegacyEditUc4SelectedThemeIntent =>
await _controller.EditSelectedThemeCatalogAsync(cancellationToken),
LegacyDeleteUc4SelectedThemeIntent =>
await _controller.DeleteSelectedThemeCatalogAsync(cancellationToken),
LegacyBeginUc6ExpertCatalogIntent =>
await _controller.BeginExpertCatalogFromUc6Async(cancellationToken),
LegacyEditUc6SelectedExpertIntent =>
await _controller.EditSelectedExpertCatalogAsync(cancellationToken),
LegacyDeleteUc6SelectedExpertIntent =>
await _controller.DeleteSelectedExpertCatalogAsync(cancellationToken),
LegacyCloseOperatorCatalogIntent =>
_controller.CloseOperatorCatalog(),
LegacySearchOperatorCatalogIntent search =>
await _controller.SearchOperatorCatalogAsync(
search.Query,
search.NxtSession,
cancellationToken),
LegacySelectOperatorCatalogResultIntent selection =>
await _controller.SelectOperatorCatalogResultAsync(
selection.ResultId,
cancellationToken),
LegacyBeginCreateThemeCatalogIntent create =>
await _controller.BeginCreateThemeCatalogAsync(
create.Market,
cancellationToken),
LegacyBeginCreateExpertCatalogIntent =>
await _controller.BeginCreateExpertCatalogAsync(cancellationToken),
LegacySearchOperatorCatalogStocksIntent search =>
await _controller.SearchOperatorCatalogStocksAsync(
search.Query,
cancellationToken),
LegacySelectOperatorCatalogStockIntent selection =>
_controller.SelectOperatorCatalogStock(selection.ResultId),
LegacyAddOperatorCatalogStockIntent add =>
_controller.AddOperatorCatalogStock(add.BuyAmount),
LegacyActivateOperatorCatalogStockIntent activation =>
_controller.ActivateOperatorCatalogStock(
activation.ResultId,
activation.BuyAmount),
LegacyMoveOperatorCatalogDraftRowIntent move =>
_controller.MoveOperatorCatalogDraftRow(move.RowId, move.Direction),
LegacyRemoveOperatorCatalogDraftRowIntent remove =>
_controller.RemoveOperatorCatalogDraftRow(remove.RowId),
LegacySetOperatorCatalogDraftBuyAmountIntent amount =>
_controller.SetOperatorCatalogDraftBuyAmount(
amount.RowId,
amount.BuyAmount),
LegacySaveOperatorCatalogIntent save =>
await _controller.SaveOperatorCatalogAsync(
save.Name,
cancellationToken),
LegacyDeleteOperatorCatalogIntent =>
await _controller.DeleteOperatorCatalogAsync(cancellationToken),
LegacyRefreshNamedPlaylistsIntent =>
await _controller.RefreshNamedPlaylistsAsync(cancellationToken),
LegacySelectNamedPlaylistIntent selection =>
_controller.SelectNamedPlaylist(selection.DefinitionId),
LegacyLoadSelectedNamedPlaylistIntent =>
await _controller.LoadSelectedNamedPlaylistAsync(cancellationToken),
LegacyLoadNamedPlaylistByIdIntent load =>
await _controller.LoadNamedPlaylistByIdAsync(
load.DefinitionId,
cancellationToken),
LegacyCreateNamedPlaylistIntent create =>
await _controller.CreateNamedPlaylistAsync(
create.Title,
cancellationToken),
LegacySaveCurrentNamedPlaylistIntent =>
await _controller.SaveCurrentNamedPlaylistAsync(cancellationToken),
LegacySaveCurrentNamedPlaylistToIntent save =>
await _controller.SaveCurrentNamedPlaylistToAsync(
save.DefinitionId,
cancellationToken),
LegacyDeleteSelectedNamedPlaylistIntent =>
await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken),
LegacyOpenManualFinancialIntent manual =>
await _controller.OpenManualFinancialAsync(
manual.Screen,
cancellationToken),
LegacyCloseManualFinancialIntent =>
_controller.CloseManualFinancial(),
LegacySearchManualFinancialIntent search =>
await _controller.SearchManualFinancialAsync(
search.Query,
cancellationToken),
LegacyFindManualFinancialIntent find =>
await _controller.FindManualFinancialAsync(
find.Query,
find.Direction,
find.Generation,
cancellationToken),
LegacyToggleManualFinancialNameSortIntent sort =>
_controller.ToggleManualFinancialNameSort(sort.Generation),
LegacyLoadManualFinancialIntent load =>
await _controller.LoadManualFinancialAsync(
load.ResultId,
cancellationToken),
LegacyActivateManualFinancialResultIntent activation =>
await _controller.ActivateManualFinancialResultAsync(
activation.ResultId,
cancellationToken),
LegacyBeginManualFinancialCreateIntent =>
_controller.BeginManualFinancialCreate(),
LegacySearchManualFinancialStocksIntent search =>
await _controller.SearchManualFinancialStocksAsync(
search.StockName,
cancellationToken),
LegacySelectManualFinancialStockIntent selection =>
_controller.SelectManualFinancialStock(selection.ResultId),
LegacySaveManualFinancialIntent save =>
await _controller.SaveManualFinancialAsync(
save.Record,
cancellationToken),
LegacyDeleteManualFinancialIntent =>
await _controller.DeleteManualFinancialAsync(
all: false,
cancellationToken),
LegacyDeleteAllManualFinancialIntent =>
await _controller.DeleteManualFinancialAsync(
all: true,
cancellationToken),
LegacyActivateManualFinancialActionIntent action =>
_controller.ActivateManualFinancialAction(action.ActionId),
LegacyOpenManualListIntent manual =>
manual.Screen == LegacyManualListScreen.NetSell
? await _controller.OpenManualNetSellAsync(
manual.Audience,
cancellationToken)
: await _controller.OpenManualViAsync(cancellationToken),
LegacyCloseManualListIntent =>
_controller.CloseManualLists(),
LegacyRefreshManualListIntent =>
await _controller.RefreshManualListsAsync(cancellationToken),
LegacySetManualNetSellCellIntent cell =>
_controller.SetManualNetSellCell(cell.RowId, cell.Field, cell.Value),
LegacySaveManualNetSellIntent =>
await _controller.SaveManualNetSellAsync(cancellationToken),
LegacyAddManualNetSellCutIntent =>
_controller.AddManualNetSellPlaylistRow(),
LegacySearchManualViIntent search =>
await _controller.SearchManualViAsync(search.Query, cancellationToken),
LegacySelectManualViResultIntent result =>
_controller.SelectManualViSearchResult(result.ResultId),
LegacyAddManualViResultIntent result =>
_controller.AddManualViSearchResult(result.ResultId),
LegacyMoveManualViItemIntent move =>
_controller.MoveManualViItem(move.ItemId, move.Direction),
LegacyDeleteManualViItemIntent item =>
_controller.DeleteManualViItem(item.ItemId),
LegacyDeleteAllManualViItemsIntent =>
_controller.DeleteAllManualViItems(),
LegacySaveManualViIntent =>
await _controller.SaveManualViAsync(cancellationToken),
LegacyAddManualViCutIntent =>
_controller.AddManualViPlaylistRow(),
LegacyConfirmManualListIntent =>
await _controller.ConfirmManualListsAsync(cancellationToken),
LegacyImportManualListsIntent =>
await _controller.ImportLegacyManualListsAsync(cancellationToken),
LegacyExecutePlayoutIntent playout =>
await ExecuteOperatorPlayoutAsync(
playout.Command,
cancellationToken),
LegacySetFadeDurationIntent fade =>
await SetFadeDurationAsync(fade.Duration, cancellationToken),
LegacyToggleBackgroundIntent =>
await ToggleBackgroundAsync(cancellationToken),
LegacyChooseBackgroundIntent =>
await ChooseBackgroundAsync(cancellationToken),
LegacyDismissDialogIntent => _controller.DismissDialog(),
_ => _controller.ReportError("지원하지 않는 화면 요청입니다.")
};
@@ -282,12 +794,17 @@ public sealed partial class MainWindow : Window
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
{
Browser.CoreWebView2.PostWebMessageAsJson(
LegacyBridgeProtocol.SerializeState(snapshot, _intentBusy));
LegacyBridgeProtocol.SerializeState(
snapshot,
_intentBusy,
CreatePlayoutSnapshot()));
}
}
private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args)
{
ObserveOperatorMutationQuarantine(
_controller.InvalidateOperatorCatalogBrowserCorrelation());
if (!_closing)
{
ShowError("WebView2 프로세스가 중단되었습니다.", args.ProcessFailedKind.ToString());
@@ -314,9 +831,14 @@ public sealed partial class MainWindow : Window
}
_closing = true;
ObserveOperatorMutationQuarantine(
_controller.InvalidateOperatorCatalogBrowserCorrelation());
Root.Loaded -= OnRootLoaded;
Closed -= OnClosed;
DetachCloseConfirmation();
_lifetimeCancellation.Cancel();
ShutdownPlayoutRuntime();
ShutdownManualListsRuntime();
DataQueryExecutor.Reset();
if (Browser.CoreWebView2 is not null)
@@ -335,6 +857,166 @@ public sealed partial class MainWindow : Window
// cancellation and release the gate without racing disposed objects.
}
private sealed class UnavailableThemeCatalogPersistenceService
: IThemeCatalogPersistenceService
{
private readonly string _message;
public UnavailableThemeCatalogPersistenceService(string message)
{
_message = message;
}
public bool CanMutate => false;
public Task<string> SuggestNextCodeAsync(
ThemeMarket market,
CancellationToken cancellationToken = default) =>
Task.FromException<string>(new InvalidOperationException(_message));
public Task<OperatorCatalogMutationReceipt> CreateAsync(
ThemeCatalogDefinition definition,
CancellationToken cancellationToken = default) =>
Task.FromException<OperatorCatalogMutationReceipt>(
new InvalidOperationException(_message));
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
ThemeSelectionIdentity expectedIdentity,
string newTitle,
IReadOnlyList<ThemeCatalogItem> items,
CancellationToken cancellationToken = default) =>
Task.FromException<OperatorCatalogMutationReceipt>(
new InvalidOperationException(_message));
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
ThemeSelectionIdentity expectedIdentity,
CancellationToken cancellationToken = default) =>
Task.FromException<OperatorCatalogMutationReceipt>(
new InvalidOperationException(_message));
}
private sealed class UnavailableExpertCatalogPersistenceService
: IExpertCatalogPersistenceService
{
private readonly string _message;
public UnavailableExpertCatalogPersistenceService(string message)
{
_message = message;
}
public bool CanMutate => false;
public Task<string> SuggestNextCodeAsync(
CancellationToken cancellationToken = default) =>
Task.FromException<string>(new InvalidOperationException(_message));
public Task<OperatorCatalogMutationReceipt> CreateAsync(
ExpertCatalogDefinition definition,
CancellationToken cancellationToken = default) =>
Task.FromException<OperatorCatalogMutationReceipt>(
new InvalidOperationException(_message));
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
ExpertSelectionIdentity expectedIdentity,
string newName,
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
CancellationToken cancellationToken = default) =>
Task.FromException<OperatorCatalogMutationReceipt>(
new InvalidOperationException(_message));
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
ExpertSelectionIdentity expectedIdentity,
CancellationToken cancellationToken = default) =>
Task.FromException<OperatorCatalogMutationReceipt>(
new InvalidOperationException(_message));
}
private sealed class UnavailableStockMasterIdentityValidationService
: IStockMasterIdentityValidationService
{
private readonly string _message;
public UnavailableStockMasterIdentityValidationService(string message)
{
_message = message;
}
public Task<IReadOnlyList<StockMasterIdentity>> ValidateThemeItemsAsync(
IReadOnlyList<ThemeCatalogItem> items,
CancellationToken cancellationToken = default) =>
Task.FromException<IReadOnlyList<StockMasterIdentity>>(
new InvalidOperationException(_message));
public Task<IReadOnlyList<StockMasterIdentity>> ValidateExpertRecommendationsAsync(
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
CancellationToken cancellationToken = default) =>
Task.FromException<IReadOnlyList<StockMasterIdentity>>(
new InvalidOperationException(_message));
}
private sealed class UnavailableOperatorCatalogSchemaValidationService
: IOperatorCatalogSchemaValidationService
{
private readonly string _message;
public UnavailableOperatorCatalogSchemaValidationService(string message)
{
_message = message;
}
public Task<OperatorCatalogSchemaValidationResult> ValidateAsync(
CancellationToken cancellationToken = default) =>
Task.FromException<OperatorCatalogSchemaValidationResult>(
new InvalidOperationException(_message));
}
private sealed class UnavailableNamedPlaylistPersistenceService
: INamedPlaylistPersistenceService
{
private readonly string _message;
public UnavailableNamedPlaylistPersistenceService(string message)
{
_message = message;
}
public bool CanMutate => false;
public Task<NamedPlaylistListResult> ListAsync(
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
CancellationToken cancellationToken = default) =>
Task.FromException<NamedPlaylistListResult>(
new InvalidOperationException(_message));
public Task<string> SuggestNextProgramCodeAsync(
CancellationToken cancellationToken = default) =>
Task.FromException<string>(new InvalidOperationException(_message));
public Task<NamedPlaylistDocument> LoadAsync(
string programCode,
CancellationToken cancellationToken = default) =>
Task.FromException<NamedPlaylistDocument>(
new InvalidOperationException(_message));
public Task CreateDefinitionAsync(
string programCode,
string title,
CancellationToken cancellationToken = default) =>
Task.FromException(new InvalidOperationException(_message));
public Task ReplaceItemsAsync(
string programCode,
IReadOnlyList<NamedPlaylistStoredItem> items,
CancellationToken cancellationToken = default) =>
Task.FromException(new InvalidOperationException(_message));
public Task DeleteAsync(
string programCode,
CancellationToken cancellationToken = default) =>
Task.FromException(new InvalidOperationException(_message));
}
private sealed class UnavailableStockLookup : ILegacyStockLookup
{
private readonly string _message;
@@ -350,4 +1032,185 @@ public sealed partial class MainWindow : Window
Task.FromException<LegacyStockSearchData>(
new InvalidOperationException(_message));
}
private sealed class UnavailableIndustrySelectionService : IIndustrySelectionService
{
private readonly string _message;
public UnavailableIndustrySelectionService(string message)
{
_message = message;
}
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
IndustryMarket market,
CancellationToken cancellationToken = default) =>
Task.FromException<IReadOnlyList<IndustrySelection>>(
new InvalidOperationException(_message));
}
private sealed class UnavailableThemeSelectionService : IThemeSelectionService
{
private readonly string _message;
public UnavailableThemeSelectionService(string message)
{
_message = message;
}
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<ThemeSearchResult>(new InvalidOperationException(_message));
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) =>
Task.FromException<ThemePreviewResult>(new InvalidOperationException(_message));
}
private sealed class UnavailableExpertSelectionService : IExpertSelectionService
{
private readonly string _message;
public UnavailableExpertSelectionService(string message)
{
_message = message;
}
public Task<ExpertSearchResult> SearchAsync(
string query = "",
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<ExpertSearchResult>(new InvalidOperationException(_message));
public Task<ExpertPreviewResult> PreviewAsync(
ExpertSelectionIdentity identity,
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) =>
Task.FromException<ExpertPreviewResult>(new InvalidOperationException(_message));
}
private sealed class UnavailableTradingHaltSelectionService : ITradingHaltSelectionService
{
private readonly string _message;
public UnavailableTradingHaltSelectionService(string message)
{
_message = message;
}
public Task<TradingHaltSelectionResult> SearchAsync(
string query = "",
int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<TradingHaltSelectionResult>(
new InvalidOperationException(_message));
}
private sealed class UnavailableManualFinancialScreenService
: IManualFinancialScreenService
{
private readonly string _message;
public UnavailableManualFinancialScreenService(string message)
{
_message = message;
}
public bool CanMutate => false;
public Task<ManualFinancialSearchResult> SearchAsync(
ManualFinancialScreenKind screen,
string query = "",
int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<ManualFinancialSearchResult>(
new InvalidOperationException(_message));
public Task<ManualFinancialSnapshot> GetAsync(
ManualFinancialIdentity identity,
CancellationToken cancellationToken = default) =>
Task.FromException<ManualFinancialSnapshot>(
new InvalidOperationException(_message));
public Task<ManualFinancialMutationReceipt> CreateAsync(
ManualFinancialRecord record,
CancellationToken cancellationToken = default) =>
Task.FromException<ManualFinancialMutationReceipt>(
new InvalidOperationException(_message));
public Task<ManualFinancialMutationReceipt> UpdateAsync(
ManualFinancialSnapshot expected,
ManualFinancialRecord replacement,
CancellationToken cancellationToken = default) =>
Task.FromException<ManualFinancialMutationReceipt>(
new InvalidOperationException(_message));
public Task<ManualFinancialMutationReceipt> DeleteAsync(
ManualFinancialSnapshot expected,
CancellationToken cancellationToken = default) =>
Task.FromException<ManualFinancialMutationReceipt>(
new InvalidOperationException(_message));
public Task<ManualFinancialMutationReceipt> DeleteAllAsync(
ManualFinancialScreenKind screen,
CancellationToken cancellationToken = default) =>
Task.FromException<ManualFinancialMutationReceipt>(
new InvalidOperationException(_message));
}
private sealed class UnavailableStockSearchService : IStockSearchService
{
private readonly string _message;
public UnavailableStockSearchService(string message)
{
_message = message;
}
public Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
}
private sealed class UnavailableWorldStockSearchService : IWorldStockSearchService
{
private readonly string _message;
public UnavailableWorldStockSearchService(string message)
{
_message = message;
}
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<WorldStockSearchResult>(
new InvalidOperationException(_message));
}
private sealed class UnavailableOverseasIndustryIndexSearchService
: IOverseasIndustryIndexSearchService
{
private readonly string _message;
public UnavailableOverseasIndustryIndexSearchService(string message)
{
_message = message;
}
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<OverseasIndustryIndexSearchResult>(
new InvalidOperationException(_message));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,8 @@
<section class="left-panel" aria-label="종목 및 컷 선택">
<header class="brand-line">
<div class="brand"><span>매일경제TV</span><strong>VRi</strong></div>
<div class="connection-state" aria-label="Tornado2 연결 상태">미연결</div>
<div class="connection-state" id="playout-connection-state"
aria-label="Tornado2 연결 상태">미연결</div>
</header>
<div class="search-line">
@@ -23,10 +24,16 @@
<div id="stock-results" class="stock-results" role="listbox" aria-label="종목명"></div>
<div class="manual-actions" aria-label="수동 입력">
<button type="button" disabled>주요매출 구성</button>
<button type="button" disabled>성장성 지표</button>
<button type="button" disabled>매출액</button>
<button type="button" disabled>영업이익</button>
<button type="button" data-manual-screen="revenue-composition">주요매출 구성</button>
<button type="button" data-manual-screen="growth-metrics">성장성 지표</button>
<button type="button" data-manual-screen="sales">매출액</button>
<button type="button" data-manual-screen="operating-profit">영업이익</button>
</div>
<div class="manual-list-actions" aria-label="수동 송출 목록" hidden>
<button type="button" data-manual-list-screen="individual">개인 순매도</button>
<button type="button" data-manual-list-screen="foreign">외국인 순매도</button>
<button type="button" data-manual-list-screen="institution">기관 순매도</button>
<button type="button" data-manual-list-screen="vi">VI 발동</button>
</div>
<div id="cut-list" class="cut-list" role="listbox" aria-label="종목 컷" aria-multiselectable="true"></div>
@@ -34,74 +41,195 @@
</section>
<section class="catalog-panel" aria-label="분류별 그래픽">
<nav class="market-tabs" aria-label="시장 분류">
<button class="active" type="button" disabled>해외</button>
<button type="button" disabled>환율</button>
<button type="button" disabled>지수</button>
<button type="button" disabled>코스피</button>
<button type="button" disabled>코스닥</button>
<button type="button" disabled>비교</button>
<button type="button" disabled>테마</button>
<button type="button" disabled>해외종목</button>
<button type="button" disabled>전문가</button>
<button type="button" disabled>정지</button>
<nav id="market-tabs" class="market-tabs" aria-label="시장 분류">
<button class="active" type="button" draggable="true" data-tab-id="overseas">해외</button>
<button type="button" draggable="true" data-tab-id="exchange">환율</button>
<button type="button" draggable="true" data-tab-id="index">지수</button>
<button type="button" draggable="true" data-tab-id="kospiIndustry">코스피</button>
<button type="button" draggable="true" data-tab-id="kosdaqIndustry">코스닥</button>
<button type="button" draggable="true" data-tab-id="comparison">비교</button>
<button type="button" draggable="true" data-tab-id="theme">테마</button>
<button type="button" draggable="true" data-tab-id="overseasStocks">해외종목</button>
<button type="button" draggable="true" data-tab-id="expert">전문가</button>
<button type="button" draggable="true" data-tab-id="tradingHalt">정지</button>
</nav>
<label class="expand-line"><input type="checkbox" checked disabled> Expand</label>
<label class="expand-line"><input id="catalog-expand-all" type="checkbox" checked> Expand</label>
<div class="category-title">Category</div>
<div class="category-tree" aria-label="원본 카테고리">
<div class="tree-root"><strong>[1열판기본]</strong></div>
<div class="tree-item">▸ 다우</div>
<div class="tree-item">▸ 나스닥</div>
<div class="tree-item">▸ S&amp;P</div>
<div class="tree-item">▸ 독일</div>
<div class="tree-item">▸ 영국</div>
<div class="tree-item">▸ 프랑스</div>
<div class="tree-item">▸ 니케이</div>
<div class="tree-item">▸ 중국 상해</div>
<div class="tree-item">▸ 홍콩 항셍</div>
<div class="tree-item">▸ 대만 가권</div>
<div class="tree-item">▸ 싱가포르 지수</div>
<div class="tree-item">▸ 필리핀 지수</div>
<div class="tree-item">▸ 말레이시아 지수</div>
<div class="tree-root second"><strong>[2열판-]</strong></div>
<div class="tree-item">▸ 공산품 [원면, 목화(면화)]</div>
<div class="tree-item">▸ 원자재 [국제 금, 국제 은]</div>
</div>
<div id="category-tree" class="category-tree" aria-label="원본 카테고리"></div>
</section>
<section class="playlist-panel" aria-label="플레이리스트">
<div class="playlist-toolbar">
<label><input type="checkbox" checked disabled> 전체</label>
<label><input type="checkbox" checked disabled> 5일선</label>
<label><input type="checkbox" checked disabled> 20일선</label>
<button type="button" disabled></button>
<button type="button" disabled></button>
<label><input id="playlist-enable-all" type="checkbox" checked disabled> 전체</label>
<label><input id="moving-average-5" type="checkbox" checked> 5일선</label>
<label><input id="moving-average-20" type="checkbox" checked> 20일선</label>
<button id="playlist-move-up" type="button" disabled aria-label="선택 행 위로 이동"></button>
<button id="playlist-move-down" type="button" disabled aria-label="선택 행 아래로 이동"></button>
<span class="toolbar-spacer"></span>
<label><input type="checkbox" disabled> 배경없음(F3)</label>
<button type="button" disabled>배경파일(F2)</button>
<input class="background-name" value="기본.vrv" readonly>
<button type="button" disabled>DB 저장</button>
<button type="button" disabled>DB 불러오기</button>
<button type="button" disabled>선택삭제</button>
<button type="button" disabled>전체삭제</button>
<label><input id="playout-background-none" type="checkbox" disabled> 배경없음(F3)</label>
<button id="playout-background-file" type="button" disabled>배경파일(F2)</button>
<input id="playout-background-name" class="background-name" value="배경없음" readonly>
<label class="fade-control" for="playout-fade-duration" hidden>효과</label>
<select id="playout-fade-duration" disabled aria-label="장면 효과 시간" hidden>
<option value="0">1</option><option value="1">2</option>
<option value="2">3</option><option value="3">4</option>
<option value="4">5</option><option value="5">6</option>
<option value="6">7</option><option value="7">8</option>
<option value="8">9</option><option value="9">10</option>
<option value="10">11</option><option value="11">12</option>
<option value="12">13</option><option value="13">14</option>
<option value="14">15</option><option value="15">16</option>
<option value="16">17</option><option value="17">18</option>
<option value="18">19</option><option value="19">20</option>
</select>
<button id="db-save-button" type="button" disabled>DB 저장</button>
<button id="db-load-button" type="button" disabled>DB 불러오기</button>
<button id="playlist-delete-selected" type="button" disabled>선택삭제(DEL)</button>
<button id="playlist-delete-all" type="button" disabled>전체삭제</button>
</div>
<div id="playlist-drop-zone" class="playlist-grid">
<div class="playlist-header playlist-row">
<div>송출</div><div>종류</div><div>종목</div><div>컷파일</div><div>세부사항</div><div>Page</div>
</div>
<div id="playlist-rows" class="playlist-rows"></div>
<div id="playlist-rows" class="playlist-rows" role="listbox" aria-label="송출 목록" aria-multiselectable="true"></div>
</div>
<div class="playout-actions">
<button class="take-in" type="button" disabled> TAKE IN [F8]</button>
<button class="next" type="button" disabled> NEXT</button>
<button class="take-out" type="button" disabled>TAKE OUT[Esc]</button>
<button id="playout-prepare" class="prepare" type="button" disabled hidden
aria-hidden="true">PREPARE</button>
<button id="playout-take-in" class="take-in" type="button" disabled>● TAKE IN [F8]</button>
<button id="playout-next" class="next" type="button" disabled> NEXT</button>
<button id="playout-take-out" class="take-out" type="button" disabled>✖ TAKE OUT[Esc]</button>
</div>
<div class="playout-status">원본 호환 UI 기준선 · 송출 연결 안 함</div>
<div id="playout-status" class="playout-status" aria-live="polite">송출 상태를 확인하고 있습니다.</div>
</section>
</main>
<div id="named-playlist-modal" class="named-playlist-backdrop" hidden>
<section class="named-playlist-dialog" role="dialog" aria-modal="true"
aria-labelledby="named-playlist-title">
<header>
<h2 id="named-playlist-title">DB 재생목록</h2>
<button id="named-playlist-close" type="button" aria-label="닫기">×</button>
</header>
<div id="named-playlist-status" class="named-playlist-status" aria-live="polite"></div>
<div class="named-playlist-body">
<div id="named-playlist-definitions" class="named-playlist-definitions"
role="listbox" aria-label="저장된 DB 재생목록"></div>
<section class="named-playlist-create" aria-labelledby="named-playlist-create-title">
<h3 id="named-playlist-create-title">새 재생목록</h3>
<label for="named-playlist-new-title">이름</label>
<input id="named-playlist-new-title" maxlength="128" autocomplete="off"
spellcheck="false">
<button id="named-playlist-create-button" type="button" disabled>새로 만들기</button>
<p>DB 쓰기는 이 버튼 또는 저장·삭제 버튼을 누른 경우에만 한 번 실행됩니다.</p>
</section>
</div>
<footer>
<button id="named-playlist-delete-button" type="button" disabled>선택 삭제</button>
<span class="named-playlist-footer-spacer"></span>
<button id="named-playlist-cancel-button" type="button">취소</button>
<button id="named-playlist-confirm-button" type="button" disabled>확인</button>
</footer>
</section>
</div>
<div id="manual-financial-modal" class="manual-financial-backdrop" hidden>
<section class="manual-financial-dialog" role="dialog" aria-modal="true"
aria-labelledby="manual-financial-title">
<header>
<h2 id="manual-financial-title">수동 재무 입력</h2>
<button id="manual-financial-close" type="button" aria-label="닫기">×</button>
</header>
<div id="manual-financial-workspace" class="manual-financial-workspace"></div>
</section>
</div>
<div id="manual-list-modal" class="manual-list-backdrop" hidden>
<section class="manual-list-dialog" role="dialog" aria-modal="true"
aria-labelledby="manual-list-title">
<header>
<h2 id="manual-list-title">수동 송출 목록</h2>
<button id="manual-list-close" type="button" aria-label="닫기">×</button>
</header>
<div id="manual-list-workspace" class="manual-list-workspace"></div>
</section>
</div>
<div id="operator-catalog-modal" class="operator-catalog-backdrop" hidden>
<section class="operator-catalog-dialog" role="dialog" aria-modal="true"
aria-labelledby="operator-catalog-title">
<header>
<h2 id="operator-catalog-title">ThemeA / EList 관리</h2>
<button id="operator-catalog-close" type="button" aria-label="닫기">×</button>
</header>
<div id="operator-catalog-status" class="operator-catalog-status"
aria-live="polite"></div>
<div class="operator-catalog-toolbar">
<button id="operator-catalog-theme-tab" type="button">ThemeA</button>
<button id="operator-catalog-expert-tab" type="button">EList</button>
<input id="operator-catalog-query" type="search" maxlength="64"
autocomplete="off" placeholder="이름 검색">
<select id="operator-catalog-session" aria-label="NXT 세션">
<option value="preMarket">NXT 장전</option>
<option value="afterMarket">NXT 시간외</option>
</select>
<button id="operator-catalog-search" type="button">검색</button>
</div>
<div class="operator-catalog-body">
<section class="operator-catalog-list-panel" aria-labelledby="operator-catalog-list-title">
<div class="operator-catalog-panel-header">
<h3 id="operator-catalog-list-title">DB 목록</h3>
<select id="operator-catalog-new-market" aria-label="새 ThemeA 시장">
<option value="krx">KRX</option>
<option value="nxt">NXT</option>
</select>
<button id="operator-catalog-new" type="button">새 항목</button>
</div>
<div id="operator-catalog-results" class="operator-catalog-results"
role="listbox" aria-label="ThemeA EList 목록"></div>
</section>
<section id="operator-catalog-editor" class="operator-catalog-editor"
aria-labelledby="operator-catalog-editor-title">
<div class="operator-catalog-panel-header">
<h3 id="operator-catalog-editor-title">추가·편집</h3>
<span id="operator-catalog-code" class="operator-catalog-code"></span>
</div>
<label class="operator-catalog-name-field">
<span id="operator-catalog-name-label">이름</span>
<input id="operator-catalog-name" maxlength="128" autocomplete="off">
</label>
<div class="operator-catalog-stock-search">
<input id="operator-catalog-stock-query" type="search" maxlength="64"
autocomplete="off" placeholder="종목명 검색">
<button id="operator-catalog-stock-search" type="button">종목 검색</button>
</div>
<div id="operator-catalog-stock-results" class="operator-catalog-stock-results"
role="listbox" aria-label="stock master 검색 결과"></div>
<div class="operator-catalog-add-line">
<label id="operator-catalog-amount-label" for="operator-catalog-amount">매수가</label>
<input id="operator-catalog-amount" type="number" min="1" step="1"
inputmode="numeric">
<button id="operator-catalog-add-stock" type="button">선택 종목 추가</button>
</div>
<div class="operator-catalog-draft-header">
<strong>구성 종목</strong>
<span>위·아래 버튼으로 송출 순서를 변경합니다.</span>
</div>
<div id="operator-catalog-draft-rows" class="operator-catalog-draft-rows"
aria-label="ThemeA EList 구성 종목"></div>
<footer class="operator-catalog-editor-actions">
<button id="operator-catalog-delete" type="button">선택 삭제</button>
<span></span>
<button id="operator-catalog-cancel" type="button">편집 취소</button>
<button id="operator-catalog-save" type="button">저장</button>
</footer>
</section>
</div>
</section>
</div>
<div id="legacy-dialog" class="dialog-backdrop" hidden>
<div class="legacy-dialog" role="alertdialog" aria-modal="true" aria-labelledby="legacy-dialog-message">
<div id="legacy-dialog-message"></div>
@@ -110,5 +238,6 @@
</div>
<script src="app.js"></script>
<script src="playout-ui.js"></script>
</body>
</html>

View File

@@ -0,0 +1,172 @@
(function () {
"use strict";
const webview = window.chrome && window.chrome.webview;
const connection = document.getElementById("playout-connection-state");
const prepare = document.getElementById("playout-prepare");
const takeIn = document.getElementById("playout-take-in");
const next = document.getElementById("playout-next");
const takeOut = document.getElementById("playout-take-out");
const backgroundNone = document.getElementById("playout-background-none");
const backgroundFile = document.getElementById("playout-background-file");
const backgroundName = document.getElementById("playout-background-name");
const fadeDuration = document.getElementById("playout-fade-duration");
const status = document.getElementById("playout-status");
const controls = [prepare, takeIn, next, takeOut, backgroundNone,
backgroundFile, fadeDuration];
let currentState = null;
let localBusy = false;
function hasOpenDialog() {
return Array.from(document.querySelectorAll("[role='dialog'], [role='alertdialog']"))
.some(function (dialog) {
const container = dialog.closest("[hidden]");
return !container && !dialog.hidden;
});
}
function post(type, payload) {
if (!webview || localBusy || hasOpenDialog()) return;
localBusy = true;
render(currentState);
webview.postMessage({ type: type, payload: payload || {} });
}
function setAllDisabled(disabled) {
controls.forEach(function (control) {
if (control) control.disabled = disabled;
});
}
function phaseLabel(phase) {
if (phase === "prepared") return "PREPARED";
if (phase === "program") return "PROGRAM";
return "IDLE";
}
function connectionLabel(playout) {
if (!playout) return "미연결";
if (playout.outcomeUnknown) return "결과 불명확";
if (playout.mode === "disabled") return "송출 비활성";
if (playout.mode === "dryRun") return "DRY RUN";
if (playout.isConnected) return "Tornado2 연결됨";
if (playout.isProcessRunning) return "Tornado2 감지됨";
return "미연결";
}
function pageLabel(playout) {
if (!playout || playout.pageCount <= 0) return "";
return " · Page " + String(playout.pageIndexZeroBased + 1) +
"/" + String(playout.pageCount);
}
function nextLabel(playout) {
if (!playout || playout.nextKind === "none") return "";
return playout.nextKind === "pageNext" ? " · 다음 페이지" : " · 다음 항목";
}
function render(state) {
currentState = state || currentState;
const playout = currentState && currentState.playout;
const nativeBusy = !!(currentState && currentState.isBusy) ||
!!(playout && playout.isBusy);
const busy = localBusy || nativeBusy;
if (!playout) {
connection.textContent = "미연결";
status.textContent = "송출 엔진 상태를 사용할 수 없습니다.";
setAllDisabled(true);
return;
}
const commandAvailable = playout.isCommandAvailable === true &&
playout.outcomeUnknown !== true;
const hasPlaylist = Array.isArray(currentState.playlist) &&
currentState.playlist.some(function (row) { return row.isEnabled === true; });
const liveTakeInAllowed = playout.mode !== "live" ||
playout.liveTakeInAllowed === true;
const canPrepareToggle = playout.phase === "idle" ? hasPlaylist :
playout.phase === "prepared" || playout.phase === "program";
connection.textContent = connectionLabel(playout);
connection.classList.toggle("connected", playout.isConnected === true);
connection.classList.toggle("outcome-unknown", playout.outcomeUnknown === true);
prepare.classList.toggle("active", playout.phase !== "idle");
prepare.disabled = busy || !commandAvailable || !canPrepareToggle ||
playout.isPlayCompletionPending === true;
const canTakeIn = playout.phase === "prepared" ||
(playout.phase === "idle" && hasPlaylist);
takeIn.disabled = busy || !commandAvailable || !liveTakeInAllowed ||
!canTakeIn || playout.isPlayCompletionPending === true;
next.disabled = busy || !commandAvailable ||
playout.phase !== "program" || playout.nextKind === "none" ||
playout.isPlayCompletionPending === true;
takeOut.disabled = busy || !commandAvailable ||
(playout.phase !== "prepared" && playout.phase !== "program");
backgroundNone.disabled = busy || playout.canChangeBackground !== true;
backgroundFile.disabled = busy || playout.canChangeBackground !== true;
fadeDuration.disabled = busy || playout.canChangeBackground !== true;
backgroundNone.checked = playout.backgroundEnabled !== true;
backgroundName.value = playout.backgroundEnabled === true
? (playout.backgroundFileName || "선택한 배경") : "배경없음";
fadeDuration.value = String(playout.fadeDuration);
const scene = playout.onAirCode || playout.preparedCode;
const sceneLabel = scene ? " · " + scene : "";
const refresh = playout.refreshActive
? " · 자동 갱신 " + String(playout.refreshCompletedCount) + "회" : "";
status.textContent = phaseLabel(playout.phase) + sceneLabel +
pageLabel(playout) + nextLabel(playout) + refresh +
(playout.message ? " · " + playout.message : "");
}
prepare.addEventListener("click", function () {
post("prepare-playout", {});
});
takeIn.addEventListener("click", function () {
post("take-in", {});
});
next.addEventListener("click", function () {
post("next-playout", {});
});
takeOut.addEventListener("click", function () {
post("take-out", {});
});
backgroundFile.addEventListener("click", function () {
post("choose-background", {});
});
backgroundNone.addEventListener("change", function () {
post("toggle-background", {});
});
fadeDuration.addEventListener("change", function () {
post("set-fade-duration", { duration: Number(fadeDuration.value) });
});
document.addEventListener("keydown", function (event) {
if (event.repeat || hasOpenDialog()) return;
if (event.key === "F2") {
event.preventDefault();
if (!backgroundFile.disabled) backgroundFile.click();
} else if (event.key === "F3") {
event.preventDefault();
if (!backgroundNone.disabled) backgroundNone.click();
} else if (event.key === "F8") {
event.preventDefault();
if (!takeIn.disabled) takeIn.click();
} else if (event.key === "Escape") {
event.preventDefault();
if (!takeOut.disabled) takeOut.click();
}
});
if (webview) {
webview.addEventListener("message", function (event) {
if (event.data && event.data.type === "state" && event.data.payload) {
localBusy = false;
render(event.data.payload);
}
});
} else {
setAllDisabled(true);
status.textContent = "WebView2 송출 브리지를 사용할 수 없습니다.";
}
}());

View File

@@ -7,6 +7,7 @@
}
* { box-sizing: border-box; }
*[hidden] { display: none !important; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body.busy { cursor: wait; }
body.busy .operator-shell { pointer-events: none; }
@@ -32,6 +33,8 @@ button:disabled { color: #555; opacity: 1; }
.brand span { color: #ed671f; font-size: 28px; font-weight: 800; letter-spacing: -2px; }
.brand strong { font: italic 900 38px Arial, sans-serif; letter-spacing: -5px; }
.connection-state { padding: 10px 12px; background: #c40000; color: white; font: 700 18px Consolas, monospace; }
.connection-state.connected { background: #176c32; }
.connection-state.outcome-unknown { background: #8a4d00; }
.search-line { display: grid; grid-template-columns: 40px 162px 1fr 117px; align-items: center; gap: 3px; height: 38px; }
.search-line label { font-weight: 700; }
@@ -45,6 +48,85 @@ button:disabled { color: #555; opacity: 1; }
.manual-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 9px 0; }
.manual-actions button { height: 34px; font-size: 12px; font-weight: 700; }
.manual-list-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 0 0 9px; }
.manual-list-actions button { height: 31px; padding: 2px 3px; font-size: 11px; font-weight: 700; }
.named-playlist-backdrop { position: fixed; inset: 0; z-index: 19; background: rgba(0, 0, 0, .3); }
.named-playlist-backdrop[hidden] { display: none; }
.named-playlist-dialog { position: absolute; left: 50%; top: 50%; width: 650px; height: 500px; transform: translate(-50%, -50%); display: grid; grid-template-rows: 45px auto minmax(0, 1fr) 52px; border: 1px solid #666; background: #f2f2f2; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.named-playlist-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; background: #ececec; }
.named-playlist-dialog h2 { margin: 0; font-size: 18px; }
.named-playlist-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
.named-playlist-status { min-height: 38px; padding: 8px 12px; border-bottom: 1px solid #bbb; color: #444; }
.named-playlist-status.error { background: #fff1f3; color: #9b001b; font-weight: 700; }
.named-playlist-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 9px; padding: 10px; }
.named-playlist-definitions { min-height: 0; overflow: auto; border: 1px solid #888; background: #fff; }
.named-playlist-definitions button { display: block; width: 100%; min-height: 28px; padding: 4px 8px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.named-playlist-definitions button.selected { background: #d9e8f9; font-weight: 700; }
.named-playlist-create { align-self: start; display: grid; grid-template-columns: 1fr; gap: 7px; padding: 10px; border: 1px solid #aaa; background: #fff; }
.named-playlist-create h3 { margin: 0 0 5px; font-size: 15px; }
.named-playlist-create input, .named-playlist-create button { min-width: 0; height: 30px; }
.named-playlist-create p { margin: 5px 0 0; color: #555; font-size: 11px; line-height: 1.45; }
.named-playlist-dialog > footer { display: flex; align-items: center; gap: 8px; padding: 9px 11px; border-top: 1px solid #aaa; }
.named-playlist-dialog > footer button { min-width: 96px; height: 32px; }
.named-playlist-footer-spacer { flex: 1; }
.named-playlist-dialog > footer #named-playlist-confirm-button { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
.manual-financial-backdrop { position: fixed; inset: 0; z-index: 18; background: rgba(0, 0, 0, .3); }
.manual-financial-backdrop[hidden] { display: none; }
.manual-financial-dialog { position: absolute; left: 50%; top: 50%; width: min(1120px, 92vw); height: min(800px, 90vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr); border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.manual-financial-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; background: #ececec; }
.manual-financial-dialog h2 { margin: 0; font-size: 18px; }
.manual-financial-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
.manual-financial-workspace { min-height: 0; display: grid; grid-template-rows: 38px minmax(0, 1fr); gap: 7px; padding: 8px; }
.manual-financial-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; }
.manual-financial-toolbar input, .manual-financial-toolbar button { min-width: 0; height: 30px; }
.manual-financial-body { min-height: 0; display: grid; grid-template-columns: 270px minmax(0, 1fr); gap: 8px; }
.manual-financial-list { min-height: 0; overflow: auto; border: 1px solid #999; background: #fff; }
.manual-financial-list button { display: block; width: 100%; min-height: 25px; padding: 3px 7px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.manual-financial-list button.selected { background: #dcdcdc; }
.manual-financial-list button.manual-financial-list-header { position: sticky; top: 0; z-index: 1; background: #ececec; border-bottom: 1px solid #888; font-weight: 700; }
.manual-financial-editor { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 7px; padding: 8px; border: 1px solid #999; background: #fff; }
.manual-financial-stock-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; }
.manual-financial-stock-line input, .manual-financial-stock-line button { height: 30px; }
.manual-financial-stock-results { display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; max-height: 84px; overflow: auto; padding: 3px; border: 1px solid #ccc; }
.manual-financial-stock-results button { padding: 3px 7px; }
.manual-financial-stock-results button.selected { border-color: #145ab4; background: #dceaff; }
.manual-financial-verification { min-height: 22px; color: #145ab4; }
.manual-financial-fields { display: grid; gap: 7px; }
.manual-financial-field { display: grid; grid-template-columns: 100px minmax(0, 1fr); align-items: center; gap: 6px; }
.manual-financial-field input { min-width: 0; height: 28px; }
.manual-financial-grid { display: grid; gap: 4px; align-items: center; }
.manual-financial-grid input { min-width: 0; height: 27px; }
.manual-revenue-grid, .manual-quarter-grid { grid-template-columns: minmax(0, 1fr) minmax(120px, .45fr); }
.manual-growth-grid { grid-template-columns: 130px repeat(4, minmax(90px, 1fr)); }
.manual-financial-actions { margin-top: auto; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.manual-financial-actions button { min-height: 34px; }
.manual-financial-actions .manual-primary { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
.manual-financial-message { color: #444; }
.manual-financial-warning { padding: 7px; border: 1px solid #b00020; background: #fff1f3; color: #9b001b; font-weight: 700; }
.manual-list-backdrop { position: fixed; inset: 0; z-index: 19; background: rgba(0, 0, 0, .32); }
.manual-list-backdrop[hidden] { display: none; }
.manual-list-dialog { position: absolute; left: 50%; top: 50%; width: min(980px, 94vw); height: min(760px, 91vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr); border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.manual-list-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; }
.manual-list-dialog h2 { margin: 0; font-size: 18px; }
.manual-list-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
.manual-list-workspace { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 8px; padding: 10px; }
.manual-list-tabs, .manual-list-actions-row, .manual-list-search { display: flex; gap: 6px; align-items: center; }
.manual-list-tabs button.active { border-color: #145ab4; background: #dceaff; font-weight: 700; }
.manual-list-table-wrap { min-height: 0; overflow: auto; border: 1px solid #999; background: #fff; }
.manual-list-table { width: 100%; border-collapse: collapse; }
.manual-list-table th, .manual-list-table td { padding: 4px; border-right: 1px solid #ddd; border-bottom: 1px solid #ddd; }
.manual-list-table input { width: 100%; min-width: 0; height: 27px; }
.manual-list-table .manual-list-row-actions { width: 170px; white-space: nowrap; }
.manual-list-search input { flex: 1; min-width: 0; height: 30px; }
.manual-list-search-results { display: flex; flex-wrap: wrap; gap: 4px; max-height: 110px; overflow: auto; padding: 5px; border: 1px solid #aaa; background: #fff; }
.manual-list-search-results button.selected { background: #dcdcdc; font-weight: 600; }
.manual-list-status { padding: 7px; border: 1px solid #bbb; background: #fff; }
.manual-list-status.error { border-color: #b00020; background: #fff1f3; color: #9b001b; font-weight: 700; }
.manual-list-actions-row { margin-top: auto; justify-content: flex-end; }
.manual-list-actions-row button { min-width: 100px; height: 34px; }
.manual-list-actions-row .primary { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
.cut-list { flex: 1; min-height: 0; border: 1px solid #999; background: #fff; overflow-y: auto; user-select: none; }
.cut-row { display: grid; grid-template-columns: 62px 1fr; min-height: 23px; border-bottom: 1px solid #ddd; background: #fff; font-size: 15px; }
@@ -63,13 +145,123 @@ button:disabled { color: #555; opacity: 1; }
.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; }
.tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; }
.tree-root.second { margin-top: 15px; }
.tree-item { padding: 3px 0 3px 29px; font-size: 15px; }
.catalog-section > summary { margin: 5px 0 2px; color: #168000; font-size: 16px; cursor: default; user-select: none; }
.catalog-actions { display: grid; }
.tree-item { width: 100%; padding: 3px 0 3px 29px; border: 0; background: transparent; color: #111; font: inherit; font-size: 15px; text-align: left; cursor: default; }
.tree-item:hover, .tree-item:focus-visible { background: #dcdcdc; outline: none; }
.tree-item.unavailable { color: #777; }
.industry-workspace { display: grid; grid-template-rows: minmax(170px, 42%) minmax(0, 1fr); height: 100%; gap: 7px; }
.industry-selector { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
.industry-slots { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 4px; margin-bottom: 5px; }
.industry-slots input { min-width: 0; }
.industry-list { overflow: auto; border: 1px solid #aaa; background: #fff; }
.industry-list button { display: block; width: 100%; padding: 2px 7px; border: 0; background: #fff; text-align: left; }
.industry-list button.selected { background: #dcdcdc; }
.industry-action-tree { min-height: 0; overflow: auto; border-top: 1px solid #aaa; padding-top: 4px; }
.industry-action-tree summary { color: #168000; font-size: 15px; cursor: default; }
.overseas-workspace { display: grid; grid-template-rows: minmax(92px, 20%) minmax(170px, 42%) minmax(0, 1fr); height: 100%; gap: 7px; }
.overseas-panel { min-height: 0; }
.overseas-panel h3 { margin: 0 0 4px; color: #168000; font-size: 14px; font-weight: 600; }
.overseas-fixed-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); }
.overseas-fixed-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: auto; border: 1px solid #aaa; background: #fff; }
.overseas-fixed-list button { min-width: 0; padding: 2px 5px; border-width: 0 1px 1px 0; background: #fff; text-align: left; }
.overseas-fixed-list button.selected { background: #dcdcdc; font-weight: 600; }
.overseas-searches { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; gap: 7px; }
.overseas-search-panel { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; }
.overseas-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px; }
.overseas-search-line input { min-width: 0; }
.overseas-selected, .overseas-note { min-height: 18px; padding: 2px 3px; color: #444; font-size: 11px; }
.overseas-note.error { color: #a40000; }
.overseas-results { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
.overseas-results button { display: block; width: 100%; padding: 2px 5px; border: 0; background: #fff; text-align: left; }
.overseas-results button.selected { background: #dcdcdc; }
.overseas-action-panel { overflow: auto; border-top: 1px solid #aaa; padding-top: 4px; }
.overseas-action-panel summary { color: #168000; font-size: 15px; cursor: default; }
.comparison-workspace { display: grid; grid-template-rows: minmax(110px, 22%) minmax(70px, 16%) minmax(150px, 1fr) auto; height: 100%; gap: 6px; }
.comparison-searches { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; gap: 6px; }
.comparison-search-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
.comparison-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px; }
.comparison-results, .comparison-saved { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
.comparison-results button, .comparison-saved button { display: block; width: 100%; padding: 2px 5px; border: 0; background: #fff; text-align: left; }
.comparison-results button:hover, .comparison-results button.selected, .comparison-saved button.selected { background: #dcdcdc; }
.comparison-fixed { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
.comparison-fixed-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: auto; border: 1px solid #aaa; }
.comparison-fixed-list button { padding: 2px 4px; text-align: left; }
.comparison-fixed-list button.selected { background: #dcdcdc; font-weight: 600; }
.comparison-pair-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-height: 0; gap: 4px; }
.comparison-draft { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto auto; gap: 4px; }
.comparison-draft input { min-width: 0; }
.comparison-saved-tools { display: flex; gap: 4px; justify-content: flex-end; }
.comparison-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
.theme-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: 100%; gap: 7px; }
.theme-search-line { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; }
.theme-search-line #theme-search { grid-column: span 2; min-width: 0; }
.theme-search-line button[data-uc4-theme-catalog="create"] { grid-column: 2; }
.theme-body { display: grid; grid-template-columns: minmax(150px, 42%) minmax(0, 1fr); min-height: 0; gap: 7px; }
.theme-results, .theme-preview-panel { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
.theme-results button { display: block; width: 100%; padding: 3px 6px; border: 0; background: #fff; text-align: left; }
.theme-results button.selected { background: #dcdcdc; }
.theme-preview-panel { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 4px; padding: 5px; }
.theme-preview-list { overflow: auto; margin: 0; padding-left: 28px; }
.theme-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
.expert-workspace, .halt-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: 100%; gap: 7px; }
.expert-search-line { display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, auto); gap: 5px; }
.expert-body { display: grid; grid-template-columns: minmax(150px, 40%) minmax(0, 1fr); min-height: 0; gap: 7px; }
.expert-results, .expert-preview, .halt-results { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
.expert-results button, .halt-results button { display: block; width: 100%; padding: 3px 6px; border: 0; background: #fff; text-align: left; }
.expert-results button.selected, .halt-results button.selected { background: #dcdcdc; }
.expert-preview { padding: 6px; }
.expert-preview ol { margin: 5px 0 0; padding-left: 28px; }
.expert-actions, .halt-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
.halt-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; }
.operator-catalog-backdrop { position: fixed; inset: 0; z-index: 24; display: grid; place-items: center; background: rgba(0, 0, 0, .36); }
.operator-catalog-backdrop[hidden] { display: none; }
.operator-catalog-dialog { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); width: min(1160px, calc(100vw - 70px)); height: min(820px, calc(100vh - 60px)); overflow: hidden; border: 1px solid #555; background: #efefef; box-shadow: 0 12px 42px rgba(0, 0, 0, .38); }
.operator-catalog-dialog > header { display: flex; align-items: center; min-height: 48px; padding: 7px 12px; border-bottom: 1px solid #888; background: #fff; }
.operator-catalog-dialog > header h2 { flex: 1; margin: 0; font-size: 20px; }
.operator-catalog-dialog > header button { width: 38px; height: 32px; font-size: 22px; }
.operator-catalog-status { min-height: 34px; padding: 8px 12px; border-bottom: 1px solid #bbb; color: #333; background: #f8f8f8; }
.operator-catalog-status.error { color: #a40000; background: #fff0f0; font-weight: 600; }
.operator-catalog-toolbar { display: grid; grid-template-columns: 94px 94px minmax(240px, 1fr) 120px 80px; gap: 6px; padding: 8px 12px; border-bottom: 1px solid #aaa; }
.operator-catalog-toolbar button.selected { color: #fff; background: #176b2d; font-weight: 700; }
.operator-catalog-body { display: grid; grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); min-height: 0; gap: 10px; padding: 10px 12px 12px; }
.operator-catalog-list-panel, .operator-catalog-editor { display: grid; min-height: 0; border: 1px solid #999; background: #fff; }
.operator-catalog-list-panel { grid-template-rows: auto minmax(0, 1fr); }
.operator-catalog-panel-header { display: flex; align-items: center; min-height: 40px; gap: 6px; padding: 6px 8px; border-bottom: 1px solid #bbb; background: #f3f3f3; }
.operator-catalog-panel-header h3 { flex: 1; margin: 0; font-size: 15px; }
.operator-catalog-code { color: #555; font-family: Consolas, monospace; }
.operator-catalog-results, .operator-catalog-stock-results, .operator-catalog-draft-rows { min-height: 0; overflow: auto; }
.operator-catalog-results button { display: grid; grid-template-columns: 84px minmax(0, 1fr); width: 100%; gap: 2px 8px; padding: 6px 8px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.operator-catalog-results button small { grid-column: 2; color: #666; }
.operator-catalog-results button.selected { background: #dcebdc; }
.operator-catalog-editor { grid-template-rows: auto auto auto minmax(92px, 20%) auto auto minmax(120px, 1fr) auto; gap: 7px; padding-bottom: 8px; }
.operator-catalog-editor[hidden] { display: none; }
.operator-catalog-name-field { display: grid; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 6px; padding: 0 9px; }
.operator-catalog-stock-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 0 9px; }
.operator-catalog-stock-results { margin: 0 9px; border: 1px solid #aaa; }
.operator-catalog-stock-results button { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; padding: 4px 7px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.operator-catalog-stock-results button.selected { background: #dcebdc; }
.operator-catalog-stock-results button:disabled { color: #999; }
.operator-catalog-add-line { display: grid; grid-template-columns: auto 150px minmax(130px, auto); justify-content: end; align-items: center; gap: 6px; padding: 0 9px; }
.operator-catalog-add-line [hidden] { display: none; }
.operator-catalog-draft-header { display: flex; justify-content: space-between; gap: 8px; padding: 2px 9px; color: #555; }
.operator-catalog-draft-header span { font-size: 11px; }
.operator-catalog-draft-rows { margin: 0 9px; border: 1px solid #aaa; }
.operator-catalog-draft-row { display: grid; grid-template-columns: 34px minmax(140px, 1fr) minmax(110px, auto) 36px 36px 58px; align-items: center; gap: 4px; min-height: 34px; padding: 3px 5px; border-bottom: 1px solid #ddd; }
.operator-catalog-draft-row > span { text-align: center; }
.operator-catalog-draft-row > small { color: #555; text-align: right; }
.operator-catalog-draft-row > input { min-width: 90px; width: 100%; height: 27px; text-align: right; }
.operator-catalog-editor-actions { display: grid; grid-template-columns: auto 1fr auto auto; gap: 7px; padding: 2px 9px 0; }
.operator-catalog-editor-actions button:last-child { min-width: 92px; color: #fff; background: #176b2d; font-weight: 700; }
.playlist-panel { display: grid; grid-template-rows: 58px minmax(0, 1fr) 76px 42px; padding: 0 8px; background: #efefef; }
.playlist-toolbar { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.playlist-toolbar button { height: 25px; padding: 2px 9px; }
.playlist-toolbar { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.playlist-toolbar button { height: 25px; padding: 2px 7px; }
.playlist-toolbar .toolbar-spacer { flex: 1; }
.background-name { width: 95px; height: 25px; border: 1px solid #888; background: #fff; }
.background-name { width: 82px; height: 25px; border: 1px solid #888; background: #fff; }
.fade-control { margin-left: 2px; }
#playout-fade-duration { width: 48px; height: 25px; }
.playlist-grid { min-height: 0; border: 1px solid #888; background: #d5d5d5; overflow: auto; }
.playlist-row { display: grid; grid-template-columns: 40px 100px 260px 200px minmax(255px, 1fr) 52px; min-width: 907px; }
@@ -78,12 +270,14 @@ button:disabled { color: #555; opacity: 1; }
.playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; }
.playlist-data-row > div { padding: 3px 6px; border-right: 1px solid #ddd; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.playlist-data-row .enabled-cell { text-align: center; }
.playlist-data-row.selected { background: #dcdcdc; }
.playlist-data-row:focus { outline: 1px dotted #333; outline-offset: -2px; }
.playout-actions { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.playout-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: 12px; }
.playout-actions button { height: 51px; background: #fff; font-size: 25px; font-weight: 700; }
.take-in { width: 215px; }
.next { width: 180px; }
.take-out { width: 212px; }
.playout-actions button:disabled { color: #888; background: #f7f7f7; }
.playout-actions .prepare { font-size: 21px; }
.playout-actions .prepare.active { color: #fff; background: #1a7700; }
.playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; color: #666; }
.dialog-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, .25); }

View File

@@ -0,0 +1,52 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
public sealed record LegacyParityStartupCompositionResolution(
LegacySceneCueCompositionOptions Composition,
string WarningMessage)
{
public bool HasWarning => WarningMessage.Length > 0;
}
/// <summary>
/// Resolves the startup-only MainForm background behavior used by LegacyParityApp.
/// An explicit local file remains authoritative; only an absent file enables the
/// validated legacy 기본.vrv fallback.
/// </summary>
public static class LegacyParityStartupCompositionResolver
{
public const string MissingLegacyDefaultWarning =
"신뢰 배경 폴더에서 기본.vrv를 확인할 수 없어 배경없음으로 시작했습니다.";
public static LegacyParityStartupCompositionResolution Resolve(
PlayoutOptions options,
bool explicitLocalConfigurationExists)
{
ArgumentNullException.ThrowIfNull(options);
var configured = PlayoutSceneCompositionFactory.Create(options);
if (explicitLocalConfigurationExists ||
configured.BackgroundKind != LegacySceneBackgroundKind.None)
{
return new LegacyParityStartupCompositionResolution(configured, string.Empty);
}
try
{
return new LegacyParityStartupCompositionResolution(
PlayoutSceneCompositionFactory.CreateLegacyDefault(
options,
configured.FadeDuration),
string.Empty);
}
catch (PlayoutConfigurationException)
{
// Missing/invalid external media is not an application-startup failure.
// Keep the already validated None composition and expose one bounded,
// path-free warning through the native state projection.
return new LegacyParityStartupCompositionResolution(
configured,
MissingLegacyDefaultWarning);
}
}
}