feat: consolidate legacy parity migration baseline
This commit is contained in:
@@ -454,10 +454,8 @@ public sealed class LegacyNamedForeignStockRestoreService
|
||||
var punctuationIssue = character switch
|
||||
{
|
||||
'+' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPlus,
|
||||
'&' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAmpersand,
|
||||
'\'' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedApostrophe,
|
||||
',' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedComma,
|
||||
'(' or ')' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedParenthesis,
|
||||
'#' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedHash,
|
||||
'%' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPercent,
|
||||
'*' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsterisk,
|
||||
@@ -470,7 +468,11 @@ public sealed class LegacyNamedForeignStockRestoreService
|
||||
return punctuationIssue.Value;
|
||||
}
|
||||
|
||||
if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' '))
|
||||
// Current Oracle identities include ampersands and parenthesized
|
||||
// provider symbols. They are safe bound values and cannot collide
|
||||
// with the '|' or '^' serialization delimiters guarded above.
|
||||
if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' ' or
|
||||
'&' or '(' or ')'))
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation;
|
||||
}
|
||||
|
||||
@@ -169,8 +169,18 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
|
||||
|
||||
private static string ReadCanonical(DataRow row, int ordinal, int maximumLength)
|
||||
{
|
||||
if (row[ordinal] is not string value || value.Length < 1 || value.Length > maximumLength ||
|
||||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) || !IsSafeText(value))
|
||||
if (row[ordinal] is not string rawValue || rawValue.Length < 1 ||
|
||||
rawValue.Length > maximumLength || !IsSafeText(rawValue))
|
||||
{
|
||||
throw InvalidData("value");
|
||||
}
|
||||
|
||||
// The current legacy master contains one US/TW row whose name and symbol
|
||||
// are padded with an outer ASCII space. The original UC5 accepted those
|
||||
// values. Normalize only that closed padding form; the identity set below
|
||||
// still rejects any collision created by normalization.
|
||||
var value = rawValue.Trim(' ');
|
||||
if (value.Length < 1 || value.Length > maximumLength)
|
||||
{
|
||||
throw InvalidData("value");
|
||||
}
|
||||
|
||||
@@ -616,10 +616,18 @@ internal static class ThemeQueries
|
||||
LEFT JOIN (
|
||||
SELECT i.SB_M_CODE, i.SB_I_NAME, i.SB_I_CODE, i.SB_I_INDEX
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock s
|
||||
ON SUBSTRING(i.SB_I_CODE, 2, 12) = s.F_STOCK_CODE
|
||||
WHERE s.F_STOP_GUBUN = 'N'
|
||||
) a ON l.SB_CODE = a.SB_M_CODE
|
||||
JOIN (
|
||||
SELECT CONCAT('X', s.F_STOCK_CODE) ITEM_CODE
|
||||
FROM N_STOCK s
|
||||
WHERE s.F_STOP_GUBUN = 'N'
|
||||
UNION ALL
|
||||
SELECT CONCAT('T', s.F_STOCK_CODE) ITEM_CODE
|
||||
FROM N_KOSDAQ_STOCK s
|
||||
WHERE s.F_STOP_GUBUN = 'N'
|
||||
) current_nxt
|
||||
ON BINARY i.SB_I_CODE = BINARY current_nxt.ITEM_CODE
|
||||
) a ON BINARY l.SB_CODE = BINARY a.SB_M_CODE
|
||||
AND l.SB_CODE REGEXP BINARY '^[0-9]{8}$'
|
||||
WHERE l.SB_CODE = @theme_code
|
||||
AND l.SB_TITLE = @theme_title
|
||||
AND l.SB_MARKET = 'NXT'
|
||||
|
||||
@@ -70,6 +70,23 @@ public static class CandleCutAliasMetadata
|
||||
CandleCutAlias.Cut8056 => 1464d,
|
||||
_ => throw new LegacySceneDataException("Unknown candle cut alias.")
|
||||
};
|
||||
|
||||
internal static int GetAssetObjectCapacity(CandleCutAlias alias) => alias switch
|
||||
{
|
||||
CandleCutAlias.Cut8035 => 78,
|
||||
CandleCutAlias.Cut8061 => 5,
|
||||
CandleCutAlias.Cut8040 => 20,
|
||||
CandleCutAlias.Cut8046 => 60,
|
||||
CandleCutAlias.Cut8051 => 120,
|
||||
CandleCutAlias.Cut8056 => 240,
|
||||
_ => throw new LegacySceneDataException("Unknown candle cut alias.")
|
||||
};
|
||||
|
||||
internal static bool SupportsMovingAveragePaths(CandleCutAlias alias) =>
|
||||
alias != CandleCutAlias.Cut8035;
|
||||
|
||||
internal static bool SupportsGuide(CandleCutAlias alias) => alias is not
|
||||
(CandleCutAlias.Cut8035 or CandleCutAlias.Cut8061);
|
||||
}
|
||||
|
||||
public sealed record CandleQuoteData(
|
||||
@@ -101,7 +118,6 @@ public sealed record S8010SceneData(
|
||||
|
||||
public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S8010SceneData>
|
||||
{
|
||||
private const int MaximumCandleCount = 240;
|
||||
private const double GraphStartX = -730.57d;
|
||||
private const double GraphEndX = 738.65d;
|
||||
private const double TagMinimumX = -657.57d;
|
||||
@@ -122,13 +138,25 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
|
||||
var period = CandleCutAliasMetadata.GetPeriod(data.CutAlias);
|
||||
var lineWidth = CandleCutAliasMetadata.GetMovingAverageWidth(data.CutAlias);
|
||||
var format = UsesIntegerPrices(data.MarketTarget) ? "#,##0" : "#,##0.00";
|
||||
var mutations = new List<PlayoutMutation>(540 + (rows.Count * 12))
|
||||
var mutations = new List<PlayoutMutation>(
|
||||
(CandleCutAliasMetadata.GetAssetObjectCapacity(data.CutAlias) * 2) +
|
||||
60 + (rows.Count * 12))
|
||||
{
|
||||
new PlayoutSetValue("title", InitialTitle(displayName, data.ChartMode)),
|
||||
// A path mutation with no points reproduces Begin/Clear/End from the legacy setup.
|
||||
new PlayoutSetPathPoints("ma5", Array.Empty<PlayoutPoint>()),
|
||||
new PlayoutSetPathPoints("ma20", Array.Empty<PlayoutPoint>())
|
||||
new PlayoutSetValue("title", InitialTitle(displayName, data.ChartMode))
|
||||
};
|
||||
if (CandleCutAliasMetadata.SupportsMovingAveragePaths(data.CutAlias))
|
||||
{
|
||||
// A path mutation with no points reproduces Begin/Clear/End from the legacy setup.
|
||||
mutations.Add(new PlayoutSetPathPoints("ma5", Array.Empty<PlayoutPoint>()));
|
||||
mutations.Add(new PlayoutSetPathPoints("ma20", Array.Empty<PlayoutPoint>()));
|
||||
}
|
||||
else
|
||||
{
|
||||
// The intraday asset exposes ma5/ma20 as ordinary visible objects,
|
||||
// not editable paths. Hide the baked defaults without PATH_POINT.
|
||||
mutations.Add(new PlayoutSetVisible("ma5", false));
|
||||
mutations.Add(new PlayoutSetVisible("ma20", false));
|
||||
}
|
||||
|
||||
AddResolvedTitleAndUnit(
|
||||
mutations,
|
||||
@@ -138,7 +166,7 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
|
||||
AddQuote(mutations, quote, format);
|
||||
|
||||
var range = CreateRange(rows, data);
|
||||
AddInitialVisibility(mutations, data.ChartMode);
|
||||
AddInitialVisibility(mutations, data.CutAlias, data.ChartMode);
|
||||
var paths = AddCandles(
|
||||
mutations,
|
||||
rows,
|
||||
@@ -187,7 +215,7 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
|
||||
var values = LegacySceneBuilderGuard.Range(
|
||||
rows,
|
||||
1,
|
||||
MaximumCandleCount,
|
||||
CandleCutAliasMetadata.GetAssetObjectCapacity(alias),
|
||||
nameof(rows));
|
||||
var requiresTime = CandleCutAliasMetadata.GetPeriod(alias) == CandlePeriod.Intraday;
|
||||
return values.Select((row, index) =>
|
||||
@@ -379,17 +407,19 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
|
||||
|
||||
private static void AddInitialVisibility(
|
||||
ICollection<PlayoutMutation> mutations,
|
||||
CandleCutAlias alias,
|
||||
CandleChartMode mode)
|
||||
{
|
||||
mutations.Add(new PlayoutSetVisible(
|
||||
"volrumeG",
|
||||
mode == CandleChartMode.PriceWithVolume));
|
||||
for (var index = 1; index <= MaximumCandleCount; index++)
|
||||
var capacity = CandleCutAliasMetadata.GetAssetObjectCapacity(alias);
|
||||
for (var index = 1; index <= capacity; index++)
|
||||
{
|
||||
mutations.Add(new PlayoutSetVisible("volume" + index, false));
|
||||
}
|
||||
|
||||
for (var index = 1; index <= MaximumCandleCount; index++)
|
||||
for (var index = 1; index <= capacity; index++)
|
||||
{
|
||||
mutations.Add(new PlayoutSetVisible("candle" + index, false));
|
||||
}
|
||||
@@ -617,17 +647,28 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
|
||||
CandlePaths paths,
|
||||
S8010SceneData data)
|
||||
{
|
||||
if (!CandleCutAliasMetadata.SupportsMovingAveragePaths(data.CutAlias))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
mutations.Add(new PlayoutSetPathPoints(
|
||||
"ma5",
|
||||
data.ShowMovingAverage5 ? paths.MovingAverage5 : Array.Empty<PlayoutPoint>()));
|
||||
mutations.Add(new PlayoutSetPathPoints(
|
||||
"ma20",
|
||||
data.ShowMovingAverage20 ? paths.MovingAverage20 : Array.Empty<PlayoutPoint>()));
|
||||
mutations.Add(new PlayoutSetVisible("guide", data.ShowMovingAverage20));
|
||||
if (CandleCutAliasMetadata.SupportsGuide(data.CutAlias))
|
||||
{
|
||||
mutations.Add(new PlayoutSetVisible("guide", data.ShowMovingAverage20));
|
||||
}
|
||||
|
||||
if (!SupportsMovingAverages(data.MarketTarget))
|
||||
{
|
||||
mutations.Add(new PlayoutSetVisible("guide", false));
|
||||
if (CandleCutAliasMetadata.SupportsGuide(data.CutAlias))
|
||||
{
|
||||
mutations.Add(new PlayoutSetVisible("guide", false));
|
||||
}
|
||||
mutations.Add(new PlayoutSetPathPoints("ma5", Array.Empty<PlayoutPoint>()));
|
||||
mutations.Add(new PlayoutSetPathPoints("ma20", Array.Empty<PlayoutPoint>()));
|
||||
}
|
||||
|
||||
@@ -348,8 +348,21 @@ public sealed class S5087SceneMutationBuilder : ILegacySceneMutationBuilder<S508
|
||||
data.Series,
|
||||
ComparisonInitialHideStyle.S5087,
|
||||
maximumX: 700f,
|
||||
baselineOffset: -170f);
|
||||
baselineOffset: -170f)
|
||||
.Where(IsAuthoritativeAssetMutation)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsAuthoritativeAssetMutation(PlayoutMutation mutation) => mutation switch
|
||||
{
|
||||
PlayoutSetValue value when value.ObjectName is
|
||||
"date1_2" or "date1_4" or "date2_2" or "date2_4" or
|
||||
"highPrice1" or "lowPrice1" or "highPrice2" or "lowPrice2" => false,
|
||||
PlayoutSetPosition position when position.ObjectName is
|
||||
"baseline1" or "baseline2" or
|
||||
"highTag1" or "lowTag1" or "highTag2" or "lowTag2" => false,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
public enum YieldGraphPeriod
|
||||
|
||||
@@ -136,7 +136,7 @@ public sealed class LegacyParameterizedSceneRequestResolver
|
||||
// MainForm selects the special s5032 builder whenever the combined subject
|
||||
// contains the Korean futures label. s5032 then normalizes futures to side two.
|
||||
if (subject.Contains("선물", StringComparison.Ordinal) ||
|
||||
subject.Contains("FUTURES", StringComparison.Ordinal))
|
||||
subject.Contains("FUTURES", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var values = SplitPair(subject, ',');
|
||||
// Keep this branch closed to the market targets that s5032 renders with
|
||||
|
||||
@@ -59,7 +59,13 @@ public sealed class S5074SceneMutationBuilder : ILegacySceneMutationBuilder<Page
|
||||
public string BuilderKey => "s5074";
|
||||
|
||||
public IReadOnlyList<PlayoutMutation> Build(PagedQuoteSceneData data) =>
|
||||
PagedQuoteMutations.Build(data, ScenePageSize.Five, useWideRateVariants: false);
|
||||
PagedQuoteMutations.Build(
|
||||
data,
|
||||
ScenePageSize.Five,
|
||||
useWideRateVariants: false,
|
||||
includeTitle: true,
|
||||
includeUnit: true,
|
||||
includeColumns: true);
|
||||
}
|
||||
|
||||
public sealed class S5077SceneMutationBuilder : ILegacySceneMutationBuilder<PagedQuoteSceneData>
|
||||
@@ -67,7 +73,13 @@ public sealed class S5077SceneMutationBuilder : ILegacySceneMutationBuilder<Page
|
||||
public string BuilderKey => "s5077";
|
||||
|
||||
public IReadOnlyList<PlayoutMutation> Build(PagedQuoteSceneData data) =>
|
||||
PagedQuoteMutations.Build(data, ScenePageSize.Six, useWideRateVariants: true);
|
||||
PagedQuoteMutations.Build(
|
||||
data,
|
||||
ScenePageSize.Six,
|
||||
useWideRateVariants: true,
|
||||
includeTitle: false,
|
||||
includeUnit: false,
|
||||
includeColumns: false);
|
||||
}
|
||||
|
||||
public sealed class S5088SceneMutationBuilder : ILegacySceneMutationBuilder<PagedQuoteSceneData>
|
||||
@@ -75,7 +87,13 @@ public sealed class S5088SceneMutationBuilder : ILegacySceneMutationBuilder<Page
|
||||
public string BuilderKey => "s5088";
|
||||
|
||||
public IReadOnlyList<PlayoutMutation> Build(PagedQuoteSceneData data) =>
|
||||
PagedQuoteMutations.Build(data, ScenePageSize.Twelve, useWideRateVariants: true);
|
||||
PagedQuoteMutations.Build(
|
||||
data,
|
||||
ScenePageSize.Twelve,
|
||||
useWideRateVariants: true,
|
||||
includeTitle: true,
|
||||
includeUnit: true,
|
||||
includeColumns: false);
|
||||
}
|
||||
|
||||
internal static class PagedQuoteMutations
|
||||
@@ -86,7 +104,10 @@ internal static class PagedQuoteMutations
|
||||
public static IReadOnlyList<PlayoutMutation> Build(
|
||||
PagedQuoteSceneData data,
|
||||
ScenePageSize pageSize,
|
||||
bool useWideRateVariants)
|
||||
bool useWideRateVariants,
|
||||
bool includeTitle,
|
||||
bool includeUnit,
|
||||
bool includeColumns)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
|
||||
@@ -144,9 +165,16 @@ internal static class PagedQuoteMutations
|
||||
mutations.Add(new PlayoutSetVisible("row" + slot, false));
|
||||
}
|
||||
|
||||
mutations.Add(new PlayoutSetValue("title", BuildTitle(title, data.TitleStyle)));
|
||||
// The authoritative 5077.t2s has no global title/header objects, while
|
||||
// 5088.t2s has title/unit but no column1..3. Keep the shared row logic,
|
||||
// but never send mutations for objects absent from the selected asset.
|
||||
if (includeTitle)
|
||||
{
|
||||
mutations.Add(new PlayoutSetValue("title", BuildTitle(title, data.TitleStyle)));
|
||||
}
|
||||
|
||||
AddMarketMark(mutations, data.Branch, data.NxtSession);
|
||||
AddHeader(mutations, data.HeaderStyle);
|
||||
AddHeader(mutations, data.HeaderStyle, includeUnit, includeColumns);
|
||||
|
||||
for (var slot = 1; slot <= size; slot++)
|
||||
{
|
||||
@@ -228,7 +256,9 @@ internal static class PagedQuoteMutations
|
||||
|
||||
private static void AddHeader(
|
||||
ICollection<PlayoutMutation> mutations,
|
||||
PagedQuoteHeaderStyle style)
|
||||
PagedQuoteHeaderStyle style,
|
||||
bool includeUnit,
|
||||
bool includeColumns)
|
||||
{
|
||||
var values = style switch
|
||||
{
|
||||
@@ -240,10 +270,17 @@ internal static class PagedQuoteMutations
|
||||
(Unit: "\uB2E8\uC704 : \uC6D0", Column1: "\uB9E4\uC218\uAC00", Column2: "\uD604\uC7AC\uAC00", Column3: "\uC218\uC775\uB960"),
|
||||
_ => throw new LegacySceneDataException("Unknown paged quote header style.")
|
||||
};
|
||||
mutations.Add(new PlayoutSetValue("unit", values.Unit));
|
||||
mutations.Add(new PlayoutSetValue("column1", values.Column1));
|
||||
mutations.Add(new PlayoutSetValue("column2", values.Column2));
|
||||
mutations.Add(new PlayoutSetValue("column3", values.Column3));
|
||||
if (includeUnit)
|
||||
{
|
||||
mutations.Add(new PlayoutSetValue("unit", values.Unit));
|
||||
}
|
||||
|
||||
if (includeColumns)
|
||||
{
|
||||
mutations.Add(new PlayoutSetValue("column1", values.Column1));
|
||||
mutations.Add(new PlayoutSetValue("column2", values.Column2));
|
||||
mutations.Add(new PlayoutSetValue("column3", values.Column3));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddPopulatedRow(
|
||||
|
||||
@@ -1154,7 +1154,7 @@ public enum S8001Sector
|
||||
TextilesAndApparel,
|
||||
ElectricalAndElectronics,
|
||||
Chemicals,
|
||||
NonMetallicMinerals,
|
||||
Pharmaceuticals,
|
||||
MachineryAndEquipment,
|
||||
ItComponents,
|
||||
Transportation,
|
||||
@@ -1242,7 +1242,7 @@ public sealed class S8001SceneMutationBuilder : ILegacySceneMutationBuilder<S800
|
||||
S8001Sector.FoodAndBeverage or S8001Sector.SteelAndMetal or
|
||||
S8001Sector.MedicalPrecision or S8001Sector.TextilesAndApparel or
|
||||
S8001Sector.ElectricalAndElectronics or S8001Sector.Chemicals or
|
||||
S8001Sector.NonMetallicMinerals,
|
||||
S8001Sector.Pharmaceuticals,
|
||||
S8001Market.Kosdaq => sector is
|
||||
S8001Sector.MachineryAndEquipment or S8001Sector.ItComponents or
|
||||
S8001Sector.Transportation or S8001Sector.PaperAndWood or
|
||||
@@ -1266,7 +1266,7 @@ public sealed class S8001SceneMutationBuilder : ILegacySceneMutationBuilder<S800
|
||||
S8001Sector.TextilesAndApparel => "섬유의복",
|
||||
S8001Sector.ElectricalAndElectronics => "전기전자",
|
||||
S8001Sector.Chemicals => "화학",
|
||||
S8001Sector.NonMetallicMinerals => "비금속광물",
|
||||
S8001Sector.Pharmaceuticals => "의약품",
|
||||
S8001Sector.MachineryAndEquipment => "기계장비",
|
||||
S8001Sector.ItComponents => "IT부품",
|
||||
S8001Sector.Transportation => "운송",
|
||||
|
||||
@@ -913,7 +913,9 @@ public sealed class S8001SceneDataLoader
|
||||
"006" => S8001Sector.TextilesAndApparel,
|
||||
"007" => S8001Sector.PaperAndWood,
|
||||
"008" => S8001Sector.Chemicals,
|
||||
"009" => S8001Sector.NonMetallicMinerals,
|
||||
// The live taxonomy now names code 009 "제약" while the authoritative
|
||||
// 8001 cut keeps the legacy on-air object name "의약품".
|
||||
"009" => S8001Sector.Pharmaceuticals,
|
||||
"011" => S8001Sector.SteelAndMetal,
|
||||
"012" => S8001Sector.Machinery,
|
||||
"013" => S8001Sector.ElectricalAndElectronics,
|
||||
|
||||
@@ -148,11 +148,34 @@ public sealed class S5011SceneMutationBuilder : ILegacySceneMutationBuilder<S501
|
||||
mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatInteger(data.NetChange)));
|
||||
mutations.Add(new PlayoutSetValue("rate" + variant, FormatRate(data.Rate)));
|
||||
ClosedSceneMappings.AddDirectionHides(mutations, string.Empty);
|
||||
mutations.AddRange(LegacyPriceDirectionMutations.Build(direction, 0));
|
||||
AddAssetSupportedDirectionMutations(mutations, direction);
|
||||
mutations.Add(new PlayoutSetVisible("mart", ClosedSceneMappings.IsNxt(market)));
|
||||
return mutations;
|
||||
}
|
||||
|
||||
private static void AddAssetSupportedDirectionMutations(
|
||||
ICollection<PlayoutMutation> mutations,
|
||||
ScenePriceDirection direction)
|
||||
{
|
||||
foreach (var mutation in LegacyPriceDirectionMutations.Build(direction, 0))
|
||||
{
|
||||
// The authoritative 5011.t2s has the bg/changePrice/rate/percent
|
||||
// variants but no wonText variants or pattern asset object.
|
||||
if (mutation is PlayoutSetVisible { ObjectName: var objectName } &&
|
||||
objectName.StartsWith("wonText", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mutation is PlayoutSetAssetValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
mutations.Add(mutation);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDetailMutations(
|
||||
ICollection<PlayoutMutation> mutations,
|
||||
S5011DetailData detail)
|
||||
|
||||
@@ -173,9 +173,10 @@ public sealed class RegisteredLegacySceneCueProvider :
|
||||
}
|
||||
|
||||
var builderMutations = _builders.Build(definition.BuilderKey, loaded.Data);
|
||||
var mutations = new List<PlayoutMutation>(builderMutations.Count + 2);
|
||||
var assetMutations = ApplyAssetContract(entry.CutCode, builderMutations);
|
||||
var mutations = new List<PlayoutMutation>(assetMutations.Count + 2);
|
||||
AddBackgroundMutations(mutations, options);
|
||||
mutations.AddRange(builderMutations);
|
||||
mutations.AddRange(assetMutations);
|
||||
|
||||
return new LegacySceneCuePage(
|
||||
new PlayoutCue(
|
||||
@@ -250,6 +251,47 @@ public sealed class RegisteredLegacySceneCueProvider :
|
||||
_ => throw new LegacySceneDataException("The scene catalog contains an invalid page size.")
|
||||
};
|
||||
|
||||
private static IReadOnlyList<PlayoutMutation> ApplyAssetContract(
|
||||
string cutCode,
|
||||
IReadOnlyList<PlayoutMutation> mutations)
|
||||
{
|
||||
if (string.Equals(cutCode, "5032", StringComparison.Ordinal))
|
||||
{
|
||||
return mutations.Where(static mutation => mutation switch
|
||||
{
|
||||
PlayoutSetAssetValue { ObjectName: "pattern" } => false,
|
||||
PlayoutSetVisible visible when IsAbsent5032Decoration(visible.ObjectName) => false,
|
||||
_ => true
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
if (!string.Equals(cutCode, "8032", StringComparison.Ordinal))
|
||||
{
|
||||
return mutations;
|
||||
}
|
||||
|
||||
return mutations.Where(static mutation => mutation switch
|
||||
{
|
||||
PlayoutSetAssetValue { ObjectName: "pattern1" or "pattern2" } => false,
|
||||
PlayoutSetValue { ObjectName: "wonText1" or "wonText2" } => false,
|
||||
PlayoutSetVisible visible when IsAbsent8032Variant(visible.ObjectName) => false,
|
||||
_ => true
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static bool IsAbsent8032Variant(string objectName) => objectName is
|
||||
"percent1_1" or "percent2_1" or "percent3_1" or
|
||||
"percent1_2" or "percent2_2" or "percent3_2" or
|
||||
"wonText1_1" or "wonText2_1" or "wonText3_1" or
|
||||
"wonText1_2" or "wonText2_2" or "wonText3_2";
|
||||
|
||||
private static bool IsAbsent5032Decoration(string objectName) => objectName is
|
||||
"bg1_" or "changePrice1_" or "wonText1_" or "rate1_" or "percent1_" or
|
||||
"bg2_" or "changePrice2_" or "wonText2_" or "rate2_" or "percent2_" or
|
||||
"bg3_" or "changePrice3_" or "wonText3_" or "rate3_" or "percent3_" or
|
||||
"unit1_" or "unit2_" or "unit3_" or
|
||||
"price1_" or "price2_" or "price3_";
|
||||
|
||||
internal static LegacySceneCueCompositionOptions ValidateOptions(
|
||||
LegacySceneCueCompositionOptions options)
|
||||
{
|
||||
|
||||
@@ -354,10 +354,11 @@ public sealed class S5037SceneMutationBuilder : ILegacySceneMutationBuilder<S503
|
||||
|
||||
var buys = NormalizeDealers(data.BuyDealers, nameof(data.BuyDealers));
|
||||
var sells = NormalizeDealers(data.SellDealers, nameof(data.SellDealers));
|
||||
// The authoritative 5037.t2s bakes the unit label into the artwork and
|
||||
// does not expose the legacy code's "unit" object.
|
||||
var mutations = new List<PlayoutMutation>(44)
|
||||
{
|
||||
new PlayoutSetValue("title", stockName),
|
||||
new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"),
|
||||
new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)),
|
||||
new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)),
|
||||
new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture))
|
||||
@@ -457,10 +458,11 @@ public sealed class S8003SceneMutationBuilder : ILegacySceneMutationBuilder<S800
|
||||
|
||||
var buys = NormalizeLevels(data.BuyLevels, nameof(data.BuyLevels));
|
||||
var sells = NormalizeLevels(data.SellLevels, nameof(data.SellLevels));
|
||||
// The authoritative 8003.t2s bakes the unit label into the artwork and
|
||||
// does not expose the legacy code's "unit" object.
|
||||
var mutations = new List<PlayoutMutation>(40)
|
||||
{
|
||||
new PlayoutSetValue("title", stockName),
|
||||
new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"),
|
||||
new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)),
|
||||
new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)),
|
||||
new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture))
|
||||
|
||||
@@ -4,6 +4,10 @@ using CoreMovingAverageSelection =
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection;
|
||||
using CoreMovingAverageSource =
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource;
|
||||
using CoreSceneSelection =
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection;
|
||||
using S5011DetailBranch =
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5011DetailBranch;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
@@ -403,8 +407,17 @@ public sealed class LegacyOperatorController
|
||||
bool shift,
|
||||
int x,
|
||||
int y,
|
||||
long timestampMilliseconds)
|
||||
long timestampMilliseconds,
|
||||
bool allowAppend = true)
|
||||
{
|
||||
if (!allowAppend)
|
||||
{
|
||||
// Selection remains available while a scene is prepared or on air, but
|
||||
// an on-air click must neither activate a cut nor prime the next IDLE
|
||||
// click as the second half of a legacy pointer double-click.
|
||||
_previousPointerTimestamp = null;
|
||||
}
|
||||
|
||||
if (_selectedStockIndex is null || !IsCutIndex(physicalIndex))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
@@ -413,7 +426,8 @@ public sealed class LegacyOperatorController
|
||||
var gap = _previousPointerTimestamp is long previousTimestamp
|
||||
? timestampMilliseconds - previousTimestamp
|
||||
: long.MaxValue;
|
||||
if (_previousPointerTimestamp is not null &&
|
||||
if (allowAppend &&
|
||||
_previousPointerTimestamp is not null &&
|
||||
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
|
||||
_previousPointerRow == physicalIndex &&
|
||||
_previousPointerX == x &&
|
||||
@@ -425,7 +439,7 @@ public sealed class LegacyOperatorController
|
||||
}
|
||||
else
|
||||
{
|
||||
_previousPointerTimestamp = timestampMilliseconds;
|
||||
_previousPointerTimestamp = allowAppend ? timestampMilliseconds : null;
|
||||
}
|
||||
|
||||
_previousPointerRow = physicalIndex;
|
||||
@@ -3409,7 +3423,18 @@ public sealed class LegacyOperatorController
|
||||
var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync(
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var loadedRows = namedPlaylist.Rows
|
||||
.Select(CreateNamedPlaylistOperatorRow)
|
||||
.Select(row =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return CreateNamedPlaylistOperatorRow(row);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
exception.Data["NamedPlaylistItemIndex"] = row.ItemIndex;
|
||||
throw;
|
||||
}
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
_playlist.Clear();
|
||||
@@ -3422,10 +3447,13 @@ public sealed class LegacyOperatorController
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
var rowNumber = exception.Data["NamedPlaylistItemIndex"] is int itemIndex
|
||||
? $" {itemIndex + 1}행에"
|
||||
: string.Empty;
|
||||
_dialog = new LegacyDialogState(
|
||||
"DB 재생목록에 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
|
||||
$"DB 재생목록{rowNumber} 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
|
||||
_statusMessage = _dialog.Message;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
}
|
||||
@@ -4176,12 +4204,33 @@ public sealed class LegacyOperatorController
|
||||
{
|
||||
var selection = row.Selection;
|
||||
var sceneAlias = ResolveNamedPlaylistSceneAlias(selection);
|
||||
var playoutSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
||||
selection.GroupCode,
|
||||
selection.Subject,
|
||||
selection.GraphicType,
|
||||
selection.Subtype,
|
||||
selection.DataCode);
|
||||
var playoutSelection = TryCreateNamedFixedSelection(
|
||||
selection,
|
||||
out var fixedSelection,
|
||||
out _)
|
||||
? fixedSelection
|
||||
: TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection)
|
||||
? stockDetailSelection
|
||||
: TryCreateNamedIndustrySelection(selection, out var industrySelection)
|
||||
? industrySelection
|
||||
: TryCreateNamedComparisonSelection(selection, out var comparisonSelection)
|
||||
? comparisonSelection
|
||||
: TryCreateNamedManualFinancialSelection(
|
||||
selection,
|
||||
out var manualFinancialSelection)
|
||||
? manualFinancialSelection
|
||||
: TryCreateNamedManualNetSellSelection(
|
||||
selection,
|
||||
out var manualNetSellSelection)
|
||||
? manualNetSellSelection
|
||||
: TryCreateNamedVersionedViSelection(selection, out var viSelection)
|
||||
? viSelection
|
||||
: new CoreSceneSelection(
|
||||
selection.GroupCode,
|
||||
selection.Subject,
|
||||
selection.GraphicType,
|
||||
selection.Subtype,
|
||||
selection.DataCode);
|
||||
var rawCutLabel = selection.Subtype.Length == 0
|
||||
? selection.GraphicType
|
||||
: $"{selection.GraphicType}_{selection.Subtype}";
|
||||
@@ -4226,26 +4275,9 @@ public sealed class LegacyOperatorController
|
||||
}
|
||||
}
|
||||
|
||||
var fixedMarket = selection.GroupCode switch
|
||||
if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction))
|
||||
{
|
||||
"해외지수" => LegacyFixedMarket.Overseas,
|
||||
"환율" => LegacyFixedMarket.Exchange,
|
||||
"주요지수" => LegacyFixedMarket.Index,
|
||||
_ => (LegacyFixedMarket?)null
|
||||
};
|
||||
if (fixedMarket is { } market)
|
||||
{
|
||||
var actions = _fixedCatalog.GetActions(market)
|
||||
.Where(action =>
|
||||
string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) &&
|
||||
string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) &&
|
||||
string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal))
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
if (actions.Length == 1)
|
||||
{
|
||||
return actions[0].Code;
|
||||
}
|
||||
return fixedAction!.Code;
|
||||
}
|
||||
|
||||
var industryMarket = selection.GroupCode switch
|
||||
@@ -4294,6 +4326,23 @@ public sealed class LegacyOperatorController
|
||||
return "5074";
|
||||
}
|
||||
|
||||
// FSell rows are saved with the closed canonical selection emitted by the
|
||||
// native manual-list workflow. Older PList rows use the original visible
|
||||
// seven-field identity; the scene resolver accepts both shapes and performs
|
||||
// the fresh trusted-file read again before any cue can reach Tornado2.
|
||||
if (TryCreateNamedManualNetSellSelection(selection, out _))
|
||||
{
|
||||
return "5025";
|
||||
}
|
||||
|
||||
// A versioned VI row carries no path or operator-controlled scene name. The
|
||||
// native cue provider reopens the trusted snapshot and requires this exact
|
||||
// version at PREPARE, so alias restoration remains fail-closed.
|
||||
if (TryCreateNamedVersionedViSelection(selection, out _))
|
||||
{
|
||||
return "5074";
|
||||
}
|
||||
|
||||
if (string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
|
||||
{
|
||||
var alias = selection.GraphicType switch
|
||||
@@ -4327,6 +4376,474 @@ public sealed class LegacyOperatorController
|
||||
"The named-playlist row does not map to a registered scene alias.");
|
||||
}
|
||||
|
||||
private bool TryCreateNamedFixedSelection(
|
||||
LegacyNamedPlaylistSelectionView selection,
|
||||
out CoreSceneSelection normalized,
|
||||
out LegacyFixedAction? action)
|
||||
{
|
||||
normalized = null!;
|
||||
action = null;
|
||||
var fixedMarket = selection.GroupCode switch
|
||||
{
|
||||
"해외지수" => LegacyFixedMarket.Overseas,
|
||||
"환율" => LegacyFixedMarket.Exchange,
|
||||
"주요지수" => LegacyFixedMarket.Index,
|
||||
_ => (LegacyFixedMarket?)null
|
||||
};
|
||||
if (fixedMarket is not { } market)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var matches = _fixedCatalog.GetActions(market)
|
||||
.Where(candidate =>
|
||||
string.Equals(candidate.Label, selection.Subject, StringComparison.Ordinal) &&
|
||||
string.Equals(candidate.Section, selection.GraphicType, StringComparison.Ordinal) &&
|
||||
string.Equals(candidate.Detail, selection.Subtype, StringComparison.Ordinal))
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
if (matches.Length != 1 || !matches[0].Available)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
action = matches[0];
|
||||
var materialized = _fixedCatalog.Materialize(action.Id, DateTimeOffset.Now).Selection;
|
||||
normalized = new CoreSceneSelection(
|
||||
materialized.GroupCode,
|
||||
materialized.Subject,
|
||||
materialized.GraphicType,
|
||||
materialized.Subtype,
|
||||
selection.DataCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryCreateNamedStockDetailSelection(
|
||||
LegacyNamedPlaylistSelectionView selection,
|
||||
out CoreSceneSelection normalized)
|
||||
{
|
||||
normalized = null!;
|
||||
if (selection.GroupCode is not ("코스피" or "코스닥") ||
|
||||
!string.Equals(selection.GraphicType, "1열판상세", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var branch = selection.Subtype switch
|
||||
{
|
||||
"액면가" => nameof(S5011DetailBranch.FaceValue),
|
||||
"PBR" => nameof(S5011DetailBranch.Valuation),
|
||||
"거래량" => nameof(S5011DetailBranch.Volume),
|
||||
_ => null
|
||||
};
|
||||
if (branch is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LegacyComparisonValueGuard.RequireText(
|
||||
selection.Subject,
|
||||
200,
|
||||
nameof(selection.Subject));
|
||||
LegacyComparisonValueGuard.RequireStockCode(
|
||||
selection.DataCode,
|
||||
nameof(selection.DataCode));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = new CoreSceneSelection(
|
||||
selection.GroupCode,
|
||||
selection.Subject,
|
||||
selection.GraphicType,
|
||||
branch,
|
||||
selection.DataCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryCreateNamedIndustrySelection(
|
||||
LegacyNamedPlaylistSelectionView selection,
|
||||
out CoreSceneSelection normalized)
|
||||
{
|
||||
normalized = null!;
|
||||
var market = selection.GroupCode switch
|
||||
{
|
||||
"업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
|
||||
"업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
|
||||
_ => (MMoneyCoderSharp.Data.IndustryMarket?)null
|
||||
};
|
||||
if (market is not { } exactMarket)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rawLabel = selection.Subtype.Length == 0
|
||||
? selection.GraphicType
|
||||
: $"{selection.GraphicType}_{selection.Subtype}";
|
||||
var actions = LegacyIndustryActionCatalog.GetActions(exactMarket)
|
||||
.Where(action => string.Equals(action.Label, rawLabel, StringComparison.Ordinal))
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
if (actions.Length != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LegacyComparisonValueGuard.RequireText(
|
||||
selection.Subject,
|
||||
256,
|
||||
nameof(selection.Subject));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var action = actions[0];
|
||||
var industryGroup = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
|
||||
? "INDUSTRY_KOSPI"
|
||||
: "INDUSTRY_KOSDAQ";
|
||||
var marketName = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
|
||||
? "KOSPI"
|
||||
: "KOSDAQ";
|
||||
normalized = action.Kind switch
|
||||
{
|
||||
LegacyIndustryActionKind.Single => new CoreSceneSelection(
|
||||
industryGroup,
|
||||
selection.Subject,
|
||||
"1열판",
|
||||
"CURRENT",
|
||||
string.Empty),
|
||||
LegacyIndustryActionKind.Pair => new CoreSceneSelection(
|
||||
industryGroup,
|
||||
selection.Subject,
|
||||
"2열판",
|
||||
string.Empty,
|
||||
string.Empty),
|
||||
LegacyIndustryActionKind.Yield => new CoreSceneSelection(
|
||||
industryGroup,
|
||||
selection.Subject,
|
||||
"YIELD_GRAPH",
|
||||
action.YieldPeriod!,
|
||||
string.Empty),
|
||||
LegacyIndustryActionKind.Candle => new CoreSceneSelection(
|
||||
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
|
||||
? "KOSPI_INDUSTRY"
|
||||
: "KOSDAQ_INDUSTRY",
|
||||
selection.Subject,
|
||||
string.Empty,
|
||||
action.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE",
|
||||
string.Empty),
|
||||
LegacyIndustryActionKind.FiveRow when
|
||||
DateOnly.TryParseExact(
|
||||
selection.DataCode,
|
||||
"yyyy-MM-dd",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out _) => new CoreSceneSelection(
|
||||
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
|
||||
? "PAGED_DOMESTIC_KOSPI"
|
||||
: "PAGED_DOMESTIC_KOSDAQ",
|
||||
"INDEX",
|
||||
string.Empty,
|
||||
"INDUSTRY",
|
||||
selection.DataCode),
|
||||
LegacyIndustryActionKind.Square => new CoreSceneSelection(
|
||||
industryGroup,
|
||||
marketName,
|
||||
"네모그래프",
|
||||
string.Empty,
|
||||
string.Empty),
|
||||
LegacyIndustryActionKind.Sector => new CoreSceneSelection(
|
||||
industryGroup,
|
||||
marketName,
|
||||
"SECTOR_INDEX",
|
||||
string.Empty,
|
||||
string.Empty),
|
||||
_ => null!
|
||||
};
|
||||
return normalized is not null;
|
||||
}
|
||||
|
||||
private static bool TryCreateNamedComparisonSelection(
|
||||
LegacyNamedPlaylistSelectionView selection,
|
||||
out CoreSceneSelection normalized)
|
||||
{
|
||||
normalized = null!;
|
||||
if (!string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var names = selection.Subject.Split(" ↔ ", StringSplitOptions.None);
|
||||
if (names.Length != 2 || names.Any(string.IsNullOrWhiteSpace) ||
|
||||
names.Any(name => !string.Equals(name, name.Trim(), StringComparison.Ordinal)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
LegacyComparisonTarget first;
|
||||
LegacyComparisonTarget second;
|
||||
if (selection.DataCode.Length == 0)
|
||||
{
|
||||
first = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
|
||||
new LegacyComparisonStoredTargetReference(names[0], string.Empty))!;
|
||||
second = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
|
||||
new LegacyComparisonStoredTargetReference(names[1], string.Empty))!;
|
||||
if (first is null || second is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!TryCreateNamedComparisonStocks(
|
||||
names,
|
||||
selection.DataCode,
|
||||
out first,
|
||||
out second))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var actions = LegacyComparisonWorkflowCatalog.Actions
|
||||
.Where(action => string.Equals(
|
||||
action.Label,
|
||||
selection.Subtype,
|
||||
StringComparison.Ordinal))
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
if (actions.Length != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var draft = LegacyComparisonWorkflowCatalog.Materialize(
|
||||
actions[0].ActionId,
|
||||
first,
|
||||
second,
|
||||
"named-restore");
|
||||
if (!string.Equals(draft.BuilderKey, selection.GraphicType, StringComparison.Ordinal) ||
|
||||
!string.Equals(draft.Detail, selection.Subtype, StringComparison.Ordinal) ||
|
||||
!string.Equals(
|
||||
draft.Selection.DataCode,
|
||||
selection.DataCode,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = draft.Selection;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is
|
||||
ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateNamedComparisonStocks(
|
||||
IReadOnlyList<string> names,
|
||||
string dataCode,
|
||||
out LegacyComparisonTarget first,
|
||||
out LegacyComparisonTarget second)
|
||||
{
|
||||
first = null!;
|
||||
second = null!;
|
||||
var identities = dataCode.Split('|', StringSplitOptions.None);
|
||||
if (identities.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var targets = new LegacyComparisonTarget[2];
|
||||
for (var index = 0; index < targets.Length; index++)
|
||||
{
|
||||
var separator = identities[index].IndexOf(':');
|
||||
if (separator <= 0 ||
|
||||
separator != identities[index].LastIndexOf(':') ||
|
||||
separator == identities[index].Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var market = identities[index][..separator] switch
|
||||
{
|
||||
"KOSPI" => LegacyComparisonDomesticMarket.Kospi,
|
||||
"KOSDAQ" => LegacyComparisonDomesticMarket.Kosdaq,
|
||||
_ => (LegacyComparisonDomesticMarket?)null
|
||||
};
|
||||
if (market is not { } exactMarket)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
targets[index] = LegacyComparisonTarget.CreateKrxStock(
|
||||
exactMarket,
|
||||
names[index],
|
||||
identities[index][(separator + 1)..]);
|
||||
}
|
||||
|
||||
first = targets[0];
|
||||
second = targets[1];
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateNamedManualFinancialSelection(
|
||||
LegacyNamedPlaylistSelectionView selection,
|
||||
out CoreSceneSelection normalized)
|
||||
{
|
||||
normalized = null!;
|
||||
if (selection.GroupCode is not
|
||||
("KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var definitions = LegacyManualFinancialWorkflow.Screens
|
||||
.Where(definition =>
|
||||
string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) &&
|
||||
string.Equals(
|
||||
definition.CanonicalGraphicType,
|
||||
selection.Subtype,
|
||||
StringComparison.Ordinal))
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
if (definitions.Length != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LegacyComparisonValueGuard.RequireText(
|
||||
selection.Subject,
|
||||
200,
|
||||
nameof(selection.Subject));
|
||||
LegacyComparisonValueGuard.RequireStockCode(
|
||||
selection.DataCode,
|
||||
nameof(selection.DataCode));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = new CoreSceneSelection(
|
||||
selection.GroupCode,
|
||||
selection.Subject,
|
||||
definitions[0].CanonicalGraphicType,
|
||||
string.Empty,
|
||||
string.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryCreateNamedManualNetSellSelection(
|
||||
LegacyNamedPlaylistSelectionView selection,
|
||||
out CoreSceneSelection normalized)
|
||||
{
|
||||
normalized = null!;
|
||||
if (selection.DataCode.Length != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? audience = null;
|
||||
if (selection.GroupCode is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION" &&
|
||||
string.Equals(selection.Subject, "INDEX", StringComparison.Ordinal) &&
|
||||
string.Equals(selection.GraphicType, "MANUAL_NET_SELL", StringComparison.Ordinal) &&
|
||||
string.Equals(selection.Subtype, "MANUAL_NET_SELL", StringComparison.Ordinal))
|
||||
{
|
||||
audience = selection.GroupCode;
|
||||
}
|
||||
else if (string.Equals(selection.Subject, "지수", StringComparison.Ordinal) &&
|
||||
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
|
||||
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
|
||||
{
|
||||
audience = selection.GroupCode switch
|
||||
{
|
||||
"개인 순매도 상위(수동)" => "INDIVIDUAL",
|
||||
"외국인 순매도 상위(수동)" => "FOREIGN",
|
||||
"기관 순매도 상위(수동)" => "INSTITUTION",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
else if (string.Equals(selection.GroupCode, "지수", StringComparison.Ordinal) &&
|
||||
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
|
||||
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
|
||||
{
|
||||
audience = selection.Subject switch
|
||||
{
|
||||
"개인 순매도 상위(수동)" => "INDIVIDUAL",
|
||||
"외국인 순매도 상위(수동)" => "FOREIGN",
|
||||
"기관 순매도 상위(수동)" => "INSTITUTION",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
if (audience is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = new CoreSceneSelection(
|
||||
audience,
|
||||
"INDEX",
|
||||
"MANUAL_NET_SELL",
|
||||
"MANUAL_NET_SELL",
|
||||
string.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryCreateNamedVersionedViSelection(
|
||||
LegacyNamedPlaylistSelectionView selection,
|
||||
out CoreSceneSelection normalized)
|
||||
{
|
||||
normalized = null!;
|
||||
var canonical = string.Equals(selection.GroupCode, "PAGED_VI", StringComparison.Ordinal) &&
|
||||
string.Equals(
|
||||
selection.Subject,
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
|
||||
StringComparison.Ordinal) &&
|
||||
string.Equals(selection.GraphicType, "INPUT_ORDER", StringComparison.Ordinal) &&
|
||||
string.Equals(selection.Subtype, "CURRENT", StringComparison.Ordinal);
|
||||
var savedVisibleFields = string.Equals(
|
||||
selection.GroupCode,
|
||||
"지수",
|
||||
StringComparison.Ordinal) &&
|
||||
selection.Subject.Length > 0 &&
|
||||
string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) &&
|
||||
string.Equals(selection.Subtype, "VI 발동(수동)", StringComparison.Ordinal);
|
||||
if ((!canonical && !savedVisibleFields) ||
|
||||
!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.IsRowVersion(
|
||||
selection.DataCode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = new CoreSceneSelection(
|
||||
"PAGED_VI",
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
|
||||
"INPUT_ORDER",
|
||||
"CURRENT",
|
||||
selection.DataCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
private LegacyOperatorSnapshot CreateSnapshot()
|
||||
{
|
||||
var searchRows = _searchData.DisplayNames
|
||||
|
||||
@@ -957,11 +957,7 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
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 '-');
|
||||
LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(value);
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
|
||||
@@ -209,17 +209,38 @@ public static class LegacyStockCutCatalog
|
||||
throw UnknownCut(cut.RawLabel);
|
||||
}
|
||||
|
||||
var marketText = identity?.MarketText ?? string.Empty;
|
||||
var graphicType = cut.RawLabel[..split];
|
||||
var subtype = cut.RawLabel[(split + 1)..];
|
||||
var stockCode = identity?.StockCode ?? string.Empty;
|
||||
var runtimeDetailBranch = cut.RawLabel switch
|
||||
{
|
||||
"1열판상세_액면가" => nameof(S5011DetailBranch.FaceValue),
|
||||
"1열판상세_PBR" => nameof(S5011DetailBranch.Valuation),
|
||||
"1열판상세_거래량" => nameof(S5011DetailBranch.Volume),
|
||||
_ => null
|
||||
};
|
||||
var playoutSelection = runtimeDetailBranch is null
|
||||
? null
|
||||
: new LegacySceneSelection(
|
||||
marketText,
|
||||
stockName,
|
||||
graphicType,
|
||||
runtimeDetailBranch,
|
||||
stockCode);
|
||||
|
||||
return new LegacyOperatorPlaylistRow(
|
||||
rowId,
|
||||
true,
|
||||
identity?.MarketText ?? string.Empty,
|
||||
marketText,
|
||||
stockName,
|
||||
cut.RawLabel[..split],
|
||||
cut.RawLabel[(split + 1)..],
|
||||
graphicType,
|
||||
subtype,
|
||||
"1/1",
|
||||
identity?.StockCode ?? string.Empty,
|
||||
stockCode,
|
||||
cut.RawLabel,
|
||||
fadeDuration);
|
||||
fadeDuration,
|
||||
PlayoutSelection: playoutSelection);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyCutCatalogRow> CreateRows()
|
||||
|
||||
@@ -22,7 +22,8 @@ public sealed record LegacyCutPointerDownIntent(
|
||||
bool Shift,
|
||||
int X,
|
||||
int Y,
|
||||
long TimestampMilliseconds) : LegacyUiIntent;
|
||||
long TimestampMilliseconds,
|
||||
bool AllowAppend) : LegacyUiIntent;
|
||||
|
||||
public sealed record LegacyCutKeyDownIntent(
|
||||
LegacyCutKeyboardKey Key,
|
||||
@@ -1221,7 +1222,8 @@ public static class LegacyBridgeProtocol
|
||||
"shift",
|
||||
"x",
|
||||
"y",
|
||||
"timestampMilliseconds") ||
|
||||
"timestampMilliseconds",
|
||||
"allowAppend") ||
|
||||
!TryInt32(
|
||||
payload,
|
||||
"physicalIndex",
|
||||
@@ -1232,7 +1234,8 @@ public static class LegacyBridgeProtocol
|
||||
!TryBoolean(payload, "shift", out var shift) ||
|
||||
!TryInt32(payload, "x", 0, 16_384, out var x) ||
|
||||
!TryInt32(payload, "y", 0, 16_384, out var y) ||
|
||||
!TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp))
|
||||
!TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp) ||
|
||||
!TryBoolean(payload, "allowAppend", out var allowAppend))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -1243,7 +1246,8 @@ public static class LegacyBridgeProtocol
|
||||
shift,
|
||||
x,
|
||||
y,
|
||||
timestamp);
|
||||
timestamp,
|
||||
allowAppend);
|
||||
}
|
||||
|
||||
private static LegacyUiIntent? ParseDrop(JsonElement payload) =>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private static readonly TimeSpan DatabaseHealthPollingInterval =
|
||||
TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly SemaphoreSlim _databaseActivityGate = new(1, 1);
|
||||
private readonly INativeOperatorLogWriter _databaseHealthLogWriter =
|
||||
new NativeOperatorLogWriter();
|
||||
private readonly NativeOperatorLogTransitionTracker _databaseHealthTransitions =
|
||||
new();
|
||||
private int _databaseActivityPriorityWaiterCount;
|
||||
private CancellationTokenSource? _databaseHealthCheckCancellation;
|
||||
private Task? _databaseHealthMonitorTask;
|
||||
|
||||
private void StartDatabaseHealthMonitor()
|
||||
{
|
||||
_databaseHealthMonitorTask ??=
|
||||
MonitorDatabaseHealthAsync(_lifetimeCancellation.Token);
|
||||
}
|
||||
|
||||
private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// WinForms timerAliveChecker first fired after its 10-second interval.
|
||||
await Task.Delay(DatabaseHealthPollingInterval, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await CheckAndLogDatabaseHealthAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckAndLogDatabaseHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Volatile.Read(ref _playoutBusy) != 0 ||
|
||||
Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0 ||
|
||||
!await _databaseActivityGate.WaitAsync(0, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationTokenSource? healthCancellation = null;
|
||||
try
|
||||
{
|
||||
if (Volatile.Read(ref _playoutBusy) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_databaseRuntime is null)
|
||||
{
|
||||
ObserveDatabaseHealthState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.NotConfigured);
|
||||
ObserveDatabaseHealthState(
|
||||
DataSourceKind.MariaDb,
|
||||
DatabaseHealthState.NotConfigured);
|
||||
return;
|
||||
}
|
||||
|
||||
healthCancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken);
|
||||
Interlocked.Exchange(
|
||||
ref _databaseHealthCheckCancellation,
|
||||
healthCancellation);
|
||||
|
||||
// Close the race where an operator command starts after this monitor
|
||||
// acquired the activity gate but before the provider probes begin.
|
||||
if (Volatile.Read(ref _playoutBusy) != 0 ||
|
||||
Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0)
|
||||
{
|
||||
healthCancellation.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
var statuses = await _databaseRuntime.HealthService
|
||||
.CheckAllAsync(healthCancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
ObserveDatabaseHealthState(status.Source, status.State);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (
|
||||
cancellationToken.IsCancellationRequested ||
|
||||
healthCancellation?.IsCancellationRequested == true)
|
||||
{
|
||||
// Shutdown and operator work both have priority over diagnostics.
|
||||
}
|
||||
catch
|
||||
{
|
||||
ObserveDatabaseHealthState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.Unhealthy);
|
||||
ObserveDatabaseHealthState(
|
||||
DataSourceKind.MariaDb,
|
||||
DatabaseHealthState.Unhealthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (healthCancellation is not null)
|
||||
{
|
||||
Interlocked.CompareExchange(
|
||||
ref _databaseHealthCheckCancellation,
|
||||
null,
|
||||
healthCancellation);
|
||||
healthCancellation.Dispose();
|
||||
}
|
||||
|
||||
_databaseActivityGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcquirePriorityDatabaseActivityAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Signal priority before inspecting the active probe. This closes both
|
||||
// sides of the acquisition/publish race:
|
||||
// * a probe that has not acquired the gate sees the waiter and yields;
|
||||
// * a probe that owns the gate either sees the waiter after publishing
|
||||
// its CTS or is cancelled through that already-published CTS.
|
||||
Interlocked.Increment(ref _databaseActivityPriorityWaiterCount);
|
||||
try
|
||||
{
|
||||
CancelActiveDatabaseHealthCheck();
|
||||
await _databaseActivityGate.WaitAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _databaseActivityPriorityWaiterCount);
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelActiveDatabaseHealthCheck()
|
||||
{
|
||||
var healthCancellation = Volatile.Read(ref _databaseHealthCheckCancellation);
|
||||
try
|
||||
{
|
||||
healthCancellation?.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Probe completion won the race after the reference was read.
|
||||
}
|
||||
}
|
||||
|
||||
private void ObserveDatabaseHealthState(
|
||||
DataSourceKind source,
|
||||
DatabaseHealthState state)
|
||||
{
|
||||
if (!_databaseHealthTransitions.TryObserveDatabaseState(
|
||||
source,
|
||||
state,
|
||||
out var record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = _databaseHealthLogWriter.TryWrite(record);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logging is best effort and cannot affect DB or playout work.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,8 +178,9 @@ public sealed partial class MainWindow
|
||||
{
|
||||
case LegacyOperatorPlayoutCommand.Prepare:
|
||||
var request = gateAPrepareRequest ??
|
||||
_controller.CreatePlayoutRequest(
|
||||
_compositionOptions!.Current.FadeDuration);
|
||||
await CreateFreshPagedPlayoutRequestAsync(
|
||||
workflow,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
result = await workflow.PrepareAsync(
|
||||
request.Playlist,
|
||||
request.SelectedIndexZeroBased,
|
||||
@@ -190,8 +191,9 @@ public sealed partial class MainWindow
|
||||
// candidate) to m_crow and ran ONAirMode + Play, even while another
|
||||
// row was already on air. Re-read the current selection on every F8;
|
||||
// a stale explicit PREPARE must never override a later row click.
|
||||
var takeInRequest = _controller.CreatePlayoutRequest(
|
||||
_compositionOptions!.Current.FadeDuration);
|
||||
var takeInRequest = await CreateFreshPagedPlayoutRequestAsync(
|
||||
workflow,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
result = await workflow.TakeSelectedAsync(
|
||||
takeInRequest.Playlist,
|
||||
takeInRequest.SelectedIndexZeroBased,
|
||||
@@ -280,6 +282,61 @@ public sealed partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(
|
||||
LegacyPlayoutWorkflow workflow,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fadeDuration = _compositionOptions!.Current.FadeDuration;
|
||||
var request = _controller.CreatePlayoutRequest(fadeDuration);
|
||||
|
||||
// The first PREPARE/TAKE IN freezes the complete playlist. Resolve every
|
||||
// operator PageN row from a fresh read-only DB preflight before that snapshot
|
||||
// is handed to the workflow. A later F8 while PREPARED/PROGRAM reuses the
|
||||
// already-frozen 1/N values and must not query a second navigation contract.
|
||||
if (workflow.State.CurrentEntryId is not null)
|
||||
{
|
||||
return request;
|
||||
}
|
||||
|
||||
var pagedEntries = request.Playlist
|
||||
.Where(static entry => entry.PageNavigation?.PageSize.HasValue == true)
|
||||
.ToArray();
|
||||
if (pagedEntries.Length == 0)
|
||||
{
|
||||
return request;
|
||||
}
|
||||
|
||||
var plans = await workflow.PreflightPagePlansAsync(
|
||||
Array.AsReadOnly(pagedEntries),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (plans.Count != pagedEntries.Length)
|
||||
{
|
||||
throw new LegacySceneDataException(
|
||||
"The playlist page preflight did not return every PageN entry.");
|
||||
}
|
||||
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var entry = pagedEntries.SingleOrDefault(candidate =>
|
||||
string.Equals(candidate.EntryId, plan.EntryId, StringComparison.Ordinal));
|
||||
if (entry?.PageNavigation?.PageSize is not { } expectedPageSize ||
|
||||
plan.PageSize != (int)expectedPageSize ||
|
||||
plan.PageCount is < 1 or > ScenePaging.MaximumPageCount)
|
||||
{
|
||||
throw new LegacySceneDataException(
|
||||
"The playlist page preflight does not match the operator PageN row.");
|
||||
}
|
||||
|
||||
_controller.ReflectSuccessfulPlayout(
|
||||
plan.EntryId,
|
||||
pageIndexZeroBased: 0,
|
||||
pageCount: plan.PageCount,
|
||||
synchronizeOperatorSelection: false);
|
||||
}
|
||||
|
||||
return _controller.CreatePlayoutRequest(fadeDuration);
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(
|
||||
string capability,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -596,9 +653,7 @@ public sealed partial class MainWindow
|
||||
}
|
||||
|
||||
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
|
||||
LegacyCutPointerDownIntent or
|
||||
LegacyCutKeyDownIntent or
|
||||
LegacyClearCutSelectionIntent or
|
||||
LegacyCutPointerDownIntent { AllowAppend: true } or
|
||||
LegacyDropSelectedCutsIntent or
|
||||
LegacySetPlaylistEnabledIntent or
|
||||
LegacySetAllPlaylistEnabledIntent or
|
||||
@@ -948,6 +1003,7 @@ public sealed partial class MainWindow
|
||||
}
|
||||
|
||||
var intentGateEntered = false;
|
||||
var databaseGateEntered = false;
|
||||
var playoutGateEntered = false;
|
||||
try
|
||||
{
|
||||
@@ -958,6 +1014,9 @@ public sealed partial class MainWindow
|
||||
_intentBusy = true;
|
||||
QueueOperatorState();
|
||||
|
||||
await AcquirePriorityDatabaseActivityAsync(token).ConfigureAwait(false);
|
||||
databaseGateEntered = true;
|
||||
|
||||
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
|
||||
playoutGateEntered = true;
|
||||
Interlocked.Exchange(ref _playoutBusy, 1);
|
||||
@@ -1058,6 +1117,11 @@ public sealed partial class MainWindow
|
||||
_playoutCommandGate.Release();
|
||||
}
|
||||
|
||||
if (databaseGateEntered)
|
||||
{
|
||||
_databaseActivityGate.Release();
|
||||
}
|
||||
|
||||
if (intentGateEntered)
|
||||
{
|
||||
_intentBusy = false;
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
private readonly CancellationTokenSource _lifetimeCancellation = new();
|
||||
private readonly SemaphoreSlim _intentGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
|
||||
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
|
||||
private readonly WindowSubclassProcedure _windowSubclassProcedure;
|
||||
private readonly LegacyOperatorController _controller;
|
||||
@@ -241,6 +242,7 @@ public sealed partial class MainWindow : Window
|
||||
await InitializeWebViewAsync();
|
||||
if (!_closing)
|
||||
{
|
||||
StartDatabaseHealthMonitor();
|
||||
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
|
||||
}
|
||||
}
|
||||
@@ -380,15 +382,35 @@ public sealed partial class MainWindow : Window
|
||||
private async Task ProcessIntentAsync(LegacyUiIntent intent)
|
||||
{
|
||||
var gateEntered = false;
|
||||
var orderedPlayoutIntentEntered = false;
|
||||
var databaseGateEntered = false;
|
||||
var dispatchStarted = false;
|
||||
try
|
||||
{
|
||||
// MainForm performed database search synchronously. The busy snapshot
|
||||
// freezes the Web controls, while this zero-timeout gate prevents an
|
||||
// already-posted duplicate from growing an unbounded DB request queue.
|
||||
gateEntered = await _intentGate.WaitAsync(
|
||||
0,
|
||||
_lifetimeCancellation.Token);
|
||||
// freezes the Web controls. Playout commands must retain their order behind
|
||||
// a preceding local row-selection intent, but only one may wait or execute
|
||||
// at a time so a double post can never become a queued vendor command.
|
||||
if (IsOrderedPlayoutIntent(intent))
|
||||
{
|
||||
orderedPlayoutIntentEntered = await _orderedPlayoutIntentGate.WaitAsync(
|
||||
0,
|
||||
_lifetimeCancellation.Token);
|
||||
if (orderedPlayoutIntentEntered)
|
||||
{
|
||||
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
gateEntered = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep the zero-timeout admission used by database and edit intents;
|
||||
// those requests must not build an unbounded queue behind slow I/O.
|
||||
gateEntered = await _intentGate.WaitAsync(
|
||||
0,
|
||||
_lifetimeCancellation.Token);
|
||||
}
|
||||
|
||||
if (!gateEntered)
|
||||
{
|
||||
PostState(IsNamedPlaylistModalIntent(intent)
|
||||
@@ -514,6 +536,13 @@ public sealed partial class MainWindow : Window
|
||||
PostState(_controller.Current);
|
||||
}
|
||||
|
||||
await AcquirePriorityDatabaseActivityAsync(_lifetimeCancellation.Token);
|
||||
databaseGateEntered = true;
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchStarted = true;
|
||||
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
|
||||
ObserveOperatorMutationQuarantine(state);
|
||||
@@ -546,13 +575,26 @@ public sealed partial class MainWindow : Window
|
||||
_intentBusy = false;
|
||||
}
|
||||
|
||||
if (databaseGateEntered)
|
||||
{
|
||||
_databaseActivityGate.Release();
|
||||
}
|
||||
|
||||
if (gateEntered)
|
||||
{
|
||||
_intentGate.Release();
|
||||
}
|
||||
|
||||
if (orderedPlayoutIntentEntered)
|
||||
{
|
||||
_orderedPlayoutIntentGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
|
||||
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
|
||||
|
||||
private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) =>
|
||||
intent is LegacyRefreshNamedPlaylistsIntent or
|
||||
LegacySelectNamedPlaylistIntent or
|
||||
@@ -648,7 +690,8 @@ public sealed partial class MainWindow : Window
|
||||
pointer.Shift,
|
||||
pointer.X,
|
||||
pointer.Y,
|
||||
pointer.TimestampMilliseconds),
|
||||
pointer.TimestampMilliseconds,
|
||||
pointer.AllowAppend),
|
||||
LegacyCutKeyDownIntent key => _controller.CutKeyDown(
|
||||
key.Key,
|
||||
key.Control,
|
||||
@@ -1347,6 +1390,7 @@ public sealed partial class MainWindow : Window
|
||||
DetachCloseConfirmation();
|
||||
DetachInteractionMetricsRefresh();
|
||||
_lifetimeCancellation.Cancel();
|
||||
CancelActiveDatabaseHealthCheck();
|
||||
ShutdownPlayoutRuntime();
|
||||
ShutdownManualListsRuntime();
|
||||
DataQueryExecutor.Reset();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const searchButton = document.getElementById("stock-search-button");
|
||||
const stockResults = document.getElementById("stock-results");
|
||||
const cutList = document.getElementById("cut-list");
|
||||
const cutDragFeedback = document.getElementById("cut-drag-feedback");
|
||||
const playlistRows = document.getElementById("playlist-rows");
|
||||
const playlistDropZone = document.getElementById("playlist-drop-zone");
|
||||
const playlistMoveUp = document.getElementById("playlist-move-up");
|
||||
@@ -123,6 +124,7 @@
|
||||
let operatorCatalogDraftKey = "";
|
||||
let manualResultClickTimer = null;
|
||||
let comparisonTargetClickTimer = null;
|
||||
let themeResultClickTimer = null;
|
||||
let manualViResultClickTimer = null;
|
||||
let operatorCatalogStockClickTimer = null;
|
||||
let pendingNamedPlaylistCommand = null;
|
||||
@@ -357,6 +359,8 @@
|
||||
treeFocusOwnedBeforeRender = categoryTree.contains(document.activeElement);
|
||||
if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId);
|
||||
if (nextTabId !== renderedTreeTabId) {
|
||||
clearDelayedClick(themeResultClickTimer);
|
||||
themeResultClickTimer = null;
|
||||
renderedTreeTabId = nextTabId;
|
||||
pendingTreeResetTabs.clear();
|
||||
if (nextTabId) pendingTreeResetTabs.add(nextTabId);
|
||||
@@ -461,6 +465,14 @@
|
||||
playout.isTakeOutCompletionPending === true || playout.phase !== "idle"));
|
||||
}
|
||||
|
||||
function isCutSelectionLocked(state) {
|
||||
const playout = state && state.playout;
|
||||
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
|
||||
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
|
||||
playout.isPlayCompletionPending === true ||
|
||||
playout.isTakeOutCompletionPending === true));
|
||||
}
|
||||
|
||||
function dragDropPosition(event, element) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after";
|
||||
@@ -669,10 +681,15 @@
|
||||
|
||||
function clearCutDragGesture(releaseCapture) {
|
||||
const gesture = cutDragGesture;
|
||||
if (!gesture) return;
|
||||
cutDragGesture = null;
|
||||
gesture.element.classList.remove("dragging");
|
||||
playlistDropZone.classList.remove("cut-drag-over");
|
||||
cutDragFeedback.hidden = true;
|
||||
cutDragFeedback.classList.remove("allowed");
|
||||
delete cutDragFeedback.dataset.dropAllowed;
|
||||
cutDragFeedback.style.removeProperty("left");
|
||||
cutDragFeedback.style.removeProperty("top");
|
||||
if (!gesture) return;
|
||||
gesture.element.classList.remove("dragging");
|
||||
if (releaseCapture !== false &&
|
||||
typeof gesture.element.hasPointerCapture === "function" &&
|
||||
gesture.element.hasPointerCapture(gesture.pointerId) &&
|
||||
@@ -685,6 +702,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showCutDragFeedback(event, allowed) {
|
||||
cutDragFeedback.hidden = false;
|
||||
cutDragFeedback.classList.toggle("allowed", allowed);
|
||||
cutDragFeedback.dataset.dropAllowed = allowed ? "true" : "false";
|
||||
const gap = 14;
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
const width = cutDragFeedback.offsetWidth;
|
||||
const height = cutDragFeedback.offsetHeight;
|
||||
const left = Math.max(4, Math.min(event.clientX + gap, viewportWidth - width - 4));
|
||||
const top = Math.max(4, Math.min(event.clientY + gap, viewportHeight - height - 4));
|
||||
cutDragFeedback.style.left = Math.round(left) + "px";
|
||||
cutDragFeedback.style.top = Math.round(top) + "px";
|
||||
}
|
||||
|
||||
function beginCutDragGesture(element, event, position) {
|
||||
clearCutDragGesture();
|
||||
if (event.button !== 0) return;
|
||||
@@ -719,9 +751,10 @@
|
||||
}
|
||||
if (gesture.started) {
|
||||
event.preventDefault();
|
||||
playlistDropZone.classList.toggle(
|
||||
"cut-drag-over",
|
||||
!playlistMutationLocked && pointInsideElement(playlistDropZone, event));
|
||||
const dropAllowed = !playlistMutationLocked &&
|
||||
pointInsideElement(playlistDropZone, event);
|
||||
playlistDropZone.classList.toggle("cut-drag-over", dropAllowed);
|
||||
showCutDragFeedback(event, dropAllowed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +823,6 @@
|
||||
element.append(number, label);
|
||||
|
||||
element.addEventListener("pointerdown", function (event) {
|
||||
if (playlistMutationLocked) return event.preventDefault();
|
||||
if (!cutSelectionEnabled) return event.preventDefault();
|
||||
focusCutElement(element, false);
|
||||
const position = pointerPosition(event);
|
||||
@@ -802,9 +834,12 @@
|
||||
// equality contract. Drag geometry keeps the raw fractional CSS point.
|
||||
x: Math.round(position.x),
|
||||
y: Math.round(position.y),
|
||||
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
|
||||
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)),
|
||||
allowAppend: !playlistMutationLocked
|
||||
});
|
||||
beginCutDragGesture(element, event, position);
|
||||
if (!playlistMutationLocked) {
|
||||
beginCutDragGesture(element, event, position);
|
||||
}
|
||||
});
|
||||
element.addEventListener("pointermove", onCutPointerMove);
|
||||
element.addEventListener("pointerup", onCutPointerUp);
|
||||
@@ -816,7 +851,6 @@
|
||||
// The native ListView consumes these keys. Home/End still bubble to the
|
||||
// document-level MainForm KeyPreview equivalent for its playlist side effect.
|
||||
event.preventDefault();
|
||||
if (playlistMutationLocked) return;
|
||||
if (!cutSelectionEnabled) return;
|
||||
const target = cutKeyboardTarget(element, event.key);
|
||||
focusCutElement(target, true);
|
||||
@@ -831,7 +865,8 @@
|
||||
|
||||
function renderCuts(state) {
|
||||
const cutDragLocked = isOperatorMutationLocked(state);
|
||||
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) && !cutDragLocked;
|
||||
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) &&
|
||||
!isCutSelectionLocked(state);
|
||||
const focusedCutIndex = state.cutRows.findIndex(function (row) {
|
||||
return row.isFocused === true;
|
||||
});
|
||||
@@ -864,7 +899,9 @@
|
||||
? 0
|
||||
: -1;
|
||||
element.draggable = false;
|
||||
element.dataset.cutDragEnabled = cutSelectionEnabled ? "true" : "false";
|
||||
element.dataset.cutDragEnabled = cutSelectionEnabled && !cutDragLocked
|
||||
? "true"
|
||||
: "false";
|
||||
});
|
||||
|
||||
while (cutList.children.length > state.cutRows.length) {
|
||||
@@ -876,7 +913,7 @@
|
||||
}
|
||||
|
||||
cutList.addEventListener("pointerdown", function (event) {
|
||||
if (event.target !== cutList || playlistMutationLocked) return;
|
||||
if (event.target !== cutList || !cutSelectionEnabled) return;
|
||||
send("clear-cut-selection", {});
|
||||
});
|
||||
|
||||
@@ -4000,6 +4037,8 @@
|
||||
const themeResult = event.target.closest("button[data-theme-result-id]");
|
||||
if (themeResult && !themeResult.disabled) {
|
||||
event.preventDefault();
|
||||
clearDelayedClick(themeResultClickTimer);
|
||||
themeResultClickTimer = null;
|
||||
send("activate-theme-result", {
|
||||
resultId: themeResult.dataset.themeResultId
|
||||
});
|
||||
@@ -4260,7 +4299,12 @@
|
||||
}
|
||||
const themeResult = event.target.closest("button[data-theme-result-id]");
|
||||
if (themeResult) {
|
||||
send("select-theme", { resultId: themeResult.dataset.themeResultId });
|
||||
clearDelayedClick(themeResultClickTimer);
|
||||
const resultId = themeResult.dataset.themeResultId;
|
||||
themeResultClickTimer = window.setTimeout(function () {
|
||||
themeResultClickTimer = null;
|
||||
send("select-theme", { resultId: resultId });
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("button[data-theme-search]")) {
|
||||
|
||||
@@ -257,6 +257,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cut-drag-feedback" class="cut-drag-feedback" aria-hidden="true" hidden>
|
||||
<span class="cut-drag-feedback-icon"></span>
|
||||
<span>선택 컷 추가</span>
|
||||
</div>
|
||||
|
||||
<script src="legacy-cut-drag.js"></script>
|
||||
<script src="legacy-modal-focus.js"></script>
|
||||
<script src="legacy-named-playlist-selection.js"></script>
|
||||
|
||||
@@ -84,7 +84,7 @@ button:disabled { color: #555; opacity: 1; }
|
||||
.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-workspace { min-height: 0; display: grid; grid-template-rows: auto 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; }
|
||||
@@ -144,6 +144,13 @@ button:disabled { color: #555; opacity: 1; }
|
||||
.cut-row[data-cut-drag-enabled="true"] { cursor: grab; }
|
||||
.cut-row.dragging { opacity: .58; cursor: grabbing; }
|
||||
.playlist-grid.cut-drag-over { box-shadow: inset 0 0 0 3px #176b2d; }
|
||||
.cut-drag-feedback { position: fixed; z-index: 10000; display: flex; align-items: center; gap: 7px; max-width: 260px; padding: 7px 10px; border: 1px solid #8a8a8a; border-radius: 4px; background: rgba(255, 255, 255, .97); box-shadow: 0 3px 10px rgba(0, 0, 0, .28); color: #555; font-size: 13px; font-weight: 700; line-height: 1; pointer-events: none; user-select: none; white-space: nowrap; }
|
||||
.cut-drag-feedback[hidden] { display: none; }
|
||||
.cut-drag-feedback-icon { display: inline-flex; width: 17px; height: 17px; align-items: center; justify-content: center; border-radius: 50%; background: #777; color: #fff; font-size: 16px; font-weight: 800; }
|
||||
.cut-drag-feedback-icon::before { content: "×"; }
|
||||
.cut-drag-feedback.allowed { border-color: #176b2d; color: #125824; }
|
||||
.cut-drag-feedback.allowed .cut-drag-feedback-icon { background: #176b2d; }
|
||||
.cut-drag-feedback.allowed .cut-drag-feedback-icon::before { content: "+"; }
|
||||
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
|
||||
|
||||
.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; }
|
||||
|
||||
Reference in New Issue
Block a user