888 lines
36 KiB
C#
888 lines
36 KiB
C#
using System.Text;
|
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
|
using MMoneyCoderSharp.Data;
|
|
|
|
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
|
|
|
public sealed class LegacyComparisonWorkflowTests
|
|
{
|
|
private static readonly LegacyComparisonTarget Samsung =
|
|
LegacyComparisonTarget.CreateKrxStock(
|
|
LegacyComparisonDomesticMarket.Kospi,
|
|
"삼성전자",
|
|
"005930");
|
|
|
|
private static readonly LegacyComparisonTarget SkHynix =
|
|
LegacyComparisonTarget.CreateKrxStock(
|
|
LegacyComparisonDomesticMarket.Kospi,
|
|
"SK하이닉스",
|
|
"000660");
|
|
|
|
private static readonly LegacyComparisonTarget NxtSamsung =
|
|
LegacyComparisonTarget.CreateNxtStock(
|
|
LegacyComparisonDomesticMarket.Kospi,
|
|
"삼성전자",
|
|
"005930");
|
|
|
|
private static readonly LegacyComparisonTarget Apple =
|
|
LegacyComparisonTarget.CreateWorldStock("APPLE", "AAPL", "US");
|
|
|
|
[Fact]
|
|
public void InitialSnapshot_ClosesFixedTargetsAndRuntimeActions()
|
|
{
|
|
var controller = CreateController();
|
|
|
|
var snapshot = controller.Current;
|
|
|
|
Assert.Equal(24, snapshot.FixedTargets.Count);
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"kospi", "kosdaq", "kospi200", "futures", "krx100",
|
|
"won-dollar", "won-yen", "won-yuan", "won-euro", "dow",
|
|
"nasdaq", "sp500", "germany-dax", "united-kingdom-ftse",
|
|
"france-cac", "nikkei", "hang-seng", "taiwan-weighted",
|
|
"singapore-straits-times", "thailand-set",
|
|
"philippines-composite", "malaysia-klse",
|
|
"indonesia-composite", "shanghai-composite"
|
|
},
|
|
snapshot.FixedTargets.Select(target => target.SelectionId));
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"two-column-expected",
|
|
"two-column-current",
|
|
"comparison-candle",
|
|
"comparison-line",
|
|
"return-5d",
|
|
"return-1m",
|
|
"return-3m",
|
|
"return-6m",
|
|
"return-12m"
|
|
},
|
|
snapshot.Actions.Select(action => action.ActionId));
|
|
Assert.All(snapshot.Actions, action => Assert.False(action.IsAvailable));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchesAndSelection_ReproduceUc3RotateThenSwapRule()
|
|
{
|
|
var data = new FakeDataService
|
|
{
|
|
DomesticResults = [Samsung, NxtSamsung],
|
|
WorldResults = [Apple]
|
|
};
|
|
var controller = CreateController(data);
|
|
|
|
var domestic = await controller.SearchDomesticAsync(" 삼성 ");
|
|
|
|
Assert.Equal("삼성", data.DomesticQuery);
|
|
Assert.Equal(LegacyStockSearchService.MaximumResults, data.DomesticMaximumResults);
|
|
Assert.Equal(
|
|
new[] { "삼성전자", "삼성전자(NXT)" },
|
|
domestic.DomesticSearch.Results.Select(result => result.DisplayName));
|
|
|
|
var selectedDomestic = controller.SelectDomesticResult(
|
|
domestic.DomesticSearch.Results[0].SelectionId);
|
|
Assert.Equal(
|
|
domestic.DomesticSearch.Results[0].SelectionId,
|
|
selectedDomestic.DomesticSearch.SelectedResultId);
|
|
Assert.Null(selectedDomestic.FirstTarget);
|
|
Assert.Null(selectedDomestic.SecondTarget);
|
|
|
|
var first = controller.ChooseDomesticResult(
|
|
domestic.DomesticSearch.Results[0].SelectionId);
|
|
Assert.Equal("삼성전자", first.FirstTarget?.DisplayName);
|
|
Assert.Null(first.SecondTarget);
|
|
|
|
var second = controller.ChooseDomesticResult(
|
|
domestic.DomesticSearch.Results[1].SelectionId);
|
|
Assert.Equal("삼성전자(NXT)", second.FirstTarget?.DisplayName);
|
|
Assert.Equal("삼성전자", second.SecondTarget?.DisplayName);
|
|
|
|
var world = await controller.SearchWorldAsync(string.Empty);
|
|
Assert.Equal(string.Empty, data.WorldQuery);
|
|
Assert.Equal(LegacyWorldStockSearchService.MaximumResults, data.WorldMaximumResults);
|
|
var selectedWorld = controller.SelectWorldResult(
|
|
world.WorldSearch.Results[0].SelectionId);
|
|
Assert.Equal(
|
|
world.WorldSearch.Results[0].SelectionId,
|
|
selectedWorld.WorldSearch.SelectedResultId);
|
|
Assert.Equal(second.FirstTarget?.DisplayName, selectedWorld.FirstTarget?.DisplayName);
|
|
var third = controller.ChooseWorldResult(world.WorldSearch.Results[0].SelectionId);
|
|
Assert.Equal("APPLE", third.FirstTarget?.DisplayName);
|
|
Assert.Equal("삼성전자(NXT)", third.SecondTarget?.DisplayName);
|
|
|
|
var selectedFixed = controller.SelectFixedTarget("kospi");
|
|
Assert.Equal("kospi", selectedFixed.SelectedFixedTargetId);
|
|
Assert.Equal(third.FirstTarget?.DisplayName, selectedFixed.FirstTarget?.DisplayName);
|
|
var fixedTarget = controller.ChooseFixedTarget("kospi");
|
|
Assert.Equal("코스피 지수", fixedTarget.FirstTarget?.DisplayName);
|
|
Assert.Equal("APPLE", fixedTarget.SecondTarget?.DisplayName);
|
|
|
|
var swapped = controller.SwapDraftTargets();
|
|
Assert.Equal("APPLE", swapped.FirstTarget?.DisplayName);
|
|
Assert.Equal("코스피 지수", swapped.SecondTarget?.DisplayName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Search_projects_row_level_isolation_without_rejecting_safe_results()
|
|
{
|
|
var data = new FakeDataService
|
|
{
|
|
DomesticResults = [Samsung],
|
|
DomesticIsTruncated = true,
|
|
DomesticIsolatedInvalidRowCount = 2,
|
|
WorldResults = [Apple],
|
|
WorldIsTruncated = true,
|
|
WorldIsolatedInvalidRowCount = 16
|
|
};
|
|
var controller = CreateController(data);
|
|
|
|
var domestic = await controller.SearchDomesticAsync(string.Empty);
|
|
var world = await controller.SearchWorldAsync(string.Empty);
|
|
|
|
Assert.Single(domestic.DomesticSearch.Results);
|
|
Assert.True(domestic.DomesticSearch.IsTruncated);
|
|
Assert.Equal(2, domestic.DomesticSearch.IsolatedInvalidRowCount);
|
|
Assert.Single(world.WorldSearch.Results);
|
|
Assert.True(world.WorldSearch.IsTruncated);
|
|
Assert.Equal(16, world.WorldSearch.IsolatedInvalidRowCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Comma_world_name_stays_visible_but_native_selection_and_playout_are_blocked()
|
|
{
|
|
var deferred = LegacyComparisonTarget.CreateWorldStock(
|
|
"ACME, INC",
|
|
"ACME",
|
|
"US");
|
|
var data = new FakeDataService
|
|
{
|
|
WorldResults = [Apple, deferred],
|
|
WorldDeferredUnsafeRowCount = 1
|
|
};
|
|
var controller = CreateController(data);
|
|
|
|
var searched = await controller.SearchWorldAsync(string.Empty);
|
|
var row = Assert.Single(searched.WorldSearch.Results, item => !item.CanSelect);
|
|
Assert.Equal(2, searched.WorldSearch.Results.Count);
|
|
Assert.Equal(1, searched.WorldSearch.DeferredUnsafeRowCount);
|
|
Assert.Contains("쉼표", row.UnavailableReason, StringComparison.Ordinal);
|
|
|
|
var selected = controller.SelectWorldResult(row.SelectionId);
|
|
var chosen = controller.ChooseWorldResult(row.SelectionId);
|
|
|
|
Assert.Null(selected.WorldSearch.SelectedResultId);
|
|
Assert.Null(chosen.FirstTarget);
|
|
Assert.Equal(LegacyOperatorStatusKind.Warning, chosen.StatusKind);
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
LegacyComparisonWorkflowCatalog.Materialize(
|
|
"two-column-current",
|
|
deferred,
|
|
Apple,
|
|
"comparison-draft-safe-id"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Search_RejectsDuplicateOrWrongUniverseResults()
|
|
{
|
|
var duplicateData = new FakeDataService
|
|
{
|
|
DomesticResults = [Samsung, Samsung]
|
|
};
|
|
var duplicateController = CreateController(duplicateData);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => duplicateController.SearchDomesticAsync("삼성"));
|
|
Assert.Equal(
|
|
LegacyComparisonSearchStatus.Error,
|
|
duplicateController.Current.DomesticSearch.Status);
|
|
Assert.Empty(duplicateController.Current.DomesticSearch.Results);
|
|
|
|
var wrongUniverse = new FakeDataService
|
|
{
|
|
WorldResults = [Samsung]
|
|
};
|
|
var wrongController = CreateController(wrongUniverse);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => wrongController.SearchWorldAsync("삼성"));
|
|
Assert.Equal(
|
|
LegacyComparisonSearchStatus.Error,
|
|
wrongController.Current.WorldSearch.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadSavedPairs_ParsesStrictCp949AndDisplaysResolvedRows()
|
|
{
|
|
var data = new FakeDataService();
|
|
data.Resolve(Samsung, "삼성전자", "코스피");
|
|
data.Resolve(SkHynix, "SK하이닉스", "코스피");
|
|
data.Resolve(Apple, "APPLE", string.Empty);
|
|
var store = new FakePairStore
|
|
{
|
|
Bytes = EncodeCp949(
|
|
("삼성전자", "코스피", "SK하이닉스", "코스피"),
|
|
("코스피 지수", string.Empty, "APPLE", string.Empty))
|
|
};
|
|
var controller = CreateController(data, store);
|
|
|
|
var snapshot = await controller.LoadSavedPairsAsync();
|
|
|
|
Assert.False(snapshot.IsBusy);
|
|
Assert.Equal(2, snapshot.SavedPairs.Count);
|
|
Assert.Equal("삼성전자 ↔ SK하이닉스", snapshot.SavedPairs[0].DisplayLabel);
|
|
Assert.Equal("코스피 지수 ↔ APPLE", snapshot.SavedPairs[1].DisplayLabel);
|
|
Assert.True(snapshot.SavedPairs[0].IsSelected);
|
|
Assert.Equal("comparison-pair-00000001", snapshot.SelectedPairId);
|
|
Assert.Empty(store.Writes);
|
|
Assert.Equal(3, data.ResolveRequests.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadSavedPairs_RejectsNonCp949NineFieldShapeWithoutChangingRows()
|
|
{
|
|
var store = new FakePairStore
|
|
{
|
|
Bytes = Encoding.UTF8.GetBytes("1^삼성전자,SK하이닉스^코스피^코스피^\n")
|
|
};
|
|
var controller = CreateController(pairStore: store);
|
|
|
|
await Assert.ThrowsAnyAsync<Exception>(() => controller.LoadSavedPairsAsync());
|
|
|
|
Assert.Empty(controller.Current.SavedPairs);
|
|
Assert.False(controller.Current.IsBusy);
|
|
Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddDraftPair_WritesOnlyThroughTypedStoreBoundary()
|
|
{
|
|
var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] };
|
|
var store = new FakePairStore();
|
|
var controller = CreateController(data, store);
|
|
var search = await controller.SearchDomesticAsync(string.Empty);
|
|
controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId);
|
|
controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId);
|
|
|
|
var snapshot = await controller.AddDraftPairAsync();
|
|
|
|
var write = Assert.Single(store.Writes);
|
|
var row = Assert.Single(write);
|
|
Assert.Equal(
|
|
new LegacyComparisonPairWriteRow(
|
|
1,
|
|
"SK하이닉스",
|
|
"코스피",
|
|
"삼성전자",
|
|
"코스피"),
|
|
row);
|
|
Assert.Equal("SK하이닉스 ↔ 삼성전자", Assert.Single(snapshot.SavedPairs).DisplayLabel);
|
|
Assert.False(snapshot.IsBusy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Uc3_AllowsExactDuplicatePairsAndMovesOrDeletesOnlyTheSelectedStableRow()
|
|
{
|
|
var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] };
|
|
var store = new FakePairStore();
|
|
var controller = CreateController(data, store);
|
|
var search = await controller.SearchDomesticAsync(string.Empty);
|
|
controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId);
|
|
controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId);
|
|
|
|
var firstAdd = await controller.AddDraftPairAsync();
|
|
var firstRowId = Assert.Single(firstAdd.SavedPairs).RowId;
|
|
var secondAdd = await controller.AddDraftPairAsync();
|
|
var secondRowId = secondAdd.SavedPairs[1].RowId;
|
|
|
|
Assert.NotEqual(firstRowId, secondRowId);
|
|
Assert.Equal(
|
|
new[] { firstRowId, secondRowId },
|
|
secondAdd.SavedPairs.Select(pair => pair.RowId));
|
|
Assert.All(
|
|
secondAdd.SavedPairs,
|
|
pair => Assert.Equal("SK하이닉스 ↔ 삼성전자", pair.DisplayLabel));
|
|
Assert.Collection(
|
|
store.Writes[^1],
|
|
row => Assert.Equal(
|
|
new LegacyComparisonPairWriteRow(
|
|
1,
|
|
"SK하이닉스",
|
|
"코스피",
|
|
"삼성전자",
|
|
"코스피"),
|
|
row),
|
|
row => Assert.Equal(
|
|
new LegacyComparisonPairWriteRow(
|
|
2,
|
|
"SK하이닉스",
|
|
"코스피",
|
|
"삼성전자",
|
|
"코스피"),
|
|
row));
|
|
|
|
controller.SelectSavedPair(secondRowId);
|
|
var moved = await controller.MoveSelectedPairAsync(
|
|
LegacyComparisonPairMoveDirection.Up);
|
|
|
|
Assert.Equal(
|
|
new[] { secondRowId, firstRowId },
|
|
moved.SavedPairs.Select(pair => pair.RowId));
|
|
Assert.Equal(secondRowId, moved.SelectedPairId);
|
|
Assert.True(moved.SavedPairs[0].IsSelected);
|
|
Assert.False(moved.SavedPairs[1].IsSelected);
|
|
|
|
var deleted = await controller.DeleteSelectedPairAsync();
|
|
|
|
var remaining = Assert.Single(deleted.SavedPairs);
|
|
Assert.Equal(firstRowId, remaining.RowId);
|
|
Assert.True(remaining.IsSelected);
|
|
Assert.Equal(firstRowId, deleted.SelectedPairId);
|
|
Assert.Single(store.Writes[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Uc3_DuplicatePairSaveRoundTripPreservesPhysicalRowOrder()
|
|
{
|
|
var data = new FakeDataService();
|
|
data.Resolve(Samsung, "삼성전자", "코스피");
|
|
data.Resolve(SkHynix, "SK하이닉스", "코스피");
|
|
data.Resolve(Apple, "APPLE", string.Empty);
|
|
var store = new FakePairStore
|
|
{
|
|
Bytes = EncodeCp949(
|
|
("삼성전자", "코스피", "SK하이닉스", "코스피"),
|
|
("삼성전자", "코스피", "SK하이닉스", "코스피"),
|
|
("코스피 지수", string.Empty, "APPLE", string.Empty))
|
|
};
|
|
var controller = CreateController(data, store);
|
|
var loaded = await controller.LoadSavedPairsAsync();
|
|
var thirdRowId = loaded.SavedPairs[2].RowId;
|
|
controller.SelectSavedPair(thirdRowId);
|
|
|
|
var moved = await controller.MoveSelectedPairAsync(
|
|
LegacyComparisonPairMoveDirection.Up);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"삼성전자 ↔ SK하이닉스",
|
|
"코스피 지수 ↔ APPLE",
|
|
"삼성전자 ↔ SK하이닉스"
|
|
},
|
|
moved.SavedPairs.Select(pair => pair.DisplayLabel));
|
|
Assert.Equal(new[] { 1, 2, 3 }, store.Writes[^1].Select(row => row.RowNumber));
|
|
|
|
var reloadedStore = new FakePairStore
|
|
{
|
|
Bytes = EncodeCp949(store.Writes[^1])
|
|
};
|
|
var reloaded = await CreateController(data, reloadedStore).LoadSavedPairsAsync();
|
|
|
|
Assert.Equal(
|
|
moved.SavedPairs.Select(pair => pair.DisplayLabel),
|
|
reloaded.SavedPairs.Select(pair => pair.DisplayLabel));
|
|
Assert.Equal(3, reloaded.SavedPairs.Select(pair => pair.RowId).Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SavedPairMoveAndDelete_PreserveActiveRowAndPersistPhysicalOrder()
|
|
{
|
|
var data = new FakeDataService();
|
|
data.Resolve(Samsung, "삼성전자", "코스피");
|
|
data.Resolve(SkHynix, "SK하이닉스", "코스피");
|
|
data.Resolve(NxtSamsung, "삼성전자(NXT)", "코스피_NXT");
|
|
var store = new FakePairStore
|
|
{
|
|
Bytes = EncodeCp949(
|
|
("삼성전자", "코스피", "SK하이닉스", "코스피"),
|
|
("삼성전자(NXT)", "코스피_NXT", "삼성전자", "코스피"))
|
|
};
|
|
var controller = CreateController(data, store);
|
|
var loaded = await controller.LoadSavedPairsAsync();
|
|
controller.SelectSavedPair(loaded.SavedPairs[1].RowId);
|
|
|
|
var moved = await controller.MoveSelectedPairAsync(
|
|
LegacyComparisonPairMoveDirection.Up);
|
|
|
|
Assert.Equal(
|
|
new[] { "삼성전자(NXT) ↔ 삼성전자", "삼성전자 ↔ SK하이닉스" },
|
|
moved.SavedPairs.Select(pair => pair.DisplayLabel));
|
|
Assert.True(moved.SavedPairs[0].IsSelected);
|
|
Assert.Equal("삼성전자(NXT)", store.Writes[^1][0].FirstName);
|
|
|
|
var writesAtBoundary = store.Writes.Count;
|
|
await controller.MoveSelectedPairAsync(LegacyComparisonPairMoveDirection.Up);
|
|
Assert.Equal(writesAtBoundary, store.Writes.Count);
|
|
|
|
var deleted = await controller.DeleteSelectedPairAsync();
|
|
Assert.Equal("삼성전자 ↔ SK하이닉스", Assert.Single(deleted.SavedPairs).DisplayLabel);
|
|
Assert.True(deleted.SavedPairs[0].IsSelected);
|
|
|
|
var empty = await controller.DeleteAllPairsAsync();
|
|
Assert.Empty(empty.SavedPairs);
|
|
Assert.Null(empty.SelectedPairId);
|
|
Assert.Empty(store.Writes[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FailedWrite_RollsBackAuthoritativePairState()
|
|
{
|
|
var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] };
|
|
var store = new FakePairStore { ThrowOnWrite = true };
|
|
var controller = CreateController(data, store);
|
|
var search = await controller.SearchDomesticAsync("삼성");
|
|
controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId);
|
|
controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId);
|
|
|
|
await Assert.ThrowsAsync<IOException>(() => controller.AddDraftPairAsync());
|
|
|
|
Assert.Empty(controller.Current.SavedPairs);
|
|
Assert.False(controller.Current.IsBusy);
|
|
Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MaterializeSelectedAction_MapsAllNineClosedActionsFromActionIdOnly()
|
|
{
|
|
var controller = await CreateLoadedKrxPairControllerAsync();
|
|
|
|
Assert.Equal(9, controller.Current.Actions.Count);
|
|
Assert.All(controller.Current.Actions, action => Assert.True(action.IsAvailable));
|
|
var method = typeof(LegacyComparisonWorkflowController).GetMethod(
|
|
nameof(LegacyComparisonWorkflowController.MaterializeSelectedAction));
|
|
Assert.NotNull(method);
|
|
Assert.Collection(
|
|
method.GetParameters(),
|
|
parameter => Assert.Equal("actionId", parameter.Name));
|
|
|
|
var drafts = controller.Current.Actions.ToDictionary(
|
|
action => action.ActionId,
|
|
action => controller.MaterializeSelectedAction(action.ActionId),
|
|
StringComparer.Ordinal);
|
|
|
|
Assert.Equal(9, drafts.Count);
|
|
Assert.Equal(("s8018", "8032", "2열판", "EXPECTED", ""), Fields(drafts["two-column-expected"]));
|
|
Assert.Equal(("s8018", "8032", "2열판", "CURRENT", ""), Fields(drafts["two-column-current"]));
|
|
Assert.Equal(("s5026", "5026", "COMPARISON_CANDLE", "FiveDays", "KOSPI:005930|KOSPI:000660"), Fields(drafts["comparison-candle"]));
|
|
Assert.Equal(("s5087", "5087", "COMPARISON_LINE", "FiveDays", "KOSPI:005930|KOSPI:000660"), Fields(drafts["comparison-line"]));
|
|
Assert.Equal("FiveDays", drafts["return-5d"].Selection.Subtype);
|
|
Assert.Equal("OneMonth", drafts["return-1m"].Selection.Subtype);
|
|
Assert.Equal("ThreeMonths", drafts["return-3m"].Selection.Subtype);
|
|
Assert.Equal("SixMonths", drafts["return-6m"].Selection.Subtype);
|
|
Assert.Equal("TwelveMonths", drafts["return-12m"].Selection.Subtype);
|
|
Assert.All(drafts.Where(pair => pair.Key.StartsWith("return-", StringComparison.Ordinal)), pair =>
|
|
{
|
|
Assert.Equal("s5029", pair.Value.BuilderKey);
|
|
Assert.Equal("5029", pair.Value.CutCode);
|
|
Assert.Equal("RETURN_COMPARISON", pair.Value.Selection.GraphicType);
|
|
});
|
|
Assert.All(drafts.Values, draft =>
|
|
{
|
|
Assert.Equal("삼성전자,SK하이닉스", draft.Selection.Subject);
|
|
Assert.Equal("legacy-comparison-workflow", draft.Source);
|
|
Assert.Equal(6, draft.FadeDuration);
|
|
Assert.Equal(new[] { draft.CutCode }, draft.Aliases);
|
|
Assert.Equal(draft.DraftId, draft.ToLegacyPlaylistEntry().EntryId);
|
|
});
|
|
Assert.Throws<ArgumentOutOfRangeException>(
|
|
() => controller.MaterializeSelectedAction("caller-builder-override"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FuturesExpectedAction_UsesClosedCurrentS5032CompatibilityBranch()
|
|
{
|
|
var store = new FakePairStore
|
|
{
|
|
Bytes = EncodeCp949(
|
|
("선물 지수", string.Empty, "코스피 지수", string.Empty))
|
|
};
|
|
var controller = CreateController(pairStore: store);
|
|
await controller.LoadSavedPairsAsync();
|
|
|
|
var expected = controller.MaterializeSelectedAction("two-column-expected");
|
|
|
|
Assert.Equal("s5032", expected.BuilderKey);
|
|
Assert.Equal("5032", expected.CutCode);
|
|
Assert.Equal("EXPECTED", expected.Selection.Subtype);
|
|
Assert.Equal("CURRENT", expected.EffectiveMode);
|
|
Assert.NotNull(expected.Warning);
|
|
Assert.False(controller.Current.Actions.Single(
|
|
action => action.ActionId == "comparison-candle").IsAvailable);
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => controller.MaterializeSelectedAction("comparison-candle"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Snapshots_RemainImmutableAfterLaterStateChanges()
|
|
{
|
|
var data = new FakeDataService { DomesticResults = [Samsung] };
|
|
var controller = CreateController(data);
|
|
var before = controller.Current;
|
|
|
|
var after = await controller.SearchDomesticAsync("삼성");
|
|
|
|
Assert.Empty(before.DomesticSearch.Results);
|
|
Assert.Single(after.DomesticSearch.Results);
|
|
var fixedCollection = Assert.IsAssignableFrom<ICollection<LegacyComparisonTargetView>>(
|
|
before.FixedTargets);
|
|
Assert.Throws<NotSupportedException>(() => fixedCollection.Add(before.FixedTargets[0]));
|
|
var actionCollection = Assert.IsAssignableFrom<ICollection<LegacyComparisonActionView>>(
|
|
before.Actions);
|
|
Assert.Throws<NotSupportedException>(() => actionCollection.Clear());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_preserves_current_order_appends_unseen_pairs_and_is_idempotent()
|
|
{
|
|
var data = new FakeDataService();
|
|
var samsungStored = LegacyComparisonWorkflowCatalog.ToStoredReference(Samsung);
|
|
var skHynixStored = LegacyComparisonWorkflowCatalog.ToStoredReference(SkHynix);
|
|
data.Resolve(Samsung, samsungStored.Name, samsungStored.Market);
|
|
data.Resolve(SkHynix, skHynixStored.Name, skHynixStored.Market);
|
|
var store = new FakePairStore
|
|
{
|
|
Bytes = EncodeCp949(
|
|
(samsungStored.Name, samsungStored.Market,
|
|
skHynixStored.Name, skHynixStored.Market))
|
|
};
|
|
var importer = new FakeImporter(CreateImportResult(
|
|
ImportKrx("kospi", Samsung.StockName!, Samsung.StockCode!),
|
|
ImportKrx("kospi", SkHynix.StockName!, SkHynix.StockCode!),
|
|
ImportWorld("APPLE", "AAPL", "US"),
|
|
ImportMarket("Kospi"),
|
|
ImportWorld("APPLE", "AAPL", "US"),
|
|
ImportMarket("Kospi")));
|
|
var controller = CreateController(data, store, importer);
|
|
await controller.LoadSavedPairsAsync();
|
|
var originalRowId = controller.Current.SavedPairs[0].RowId;
|
|
|
|
var first = await controller.ImportLegacyPairsAsync();
|
|
|
|
Assert.Equal(2, first.SavedPairs.Count);
|
|
Assert.Equal(originalRowId, first.SavedPairs[0].RowId);
|
|
Assert.Equal("APPLE", first.SavedPairs[1].First.DisplayName);
|
|
Assert.Equal("Kospi", first.SavedPairs[1].Second.Metadata);
|
|
var receipt = Assert.IsType<LegacyComparisonImportReceipt>(first.LastImport);
|
|
Assert.Equal(3, receipt.SourceRowCount);
|
|
Assert.Equal(1, receipt.AddedPairCount);
|
|
Assert.Equal(2, receipt.SkippedDuplicateCount);
|
|
Assert.Equal(2, receipt.TotalPairCount);
|
|
var write = Assert.Single(store.Writes);
|
|
Assert.Equal(2, write.Count);
|
|
Assert.Equal(Samsung.StockName, write[0].FirstName);
|
|
Assert.Equal("APPLE", write[1].FirstName);
|
|
|
|
var second = await controller.ImportLegacyPairsAsync();
|
|
|
|
Assert.Equal(2, second.SavedPairs.Count);
|
|
Assert.Single(store.Writes);
|
|
Assert.Equal(0, second.LastImport?.AddedPairCount);
|
|
Assert.Equal(2, importer.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_validates_every_resolved_target_before_writing()
|
|
{
|
|
var store = new FakePairStore();
|
|
var invalid = new LegacyComparisonImportTarget(
|
|
LegacyComparisonImportTargetKind.KrxStock,
|
|
Market: "kospi",
|
|
StockName: Samsung.StockName,
|
|
StockCode: "005930",
|
|
Symbol: "forged-extra-field");
|
|
var importer = new FakeImporter(CreateImportResult(
|
|
ImportWorld("APPLE", "AAPL", "US"),
|
|
ImportMarket("Kospi"),
|
|
invalid,
|
|
ImportMarket("Kosdaq")));
|
|
var controller = CreateController(pairStore: store, importer: importer);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => controller.ImportLegacyPairsAsync());
|
|
|
|
Assert.Empty(store.Writes);
|
|
Assert.Empty(controller.Current.SavedPairs);
|
|
Assert.Null(controller.Current.LastImport);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_store_failure_rolls_back_controller_state()
|
|
{
|
|
var data = new FakeDataService();
|
|
var samsungStored = LegacyComparisonWorkflowCatalog.ToStoredReference(Samsung);
|
|
var skHynixStored = LegacyComparisonWorkflowCatalog.ToStoredReference(SkHynix);
|
|
data.Resolve(Samsung, samsungStored.Name, samsungStored.Market);
|
|
data.Resolve(SkHynix, skHynixStored.Name, skHynixStored.Market);
|
|
var store = new FakePairStore
|
|
{
|
|
Bytes = EncodeCp949(
|
|
(samsungStored.Name, samsungStored.Market,
|
|
skHynixStored.Name, skHynixStored.Market)),
|
|
ThrowOnWrite = true
|
|
};
|
|
var importer = new FakeImporter(CreateImportResult(
|
|
ImportWorld("APPLE", "AAPL", "US"),
|
|
ImportMarket("Kospi")));
|
|
var controller = CreateController(data, store, importer);
|
|
await controller.LoadSavedPairsAsync();
|
|
var before = controller.Current.SavedPairs.Select(row => row.RowId).ToArray();
|
|
|
|
await Assert.ThrowsAsync<IOException>(() => controller.ImportLegacyPairsAsync());
|
|
|
|
Assert.Equal(before, controller.Current.SavedPairs.Select(row => row.RowId));
|
|
Assert.Null(controller.Current.LastImport);
|
|
Assert.Empty(store.Writes);
|
|
}
|
|
|
|
private static LegacyComparisonWorkflowController CreateController(
|
|
FakeDataService? dataService = null,
|
|
FakePairStore? pairStore = null,
|
|
ILegacyComparisonPairImportService? importer = null) =>
|
|
new(
|
|
dataService ?? new FakeDataService(),
|
|
pairStore ?? new FakePairStore(),
|
|
pairImporter: importer);
|
|
|
|
private static LegacyComparisonImportResult CreateImportResult(
|
|
params LegacyComparisonImportTarget[] targets)
|
|
{
|
|
Assert.True(targets.Length % 2 == 0);
|
|
var pairs = targets.Chunk(2).Select((pair, index) =>
|
|
new LegacyComparisonImportPair(index + 1, pair[0], pair[1])).ToArray();
|
|
return new LegacyComparisonImportResult(
|
|
new string('A', 64),
|
|
pairs.Length,
|
|
DateTimeOffset.Parse("2026-07-22T12:00:00+09:00"),
|
|
Array.AsReadOnly(pairs));
|
|
}
|
|
|
|
private static LegacyComparisonImportTarget ImportKrx(
|
|
string market,
|
|
string name,
|
|
string code) =>
|
|
new(
|
|
LegacyComparisonImportTargetKind.KrxStock,
|
|
Market: market,
|
|
StockName: name,
|
|
StockCode: code);
|
|
|
|
private static LegacyComparisonImportTarget ImportWorld(
|
|
string name,
|
|
string symbol,
|
|
string nation) =>
|
|
new(
|
|
LegacyComparisonImportTargetKind.WorldStock,
|
|
InputName: name,
|
|
Symbol: symbol,
|
|
Nation: nation);
|
|
|
|
private static LegacyComparisonImportTarget ImportMarket(string target) =>
|
|
new(LegacyComparisonImportTargetKind.MarketTarget, MarketTarget: target);
|
|
|
|
private static async Task<LegacyComparisonWorkflowController>
|
|
CreateLoadedKrxPairControllerAsync()
|
|
{
|
|
var data = new FakeDataService();
|
|
data.Resolve(Samsung, "삼성전자", "코스피");
|
|
data.Resolve(SkHynix, "SK하이닉스", "코스피");
|
|
var store = new FakePairStore
|
|
{
|
|
Bytes = EncodeCp949(
|
|
("삼성전자", "코스피", "SK하이닉스", "코스피"))
|
|
};
|
|
var controller = CreateController(data, store);
|
|
await controller.LoadSavedPairsAsync();
|
|
return controller;
|
|
}
|
|
|
|
private static (
|
|
string BuilderKey,
|
|
string CutCode,
|
|
string GraphicType,
|
|
string Subtype,
|
|
string DataCode) Fields(LegacyGenericPlaylistDraft draft) =>
|
|
(
|
|
draft.BuilderKey,
|
|
draft.CutCode,
|
|
draft.Selection.GraphicType,
|
|
draft.Selection.Subtype,
|
|
draft.Selection.DataCode);
|
|
|
|
private static byte[] EncodeCp949(
|
|
params (string First, string FirstMarket, string Second, string SecondMarket)[] rows)
|
|
{
|
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
|
var encoding = Encoding.GetEncoding(
|
|
949,
|
|
EncoderFallback.ExceptionFallback,
|
|
DecoderFallback.ExceptionFallback);
|
|
var lines = rows.Select((row, index) => string.Join(
|
|
'^',
|
|
(index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
$"{row.First},{row.Second}",
|
|
row.FirstMarket,
|
|
row.SecondMarket,
|
|
string.Empty,
|
|
string.Empty,
|
|
string.Empty,
|
|
string.Empty,
|
|
string.Empty));
|
|
return encoding.GetBytes(string.Join("\r\n", lines) + "\r\n");
|
|
}
|
|
|
|
private static byte[] EncodeCp949(IReadOnlyList<LegacyComparisonPairWriteRow> rows) =>
|
|
EncodeCp949(rows.Select(row => (
|
|
row.FirstName,
|
|
row.FirstMarket,
|
|
row.SecondName,
|
|
row.SecondMarket)).ToArray());
|
|
|
|
private sealed class FakeDataService :
|
|
ILegacyComparisonDataService,
|
|
ILegacyComparisonDataDiagnosticsService
|
|
{
|
|
private readonly Dictionary<LegacyComparisonStoredTargetReference, LegacyComparisonTarget>
|
|
_resolved = [];
|
|
|
|
public IReadOnlyList<LegacyComparisonTarget> DomesticResults { get; init; } = [];
|
|
|
|
public IReadOnlyList<LegacyComparisonTarget> WorldResults { get; init; } = [];
|
|
|
|
public bool DomesticIsTruncated { get; init; }
|
|
|
|
public bool WorldIsTruncated { get; init; }
|
|
|
|
public int DomesticIsolatedInvalidRowCount { get; init; }
|
|
|
|
public int WorldIsolatedInvalidRowCount { get; init; }
|
|
|
|
public int DomesticDeferredUnsafeRowCount { get; init; }
|
|
|
|
public int WorldDeferredUnsafeRowCount { get; init; }
|
|
|
|
public string? DomesticQuery { get; private set; }
|
|
|
|
public string? WorldQuery { get; private set; }
|
|
|
|
public int DomesticMaximumResults { get; private set; }
|
|
|
|
public int WorldMaximumResults { get; private set; }
|
|
|
|
public List<LegacyComparisonStoredTargetReference> ResolveRequests { get; } = [];
|
|
|
|
public void Resolve(LegacyComparisonTarget target, string name, string market) =>
|
|
_resolved[new LegacyComparisonStoredTargetReference(name, market)] = target;
|
|
|
|
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
|
|
string query,
|
|
int maximumResults,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
DomesticQuery = query;
|
|
DomesticMaximumResults = maximumResults;
|
|
return Task.FromResult(DomesticResults);
|
|
}
|
|
|
|
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
|
|
string query,
|
|
int maximumResults,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
WorldQuery = query;
|
|
WorldMaximumResults = maximumResults;
|
|
return Task.FromResult(WorldResults);
|
|
}
|
|
|
|
public Task<LegacyComparisonSearchData> SearchDomesticWithDiagnosticsAsync(
|
|
string query,
|
|
int maximumResults,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
DomesticQuery = query;
|
|
DomesticMaximumResults = maximumResults;
|
|
return Task.FromResult(new LegacyComparisonSearchData(
|
|
DomesticResults,
|
|
DomesticIsTruncated,
|
|
DomesticIsolatedInvalidRowCount)
|
|
{
|
|
DeferredUnsafeRowCount = DomesticDeferredUnsafeRowCount
|
|
});
|
|
}
|
|
|
|
public Task<LegacyComparisonSearchData> SearchWorldWithDiagnosticsAsync(
|
|
string query,
|
|
int maximumResults,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
WorldQuery = query;
|
|
WorldMaximumResults = maximumResults;
|
|
return Task.FromResult(new LegacyComparisonSearchData(
|
|
WorldResults,
|
|
WorldIsTruncated,
|
|
WorldIsolatedInvalidRowCount)
|
|
{
|
|
DeferredUnsafeRowCount = WorldDeferredUnsafeRowCount
|
|
});
|
|
}
|
|
|
|
public Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
|
|
LegacyComparisonStoredTargetReference target,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
ResolveRequests.Add(target);
|
|
_resolved.TryGetValue(target, out var resolved);
|
|
return Task.FromResult(resolved);
|
|
}
|
|
}
|
|
|
|
private sealed class FakePairStore : ILegacyComparisonPairStore
|
|
{
|
|
public byte[]? Bytes { get; init; }
|
|
|
|
public bool ThrowOnWrite { get; init; }
|
|
|
|
public List<IReadOnlyList<LegacyComparisonPairWriteRow>> Writes { get; } = [];
|
|
|
|
public Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return Task.FromResult(Bytes?.ToArray());
|
|
}
|
|
|
|
public Task WriteCp949Async(
|
|
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (ThrowOnWrite)
|
|
{
|
|
throw new IOException("Synthetic comparison store failure.");
|
|
}
|
|
|
|
Writes.Add(Array.AsReadOnly(rows.ToArray()));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeImporter(LegacyComparisonImportResult result)
|
|
: ILegacyComparisonPairImportService
|
|
{
|
|
public int Calls { get; private set; }
|
|
|
|
public Task<LegacyComparisonImportResult> ImportAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
Calls++;
|
|
return Task.FromResult(result);
|
|
}
|
|
}
|
|
}
|