feat: advance legacy UI behavior parity

This commit is contained in:
2026-07-18 10:18:29 +09:00
parent d7ec571343
commit 1302b1b92f
71 changed files with 11244 additions and 1153 deletions

View File

@@ -9,7 +9,7 @@ public sealed class LegacyOverseasSelectionWorkflowTests
new(2026, 7, 15, 9, 30, 0, TimeSpan.FromHours(9));
[Fact]
public void InitialSnapshotIsTypedImmutableAndContainsClosedCatalogs()
public void InitialSnapshotIsTypedImmutableAndKeepsFarPointFirstFixedRowActive()
{
var workflow = CreateWorkflow();
@@ -18,7 +18,7 @@ public sealed class LegacyOverseasSelectionWorkflowTests
Assert.Equal(0, snapshot.Revision);
Assert.Equal(10, snapshot.FixedIndexTargets.Count);
Assert.Equal(12, snapshot.Actions.Count);
Assert.Null(snapshot.SelectedFixedIndex);
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);
@@ -68,6 +68,8 @@ public sealed class LegacyOverseasSelectionWorkflowTests
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);
@@ -81,6 +83,28 @@ public sealed class LegacyOverseasSelectionWorkflowTests
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()
{
@@ -108,8 +132,8 @@ public sealed class LegacyOverseasSelectionWorkflowTests
await workflow.SearchIndustriesAsync("next");
await workflow.SearchStocksAsync("next");
Assert.Null(workflow.Current.Industry.Selected);
Assert.Null(workflow.Current.Stock.Selected);
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(
@@ -145,7 +169,7 @@ public sealed class LegacyOverseasSelectionWorkflowTests
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("독일 DAX 30", draft.Title);
Assert.Equal("chart", draft.Category);
Assert.Equal("overseas", draft.Market);
Assert.Equal(LegacyOverseasActionSource.FixedIndex, draft.Source);
@@ -160,6 +184,10 @@ public sealed class LegacyOverseasSelectionWorkflowTests
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);
}
}
@@ -189,6 +217,10 @@ public sealed class LegacyOverseasSelectionWorkflowTests
"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]
@@ -213,6 +245,10 @@ public sealed class LegacyOverseasSelectionWorkflowTests
"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)
{
@@ -232,6 +268,11 @@ public sealed class LegacyOverseasSelectionWorkflowTests
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);
}
}
@@ -261,8 +302,9 @@ public sealed class LegacyOverseasSelectionWorkflowTests
{
var workflow = CreateWorkflow();
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("index-candle-5d"));
Assert.Equal(
"DJI@DJI",
workflow.MaterializePlaylistDraft("index-candle-5d").Selection.DataCode.Split('|')[0]);
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("industry-current"));
Assert.Throws<LegacyOverseasWorkflowException>(() =>
@@ -280,6 +322,38 @@ public sealed class LegacyOverseasSelectionWorkflowTests
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()
{
@@ -361,7 +435,7 @@ public sealed class LegacyOverseasSelectionWorkflowTests
{
var invalid = StockResult(
"query",
new WorldStockSearchItem("London", "LNS@BAD", "GB"));
new WorldStockSearchItem("London", "LNS@BAD", "gB"));
var workflow = new LegacyOverseasSelectionWorkflow(
new RecordingIndustryService([]),
new QueueStockService(invalid));
@@ -465,13 +539,13 @@ public sealed class LegacyOverseasSelectionWorkflowTests
}
private sealed class RecordingStockService(
IReadOnlyList<WorldStockSearchItem> items) : IWorldStockSearchService
IReadOnlyList<WorldStockSearchItem> items) : IOverseasStockSearchService
{
public List<(string Query, int MaximumResults)> Calls { get; } = [];
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -500,13 +574,13 @@ public sealed class LegacyOverseasSelectionWorkflowTests
}
private sealed class QueueStockService(
params WorldStockSearchResult[] results) : IWorldStockSearchService
params WorldStockSearchResult[] results) : IOverseasStockSearchService
{
private readonly Queue<WorldStockSearchResult> _results = new(results);
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -534,14 +608,14 @@ public sealed class LegacyOverseasSelectionWorkflowTests
_pending[query].SetResult(result);
}
private sealed class ControlledStockService : IWorldStockSearchService
private sealed class ControlledStockService : IOverseasStockSearchService
{
private readonly Dictionary<string, TaskCompletionSource<WorldStockSearchResult>> _pending =
new(StringComparer.Ordinal);
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var source = new TaskCompletionSource<WorldStockSearchResult>(