702 lines
30 KiB
C#
702 lines
30 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 InitialSnapshotIsTypedImmutableAndKeepsFarPointFirstFixedRowActive()
|
|
{
|
|
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.Equal("dow-jones", snapshot.SelectedFixedIndex?.TargetId);
|
|
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("Alpha", industryState.Industry.Selected?.KoreanName);
|
|
Assert.Equal("Apple", stockState.Stock.Selected?.InputName);
|
|
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 FirstInsertedSearchRowsBecomeTheActiveSourcesAtTheFullCompatibilityBound()
|
|
{
|
|
var industry = new RecordingIndustryService(
|
|
[new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")]);
|
|
var stock = new RecordingStockService(
|
|
[new WorldStockSearchItem("Apple", "NAS@AAPL", "US")]);
|
|
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
|
|
|
|
var industries = await workflow.SearchIndustriesAsync("alpha");
|
|
var stocks = await workflow.SearchStocksAsync("apple");
|
|
|
|
Assert.Equal("Alpha", industries.Industry.Selected?.KoreanName);
|
|
Assert.Equal("Apple", stocks.Stock.Selected?.InputName);
|
|
Assert.Equal(
|
|
[("alpha", LegacyOverseasSelectionWorkflow.DefaultMaximumResults)],
|
|
industry.Calls);
|
|
Assert.Equal(
|
|
[("apple", LegacyOverseasStockSearchService.MaximumResults)],
|
|
stock.Calls);
|
|
}
|
|
|
|
[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.Equal("Beta", workflow.Current.Industry.Selected?.KoreanName);
|
|
Assert.Equal("Taiwan Semi", workflow.Current.Stock.Selected?.InputName);
|
|
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 async Task DuplicateIndustryDisplayNamesRemainDistinctOpaqueSelections()
|
|
{
|
|
var workflow = CreateWorkflow(industries:
|
|
[
|
|
new OverseasIndustryIndexItem("Shared", "First Input", "NAS@ONE"),
|
|
new OverseasIndustryIndexItem("Shared", "Second Input", "NAS@TWO")
|
|
]);
|
|
|
|
var searched = await workflow.SearchIndustriesAsync(string.Empty);
|
|
|
|
Assert.Equal(2, searched.Industry.Results.Count);
|
|
Assert.Equal("Shared", searched.Industry.Results[0].KoreanName);
|
|
Assert.Equal("Shared", searched.Industry.Results[1].KoreanName);
|
|
Assert.NotEqual(
|
|
searched.Industry.Results[0].SelectionId,
|
|
searched.Industry.Results[1].SelectionId);
|
|
|
|
workflow.SelectIndustry(searched.Industry.Results[1].SelectionId);
|
|
var draft = workflow.MaterializePlaylistDraft("industry-current");
|
|
|
|
Assert.Equal("Second Input|NAS@TWO|US|0", draft.Selection.DataCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsolatedStockRowsAreReportedWithoutRejectingValidSelections()
|
|
{
|
|
var stockResult = new WorldStockSearchResult(
|
|
string.Empty,
|
|
RetrievedAt,
|
|
[new WorldStockSearchItem("Apple", "NAS@AAPL", "US")],
|
|
IsTruncated: true,
|
|
IsolatedInvalidRowCount: 16);
|
|
var workflow = new LegacyOverseasSelectionWorkflow(
|
|
new RecordingIndustryService([]),
|
|
new QueueStockService(stockResult));
|
|
|
|
var snapshot = await workflow.SearchStocksAsync(string.Empty);
|
|
|
|
Assert.Equal(LegacyOverseasSearchStatus.Ready, snapshot.Stock.Status);
|
|
Assert.Single(snapshot.Stock.Results);
|
|
Assert.Equal(16, snapshot.Stock.IsolatedInvalidRowCount);
|
|
Assert.True(snapshot.Stock.IsTruncated);
|
|
}
|
|
|
|
[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", 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);
|
|
Assert.Equal(
|
|
new LegacyOverseasPlaylistVisibleFields(
|
|
"해외지수", "독일 DAX 30", "캔들그래프", $"{pair.Value.Days}일", "1/1"),
|
|
draft.VisibleFields);
|
|
}
|
|
}
|
|
|
|
[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);
|
|
Assert.Equal(
|
|
new LegacyOverseasPlaylistVisibleFields(
|
|
"해외업종", "반도체", "1열판기본", string.Empty, "1/1"),
|
|
draft.VisibleFields);
|
|
}
|
|
|
|
[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);
|
|
Assert.Equal(
|
|
new LegacyOverseasPlaylistVisibleFields(
|
|
"해외종목", "Taiwan Semi", "1열판기본", string.Empty, "1/1"),
|
|
current.VisibleFields);
|
|
|
|
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);
|
|
Assert.Equal(
|
|
new LegacyOverseasPlaylistVisibleFields(
|
|
"해외종목", "Taiwan Semi", "캔들그래프",
|
|
$"{LegacyOverseasActionCatalog.GetAction(pair.Key).PeriodDays}일", "1/1"),
|
|
candle.VisibleFields);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CurrentProviderPunctuationRemainsAnExactBoundStockIdentity()
|
|
{
|
|
var workflow = CreateWorkflow(stocks:
|
|
[
|
|
new("Ampersand", "IDX@A&B", "US"),
|
|
new("Parenthesized", "IDX@(A)", "US")
|
|
]);
|
|
|
|
var searched = await workflow.SearchStocksAsync(string.Empty);
|
|
|
|
Assert.Equal(LegacyOverseasSearchStatus.Ready, searched.Stock.Status);
|
|
Assert.Equal(2, searched.Stock.Results.Count);
|
|
workflow.SelectStock(searched.Stock.Results[0].SelectionId);
|
|
Assert.Equal(
|
|
"IDX@A&B|US|1",
|
|
workflow.MaterializePlaylistDraft("stock-current").Selection.DataCode);
|
|
workflow.SelectStock(searched.Stock.Results[1].SelectionId);
|
|
Assert.Equal(
|
|
"IDX@(A)|US|1",
|
|
workflow.MaterializePlaylistDraft("stock-candle-5d").Selection.DataCode);
|
|
}
|
|
|
|
[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.Equal(
|
|
"DJI@DJI",
|
|
workflow.MaterializePlaylistDraft("index-candle-5d").Selection.DataCode.Split('|')[0]);
|
|
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 NonUsTwSearchHitMaterializesAnExactAllNationIdentity()
|
|
{
|
|
var workflow = CreateWorkflow(stocks: [new("London Stock", "LNS@LON", "GB")]);
|
|
var searched = await workflow.SearchStocksAsync("london");
|
|
workflow.SelectStock(Assert.Single(searched.Stock.Results).SelectionId);
|
|
|
|
var availability = workflow.GetActionAvailability("stock-current");
|
|
|
|
Assert.True(availability.IsAvailable);
|
|
Assert.Null(availability.Reason);
|
|
var current = workflow.MaterializePlaylistDraft("stock-current");
|
|
var candle = workflow.MaterializePlaylistDraft("stock-candle-5d");
|
|
Assert.Equal("LNS@LON|GB|1", current.Selection.DataCode);
|
|
Assert.Equal("LNS@LON|GB|1", candle.Selection.DataCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ThreeLetterNationRowsRemainVisibleButFailClosedBeforePlaylistMaterialization()
|
|
{
|
|
var workflow = CreateWorkflow(stocks: [new("Unsupported", "LNS@UNSUPPORTED", "GBR")]);
|
|
|
|
var searched = await workflow.SearchStocksAsync("unsupported");
|
|
|
|
Assert.Equal("Unsupported", searched.Stock.Selected?.InputName);
|
|
var availability = workflow.GetActionAvailability("stock-current");
|
|
Assert.False(availability.IsAvailable);
|
|
Assert.Equal("선택한 해외종목의 국가 코드는 두 글자여야 합니다.", availability.Reason);
|
|
Assert.Throws<LegacyOverseasWorkflowException>(() =>
|
|
workflow.MaterializePlaylistDraft("stock-current"));
|
|
}
|
|
|
|
[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("", LegacyOverseasSelectionWorkflow.MaximumResults + 1));
|
|
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) : IOverseasStockSearchService
|
|
{
|
|
public List<(string Query, int MaximumResults)> Calls { get; } = [];
|
|
|
|
public Task<WorldStockSearchResult> SearchAsync(
|
|
string query,
|
|
int maximumResults = LegacyOverseasStockSearchService.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) : IOverseasStockSearchService
|
|
{
|
|
private readonly Queue<WorldStockSearchResult> _results = new(results);
|
|
|
|
public Task<WorldStockSearchResult> SearchAsync(
|
|
string query,
|
|
int maximumResults = LegacyOverseasStockSearchService.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 : IOverseasStockSearchService
|
|
{
|
|
private readonly Dictionary<string, TaskCompletionSource<WorldStockSearchResult>> _pending =
|
|
new(StringComparer.Ordinal);
|
|
|
|
public Task<WorldStockSearchResult> SearchAsync(
|
|
string query,
|
|
int maximumResults = LegacyOverseasStockSearchService.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);
|
|
}
|
|
}
|