Files
MBN_STOCK_WEBVIEW/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOverseasSelectionWorkflowTests.cs

560 lines
24 KiB
C#

using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOverseasSelectionWorkflowTests
{
private static readonly DateTimeOffset RetrievedAt =
new(2026, 7, 15, 9, 30, 0, TimeSpan.FromHours(9));
[Fact]
public void InitialSnapshotIsTypedImmutableAndContainsClosedCatalogs()
{
var workflow = CreateWorkflow();
var snapshot = workflow.Current;
Assert.Equal(0, snapshot.Revision);
Assert.Equal(10, snapshot.FixedIndexTargets.Count);
Assert.Equal(12, snapshot.Actions.Count);
Assert.Null(snapshot.SelectedFixedIndex);
Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Industry.Status);
Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Stock.Status);
Assert.Empty(snapshot.Industry.Results);
Assert.Empty(snapshot.Stock.Results);
Assert.Equal(default, snapshot.MovingAverages);
var targets = Assert.IsAssignableFrom<IList<LegacyOverseasFixedIndexTarget>>(
snapshot.FixedIndexTargets);
var actions = Assert.IsAssignableFrom<IList<LegacyOverseasActionDefinition>>(
snapshot.Actions);
Assert.True(targets.IsReadOnly);
Assert.True(actions.IsReadOnly);
Assert.Throws<NotSupportedException>(() => targets.RemoveAt(0));
Assert.Throws<NotSupportedException>(() => actions.RemoveAt(0));
}
[Fact]
public async Task SearchesUseInjectedInterfacesNormalizeQueriesAndIssueGenerationBoundIds()
{
var industryItems = new List<OverseasIndustryIndexItem>
{
new("Alpha", "Alpha Industry", "NAS@ALPHA"),
new("Beta", "Beta Industry", "NAS@BETA")
};
var stockItems = new List<WorldStockSearchItem>
{
new("Apple", "NAS@AAPL", "US"),
new("Taiwan Semi", "TWS@2330", "TW")
};
var industry = new RecordingIndustryService(industryItems);
var stock = new RecordingStockService(stockItems);
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
var industryState = await workflow.SearchIndustriesAsync(" alpha ", 25);
var stockState = await workflow.SearchStocksAsync(" semi ", 30);
industryItems.Clear();
stockItems.Clear();
Assert.Equal([("alpha", 25)], industry.Calls);
Assert.Equal([("semi", 30)], stock.Calls);
Assert.Equal(LegacyOverseasSearchStatus.Ready, industryState.Industry.Status);
Assert.Equal("alpha", industryState.Industry.Query);
Assert.Equal(RetrievedAt, industryState.Industry.RetrievedAt);
Assert.Equal(
["overseas-industry-00000001-0001", "overseas-industry-00000001-0002"],
industryState.Industry.Results.Select(row => row.SelectionId));
Assert.Equal(
["overseas-stock-00000001-0001", "overseas-stock-00000001-0002"],
stockState.Stock.Results.Select(row => row.SelectionId));
Assert.Equal(2, workflow.Current.Industry.ResultCount);
Assert.Equal(2, workflow.Current.Stock.ResultCount);
var industryRows = Assert.IsAssignableFrom<IList<LegacyOverseasIndustrySearchRow>>(
workflow.Current.Industry.Results);
var stockRows = Assert.IsAssignableFrom<IList<LegacyOverseasStockSearchRow>>(
workflow.Current.Stock.Results);
Assert.True(industryRows.IsReadOnly);
Assert.True(stockRows.IsReadOnly);
Assert.Throws<NotSupportedException>(() => industryRows.RemoveAt(0));
Assert.Throws<NotSupportedException>(() => stockRows.RemoveAt(0));
}
[Fact]
public async Task SelectionUsesCurrentOpaqueIdsAndARefreshInvalidatesOldIds()
{
var industry = new QueueIndustryService(
IndustryResult(
"",
new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")),
IndustryResult(
"next",
new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA")));
var stock = new QueueStockService(
StockResult("", new WorldStockSearchItem("Apple", "NAS@AAPL", "US")),
StockResult("next", new WorldStockSearchItem("Taiwan Semi", "TWS@2330", "TW")));
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
var firstIndustry = await workflow.SearchIndustriesAsync("");
var oldIndustryId = firstIndustry.Industry.Results[0].SelectionId;
Assert.Equal(
"Alpha",
workflow.SelectIndustry(oldIndustryId).Industry.Selected?.KoreanName);
var firstStock = await workflow.SearchStocksAsync("");
var oldStockId = firstStock.Stock.Results[0].SelectionId;
Assert.Equal("Apple", workflow.SelectStock(oldStockId).Stock.Selected?.InputName);
await workflow.SearchIndustriesAsync("next");
await workflow.SearchStocksAsync("next");
Assert.Null(workflow.Current.Industry.Selected);
Assert.Null(workflow.Current.Stock.Selected);
Assert.Throws<KeyNotFoundException>(() => workflow.SelectIndustry(oldIndustryId));
Assert.Throws<KeyNotFoundException>(() => workflow.SelectStock(oldStockId));
Assert.StartsWith(
"overseas-industry-00000002-",
workflow.Current.Industry.Results[0].SelectionId,
StringComparison.Ordinal);
Assert.StartsWith(
"overseas-stock-00000002-",
workflow.Current.Stock.Results[0].SelectionId,
StringComparison.Ordinal);
}
[Fact]
public void FixedIndexActionsMaterializeExactAliasIdentityPageAndMovingAverages()
{
var workflow = CreateWorkflow();
workflow.SelectFixedIndex("germany-dax-30");
workflow.SetMovingAverages(ma5: true, ma20: false);
var expected = new Dictionary<string, (string Alias, int Days)>(StringComparer.Ordinal)
{
["index-candle-5d"] = ("8061", 5),
["index-candle-20d"] = ("8040", 20),
["index-candle-60d"] = ("8046", 60),
["index-candle-120d"] = ("8051", 120),
["index-candle-240d"] = ("8056", 240)
};
foreach (var pair in expected)
{
var draft = workflow.MaterializePlaylistDraft(pair.Key);
Assert.Equal("s8010", draft.BuilderKey);
Assert.Equal(pair.Value.Alias, draft.SceneAlias);
Assert.Equal([pair.Value.Alias], draft.Aliases);
Assert.Equal(0, draft.PageSize);
Assert.Equal("1/1", draft.InitialPageText);
Assert.Equal($"독일 DAX 30 캔들 {pair.Value.Days}일", draft.Title);
Assert.Equal("chart", draft.Category);
Assert.Equal("overseas", draft.Market);
Assert.Equal(LegacyOverseasActionSource.FixedIndex, draft.Source);
Assert.Equal(
new LegacyOverseasPlaylistSelection(
"FOREIGN_INDEX",
"독일 DAX 30",
"MA5",
"PRICE",
"XTR@DAX30|DE|0"),
draft.Selection);
Assert.Equal(new LegacyOverseasMovingAverageSelection(true, false), draft.MovingAverages);
Assert.True(draft.IsEnabled);
Assert.Equal(6, draft.FadeDuration);
}
}
[Fact]
public async Task IndustryCurrentMaterializesRootWorkflowSelectionAndIgnoresMaOptions()
{
var workflow = CreateWorkflow(
industries: [new("반도체", "US Semiconductor", "NAS@SOX")]);
var searched = await workflow.SearchIndustriesAsync("");
workflow.SelectIndustry(searched.Industry.Results[0].SelectionId);
workflow.SetMovingAverages(ma5: true, ma20: true);
var draft = workflow.MaterializePlaylistDraft("industry-current");
Assert.Equal("s5001", draft.BuilderKey);
Assert.Equal("5001", draft.SceneAlias);
Assert.Equal(0, draft.PageSize);
Assert.Equal("1/1", draft.InitialPageText);
Assert.Equal("반도체", draft.Title);
Assert.Equal("plate", draft.Category);
Assert.Equal(
new LegacyOverseasPlaylistSelection(
"FOREIGN_INDUSTRY",
"반도체",
"1열판기본",
"CURRENT",
"US Semiconductor|NAS@SOX|US|0"),
draft.Selection);
Assert.Equal(default, draft.MovingAverages);
}
[Fact]
public async Task StockCurrentAndAllCandleActionsUseSelectedUsTwIdentity()
{
var workflow = CreateWorkflow(
stocks: [new("Taiwan Semi", "TWS@2330", "TW")]);
var searched = await workflow.SearchStocksAsync("semi");
workflow.SelectStock(searched.Stock.Results[0].SelectionId);
workflow.SetMovingAverages(ma5: true, ma20: true);
var current = workflow.MaterializePlaylistDraft("stock-current");
Assert.Equal("s5001", current.BuilderKey);
Assert.Equal("5001", current.SceneAlias);
Assert.Equal("stock", current.Category);
Assert.Equal(
new LegacyOverseasPlaylistSelection(
"FOREIGN_STOCK",
"Taiwan Semi",
"1열판기본",
"CURRENT",
"TWS@2330|TW|1"),
current.Selection);
Assert.Equal(default, current.MovingAverages);
var expectedAliases = new Dictionary<string, string>(StringComparer.Ordinal)
{
["stock-candle-5d"] = "8061",
["stock-candle-20d"] = "8040",
["stock-candle-60d"] = "8046",
["stock-candle-120d"] = "8051",
["stock-candle-240d"] = "8056"
};
foreach (var pair in expectedAliases)
{
var candle = workflow.MaterializePlaylistDraft(pair.Key);
Assert.Equal("s8010", candle.BuilderKey);
Assert.Equal(pair.Value, candle.SceneAlias);
Assert.Equal("chart", candle.Category);
Assert.Equal("MA5,MA20", candle.Selection.GraphicType);
Assert.Equal("PRICE", candle.Selection.Subtype);
Assert.Equal("TWS@2330|TW|1", candle.Selection.DataCode);
Assert.Equal(new LegacyOverseasMovingAverageSelection(true, true), candle.MovingAverages);
}
}
[Theory]
[InlineData(false, false, "")]
[InlineData(true, false, "MA5")]
[InlineData(false, true, "MA20")]
[InlineData(true, true, "MA5,MA20")]
public async Task MovingAverageOptionsHaveExactClosedText(
bool ma5,
bool ma20,
string expected)
{
var workflow = CreateWorkflow(stocks: [new("Apple", "NAS@AAPL", "US")]);
var searched = await workflow.SearchStocksAsync("");
workflow.SelectStock(searched.Stock.Results[0].SelectionId);
workflow.SetMovingAverages(ma5, ma20);
var draft = workflow.MaterializePlaylistDraft("stock-candle-20d");
Assert.Equal(expected, draft.Selection.GraphicType);
Assert.Equal(new LegacyOverseasMovingAverageSelection(ma5, ma20), draft.MovingAverages);
}
[Fact]
public void ActionPrerequisitesUnknownIdsAndCallerOverridesFailClosed()
{
var workflow = CreateWorkflow();
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("index-candle-5d"));
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("industry-current"));
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("stock-current"));
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("../../8010"));
Assert.Throws<ArgumentNullException>(() =>
workflow.MaterializePlaylistDraft(null!));
Assert.Throws<KeyNotFoundException>(() => workflow.SelectFixedIndex("DOW-JONES"));
var methods = typeof(LegacyOverseasSelectionWorkflow).GetMethods()
.Where(method => method.Name == nameof(LegacyOverseasSelectionWorkflow.MaterializePlaylistDraft));
var method = Assert.Single(methods);
var parameter = Assert.Single(method.GetParameters());
Assert.Equal(typeof(string), parameter.ParameterType);
}
[Fact]
public async Task SlowerIndustryResponseCannotReplaceOrInvalidateNewerState()
{
var industry = new ControlledIndustryService();
var workflow = new LegacyOverseasSelectionWorkflow(industry, new RecordingStockService([]));
var older = workflow.SearchIndustriesAsync("old");
var newer = workflow.SearchIndustriesAsync("new");
industry.Complete(
"new",
IndustryResult(
"new",
new OverseasIndustryIndexItem("New", "New Industry", "NAS@NEW")));
var newerState = await newer;
// The stale response is intentionally malformed. Generation gating must happen
// before its envelope and identities are examined.
industry.Complete(
"old",
new OverseasIndustryIndexSearchResult(
"wrong",
default,
[new OverseasIndustryIndexItem(" bad ", "bad", "|")],
false));
var olderState = await older;
Assert.Equal("new", newerState.Industry.Query);
Assert.Equal("new", olderState.Industry.Query);
Assert.Equal("New", workflow.Current.Industry.Results[0].KoreanName);
Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Industry.Status);
Assert.Equal(3, workflow.Current.Revision);
}
[Fact]
public async Task SlowerStockFaultCannotReplaceOrFaultNewerState()
{
var stock = new ControlledStockService();
var workflow = new LegacyOverseasSelectionWorkflow(new RecordingIndustryService([]), stock);
var older = workflow.SearchStocksAsync("old");
var newer = workflow.SearchStocksAsync("new");
stock.Complete(
"new",
StockResult("new", new WorldStockSearchItem("New Stock", "NAS@NEW", "US")));
await newer;
stock.Fail("old", new InvalidOperationException("stale provider failure"));
var olderState = await older;
Assert.Equal("new", olderState.Stock.Query);
Assert.Equal("New Stock", olderState.Stock.Results[0].InputName);
Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Stock.Status);
Assert.Equal(3, workflow.Current.Revision);
}
[Fact]
public async Task InvalidCurrentIndustryResponseFailsAtomicallyIntoErrorState()
{
var invalid = IndustryResult(
"query",
new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA"),
new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA"));
var workflow = new LegacyOverseasSelectionWorkflow(
new QueueIndustryService(invalid),
new RecordingStockService([]));
await Assert.ThrowsAsync<LegacyOverseasWorkflowException>(() =>
workflow.SearchIndustriesAsync("query"));
Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Industry.Status);
Assert.Empty(workflow.Current.Industry.Results);
Assert.Null(workflow.Current.Industry.Selected);
Assert.NotEmpty(workflow.Current.Industry.Error);
Assert.Equal(2, workflow.Current.Revision);
}
[Fact]
public async Task InvalidCurrentStockIdentityFailsAtomicallyIntoErrorState()
{
var invalid = StockResult(
"query",
new WorldStockSearchItem("London", "LNS@BAD", "GB"));
var workflow = new LegacyOverseasSelectionWorkflow(
new RecordingIndustryService([]),
new QueueStockService(invalid));
await Assert.ThrowsAsync<LegacyOverseasWorkflowException>(() =>
workflow.SearchStocksAsync("query"));
Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Stock.Status);
Assert.Empty(workflow.Current.Stock.Results);
Assert.Null(workflow.Current.Stock.Selected);
Assert.NotEmpty(workflow.Current.Stock.Error);
}
[Fact]
public async Task SearchBoundsUnsafeQueriesAndNullServicesAreRejectedBeforeCalls()
{
var industry = new RecordingIndustryService([]);
var stock = new RecordingStockService([]);
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
workflow.SearchIndustriesAsync("", 0));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
workflow.SearchStocksAsync("", 501));
await Assert.ThrowsAsync<ArgumentException>(() =>
workflow.SearchIndustriesAsync(new string('x', 65)));
await Assert.ThrowsAsync<ArgumentException>(() =>
workflow.SearchStocksAsync("bad\u0001query"));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
workflow.SearchStocksAsync(null!));
Assert.Empty(industry.Calls);
Assert.Empty(stock.Calls);
Assert.Throws<ArgumentNullException>(() =>
new LegacyOverseasSelectionWorkflow(null!, stock));
Assert.Throws<ArgumentNullException>(() =>
new LegacyOverseasSelectionWorkflow(industry, null!));
}
[Fact]
public async Task ClearOperationsAndRepeatedMaChoiceHaveStableRevisionSemantics()
{
var workflow = CreateWorkflow(
industries: [new("Alpha", "Alpha Industry", "NAS@ALPHA")],
stocks: [new("Apple", "NAS@AAPL", "US")]);
workflow.SelectFixedIndex("dow-jones");
var industry = await workflow.SearchIndustriesAsync("");
workflow.SelectIndustry(industry.Industry.Results[0].SelectionId);
var stock = await workflow.SearchStocksAsync("");
workflow.SelectStock(stock.Stock.Results[0].SelectionId);
workflow.SetMovingAverages(true, false);
var beforeClear = workflow.Current.Revision;
workflow.ClearFixedIndexSelection();
workflow.ClearIndustrySelection();
workflow.ClearStockSelection();
var cleared = workflow.Current;
Assert.Equal(beforeClear + 3, cleared.Revision);
Assert.Null(cleared.SelectedFixedIndex);
Assert.Null(cleared.Industry.Selected);
Assert.Null(cleared.Stock.Selected);
var stable = workflow.SetMovingAverages(true, false);
Assert.Equal(cleared.Revision, stable.Revision);
}
private static LegacyOverseasSelectionWorkflow CreateWorkflow(
IReadOnlyList<OverseasIndustryIndexItem>? industries = null,
IReadOnlyList<WorldStockSearchItem>? stocks = null) =>
new(
new RecordingIndustryService(industries ?? []),
new RecordingStockService(stocks ?? []));
private static OverseasIndustryIndexSearchResult IndustryResult(
string query,
params OverseasIndustryIndexItem[] items) =>
new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false);
private static WorldStockSearchResult StockResult(
string query,
params WorldStockSearchItem[] items) =>
new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false);
private sealed class RecordingIndustryService(
IReadOnlyList<OverseasIndustryIndexItem> items) : IOverseasIndustryIndexSearchService
{
public List<(string Query, int MaximumResults)> Calls { get; } = [];
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Calls.Add((query, maximumResults));
return Task.FromResult(new OverseasIndustryIndexSearchResult(
query,
RetrievedAt,
items,
IsTruncated: false));
}
}
private sealed class RecordingStockService(
IReadOnlyList<WorldStockSearchItem> items) : IWorldStockSearchService
{
public List<(string Query, int MaximumResults)> Calls { get; } = [];
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Calls.Add((query, maximumResults));
return Task.FromResult(new WorldStockSearchResult(
query,
RetrievedAt,
items,
IsTruncated: false));
}
}
private sealed class QueueIndustryService(
params OverseasIndustryIndexSearchResult[] results) : IOverseasIndustryIndexSearchService
{
private readonly Queue<OverseasIndustryIndexSearchResult> _results = new(results);
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_results.Dequeue());
}
}
private sealed class QueueStockService(
params WorldStockSearchResult[] results) : IWorldStockSearchService
{
private readonly Queue<WorldStockSearchResult> _results = new(results);
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_results.Dequeue());
}
}
private sealed class ControlledIndustryService : IOverseasIndustryIndexSearchService
{
private readonly Dictionary<string, TaskCompletionSource<OverseasIndustryIndexSearchResult>>
_pending = new(StringComparer.Ordinal);
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var source = new TaskCompletionSource<OverseasIndustryIndexSearchResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
_pending.Add(query, source);
return source.Task.WaitAsync(cancellationToken);
}
public void Complete(string query, OverseasIndustryIndexSearchResult result) =>
_pending[query].SetResult(result);
}
private sealed class ControlledStockService : IWorldStockSearchService
{
private readonly Dictionary<string, TaskCompletionSource<WorldStockSearchResult>> _pending =
new(StringComparer.Ordinal);
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var source = new TaskCompletionSource<WorldStockSearchResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
_pending.Add(query, source);
return source.Task.WaitAsync(cancellationToken);
}
public void Complete(string query, WorldStockSearchResult result) =>
_pending[query].SetResult(result);
public void Fail(string query, Exception exception) =>
_pending[query].SetException(exception);
}
}