Files
MBN_STOCK_WEBVIEW/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/Comparison/LegacyComparisonWorkflowTests.cs

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);
}
}
}