816 lines
28 KiB
C#
816 lines
28 KiB
C#
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,
|
|
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];
|
|
}
|
|
|
|
internal LegacyFixedActionCatalog WithRuntimeOrder(
|
|
IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>> actionsByMarket)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionsByMarket);
|
|
var ordered = new List<LegacyFixedAction>(Actions.Count);
|
|
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
|
|
{
|
|
var source = actionsByMarket.TryGetValue(market, out var runtimeActions)
|
|
? runtimeActions
|
|
: _byMarket[market];
|
|
if (source is null || source.Count != _byMarket[market].Count ||
|
|
source.Any(action => action is null || action.Market != market) ||
|
|
!source.Select(action => action.Id).ToHashSet(StringComparer.Ordinal)
|
|
.SetEquals(_byMarket[market].Select(action => action.Id)))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"The runtime fixed-action order for '{MarketText(market)}' is invalid.");
|
|
}
|
|
|
|
ordered.AddRange(source);
|
|
}
|
|
|
|
return new LegacyFixedActionCatalog(
|
|
ReadOnly(ordered),
|
|
RuntimeAssets,
|
|
DocumentSha256,
|
|
GeneratorSourceSha256);
|
|
}
|
|
|
|
public LegacyMaterializedFixedAction Materialize(
|
|
string actionId,
|
|
DateTimeOffset localNow)
|
|
{
|
|
var action = GetAction(actionId);
|
|
if (!action.Available)
|
|
{
|
|
throw new InvalidOperationException(action.Prerequisite);
|
|
}
|
|
|
|
var graphicType = action.DynamicNxtSession
|
|
? localNow.Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET"
|
|
: 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,
|
|
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 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; }
|
|
}
|
|
}
|