feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
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(200, 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(100, 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_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 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());
|
||||
}
|
||||
|
||||
private static LegacyComparisonWorkflowController CreateController(
|
||||
FakeDataService? dataService = null,
|
||||
FakePairStore? pairStore = null) =>
|
||||
new(dataService ?? new FakeDataService(), pairStore ?? new FakePairStore());
|
||||
|
||||
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 sealed class FakeDataService : ILegacyComparisonDataService
|
||||
{
|
||||
private readonly Dictionary<LegacyComparisonStoredTargetReference, LegacyComparisonTarget>
|
||||
_resolved = [];
|
||||
|
||||
public IReadOnlyList<LegacyComparisonTarget> DomesticResults { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<LegacyComparisonTarget> WorldResults { 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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyOperatorComparisonIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ComparisonTabBuildsPairAndAddsAuthoritativeSceneRow()
|
||||
{
|
||||
var data = new StaticComparisonDataService();
|
||||
var store = new MemoryComparisonPairStore();
|
||||
var controller = new LegacyOperatorController(
|
||||
new EmptyStockLookup(),
|
||||
comparisonWorkflow: new LegacyComparisonWorkflowController(data, store));
|
||||
|
||||
var selected = await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
|
||||
Assert.NotNull(selected.Comparison);
|
||||
Assert.Empty(selected.Comparison.SavedPairs);
|
||||
|
||||
var searched = await controller.SearchComparisonDomesticAsync("반도체");
|
||||
var results = searched.Comparison!.DomesticSearch.Results;
|
||||
controller.ChooseComparisonDomesticResult(results[0].SelectionId);
|
||||
controller.ChooseComparisonDomesticResult(results[1].SelectionId);
|
||||
var saved = await controller.AddComparisonPairAsync();
|
||||
Assert.Single(saved.Comparison!.SavedPairs);
|
||||
Assert.Single(store.Writes);
|
||||
|
||||
var added = controller.ActivateComparisonAction("comparison-candle");
|
||||
var row = Assert.Single(added.Playlist);
|
||||
Assert.Equal("5026", row.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal(
|
||||
"KOSPI:000660|KOSPI:005930",
|
||||
Assert.IsType<LegacySceneSelection>(row.ToPlayoutEntry().Selection).DataCode);
|
||||
Assert.Equal("비교", row.MarketText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComparisonParentDoubleClickAddsOnlyDirectChildrenInOriginalOrder()
|
||||
{
|
||||
var controller = new LegacyOperatorController(
|
||||
new EmptyStockLookup(),
|
||||
comparisonWorkflow: new LegacyComparisonWorkflowController(
|
||||
new StaticComparisonDataService(),
|
||||
new MemoryComparisonPairStore()));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
|
||||
var searched = await controller.SearchComparisonDomesticAsync("반도체");
|
||||
controller.ChooseComparisonDomesticResult(
|
||||
searched.Comparison!.DomesticSearch.Results[0].SelectionId);
|
||||
controller.ChooseComparisonDomesticResult(
|
||||
searched.Comparison.DomesticSearch.Results[1].SelectionId);
|
||||
await controller.AddComparisonPairAsync();
|
||||
|
||||
var added = controller.ActivateComparisonSection(1);
|
||||
|
||||
Assert.Collection(
|
||||
added.Playlist,
|
||||
row => Assert.Equal("5026", row.SceneAlias),
|
||||
row => Assert.Equal("5087", row.SceneAlias));
|
||||
}
|
||||
|
||||
private sealed class EmptyStockLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new LegacyStockSearchData(query, [], []));
|
||||
}
|
||||
|
||||
private sealed class StaticComparisonDataService : ILegacyComparisonDataService
|
||||
{
|
||||
private static readonly IReadOnlyList<LegacyComparisonTarget> Domestic =
|
||||
[
|
||||
LegacyComparisonTarget.CreateKrxStock(
|
||||
LegacyComparisonDomesticMarket.Kospi,
|
||||
"삼성전자",
|
||||
"005930"),
|
||||
LegacyComparisonTarget.CreateKrxStock(
|
||||
LegacyComparisonDomesticMarket.Kospi,
|
||||
"SK하이닉스",
|
||||
"000660")
|
||||
];
|
||||
|
||||
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(Domestic);
|
||||
|
||||
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult<
|
||||
IReadOnlyList<LegacyComparisonTarget>>([]);
|
||||
|
||||
public Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
|
||||
LegacyComparisonStoredTargetReference target,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult<
|
||||
LegacyComparisonTarget?>(null);
|
||||
}
|
||||
|
||||
private sealed class MemoryComparisonPairStore : ILegacyComparisonPairStore
|
||||
{
|
||||
public List<IReadOnlyList<LegacyComparisonPairWriteRow>> Writes { get; } = [];
|
||||
|
||||
public Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<byte[]?>(null);
|
||||
|
||||
public Task WriteCp949Async(
|
||||
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Writes.Add(rows.ToArray());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LegacyComparisonProductionAdapterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DataAdapterMapsDomesticNxtAndWorldIdentities()
|
||||
{
|
||||
var lookup = new StaticStockLookup();
|
||||
var world = new StaticWorldLookup();
|
||||
var service = new LegacyComparisonDataService(lookup, world);
|
||||
|
||||
var domestic = await service.SearchDomesticAsync("삼성", 10);
|
||||
Assert.Equal(LegacyComparisonTargetKind.KrxStock, domestic[0].Kind);
|
||||
Assert.Equal(LegacyComparisonTargetKind.NxtStock, domestic[1].Kind);
|
||||
Assert.Equal("삼성전자(NXT)", domestic[1].DisplayName);
|
||||
|
||||
var overseas = Assert.Single(await service.SearchWorldAsync("APPLE", 10));
|
||||
Assert.Equal("world:US:AAPL", overseas.TargetId);
|
||||
var resolved = await service.ResolveStoredTargetAsync(
|
||||
new LegacyComparisonStoredTargetReference("APPLE", string.Empty));
|
||||
Assert.Equal(overseas, resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileStoreRoundTripsStrictCp949WithAtomicTarget()
|
||||
{
|
||||
var directory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"mbn-comparison-{Guid.NewGuid():N}");
|
||||
var path = Path.Combine(directory, "종목비교.dat");
|
||||
try
|
||||
{
|
||||
var store = new LegacyComparisonPairFileStore(path);
|
||||
await store.WriteCp949Async(
|
||||
[
|
||||
new LegacyComparisonPairWriteRow(
|
||||
1,
|
||||
"삼성전자",
|
||||
"코스피",
|
||||
"SK하이닉스",
|
||||
"코스피")
|
||||
]);
|
||||
|
||||
var bytes = Assert.IsType<byte[]>(await store.ReadCp949Async());
|
||||
var parsed = Assert.Single(LegacyComparisonFileParser.Parse(bytes));
|
||||
Assert.Equal("삼성전자", parsed.First.Name);
|
||||
Assert.Empty(Directory.EnumerateFiles(directory, "*.tmp"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticStockLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new LegacyStockSearchData(
|
||||
query,
|
||||
["삼성전자", "삼성전자(NXT)"],
|
||||
[
|
||||
new LegacyStockIdentity(
|
||||
LegacyDomesticStockMarket.Kospi,
|
||||
"삼성전자",
|
||||
"005930"),
|
||||
new LegacyStockIdentity(
|
||||
LegacyDomesticStockMarket.NxtKospi,
|
||||
"삼성전자(NXT)",
|
||||
"005930")
|
||||
]));
|
||||
}
|
||||
|
||||
private sealed class StaticWorldLookup : IWorldStockSearchService
|
||||
{
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new WorldStockSearchResult(
|
||||
query.Trim(),
|
||||
DateTimeOffset.UtcNow,
|
||||
[new WorldStockSearchItem("APPLE", "AAPL", "US")],
|
||||
false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyExpertWorkflowTests
|
||||
{
|
||||
private static readonly ExpertSelectionIdentity Alpha = new("0001", "강전문");
|
||||
private static readonly ExpertSelectionIdentity Beta = new("0002", "김전문");
|
||||
|
||||
[Fact]
|
||||
public async Task SearchSelectPreview_MaterializesClosedS5074DraftFromActionIdOnly()
|
||||
{
|
||||
var service = new DelegateExpertService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha, Beta)),
|
||||
Preview = (identity, _, _) => Task.FromResult(
|
||||
PreviewResult(identity, Recommendations(6)))
|
||||
};
|
||||
var controller = new LegacyExpertWorkflowController(service);
|
||||
|
||||
var search = await controller.SearchAsync(" 전문 ");
|
||||
var preview = await controller.SelectExpertAsync(search.Search.Results[1].SelectionId);
|
||||
var draft = controller.MaterializeSelectedAction("five-row-current");
|
||||
|
||||
Assert.Equal("전문", service.SearchQuery);
|
||||
Assert.Equal(500, service.SearchMaximum);
|
||||
Assert.Equal(100, service.PreviewMaximum);
|
||||
Assert.Equal("김전문", preview.SelectedExpert?.ExpertName);
|
||||
Assert.Equal(6, preview.Preview.Items.Count);
|
||||
Assert.Equal(2, preview.Preview.PageCount);
|
||||
Assert.True(Assert.Single(preview.Actions).IsAvailable);
|
||||
Assert.Equal("s5074", draft.BuilderKey);
|
||||
Assert.Equal("5074", draft.CutCode);
|
||||
Assert.Equal(new[] { "5074" }, draft.Aliases);
|
||||
Assert.Equal("PAGED_EXPERT", draft.Selection.GroupCode);
|
||||
Assert.Equal("김전문", draft.Selection.Subject);
|
||||
Assert.Equal("INPUT_ORDER", draft.Selection.GraphicType);
|
||||
Assert.Equal("CURRENT", draft.Selection.Subtype);
|
||||
Assert.Equal("0002", draft.Selection.DataCode);
|
||||
Assert.Equal(5, draft.PageSize);
|
||||
Assert.Equal(2, draft.PageCount);
|
||||
Assert.Equal(20, draft.MaximumPageCount);
|
||||
Assert.Equal("1/2", draft.PageText);
|
||||
Assert.Equal(draft.DraftId, draft.ToLegacyPlaylistEntry().EntryId);
|
||||
Assert.Equal("legacy-expert-workflow", draft.Source);
|
||||
|
||||
var method = typeof(LegacyExpertWorkflowController).GetMethod(
|
||||
nameof(LegacyExpertWorkflowController.MaterializeSelectedAction));
|
||||
Assert.NotNull(method);
|
||||
Assert.Collection(
|
||||
method.GetParameters(),
|
||||
parameter => Assert.Equal("actionId", parameter.Name));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => controller.MaterializeSelectedAction("5074-caller-override"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, false, 1)]
|
||||
[InlineData(5, false, 1)]
|
||||
[InlineData(6, false, 2)]
|
||||
[InlineData(100, true, 20)]
|
||||
public async Task PageN_UsesFiveRowsAndNeverExceedsTwentyPages(
|
||||
int itemCount,
|
||||
bool truncated,
|
||||
int expectedPageCount)
|
||||
{
|
||||
var service = new DelegateExpertService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha)),
|
||||
Preview = (identity, _, _) => Task.FromResult(
|
||||
PreviewResult(identity, Recommendations(itemCount), truncated))
|
||||
};
|
||||
var controller = new LegacyExpertWorkflowController(service);
|
||||
var search = await controller.SearchAsync(string.Empty);
|
||||
var selected = await controller.SelectExpertAsync(
|
||||
search.Search.Results[0].SelectionId);
|
||||
|
||||
var draft = controller.MaterializeSelectedAction(
|
||||
LegacyExpertWorkflowController.ActionId);
|
||||
|
||||
Assert.Equal(expectedPageCount, selected.Preview.PageCount);
|
||||
Assert.Equal(expectedPageCount, draft.PageCount);
|
||||
Assert.InRange(draft.PageCount, 1, 20);
|
||||
Assert.Equal(truncated, draft.PreviewTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyRecommendationPreview_RemainsVisibleButCannotCreateCut()
|
||||
{
|
||||
var service = new DelegateExpertService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha)),
|
||||
Preview = (identity, _, _) => Task.FromResult(
|
||||
PreviewResult(identity, []))
|
||||
};
|
||||
var controller = new LegacyExpertWorkflowController(service);
|
||||
var search = await controller.SearchAsync(string.Empty);
|
||||
|
||||
var selected = await controller.SelectExpertAsync(
|
||||
search.Search.Results[0].SelectionId);
|
||||
|
||||
Assert.Equal(LegacyExpertRequestStatus.Ready, selected.Preview.Status);
|
||||
Assert.Empty(selected.Preview.Items);
|
||||
Assert.Equal(0, selected.Preview.PageCount);
|
||||
Assert.False(Assert.Single(selected.Actions).IsAvailable);
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => controller.MaterializeSelectedAction(
|
||||
LegacyExpertWorkflowController.ActionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LateSearchResponse_CannotReplaceNewerExpertResult()
|
||||
{
|
||||
var first = new TaskCompletionSource<ExpertSearchResult>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var second = new TaskCompletionSource<ExpertSearchResult>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var service = new DelegateExpertService
|
||||
{
|
||||
Search = (query, _, _) => query == "첫째" ? first.Task : second.Task
|
||||
};
|
||||
var controller = new LegacyExpertWorkflowController(service);
|
||||
|
||||
var firstRequest = controller.SearchAsync("첫째");
|
||||
var secondRequest = controller.SearchAsync("둘째");
|
||||
second.SetResult(SearchResult("둘째", Beta));
|
||||
var newest = await secondRequest;
|
||||
first.SetResult(SearchResult("첫째", Alpha));
|
||||
var staleCompletion = await firstRequest;
|
||||
|
||||
Assert.Equal("둘째", newest.Search.Query);
|
||||
Assert.Equal("김전문", Assert.Single(newest.Search.Results).ExpertName);
|
||||
Assert.Equal("둘째", staleCompletion.Search.Query);
|
||||
Assert.Equal("김전문", Assert.Single(staleCompletion.Search.Results).ExpertName);
|
||||
Assert.Equal("둘째", controller.Current.Search.Query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LatePreviewResponse_CannotReplaceNewerExpertSelection()
|
||||
{
|
||||
var alphaPreview = new TaskCompletionSource<ExpertPreviewResult>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var betaPreview = new TaskCompletionSource<ExpertPreviewResult>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var service = new DelegateExpertService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha, Beta)),
|
||||
Preview = (identity, _, _) => identity == Alpha
|
||||
? alphaPreview.Task
|
||||
: betaPreview.Task
|
||||
};
|
||||
var controller = new LegacyExpertWorkflowController(service);
|
||||
var search = await controller.SearchAsync(string.Empty);
|
||||
|
||||
var firstSelection = controller.SelectExpertAsync(search.Search.Results[0].SelectionId);
|
||||
var secondSelection = controller.SelectExpertAsync(search.Search.Results[1].SelectionId);
|
||||
betaPreview.SetResult(PreviewResult(Beta, Recommendations(2, "B")));
|
||||
var newest = await secondSelection;
|
||||
alphaPreview.SetResult(PreviewResult(Alpha, Recommendations(1, "A")));
|
||||
var staleCompletion = await firstSelection;
|
||||
|
||||
Assert.Equal("김전문", newest.SelectedExpert?.ExpertName);
|
||||
Assert.Equal(2, newest.Preview.Items.Count);
|
||||
Assert.Equal("김전문", staleCompletion.SelectedExpert?.ExpertName);
|
||||
Assert.Equal(2, staleCompletion.Preview.Items.Count);
|
||||
Assert.All(staleCompletion.Preview.Items, item =>
|
||||
Assert.StartsWith("B", item.StockCode, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrictExpertIdentityOrderAndPreviewIdentity_AreRevalidated()
|
||||
{
|
||||
var unsorted = new DelegateExpertService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(SearchResult(query, Beta, Alpha))
|
||||
};
|
||||
var unsortedController = new LegacyExpertWorkflowController(unsorted);
|
||||
await Assert.ThrowsAsync<ExpertSelectionDataException>(
|
||||
() => unsortedController.SearchAsync(string.Empty));
|
||||
Assert.Equal(
|
||||
LegacyExpertRequestStatus.Error,
|
||||
unsortedController.Current.Search.Status);
|
||||
|
||||
var wrongPreview = new DelegateExpertService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha)),
|
||||
Preview = (_, _, _) => Task.FromResult(
|
||||
PreviewResult(Beta, Recommendations(1)))
|
||||
};
|
||||
var wrongController = new LegacyExpertWorkflowController(wrongPreview);
|
||||
var search = await wrongController.SearchAsync(string.Empty);
|
||||
await Assert.ThrowsAsync<ExpertSelectionDataException>(
|
||||
() => wrongController.SelectExpertAsync(search.Search.Results[0].SelectionId));
|
||||
Assert.Equal(
|
||||
LegacyExpertRequestStatus.Error,
|
||||
wrongController.Current.Preview.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpertSnapshots_AreImmutableAcrossLaterQueries()
|
||||
{
|
||||
var service = new DelegateExpertService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(
|
||||
query == "A" ? SearchResult(query, Alpha) : SearchResult(query, Beta))
|
||||
};
|
||||
var controller = new LegacyExpertWorkflowController(service);
|
||||
var before = await controller.SearchAsync("A");
|
||||
|
||||
var after = await controller.SearchAsync("B");
|
||||
|
||||
Assert.Equal("강전문", Assert.Single(before.Search.Results).ExpertName);
|
||||
Assert.Equal("김전문", Assert.Single(after.Search.Results).ExpertName);
|
||||
var rows = Assert.IsAssignableFrom<ICollection<LegacyExpertResultView>>(
|
||||
before.Search.Results);
|
||||
Assert.Throws<NotSupportedException>(() => rows.Clear());
|
||||
var actions = Assert.IsAssignableFrom<ICollection<LegacyExpertActionView>>(
|
||||
before.Actions);
|
||||
Assert.Throws<NotSupportedException>(() => actions.Add(before.Actions[0]));
|
||||
}
|
||||
|
||||
private static ExpertSearchResult SearchResult(
|
||||
string query,
|
||||
params ExpertSelectionIdentity[] identities) =>
|
||||
new(
|
||||
query,
|
||||
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
|
||||
identities.Select(identity =>
|
||||
new ExpertSelectorItem(identity, DataSourceKind.Oracle)).ToArray(),
|
||||
false);
|
||||
|
||||
private static ExpertPreviewResult PreviewResult(
|
||||
ExpertSelectionIdentity identity,
|
||||
IReadOnlyList<ExpertRecommendationPreview> items,
|
||||
bool truncated = false) =>
|
||||
new(
|
||||
identity,
|
||||
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
|
||||
items,
|
||||
truncated);
|
||||
|
||||
private static IReadOnlyList<ExpertRecommendationPreview> Recommendations(
|
||||
int count,
|
||||
string prefix = "S") =>
|
||||
Enumerable.Range(0, count)
|
||||
.Select(index => new ExpertRecommendationPreview(
|
||||
index,
|
||||
$"{prefix}{index:D4}",
|
||||
$"추천종목{prefix}{index:D4}",
|
||||
10_000m + index))
|
||||
.ToArray();
|
||||
|
||||
private sealed class DelegateExpertService : IExpertSelectionService
|
||||
{
|
||||
public Func<string, int, CancellationToken, Task<ExpertSearchResult>> Search { get; init; } =
|
||||
(query, _, _) => Task.FromResult(SearchResult(query));
|
||||
|
||||
public Func<ExpertSelectionIdentity, int, CancellationToken, Task<ExpertPreviewResult>>
|
||||
Preview
|
||||
{ get; init; } =
|
||||
(identity, _, _) => Task.FromResult(PreviewResult(identity, []));
|
||||
|
||||
public string? SearchQuery { get; private set; }
|
||||
|
||||
public int SearchMaximum { get; private set; }
|
||||
|
||||
public int PreviewMaximum { get; private set; }
|
||||
|
||||
public Task<ExpertSearchResult> SearchAsync(
|
||||
string query = "",
|
||||
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SearchQuery = query;
|
||||
SearchMaximum = maximumResults;
|
||||
return Search(query, maximumResults, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ExpertPreviewResult> PreviewAsync(
|
||||
ExpertSelectionIdentity identity,
|
||||
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PreviewMaximum = maximumItems;
|
||||
return Preview(identity, maximumItems, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
using System.Collections;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyFixedActionCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmbeddedCatalog_HasApprovedIdentityCountsAndOrder()
|
||||
{
|
||||
var catalog = LegacyFixedActionCatalog.Default;
|
||||
|
||||
Assert.Equal(LegacyFixedActionCatalog.ExpectedDocumentSha256, catalog.DocumentSha256);
|
||||
Assert.Equal(
|
||||
LegacyFixedActionCatalog.ExpectedGeneratorSourceSha256,
|
||||
catalog.GeneratorSourceSha256);
|
||||
Assert.Equal(328, catalog.Actions.Count);
|
||||
Assert.Equal(Enumerable.Range(1, 328).Select(index => $"fixed-{index:000}"),
|
||||
catalog.Actions.Select(action => action.Id));
|
||||
Assert.Equal(78, catalog.GetActions(LegacyFixedMarket.Overseas).Count);
|
||||
Assert.Equal(46, catalog.GetActions(LegacyFixedMarket.Exchange).Count);
|
||||
Assert.Equal(204, catalog.GetActions(LegacyFixedMarket.Index).Count);
|
||||
Assert.Equal(322, catalog.Actions.Count(action => action.Available));
|
||||
Assert.Equal(6, catalog.Actions.Count(action => !action.Available));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmbeddedCatalog_ClosesEverySceneIdentityAgainstCoreCatalog()
|
||||
{
|
||||
foreach (var action in LegacyFixedActionCatalog.Default.Actions)
|
||||
{
|
||||
var definition = Assert.Single(
|
||||
LegacySceneCatalog.Definitions,
|
||||
candidate => candidate.BuilderKey == action.BuilderKey);
|
||||
Assert.Single(action.Aliases);
|
||||
Assert.Equal(action.Code, action.Aliases[0]);
|
||||
Assert.Contains(action.Code, definition.CutAliases);
|
||||
Assert.Contains(
|
||||
LegacySceneCatalog.FindByCutAlias(action.Code),
|
||||
candidate => candidate.BuilderKey == action.BuilderKey);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeAssets_AreApprovedAndImmutable()
|
||||
{
|
||||
var catalog = LegacyFixedActionCatalog.Default;
|
||||
|
||||
Assert.Equal(
|
||||
"bin/Debug/Res/해외.ini",
|
||||
catalog.GetRuntimeAsset(LegacyFixedMarket.Overseas).RelativePath);
|
||||
Assert.Equal(
|
||||
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A",
|
||||
catalog.GetRuntimeAsset(LegacyFixedMarket.Overseas).Sha256);
|
||||
Assert.Equal(
|
||||
"bin/Debug/Res/환율.ini",
|
||||
catalog.GetRuntimeAsset(LegacyFixedMarket.Exchange).RelativePath);
|
||||
Assert.Equal(
|
||||
"bin/Debug/Res/지수.ini",
|
||||
catalog.GetRuntimeAsset(LegacyFixedMarket.Index).RelativePath);
|
||||
|
||||
var dictionary = Assert.IsAssignableFrom<IDictionary>(catalog.RuntimeAssets);
|
||||
Assert.True(dictionary.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
dictionary.Add(
|
||||
LegacyFixedMarket.Overseas,
|
||||
new LegacyFixedRuntimeAsset(LegacyFixedMarket.Overseas, "bad", "bad")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryApi_ReturnsImmutableMarketAndSectionViews()
|
||||
{
|
||||
var catalog = LegacyFixedActionCatalog.Default;
|
||||
var overseas = catalog.GetActions(LegacyFixedMarket.Overseas);
|
||||
var sections = catalog.GetSections(LegacyFixedMarket.Overseas);
|
||||
var firstSection = sections[0];
|
||||
|
||||
Assert.Equal(LegacyFixedMarket.Overseas, firstSection.Market);
|
||||
Assert.Equal("1열판기본", firstSection.Name);
|
||||
Assert.Equal(43, firstSection.Actions.Count);
|
||||
Assert.Same(catalog.Actions[0], catalog.GetAction("fixed-001"));
|
||||
Assert.Same(
|
||||
firstSection.Actions[0],
|
||||
catalog.GetActions(LegacyFixedMarket.Overseas, "1열판기본")[0]);
|
||||
Assert.Empty(catalog.GetActions(LegacyFixedMarket.Overseas, "없는 섹션"));
|
||||
|
||||
var mutableActions = Assert.IsAssignableFrom<IList<LegacyFixedAction>>(overseas);
|
||||
Assert.True(mutableActions.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() => mutableActions.RemoveAt(0));
|
||||
Assert.Null(catalog.FindAction(null));
|
||||
Assert.Null(catalog.FindAction("fixed-999"));
|
||||
Assert.Throws<KeyNotFoundException>(() => catalog.GetAction("fixed-999"));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
catalog.GetActions((LegacyFixedMarket)99));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Materialize_ComputesDateFromTrustedHostClock()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 7, 15, 23, 58, 0, TimeSpan.FromHours(9));
|
||||
|
||||
var result = LegacyFixedActionCatalog.Default.Materialize("fixed-248", now);
|
||||
|
||||
Assert.Equal("2026-07-15", result.Selection.DataCode);
|
||||
Assert.Equal("", result.Selection.GraphicType);
|
||||
Assert.Equal("$TODAY", result.Action.Selection.DataCode);
|
||||
Assert.Equal(LegacyFixedMarket.Index, result.RuntimeAsset.Market);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "PRE_MARKET")]
|
||||
[InlineData(12, "PRE_MARKET")]
|
||||
[InlineData(13, "AFTER_MARKET")]
|
||||
[InlineData(23, "AFTER_MARKET")]
|
||||
public void Materialize_ComputesNxtSessionFromTrustedHostClock(int hour, string expected)
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 7, 15, hour, 59, 0, TimeSpan.FromHours(9));
|
||||
|
||||
var result = LegacyFixedActionCatalog.Default.Materialize("fixed-263", now);
|
||||
|
||||
Assert.Equal(expected, result.Selection.GraphicType);
|
||||
Assert.Equal("$NXT_SESSION", result.Action.Selection.GraphicType);
|
||||
Assert.Equal("", result.Selection.DataCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, "")]
|
||||
[InlineData(true, false, "MA5")]
|
||||
[InlineData(false, true, "MA20")]
|
||||
[InlineData(true, true, "MA5,MA20")]
|
||||
public void Materialize_ClosesMovingAverageFlags(bool ma5, bool ma20, string expected)
|
||||
{
|
||||
var result = LegacyFixedActionCatalog.Default.Materialize(
|
||||
"fixed-136",
|
||||
new DateTimeOffset(2026, 7, 15, 9, 0, 0, TimeSpan.FromHours(9)),
|
||||
new LegacyMovingAverageSelection(ma5, ma20));
|
||||
|
||||
Assert.Equal(expected, result.Selection.GraphicType);
|
||||
Assert.Equal(new LegacyMovingAverageSelection(ma5, ma20), result.MovingAverages);
|
||||
Assert.Equal("", result.Action.Selection.GraphicType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Materialize_IgnoresMovingAverageFlagsForOtherBuilders()
|
||||
{
|
||||
var result = LegacyFixedActionCatalog.Default.Materialize(
|
||||
"fixed-001",
|
||||
DateTimeOffset.Now,
|
||||
new LegacyMovingAverageSelection(true, true));
|
||||
|
||||
Assert.Equal("1열판기본", result.Selection.GraphicType);
|
||||
Assert.Equal(default, result.MovingAverages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Materialize_ResolvesEveryTemplateWithoutAcceptingSelectionOverrides()
|
||||
{
|
||||
var catalog = LegacyFixedActionCatalog.Default;
|
||||
var beforeBoundary = new DateTimeOffset(
|
||||
2026, 7, 15, 12, 59, 0, TimeSpan.FromHours(9));
|
||||
var afterBoundary = beforeBoundary.AddHours(1);
|
||||
|
||||
foreach (var action in catalog.Actions.Where(action => action.Available))
|
||||
{
|
||||
var before = catalog.Materialize(
|
||||
action.Id,
|
||||
beforeBoundary,
|
||||
new LegacyMovingAverageSelection(true, true));
|
||||
Assert.DoesNotContain('$', before.Selection.GroupCode);
|
||||
Assert.DoesNotContain('$', before.Selection.Subject);
|
||||
Assert.DoesNotContain('$', before.Selection.GraphicType);
|
||||
Assert.DoesNotContain('$', before.Selection.Subtype);
|
||||
Assert.DoesNotContain('$', before.Selection.DataCode);
|
||||
|
||||
if (action.DynamicDate)
|
||||
{
|
||||
Assert.Equal("2026-07-15", before.Selection.DataCode);
|
||||
}
|
||||
else if (action.DynamicNxtSession)
|
||||
{
|
||||
Assert.Equal("PRE_MARKET", before.Selection.GraphicType);
|
||||
Assert.Equal(
|
||||
"AFTER_MARKET",
|
||||
catalog.Materialize(action.Id, afterBoundary).Selection.GraphicType);
|
||||
}
|
||||
else if (action.SupportsMovingAverages)
|
||||
{
|
||||
Assert.Equal("MA5,MA20", before.Selection.GraphicType);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(action.Selection, before.Selection);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(50, catalog.Actions.Count(action => action.DynamicDate));
|
||||
Assert.Equal(28, catalog.Actions.Count(action => action.DynamicNxtSession));
|
||||
Assert.Equal(74, catalog.Actions.Count(action => action.SupportsMovingAverages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Materialize_RejectsUnknownAndUnavailableActions()
|
||||
{
|
||||
var catalog = LegacyFixedActionCatalog.Default;
|
||||
|
||||
Assert.Throws<KeyNotFoundException>(() =>
|
||||
catalog.Materialize("fixed-999", DateTimeOffset.Now));
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
catalog.Materialize("fixed-245", DateTimeOffset.Now));
|
||||
Assert.Contains("수동 순매도", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_RejectsUnknownJsonMembersAndMissingRequiredMembers()
|
||||
{
|
||||
var unknown = ReadDocument();
|
||||
unknown["unexpected"] = true;
|
||||
AssertInvalid(unknown, "JSON schema");
|
||||
|
||||
var nestedUnknown = ReadDocument();
|
||||
Action(nestedUnknown, 0)["selection"]!["webOverride"] = "5006";
|
||||
AssertInvalid(nestedUnknown, "JSON schema");
|
||||
|
||||
var missing = ReadDocument();
|
||||
missing.AsObject().Remove("schemaVersion");
|
||||
AssertInvalid(missing, "JSON schema");
|
||||
|
||||
var nullActions = ReadDocument();
|
||||
nullActions["actions"] = null;
|
||||
AssertInvalid(nullActions, "actions is required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_RejectsDuplicateJsonMembers()
|
||||
{
|
||||
var original = Encoding.UTF8.GetString(ReadEmbeddedBytes());
|
||||
var duplicate = original.Replace(
|
||||
"\"schemaVersion\": 1,",
|
||||
"\"schemaVersion\": 1,\n \"schemaVersion\": 1,",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
var exception = Assert.Throws<InvalidDataException>(() =>
|
||||
LegacyFixedActionCatalog.LoadForValidation(Encoding.UTF8.GetBytes(duplicate)));
|
||||
|
||||
Assert.Contains("JSON schema", exception.Message, StringComparison.Ordinal);
|
||||
Assert.IsType<System.Text.Json.JsonException>(exception.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_RejectsTamperedDocumentHash()
|
||||
{
|
||||
var tampered = ReadEmbeddedBytes().Concat([(byte)' ']).ToArray();
|
||||
|
||||
var exception = Assert.Throws<InvalidDataException>(() =>
|
||||
LegacyFixedActionCatalog.LoadForValidation(
|
||||
tampered,
|
||||
LegacyFixedActionCatalog.ExpectedDocumentSha256));
|
||||
|
||||
Assert.Contains("hash is invalid", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_RejectsUnknownBuilderCodeAndAlias()
|
||||
{
|
||||
var unknownBuilder = ReadDocument();
|
||||
Action(unknownBuilder, 0)["builderKey"] = "s9999";
|
||||
AssertInvalid(unknownBuilder, "unknown builder");
|
||||
|
||||
var wrongCode = ReadDocument();
|
||||
Action(wrongCode, 0)["code"] = "5006";
|
||||
AssertInvalid(wrongCode, "is not an alias");
|
||||
|
||||
var wrongAlias = ReadDocument();
|
||||
Action(wrongAlias, 0)["aliases"] = new JsonArray("N5001");
|
||||
AssertInvalid(wrongAlias, "sole alias");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_RejectsNonSequentialIdAndSelectionPlaceholders()
|
||||
{
|
||||
var wrongId = ReadDocument();
|
||||
Action(wrongId, 0)["id"] = "fixed-002";
|
||||
AssertInvalid(wrongId, "action id at index 0");
|
||||
|
||||
var untrustedSelection = ReadDocument();
|
||||
Action(untrustedSelection, 0)["selection"]!["graphicType"] = "$WEB_VALUE";
|
||||
AssertInvalid(untrustedSelection, "unapproved selection placeholder");
|
||||
|
||||
var badDynamicDate = ReadDocument();
|
||||
Action(badDynamicDate, 247)["dynamicDate"] = false;
|
||||
AssertInvalid(badDynamicDate, "dynamic-date declaration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_RejectsTamperedRuntimeAssetMetadata()
|
||||
{
|
||||
var path = ReadDocument();
|
||||
path["runtimeAssets"]!["overseas"]!["relativePath"] = "C:/untrusted/해외.ini";
|
||||
AssertInvalid(path, "runtime asset metadata");
|
||||
|
||||
var hash = ReadDocument();
|
||||
hash["runtimeAssets"]!["index"]!["sha256"] = new string('0', 64);
|
||||
AssertInvalid(hash, "runtime asset metadata");
|
||||
}
|
||||
|
||||
private static JsonNode ReadDocument() =>
|
||||
JsonNode.Parse(ReadEmbeddedBytes()) ?? throw new InvalidOperationException();
|
||||
|
||||
private static byte[] ReadEmbeddedBytes()
|
||||
{
|
||||
using var stream = typeof(LegacyFixedActionCatalog).Assembly.GetManifestResourceStream(
|
||||
"MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json")
|
||||
?? throw new InvalidOperationException("Embedded test catalog was not found.");
|
||||
using var buffer = new MemoryStream();
|
||||
stream.CopyTo(buffer);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static JsonObject Action(JsonNode document, int index) =>
|
||||
document["actions"]!.AsArray()[index]!.AsObject();
|
||||
|
||||
private static void AssertInvalid(JsonNode document, string expectedMessage)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(document.ToJsonString());
|
||||
var exception = Assert.Throws<InvalidDataException>(() =>
|
||||
LegacyFixedActionCatalog.LoadForValidation(bytes));
|
||||
Assert.Contains(expectedMessage, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyIndustryActionCatalogTests
|
||||
{
|
||||
private static readonly string[] ExpectedActionIds =
|
||||
[
|
||||
"one-column",
|
||||
"two-column",
|
||||
"yield-5일",
|
||||
"yield-20일",
|
||||
"yield-60일",
|
||||
"yield-120일",
|
||||
"yield-240일",
|
||||
"candle-일봉",
|
||||
"candle-5일",
|
||||
"candle-20일",
|
||||
"candle-60일",
|
||||
"candle-120일",
|
||||
"candle-240일",
|
||||
"candle-volume-일봉",
|
||||
"candle-volume-5일",
|
||||
"candle-volume-20일",
|
||||
"candle-volume-60일",
|
||||
"candle-volume-120일",
|
||||
"candle-volume-240일",
|
||||
"five-row",
|
||||
"square",
|
||||
"sector"
|
||||
];
|
||||
|
||||
private static readonly string[] ExpectedRuntimeLabels =
|
||||
[
|
||||
"1열판",
|
||||
"2열판",
|
||||
"수익률그래프_5일",
|
||||
"수익률그래프_20일",
|
||||
"수익률그래프_60일",
|
||||
"수익률그래프_120일",
|
||||
"수익률그래프_240일",
|
||||
"캔들그래프_일봉",
|
||||
"캔들그래프_5일",
|
||||
"캔들그래프_20일",
|
||||
"캔들그래프_60일",
|
||||
"캔들그래프_120일",
|
||||
"캔들그래프_240일",
|
||||
"캔들그래프(거래량)_일봉",
|
||||
"캔들그래프(거래량)_5일",
|
||||
"캔들그래프(거래량)_20일",
|
||||
"캔들그래프(거래량)_60일",
|
||||
"캔들그래프(거래량)_120일",
|
||||
"캔들그래프(거래량)_240일",
|
||||
"5단 표그래프",
|
||||
"네모그래프",
|
||||
"섹터지수"
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[InlineData(
|
||||
IndustryMarket.Kospi,
|
||||
"bin/Debug/Res/업종_코스피.ini",
|
||||
"F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52")]
|
||||
[InlineData(
|
||||
IndustryMarket.Kosdaq,
|
||||
"bin/Debug/Res/업종_코스닥.ini",
|
||||
"F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4")]
|
||||
public void EachMarketHasTheExactImmutableRuntimeActionsAndAsset(
|
||||
IndustryMarket market,
|
||||
string expectedPath,
|
||||
string expectedSha256)
|
||||
{
|
||||
var actions = LegacyIndustryActionCatalog.GetActions(market);
|
||||
|
||||
Assert.Equal(LegacyIndustryActionCatalog.ActionsPerMarket, actions.Count);
|
||||
Assert.Equal(ExpectedActionIds, actions.Select(action => action.ActionId));
|
||||
Assert.Equal(ExpectedRuntimeLabels, actions.Select(action => action.Label));
|
||||
Assert.Equal(
|
||||
LegacyIndustryActionCatalog.ActionsPerMarket,
|
||||
actions.Select(action => action.ActionId).Distinct(StringComparer.Ordinal).Count());
|
||||
Assert.All(actions, action => Assert.Equal(market, action.Market));
|
||||
|
||||
var mutableView = Assert.IsAssignableFrom<IList<LegacyIndustryActionDefinition>>(actions);
|
||||
Assert.Throws<NotSupportedException>(() => mutableView.RemoveAt(0));
|
||||
|
||||
var asset = LegacyIndustryActionCatalog.GetRuntimeAsset(market);
|
||||
Assert.Equal(expectedPath, asset.RelativePath);
|
||||
Assert.Equal(expectedSha256, asset.Sha256);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(IndustryMarket.Kospi, "two-column", "s8018", "8018", 0)]
|
||||
[InlineData(IndustryMarket.Kosdaq, "two-column", "s8018", "8032", 0)]
|
||||
[InlineData(IndustryMarket.Kospi, "candle-일봉", "s8010", "8035", 0)]
|
||||
[InlineData(IndustryMarket.Kospi, "candle-volume-5일", "s8010", "8061", 0)]
|
||||
[InlineData(IndustryMarket.Kosdaq, "candle-20일", "s8010", "8040", 0)]
|
||||
[InlineData(IndustryMarket.Kosdaq, "candle-volume-60일", "s8010", "8046", 0)]
|
||||
[InlineData(IndustryMarket.Kospi, "candle-120일", "s8010", "8051", 0)]
|
||||
[InlineData(IndustryMarket.Kosdaq, "candle-volume-240일", "s8010", "8056", 0)]
|
||||
[InlineData(IndustryMarket.Kospi, "five-row", "s5074", "5074", 5)]
|
||||
[InlineData(IndustryMarket.Kospi, "square", "s8001", "8001", 0)]
|
||||
[InlineData(IndustryMarket.Kosdaq, "square", "s8001", "8002", 0)]
|
||||
[InlineData(IndustryMarket.Kosdaq, "sector", "s5078", "5078", 0)]
|
||||
public void SceneAliasAndPageSizeAreClosedPerMarket(
|
||||
IndustryMarket market,
|
||||
string actionId,
|
||||
string expectedBuilder,
|
||||
string expectedAlias,
|
||||
int expectedPageSize)
|
||||
{
|
||||
var action = LegacyIndustryActionCatalog.GetRequiredAction(market, actionId);
|
||||
|
||||
Assert.Equal(expectedBuilder, action.BuilderKey);
|
||||
Assert.Equal(expectedAlias, action.SceneAlias);
|
||||
Assert.Equal(expectedPageSize, action.PageSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(IndustryMarket.Kospi)]
|
||||
[InlineData(IndustryMarket.Kosdaq)]
|
||||
public void OnlyFiveRowHasLegacyPaging(IndustryMarket market)
|
||||
{
|
||||
var paged = Assert.Single(
|
||||
LegacyIndustryActionCatalog.GetActions(market),
|
||||
action => action.PageSize != 0);
|
||||
|
||||
Assert.Equal("five-row", paged.ActionId);
|
||||
Assert.Equal(5, paged.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownMarketAndActionFailClosed()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
LegacyIndustryActionCatalog.GetActions((IndustryMarket)999));
|
||||
Assert.Throws<KeyNotFoundException>(() =>
|
||||
LegacyIndustryActionCatalog.GetRequiredAction(IndustryMarket.Kospi, "../5001"));
|
||||
Assert.False(LegacyIndustryActionCatalog.TryGetAction(
|
||||
IndustryMarket.Kosdaq,
|
||||
"ONE-COLUMN",
|
||||
out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyIndustrySelectionWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadMarketUsesServiceOrderAndCopiesRowsIntoImmutableSnapshot()
|
||||
{
|
||||
var source = KospiRows().ToList();
|
||||
var service = new QueueIndustryService(source);
|
||||
var workflow = new LegacyIndustrySelectionWorkflow(service);
|
||||
|
||||
var state = await workflow.LoadMarketAsync(IndustryMarket.Kospi);
|
||||
source.Clear();
|
||||
|
||||
Assert.Equal([IndustryMarket.Kospi], service.Markets);
|
||||
Assert.Equal(3, state.Industries.Count);
|
||||
Assert.Equal("전기전자", state.Industries[0].IndustryName);
|
||||
Assert.Equal(22, state.Actions.Count);
|
||||
Assert.Null(state.SelectedIndustry);
|
||||
Assert.Null(state.FirstIndustry);
|
||||
Assert.Null(state.SecondIndustry);
|
||||
|
||||
var rows = Assert.IsAssignableFrom<IList<IndustrySelection>>(state.Industries);
|
||||
Assert.Throws<NotSupportedException>(() => rows.RemoveAt(0));
|
||||
var actions = Assert.IsAssignableFrom<IList<LegacyIndustryActionDefinition>>(state.Actions);
|
||||
Assert.Throws<NotSupportedException>(() => actions.RemoveAt(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDoubleClickRotatesFirstIntoSecondBeforeAssigningNewFirst()
|
||||
{
|
||||
var workflow = await KospiWorkflowAsync();
|
||||
|
||||
var selectedOnly = workflow.SelectIndustry(1);
|
||||
Assert.Equal("운수장비", selectedOnly.SelectedIndustry?.IndustryName);
|
||||
Assert.Null(selectedOnly.FirstIndustry);
|
||||
|
||||
var first = workflow.DoubleClickIndustry(0);
|
||||
Assert.Equal("전기전자", first.SelectedIndustry?.IndustryName);
|
||||
Assert.Equal("전기전자", first.FirstIndustry?.IndustryName);
|
||||
Assert.Null(first.SecondIndustry);
|
||||
|
||||
var second = workflow.DoubleClickIndustry(2);
|
||||
Assert.Equal("화학", second.SelectedIndustry?.IndustryName);
|
||||
Assert.Equal("화학", second.FirstIndustry?.IndustryName);
|
||||
Assert.Equal("전기전자", second.SecondIndustry?.IndustryName);
|
||||
|
||||
var third = workflow.DoubleClickIndustry(1);
|
||||
Assert.Equal("운수장비", third.FirstIndustry?.IndustryName);
|
||||
Assert.Equal("화학", third.SecondIndustry?.IndustryName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwapMatchesUc2EvenWhenOnlyOneTextSlotHasAValue()
|
||||
{
|
||||
var workflow = await KospiWorkflowAsync();
|
||||
workflow.DoubleClickIndustry(0);
|
||||
|
||||
var swapped = workflow.SwapIndustries();
|
||||
Assert.Null(swapped.FirstIndustry);
|
||||
Assert.Equal("전기전자", swapped.SecondIndustry?.IndustryName);
|
||||
|
||||
var restored = workflow.SwapIndustries();
|
||||
Assert.Equal("전기전자", restored.FirstIndustry?.IndustryName);
|
||||
Assert.Null(restored.SecondIndustry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SameMarketRefreshReconcilesIdentityButMarketChangeClearsSelection()
|
||||
{
|
||||
var initial = KospiRows();
|
||||
var refreshed = new[] { initial[1], initial[0], initial[2] };
|
||||
var kosdaq = KosdaqRows();
|
||||
var service = new QueueIndustryService(initial, refreshed, kosdaq);
|
||||
var workflow = new LegacyIndustrySelectionWorkflow(service);
|
||||
|
||||
await workflow.LoadMarketAsync(IndustryMarket.Kospi);
|
||||
workflow.DoubleClickIndustry(0);
|
||||
workflow.DoubleClickIndustry(1);
|
||||
|
||||
var reconciled = await workflow.LoadMarketAsync(IndustryMarket.Kospi);
|
||||
Assert.Equal(0, reconciled.SelectedIndex);
|
||||
Assert.Equal("운수장비", reconciled.SelectedIndustry?.IndustryName);
|
||||
Assert.Equal("운수장비", reconciled.FirstIndustry?.IndustryName);
|
||||
Assert.Equal("전기전자", reconciled.SecondIndustry?.IndustryName);
|
||||
|
||||
var changed = await workflow.LoadMarketAsync(IndustryMarket.Kosdaq);
|
||||
Assert.Equal(IndustryMarket.Kosdaq, changed.Market);
|
||||
Assert.Null(changed.SelectedIndex);
|
||||
Assert.Null(changed.SelectedIndustry);
|
||||
Assert.Null(changed.FirstIndustry);
|
||||
Assert.Null(changed.SecondIndustry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidIndexAndEmptyClearAreNoOps()
|
||||
{
|
||||
var workflow = await KospiWorkflowAsync();
|
||||
var before = workflow.Current;
|
||||
|
||||
Assert.Equal(before.Revision, workflow.SelectIndustry(-1).Revision);
|
||||
Assert.Equal(before.Revision, workflow.DoubleClickIndustry(3).Revision);
|
||||
Assert.Equal(before.Revision, workflow.ClearSelections().Revision);
|
||||
Assert.Equal(before.Revision, workflow.SwapIndustries().Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RepresentativeActionsMaterializeOnlyFromCatalogAndAuthoritativeState()
|
||||
{
|
||||
var workflow = await KospiWorkflowAsync(new FixedTimeProvider(
|
||||
new DateTimeOffset(2026, 7, 11, 10, 0, 0, TimeSpan.Zero)));
|
||||
workflow.DoubleClickIndustry(0);
|
||||
workflow.DoubleClickIndustry(1);
|
||||
|
||||
var single = workflow.MaterializePlaylistDraft("one-column");
|
||||
Assert.Equal("s5001", single.BuilderKey);
|
||||
Assert.Equal("5001", single.SceneAlias);
|
||||
Assert.Equal("운수장비", single.Title);
|
||||
Assert.Equal(
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
"INDUSTRY_KOSPI", "운수장비", "1열판", "CURRENT", string.Empty),
|
||||
single.Selection);
|
||||
Assert.True(single.IsEnabled);
|
||||
Assert.Equal(6, single.FadeDuration);
|
||||
|
||||
var pair = workflow.MaterializePlaylistDraft("two-column");
|
||||
Assert.Equal("s8018", pair.BuilderKey);
|
||||
Assert.Equal("8018", pair.SceneAlias);
|
||||
Assert.Equal("운수장비-전기전자", pair.Selection.Subject);
|
||||
Assert.Equal("2열판", pair.Selection.GraphicType);
|
||||
|
||||
var yield = workflow.MaterializePlaylistDraft("yield-120일");
|
||||
Assert.Equal("s5086", yield.BuilderKey);
|
||||
Assert.Equal("5086", yield.SceneAlias);
|
||||
Assert.Equal("OneHundredTwentyDays", yield.Selection.Subtype);
|
||||
|
||||
var candle = workflow.MaterializePlaylistDraft("candle-volume-240일");
|
||||
Assert.Equal("s8010", candle.BuilderKey);
|
||||
Assert.Equal("8056", candle.SceneAlias);
|
||||
Assert.Equal("KOSPI_INDUSTRY", candle.Selection.GroupCode);
|
||||
Assert.Equal("VOLUME", candle.Selection.Subtype);
|
||||
|
||||
var five = workflow.MaterializePlaylistDraft("five-row");
|
||||
Assert.Equal("s5074", five.BuilderKey);
|
||||
Assert.Equal("5074", five.SceneAlias);
|
||||
Assert.Equal(5, five.PageSize);
|
||||
Assert.Equal("PAGED_DOMESTIC_KOSPI", five.Selection.GroupCode);
|
||||
Assert.Equal("INDEX", five.Selection.Subject);
|
||||
Assert.Equal("INDUSTRY", five.Selection.Subtype);
|
||||
Assert.Equal("2026-07-11", five.Selection.DataCode);
|
||||
Assert.Equal(
|
||||
"F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52",
|
||||
five.RuntimeAssetSha256);
|
||||
|
||||
var square = workflow.MaterializePlaylistDraft("square");
|
||||
Assert.Equal("8001", square.SceneAlias);
|
||||
Assert.Equal("KOSPI", square.Selection.Subject);
|
||||
Assert.Equal("네모그래프", square.Selection.GraphicType);
|
||||
|
||||
var sector = workflow.MaterializePlaylistDraft("sector");
|
||||
Assert.Equal("5078", sector.SceneAlias);
|
||||
Assert.Equal("SECTOR_INDEX", sector.Selection.GraphicType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryKosdaqActionMaterializesWithItsExactAliasAndPageSize()
|
||||
{
|
||||
var service = new QueueIndustryService(KosdaqRows());
|
||||
var workflow = new LegacyIndustrySelectionWorkflow(
|
||||
service,
|
||||
new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 0, 0, 0, TimeSpan.Zero)));
|
||||
await workflow.LoadMarketAsync(IndustryMarket.Kosdaq);
|
||||
workflow.DoubleClickIndustry(0);
|
||||
workflow.DoubleClickIndustry(1);
|
||||
|
||||
foreach (var action in LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq))
|
||||
{
|
||||
var draft = workflow.MaterializePlaylistDraft(action.ActionId);
|
||||
Assert.Equal(action.ActionId, draft.ActionId);
|
||||
Assert.Equal(action.BuilderKey, draft.BuilderKey);
|
||||
Assert.Equal(action.SceneAlias, draft.SceneAlias);
|
||||
Assert.Equal(action.PageSize, draft.PageSize);
|
||||
Assert.Equal(IndustryMarket.Kosdaq, draft.Market);
|
||||
}
|
||||
|
||||
Assert.Equal("8032", workflow.MaterializePlaylistDraft("two-column").SceneAlias);
|
||||
Assert.Equal("8002", workflow.MaterializePlaylistDraft("square").SceneAlias);
|
||||
Assert.Equal(
|
||||
"PAGED_DOMESTIC_KOSDAQ",
|
||||
workflow.MaterializePlaylistDraft("five-row").Selection.GroupCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectionPrerequisitesUnsafePairAndUnknownActionFailClosed()
|
||||
{
|
||||
var workflow = await KospiWorkflowAsync();
|
||||
|
||||
Assert.Throws<LegacyIndustryWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("one-column"));
|
||||
Assert.Throws<LegacyIndustryWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("two-column"));
|
||||
Assert.NotNull(workflow.MaterializePlaylistDraft("five-row"));
|
||||
Assert.Throws<LegacyIndustryWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("../../5001"));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
workflow.MaterializePlaylistDraft(null!));
|
||||
|
||||
var unsafePair = new LegacyIndustrySelectionWorkflow(new QueueIndustryService(
|
||||
[
|
||||
new IndustrySelection(IndustryMarket.Kospi, "전기-전자", "013"),
|
||||
new IndustrySelection(IndustryMarket.Kospi, "운수장비", "015")
|
||||
]));
|
||||
await unsafePair.LoadMarketAsync(IndustryMarket.Kospi);
|
||||
unsafePair.DoubleClickIndustry(0);
|
||||
unsafePair.DoubleClickIndustry(1);
|
||||
Assert.Throws<LegacyIndustryWorkflowException>(() =>
|
||||
unsafePair.MaterializePlaylistDraft("two-column"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidServiceRowsFailAtomicallyBeforeStateChanges()
|
||||
{
|
||||
var service = new QueueIndustryService(
|
||||
[
|
||||
new IndustrySelection(IndustryMarket.Kosdaq, "반도체", "027")
|
||||
]);
|
||||
var workflow = new LegacyIndustrySelectionWorkflow(service);
|
||||
|
||||
await Assert.ThrowsAsync<LegacyIndustryWorkflowException>(() =>
|
||||
workflow.LoadMarketAsync(IndustryMarket.Kospi));
|
||||
|
||||
Assert.Equal(0, workflow.Current.Revision);
|
||||
Assert.Null(workflow.Current.Market);
|
||||
Assert.Empty(workflow.Current.Industries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowerPreviousMarketResponseCannotReplaceOrFaultNewerMarketState()
|
||||
{
|
||||
var service = new ControlledIndustryService();
|
||||
var workflow = new LegacyIndustrySelectionWorkflow(service);
|
||||
|
||||
var older = workflow.LoadMarketAsync(IndustryMarket.Kospi);
|
||||
var newer = workflow.LoadMarketAsync(IndustryMarket.Kosdaq);
|
||||
service.Kosdaq.SetResult(KosdaqRows());
|
||||
var newerState = await newer;
|
||||
|
||||
// This row is invalid for the older KOSPI request. Because that request is
|
||||
// already stale, it must be ignored before it can disturb current state.
|
||||
service.Kospi.SetResult(KosdaqRows());
|
||||
var olderResult = await older;
|
||||
|
||||
Assert.Equal(IndustryMarket.Kosdaq, newerState.Market);
|
||||
Assert.Equal(IndustryMarket.Kosdaq, olderResult.Market);
|
||||
Assert.Equal(IndustryMarket.Kosdaq, workflow.Current.Market);
|
||||
Assert.Equal("반도체", workflow.Current.Industries[0].IndustryName);
|
||||
Assert.Equal(1, workflow.Current.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearSelectionsClearsHighlightedAndBothPairSlots()
|
||||
{
|
||||
var workflow = await KospiWorkflowAsync();
|
||||
workflow.DoubleClickIndustry(0);
|
||||
workflow.DoubleClickIndustry(1);
|
||||
|
||||
var cleared = workflow.ClearSelections();
|
||||
|
||||
Assert.Null(cleared.SelectedIndex);
|
||||
Assert.Null(cleared.SelectedIndustry);
|
||||
Assert.Null(cleared.FirstIndustry);
|
||||
Assert.Null(cleared.SecondIndustry);
|
||||
}
|
||||
|
||||
private static async Task<LegacyIndustrySelectionWorkflow> KospiWorkflowAsync(
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var workflow = new LegacyIndustrySelectionWorkflow(
|
||||
new QueueIndustryService(KospiRows()),
|
||||
timeProvider);
|
||||
await workflow.LoadMarketAsync(IndustryMarket.Kospi);
|
||||
return workflow;
|
||||
}
|
||||
|
||||
private static IndustrySelection[] KospiRows() =>
|
||||
[
|
||||
new(IndustryMarket.Kospi, "전기전자", "013"),
|
||||
new(IndustryMarket.Kospi, "운수장비", "015"),
|
||||
new(IndustryMarket.Kospi, "화학", "008")
|
||||
];
|
||||
|
||||
private static IndustrySelection[] KosdaqRows() =>
|
||||
[
|
||||
new(IndustryMarket.Kosdaq, "반도체", "027"),
|
||||
new(IndustryMarket.Kosdaq, "IT부품", "029")
|
||||
];
|
||||
|
||||
private sealed class QueueIndustryService(
|
||||
params IReadOnlyList<IndustrySelection>[] results) : IIndustrySelectionService
|
||||
{
|
||||
private readonly Queue<IReadOnlyList<IndustrySelection>> _results = new(results);
|
||||
|
||||
public List<IndustryMarket> Markets { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
|
||||
IndustryMarket market,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Markets.Add(market);
|
||||
if (_results.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No fake industry result remains.");
|
||||
}
|
||||
|
||||
return Task.FromResult(_results.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||
{
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||
}
|
||||
|
||||
private sealed class ControlledIndustryService : IIndustrySelectionService
|
||||
{
|
||||
public TaskCompletionSource<IReadOnlyList<IndustrySelection>> Kospi { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource<IReadOnlyList<IndustrySelection>> Kosdaq { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
|
||||
IndustryMarket market,
|
||||
CancellationToken cancellationToken = default) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => Kospi.Task.WaitAsync(cancellationToken),
|
||||
IndustryMarket.Kosdaq => Kosdaq.Task.WaitAsync(cancellationToken),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, null)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,307 @@
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyOperatorControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task IndustryTabLoadsSelectorRotatesPairAndAddsTrustedActions()
|
||||
{
|
||||
var industries = Enumerable.Range(1, 11)
|
||||
.Select(index => new IndustrySelection(
|
||||
IndustryMarket.Kospi,
|
||||
$"업종{index}",
|
||||
index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture)))
|
||||
.ToArray();
|
||||
var workflow = new LegacyIndustrySelectionWorkflow(
|
||||
new StaticIndustryService(industries));
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
industryWorkflow: workflow);
|
||||
|
||||
var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry);
|
||||
Assert.Equal(11, loaded.Industry?.Industries.Count);
|
||||
|
||||
controller.SelectIndustry(0, doubleClick: true);
|
||||
var pair = controller.SelectIndustry(1, doubleClick: true);
|
||||
Assert.Equal("업종2", pair.Industry?.FirstIndustry?.IndustryName);
|
||||
Assert.Equal("업종1", pair.Industry?.SecondIndustry?.IndustryName);
|
||||
|
||||
var twoColumn = controller.ActivateIndustryAction("two-column");
|
||||
var pairRow = Assert.Single(twoColumn.Playlist);
|
||||
Assert.Equal("8018", pairRow.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("업종2-업종1", pairRow.StockName);
|
||||
|
||||
var paged = controller.ActivateIndustryAction("five-row");
|
||||
Assert.Equal("1/3", paged.Playlist[1].PageText);
|
||||
Assert.Equal("5074", paged.Playlist[1].ToPlayoutEntry().CutCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IndustryParentDoubleClickMaterializesDirectChildrenAtomicallyInOrder()
|
||||
{
|
||||
var industries = Enumerable.Range(1, 3)
|
||||
.Select(index => new IndustrySelection(
|
||||
IndustryMarket.Kospi,
|
||||
$"업종{index}",
|
||||
index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture)))
|
||||
.ToArray();
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
industryWorkflow: new LegacyIndustrySelectionWorkflow(
|
||||
new StaticIndustryService(industries)));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry);
|
||||
controller.SelectIndustry(0, doubleClick: true);
|
||||
|
||||
var rejected = controller.ActivateIndustrySection(0);
|
||||
Assert.Empty(rejected.Playlist);
|
||||
|
||||
controller.SelectIndustry(1, doubleClick: true);
|
||||
var added = controller.ActivateIndustrySection(0);
|
||||
Assert.Collection(
|
||||
added.Playlist,
|
||||
row => Assert.Equal("5001", row.SceneAlias),
|
||||
row => Assert.Equal("8018", row.SceneAlias));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeTabSearchPreviewSortAndActionRemainCSharpAuthoritative()
|
||||
{
|
||||
var identity = new ThemeSelectionIdentity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"00000001",
|
||||
"AI 테마");
|
||||
var service = new StaticThemeService(
|
||||
identity,
|
||||
Enumerable.Range(0, 13)
|
||||
.Select(index => new ThemeItemPreview(
|
||||
index,
|
||||
$"P{index + 1:000000}",
|
||||
$"종목{index + 1}"))
|
||||
.ToArray());
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
themeWorkflow: new LegacyThemeWorkflow(service));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
|
||||
var searched = await controller.SearchThemesAsync("AI", ThemeSession.PreMarket);
|
||||
var resultId = Assert.Single(searched.Theme!.SearchResults).ResultId;
|
||||
var selected = await controller.SelectThemeAsync(resultId);
|
||||
Assert.Equal(13, selected.Theme?.PreviewItems.Count);
|
||||
|
||||
controller.SetThemeSort("GAIN_DESC");
|
||||
var added = controller.ActivateThemeAction("five-row-current");
|
||||
var row = Assert.Single(added.Playlist);
|
||||
Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("1/3", row.PageText);
|
||||
Assert.Equal("AI 테마", row.StockName);
|
||||
Assert.Equal(
|
||||
"GAIN_DESC",
|
||||
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
row.ToPlayoutEntry().Selection).GraphicType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Theme_result_double_click_awaits_preview_and_adds_fixed_current_action_once()
|
||||
{
|
||||
var identity = new ThemeSelectionIdentity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"00000001",
|
||||
"AI theme");
|
||||
var service = new StaticThemeService(
|
||||
identity,
|
||||
Enumerable.Range(0, 7)
|
||||
.Select(index => new ThemeItemPreview(
|
||||
index,
|
||||
$"P{index + 1:000000}",
|
||||
$"Stock {index + 1}"))
|
||||
.ToArray());
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
themeWorkflow: new LegacyThemeWorkflow(service));
|
||||
var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
var resultId = Assert.Single(loaded.Theme!.SearchResults).ResultId;
|
||||
|
||||
var activated = await controller.ActivateThemeResultAsync(resultId);
|
||||
|
||||
Assert.Equal(1, service.PreviewCalls);
|
||||
Assert.Equal(resultId, activated.Theme?.SelectedResultId);
|
||||
var row = Assert.Single(activated.Playlist);
|
||||
Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("1/2", row.PageText);
|
||||
Assert.Equal("INPUT_ORDER", Assert.IsType<
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
row.ToPlayoutEntry().Selection).GraphicType);
|
||||
Assert.Equal("activate-theme-result", activated.CommandResult?.Command);
|
||||
Assert.True(activated.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRow()
|
||||
{
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
expertWorkflow: new LegacyExpertWorkflowController(new StaticExpertService()));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
|
||||
|
||||
var search = await controller.SearchExpertsAsync("전문가");
|
||||
var selectionId = Assert.Single(search.Expert!.Search.Results).SelectionId;
|
||||
await controller.SelectExpertAsync(selectionId);
|
||||
var added = controller.ActivateExpertAction(
|
||||
LegacyExpertWorkflowController.ActionId);
|
||||
|
||||
var row = Assert.Single(added.Playlist);
|
||||
Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("1/2", row.PageText);
|
||||
Assert.Equal("전문가", row.MarketText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TradingHaltTabAddsCurrentAndCandleWithGlobalMovingAverages()
|
||||
{
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
tradingHaltWorkflow: new LegacyTradingHaltWorkflowController(
|
||||
new StaticTradingHaltService()));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.TradingHalt);
|
||||
var search = await controller.SearchTradingHaltsAsync("");
|
||||
var selectionId = Assert.Single(search.TradingHalt!.Results).SelectionId;
|
||||
controller.SelectTradingHalt(selectionId);
|
||||
|
||||
var current = controller.ActivateTradingHaltAction(
|
||||
LegacyTradingHaltWorkflowController.CurrentActionId);
|
||||
Assert.Equal("5001", Assert.Single(current.Playlist).ToPlayoutEntry().CutCode);
|
||||
|
||||
controller.SetMovingAverages(ma5: true, ma20: false);
|
||||
var candle = controller.ActivateTradingHaltAction(
|
||||
LegacyTradingHaltWorkflowController.CandleActionId);
|
||||
var candleRow = candle.Playlist[1];
|
||||
Assert.Equal("8051", candleRow.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal(
|
||||
"MA5",
|
||||
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
candleRow.ToPlayoutEntry().Selection).GraphicType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tabs_StartInOriginalOrderAndSwapByIdentity()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
|
||||
Assert.Equal(
|
||||
["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외종목", "전문가", "정지"],
|
||||
controller.Current.Tabs!.Select(tab => tab.Label));
|
||||
Assert.Equal(
|
||||
LegacyOperatorTabId.Overseas,
|
||||
Assert.Single(controller.Current.Tabs!, tab => tab.IsActive).Id);
|
||||
|
||||
var selected = controller.SelectTab(LegacyOperatorTabId.Theme);
|
||||
Assert.Equal(
|
||||
LegacyOperatorTabId.Theme,
|
||||
Assert.Single(selected.Tabs!, tab => tab.IsActive).Id);
|
||||
|
||||
var swapped = controller.SwapTabs(
|
||||
LegacyOperatorTabId.Theme,
|
||||
LegacyOperatorTabId.Exchange);
|
||||
Assert.Equal(LegacyOperatorTabId.Theme, swapped.Tabs![1].Id);
|
||||
Assert.Equal(LegacyOperatorTabId.Exchange, swapped.Tabs[6].Id);
|
||||
Assert.Equal(
|
||||
LegacyOperatorTabId.Theme,
|
||||
Assert.Single(swapped.Tabs, tab => tab.IsActive).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaylistKeyboardCommandsUseActiveRowAndBoundaries()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
|
||||
var rows = controller.Current.Playlist;
|
||||
|
||||
var last = controller.SelectPlaylistBoundary(last: true);
|
||||
Assert.True(last.Playlist[0].IsEnabled);
|
||||
Assert.True(last.Playlist[2].IsSelected);
|
||||
|
||||
var toggled = controller.ToggleActivePlaylistRowEnabled();
|
||||
Assert.False(toggled.Playlist[2].IsEnabled);
|
||||
|
||||
var first = controller.SelectPlaylistBoundary(last: false);
|
||||
Assert.True(first.Playlist[0].IsSelected);
|
||||
Assert.False(first.Playlist[2].IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FixedCatalogLeafAndSectionAreMaterializedOnlyByCSharp()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
var first = LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Overseas)[0];
|
||||
|
||||
var leaf = controller.ActivateFixedAction(first.Id);
|
||||
|
||||
var row = Assert.Single(leaf.Playlist);
|
||||
Assert.Equal("해외지수", row.MarketText);
|
||||
Assert.Equal(first.Label, row.StockName);
|
||||
Assert.Equal(first.Code, row.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal(
|
||||
first.Selection.Subject,
|
||||
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
row.ToPlayoutEntry().Selection).Subject);
|
||||
|
||||
var firstSection = LegacyFixedActionCatalog.Default
|
||||
.GetSections(LegacyFixedMarket.Overseas)[0];
|
||||
var section = await controller.ActivateFixedSection(0);
|
||||
Assert.Equal(1 + firstSection.Actions.Count, section.Playlist.Count);
|
||||
Assert.Equal(
|
||||
firstSection.Actions.Select(action => action.Label),
|
||||
section.Playlist.Skip(1).Select(item => item.StockName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedCatalogRejectsCrossTabAndUnavailableActionsAtomically()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
var exchange = LegacyFixedActionCatalog.Default
|
||||
.GetActions(LegacyFixedMarket.Exchange)[0];
|
||||
|
||||
var crossTab = controller.ActivateFixedAction(exchange.Id);
|
||||
Assert.Empty(crossTab.Playlist);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Error, crossTab.StatusKind);
|
||||
|
||||
var unavailable = LegacyFixedActionCatalog.Default.Actions
|
||||
.First(action => !action.Available);
|
||||
controller.SelectTab(unavailable.Market switch
|
||||
{
|
||||
LegacyFixedMarket.Overseas => LegacyOperatorTabId.Overseas,
|
||||
LegacyFixedMarket.Exchange => LegacyOperatorTabId.Exchange,
|
||||
LegacyFixedMarket.Index => LegacyOperatorTabId.Index,
|
||||
_ => throw new InvalidOperationException()
|
||||
});
|
||||
var blocked = controller.ActivateFixedAction(unavailable.Id);
|
||||
Assert.Empty(blocked.Playlist);
|
||||
Assert.Equal(unavailable.Prerequisite, blocked.Dialog?.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovingAverageFlagsAreCapturedInFixedCandleSelection()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
var candle = LegacyFixedActionCatalog.Default
|
||||
.GetActions(LegacyFixedMarket.Index)
|
||||
.First(action => action.SupportsMovingAverages);
|
||||
controller.SelectTab(LegacyOperatorTabId.Index);
|
||||
controller.SetMovingAverages(ma5: false, ma20: true);
|
||||
|
||||
var state = controller.ActivateFixedAction(candle.Id);
|
||||
|
||||
Assert.Equal(
|
||||
"MA20",
|
||||
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
Assert.Single(state.Playlist).ToPlayoutEntry().Selection).GraphicType);
|
||||
Assert.False(state.MovingAverages.Ma5);
|
||||
Assert.True(state.MovingAverages.Ma20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyButtonIsNoOpButEmptyEnterSearches()
|
||||
{
|
||||
@@ -191,6 +489,167 @@ public sealed class LegacyOperatorControllerTests
|
||||
Assert.Single(state.Playlist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaylistPlainControlAndControlShiftSelectionMatchMainFormRules()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5);
|
||||
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
|
||||
|
||||
controller.SelectPlaylistRow(ids[1], control: false, shift: false);
|
||||
controller.SelectPlaylistRow(ids[3], control: true, shift: false);
|
||||
controller.SelectPlaylistRow(ids[3], control: true, shift: false);
|
||||
var state = controller.SelectPlaylistRow(ids[4], control: true, shift: true);
|
||||
|
||||
Assert.Equal(
|
||||
[ids[1], ids[3], ids[4]],
|
||||
state.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaylistShiftUsesPreviousActiveRowAndMovesItsRangeAnchor()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5);
|
||||
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
|
||||
|
||||
controller.SelectPlaylistRow(ids[1], control: false, shift: false);
|
||||
var firstRange = controller.SelectPlaylistRow(ids[3], control: false, shift: true);
|
||||
Assert.Equal(
|
||||
ids[1..4],
|
||||
firstRange.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
|
||||
|
||||
var movedRange = controller.SelectPlaylistRow(ids[4], control: false, shift: true);
|
||||
Assert.Equal(
|
||||
ids[3..5],
|
||||
movedRange.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstPlaylistShiftStartsAtOriginalZeroCursor()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
|
||||
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
|
||||
|
||||
var state = controller.SelectPlaylistRow(ids[2], control: false, shift: true);
|
||||
|
||||
Assert.All(state.Playlist, row => Assert.True(row.IsSelected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaylistEnabledSupportsIndividualToggleAndAllRows()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
|
||||
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
|
||||
|
||||
controller.SetPlaylistRowEnabled(ids[0], false);
|
||||
var toggled = controller.TogglePlaylistRowEnabled(ids[1]);
|
||||
Assert.False(toggled.Playlist[0].IsEnabled);
|
||||
Assert.False(toggled.Playlist[1].IsEnabled);
|
||||
Assert.True(toggled.Playlist[2].IsEnabled);
|
||||
|
||||
var disabled = controller.SetAllPlaylistRowsEnabled(false);
|
||||
Assert.All(disabled.Playlist, row => Assert.False(row.IsEnabled));
|
||||
|
||||
var enabled = controller.SetAllPlaylistRowsEnabled(true);
|
||||
Assert.All(enabled.Playlist, row => Assert.True(row.IsEnabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSelectedRowsPreservesRemainingPhysicalOrderAndClearsSelection()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
|
||||
var initial = controller.Current.Playlist.ToArray();
|
||||
|
||||
controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false);
|
||||
controller.SelectPlaylistRow(initial[3].RowId, control: true, shift: false);
|
||||
var state = controller.DeleteSelectedPlaylistRows();
|
||||
|
||||
Assert.Equal(
|
||||
[initial[0].RowId, initial[2].RowId],
|
||||
state.Playlist.Select(row => row.RowId));
|
||||
Assert.DoesNotContain(state.Playlist, row => row.IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAllRowsClearsPlaylistAndSelectionState()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
|
||||
controller.SelectPlaylistRow(
|
||||
controller.Current.Playlist[1].RowId,
|
||||
control: false,
|
||||
shift: false);
|
||||
|
||||
var empty = controller.DeleteAllPlaylistRows();
|
||||
Assert.Empty(empty.Playlist);
|
||||
|
||||
var unchangedRevision = empty.Revision;
|
||||
var stillEmpty = controller.DeleteAllPlaylistRows();
|
||||
Assert.Equal(unchangedRevision, stillEmpty.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveUsesOnlyActiveRowAndCollapsesMultiSelectionToMovedRow()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
|
||||
var initial = controller.Current.Playlist.ToArray();
|
||||
|
||||
controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false);
|
||||
controller.SelectPlaylistRow(initial[2].RowId, control: true, shift: false);
|
||||
var moved = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Up);
|
||||
|
||||
Assert.Equal(
|
||||
[initial[0].RowId, initial[2].RowId, initial[1].RowId, initial[3].RowId],
|
||||
moved.Playlist.Select(row => row.RowId));
|
||||
Assert.Equal(
|
||||
initial[2].RowId,
|
||||
Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
|
||||
|
||||
var restored = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Down);
|
||||
Assert.Equal(initial.Select(row => row.RowId), restored.Playlist.Select(row => row.RowId));
|
||||
Assert.Equal(
|
||||
initial[2].RowId,
|
||||
Assert.Single(restored.Playlist, row => row.IsSelected).RowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveAtBoundaryIsNoOp()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1);
|
||||
var first = controller.Current.Playlist[0];
|
||||
controller.SelectPlaylistRow(first.RowId, control: false, shift: false);
|
||||
var before = controller.Current;
|
||||
|
||||
var after = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Up);
|
||||
|
||||
Assert.Equal(before.Revision, after.Revision);
|
||||
Assert.Equal(
|
||||
before.Playlist.Select(row => row.RowId),
|
||||
after.Playlist.Select(row => row.RowId));
|
||||
}
|
||||
|
||||
private static async Task<LegacyOperatorController> ControllerWithPlaylistAsync(
|
||||
params int[] physicalCutRows)
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(0);
|
||||
|
||||
var timestamp = 1_000L;
|
||||
foreach (var physicalCutRow in physicalCutRows)
|
||||
{
|
||||
controller.CutPointerDown(
|
||||
physicalCutRow,
|
||||
control: false,
|
||||
shift: false,
|
||||
x: 1,
|
||||
y: physicalCutRow * 23,
|
||||
timestampMilliseconds: timestamp);
|
||||
controller.DropSelectedCutsOnPlaylist();
|
||||
timestamp += 1_000;
|
||||
}
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static FakeLookup Lookup() => new(new LegacyStockSearchData(
|
||||
"query",
|
||||
["삼성전자", "넥스트(NXT)"],
|
||||
@@ -218,4 +677,104 @@ public sealed class LegacyOperatorControllerTests
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticIndustryService : IIndustrySelectionService
|
||||
{
|
||||
private readonly IReadOnlyList<IndustrySelection> _rows;
|
||||
|
||||
public StaticIndustryService(IReadOnlyList<IndustrySelection> rows)
|
||||
{
|
||||
_rows = rows;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
|
||||
IndustryMarket market,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(_rows);
|
||||
}
|
||||
|
||||
private sealed class StaticThemeService : IThemeSelectionService
|
||||
{
|
||||
private readonly ThemeSelectionIdentity _identity;
|
||||
private readonly IReadOnlyList<ThemeItemPreview> _items;
|
||||
|
||||
public StaticThemeService(
|
||||
ThemeSelectionIdentity identity,
|
||||
IReadOnlyList<ThemeItemPreview> items)
|
||||
{
|
||||
_identity = identity;
|
||||
_items = items;
|
||||
}
|
||||
|
||||
public int PreviewCalls { get; private set; }
|
||||
|
||||
public Task<ThemeSearchResult> SearchAsync(
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new ThemeSearchResult(
|
||||
query,
|
||||
nxtSession,
|
||||
DateTimeOffset.UtcNow,
|
||||
[new ThemeSelectorItem(_identity, DataSourceKind.Oracle)],
|
||||
false));
|
||||
|
||||
public Task<ThemePreviewResult> PreviewAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PreviewCalls++;
|
||||
return Task.FromResult(
|
||||
new ThemePreviewResult(
|
||||
_identity,
|
||||
DateTimeOffset.UtcNow,
|
||||
_items,
|
||||
false));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticExpertService : IExpertSelectionService
|
||||
{
|
||||
private static readonly ExpertSelectionIdentity Identity = new("0001", "전문가A");
|
||||
|
||||
public Task<ExpertSearchResult> SearchAsync(
|
||||
string query = "",
|
||||
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new ExpertSearchResult(
|
||||
query.Trim(),
|
||||
DateTimeOffset.UtcNow,
|
||||
[new ExpertSelectorItem(Identity, DataSourceKind.Oracle)],
|
||||
false));
|
||||
|
||||
public Task<ExpertPreviewResult> PreviewAsync(
|
||||
ExpertSelectionIdentity identity,
|
||||
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new ExpertPreviewResult(
|
||||
Identity,
|
||||
DateTimeOffset.UtcNow,
|
||||
Enumerable.Range(1, 6)
|
||||
.Select(index => new ExpertRecommendationPreview(
|
||||
index,
|
||||
index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture),
|
||||
$"추천{index}",
|
||||
10_000m + index))
|
||||
.ToArray(),
|
||||
false));
|
||||
}
|
||||
|
||||
private sealed class StaticTradingHaltService : ITradingHaltSelectionService
|
||||
{
|
||||
public Task<TradingHaltSelectionResult> SearchAsync(
|
||||
string query = "",
|
||||
int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new TradingHaltSelectionResult(
|
||||
query.Trim(),
|
||||
DateTimeOffset.UtcNow,
|
||||
[new TradingHaltSelection(TradingHaltMarket.Kospi, "005930", "삼성전자")],
|
||||
false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyOperatorPlayoutRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyPlaylistCannotCreatePlayoutRequest()
|
||||
{
|
||||
var controller = new LegacyOperatorController(new StaticLookup());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(controller.CreatePlayoutRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestPreservesNativePlaylistIdentityEnabledStateAndSelection()
|
||||
{
|
||||
var controller = new LegacyOperatorController(new StaticLookup());
|
||||
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||
controller.SelectStock(0);
|
||||
AddCut(controller, physicalIndex: 0, timestamp: 1_000);
|
||||
AddCut(controller, physicalIndex: 1, timestamp: 2_000);
|
||||
var rows = controller.Current.Playlist;
|
||||
controller.SetPlaylistRowEnabled(rows[0].RowId, enabled: false);
|
||||
controller.SelectPlaylistRow(rows[1].RowId, control: false, shift: false);
|
||||
|
||||
var request = controller.CreatePlayoutRequest();
|
||||
|
||||
Assert.Equal(1, request.SelectedIndexZeroBased);
|
||||
Assert.Equal(2, request.Playlist.Count);
|
||||
Assert.Equal(rows.Select(row => row.RowId),
|
||||
request.Playlist.Select(entry => entry.EntryId));
|
||||
Assert.False(request.Playlist[0].IsEnabled);
|
||||
Assert.True(request.Playlist[1].IsEnabled);
|
||||
Assert.All(request.Playlist, entry =>
|
||||
Assert.Equal("005930", entry.Selection?.DataCode));
|
||||
}
|
||||
|
||||
private static void AddCut(
|
||||
LegacyOperatorController controller,
|
||||
int physicalIndex,
|
||||
long timestamp)
|
||||
{
|
||||
controller.CutPointerDown(
|
||||
physicalIndex,
|
||||
control: false,
|
||||
shift: false,
|
||||
x: 1,
|
||||
y: physicalIndex * 23,
|
||||
timestampMilliseconds: timestamp);
|
||||
controller.DropSelectedCutsOnPlaylist();
|
||||
}
|
||||
|
||||
private sealed class StaticLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new LegacyStockSearchData(
|
||||
query,
|
||||
["삼성전자"],
|
||||
[new LegacyStockIdentity(
|
||||
LegacyDomesticStockMarket.Kospi,
|
||||
"삼성전자",
|
||||
"005930")]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Collections;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyOverseasActionCatalogTests
|
||||
{
|
||||
private static readonly string[] ExpectedFixedNames =
|
||||
[
|
||||
"다우존스",
|
||||
"나스닥",
|
||||
"S&P500",
|
||||
"독일 DAX 30",
|
||||
"영국 FTSE 100",
|
||||
"프랑스 CAC 40",
|
||||
"일본 니케이",
|
||||
"중국 상해종합",
|
||||
"홍콩 항셍",
|
||||
"대만 가권"
|
||||
];
|
||||
|
||||
private static readonly string[] ExpectedSymbols =
|
||||
[
|
||||
"DJI@DJI",
|
||||
"NAS@IXIC",
|
||||
"SPI@SPX",
|
||||
"XTR@DAX30",
|
||||
"LNS@FTSE100",
|
||||
"PAS@CAC40",
|
||||
"NII@NI225",
|
||||
"SHS@000001",
|
||||
"HSI@HSI",
|
||||
"TWS@TI01"
|
||||
];
|
||||
|
||||
private static readonly string[] ExpectedActionIds =
|
||||
[
|
||||
"index-candle-5d",
|
||||
"index-candle-20d",
|
||||
"index-candle-60d",
|
||||
"index-candle-120d",
|
||||
"index-candle-240d",
|
||||
"industry-current",
|
||||
"stock-current",
|
||||
"stock-candle-5d",
|
||||
"stock-candle-20d",
|
||||
"stock-candle-60d",
|
||||
"stock-candle-120d",
|
||||
"stock-candle-240d"
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void FixedIndexListMatchesUc5OrderAndClosedIdentity()
|
||||
{
|
||||
var targets = LegacyOverseasActionCatalog.FixedIndexTargets;
|
||||
|
||||
Assert.Equal(LegacyOverseasActionCatalog.FixedIndexTargetCount, targets.Count);
|
||||
Assert.Equal(ExpectedFixedNames, targets.Select(target => target.DisplayName));
|
||||
Assert.Equal(ExpectedFixedNames, targets.Select(target => target.InputName));
|
||||
Assert.Equal(ExpectedSymbols, targets.Select(target => target.Symbol));
|
||||
Assert.Equal(
|
||||
["US", "US", "US", "DE", "GB", "FR", "JP", "CN", "HK", "TW"],
|
||||
targets.Select(target => target.NationCode));
|
||||
Assert.All(targets, target => Assert.Equal("0", target.ForeignDomesticCode));
|
||||
Assert.Equal(
|
||||
targets.Count,
|
||||
targets.Select(target => target.TargetId).Distinct(StringComparer.Ordinal).Count());
|
||||
|
||||
var list = Assert.IsAssignableFrom<IList<LegacyOverseasFixedIndexTarget>>(targets);
|
||||
Assert.True(list.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() => list.RemoveAt(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActionsMatchUc5ButtonOrderAliasesAndPaging()
|
||||
{
|
||||
var actions = LegacyOverseasActionCatalog.Actions;
|
||||
|
||||
Assert.Equal(LegacyOverseasActionCatalog.ActionCount, actions.Count);
|
||||
Assert.Equal(ExpectedActionIds, actions.Select(action => action.ActionId));
|
||||
Assert.Equal(
|
||||
["8061", "8040", "8046", "8051", "8056"],
|
||||
actions.Take(5).Select(action => action.SceneAlias));
|
||||
Assert.Equal("5001", actions[5].SceneAlias);
|
||||
Assert.Equal("5001", actions[6].SceneAlias);
|
||||
Assert.Equal(
|
||||
["8061", "8040", "8046", "8051", "8056"],
|
||||
actions.Skip(7).Select(action => action.SceneAlias));
|
||||
Assert.All(actions, action =>
|
||||
{
|
||||
Assert.Equal(0, action.PageSize);
|
||||
Assert.Equal([action.SceneAlias], action.Aliases);
|
||||
Assert.Contains(
|
||||
LegacySceneCatalog.FindByCutAlias(action.SceneAlias),
|
||||
scene => scene.BuilderKey == action.BuilderKey && (int)scene.PageSize == 0);
|
||||
});
|
||||
Assert.Equal(5, actions.Count(action => action.Source == LegacyOverseasActionSource.FixedIndex));
|
||||
Assert.Single(actions, action => action.Source == LegacyOverseasActionSource.Industry);
|
||||
Assert.Equal(6, actions.Count(action => action.Source == LegacyOverseasActionSource.Stock));
|
||||
Assert.Equal(10, actions.Count(action => action.SupportsMovingAverages));
|
||||
|
||||
var list = Assert.IsAssignableFrom<IList<LegacyOverseasActionDefinition>>(actions);
|
||||
Assert.True(list.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() => list.RemoveAt(0));
|
||||
var aliases = Assert.IsAssignableFrom<IList<string>>(actions[0].Aliases);
|
||||
Assert.True(aliases.IsReadOnly);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("index-candle-5d", LegacyOverseasActionSource.FixedIndex, 5, "s8010", "8061")]
|
||||
[InlineData("index-candle-240d", LegacyOverseasActionSource.FixedIndex, 240, "s8010", "8056")]
|
||||
[InlineData("industry-current", LegacyOverseasActionSource.Industry, null, "s5001", "5001")]
|
||||
[InlineData("stock-current", LegacyOverseasActionSource.Stock, null, "s5001", "5001")]
|
||||
[InlineData("stock-candle-120d", LegacyOverseasActionSource.Stock, 120, "s8010", "8051")]
|
||||
public void LookupReturnsExactClosedDefinition(
|
||||
string actionId,
|
||||
LegacyOverseasActionSource source,
|
||||
int? periodDays,
|
||||
string builderKey,
|
||||
string sceneAlias)
|
||||
{
|
||||
var action = LegacyOverseasActionCatalog.GetAction(actionId);
|
||||
|
||||
Assert.Same(action, LegacyOverseasActionCatalog.FindAction(actionId));
|
||||
Assert.Equal(source, action.Source);
|
||||
Assert.Equal(periodDays, action.PeriodDays);
|
||||
Assert.Equal(builderKey, action.BuilderKey);
|
||||
Assert.Equal(sceneAlias, action.SceneAlias);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SourceViewsAndUnknownValuesFailClosed()
|
||||
{
|
||||
Assert.Equal(
|
||||
5,
|
||||
LegacyOverseasActionCatalog.GetActions(LegacyOverseasActionSource.FixedIndex).Count);
|
||||
Assert.Single(LegacyOverseasActionCatalog.GetActions(LegacyOverseasActionSource.Industry));
|
||||
Assert.Equal(
|
||||
6,
|
||||
LegacyOverseasActionCatalog.GetActions(LegacyOverseasActionSource.Stock).Count);
|
||||
Assert.Equal(
|
||||
"독일 DAX 30",
|
||||
LegacyOverseasActionCatalog.GetFixedIndexTarget("germany-dax-30").DisplayName);
|
||||
Assert.Null(LegacyOverseasActionCatalog.FindAction("../8010"));
|
||||
Assert.Null(LegacyOverseasActionCatalog.FindFixedIndexTarget("DOW-JONES"));
|
||||
Assert.Throws<KeyNotFoundException>(() =>
|
||||
LegacyOverseasActionCatalog.GetAction("../8010"));
|
||||
Assert.Throws<KeyNotFoundException>(() =>
|
||||
LegacyOverseasActionCatalog.GetFixedIndexTarget("DOW-JONES"));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
LegacyOverseasActionCatalog.GetActions((LegacyOverseasActionSource)99));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyOverseasOperatorControllerTests
|
||||
{
|
||||
private static readonly DateTimeOffset RetrievedAt =
|
||||
new(2026, 7, 15, 10, 0, 0, TimeSpan.FromHours(9));
|
||||
|
||||
[Fact]
|
||||
public void OverseasSnapshotAndFixedIndexActionRemainControllerAuthoritative()
|
||||
{
|
||||
var controller = CreateController();
|
||||
|
||||
var selectedTab = controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
|
||||
|
||||
Assert.NotNull(selectedTab.Overseas);
|
||||
Assert.Equal(10, selectedTab.Overseas.FixedIndexTargets.Count);
|
||||
Assert.Equal(12, selectedTab.Overseas.Actions.Count);
|
||||
Assert.Equal(new LegacyMovingAverageSelection(true, true), selectedTab.MovingAverages);
|
||||
Assert.Equal(
|
||||
new LegacyOverseasMovingAverageSelection(true, true),
|
||||
selectedTab.Overseas.MovingAverages);
|
||||
|
||||
controller.SelectOverseasFixedIndex("nasdaq");
|
||||
controller.SetMovingAverages(ma5: false, ma20: true);
|
||||
var state = controller.ActivateOverseasAction("index-candle-20d");
|
||||
var row = Assert.Single(state.Playlist);
|
||||
var entry = row.ToPlayoutEntry();
|
||||
var selection = Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
|
||||
entry.Selection);
|
||||
|
||||
Assert.Equal("legacy-overseas-00000001", row.RowId);
|
||||
Assert.Equal("FOREIGN_INDEX", row.MarketText);
|
||||
Assert.Equal("나스닥 캔들 20일", row.StockName);
|
||||
Assert.Equal("MA20", row.GraphicType);
|
||||
Assert.Equal("PRICE", row.Subtype);
|
||||
Assert.Equal("1/1", row.PageText);
|
||||
Assert.Equal("8040", entry.CutCode);
|
||||
Assert.Equal("FOREIGN_INDEX", selection.GroupCode);
|
||||
Assert.Equal("나스닥", selection.Subject);
|
||||
Assert.Equal("NAS@IXIC|US|0", selection.DataCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OverseasIndustryAndStockSearchSelectionsMaterializeExactPlaylistRows()
|
||||
{
|
||||
var controller = CreateController(
|
||||
industries: [new("반도체", "US Semiconductor", "NAS@SOX")],
|
||||
stocks: [new("Apple", "NAS@AAPL", "US")]);
|
||||
controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
|
||||
|
||||
var industries = await controller.SearchOverseasIndustriesAsync("반도체");
|
||||
var industryId = Assert.Single(industries.Overseas!.Industry.Results).SelectionId;
|
||||
controller.SelectOverseasIndustry(industryId);
|
||||
var industryState = controller.ActivateOverseasAction("industry-current");
|
||||
|
||||
var industryRow = Assert.Single(industryState.Playlist);
|
||||
Assert.Equal("5001", industryRow.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("FOREIGN_INDUSTRY", industryRow.PlayoutSelection!.GroupCode);
|
||||
Assert.Equal("반도체", industryRow.PlayoutSelection.Subject);
|
||||
Assert.Equal("1열판기본", industryRow.PlayoutSelection.GraphicType);
|
||||
Assert.Equal("CURRENT", industryRow.PlayoutSelection.Subtype);
|
||||
Assert.Equal("US Semiconductor|NAS@SOX|US|0", industryRow.PlayoutSelection.DataCode);
|
||||
|
||||
var stocks = await controller.SearchOverseasStocksAsync("apple");
|
||||
var stockId = Assert.Single(stocks.Overseas!.Stock.Results).SelectionId;
|
||||
controller.SelectOverseasStock(stockId);
|
||||
var stockState = controller.ActivateOverseasAction("stock-candle-5d");
|
||||
|
||||
Assert.Equal(2, stockState.Playlist.Count);
|
||||
var stockRow = stockState.Playlist[1];
|
||||
Assert.Equal("8061", stockRow.ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("FOREIGN_STOCK", stockRow.PlayoutSelection!.GroupCode);
|
||||
Assert.Equal("Apple", stockRow.PlayoutSelection.Subject);
|
||||
Assert.Equal("MA5,MA20", stockRow.PlayoutSelection.GraphicType);
|
||||
Assert.Equal("PRICE", stockRow.PlayoutSelection.Subtype);
|
||||
Assert.Equal("NAS@AAPL|US|1", stockRow.PlayoutSelection.DataCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OverseasActionsRejectInactiveTabAndStaleSearchIds()
|
||||
{
|
||||
var controller = CreateController(
|
||||
industries: [new("반도체", "US Semiconductor", "NAS@SOX")]);
|
||||
|
||||
var inactive = controller.SelectOverseasFixedIndex("nasdaq");
|
||||
Assert.Equal(LegacyOperatorStatusKind.Error, inactive.StatusKind);
|
||||
Assert.Null(inactive.Overseas);
|
||||
|
||||
controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
|
||||
var first = await controller.SearchOverseasIndustriesAsync("");
|
||||
var staleId = Assert.Single(first.Overseas!.Industry.Results).SelectionId;
|
||||
await controller.SearchOverseasIndustriesAsync("new generation");
|
||||
|
||||
var stale = controller.SelectOverseasIndustry(staleId);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Error, stale.StatusKind);
|
||||
Assert.Null(stale.Overseas!.Industry.Selected);
|
||||
}
|
||||
|
||||
private static LegacyOperatorController CreateController(
|
||||
IReadOnlyList<OverseasIndustryIndexItem>? industries = null,
|
||||
IReadOnlyList<WorldStockSearchItem>? stocks = null)
|
||||
{
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(
|
||||
new StaticIndustryService(industries ?? []),
|
||||
new StaticStockService(stocks ?? []));
|
||||
return new LegacyOperatorController(
|
||||
new EmptyStockLookup(),
|
||||
overseasWorkflow: workflow);
|
||||
}
|
||||
|
||||
private sealed class EmptyStockLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new LegacyStockSearchData(query, [], []));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticIndustryService(
|
||||
IReadOnlyList<OverseasIndustryIndexItem> items) : IOverseasIndustryIndexSearchService
|
||||
{
|
||||
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new OverseasIndustryIndexSearchResult(
|
||||
query.Trim(),
|
||||
RetrievedAt,
|
||||
items,
|
||||
IsTruncated: false));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticStockService(
|
||||
IReadOnlyList<WorldStockSearchItem> items) : IWorldStockSearchService
|
||||
{
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new WorldStockSearchResult(
|
||||
query.Trim(),
|
||||
RetrievedAt,
|
||||
items,
|
||||
IsTruncated: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyOverseasSelectionWorkflowTests
|
||||
{
|
||||
private static readonly DateTimeOffset RetrievedAt =
|
||||
new(2026, 7, 15, 9, 30, 0, TimeSpan.FromHours(9));
|
||||
|
||||
[Fact]
|
||||
public void InitialSnapshotIsTypedImmutableAndContainsClosedCatalogs()
|
||||
{
|
||||
var workflow = CreateWorkflow();
|
||||
|
||||
var snapshot = workflow.Current;
|
||||
|
||||
Assert.Equal(0, snapshot.Revision);
|
||||
Assert.Equal(10, snapshot.FixedIndexTargets.Count);
|
||||
Assert.Equal(12, snapshot.Actions.Count);
|
||||
Assert.Null(snapshot.SelectedFixedIndex);
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Industry.Status);
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Stock.Status);
|
||||
Assert.Empty(snapshot.Industry.Results);
|
||||
Assert.Empty(snapshot.Stock.Results);
|
||||
Assert.Equal(default, snapshot.MovingAverages);
|
||||
|
||||
var targets = Assert.IsAssignableFrom<IList<LegacyOverseasFixedIndexTarget>>(
|
||||
snapshot.FixedIndexTargets);
|
||||
var actions = Assert.IsAssignableFrom<IList<LegacyOverseasActionDefinition>>(
|
||||
snapshot.Actions);
|
||||
Assert.True(targets.IsReadOnly);
|
||||
Assert.True(actions.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() => targets.RemoveAt(0));
|
||||
Assert.Throws<NotSupportedException>(() => actions.RemoveAt(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchesUseInjectedInterfacesNormalizeQueriesAndIssueGenerationBoundIds()
|
||||
{
|
||||
var industryItems = new List<OverseasIndustryIndexItem>
|
||||
{
|
||||
new("Alpha", "Alpha Industry", "NAS@ALPHA"),
|
||||
new("Beta", "Beta Industry", "NAS@BETA")
|
||||
};
|
||||
var stockItems = new List<WorldStockSearchItem>
|
||||
{
|
||||
new("Apple", "NAS@AAPL", "US"),
|
||||
new("Taiwan Semi", "TWS@2330", "TW")
|
||||
};
|
||||
var industry = new RecordingIndustryService(industryItems);
|
||||
var stock = new RecordingStockService(stockItems);
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
|
||||
|
||||
var industryState = await workflow.SearchIndustriesAsync(" alpha ", 25);
|
||||
var stockState = await workflow.SearchStocksAsync(" semi ", 30);
|
||||
industryItems.Clear();
|
||||
stockItems.Clear();
|
||||
|
||||
Assert.Equal([("alpha", 25)], industry.Calls);
|
||||
Assert.Equal([("semi", 30)], stock.Calls);
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Ready, industryState.Industry.Status);
|
||||
Assert.Equal("alpha", industryState.Industry.Query);
|
||||
Assert.Equal(RetrievedAt, industryState.Industry.RetrievedAt);
|
||||
Assert.Equal(
|
||||
["overseas-industry-00000001-0001", "overseas-industry-00000001-0002"],
|
||||
industryState.Industry.Results.Select(row => row.SelectionId));
|
||||
Assert.Equal(
|
||||
["overseas-stock-00000001-0001", "overseas-stock-00000001-0002"],
|
||||
stockState.Stock.Results.Select(row => row.SelectionId));
|
||||
Assert.Equal(2, workflow.Current.Industry.ResultCount);
|
||||
Assert.Equal(2, workflow.Current.Stock.ResultCount);
|
||||
|
||||
var industryRows = Assert.IsAssignableFrom<IList<LegacyOverseasIndustrySearchRow>>(
|
||||
workflow.Current.Industry.Results);
|
||||
var stockRows = Assert.IsAssignableFrom<IList<LegacyOverseasStockSearchRow>>(
|
||||
workflow.Current.Stock.Results);
|
||||
Assert.True(industryRows.IsReadOnly);
|
||||
Assert.True(stockRows.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() => industryRows.RemoveAt(0));
|
||||
Assert.Throws<NotSupportedException>(() => stockRows.RemoveAt(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectionUsesCurrentOpaqueIdsAndARefreshInvalidatesOldIds()
|
||||
{
|
||||
var industry = new QueueIndustryService(
|
||||
IndustryResult(
|
||||
"",
|
||||
new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")),
|
||||
IndustryResult(
|
||||
"next",
|
||||
new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA")));
|
||||
var stock = new QueueStockService(
|
||||
StockResult("", new WorldStockSearchItem("Apple", "NAS@AAPL", "US")),
|
||||
StockResult("next", new WorldStockSearchItem("Taiwan Semi", "TWS@2330", "TW")));
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
|
||||
|
||||
var firstIndustry = await workflow.SearchIndustriesAsync("");
|
||||
var oldIndustryId = firstIndustry.Industry.Results[0].SelectionId;
|
||||
Assert.Equal(
|
||||
"Alpha",
|
||||
workflow.SelectIndustry(oldIndustryId).Industry.Selected?.KoreanName);
|
||||
var firstStock = await workflow.SearchStocksAsync("");
|
||||
var oldStockId = firstStock.Stock.Results[0].SelectionId;
|
||||
Assert.Equal("Apple", workflow.SelectStock(oldStockId).Stock.Selected?.InputName);
|
||||
|
||||
await workflow.SearchIndustriesAsync("next");
|
||||
await workflow.SearchStocksAsync("next");
|
||||
|
||||
Assert.Null(workflow.Current.Industry.Selected);
|
||||
Assert.Null(workflow.Current.Stock.Selected);
|
||||
Assert.Throws<KeyNotFoundException>(() => workflow.SelectIndustry(oldIndustryId));
|
||||
Assert.Throws<KeyNotFoundException>(() => workflow.SelectStock(oldStockId));
|
||||
Assert.StartsWith(
|
||||
"overseas-industry-00000002-",
|
||||
workflow.Current.Industry.Results[0].SelectionId,
|
||||
StringComparison.Ordinal);
|
||||
Assert.StartsWith(
|
||||
"overseas-stock-00000002-",
|
||||
workflow.Current.Stock.Results[0].SelectionId,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedIndexActionsMaterializeExactAliasIdentityPageAndMovingAverages()
|
||||
{
|
||||
var workflow = CreateWorkflow();
|
||||
workflow.SelectFixedIndex("germany-dax-30");
|
||||
workflow.SetMovingAverages(ma5: true, ma20: false);
|
||||
var expected = new Dictionary<string, (string Alias, int Days)>(StringComparer.Ordinal)
|
||||
{
|
||||
["index-candle-5d"] = ("8061", 5),
|
||||
["index-candle-20d"] = ("8040", 20),
|
||||
["index-candle-60d"] = ("8046", 60),
|
||||
["index-candle-120d"] = ("8051", 120),
|
||||
["index-candle-240d"] = ("8056", 240)
|
||||
};
|
||||
|
||||
foreach (var pair in expected)
|
||||
{
|
||||
var draft = workflow.MaterializePlaylistDraft(pair.Key);
|
||||
Assert.Equal("s8010", draft.BuilderKey);
|
||||
Assert.Equal(pair.Value.Alias, draft.SceneAlias);
|
||||
Assert.Equal([pair.Value.Alias], draft.Aliases);
|
||||
Assert.Equal(0, draft.PageSize);
|
||||
Assert.Equal("1/1", draft.InitialPageText);
|
||||
Assert.Equal($"독일 DAX 30 캔들 {pair.Value.Days}일", draft.Title);
|
||||
Assert.Equal("chart", draft.Category);
|
||||
Assert.Equal("overseas", draft.Market);
|
||||
Assert.Equal(LegacyOverseasActionSource.FixedIndex, draft.Source);
|
||||
Assert.Equal(
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_INDEX",
|
||||
"독일 DAX 30",
|
||||
"MA5",
|
||||
"PRICE",
|
||||
"XTR@DAX30|DE|0"),
|
||||
draft.Selection);
|
||||
Assert.Equal(new LegacyOverseasMovingAverageSelection(true, false), draft.MovingAverages);
|
||||
Assert.True(draft.IsEnabled);
|
||||
Assert.Equal(6, draft.FadeDuration);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IndustryCurrentMaterializesRootWorkflowSelectionAndIgnoresMaOptions()
|
||||
{
|
||||
var workflow = CreateWorkflow(
|
||||
industries: [new("반도체", "US Semiconductor", "NAS@SOX")]);
|
||||
var searched = await workflow.SearchIndustriesAsync("");
|
||||
workflow.SelectIndustry(searched.Industry.Results[0].SelectionId);
|
||||
workflow.SetMovingAverages(ma5: true, ma20: true);
|
||||
|
||||
var draft = workflow.MaterializePlaylistDraft("industry-current");
|
||||
|
||||
Assert.Equal("s5001", draft.BuilderKey);
|
||||
Assert.Equal("5001", draft.SceneAlias);
|
||||
Assert.Equal(0, draft.PageSize);
|
||||
Assert.Equal("1/1", draft.InitialPageText);
|
||||
Assert.Equal("반도체", draft.Title);
|
||||
Assert.Equal("plate", draft.Category);
|
||||
Assert.Equal(
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_INDUSTRY",
|
||||
"반도체",
|
||||
"1열판기본",
|
||||
"CURRENT",
|
||||
"US Semiconductor|NAS@SOX|US|0"),
|
||||
draft.Selection);
|
||||
Assert.Equal(default, draft.MovingAverages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StockCurrentAndAllCandleActionsUseSelectedUsTwIdentity()
|
||||
{
|
||||
var workflow = CreateWorkflow(
|
||||
stocks: [new("Taiwan Semi", "TWS@2330", "TW")]);
|
||||
var searched = await workflow.SearchStocksAsync("semi");
|
||||
workflow.SelectStock(searched.Stock.Results[0].SelectionId);
|
||||
workflow.SetMovingAverages(ma5: true, ma20: true);
|
||||
|
||||
var current = workflow.MaterializePlaylistDraft("stock-current");
|
||||
Assert.Equal("s5001", current.BuilderKey);
|
||||
Assert.Equal("5001", current.SceneAlias);
|
||||
Assert.Equal("stock", current.Category);
|
||||
Assert.Equal(
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_STOCK",
|
||||
"Taiwan Semi",
|
||||
"1열판기본",
|
||||
"CURRENT",
|
||||
"TWS@2330|TW|1"),
|
||||
current.Selection);
|
||||
Assert.Equal(default, current.MovingAverages);
|
||||
|
||||
var expectedAliases = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["stock-candle-5d"] = "8061",
|
||||
["stock-candle-20d"] = "8040",
|
||||
["stock-candle-60d"] = "8046",
|
||||
["stock-candle-120d"] = "8051",
|
||||
["stock-candle-240d"] = "8056"
|
||||
};
|
||||
foreach (var pair in expectedAliases)
|
||||
{
|
||||
var candle = workflow.MaterializePlaylistDraft(pair.Key);
|
||||
Assert.Equal("s8010", candle.BuilderKey);
|
||||
Assert.Equal(pair.Value, candle.SceneAlias);
|
||||
Assert.Equal("chart", candle.Category);
|
||||
Assert.Equal("MA5,MA20", candle.Selection.GraphicType);
|
||||
Assert.Equal("PRICE", candle.Selection.Subtype);
|
||||
Assert.Equal("TWS@2330|TW|1", candle.Selection.DataCode);
|
||||
Assert.Equal(new LegacyOverseasMovingAverageSelection(true, true), candle.MovingAverages);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, "")]
|
||||
[InlineData(true, false, "MA5")]
|
||||
[InlineData(false, true, "MA20")]
|
||||
[InlineData(true, true, "MA5,MA20")]
|
||||
public async Task MovingAverageOptionsHaveExactClosedText(
|
||||
bool ma5,
|
||||
bool ma20,
|
||||
string expected)
|
||||
{
|
||||
var workflow = CreateWorkflow(stocks: [new("Apple", "NAS@AAPL", "US")]);
|
||||
var searched = await workflow.SearchStocksAsync("");
|
||||
workflow.SelectStock(searched.Stock.Results[0].SelectionId);
|
||||
workflow.SetMovingAverages(ma5, ma20);
|
||||
|
||||
var draft = workflow.MaterializePlaylistDraft("stock-candle-20d");
|
||||
|
||||
Assert.Equal(expected, draft.Selection.GraphicType);
|
||||
Assert.Equal(new LegacyOverseasMovingAverageSelection(ma5, ma20), draft.MovingAverages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActionPrerequisitesUnknownIdsAndCallerOverridesFailClosed()
|
||||
{
|
||||
var workflow = CreateWorkflow();
|
||||
|
||||
Assert.Throws<LegacyOverseasWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("index-candle-5d"));
|
||||
Assert.Throws<LegacyOverseasWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("industry-current"));
|
||||
Assert.Throws<LegacyOverseasWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("stock-current"));
|
||||
Assert.Throws<LegacyOverseasWorkflowException>(() =>
|
||||
workflow.MaterializePlaylistDraft("../../8010"));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
workflow.MaterializePlaylistDraft(null!));
|
||||
Assert.Throws<KeyNotFoundException>(() => workflow.SelectFixedIndex("DOW-JONES"));
|
||||
|
||||
var methods = typeof(LegacyOverseasSelectionWorkflow).GetMethods()
|
||||
.Where(method => method.Name == nameof(LegacyOverseasSelectionWorkflow.MaterializePlaylistDraft));
|
||||
var method = Assert.Single(methods);
|
||||
var parameter = Assert.Single(method.GetParameters());
|
||||
Assert.Equal(typeof(string), parameter.ParameterType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowerIndustryResponseCannotReplaceOrInvalidateNewerState()
|
||||
{
|
||||
var industry = new ControlledIndustryService();
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(industry, new RecordingStockService([]));
|
||||
|
||||
var older = workflow.SearchIndustriesAsync("old");
|
||||
var newer = workflow.SearchIndustriesAsync("new");
|
||||
industry.Complete(
|
||||
"new",
|
||||
IndustryResult(
|
||||
"new",
|
||||
new OverseasIndustryIndexItem("New", "New Industry", "NAS@NEW")));
|
||||
var newerState = await newer;
|
||||
|
||||
// The stale response is intentionally malformed. Generation gating must happen
|
||||
// before its envelope and identities are examined.
|
||||
industry.Complete(
|
||||
"old",
|
||||
new OverseasIndustryIndexSearchResult(
|
||||
"wrong",
|
||||
default,
|
||||
[new OverseasIndustryIndexItem(" bad ", "bad", "|")],
|
||||
false));
|
||||
var olderState = await older;
|
||||
|
||||
Assert.Equal("new", newerState.Industry.Query);
|
||||
Assert.Equal("new", olderState.Industry.Query);
|
||||
Assert.Equal("New", workflow.Current.Industry.Results[0].KoreanName);
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Industry.Status);
|
||||
Assert.Equal(3, workflow.Current.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowerStockFaultCannotReplaceOrFaultNewerState()
|
||||
{
|
||||
var stock = new ControlledStockService();
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(new RecordingIndustryService([]), stock);
|
||||
|
||||
var older = workflow.SearchStocksAsync("old");
|
||||
var newer = workflow.SearchStocksAsync("new");
|
||||
stock.Complete(
|
||||
"new",
|
||||
StockResult("new", new WorldStockSearchItem("New Stock", "NAS@NEW", "US")));
|
||||
await newer;
|
||||
stock.Fail("old", new InvalidOperationException("stale provider failure"));
|
||||
|
||||
var olderState = await older;
|
||||
|
||||
Assert.Equal("new", olderState.Stock.Query);
|
||||
Assert.Equal("New Stock", olderState.Stock.Results[0].InputName);
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Stock.Status);
|
||||
Assert.Equal(3, workflow.Current.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidCurrentIndustryResponseFailsAtomicallyIntoErrorState()
|
||||
{
|
||||
var invalid = IndustryResult(
|
||||
"query",
|
||||
new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA"),
|
||||
new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA"));
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(
|
||||
new QueueIndustryService(invalid),
|
||||
new RecordingStockService([]));
|
||||
|
||||
await Assert.ThrowsAsync<LegacyOverseasWorkflowException>(() =>
|
||||
workflow.SearchIndustriesAsync("query"));
|
||||
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Industry.Status);
|
||||
Assert.Empty(workflow.Current.Industry.Results);
|
||||
Assert.Null(workflow.Current.Industry.Selected);
|
||||
Assert.NotEmpty(workflow.Current.Industry.Error);
|
||||
Assert.Equal(2, workflow.Current.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidCurrentStockIdentityFailsAtomicallyIntoErrorState()
|
||||
{
|
||||
var invalid = StockResult(
|
||||
"query",
|
||||
new WorldStockSearchItem("London", "LNS@BAD", "GB"));
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(
|
||||
new RecordingIndustryService([]),
|
||||
new QueueStockService(invalid));
|
||||
|
||||
await Assert.ThrowsAsync<LegacyOverseasWorkflowException>(() =>
|
||||
workflow.SearchStocksAsync("query"));
|
||||
|
||||
Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Stock.Status);
|
||||
Assert.Empty(workflow.Current.Stock.Results);
|
||||
Assert.Null(workflow.Current.Stock.Selected);
|
||||
Assert.NotEmpty(workflow.Current.Stock.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchBoundsUnsafeQueriesAndNullServicesAreRejectedBeforeCalls()
|
||||
{
|
||||
var industry = new RecordingIndustryService([]);
|
||||
var stock = new RecordingStockService([]);
|
||||
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
workflow.SearchIndustriesAsync("", 0));
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
workflow.SearchStocksAsync("", 501));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
workflow.SearchIndustriesAsync(new string('x', 65)));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
workflow.SearchStocksAsync("bad\u0001query"));
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
workflow.SearchStocksAsync(null!));
|
||||
Assert.Empty(industry.Calls);
|
||||
Assert.Empty(stock.Calls);
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new LegacyOverseasSelectionWorkflow(null!, stock));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new LegacyOverseasSelectionWorkflow(industry, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearOperationsAndRepeatedMaChoiceHaveStableRevisionSemantics()
|
||||
{
|
||||
var workflow = CreateWorkflow(
|
||||
industries: [new("Alpha", "Alpha Industry", "NAS@ALPHA")],
|
||||
stocks: [new("Apple", "NAS@AAPL", "US")]);
|
||||
workflow.SelectFixedIndex("dow-jones");
|
||||
var industry = await workflow.SearchIndustriesAsync("");
|
||||
workflow.SelectIndustry(industry.Industry.Results[0].SelectionId);
|
||||
var stock = await workflow.SearchStocksAsync("");
|
||||
workflow.SelectStock(stock.Stock.Results[0].SelectionId);
|
||||
workflow.SetMovingAverages(true, false);
|
||||
|
||||
var beforeClear = workflow.Current.Revision;
|
||||
workflow.ClearFixedIndexSelection();
|
||||
workflow.ClearIndustrySelection();
|
||||
workflow.ClearStockSelection();
|
||||
var cleared = workflow.Current;
|
||||
|
||||
Assert.Equal(beforeClear + 3, cleared.Revision);
|
||||
Assert.Null(cleared.SelectedFixedIndex);
|
||||
Assert.Null(cleared.Industry.Selected);
|
||||
Assert.Null(cleared.Stock.Selected);
|
||||
var stable = workflow.SetMovingAverages(true, false);
|
||||
Assert.Equal(cleared.Revision, stable.Revision);
|
||||
}
|
||||
|
||||
private static LegacyOverseasSelectionWorkflow CreateWorkflow(
|
||||
IReadOnlyList<OverseasIndustryIndexItem>? industries = null,
|
||||
IReadOnlyList<WorldStockSearchItem>? stocks = null) =>
|
||||
new(
|
||||
new RecordingIndustryService(industries ?? []),
|
||||
new RecordingStockService(stocks ?? []));
|
||||
|
||||
private static OverseasIndustryIndexSearchResult IndustryResult(
|
||||
string query,
|
||||
params OverseasIndustryIndexItem[] items) =>
|
||||
new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false);
|
||||
|
||||
private static WorldStockSearchResult StockResult(
|
||||
string query,
|
||||
params WorldStockSearchItem[] items) =>
|
||||
new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false);
|
||||
|
||||
private sealed class RecordingIndustryService(
|
||||
IReadOnlyList<OverseasIndustryIndexItem> items) : IOverseasIndustryIndexSearchService
|
||||
{
|
||||
public List<(string Query, int MaximumResults)> Calls { get; } = [];
|
||||
|
||||
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Calls.Add((query, maximumResults));
|
||||
return Task.FromResult(new OverseasIndustryIndexSearchResult(
|
||||
query,
|
||||
RetrievedAt,
|
||||
items,
|
||||
IsTruncated: false));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingStockService(
|
||||
IReadOnlyList<WorldStockSearchItem> items) : IWorldStockSearchService
|
||||
{
|
||||
public List<(string Query, int MaximumResults)> Calls { get; } = [];
|
||||
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Calls.Add((query, maximumResults));
|
||||
return Task.FromResult(new WorldStockSearchResult(
|
||||
query,
|
||||
RetrievedAt,
|
||||
items,
|
||||
IsTruncated: false));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QueueIndustryService(
|
||||
params OverseasIndustryIndexSearchResult[] results) : IOverseasIndustryIndexSearchService
|
||||
{
|
||||
private readonly Queue<OverseasIndustryIndexSearchResult> _results = new(results);
|
||||
|
||||
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(_results.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QueueStockService(
|
||||
params WorldStockSearchResult[] results) : IWorldStockSearchService
|
||||
{
|
||||
private readonly Queue<WorldStockSearchResult> _results = new(results);
|
||||
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(_results.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControlledIndustryService : IOverseasIndustryIndexSearchService
|
||||
{
|
||||
private readonly Dictionary<string, TaskCompletionSource<OverseasIndustryIndexSearchResult>>
|
||||
_pending = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = new TaskCompletionSource<OverseasIndustryIndexSearchResult>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending.Add(query, source);
|
||||
return source.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void Complete(string query, OverseasIndustryIndexSearchResult result) =>
|
||||
_pending[query].SetResult(result);
|
||||
}
|
||||
|
||||
private sealed class ControlledStockService : IWorldStockSearchService
|
||||
{
|
||||
private readonly Dictionary<string, TaskCompletionSource<WorldStockSearchResult>> _pending =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = new TaskCompletionSource<WorldStockSearchResult>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending.Add(query, source);
|
||||
return source.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void Complete(string query, WorldStockSearchResult result) =>
|
||||
_pending[query].SetResult(result);
|
||||
|
||||
public void Fail(string query, Exception exception) =>
|
||||
_pending[query].SetException(exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyTabActivationParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ThemeEachActivation_LoadsOriginalEmptyQueryOncePerActivation()
|
||||
{
|
||||
// Original evidence: Control/UC4.cs UC4_Load starts timer1 and its first
|
||||
// timer1_Tick calls GetThemeByKeyword with the initially empty txts value.
|
||||
var service = new CountingThemeService();
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
themeWorkflow: new LegacyThemeWorkflow(service));
|
||||
|
||||
var first = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
Assert.Equal(string.Empty, service.Queries.Single());
|
||||
Assert.Single(first.Theme!.SearchResults);
|
||||
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
Assert.Single(service.Queries);
|
||||
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
|
||||
var second = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
|
||||
Assert.Collection(
|
||||
service.Queries,
|
||||
query => Assert.Equal(string.Empty, query),
|
||||
query => Assert.Equal(string.Empty, query));
|
||||
Assert.Single(second.Theme!.SearchResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpertEachActivation_LoadsOriginalFullListOncePerActivation()
|
||||
{
|
||||
// Original evidence: Control/UC6.cs UC6_Load calls Data_Load, whose query
|
||||
// reads the complete EXPERT_LIST without a filter.
|
||||
var service = new CountingExpertService();
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
expertWorkflow: new LegacyExpertWorkflowController(service));
|
||||
|
||||
var first = await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
|
||||
Assert.Equal(string.Empty, service.Queries.Single());
|
||||
Assert.Single(first.Expert!.Search.Results);
|
||||
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
|
||||
Assert.Single(service.Queries);
|
||||
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
|
||||
var second = await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
|
||||
|
||||
Assert.Collection(
|
||||
service.Queries,
|
||||
query => Assert.Equal(string.Empty, query),
|
||||
query => Assert.Equal(string.Empty, query));
|
||||
Assert.Single(second.Expert!.Search.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DragSwapThatActivatesTheme_UsesTheSameNativeInitialLoad()
|
||||
{
|
||||
var service = new CountingThemeService();
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
themeWorkflow: new LegacyThemeWorkflow(service));
|
||||
|
||||
var state = await controller.SwapTabsAsync(
|
||||
LegacyOperatorTabId.Theme,
|
||||
LegacyOperatorTabId.Exchange);
|
||||
|
||||
Assert.Equal(string.Empty, service.Queries.Single());
|
||||
Assert.Single(state.Theme!.SearchResults);
|
||||
Assert.Equal(
|
||||
LegacyOperatorTabId.Theme,
|
||||
Assert.Single(state.Tabs!, tab => tab.IsActive).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SupersededThemeActivation_DoesNotCommitAndRetriesWhenNeeded()
|
||||
{
|
||||
var service = new DelayedThemeService();
|
||||
var controller = new LegacyOperatorController(
|
||||
Lookup(),
|
||||
themeWorkflow: new LegacyThemeWorkflow(service));
|
||||
|
||||
var firstActivation = controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
await service.FirstRequestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
controller.SelectTab(LegacyOperatorTabId.Index);
|
||||
service.ReleaseFirstRequest.TrySetResult();
|
||||
var superseded = await firstActivation;
|
||||
|
||||
Assert.Equal(
|
||||
LegacyOperatorTabId.Index,
|
||||
Assert.Single(superseded.Tabs!, tab => tab.IsActive).Id);
|
||||
|
||||
var retried = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
|
||||
Assert.Equal(2, service.CallCount);
|
||||
Assert.Single(retried.Theme!.SearchResults);
|
||||
}
|
||||
|
||||
private static ILegacyStockLookup Lookup() =>
|
||||
new StaticLookup(new LegacyStockSearchData(string.Empty, [], []));
|
||||
|
||||
private sealed class StaticLookup : ILegacyStockLookup
|
||||
{
|
||||
private readonly LegacyStockSearchData _data;
|
||||
|
||||
public StaticLookup(LegacyStockSearchData data)
|
||||
{
|
||||
_data = data;
|
||||
}
|
||||
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string text,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(_data);
|
||||
}
|
||||
|
||||
private sealed class CountingThemeService : IThemeSelectionService
|
||||
{
|
||||
private static readonly ThemeSelectionIdentity Identity = new(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"00000001",
|
||||
"Theme A");
|
||||
|
||||
public List<string> Queries { get; } = [];
|
||||
|
||||
public Task<ThemeSearchResult> SearchAsync(
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queries.Add(query);
|
||||
return Task.FromResult(CreateThemeResult(query, nxtSession));
|
||||
}
|
||||
|
||||
public Task<ThemePreviewResult> PreviewAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new ThemePreviewResult(identity, DateTimeOffset.UtcNow, [], false));
|
||||
|
||||
public static ThemeSearchResult CreateThemeResult(
|
||||
string query,
|
||||
ThemeSession nxtSession) => new(
|
||||
query,
|
||||
nxtSession,
|
||||
DateTimeOffset.UtcNow,
|
||||
[new ThemeSelectorItem(Identity, DataSourceKind.Oracle)],
|
||||
false);
|
||||
}
|
||||
|
||||
private sealed class DelayedThemeService : IThemeSelectionService
|
||||
{
|
||||
private int _callCount;
|
||||
|
||||
public TaskCompletionSource FirstRequestStarted { get; } = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource ReleaseFirstRequest { get; } = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int CallCount => Volatile.Read(ref _callCount);
|
||||
|
||||
public async Task<ThemeSearchResult> SearchAsync(
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Increment(ref _callCount) == 1)
|
||||
{
|
||||
FirstRequestStarted.TrySetResult();
|
||||
await ReleaseFirstRequest.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return CountingThemeService.CreateThemeResult(query, nxtSession);
|
||||
}
|
||||
|
||||
public Task<ThemePreviewResult> PreviewAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new ThemePreviewResult(identity, DateTimeOffset.UtcNow, [], false));
|
||||
}
|
||||
|
||||
private sealed class CountingExpertService : IExpertSelectionService
|
||||
{
|
||||
private static readonly ExpertSelectionIdentity Identity = new("0001", "Expert A");
|
||||
|
||||
public List<string> Queries { get; } = [];
|
||||
|
||||
public Task<ExpertSearchResult> SearchAsync(
|
||||
string query = "",
|
||||
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queries.Add(query);
|
||||
return Task.FromResult(new ExpertSearchResult(
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
[new ExpertSelectorItem(Identity, DataSourceKind.Oracle)],
|
||||
false));
|
||||
}
|
||||
|
||||
public Task<ExpertPreviewResult> PreviewAsync(
|
||||
ExpertSelectionIdentity identity,
|
||||
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
new ExpertPreviewResult(identity, DateTimeOffset.UtcNow, [], false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
using System.Collections;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyThemeWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public void Actions_AreClosedImmutableAndMatchCoreSceneCatalog()
|
||||
{
|
||||
Assert.Equal(
|
||||
["five-row-current", "five-row-expected", "six-row-current", "twelve-row-current"],
|
||||
LegacyThemeWorkflow.Actions.Select(action => action.Id));
|
||||
Assert.Equal([5, 5, 6, 12], LegacyThemeWorkflow.Actions.Select(action => action.PageSize));
|
||||
Assert.Equal(
|
||||
[
|
||||
LegacyThemeValueKind.Current,
|
||||
LegacyThemeValueKind.Expected,
|
||||
LegacyThemeValueKind.Current,
|
||||
LegacyThemeValueKind.Current
|
||||
],
|
||||
LegacyThemeWorkflow.Actions.Select(action => action.ValueKind));
|
||||
|
||||
foreach (var action in LegacyThemeWorkflow.Actions)
|
||||
{
|
||||
var scene = Assert.Single(
|
||||
LegacySceneCatalog.Definitions,
|
||||
definition => definition.BuilderKey == action.BuilderKey);
|
||||
Assert.Single(action.Aliases);
|
||||
Assert.Equal(action.Code, action.Aliases[0]);
|
||||
Assert.Contains(action.Code, scene.CutAliases);
|
||||
Assert.Equal(action.PageSize, (int)scene.PageSize);
|
||||
}
|
||||
|
||||
var mutable = Assert.IsAssignableFrom<IList<LegacyThemeActionDefinition>>(
|
||||
LegacyThemeWorkflow.Actions);
|
||||
Assert.True(mutable.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() => mutable.RemoveAt(0));
|
||||
Assert.Null(LegacyThemeWorkflow.FindAction(null));
|
||||
Assert.Null(LegacyThemeWorkflow.FindAction("unknown"));
|
||||
Assert.Equal(
|
||||
["INPUT_ORDER", "GAIN_DESC", "GAIN_ASC"],
|
||||
LegacyThemeWorkflow.Sorts.Select(sort => sort.Id));
|
||||
Assert.Equal(
|
||||
["입력순", "상승률순", "하락률순"],
|
||||
LegacyThemeWorkflow.Sorts.Select(sort => sort.Label));
|
||||
var mutableSorts = Assert.IsAssignableFrom<IList<LegacyThemeSortDefinition>>(
|
||||
LegacyThemeWorkflow.Sorts);
|
||||
Assert.True(mutableSorts.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() => mutableSorts.RemoveAt(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialSnapshot_IsImmutableAndUsesOriginalInputOrderDefault()
|
||||
{
|
||||
var workflow = new LegacyThemeWorkflow(new FakeThemeSelectionService());
|
||||
|
||||
var snapshot = workflow.CreateInitial(ThemeSession.AfterMarket);
|
||||
|
||||
Assert.Equal(0, snapshot.Revision);
|
||||
Assert.Equal("", snapshot.Query);
|
||||
Assert.Equal(ThemeSession.AfterMarket, snapshot.NxtSession);
|
||||
Assert.Equal(LegacyThemeSort.InputOrder, snapshot.Sort);
|
||||
Assert.Equal("INPUT_ORDER", snapshot.SortId);
|
||||
Assert.Empty(snapshot.SearchResults);
|
||||
Assert.Null(snapshot.SelectedResultId);
|
||||
Assert.Null(snapshot.SelectedTheme);
|
||||
Assert.Empty(snapshot.PreviewItems);
|
||||
Assert.All(snapshot.ActionStates, state =>
|
||||
{
|
||||
Assert.False(state.IsAvailable);
|
||||
Assert.Equal("theme-not-selected", state.UnavailableReason);
|
||||
});
|
||||
Assert.All(
|
||||
typeof(LegacyThemeWorkflowSnapshot).GetProperties(),
|
||||
property => Assert.False(property.CanWrite));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
workflow.CreateInitial(ThemeSession.NotApplicable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_NormalizesQueryPreservesExplicitIdentityAndOrdersRows()
|
||||
{
|
||||
var service = new FakeThemeSelectionService
|
||||
{
|
||||
SearchHandler = (query, session) => SearchResult(
|
||||
query,
|
||||
session,
|
||||
Selector(ThemeMarket.Nxt, session, "Robotics", "00000003"),
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Zulu", "00000002"),
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Alpha", "00000001"))
|
||||
};
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var initial = workflow.CreateInitial();
|
||||
|
||||
var result = await workflow.SearchAsync(
|
||||
initial,
|
||||
" ai ",
|
||||
ThemeSession.PreMarket);
|
||||
|
||||
var call = Assert.Single(service.SearchCalls);
|
||||
Assert.Equal("ai", call.Query);
|
||||
Assert.Equal(ThemeSession.PreMarket, call.Session);
|
||||
Assert.Equal(LegacyThemeWorkflow.MaximumSearchResults, call.MaximumResults);
|
||||
Assert.Equal(1, result.Revision);
|
||||
Assert.Equal("ai", result.Query);
|
||||
Assert.Collection(
|
||||
result.SearchResults,
|
||||
row =>
|
||||
{
|
||||
Assert.Equal("theme-result-001", row.ResultId);
|
||||
Assert.Equal("Alpha", row.DisplayTitle);
|
||||
Assert.Equal(ThemeMarket.Krx, row.Market);
|
||||
Assert.Equal(ThemeSession.NotApplicable, row.Session);
|
||||
Assert.Equal(DataSourceKind.Oracle, row.Source);
|
||||
},
|
||||
row => Assert.Equal("Zulu", row.DisplayTitle),
|
||||
row =>
|
||||
{
|
||||
Assert.Equal("theme-result-003", row.ResultId);
|
||||
Assert.Equal("Robotics(NXT)", row.DisplayTitle);
|
||||
Assert.Equal(ThemeMarket.Nxt, row.Market);
|
||||
Assert.Equal(ThemeSession.PreMarket, row.Session);
|
||||
Assert.Equal(DataSourceKind.MariaDb, row.Source);
|
||||
});
|
||||
Assert.Empty(initial.SearchResults);
|
||||
var mutable = Assert.IsAssignableFrom<IList<LegacyThemeSearchRow>>(result.SearchResults);
|
||||
Assert.True(mutable.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() => mutable.RemoveAt(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_RejectsUnsafeInputBeforeCallingService()
|
||||
{
|
||||
var service = new FakeThemeSelectionService();
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var snapshot = workflow.CreateInitial();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
workflow.SearchAsync(snapshot, null!, ThemeSession.PreMarket));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
workflow.SearchAsync(snapshot, "bad\u0000query", ThemeSession.PreMarket));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
workflow.SearchAsync(
|
||||
snapshot,
|
||||
new string('A', LegacyThemeSelectionService.MaximumQueryLength + 1),
|
||||
ThemeSession.PreMarket));
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
workflow.SearchAsync(snapshot, "AI", ThemeSession.NotApplicable));
|
||||
|
||||
Assert.Empty(service.SearchCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_RejectsUntrustedServiceEnvelopeProviderAndIdentity()
|
||||
{
|
||||
var service = new FakeThemeSelectionService
|
||||
{
|
||||
SearchHandler = (_, session) => SearchResult(
|
||||
"wrong-query",
|
||||
session,
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"))
|
||||
};
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var snapshot = workflow.CreateInitial();
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
|
||||
|
||||
service.SearchHandler = (query, session) => SearchResult(
|
||||
query,
|
||||
session,
|
||||
new ThemeSelectorItem(
|
||||
Identity(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"),
|
||||
DataSourceKind.MariaDb));
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
|
||||
|
||||
service.SearchHandler = (query, session) => SearchResult(
|
||||
query,
|
||||
session,
|
||||
Selector(ThemeMarket.Nxt, ThemeSession.AfterMarket, "AI", "00000001"));
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
|
||||
|
||||
service.SearchHandler = (query, session) => SearchResult(
|
||||
query,
|
||||
session,
|
||||
Selector(ThemeMarket.Nxt, session, "AI(NXT)", "00000001"));
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
|
||||
|
||||
service.SearchHandler = (query, session) => SearchResult(
|
||||
query,
|
||||
session,
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"),
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000002"));
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectAsync_UsesOpaqueResultIdAndCopiesValidatedPreview()
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var previewItems = new List<ThemeItemPreview>
|
||||
{
|
||||
new(0, "P005930", "삼성전자"),
|
||||
new(2, "D035720", "카카오")
|
||||
};
|
||||
var service = ServiceWithTheme(identity, previewItems);
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var search = await workflow.SearchAsync(
|
||||
workflow.CreateInitial(),
|
||||
"AI",
|
||||
ThemeSession.PreMarket);
|
||||
|
||||
var selected = await workflow.SelectAsync(search, "theme-result-001");
|
||||
|
||||
var call = Assert.Single(service.PreviewCalls);
|
||||
Assert.Equal(identity, call.Identity);
|
||||
Assert.Equal(LegacyThemeWorkflow.MaximumPreviewItems, call.MaximumItems);
|
||||
Assert.Equal(2, selected.Revision);
|
||||
Assert.Equal("theme-result-001", selected.SelectedResultId);
|
||||
Assert.Equal(
|
||||
new LegacySelectedThemeSummary(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
2,
|
||||
false),
|
||||
selected.SelectedTheme);
|
||||
Assert.Equal(["삼성전자", "카카오"], selected.PreviewItems.Select(row => row.ItemName));
|
||||
Assert.Null(search.SelectedTheme);
|
||||
Assert.All(selected.ActionStates, state =>
|
||||
{
|
||||
Assert.True(state.IsAvailable);
|
||||
Assert.Null(state.UnavailableReason);
|
||||
});
|
||||
|
||||
previewItems.Add(new ThemeItemPreview(3, "P000001", "Later mutation"));
|
||||
Assert.Equal(2, selected.PreviewItems.Count);
|
||||
var mutable = Assert.IsAssignableFrom<IList<LegacyThemePreviewRow>>(selected.PreviewItems);
|
||||
Assert.True(mutable.IsReadOnly);
|
||||
Assert.Throws<NotSupportedException>(() => mutable.RemoveAt(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_ReplacesResultsAndClearsOnlyTheNewSnapshotSelection()
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var selected = workflow.SelectSort(
|
||||
await SearchAndSelectAsync(workflow),
|
||||
"GAIN_ASC");
|
||||
service.SearchHandler = (query, session) => SearchResult(query, session);
|
||||
|
||||
var replaced = await workflow.SearchAsync(
|
||||
selected,
|
||||
"new",
|
||||
ThemeSession.AfterMarket);
|
||||
|
||||
Assert.Equal("new", replaced.Query);
|
||||
Assert.Equal(ThemeSession.AfterMarket, replaced.NxtSession);
|
||||
Assert.Equal(LegacyThemeSort.GainRateAscending, replaced.Sort);
|
||||
Assert.Empty(replaced.SearchResults);
|
||||
Assert.Null(replaced.SelectedTheme);
|
||||
Assert.Empty(replaced.PreviewItems);
|
||||
Assert.NotNull(selected.SelectedTheme);
|
||||
Assert.Single(selected.PreviewItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectAsync_RejectsUnknownResultWithoutCallingPreview()
|
||||
{
|
||||
var service = new FakeThemeSelectionService
|
||||
{
|
||||
SearchHandler = (query, session) => SearchResult(
|
||||
query,
|
||||
session,
|
||||
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"))
|
||||
};
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var search = await workflow.SearchAsync(
|
||||
workflow.CreateInitial(),
|
||||
"",
|
||||
ThemeSession.PreMarket);
|
||||
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||
workflow.SelectAsync(search, "theme-result-999"));
|
||||
|
||||
Assert.Empty(service.PreviewCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectAsync_RejectsUntrustedPreviewEnvelopeAndItems()
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var search = await workflow.SearchAsync(
|
||||
workflow.CreateInitial(),
|
||||
"",
|
||||
ThemeSession.PreMarket);
|
||||
|
||||
service.PreviewHandler = _ => new ThemePreviewResult(
|
||||
identity with { ThemeCode = "00000002" },
|
||||
DateTimeOffset.UtcNow,
|
||||
[new(0, "P005930", "삼성전자")],
|
||||
false);
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SelectAsync(search, "theme-result-001"));
|
||||
|
||||
service.PreviewHandler = _ => PreviewResult(
|
||||
identity,
|
||||
[new(0, "X005930", "잘못된 시장")]);
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SelectAsync(search, "theme-result-001"));
|
||||
|
||||
service.PreviewHandler = _ => PreviewResult(
|
||||
identity,
|
||||
[new(1, "P005930", "A"), new(1, "D035720", "B")]);
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SelectAsync(search, "theme-result-001"));
|
||||
|
||||
service.PreviewHandler = _ => PreviewResult(
|
||||
identity,
|
||||
CreateItems(239, ThemeMarket.Krx),
|
||||
isTruncated: true);
|
||||
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
||||
workflow.SelectAsync(search, "theme-result-001"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectSort_UsesClosedIdsAndPreservesImmutableSelection()
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var selected = await SearchAndSelectAsync(workflow);
|
||||
|
||||
var changed = workflow.SelectSort(selected, "GAIN_DESC");
|
||||
|
||||
Assert.Equal(LegacyThemeSort.GainRateDescending, changed.Sort);
|
||||
Assert.Equal("GAIN_DESC", changed.SortId);
|
||||
Assert.Equal(selected.Revision + 1, changed.Revision);
|
||||
Assert.Equal(LegacyThemeSort.InputOrder, selected.Sort);
|
||||
Assert.Equal(selected.SelectedTheme, changed.SelectedTheme);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
workflow.SelectSort(selected, "gain_desc"));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
workflow.SelectSort(selected, " GAIN_DESC "));
|
||||
Assert.Equal("입력순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.InputOrder));
|
||||
Assert.Equal("상승률순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.GainRateDescending));
|
||||
Assert.Equal("하락률순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.GainRateAscending));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("INPUT_ORDER", "입력순")]
|
||||
[InlineData("GAIN_DESC", "상승률순")]
|
||||
[InlineData("GAIN_ASC", "하락률순")]
|
||||
public async Task MaterializePlaylistDraft_KrxBuildsAllFourClosedActions(
|
||||
string sortId,
|
||||
string sortLabel)
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var service = ServiceWithTheme(identity, CreateItems(13, ThemeMarket.Krx));
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var selected = workflow.SelectSort(await SearchAndSelectAsync(workflow), sortId);
|
||||
|
||||
foreach (var action in LegacyThemeWorkflow.Actions)
|
||||
{
|
||||
var draft = workflow.MaterializePlaylistDraft(selected, action.Id);
|
||||
|
||||
Assert.Equal(action.Id, draft.ActionId);
|
||||
Assert.Equal(action.BuilderKey, draft.BuilderKey);
|
||||
Assert.Equal(action.Code, draft.Code);
|
||||
Assert.Equal(action.Aliases, draft.Aliases);
|
||||
Assert.Equal("AI", draft.Title);
|
||||
Assert.Equal($"{action.Label} · {sortLabel}", draft.Detail);
|
||||
Assert.Equal("plate", draft.Category);
|
||||
Assert.Equal("theme", draft.Market);
|
||||
Assert.Equal("PAGED_THEME", draft.Selection.GroupCode);
|
||||
Assert.Equal("AI", draft.Selection.Subject);
|
||||
Assert.Equal(sortId, draft.Selection.GraphicType);
|
||||
Assert.Equal(
|
||||
action.ValueKind == LegacyThemeValueKind.Expected ? "EXPECTED" : "CURRENT",
|
||||
draft.Selection.Subtype);
|
||||
Assert.Equal("00000001", draft.Selection.DataCode);
|
||||
Assert.Equal(action.PageSize, draft.PageSize);
|
||||
Assert.Equal($"1/{draft.PagePlan.PageCount}", draft.PagePlan.InitialPageText);
|
||||
Assert.InRange(draft.PagePlan.PageCount, 1, 20);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("INPUT_ORDER")]
|
||||
[InlineData("GAIN_DESC")]
|
||||
[InlineData("GAIN_ASC")]
|
||||
public async Task MaterializePlaylistDraft_NxtKeepsSessionAndBlocksExpected(
|
||||
string sortId)
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Nxt,
|
||||
ThemeSession.AfterMarket,
|
||||
"Robotics",
|
||||
"00000002");
|
||||
var service = ServiceWithTheme(identity, CreateItems(7, ThemeMarket.Nxt));
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var selected = workflow.SelectSort(
|
||||
await SearchAndSelectAsync(workflow, ThemeSession.AfterMarket),
|
||||
sortId);
|
||||
|
||||
foreach (var actionId in new[]
|
||||
{
|
||||
"five-row-current", "six-row-current", "twelve-row-current"
|
||||
})
|
||||
{
|
||||
var draft = workflow.MaterializePlaylistDraft(selected, actionId);
|
||||
Assert.Equal("Robotics(NXT)", draft.Title);
|
||||
Assert.Equal(
|
||||
new LegacyThemePlaylistSelection(
|
||||
"PAGED_NXT_THEME",
|
||||
"Robotics(NXT)",
|
||||
"AFTER_MARKET",
|
||||
sortId,
|
||||
"00000002"),
|
||||
draft.Selection);
|
||||
Assert.True(workflow.GetActionState(selected, actionId).IsAvailable);
|
||||
}
|
||||
|
||||
var expectedState = workflow.GetActionState(selected, "five-row-expected");
|
||||
Assert.False(expectedState.IsAvailable);
|
||||
Assert.Equal("expected-theme-is-krx-only", expectedState.UnavailableReason);
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.MaterializePlaylistDraft(selected, "five-row-expected"));
|
||||
Assert.Contains("expected-price", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("five-row-current", 1, 1, 1, false)]
|
||||
[InlineData("five-row-current", 5, 5, 1, false)]
|
||||
[InlineData("five-row-current", 6, 6, 2, false)]
|
||||
[InlineData("five-row-current", 100, 100, 20, false)]
|
||||
[InlineData("five-row-current", 101, 100, 20, true)]
|
||||
[InlineData("five-row-expected", 101, 100, 20, true)]
|
||||
[InlineData("six-row-current", 120, 120, 20, false)]
|
||||
[InlineData("six-row-current", 121, 120, 20, true)]
|
||||
[InlineData("twelve-row-current", 239, 239, 20, false)]
|
||||
[InlineData("twelve-row-current", 240, 240, 20, false)]
|
||||
public async Task MaterializePlaylistDraft_CalculatesPageNWithTwentyPageMaximum(
|
||||
string actionId,
|
||||
int itemCount,
|
||||
int accessibleCount,
|
||||
int pageCount,
|
||||
bool truncated)
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var service = ServiceWithTheme(identity, CreateItems(itemCount, ThemeMarket.Krx));
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var selected = await SearchAndSelectAsync(workflow);
|
||||
|
||||
var page = workflow.MaterializePlaylistDraft(selected, actionId).PagePlan;
|
||||
|
||||
Assert.Equal(itemCount, page.PreviewItemCount);
|
||||
Assert.Equal(accessibleCount, page.AccessibleItemCount);
|
||||
Assert.Equal(pageCount, page.PageCount);
|
||||
Assert.Equal(20, page.MaximumPageCount);
|
||||
Assert.Equal(truncated, page.IsTruncated);
|
||||
Assert.Equal($"1/{pageCount}", page.InitialPageText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaterializePlaylistDraft_PropagatesServiceTruncationAtPageTwenty()
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var service = ServiceWithTheme(
|
||||
identity,
|
||||
CreateItems(240, ThemeMarket.Krx),
|
||||
previewIsTruncated: true);
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var selected = await SearchAndSelectAsync(workflow);
|
||||
|
||||
var page = workflow.MaterializePlaylistDraft(
|
||||
selected,
|
||||
"twelve-row-current").PagePlan;
|
||||
|
||||
Assert.Equal(20, page.PageCount);
|
||||
Assert.True(page.IsTruncated);
|
||||
Assert.True(selected.PreviewIsTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaterializePlaylistDraft_RejectsMissingEmptyUnknownAndForeignState()
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var service = ServiceWithTheme(identity, []);
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
var initial = workflow.CreateInitial();
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.MaterializePlaylistDraft(initial, "five-row-current"));
|
||||
|
||||
var selected = await SearchAndSelectAsync(workflow);
|
||||
Assert.All(selected.ActionStates, state =>
|
||||
{
|
||||
Assert.False(state.IsAvailable);
|
||||
Assert.Equal("theme-preview-is-empty", state.UnavailableReason);
|
||||
});
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.MaterializePlaylistDraft(selected, "five-row-current"));
|
||||
Assert.Throws<KeyNotFoundException>(() =>
|
||||
workflow.MaterializePlaylistDraft(selected, "s5074"));
|
||||
|
||||
var other = new LegacyThemeWorkflow(new FakeThemeSelectionService());
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
other.MaterializePlaylistDraft(selected, "five-row-current"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAndSelect_RespectPreCanceledTokensWithoutServiceCalls()
|
||||
{
|
||||
var identity = Identity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"AI",
|
||||
"00000001");
|
||||
var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
workflow.SearchAsync(
|
||||
workflow.CreateInitial(),
|
||||
"",
|
||||
ThemeSession.PreMarket,
|
||||
cancellation.Token));
|
||||
Assert.Empty(service.SearchCalls);
|
||||
|
||||
var search = await workflow.SearchAsync(
|
||||
workflow.CreateInitial(),
|
||||
"",
|
||||
ThemeSession.PreMarket);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
workflow.SelectAsync(search, "theme-result-001", cancellation.Token));
|
||||
Assert.Empty(service.PreviewCalls);
|
||||
}
|
||||
|
||||
private static async Task<LegacyThemeWorkflowSnapshot> SearchAndSelectAsync(
|
||||
LegacyThemeWorkflow workflow,
|
||||
ThemeSession session = ThemeSession.PreMarket)
|
||||
{
|
||||
var search = await workflow.SearchAsync(
|
||||
workflow.CreateInitial(session),
|
||||
"",
|
||||
session);
|
||||
return await workflow.SelectAsync(search, "theme-result-001");
|
||||
}
|
||||
|
||||
private static FakeThemeSelectionService ServiceWithTheme(
|
||||
ThemeSelectionIdentity identity,
|
||||
IReadOnlyList<ThemeItemPreview> items,
|
||||
bool previewIsTruncated = false) =>
|
||||
new()
|
||||
{
|
||||
SearchHandler = (query, session) => SearchResult(
|
||||
query,
|
||||
session,
|
||||
new ThemeSelectorItem(
|
||||
identity,
|
||||
identity.Market == ThemeMarket.Krx
|
||||
? DataSourceKind.Oracle
|
||||
: DataSourceKind.MariaDb)),
|
||||
PreviewHandler = _ => PreviewResult(identity, items, previewIsTruncated)
|
||||
};
|
||||
|
||||
private static ThemeSelectionIdentity Identity(
|
||||
ThemeMarket market,
|
||||
ThemeSession session,
|
||||
string title,
|
||||
string code) =>
|
||||
new(market, session, code, title);
|
||||
|
||||
private static ThemeSelectorItem Selector(
|
||||
ThemeMarket market,
|
||||
ThemeSession session,
|
||||
string title,
|
||||
string code) =>
|
||||
new(
|
||||
Identity(market, session, title, code),
|
||||
market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb);
|
||||
|
||||
private static ThemeSearchResult SearchResult(
|
||||
string query,
|
||||
ThemeSession session,
|
||||
params ThemeSelectorItem[] items) =>
|
||||
new(query, session, DateTimeOffset.UtcNow, items, false);
|
||||
|
||||
private static ThemePreviewResult PreviewResult(
|
||||
ThemeSelectionIdentity identity,
|
||||
IReadOnlyList<ThemeItemPreview> items,
|
||||
bool isTruncated = false) =>
|
||||
new(identity, DateTimeOffset.UtcNow, items, isTruncated);
|
||||
|
||||
private static IReadOnlyList<ThemeItemPreview> CreateItems(
|
||||
int count,
|
||||
ThemeMarket market)
|
||||
{
|
||||
var prefix = market == ThemeMarket.Krx ? 'P' : 'X';
|
||||
return Enumerable.Range(0, count)
|
||||
.Select(index => new ThemeItemPreview(
|
||||
index,
|
||||
$"{prefix}{index + 1:000000}",
|
||||
$"Item {index + 1}"))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private sealed record SearchCall(
|
||||
string Query,
|
||||
ThemeSession Session,
|
||||
int MaximumResults,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed record PreviewCall(
|
||||
ThemeSelectionIdentity Identity,
|
||||
int MaximumItems,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed class FakeThemeSelectionService : IThemeSelectionService
|
||||
{
|
||||
public Func<string, ThemeSession, ThemeSearchResult> SearchHandler { get; set; } =
|
||||
(query, session) => SearchResult(query, session);
|
||||
|
||||
public Func<ThemeSelectionIdentity, ThemePreviewResult> PreviewHandler { get; set; } =
|
||||
identity => PreviewResult(identity, []);
|
||||
|
||||
public List<SearchCall> SearchCalls { get; } = [];
|
||||
|
||||
public List<PreviewCall> PreviewCalls { get; } = [];
|
||||
|
||||
public Task<ThemeSearchResult> SearchAsync(
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SearchCalls.Add(new SearchCall(query, nxtSession, maximumResults, cancellationToken));
|
||||
return Task.FromResult(SearchHandler(query, nxtSession));
|
||||
}
|
||||
|
||||
public Task<ThemePreviewResult> PreviewAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PreviewCalls.Add(new PreviewCall(identity, maximumItems, cancellationToken));
|
||||
return Task.FromResult(PreviewHandler(identity));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyManualFinancialOperatorControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GraphE_result_double_click_fresh_loads_appends_once_and_closes_on_success()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1));
|
||||
var controller = Controller(service);
|
||||
var opened = await controller.OpenManualFinancialAsync(
|
||||
ManualFinancialScreenKind.Sales);
|
||||
var resultId = Assert.Single(opened.ManualFinancial!.Rows).ResultId;
|
||||
|
||||
var activated = await controller.ActivateManualFinancialResultAsync(resultId);
|
||||
|
||||
Assert.Null(activated.ManualFinancial);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
Assert.Equal("5080", Assert.Single(activated.Playlist).ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("activate-manual-financial-result", activated.CommandResult?.Command);
|
||||
Assert.Equal(resultId, activated.CommandResult?.TargetId);
|
||||
Assert.True(activated.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GraphE_result_double_click_rejects_identity_change_without_append_or_close()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1))
|
||||
{
|
||||
GetOverride = _ => Sales("Beta", 2)
|
||||
};
|
||||
var controller = Controller(service);
|
||||
var opened = await controller.OpenManualFinancialAsync(
|
||||
ManualFinancialScreenKind.Sales);
|
||||
var resultId = Assert.Single(opened.ManualFinancial!.Rows).ResultId;
|
||||
|
||||
var rejected = await controller.ActivateManualFinancialResultAsync(resultId);
|
||||
|
||||
Assert.NotNull(rejected.ManualFinancial);
|
||||
Assert.Empty(rejected.Playlist);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
Assert.NotNull(rejected.Dialog);
|
||||
Assert.False(rejected.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_GraphE_row_loads_verifies_and_adds_CSharp_owned_playlist_row()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1));
|
||||
var controller = Controller(service);
|
||||
|
||||
var opened = await controller.OpenManualFinancialAsync(
|
||||
ManualFinancialScreenKind.Sales);
|
||||
var rowId = Assert.Single(opened.ManualFinancial!.Rows).ResultId;
|
||||
var loaded = await controller.LoadManualFinancialAsync(rowId);
|
||||
|
||||
Assert.True(loaded.ManualFinancial!.CanMaterializePlaylist);
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
var added = controller.ActivateManualFinancialAction("sales");
|
||||
var playlist = Assert.Single(added.Playlist);
|
||||
var entry = playlist.ToPlayoutEntry();
|
||||
Assert.Equal("5080", entry.CutCode);
|
||||
var selection = Assert.IsType<LegacySceneSelection>(entry.Selection);
|
||||
Assert.Equal("Alpha", selection.Subject);
|
||||
Assert.Equal("SALES", selection.GraphicType);
|
||||
Assert.Equal("1/1", playlist.PageText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GraphE_find_loads_fresh_detail_and_stale_sort_generation_opens_modal()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1));
|
||||
var controller = Controller(service);
|
||||
var opened = await controller.OpenManualFinancialAsync(
|
||||
ManualFinancialScreenKind.Sales);
|
||||
var generation = opened.ManualFinancial!.Revision;
|
||||
|
||||
var found = await controller.FindManualFinancialAsync(
|
||||
"Alpha",
|
||||
LegacyManualFinancialFindDirection.Down,
|
||||
generation);
|
||||
|
||||
Assert.Equal("Alpha", found.ManualFinancial!.SelectedRecord!.Identity.StockName);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
var sorted = controller.ToggleManualFinancialNameSort(
|
||||
found.ManualFinancial.Revision);
|
||||
var stale = controller.ToggleManualFinancialNameSort(
|
||||
found.ManualFinancial.Revision);
|
||||
Assert.NotNull(stale.Dialog);
|
||||
Assert.Equal(
|
||||
sorted.ManualFinancial!.NameSortDirection,
|
||||
stale.ManualFinancial!.NameSortDirection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_does_not_write_until_explicit_save_and_fresh_readback_completes()
|
||||
{
|
||||
var service = new FakeManualService();
|
||||
var controller = Controller(service);
|
||||
await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.OperatingProfit);
|
||||
controller.BeginManualFinancialCreate();
|
||||
var searched = await controller.SearchManualFinancialStocksAsync("Alpha");
|
||||
controller.SelectManualFinancialStock(
|
||||
Assert.Single(searched.ManualFinancial!.StockCandidates).ResultId);
|
||||
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
var saved = await controller.SaveManualFinancialAsync(Profit("Alpha", 10));
|
||||
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
Assert.Equal(
|
||||
LegacyManualFinancialWorkflowStatus.WriteCommitted,
|
||||
saved.ManualFinancial!.Status);
|
||||
Assert.True(saved.ManualFinancial.CanMaterializePlaylist);
|
||||
Assert.DoesNotContain("password", saved.StatusMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_and_delete_all_are_only_reached_by_explicit_controller_methods()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1));
|
||||
var controller = Controller(service);
|
||||
var opened = await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.Sales);
|
||||
await controller.LoadManualFinancialAsync(
|
||||
Assert.Single(opened.ManualFinancial!.Rows).ResultId);
|
||||
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
var deleted = await controller.DeleteManualFinancialAsync(all: false);
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
Assert.Empty(deleted.ManualFinancial!.Rows);
|
||||
|
||||
var all = await controller.DeleteManualFinancialAsync(all: true);
|
||||
Assert.Equal(2, service.MutationCalls);
|
||||
Assert.Empty(all.ManualFinancial!.Rows);
|
||||
}
|
||||
|
||||
private static LegacyOperatorController Controller(FakeManualService service)
|
||||
{
|
||||
var workflow = new LegacyManualFinancialWorkflow(
|
||||
service,
|
||||
new FakeStockService(),
|
||||
new LegacyManualFinancialWriteSafetyState());
|
||||
return new LegacyOperatorController(
|
||||
new EmptyStockLookup(),
|
||||
manualFinancialWorkflow: workflow);
|
||||
}
|
||||
|
||||
private static ManualFinancialSnapshot Sales(string stockName, int version) =>
|
||||
new(
|
||||
new ManualSalesRecord(
|
||||
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName),
|
||||
Enumerable.Range(1, 6)
|
||||
.Select(index => new ManualQuarterValue($"{index}Q", index))
|
||||
.ToArray()),
|
||||
Version(version));
|
||||
|
||||
private static ManualOperatingProfitRecord Profit(string stockName, int offset) =>
|
||||
new(
|
||||
new ManualFinancialIdentity(
|
||||
ManualFinancialScreenKind.OperatingProfit,
|
||||
stockName),
|
||||
Enumerable.Range(1, 6)
|
||||
.Select(index => new ManualQuarterValue($"{index}Q", offset + index))
|
||||
.ToArray());
|
||||
|
||||
private static string Version(int value) => value.ToString("X64");
|
||||
|
||||
private sealed class EmptyStockLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new LegacyStockSearchData(query, [], []));
|
||||
}
|
||||
|
||||
private sealed class FakeStockService : IStockSearchService
|
||||
{
|
||||
public Task<StockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new StockSearchResult(
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
[new StockSearchItem(
|
||||
StockMarket.Kospi,
|
||||
DataSourceKind.Oracle,
|
||||
"Alpha",
|
||||
"000001")],
|
||||
false));
|
||||
}
|
||||
|
||||
private sealed class FakeManualService : IManualFinancialScreenService
|
||||
{
|
||||
private readonly Dictionary<(ManualFinancialScreenKind, string), ManualFinancialSnapshot>
|
||||
_rows = new();
|
||||
private int _version = 10;
|
||||
|
||||
internal FakeManualService(params ManualFinancialSnapshot[] rows)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
_rows[(row.Record.Identity.Screen, row.Record.Identity.StockName)] = row;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanMutate => true;
|
||||
|
||||
internal int MutationCalls { get; private set; }
|
||||
|
||||
internal int GetCalls { get; private set; }
|
||||
|
||||
internal Func<ManualFinancialIdentity, ManualFinancialSnapshot>? GetOverride
|
||||
{
|
||||
get;
|
||||
init;
|
||||
}
|
||||
|
||||
public Task<ManualFinancialSearchResult> SearchAsync(
|
||||
ManualFinancialScreenKind screen,
|
||||
string query = "",
|
||||
int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = _rows.Values
|
||||
.Where(row => row.Record.Identity.Screen == screen)
|
||||
.Where(row => query.Length == 0 ||
|
||||
row.Record.Identity.StockName.Contains(
|
||||
query,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.Take(maximumResults)
|
||||
.ToArray();
|
||||
return Task.FromResult(new ManualFinancialSearchResult(
|
||||
screen,
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
rows,
|
||||
false));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialSnapshot> GetAsync(
|
||||
ManualFinancialIdentity identity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetCalls++;
|
||||
return Task.FromResult(
|
||||
GetOverride?.Invoke(identity) ??
|
||||
_rows[(identity.Screen, identity.StockName)]);
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> CreateAsync(
|
||||
ManualFinancialRecord record,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MutationCalls++;
|
||||
_rows[(record.Identity.Screen, record.Identity.StockName)] =
|
||||
new ManualFinancialSnapshot(record, Version(++_version));
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.Create,
|
||||
record.Identity.Screen,
|
||||
record.Identity.StockName,
|
||||
1));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> UpdateAsync(
|
||||
ManualFinancialSnapshot expected,
|
||||
ManualFinancialRecord replacement,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MutationCalls++;
|
||||
_rows[(replacement.Identity.Screen, replacement.Identity.StockName)] =
|
||||
new ManualFinancialSnapshot(replacement, Version(++_version));
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.Update,
|
||||
replacement.Identity.Screen,
|
||||
replacement.Identity.StockName,
|
||||
1));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> DeleteAsync(
|
||||
ManualFinancialSnapshot expected,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MutationCalls++;
|
||||
_rows.Remove((
|
||||
expected.Record.Identity.Screen,
|
||||
expected.Record.Identity.StockName));
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.DeleteOne,
|
||||
expected.Record.Identity.Screen,
|
||||
expected.Record.Identity.StockName,
|
||||
1));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> DeleteAllAsync(
|
||||
ManualFinancialScreenKind screen,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MutationCalls++;
|
||||
var keys = _rows.Keys.Where(key => key.Item1 == screen).ToArray();
|
||||
foreach (var key in keys)
|
||||
{
|
||||
_rows.Remove(key);
|
||||
}
|
||||
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.DeleteAll,
|
||||
screen,
|
||||
string.Empty,
|
||||
keys.Length));
|
||||
}
|
||||
|
||||
private static ManualFinancialMutationReceipt Receipt(
|
||||
ManualFinancialMutationKind kind,
|
||||
ManualFinancialScreenKind screen,
|
||||
string stockName,
|
||||
int affectedRows) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
kind,
|
||||
screen,
|
||||
stockName,
|
||||
DateTimeOffset.UtcNow,
|
||||
affectedRows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyManualFinancialWorkflowTests
|
||||
{
|
||||
public static TheoryData<
|
||||
ManualFinancialScreenKind,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string> Screens => new()
|
||||
{
|
||||
{
|
||||
ManualFinancialScreenKind.RevenueComposition,
|
||||
"revenue-composition",
|
||||
"주요매출 구성",
|
||||
"INPUT_PIE",
|
||||
"s5076",
|
||||
"5076",
|
||||
"REVENUE_COMPOSITION"
|
||||
},
|
||||
{
|
||||
ManualFinancialScreenKind.GrowthMetrics,
|
||||
"growth-metrics",
|
||||
"성장성 지표",
|
||||
"INPUT_GROW",
|
||||
"s5079",
|
||||
"5079",
|
||||
"GROWTH_METRICS"
|
||||
},
|
||||
{
|
||||
ManualFinancialScreenKind.Sales,
|
||||
"sales",
|
||||
"매출액",
|
||||
"INPUT_SELL",
|
||||
"s5080",
|
||||
"5080",
|
||||
"SALES"
|
||||
},
|
||||
{
|
||||
ManualFinancialScreenKind.OperatingProfit,
|
||||
"operating-profit",
|
||||
"영업이익",
|
||||
"INPUT_PROFIT",
|
||||
"s5081",
|
||||
"5081",
|
||||
"OPERATING_PROFIT"
|
||||
}
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Screens))]
|
||||
public void Registry_matches_every_GraphE_table_and_cut_contract(
|
||||
ManualFinancialScreenKind screen,
|
||||
string actionId,
|
||||
string label,
|
||||
string table,
|
||||
string builder,
|
||||
string code,
|
||||
string graphic)
|
||||
{
|
||||
var definition = LegacyManualFinancialWorkflow.GetScreen(screen);
|
||||
|
||||
Assert.Equal(4, LegacyManualFinancialWorkflow.Screens.Count);
|
||||
Assert.Equal(actionId, definition.ActionId);
|
||||
Assert.Equal(label, definition.Label);
|
||||
Assert.Equal(table, definition.TableName);
|
||||
Assert.Equal(builder, definition.BuilderKey);
|
||||
Assert.Equal(code, definition.Code);
|
||||
Assert.Equal([code], definition.Aliases);
|
||||
Assert.Equal(graphic, definition.CanonicalGraphicType);
|
||||
Assert.Equal("DELETE_ALL_" + table, definition.DeleteAllConfirmationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_then_load_uses_opaque_identity_and_fresh_exact_read()
|
||||
{
|
||||
var original = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1);
|
||||
var refreshed = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha", 100), 2);
|
||||
var service = new FakeManualFinancialService(original)
|
||||
{
|
||||
GetOverride = _ => refreshed
|
||||
};
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
|
||||
state = await workflow.SearchAsync(state, " Alpha ");
|
||||
|
||||
Assert.Equal("Alpha", state.Query);
|
||||
Assert.Equal(LegacyManualFinancialWorkflow.MaximumListResults, service.LastMaximumResults);
|
||||
var row = Assert.Single(state.Rows);
|
||||
Assert.StartsWith("mf-row-", row.ResultId, StringComparison.Ordinal);
|
||||
Assert.Null(state.SelectedRecord);
|
||||
Assert.False(state.IsDetailFresh);
|
||||
|
||||
state = await workflow.LoadAsync(state, row.ResultId);
|
||||
|
||||
Assert.True(state.IsDetailFresh);
|
||||
Assert.Equal(Version(2), state.SelectedRowVersion);
|
||||
Assert.Same(state.SelectedRecord, state.SelectedRecord);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
Assert.Equal("Alpha", service.LastGetIdentity?.StockName);
|
||||
Assert.Equal(Version(2), Assert.Single(state.Rows).RowVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GraphE_name_header_and_directional_find_are_CSharp_authoritative()
|
||||
{
|
||||
var service = new FakeManualFinancialService(
|
||||
Snapshot(Record(ManualFinancialScreenKind.Sales, "Gamma"), 1),
|
||||
Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 2),
|
||||
Snapshot(Record(ManualFinancialScreenKind.Sales, "Beta"), 3));
|
||||
var workflow = Workflow(service);
|
||||
var state = await workflow.SearchAsync(
|
||||
workflow.CreateInitial(ManualFinancialScreenKind.Sales));
|
||||
|
||||
Assert.Equal(["Alpha", "Beta", "Gamma"], state.Rows.Select(row => row.StockName));
|
||||
Assert.Equal(LegacyManualFinancialNameSortDirection.Ascending,
|
||||
state.NameSortDirection);
|
||||
|
||||
state = workflow.FindByName(
|
||||
state,
|
||||
"a",
|
||||
LegacyManualFinancialFindDirection.Down,
|
||||
state.Revision);
|
||||
Assert.Equal("Alpha", state.Rows.Single(row =>
|
||||
row.ResultId == state.SelectedRowId).StockName);
|
||||
state = workflow.FindByName(
|
||||
state,
|
||||
"a",
|
||||
LegacyManualFinancialFindDirection.Down,
|
||||
state.Revision);
|
||||
Assert.Equal("Beta", state.Rows.Single(row =>
|
||||
row.ResultId == state.SelectedRowId).StockName);
|
||||
state = workflow.FindByName(
|
||||
state,
|
||||
"a",
|
||||
LegacyManualFinancialFindDirection.Up,
|
||||
state.Revision);
|
||||
Assert.Equal("Alpha", state.Rows.Single(row =>
|
||||
row.ResultId == state.SelectedRowId).StockName);
|
||||
|
||||
var staleGeneration = state.Revision;
|
||||
state = workflow.ToggleNameSort(state, staleGeneration);
|
||||
Assert.Equal(["Gamma", "Beta", "Alpha"], state.Rows.Select(row => row.StockName));
|
||||
Assert.Equal(LegacyManualFinancialNameSortDirection.Descending,
|
||||
state.NameSortDirection);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.ToggleNameSort(state, staleGeneration));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Screens))]
|
||||
public async Task Create_save_readback_and_action_id_only_draft_work_for_all_modes(
|
||||
ManualFinancialScreenKind screen,
|
||||
string actionId,
|
||||
string label,
|
||||
string table,
|
||||
string builder,
|
||||
string code,
|
||||
string graphic)
|
||||
{
|
||||
_ = table;
|
||||
var service = new FakeManualFinancialService();
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(screen);
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
var request = workflow.CreateCreateRequest(state, Record(screen, "Alpha"));
|
||||
|
||||
Assert.Equal(ManualFinancialMutationKind.Create, request.Kind);
|
||||
Assert.Equal(screen, request.Screen);
|
||||
Assert.Equal("Alpha", request.StockName);
|
||||
Assert.Matches("^[a-f0-9]{32}$", request.RequestId);
|
||||
|
||||
state = await workflow.ExecuteAsync(state, request);
|
||||
var draft = workflow.MaterializePlaylistDraft(state, actionId);
|
||||
|
||||
Assert.Equal(LegacyManualFinancialWorkflowStatus.WriteCommitted, state.Status);
|
||||
Assert.True(state.IsDetailFresh);
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
Assert.Equal(request.RequestId, state.LastRequestId);
|
||||
Assert.Equal(builder, draft.BuilderKey);
|
||||
Assert.Equal(code, draft.Code);
|
||||
Assert.Equal([code], draft.Aliases);
|
||||
Assert.Equal(label, draft.Detail);
|
||||
Assert.Equal("manual-financial", draft.Category);
|
||||
Assert.Equal(graphic, draft.CutSelection.Selection.GraphicType);
|
||||
Assert.Equal(builder, draft.CutSelection.BuilderKey);
|
||||
Assert.Equal(code, draft.CutSelection.CutCode);
|
||||
Assert.Equal(1, draft.CutSelection.CurrentPage);
|
||||
Assert.Equal(1, draft.CutSelection.TotalPages);
|
||||
Assert.Equal(state.SelectedRowVersion, draft.OperatorSnapshot.RowVersion);
|
||||
Assert.Equal("KOSPI", draft.Market);
|
||||
Assert.Equal("000001", draft.StockCode);
|
||||
Assert.False(draft.IsNxt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_request_carries_selected_rowVersion_and_rebinds_fresh_version()
|
||||
{
|
||||
var service = new FakeManualFinancialService(
|
||||
Snapshot(Record(ManualFinancialScreenKind.GrowthMetrics, "Alpha"), 1));
|
||||
var workflow = Workflow(service);
|
||||
var state = await LoadAndVerifyAsync(
|
||||
workflow,
|
||||
ManualFinancialScreenKind.GrowthMetrics,
|
||||
"Alpha");
|
||||
var replacement = Record(ManualFinancialScreenKind.GrowthMetrics, "Alpha", 100);
|
||||
var request = workflow.CreateUpdateRequest(state, replacement);
|
||||
|
||||
Assert.Equal(Version(1), request.ExpectedRowVersion);
|
||||
state = await workflow.ExecuteAsync(state, request);
|
||||
|
||||
Assert.Equal(Version(11), state.SelectedRowVersion);
|
||||
Assert.Equal(Version(1), service.LastExpected?.RowVersion);
|
||||
Assert.Same(replacement.Identity, replacement.Identity);
|
||||
Assert.NotSame(replacement, request.Replacement);
|
||||
var draft = workflow.MaterializePlaylistDraft(state, "growth-metrics");
|
||||
Assert.Equal(Version(11), draft.OperatorSnapshot.RowVersion);
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
Assert.Equal(2, service.GetCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_one_uses_optimistic_snapshot_and_clears_selection()
|
||||
{
|
||||
var service = new FakeManualFinancialService(
|
||||
Snapshot(Record(ManualFinancialScreenKind.OperatingProfit, "Alpha"), 1));
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.OperatingProfit);
|
||||
state = await workflow.SearchAsync(state);
|
||||
state = await workflow.LoadAsync(state, Assert.Single(state.Rows).ResultId);
|
||||
var request = workflow.CreateDeleteRequest(state);
|
||||
|
||||
Assert.Equal(Version(1), request.ExpectedRowVersion);
|
||||
state = await workflow.ExecuteAsync(state, request);
|
||||
|
||||
Assert.Empty(state.Rows);
|
||||
Assert.Null(state.SelectedRecord);
|
||||
Assert.False(state.IsDetailFresh);
|
||||
Assert.Equal(Version(1), service.LastExpected?.RowVersion);
|
||||
Assert.Equal(ManualFinancialMutationKind.DeleteOne, state.LastReceipt?.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_all_requires_the_fixed_screen_token_and_accepts_zero_rows()
|
||||
{
|
||||
var service = new FakeManualFinancialService(
|
||||
Snapshot(Record(ManualFinancialScreenKind.RevenueComposition, "Alpha"), 1));
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.RevenueComposition);
|
||||
state = await workflow.SearchAsync(state);
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
workflow.CreateDeleteAllRequest(state, "DELETE_ALL_INPUT_SELL"));
|
||||
var request = workflow.CreateDeleteAllRequest(
|
||||
state,
|
||||
state.Definition.DeleteAllConfirmationToken);
|
||||
service.DeleteAllAffectedRows = 0;
|
||||
state = await workflow.ExecuteAsync(state, request);
|
||||
|
||||
Assert.Equal("DELETE_ALL_INPUT_PIE", request.ConfirmationToken);
|
||||
Assert.Empty(state.Rows);
|
||||
Assert.Equal(0, state.LastReceipt?.AffectedRows);
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exact_stock_resolution_rejects_duplicate_master_names()
|
||||
{
|
||||
var service = new FakeManualFinancialService();
|
||||
var stockSearch = new FakeStockSearchService(
|
||||
new(StockMarket.Kospi, DataSourceKind.Oracle, "Alpha", "000001"),
|
||||
new(StockMarket.Kosdaq, DataSourceKind.Oracle, "Alpha", "000002"));
|
||||
var workflow = new LegacyManualFinancialWorkflow(
|
||||
service,
|
||||
stockSearch,
|
||||
new LegacyManualFinancialWriteSafetyState());
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await workflow.SearchStocksAsync(state, "Alpha");
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
workflow.SelectStock(state, state.StockCandidates[0].ResultId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_and_playlist_require_detail_bound_stock_verification()
|
||||
{
|
||||
var service = new FakeManualFinancialService(
|
||||
Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1));
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await workflow.SearchAsync(state);
|
||||
state = await workflow.LoadAsync(state, Assert.Single(state.Rows).ResultId);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.CreateUpdateRequest(state, Record(ManualFinancialScreenKind.Sales, "Alpha")));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.MaterializePlaylistDraft(state, "sales"));
|
||||
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
Assert.NotNull(workflow.CreateUpdateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha", 10)));
|
||||
Assert.NotNull(workflow.MaterializePlaylistDraft(state, "sales"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutcomeUnknown_latches_process_write_quarantine_and_never_retries()
|
||||
{
|
||||
var service = new FakeManualFinancialService
|
||||
{
|
||||
MutationException = new ManualFinancialMutationException("timeout", outcomeUnknown: true)
|
||||
};
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
var request = workflow.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha"));
|
||||
var original = state;
|
||||
|
||||
state = await workflow.ExecuteAsync(state, request);
|
||||
|
||||
Assert.Equal(LegacyManualFinancialWorkflowStatus.OutcomeUnknown, state.Status);
|
||||
Assert.True(state.WritesQuarantined);
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.CreateCreateRequest(
|
||||
original,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha")));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
workflow.ExecuteAsync(original, request));
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
Assert.True(workflow.CreateInitial(ManualFinancialScreenKind.Sales).WritesQuarantined);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.MaterializePlaylistDraft(state, "sales"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Malformed_write_receipt_is_quarantined_as_unknown()
|
||||
{
|
||||
var service = new FakeManualFinancialService
|
||||
{
|
||||
ReceiptOverride = (kind, screen, name, affectedRows) => new(
|
||||
Guid.NewGuid(),
|
||||
ManualFinancialMutationKind.DeleteOne,
|
||||
screen,
|
||||
name,
|
||||
DateTimeOffset.UtcNow,
|
||||
affectedRows)
|
||||
};
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
var request = workflow.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha"));
|
||||
|
||||
state = await workflow.ExecuteAsync(state, request);
|
||||
|
||||
Assert.Equal(LegacyManualFinancialWorkflowStatus.OutcomeUnknown, state.Status);
|
||||
Assert.True(state.WritesQuarantined);
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
Assert.Equal(0, service.GetCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Known_rollback_is_not_quarantined_but_request_stays_one_shot()
|
||||
{
|
||||
var service = new FakeManualFinancialService
|
||||
{
|
||||
MutationException = new ManualFinancialMutationRejectedException(
|
||||
ManualFinancialMutationRejectionReason.DuplicateIdentity,
|
||||
"rolled back")
|
||||
};
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
var request = workflow.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha"));
|
||||
var source = state;
|
||||
|
||||
state = await workflow.ExecuteAsync(state, request);
|
||||
|
||||
Assert.Equal(LegacyManualFinancialWorkflowStatus.WriteRejected, state.Status);
|
||||
Assert.False(state.WritesQuarantined);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
workflow.ExecuteAsync(source, request));
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sibling_requests_from_one_snapshot_cannot_execute_twice()
|
||||
{
|
||||
var service = new FakeManualFinancialService();
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
var first = workflow.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha"));
|
||||
var sibling = workflow.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha"));
|
||||
|
||||
_ = await workflow.ExecuteAsync(state, first);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
workflow.ExecuteAsync(state, sibling));
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pre_write_fresh_snapshot_cannot_materialize_after_update()
|
||||
{
|
||||
var service = new FakeManualFinancialService(
|
||||
Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1));
|
||||
var workflow = Workflow(service);
|
||||
var stale = await LoadAndVerifyAsync(
|
||||
workflow,
|
||||
ManualFinancialScreenKind.Sales,
|
||||
"Alpha");
|
||||
var request = workflow.CreateUpdateRequest(
|
||||
stale,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha", 100));
|
||||
|
||||
var current = await workflow.ExecuteAsync(stale, request);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.MaterializePlaylistDraft(stale, "sales"));
|
||||
Assert.NotNull(workflow.MaterializePlaylistDraft(current, "sales"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shared_process_safety_quarantines_new_workflow_instances()
|
||||
{
|
||||
var safety = new LegacyManualFinancialWriteSafetyState();
|
||||
var firstService = new FakeManualFinancialService
|
||||
{
|
||||
MutationException = new ManualFinancialMutationException("timeout", outcomeUnknown: true)
|
||||
};
|
||||
var first = Workflow(firstService, safety);
|
||||
var state = first.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(first, state, "Alpha");
|
||||
state = await first.ExecuteAsync(
|
||||
state,
|
||||
first.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha")));
|
||||
Assert.True(state.WritesQuarantined);
|
||||
|
||||
var second = Workflow(new FakeManualFinancialService(), safety);
|
||||
var secondState = second.CreateInitial(ManualFinancialScreenKind.OperatingProfit);
|
||||
|
||||
Assert.True(secondState.WritesQuarantined);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
second.CreateDeleteAllRequest(
|
||||
secondState,
|
||||
secondState.Definition.DeleteAllConfirmationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Committed_write_without_readback_never_enables_action()
|
||||
{
|
||||
var service = new FakeManualFinancialService();
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
var request = workflow.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha"));
|
||||
service.GetException = new InvalidOperationException("read unavailable");
|
||||
|
||||
state = await workflow.ExecuteAsync(state, request);
|
||||
|
||||
Assert.Equal(LegacyManualFinancialWorkflowStatus.ReadBackRequired, state.Status);
|
||||
Assert.NotNull(state.LastReceipt);
|
||||
Assert.Null(state.SelectedRecord);
|
||||
Assert.False(state.WritesQuarantined);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.MaterializePlaylistDraft(state, "sales"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Request_is_revision_bound_and_foreign_snapshots_fail_closed()
|
||||
{
|
||||
var firstService = new FakeManualFinancialService();
|
||||
var first = Workflow(firstService);
|
||||
var state = first.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(first, state, "Alpha");
|
||||
var request = first.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha"));
|
||||
var changed = first.BeginCreate(state);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
first.ExecuteAsync(changed, request));
|
||||
Assert.Equal(0, firstService.MutationCalls);
|
||||
|
||||
var second = Workflow(new FakeManualFinancialService());
|
||||
Assert.Throws<ArgumentException>(() => second.BeginCreate(state));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
second.SearchAsync(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_only_configuration_blocks_request_creation_before_executor()
|
||||
{
|
||||
var service = new FakeManualFinancialService { CanMutate = false };
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workflow.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha")));
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_or_ambiguous_list_envelopes_are_rejected()
|
||||
{
|
||||
var duplicate = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1);
|
||||
var service = new FakeManualFinancialService
|
||||
{
|
||||
SearchOverride = (_, query) => new ManualFinancialSearchResult(
|
||||
ManualFinancialScreenKind.Sales,
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
[duplicate, duplicate],
|
||||
false)
|
||||
};
|
||||
var workflow = Workflow(service);
|
||||
|
||||
await Assert.ThrowsAsync<LegacyManualFinancialWorkflowDataException>(() =>
|
||||
workflow.SearchAsync(workflow.CreateInitial(ManualFinancialScreenKind.Sales)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DTOs_and_snapshots_clone_mutable_input_collections()
|
||||
{
|
||||
var quarters = Enumerable.Range(1, 6)
|
||||
.Select(index => new ManualQuarterValue($"{index}Q", index))
|
||||
.ToArray();
|
||||
var record = new ManualSalesRecord(
|
||||
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Alpha"),
|
||||
quarters);
|
||||
var service = new FakeManualFinancialService();
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
var request = workflow.CreateCreateRequest(state, record);
|
||||
|
||||
quarters[0] = new ManualQuarterValue("BAD", 999);
|
||||
|
||||
var cloned = Assert.IsType<ManualSalesRecord>(request.Record);
|
||||
Assert.Equal("1Q", cloned.Quarters[0].Quarter);
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
((IList<ManualQuarterValue>)cloned.Quarters).Add(new("7Q", 7)));
|
||||
|
||||
state = await workflow.ExecuteAsync(state, request);
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
((IList<LegacyManualFinancialListRow>)state.Rows).Clear());
|
||||
var selected = Assert.IsType<ManualSalesRecord>(state.SelectedRecord);
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
((IList<ManualQuarterValue>)selected.Quarters).Clear());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_action_id_and_cross_screen_action_never_materialize()
|
||||
{
|
||||
var service = new FakeManualFinancialService();
|
||||
var workflow = Workflow(service);
|
||||
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
|
||||
state = await VerifyStockAsync(workflow, state, "Alpha");
|
||||
state = await workflow.ExecuteAsync(
|
||||
state,
|
||||
workflow.CreateCreateRequest(
|
||||
state,
|
||||
Record(ManualFinancialScreenKind.Sales, "Alpha")));
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
workflow.MaterializePlaylistDraft(state, "s5080"));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
workflow.MaterializePlaylistDraft(state, "operating-profit"));
|
||||
}
|
||||
|
||||
private static LegacyManualFinancialWorkflow Workflow(
|
||||
FakeManualFinancialService service,
|
||||
LegacyManualFinancialWriteSafetyState? writeSafety = null) =>
|
||||
new(
|
||||
service,
|
||||
new FakeStockSearchService(
|
||||
new StockSearchItem(
|
||||
StockMarket.Kospi,
|
||||
DataSourceKind.Oracle,
|
||||
"Alpha",
|
||||
"000001")),
|
||||
writeSafety ?? new LegacyManualFinancialWriteSafetyState());
|
||||
|
||||
private static async Task<LegacyManualFinancialWorkflowSnapshot> VerifyStockAsync(
|
||||
LegacyManualFinancialWorkflow workflow,
|
||||
LegacyManualFinancialWorkflowSnapshot state,
|
||||
string stockName)
|
||||
{
|
||||
state = await workflow.SearchStocksAsync(state, stockName);
|
||||
return workflow.SelectStock(state, Assert.Single(state.StockCandidates).ResultId);
|
||||
}
|
||||
|
||||
private static async Task<LegacyManualFinancialWorkflowSnapshot> LoadAndVerifyAsync(
|
||||
LegacyManualFinancialWorkflow workflow,
|
||||
ManualFinancialScreenKind screen,
|
||||
string stockName)
|
||||
{
|
||||
var state = workflow.CreateInitial(screen);
|
||||
state = await workflow.SearchAsync(state, stockName);
|
||||
state = await workflow.LoadAsync(state, Assert.Single(state.Rows).ResultId);
|
||||
return await VerifyStockAsync(workflow, state, stockName);
|
||||
}
|
||||
|
||||
private static ManualFinancialSnapshot Snapshot(ManualFinancialRecord record, int version) =>
|
||||
new(record, Version(version));
|
||||
|
||||
private static string Version(int value) => value.ToString("X64");
|
||||
|
||||
private static ManualFinancialRecord Record(
|
||||
ManualFinancialScreenKind screen,
|
||||
string stockName,
|
||||
int offset = 0)
|
||||
{
|
||||
var identity = new ManualFinancialIdentity(screen, stockName);
|
||||
return screen switch
|
||||
{
|
||||
ManualFinancialScreenKind.RevenueComposition =>
|
||||
new ManualRevenueCompositionRecord(
|
||||
identity,
|
||||
"2026.07",
|
||||
[
|
||||
new ManualRevenueSlice("반도체", "25", 25),
|
||||
new ManualRevenueSlice("모바일", "20", 20),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]),
|
||||
ManualFinancialScreenKind.GrowthMetrics =>
|
||||
new ManualGrowthMetricsRecord(
|
||||
identity,
|
||||
new ManualGrowthSeriesValues(1 + offset, 2, 3, 4),
|
||||
new ManualGrowthSeriesValues(5, 6, 7, 8),
|
||||
new ManualGrowthSeriesValues(9, 10, 11, 12),
|
||||
new ManualGrowthSeriesValues(13, 14, 15, 16),
|
||||
new ManualGrowthPeriodLabels("1Q", "2Q", "3Q", "4Q")),
|
||||
ManualFinancialScreenKind.Sales =>
|
||||
new ManualSalesRecord(
|
||||
identity,
|
||||
Enumerable.Range(1, 6)
|
||||
.Select(index => new ManualQuarterValue(
|
||||
$"{index}Q",
|
||||
offset + index))
|
||||
.ToArray()),
|
||||
ManualFinancialScreenKind.OperatingProfit =>
|
||||
new ManualOperatingProfitRecord(
|
||||
identity,
|
||||
Enumerable.Range(1, 6)
|
||||
.Select(index => new ManualQuarterValue(
|
||||
$"{index}Q",
|
||||
offset + index))
|
||||
.ToArray()),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(screen))
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeStockSearchService : IStockSearchService
|
||||
{
|
||||
private readonly IReadOnlyList<StockSearchItem> _items;
|
||||
|
||||
internal FakeStockSearchService(params StockSearchItem[] items)
|
||||
{
|
||||
_items = items;
|
||||
}
|
||||
|
||||
public Task<StockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new StockSearchResult(
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
_items.Take(maximumResults).ToArray(),
|
||||
_items.Count > maximumResults));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeManualFinancialService : IManualFinancialScreenService
|
||||
{
|
||||
private readonly Dictionary<(ManualFinancialScreenKind Screen, string Name),
|
||||
ManualFinancialSnapshot> _rows = new();
|
||||
private int _nextVersion = 10;
|
||||
|
||||
internal FakeManualFinancialService(params ManualFinancialSnapshot[] rows)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
_rows[(row.Record.Identity.Screen, row.Record.Identity.StockName)] = row;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanMutate { get; set; } = true;
|
||||
|
||||
internal int MutationCalls { get; private set; }
|
||||
|
||||
internal int GetCalls { get; private set; }
|
||||
|
||||
internal int LastMaximumResults { get; private set; }
|
||||
|
||||
internal ManualFinancialIdentity? LastGetIdentity { get; private set; }
|
||||
|
||||
internal ManualFinancialSnapshot? LastExpected { get; private set; }
|
||||
|
||||
internal Exception? MutationException { get; set; }
|
||||
|
||||
internal Exception? GetException { get; set; }
|
||||
|
||||
internal Func<ManualFinancialIdentity, ManualFinancialSnapshot>? GetOverride { get; set; }
|
||||
|
||||
internal Func<ManualFinancialScreenKind, string, ManualFinancialSearchResult>?
|
||||
SearchOverride
|
||||
{ get; set; }
|
||||
|
||||
internal Func<
|
||||
ManualFinancialMutationKind,
|
||||
ManualFinancialScreenKind,
|
||||
string,
|
||||
int,
|
||||
ManualFinancialMutationReceipt>? ReceiptOverride
|
||||
{ get; set; }
|
||||
|
||||
internal int DeleteAllAffectedRows { get; set; }
|
||||
|
||||
public Task<ManualFinancialSearchResult> SearchAsync(
|
||||
ManualFinancialScreenKind screen,
|
||||
string query = "",
|
||||
int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
LastMaximumResults = maximumResults;
|
||||
if (SearchOverride is not null)
|
||||
{
|
||||
return Task.FromResult(SearchOverride(screen, query));
|
||||
}
|
||||
|
||||
var all = _rows.Values
|
||||
.Where(row => row.Record.Identity.Screen == screen)
|
||||
.Where(row => query.Length == 0 || row.Record.Identity.StockName.Contains(
|
||||
query,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(row => row.Record.Identity.StockName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return Task.FromResult(new ManualFinancialSearchResult(
|
||||
screen,
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
all.Take(maximumResults).ToArray(),
|
||||
all.Length > maximumResults));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialSnapshot> GetAsync(
|
||||
ManualFinancialIdentity identity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
GetCalls++;
|
||||
LastGetIdentity = identity;
|
||||
if (GetException is not null)
|
||||
{
|
||||
throw GetException;
|
||||
}
|
||||
|
||||
if (GetOverride is not null)
|
||||
{
|
||||
return Task.FromResult(GetOverride(identity));
|
||||
}
|
||||
|
||||
return Task.FromResult(_rows[(identity.Screen, identity.StockName)]);
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> CreateAsync(
|
||||
ManualFinancialRecord record,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
BeforeMutation(cancellationToken);
|
||||
var snapshot = new ManualFinancialSnapshot(record, Version(++_nextVersion));
|
||||
_rows[(record.Identity.Screen, record.Identity.StockName)] = snapshot;
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.Create,
|
||||
record.Identity.Screen,
|
||||
record.Identity.StockName,
|
||||
1));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> UpdateAsync(
|
||||
ManualFinancialSnapshot expected,
|
||||
ManualFinancialRecord replacement,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
BeforeMutation(cancellationToken);
|
||||
LastExpected = expected;
|
||||
_rows[(replacement.Identity.Screen, replacement.Identity.StockName)] =
|
||||
new ManualFinancialSnapshot(replacement, Version(++_nextVersion));
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.Update,
|
||||
replacement.Identity.Screen,
|
||||
replacement.Identity.StockName,
|
||||
1));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> DeleteAsync(
|
||||
ManualFinancialSnapshot expected,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
BeforeMutation(cancellationToken);
|
||||
LastExpected = expected;
|
||||
_rows.Remove((
|
||||
expected.Record.Identity.Screen,
|
||||
expected.Record.Identity.StockName));
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.DeleteOne,
|
||||
expected.Record.Identity.Screen,
|
||||
expected.Record.Identity.StockName,
|
||||
1));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> DeleteAllAsync(
|
||||
ManualFinancialScreenKind screen,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
BeforeMutation(cancellationToken);
|
||||
foreach (var key in _rows.Keys.Where(key => key.Screen == screen).ToArray())
|
||||
{
|
||||
_rows.Remove(key);
|
||||
}
|
||||
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.DeleteAll,
|
||||
screen,
|
||||
string.Empty,
|
||||
DeleteAllAffectedRows));
|
||||
}
|
||||
|
||||
private void BeforeMutation(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
MutationCalls++;
|
||||
if (MutationException is not null)
|
||||
{
|
||||
throw MutationException;
|
||||
}
|
||||
}
|
||||
|
||||
private ManualFinancialMutationReceipt Receipt(
|
||||
ManualFinancialMutationKind kind,
|
||||
ManualFinancialScreenKind screen,
|
||||
string stockName,
|
||||
int affectedRows) =>
|
||||
ReceiptOverride?.Invoke(kind, screen, stockName, affectedRows) ?? new(
|
||||
Guid.NewGuid(),
|
||||
kind,
|
||||
screen,
|
||||
stockName,
|
||||
DateTimeOffset.UtcNow,
|
||||
affectedRows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyManualListsWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public void Construction_is_side_effect_free_and_snapshot_has_no_file_or_stock_code_authority()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
|
||||
|
||||
var snapshot = workflow.Current;
|
||||
|
||||
Assert.Equal(0, store.ReadNetCalls);
|
||||
Assert.Equal(0, store.ReadViCalls);
|
||||
Assert.Equal(0, store.WriteNetCalls);
|
||||
Assert.Equal(0, store.WriteViCalls);
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyManualListsSnapshot).GetProperties(),
|
||||
property => property.Name.Contains("Path", StringComparison.OrdinalIgnoreCase) ||
|
||||
property.Name.Contains("Version", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyManualViItemView).GetProperties(),
|
||||
property => property.Name.Contains("Code", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(snapshot.IsAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FSell_saves_once_rereads_exact_five_rows_and_materializes_s5025()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
|
||||
await workflow.OpenNetSellAsync(S5025ManualAudience.Foreign);
|
||||
var row = workflow.Current.NetRows[0];
|
||||
workflow.SetNetSellCell(row.RowId, LegacyManualNetSellField.LeftName, "Alpha");
|
||||
|
||||
var saved = await workflow.SaveNetSellAsync();
|
||||
var draft = workflow.CreateNetSellPlaylistDraft();
|
||||
|
||||
Assert.Equal(1, store.WriteNetCalls);
|
||||
Assert.Equal(2, store.ReadNetCalls);
|
||||
Assert.True(saved.NetRowsAreFresh);
|
||||
Assert.Equal("Alpha", saved.NetRows[0].LeftName);
|
||||
Assert.Equal("5025", draft.SceneAlias);
|
||||
Assert.Equal("FOREIGN", draft.Selection.GroupCode);
|
||||
Assert.Equal("MANUAL_NET_SELL", draft.Selection.GraphicType);
|
||||
Assert.Equal("1/1", draft.PageText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FSell_ambiguous_failure_uses_one_readback_and_quarantines_on_mismatch()
|
||||
{
|
||||
var store = new FakeStore { ThrowNetWrite = true, PersistBeforeNetThrow = false };
|
||||
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
|
||||
await workflow.OpenNetSellAsync(S5025ManualAudience.Individual);
|
||||
workflow.SetNetSellCell(
|
||||
workflow.Current.NetRows[0].RowId,
|
||||
LegacyManualNetSellField.LeftName,
|
||||
"Changed");
|
||||
|
||||
var result = await workflow.SaveNetSellAsync();
|
||||
var second = await workflow.SaveNetSellAsync();
|
||||
|
||||
Assert.True(result.WritesQuarantined);
|
||||
Assert.True(second.WritesQuarantined);
|
||||
Assert.Equal(1, store.WriteNetCalls);
|
||||
Assert.Equal(2, store.ReadNetCalls);
|
||||
Assert.False(result.CanSave);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VI_search_uses_opaque_ids_and_save_binds_fresh_versioned_s5074_reference()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
|
||||
await workflow.OpenViAsync();
|
||||
var searched = await workflow.SearchViAsync("삼성");
|
||||
var result = Assert.Single(searched.SearchResults);
|
||||
Assert.DoesNotContain("005930", result.ResultId, StringComparison.Ordinal);
|
||||
var selected = workflow.SelectViSearchResult(result.ResultId);
|
||||
Assert.Equal(result.ResultId, selected.SelectedSearchResultId);
|
||||
Assert.Empty(selected.ViItems);
|
||||
workflow.AddViSearchResult(result.ResultId);
|
||||
|
||||
var saved = await workflow.SaveViAsync();
|
||||
var draft = workflow.CreateViPlaylistDraft();
|
||||
|
||||
Assert.Equal(1, store.WriteViCalls);
|
||||
Assert.Equal(3, store.ReadViCalls);
|
||||
Assert.True(saved.ViItemsAreFresh);
|
||||
Assert.Equal(1, saved.ViPageCount);
|
||||
Assert.Equal("5074", draft.SceneAlias);
|
||||
Assert.Equal("PAGED_VI", draft.Selection.GroupCode);
|
||||
Assert.Equal("@MBN_VI_SNAPSHOT_V1", draft.Selection.Subject);
|
||||
Assert.Equal(store.Vi.RowVersion, draft.Selection.DataCode);
|
||||
Assert.DoesNotContain("005930", draft.StockName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VI_stale_preflight_never_writes_or_retries()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
|
||||
await workflow.OpenViAsync();
|
||||
var searched = await workflow.SearchViAsync("삼성");
|
||||
workflow.AddViSearchResult(searched.SearchResults.Single().ResultId);
|
||||
store.Vi = store.Vi with { RowVersion = new string('b', 64) };
|
||||
|
||||
var result = await workflow.SaveViAsync();
|
||||
|
||||
Assert.Equal(0, store.WriteViCalls);
|
||||
Assert.False(result.ViItemsAreFresh);
|
||||
Assert.False(result.WritesQuarantined);
|
||||
Assert.Contains("변경", result.StatusMessage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_outcome_unknown_latches_process_workflow_quarantine()
|
||||
{
|
||||
var store = new FakeStore { ImportOutcomeUnknown = true };
|
||||
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
|
||||
|
||||
var result = await workflow.ImportAsync();
|
||||
|
||||
Assert.True(result.WritesQuarantined);
|
||||
Assert.False(result.CanSave);
|
||||
Assert.Equal(1, store.ImportCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Operator_controller_appends_only_native_materialized_manual_rows()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.OpenManualNetSellAsync(S5025ManualAudience.Institution);
|
||||
var row = controller.Current.ManualLists!.NetRows[0];
|
||||
controller.SetManualNetSellCell(
|
||||
row.RowId,
|
||||
LegacyManualNetSellField.RightAmount,
|
||||
"-3,000");
|
||||
await controller.SaveManualNetSellAsync();
|
||||
|
||||
var result = controller.AddManualNetSellPlaylistRow();
|
||||
var playlist = Assert.Single(result.Playlist);
|
||||
|
||||
Assert.Equal("5025", playlist.SceneAlias);
|
||||
Assert.Equal("INSTITUTION", playlist.PlayoutSelection!.GroupCode);
|
||||
Assert.Equal("MANUAL_NET_SELL", playlist.PlayoutSelection.GraphicType);
|
||||
Assert.Equal("1/1", playlist.PageText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Original_ok_saves_verifies_adds_one_cut_and_closes_the_editor()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.OpenManualNetSellAsync(S5025ManualAudience.Foreign);
|
||||
|
||||
var result = await controller.ConfirmManualListsAsync();
|
||||
|
||||
Assert.Equal(1, store.WriteNetCalls);
|
||||
Assert.Equal(2, store.ReadNetCalls);
|
||||
Assert.False(result.ManualLists!.IsOpen);
|
||||
var row = Assert.Single(result.Playlist);
|
||||
Assert.Equal("5025", row.SceneAlias);
|
||||
Assert.Equal("FOREIGN", row.PlayoutSelection!.GroupCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Original_ok_on_unknown_write_keeps_editor_open_and_never_adds_a_cut()
|
||||
{
|
||||
var store = new FakeStore
|
||||
{
|
||||
ThrowNetWrite = true,
|
||||
PersistBeforeNetThrow = false
|
||||
};
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.OpenManualNetSellAsync(S5025ManualAudience.Individual);
|
||||
var row = controller.Current.ManualLists!.NetRows[0];
|
||||
controller.SetManualNetSellCell(
|
||||
row.RowId,
|
||||
LegacyManualNetSellField.LeftAmount,
|
||||
"999");
|
||||
|
||||
var result = await controller.ConfirmManualListsAsync();
|
||||
|
||||
Assert.Equal(1, store.WriteNetCalls);
|
||||
Assert.Empty(result.Playlist);
|
||||
Assert.True(result.ManualLists!.IsOpen);
|
||||
Assert.True(result.ManualLists.WritesQuarantined);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fixed-245", S5025ManualAudience.Individual)]
|
||||
[InlineData("fixed-246", S5025ManualAudience.Foreign)]
|
||||
[InlineData("fixed-247", S5025ManualAudience.Institution)]
|
||||
public async Task Original_manual_net_sell_tree_leaves_open_the_matching_editor(
|
||||
string actionId,
|
||||
S5025ManualAudience audience)
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
|
||||
|
||||
var result = await controller.ActivateFixedActionAsync(actionId);
|
||||
|
||||
Assert.True(result.ManualLists!.IsOpen);
|
||||
Assert.Equal(LegacyManualListScreen.NetSell, result.ManualLists.Screen);
|
||||
Assert.Equal(audience, result.ManualLists.Audience);
|
||||
Assert.Empty(result.Playlist);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fixed-262")]
|
||||
[InlineData("fixed-293")]
|
||||
[InlineData("fixed-320")]
|
||||
public async Task Original_manual_vi_tree_leaves_open_the_vi_editor(string actionId)
|
||||
{
|
||||
var store = new FakeStore
|
||||
{
|
||||
Vi = new LegacyManualViStoreSnapshot(
|
||||
Enumerable.Range(1, 9)
|
||||
.Select(index => new LegacyManualViStoredItem(
|
||||
$"P{index:D6}",
|
||||
$"VI종목{index}"))
|
||||
.ToArray(),
|
||||
new string('a', 64))
|
||||
};
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
|
||||
|
||||
var result = await controller.ActivateFixedActionAsync(actionId);
|
||||
|
||||
Assert.True(result.ManualLists!.IsOpen);
|
||||
Assert.Equal(LegacyManualListScreen.Vi, result.ManualLists.Screen);
|
||||
Assert.Equal(9, result.ManualLists.ViItems.Count);
|
||||
Assert.Equal(2, result.ManualLists.ViPageCount);
|
||||
Assert.True(result.ManualLists.ViItemsAreFresh);
|
||||
Assert.True(result.ManualLists.CanSave);
|
||||
Assert.True(result.ManualLists.CanMaterializePlaylist);
|
||||
Assert.Empty(result.Playlist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fixed_parent_stages_three_FSell_children_and_commits_once_in_original_order()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
|
||||
var section = LegacyFixedActionCatalog.Default
|
||||
.GetSections(LegacyFixedMarket.Index)[6];
|
||||
|
||||
var first = await controller.ActivateFixedSection(6);
|
||||
|
||||
Assert.Empty(first.Playlist);
|
||||
Assert.Equal("fixed-245", first.FixedSectionBatch!.PendingActionId);
|
||||
Assert.Equal(1, first.FixedSectionBatch.ManualStepNumber);
|
||||
Assert.Equal(3, first.FixedSectionBatch.ManualStepCount);
|
||||
Assert.Equal(S5025ManualAudience.Individual, first.ManualLists!.Audience);
|
||||
|
||||
var second = await controller.ConfirmManualListsAsync();
|
||||
Assert.Empty(second.Playlist);
|
||||
Assert.Equal("fixed-246", second.FixedSectionBatch!.PendingActionId);
|
||||
Assert.Equal(S5025ManualAudience.Foreign, second.ManualLists!.Audience);
|
||||
|
||||
var third = await controller.ConfirmManualListsAsync();
|
||||
Assert.Empty(third.Playlist);
|
||||
Assert.Equal("fixed-247", third.FixedSectionBatch!.PendingActionId);
|
||||
Assert.Equal(S5025ManualAudience.Institution, third.ManualLists!.Audience);
|
||||
|
||||
var completed = await controller.ConfirmManualListsAsync();
|
||||
|
||||
Assert.Null(completed.FixedSectionBatch);
|
||||
Assert.False(completed.ManualLists!.IsOpen);
|
||||
Assert.Equal(3, store.WriteNetCalls);
|
||||
Assert.Equal(section.Actions.Count, completed.Playlist.Count);
|
||||
Assert.Equal(
|
||||
section.Actions.Select(action => action.Label),
|
||||
completed.Playlist.Select(row => row.StockName));
|
||||
Assert.Equal(
|
||||
new[] { "INDIVIDUAL", "FOREIGN", "INSTITUTION" },
|
||||
completed.Playlist.TakeLast(3).Select(row => row.PlayoutSelection!.GroupCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fixed_parent_cancel_discards_all_playlist_staging_but_keeps_known_manual_save()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
|
||||
await controller.ActivateFixedSection(6);
|
||||
await controller.ConfirmManualListsAsync();
|
||||
|
||||
var cancelled = controller.CloseManualLists();
|
||||
|
||||
Assert.Equal(1, store.WriteNetCalls);
|
||||
Assert.Empty(cancelled.Playlist);
|
||||
Assert.Null(cancelled.FixedSectionBatch);
|
||||
Assert.False(cancelled.ManualLists!.IsOpen);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Warning, cancelled.StatusKind);
|
||||
Assert.Contains("아무 항목도 추가하지 않았습니다", cancelled.StatusMessage,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fixed_parent_outcome_unknown_keeps_same_step_and_never_retries_or_appends()
|
||||
{
|
||||
var store = new FakeStore
|
||||
{
|
||||
ThrowNetWrite = true,
|
||||
PersistBeforeNetThrow = false
|
||||
};
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
|
||||
var opened = await controller.ActivateFixedSection(6);
|
||||
controller.SetManualNetSellCell(
|
||||
opened.ManualLists!.NetRows[0].RowId,
|
||||
LegacyManualNetSellField.LeftAmount,
|
||||
"999");
|
||||
|
||||
var unknown = await controller.ConfirmManualListsAsync();
|
||||
var blockedRepeat = await controller.ConfirmManualListsAsync();
|
||||
|
||||
Assert.Equal(1, store.WriteNetCalls);
|
||||
Assert.Empty(unknown.Playlist);
|
||||
Assert.Empty(blockedRepeat.Playlist);
|
||||
Assert.Equal("fixed-245", blockedRepeat.FixedSectionBatch!.PendingActionId);
|
||||
Assert.True(blockedRepeat.ManualLists!.IsOpen);
|
||||
Assert.True(blockedRepeat.ManualLists.WritesQuarantined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fixed_parent_VI_child_resumes_and_commits_at_its_original_child_position()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
|
||||
var section = LegacyFixedActionCatalog.Default
|
||||
.GetSections(LegacyFixedMarket.Index)[7];
|
||||
var manualIndex = section.Actions.ToList().FindIndex(action => action.Id == "fixed-262");
|
||||
var opened = await controller.ActivateFixedSection(7);
|
||||
var searched = await controller.SearchManualViAsync("삼성");
|
||||
controller.AddManualViSearchResult(
|
||||
Assert.Single(searched.ManualLists!.SearchResults).ResultId);
|
||||
|
||||
var completed = await controller.ConfirmManualListsAsync();
|
||||
|
||||
Assert.Null(completed.FixedSectionBatch);
|
||||
Assert.Equal(section.Actions.Count, completed.Playlist.Count);
|
||||
Assert.Equal(
|
||||
section.Actions.Take(manualIndex).Select(action => action.Label),
|
||||
completed.Playlist.Take(manualIndex).Select(row => row.StockName));
|
||||
Assert.Equal(
|
||||
section.Actions.Skip(manualIndex + 1).Select(action => action.Label),
|
||||
completed.Playlist.Skip(manualIndex + 1).Select(row => row.StockName));
|
||||
Assert.Equal("5074", completed.Playlist[manualIndex].SceneAlias);
|
||||
Assert.Equal("PAGED_VI", completed.Playlist[manualIndex].PlayoutSelection!.GroupCode);
|
||||
Assert.Equal(1, store.WriteViCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fixed_parent_modal_contract_blocks_tab_and_manual_target_switches()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var lookup = new FakeLookup();
|
||||
var controller = new LegacyOperatorController(
|
||||
lookup,
|
||||
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
|
||||
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
|
||||
await controller.ActivateFixedSection(6);
|
||||
|
||||
var obsoleteSave = await controller.SaveManualNetSellAsync();
|
||||
var obsoleteAdd = controller.AddManualNetSellPlaylistRow();
|
||||
var switchedManual = await controller.OpenManualNetSellAsync(
|
||||
S5025ManualAudience.Institution);
|
||||
var switchedTab = await controller.SelectTabAsync(LegacyOperatorTabId.Exchange);
|
||||
|
||||
Assert.Equal(0, store.WriteNetCalls);
|
||||
Assert.Empty(obsoleteSave.Playlist);
|
||||
Assert.Empty(obsoleteAdd.Playlist);
|
||||
Assert.Equal(S5025ManualAudience.Individual, switchedManual.ManualLists!.Audience);
|
||||
Assert.Equal(S5025ManualAudience.Individual, switchedTab.ManualLists!.Audience);
|
||||
Assert.Equal(
|
||||
LegacyOperatorTabId.Index,
|
||||
Assert.Single(switchedTab.Tabs!, tab => tab.IsActive).Id);
|
||||
Assert.Empty(switchedTab.Playlist);
|
||||
}
|
||||
|
||||
private sealed class FakeLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new LegacyStockSearchData(
|
||||
query,
|
||||
["삼성전자"],
|
||||
[new LegacyStockIdentity(
|
||||
LegacyDomesticStockMarket.Kospi,
|
||||
"삼성전자",
|
||||
"005930")]));
|
||||
}
|
||||
|
||||
private sealed class FakeStore : ILegacyManualListStore
|
||||
{
|
||||
private readonly Dictionary<S5025ManualAudience, IReadOnlyList<S5025TrustedManualRow>>
|
||||
_net = Enum.GetValues<S5025ManualAudience>().ToDictionary(
|
||||
audience => audience,
|
||||
_ => (IReadOnlyList<S5025TrustedManualRow>)Rows());
|
||||
|
||||
public LegacyManualListStoreAvailability Availability { get; } =
|
||||
new(true, "READY", null);
|
||||
|
||||
public LegacyManualViStoreSnapshot Vi { get; set; } = new(
|
||||
Array.Empty<LegacyManualViStoredItem>(),
|
||||
new string('a', 64));
|
||||
|
||||
public int ReadNetCalls { get; private set; }
|
||||
public int WriteNetCalls { get; private set; }
|
||||
public int ReadViCalls { get; private set; }
|
||||
public int WriteViCalls { get; private set; }
|
||||
public int ImportCalls { get; private set; }
|
||||
public bool ThrowNetWrite { get; init; }
|
||||
public bool PersistBeforeNetThrow { get; init; }
|
||||
public bool ImportOutcomeUnknown { get; init; }
|
||||
|
||||
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ReadNetCalls++;
|
||||
return Task.FromResult(_net[audience]);
|
||||
}
|
||||
|
||||
public Task WriteNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
IReadOnlyList<S5025TrustedManualRow> rows,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
WriteNetCalls++;
|
||||
if (!ThrowNetWrite || PersistBeforeNetThrow)
|
||||
{
|
||||
_net[audience] = rows.ToArray();
|
||||
}
|
||||
|
||||
return ThrowNetWrite
|
||||
? Task.FromException(new IOException("ambiguous"))
|
||||
: Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<LegacyManualViStoreSnapshot> ReadViAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ReadViCalls++;
|
||||
return Task.FromResult(Vi);
|
||||
}
|
||||
|
||||
public Task<LegacyManualViWriteReceipt> WriteViAsync(
|
||||
IReadOnlyList<LegacyManualViStoredItem> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
WriteViCalls++;
|
||||
Vi = new LegacyManualViStoreSnapshot(items.ToArray(), new string('c', 64));
|
||||
return Task.FromResult(new LegacyManualViWriteReceipt(Vi, true));
|
||||
}
|
||||
|
||||
public Task<LegacyManualImportReceipt> ImportAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ImportCalls++;
|
||||
return ImportOutcomeUnknown
|
||||
? Task.FromException<LegacyManualImportReceipt>(
|
||||
new LegacyManualImportStoreException("ROLLBACK_FAILED", true))
|
||||
: Task.FromResult(new LegacyManualImportReceipt(
|
||||
1,
|
||||
new string('d', 64),
|
||||
DateTimeOffset.UnixEpoch,
|
||||
15,
|
||||
0));
|
||||
}
|
||||
|
||||
private static S5025TrustedManualRow[] Rows() =>
|
||||
Enumerable.Range(1, 5).Select(index => new S5025TrustedManualRow(
|
||||
$"L{index}",
|
||||
index.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
$"R{index}",
|
||||
(-index).ToString(System.Globalization.CultureInfo.InvariantCulture))).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyNamedPlaylistOperatorIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Construction_DoesNotReadOrWriteNamedPlaylistDatabase()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence([]);
|
||||
|
||||
_ = CreateController(persistence);
|
||||
|
||||
Assert.Equal(0, persistence.ListCalls);
|
||||
Assert.Equal(0, persistence.LoadCalls);
|
||||
Assert.Equal(0, persistence.ReplaceCalls);
|
||||
Assert.Equal(0, persistence.CreateCalls);
|
||||
Assert.Equal(0, persistence.DeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_by_opaque_id_selects_and_loads_in_one_native_command()
|
||||
{
|
||||
var source = LegacyStockCutCatalog.CreatePlaylistRow(
|
||||
"source-row",
|
||||
"Samsung Electronics",
|
||||
new LegacyStockIdentity(
|
||||
LegacyDomesticStockMarket.Kospi,
|
||||
"Samsung Electronics",
|
||||
"005930"),
|
||||
LegacyStockCutCatalog.Rows[1]);
|
||||
var selection = Assert.IsType<LegacySceneSelection>(
|
||||
source.ToPlayoutEntry().Selection);
|
||||
var persistence = new OperatorNamedPlaylistPersistence(
|
||||
[
|
||||
new NamedPlaylistStoredItem(
|
||||
0,
|
||||
true,
|
||||
selection,
|
||||
new NamedPlaylistPageState(1, 1))
|
||||
]);
|
||||
var controller = CreateController(persistence);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
var definitionId = Assert.Single(
|
||||
listed.NamedPlaylist!.Definitions).DefinitionId;
|
||||
|
||||
var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId);
|
||||
|
||||
Assert.Equal(1, persistence.LoadCalls);
|
||||
Assert.Single(loaded.Playlist);
|
||||
Assert.Equal("load-named-playlist-by-id", loaded.CommandResult?.Command);
|
||||
Assert.Equal(definitionId, loaded.CommandResult?.TargetId);
|
||||
Assert.True(loaded.CommandResult?.Succeeded);
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh,
|
||||
loaded.NamedPlaylist?.DocumentFreshness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSelected_ReplacesPlaylistWithTypedOriginalSevenFields()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence(
|
||||
[
|
||||
new NamedPlaylistStoredItem(
|
||||
0,
|
||||
false,
|
||||
new LegacySceneSelection(
|
||||
"코스피",
|
||||
"삼성전자",
|
||||
"1열판기본",
|
||||
"현재가",
|
||||
"005930"),
|
||||
new NamedPlaylistPageState(1, 1))
|
||||
]);
|
||||
var controller = CreateController(persistence);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
controller.SelectNamedPlaylist(
|
||||
listed.NamedPlaylist!.Definitions.Single().DefinitionId);
|
||||
|
||||
var loaded = await controller.LoadSelectedNamedPlaylistAsync();
|
||||
|
||||
var row = Assert.Single(loaded.Playlist);
|
||||
Assert.False(row.IsEnabled);
|
||||
Assert.Equal("코스피", row.MarketText);
|
||||
Assert.Equal("삼성전자", row.StockName);
|
||||
Assert.Equal("1열판기본", row.GraphicType);
|
||||
Assert.Equal("현재가", row.Subtype);
|
||||
Assert.Equal("1/1", row.PageText);
|
||||
Assert.Equal("005930", row.StockCode);
|
||||
Assert.Equal("5001", row.SceneAlias);
|
||||
Assert.Equal("코스피", row.PlayoutSelection?.GroupCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSelected_UnknownSceneKeepsCurrentPlaylistAtomically()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence(
|
||||
[
|
||||
new NamedPlaylistStoredItem(
|
||||
0,
|
||||
true,
|
||||
new LegacySceneSelection(
|
||||
"UNKNOWN-GROUP",
|
||||
"알 수 없음",
|
||||
"UNKNOWN-GRAPHIC",
|
||||
"UNKNOWN-SUBTYPE",
|
||||
"UNKNOWN-DATA"),
|
||||
null)
|
||||
]);
|
||||
var controller = CreateController(persistence);
|
||||
var fixedAction = LegacyFixedActionCatalog.Default
|
||||
.GetActions(LegacyFixedMarket.Overseas)
|
||||
.First(action => action.Available);
|
||||
controller.ActivateFixedAction(fixedAction.Id);
|
||||
var before = controller.Current.Playlist.Single();
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
var definitionId = listed.NamedPlaylist!.Definitions.Single().DefinitionId;
|
||||
|
||||
var after = await controller.LoadNamedPlaylistByIdAsync(definitionId);
|
||||
|
||||
Assert.Equal(before.RowId, Assert.Single(after.Playlist).RowId);
|
||||
Assert.NotNull(after.Dialog);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Error, after.StatusKind);
|
||||
Assert.Equal("load-named-playlist-by-id", after.CommandResult?.Command);
|
||||
Assert.False(after.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveCurrent_WritesVisibleSevenFieldsOnceAndReadsBackFresh()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence([]);
|
||||
var controller = CreateController(persistence);
|
||||
var action = LegacyFixedActionCatalog.Default
|
||||
.GetActions(LegacyFixedMarket.Overseas)
|
||||
.First(candidate => candidate.Available);
|
||||
var added = controller.ActivateFixedAction(action.Id);
|
||||
var visible = Assert.Single(added.Playlist);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
controller.SelectNamedPlaylist(
|
||||
listed.NamedPlaylist!.Definitions.Single().DefinitionId);
|
||||
|
||||
var saved = await controller.SaveCurrentNamedPlaylistAsync();
|
||||
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
var stored = Assert.Single(persistence.LastReplaceItems!);
|
||||
Assert.Equal(0, stored.ItemIndex);
|
||||
Assert.Equal(visible.IsEnabled, stored.IsEnabled);
|
||||
Assert.Equal(visible.MarketText, stored.Selection.GroupCode);
|
||||
Assert.Equal(visible.StockName, stored.Selection.Subject);
|
||||
Assert.Equal(visible.GraphicType, stored.Selection.GraphicType);
|
||||
Assert.Equal(visible.Subtype, stored.Selection.Subtype);
|
||||
Assert.Equal(visible.StockCode, stored.Selection.DataCode);
|
||||
Assert.Equal(visible.PageText, stored.Page?.ToString());
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistMutationOutcome.CommittedFresh,
|
||||
saved.NamedPlaylist?.LastMutationOutcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_to_opaque_id_selects_and_writes_once_in_one_native_command()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence([]);
|
||||
var controller = CreateController(persistence);
|
||||
var action = LegacyFixedActionCatalog.Default
|
||||
.GetActions(LegacyFixedMarket.Overseas)
|
||||
.First(candidate => candidate.Available);
|
||||
controller.ActivateFixedAction(action.Id);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
var definitionId = Assert.Single(
|
||||
listed.NamedPlaylist!.Definitions).DefinitionId;
|
||||
|
||||
var saved = await controller.SaveCurrentNamedPlaylistToAsync(definitionId);
|
||||
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Equal("save-current-named-playlist-to", saved.CommandResult?.Command);
|
||||
Assert.Equal(definitionId, saved.CommandResult?.TargetId);
|
||||
Assert.True(saved.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
private static LegacyOperatorController CreateController(
|
||||
INamedPlaylistPersistenceService persistence) =>
|
||||
new(
|
||||
new UnusedLookup(),
|
||||
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
|
||||
|
||||
private sealed class UnusedLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new LegacyStockSearchData(query, [], []));
|
||||
}
|
||||
|
||||
private sealed class OperatorNamedPlaylistPersistence : INamedPlaylistPersistenceService
|
||||
{
|
||||
private const string ProgramCode = "00000001";
|
||||
private const string Title = "운영 재생목록";
|
||||
private List<NamedPlaylistStoredItem> _items;
|
||||
private bool _exists = true;
|
||||
|
||||
public OperatorNamedPlaylistPersistence(IEnumerable<NamedPlaylistStoredItem> items)
|
||||
{
|
||||
_items = items.Select(Clone).ToList();
|
||||
}
|
||||
|
||||
public bool CanMutate => true;
|
||||
|
||||
public int ListCalls { get; private set; }
|
||||
|
||||
public int LoadCalls { get; private set; }
|
||||
|
||||
public int ReplaceCalls { get; private set; }
|
||||
|
||||
public int CreateCalls { get; private set; }
|
||||
|
||||
public int DeleteCalls { get; private set; }
|
||||
|
||||
public IReadOnlyList<NamedPlaylistStoredItem>? LastReplaceItems { get; private set; }
|
||||
|
||||
public Task<NamedPlaylistListResult> ListAsync(
|
||||
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++ListCalls;
|
||||
IReadOnlyList<NamedPlaylistSummary> summaries = _exists
|
||||
? [new NamedPlaylistSummary(ProgramCode, Title)]
|
||||
: [];
|
||||
return Task.FromResult(new NamedPlaylistListResult(
|
||||
DateTimeOffset.UtcNow,
|
||||
summaries,
|
||||
false));
|
||||
}
|
||||
|
||||
public Task<string> SuggestNextProgramCodeAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_exists ? "00000002" : ProgramCode);
|
||||
|
||||
public Task<NamedPlaylistDocument> LoadAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++LoadCalls;
|
||||
if (!_exists || !string.Equals(programCode, ProgramCode, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromException<NamedPlaylistDocument>(
|
||||
new NamedPlaylistNotFoundException(programCode));
|
||||
}
|
||||
|
||||
return Task.FromResult(new NamedPlaylistDocument(
|
||||
new NamedPlaylistSummary(ProgramCode, Title),
|
||||
DateTimeOffset.UtcNow,
|
||||
_items.Select(Clone).ToArray()));
|
||||
}
|
||||
|
||||
public Task CreateDefinitionAsync(
|
||||
string programCode,
|
||||
string title,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++CreateCalls;
|
||||
_exists = true;
|
||||
_items = [];
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReplaceItemsAsync(
|
||||
string programCode,
|
||||
IReadOnlyList<NamedPlaylistStoredItem> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++ReplaceCalls;
|
||||
LastReplaceItems = items.Select(Clone).ToArray();
|
||||
_items = items.Select(Clone).ToList();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++DeleteCalls;
|
||||
_exists = false;
|
||||
_items = [];
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static NamedPlaylistStoredItem Clone(NamedPlaylistStoredItem item) =>
|
||||
new(
|
||||
item.ItemIndex,
|
||||
item.IsEnabled,
|
||||
item.Selection with { },
|
||||
item.Page is null
|
||||
? null
|
||||
: new NamedPlaylistPageState(
|
||||
item.Page.CurrentPage,
|
||||
item.Page.TotalPages));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyNamedPlaylistWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RefreshSelectAndLoad_ExposeOpaqueIdentityAndTypedRowsOnly()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
|
||||
var listed = await controller.RefreshAsync();
|
||||
var selected = controller.SelectDefinition(listed.Definitions.Single(
|
||||
definition => definition.Title == "마감").DefinitionId);
|
||||
var loaded = await controller.LoadSelectedAsync();
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistReadStatus.Ready, listed.ReadStatus);
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, listed.ListFreshness);
|
||||
Assert.All(listed.Definitions, definition =>
|
||||
Assert.DoesNotContain("000000", definition.DefinitionId, StringComparison.Ordinal));
|
||||
Assert.Equal("마감", selected.SelectedTitle);
|
||||
Assert.Equal("00000002", persistence.LastLoadedProgramCode);
|
||||
Assert.Collection(
|
||||
loaded.Rows,
|
||||
row =>
|
||||
{
|
||||
Assert.Equal(0, row.ItemIndex);
|
||||
Assert.True(row.IsEnabled);
|
||||
Assert.Equal("KOSDAQ", row.Selection.GroupCode);
|
||||
Assert.Equal("다온테크", row.Selection.Subject);
|
||||
Assert.Equal("1열판기본", row.Selection.GraphicType);
|
||||
Assert.Equal("현재가", row.Selection.Subtype);
|
||||
Assert.Equal("035720", row.Selection.DataCode);
|
||||
Assert.Equal("1/1", row.Page?.PageText);
|
||||
});
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyNamedPlaylistDefinitionView).GetProperties(),
|
||||
property => property.Name.Contains("ProgramCode", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyNamedPlaylistWorkflowSnapshot).GetProperties(),
|
||||
property => property.Name.Contains("ProgramCode", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyNamedPlaylistRowView).GetProperties(),
|
||||
property => property.Name.Contains("ListText", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveCurrent_SendsOneAtomicOrderedSevenFieldBatch_ThenReadsFresh()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var drafts = new[]
|
||||
{
|
||||
controller.CreateRowDraft(
|
||||
Entry("entry-a", true, "KOSPI", "삼성전자", "1열판기본", "현재가", "005930"),
|
||||
new NamedPlaylistPageState(1, 1)),
|
||||
controller.CreateRowDraft(
|
||||
Entry("entry-b", false, "PAGED_THEME", "반도체", "INPUT_ORDER", "CURRENT", "00000012"),
|
||||
new NamedPlaylistPageState(2, 3))
|
||||
};
|
||||
|
||||
var result = await controller.SaveCurrentAsync(drafts);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, result.Outcome);
|
||||
Assert.True(result.IsCommitted);
|
||||
Assert.False(result.AutomaticRetryAllowed);
|
||||
Assert.False(result.Snapshot.IsWriteInProgress);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Collection(
|
||||
persistence.LastReplaceItems!,
|
||||
item =>
|
||||
{
|
||||
Assert.Equal(0, item.ItemIndex);
|
||||
Assert.Equal(
|
||||
"1^KOSPI^삼성전자^1열판기본^현재가^1/1^005930",
|
||||
SevenFields(item));
|
||||
},
|
||||
item =>
|
||||
{
|
||||
Assert.Equal(1, item.ItemIndex);
|
||||
Assert.Equal(
|
||||
"0^PAGED_THEME^반도체^INPUT_ORDER^CURRENT^2/3^00000012",
|
||||
SevenFields(item));
|
||||
});
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, result.Snapshot.DocumentFreshness);
|
||||
Assert.Equal(["삼성전자", "반도체"], result.Snapshot.Rows.Select(row => row.Selection.Subject));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAndDelete_UseInternalSuggestedIdentityAndFreshListReadBack()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
await controller.RefreshAsync();
|
||||
var draft = controller.CreateProgramDraft("새 프로그램");
|
||||
|
||||
var created = await controller.CreateProgramAsync(draft);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, created.Outcome);
|
||||
Assert.Equal("00000003", persistence.LastCreatedProgramCode);
|
||||
Assert.Equal("새 프로그램", created.Snapshot.SelectedTitle);
|
||||
Assert.Equal(3, created.Snapshot.Definitions.Count);
|
||||
Assert.Empty(created.Snapshot.Rows);
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyNamedPlaylistProgramDraft).GetProperties(),
|
||||
property => property.Name.Contains("Code", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("00000003", draft.DraftId, StringComparison.Ordinal);
|
||||
|
||||
var deleted = await controller.DeleteSelectedAsync();
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, deleted.Outcome);
|
||||
Assert.Equal(1, persistence.DeleteCalls);
|
||||
Assert.Equal("00000003", persistence.LastDeletedProgramCode);
|
||||
Assert.Null(deleted.Snapshot.SelectedDefinitionId);
|
||||
Assert.DoesNotContain(
|
||||
deleted.Snapshot.Definitions,
|
||||
definition => string.Equals(definition.Title, "새 프로그램", StringComparison.Ordinal));
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, deleted.Snapshot.ListFreshness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownRollback_PreservesLoadedRowsAndNeverRetries()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var before = controller.Current;
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
persistence.ReplaceFailure = new NamedPlaylistMutationException(
|
||||
"The transaction rolled back.",
|
||||
outcomeUnknown: false);
|
||||
|
||||
var result = await controller.SaveCurrentAsync([draft]);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.RolledBack, result.Outcome);
|
||||
Assert.False(result.AutomaticRetryAllowed);
|
||||
Assert.False(result.Snapshot.IsWriteQuarantined);
|
||||
Assert.True(result.Snapshot.CanMutate);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Equal(
|
||||
before.Rows.Select(row => row.Selection.Subject),
|
||||
result.Snapshot.Rows.Select(row => row.Selection.Subject));
|
||||
Assert.Equal("삼성전자", persistence.StoredSubjects("00000001").Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutcomeUnknown_LatchesQuarantineAndBlocksEveryLaterWriteWithoutRetry()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
persistence.ReplaceFailure = new NamedPlaylistMutationException(
|
||||
"Commit response was lost.",
|
||||
outcomeUnknown: true);
|
||||
|
||||
var unknown = await controller.SaveCurrentAsync([draft]);
|
||||
persistence.ReplaceFailure = null;
|
||||
var blocked = await controller.SaveCurrentAsync([draft]);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.OutcomeUnknown, unknown.Outcome);
|
||||
Assert.True(unknown.Snapshot.IsWriteQuarantined);
|
||||
Assert.False(unknown.Snapshot.CanMutate);
|
||||
Assert.False(unknown.AutomaticRetryAllowed);
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.Quarantined, blocked.Outcome);
|
||||
Assert.False(blocked.AutomaticRetryAllowed);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Equal("삼성전자", persistence.StoredSubjects("00000001").Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteGate_ReturnsBusyWhileFirstAtomicSaveIsPending()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var entered = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
persistence.ReplaceEntered = entered;
|
||||
persistence.ReleaseReplace = release;
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
|
||||
var first = controller.SaveCurrentAsync([draft]);
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var concurrent = await controller.SaveCurrentAsync([draft]);
|
||||
release.SetResult();
|
||||
var completed = await first;
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.Busy, concurrent.Outcome);
|
||||
Assert.False(concurrent.AutomaticRetryAllowed);
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, completed.Outcome);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedFreshReadBack_KeepsOptimisticRowsUntilExplicitFreshLoad()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "낙관행", "1열판기본", "현재가", "654321"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
persistence.LoadFailures.Enqueue(
|
||||
new NamedPlaylistDataException("Simulated read-back failure."));
|
||||
|
||||
var optimistic = await controller.SaveCurrentAsync([draft]);
|
||||
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic,
|
||||
optimistic.Outcome);
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistFreshness.Optimistic,
|
||||
optimistic.Snapshot.DocumentFreshness);
|
||||
Assert.Equal("낙관행", Assert.Single(optimistic.Snapshot.Rows).Selection.Subject);
|
||||
Assert.False(optimistic.Snapshot.IsWriteQuarantined);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
|
||||
var fresh = await controller.LoadSelectedAsync();
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, fresh.DocumentFreshness);
|
||||
Assert.Equal("낙관행", Assert.Single(fresh.Rows).Selection.Subject);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOnlyPersistence_RejectsMutationBeforeServiceCall()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
persistence.CanMutate = false;
|
||||
var controller = await LoadOpeningAsync(persistence);
|
||||
var draft = controller.CreateRowDraft(
|
||||
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
|
||||
var result = await controller.SaveCurrentAsync([draft]);
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.ReadOnly, result.Outcome);
|
||||
Assert.False(result.Snapshot.CanMutate);
|
||||
Assert.Equal(0, persistence.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SnapshotsAreImmutable_AndStaleOpaqueIdsAreRejectedAfterRefresh()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
var before = await controller.RefreshAsync();
|
||||
var staleId = before.Definitions[0].DefinitionId;
|
||||
controller.SelectDefinition(staleId);
|
||||
var loaded = await controller.LoadSelectedAsync();
|
||||
|
||||
var after = await controller.RefreshAsync();
|
||||
|
||||
var definitions = Assert.IsAssignableFrom<
|
||||
ICollection<LegacyNamedPlaylistDefinitionView>>(before.Definitions);
|
||||
Assert.Throws<NotSupportedException>(() => definitions.Clear());
|
||||
var rows = Assert.IsAssignableFrom<ICollection<LegacyNamedPlaylistRowView>>(loaded.Rows);
|
||||
Assert.Throws<NotSupportedException>(() => rows.Clear());
|
||||
Assert.NotEqual(staleId, after.SelectedDefinitionId);
|
||||
Assert.Throws<KeyNotFoundException>(() => controller.SelectDefinition(staleId));
|
||||
Assert.Equal("마감", before.Definitions[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftsAreBoundToTheirNativeAuthorityInstance()
|
||||
{
|
||||
var firstPersistence = CreateSeededPersistence();
|
||||
var secondPersistence = CreateSeededPersistence();
|
||||
var first = await LoadOpeningAsync(firstPersistence);
|
||||
var second = await LoadOpeningAsync(secondPersistence);
|
||||
var foreignDraft = first.CreateRowDraft(
|
||||
Entry("foreign", true, "KOSPI", "외부", "1열판기본", "현재가", "111111"),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => second.SaveCurrentAsync([foreignDraft]));
|
||||
|
||||
Assert.Equal(0, secondPersistence.ReplaceCalls);
|
||||
}
|
||||
|
||||
private static FakeNamedPlaylistPersistence CreateSeededPersistence()
|
||||
{
|
||||
var persistence = new FakeNamedPlaylistPersistence();
|
||||
persistence.Seed(
|
||||
"00000001",
|
||||
"오전",
|
||||
StoredItem(0, true, "KOSPI", "삼성전자", "1열판기본", "현재가", "005930", 1, 1));
|
||||
persistence.Seed(
|
||||
"00000002",
|
||||
"마감",
|
||||
StoredItem(0, true, "KOSDAQ", "다온테크", "1열판기본", "현재가", "035720", 1, 1));
|
||||
return persistence;
|
||||
}
|
||||
|
||||
private static async Task<LegacyNamedPlaylistWorkflowController> LoadOpeningAsync(
|
||||
FakeNamedPlaylistPersistence persistence)
|
||||
{
|
||||
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
var listed = await controller.RefreshAsync();
|
||||
controller.SelectDefinition(listed.Definitions.Single(
|
||||
definition => definition.Title == "오전").DefinitionId);
|
||||
await controller.LoadSelectedAsync();
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static LegacyPlaylistEntry Entry(
|
||||
string id,
|
||||
bool enabled,
|
||||
string groupCode,
|
||||
string subject,
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string dataCode) =>
|
||||
new(
|
||||
id,
|
||||
"5001",
|
||||
enabled,
|
||||
6,
|
||||
new LegacySceneSelection(
|
||||
groupCode,
|
||||
subject,
|
||||
graphicType,
|
||||
subtype,
|
||||
dataCode));
|
||||
|
||||
private static NamedPlaylistStoredItem StoredItem(
|
||||
int index,
|
||||
bool enabled,
|
||||
string groupCode,
|
||||
string subject,
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string dataCode,
|
||||
int currentPage,
|
||||
int totalPages) =>
|
||||
new(
|
||||
index,
|
||||
enabled,
|
||||
new LegacySceneSelection(
|
||||
groupCode,
|
||||
subject,
|
||||
graphicType,
|
||||
subtype,
|
||||
dataCode),
|
||||
new NamedPlaylistPageState(currentPage, totalPages));
|
||||
|
||||
private static string SevenFields(NamedPlaylistStoredItem item) =>
|
||||
string.Join(
|
||||
'^',
|
||||
item.IsEnabled ? "1" : "0",
|
||||
item.Selection.GroupCode,
|
||||
item.Selection.Subject,
|
||||
item.Selection.GraphicType,
|
||||
item.Selection.Subtype,
|
||||
item.Page?.ToString() ?? string.Empty,
|
||||
item.Selection.DataCode);
|
||||
|
||||
private sealed class FakeNamedPlaylistPersistence : INamedPlaylistPersistenceService
|
||||
{
|
||||
private readonly Dictionary<string, StoredProgram> _programs =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public bool CanMutate { get; set; } = true;
|
||||
|
||||
public int CreateCalls { get; private set; }
|
||||
|
||||
public int ReplaceCalls { get; private set; }
|
||||
|
||||
public int DeleteCalls { get; private set; }
|
||||
|
||||
public int LoadCalls { get; private set; }
|
||||
|
||||
public string? LastCreatedProgramCode { get; private set; }
|
||||
|
||||
public string? LastLoadedProgramCode { get; private set; }
|
||||
|
||||
public string? LastDeletedProgramCode { get; private set; }
|
||||
|
||||
public IReadOnlyList<NamedPlaylistStoredItem>? LastReplaceItems { get; private set; }
|
||||
|
||||
public Exception? CreateFailure { get; set; }
|
||||
|
||||
public Exception? ReplaceFailure { get; set; }
|
||||
|
||||
public Exception? DeleteFailure { get; set; }
|
||||
|
||||
public Queue<Exception> LoadFailures { get; } = new();
|
||||
|
||||
public TaskCompletionSource? ReplaceEntered { get; set; }
|
||||
|
||||
public TaskCompletionSource? ReleaseReplace { get; set; }
|
||||
|
||||
public void Seed(
|
||||
string programCode,
|
||||
string title,
|
||||
params NamedPlaylistStoredItem[] items) =>
|
||||
_programs.Add(
|
||||
programCode,
|
||||
new StoredProgram(title, items.Select(Clone).ToList()));
|
||||
|
||||
public IEnumerable<string> StoredSubjects(string programCode) =>
|
||||
_programs[programCode].Items.Select(item => item.Selection.Subject);
|
||||
|
||||
public Task<NamedPlaylistListResult> ListAsync(
|
||||
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var ordered = _programs
|
||||
.Select(pair => new NamedPlaylistSummary(pair.Key, pair.Value.Title))
|
||||
.OrderBy(summary => summary.Title, StringComparer.Ordinal)
|
||||
.ThenBy(summary => summary.ProgramCode, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return Task.FromResult(new NamedPlaylistListResult(
|
||||
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
|
||||
ordered.Take(maximumResults).ToArray(),
|
||||
ordered.Length > maximumResults));
|
||||
}
|
||||
|
||||
public Task<string> SuggestNextProgramCodeAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var next = _programs.Count == 0
|
||||
? 1
|
||||
: _programs.Keys.Max(code => int.Parse(
|
||||
code,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture)) + 1;
|
||||
return Task.FromResult(next.ToString("D8", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public Task<NamedPlaylistDocument> LoadAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
++LoadCalls;
|
||||
LastLoadedProgramCode = programCode;
|
||||
if (LoadFailures.TryDequeue(out var failure))
|
||||
{
|
||||
return Task.FromException<NamedPlaylistDocument>(failure);
|
||||
}
|
||||
|
||||
if (!_programs.TryGetValue(programCode, out var stored))
|
||||
{
|
||||
return Task.FromException<NamedPlaylistDocument>(
|
||||
new NamedPlaylistNotFoundException(programCode));
|
||||
}
|
||||
|
||||
return Task.FromResult(new NamedPlaylistDocument(
|
||||
new NamedPlaylistSummary(programCode, stored.Title),
|
||||
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
|
||||
stored.Items.Select(Clone).ToArray()));
|
||||
}
|
||||
|
||||
public Task CreateDefinitionAsync(
|
||||
string programCode,
|
||||
string title,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
++CreateCalls;
|
||||
LastCreatedProgramCode = programCode;
|
||||
if (CreateFailure is not null)
|
||||
{
|
||||
return Task.FromException(CreateFailure);
|
||||
}
|
||||
|
||||
if (!_programs.TryAdd(programCode, new StoredProgram(title, [])))
|
||||
{
|
||||
return Task.FromException(new NamedPlaylistMutationException(
|
||||
"Duplicate program; rolled back.",
|
||||
outcomeUnknown: false));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task ReplaceItemsAsync(
|
||||
string programCode,
|
||||
IReadOnlyList<NamedPlaylistStoredItem> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
++ReplaceCalls;
|
||||
LastReplaceItems = Array.AsReadOnly(items.Select(Clone).ToArray());
|
||||
ReplaceEntered?.TrySetResult();
|
||||
if (ReleaseReplace is not null)
|
||||
{
|
||||
await ReleaseReplace.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (ReplaceFailure is not null)
|
||||
{
|
||||
throw ReplaceFailure;
|
||||
}
|
||||
|
||||
if (!_programs.TryGetValue(programCode, out var stored))
|
||||
{
|
||||
throw new NamedPlaylistNotFoundException(programCode);
|
||||
}
|
||||
|
||||
// One assignment models the executor's transaction boundary: no partial row
|
||||
// becomes visible when an exception is raised before this point.
|
||||
_programs[programCode] = stored with
|
||||
{
|
||||
Items = items.Select(Clone).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public Task DeleteAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
++DeleteCalls;
|
||||
LastDeletedProgramCode = programCode;
|
||||
if (DeleteFailure is not null)
|
||||
{
|
||||
return Task.FromException(DeleteFailure);
|
||||
}
|
||||
|
||||
if (!_programs.Remove(programCode))
|
||||
{
|
||||
return Task.FromException(new NamedPlaylistNotFoundException(programCode));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static NamedPlaylistStoredItem Clone(NamedPlaylistStoredItem item) =>
|
||||
new(
|
||||
item.ItemIndex,
|
||||
item.IsEnabled,
|
||||
item.Selection with { },
|
||||
item.Page is null
|
||||
? null
|
||||
: new NamedPlaylistPageState(
|
||||
item.Page.CurrentPage,
|
||||
item.Page.TotalPages));
|
||||
|
||||
private sealed record StoredProgram(
|
||||
string Title,
|
||||
List<NamedPlaylistStoredItem> Items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ThemeA_create_uses_opaque_stock_selection_and_writes_only_on_explicit_save()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
|
||||
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
|
||||
Assert.True(opened.SchemaValidated);
|
||||
Assert.Single(opened.Results);
|
||||
|
||||
var creating = await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Create, creating.Mode);
|
||||
Assert.Equal("00000009", creating.CodeLabel);
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
|
||||
var stocks = await workflow.SearchStocksAsync("삼성");
|
||||
var stockId = Assert.Single(stocks.StockResults).ResultId;
|
||||
workflow.SelectStock(stockId);
|
||||
var draft = workflow.AddSelectedStock(null);
|
||||
Assert.Single(draft.DraftRows);
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
|
||||
var saved = await workflow.SaveAsync("반도체 대표주");
|
||||
|
||||
Assert.Equal(1, fixture.ThemePersistence.CreateCalls);
|
||||
Assert.Equal(1, fixture.IdentityValidation.ThemeCalls);
|
||||
var definition = Assert.IsType<ThemeCatalogDefinition>(
|
||||
fixture.ThemePersistence.LastCreated);
|
||||
Assert.Equal("00000009", definition.ThemeCode);
|
||||
Assert.Equal("P005930", Assert.Single(definition.Items).ItemCode);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.List, saved.Mode);
|
||||
Assert.False(saved.WritesQuarantined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeA_existing_row_requires_fresh_complete_preview_for_edit_and_delete()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
|
||||
|
||||
var selected = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
|
||||
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Edit, selected.Mode);
|
||||
Assert.Equal("AI 반도체", selected.EditorName);
|
||||
Assert.Single(selected.DraftRows);
|
||||
Assert.Equal(1, fixture.ThemeSelection.PreviewCalls);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
|
||||
var deleted = await workflow.DeleteAsync();
|
||||
|
||||
Assert.Equal(1, fixture.ThemePersistence.DeleteCalls);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.List, deleted.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EList_preserves_buy_amount_and_CSharp_owned_reorder_in_one_create()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.StockSearch.Items =
|
||||
[
|
||||
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "삼성전자", "005930"),
|
||||
new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "카카오", "035720")
|
||||
];
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
|
||||
await workflow.BeginCreateExpertAsync();
|
||||
|
||||
var stocks = await workflow.SearchStocksAsync("주식");
|
||||
workflow.SelectStock(stocks.StockResults[0].ResultId);
|
||||
workflow.AddSelectedStock(70_000);
|
||||
workflow.SelectStock(stocks.StockResults[1].ResultId);
|
||||
var twoRows = workflow.AddSelectedStock(45_000);
|
||||
twoRows = workflow.SetDraftBuyAmount(twoRows.DraftRows[1].RowId, 46_000);
|
||||
workflow.MoveDraftRow(twoRows.DraftRows[1].RowId, LegacyOperatorCatalogMoveDirection.Up);
|
||||
|
||||
Assert.Equal(0, fixture.ExpertPersistence.CreateCalls);
|
||||
await workflow.SaveAsync("김전문");
|
||||
|
||||
Assert.Equal(1, fixture.ExpertPersistence.CreateCalls);
|
||||
Assert.Equal(1, fixture.IdentityValidation.ExpertCalls);
|
||||
var definition = Assert.IsType<ExpertCatalogDefinition>(
|
||||
fixture.ExpertPersistence.LastCreated);
|
||||
Assert.Collection(
|
||||
definition.Recommendations,
|
||||
row =>
|
||||
{
|
||||
Assert.Equal("035720", row.StockCode);
|
||||
Assert.Equal(46_000, row.BuyAmount);
|
||||
Assert.Equal(0, row.PlayIndex);
|
||||
},
|
||||
row =>
|
||||
{
|
||||
Assert.Equal("005930", row.StockCode);
|
||||
Assert.Equal(70_000, row.BuyAmount);
|
||||
Assert.Equal(1, row.PlayIndex);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uc4_and_Uc6_edit_entries_resolve_only_the_native_selected_identity()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
|
||||
var theme = await workflow.OpenThemeForEditAsync(new ThemeSelectionIdentity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"00000001",
|
||||
"AI 반도체"));
|
||||
|
||||
Assert.Equal(LegacyOperatorCatalogEntity.Theme, theme.Entity);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Edit, theme.Mode);
|
||||
Assert.Equal("AI 반도체", theme.EditorName);
|
||||
Assert.True(theme.CanDelete);
|
||||
|
||||
var expert = await workflow.OpenExpertForEditAsync(
|
||||
new ExpertSelectionIdentity("0001", "이전문"));
|
||||
|
||||
Assert.Equal(LegacyOperatorCatalogEntity.Expert, expert.Entity);
|
||||
Assert.Equal(LegacyOperatorCatalogMode.Edit, expert.Mode);
|
||||
Assert.Equal("이전문", expert.EditorName);
|
||||
Assert.Equal(70_000, Assert.Single(expert.DraftRows).BuyAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutcomeUnknown_latches_process_safety_instance_and_never_retries()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.ThemePersistence.CreateException = new OperatorCatalogMutationException(
|
||||
"lost commit acknowledgement",
|
||||
outcomeUnknown: true);
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
|
||||
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
|
||||
var stocks = await workflow.SearchStocksAsync("삼성");
|
||||
workflow.SelectStock(Assert.Single(stocks.StockResults).ResultId);
|
||||
workflow.AddSelectedStock(null);
|
||||
|
||||
var unknown = await workflow.SaveAsync("확인 불명 테마");
|
||||
var blocked = await workflow.SaveAsync("확인 불명 테마");
|
||||
|
||||
Assert.True(unknown.WritesQuarantined);
|
||||
Assert.True(blocked.WritesQuarantined);
|
||||
Assert.False(blocked.CanSave);
|
||||
Assert.Equal(1, fixture.ThemePersistence.CreateCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fabricated_catalog_stock_and_draft_ids_fail_before_authority_is_used()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
workflow.SelectAsync("catalog-result-fabricated"));
|
||||
|
||||
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
|
||||
await workflow.SearchStocksAsync("삼성");
|
||||
Assert.Throws<ArgumentException>(() => workflow.SelectStock("catalog-stock-fabricated"));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
workflow.RemoveDraftRow("catalog-draft-fabricated"));
|
||||
}
|
||||
|
||||
private sealed class Fixture
|
||||
{
|
||||
internal FakeThemeSelection ThemeSelection { get; } = new();
|
||||
|
||||
internal FakeExpertSelection ExpertSelection { get; } = new();
|
||||
|
||||
internal FakeThemePersistence ThemePersistence { get; } = new();
|
||||
|
||||
internal FakeExpertPersistence ExpertPersistence { get; } = new();
|
||||
|
||||
internal FakeStockSearch StockSearch { get; } = new();
|
||||
|
||||
internal FakeIdentityValidation IdentityValidation { get; } = new();
|
||||
|
||||
internal LegacyOperatorCatalogWorkflowController CreateWorkflow() =>
|
||||
new(
|
||||
ThemeSelection,
|
||||
ExpertSelection,
|
||||
ThemePersistence,
|
||||
ExpertPersistence,
|
||||
StockSearch,
|
||||
IdentityValidation,
|
||||
new FakeSchemaValidation(),
|
||||
new LegacyOperatorCatalogWriteSafetyState());
|
||||
}
|
||||
|
||||
private sealed class FakeThemeSelection : IThemeSelectionService
|
||||
{
|
||||
internal int PreviewCalls { get; private set; }
|
||||
|
||||
public Task<ThemeSearchResult> SearchAsync(
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new ThemeSearchResult(
|
||||
query,
|
||||
nxtSession,
|
||||
DateTimeOffset.UtcNow,
|
||||
[
|
||||
new ThemeSelectorItem(
|
||||
new ThemeSelectionIdentity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"00000001",
|
||||
"AI 반도체"),
|
||||
DataSourceKind.Oracle)
|
||||
],
|
||||
false));
|
||||
|
||||
public Task<ThemePreviewResult> PreviewAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PreviewCalls++;
|
||||
return Task.FromResult(new ThemePreviewResult(
|
||||
identity,
|
||||
DateTimeOffset.UtcNow,
|
||||
[new ThemeItemPreview(4, "P005930", "삼성전자")],
|
||||
false));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeExpertSelection : IExpertSelectionService
|
||||
{
|
||||
public Task<ExpertSearchResult> SearchAsync(
|
||||
string query = "",
|
||||
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new ExpertSearchResult(
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
[
|
||||
new ExpertSelectorItem(
|
||||
new ExpertSelectionIdentity("0001", "이전문"),
|
||||
DataSourceKind.Oracle)
|
||||
],
|
||||
false));
|
||||
|
||||
public Task<ExpertPreviewResult> PreviewAsync(
|
||||
ExpertSelectionIdentity identity,
|
||||
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new ExpertPreviewResult(
|
||||
identity,
|
||||
DateTimeOffset.UtcNow,
|
||||
[new ExpertRecommendationPreview(0, "005930", "삼성전자", 70_000)],
|
||||
false));
|
||||
}
|
||||
|
||||
private sealed class FakeThemePersistence : IThemeCatalogPersistenceService
|
||||
{
|
||||
internal int CreateCalls { get; private set; }
|
||||
|
||||
internal int DeleteCalls { get; private set; }
|
||||
|
||||
internal ThemeCatalogDefinition? LastCreated { get; private set; }
|
||||
|
||||
internal Exception? CreateException { get; set; }
|
||||
|
||||
public bool CanMutate => true;
|
||||
|
||||
public Task<string> SuggestNextCodeAsync(
|
||||
ThemeMarket market,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult("00000009");
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> CreateAsync(
|
||||
ThemeCatalogDefinition definition,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CreateCalls++;
|
||||
LastCreated = definition;
|
||||
return CreateException is null
|
||||
? Receipt(OperatorCatalogMutationKind.CreateTheme)
|
||||
: Task.FromException<OperatorCatalogMutationReceipt>(CreateException);
|
||||
}
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
|
||||
ThemeSelectionIdentity expectedIdentity,
|
||||
string newTitle,
|
||||
IReadOnlyList<ThemeCatalogItem> items,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Receipt(OperatorCatalogMutationKind.ReplaceTheme);
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
|
||||
ThemeSelectionIdentity expectedIdentity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DeleteCalls++;
|
||||
return Receipt(OperatorCatalogMutationKind.DeleteTheme);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeExpertPersistence : IExpertCatalogPersistenceService
|
||||
{
|
||||
internal int CreateCalls { get; private set; }
|
||||
|
||||
internal ExpertCatalogDefinition? LastCreated { get; private set; }
|
||||
|
||||
public bool CanMutate => true;
|
||||
|
||||
public Task<string> SuggestNextCodeAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult("0009");
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> CreateAsync(
|
||||
ExpertCatalogDefinition definition,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CreateCalls++;
|
||||
LastCreated = definition;
|
||||
return Receipt(OperatorCatalogMutationKind.CreateExpert);
|
||||
}
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
|
||||
ExpertSelectionIdentity expectedIdentity,
|
||||
string newName,
|
||||
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Receipt(OperatorCatalogMutationKind.ReplaceExpert);
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
|
||||
ExpertSelectionIdentity expectedIdentity,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Receipt(OperatorCatalogMutationKind.DeleteExpert);
|
||||
}
|
||||
|
||||
private sealed class FakeStockSearch : IStockSearchService
|
||||
{
|
||||
internal IReadOnlyList<StockSearchItem> Items { get; set; } =
|
||||
[new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "삼성전자", "005930")];
|
||||
|
||||
public Task<StockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new StockSearchResult(
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
Items,
|
||||
false));
|
||||
}
|
||||
|
||||
private sealed class FakeIdentityValidation : IStockMasterIdentityValidationService
|
||||
{
|
||||
internal int ThemeCalls { get; private set; }
|
||||
|
||||
internal int ExpertCalls { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<StockMasterIdentity>> ValidateThemeItemsAsync(
|
||||
IReadOnlyList<ThemeCatalogItem> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThemeCalls++;
|
||||
return Task.FromResult<IReadOnlyList<StockMasterIdentity>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StockMasterIdentity>> ValidateExpertRecommendationsAsync(
|
||||
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ExpertCalls++;
|
||||
return Task.FromResult<IReadOnlyList<StockMasterIdentity>>([]);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSchemaValidation : IOperatorCatalogSchemaValidationService
|
||||
{
|
||||
public Task<OperatorCatalogSchemaValidationResult> ValidateAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new OperatorCatalogSchemaValidationResult(
|
||||
DateTimeOffset.UtcNow,
|
||||
[DataSourceKind.Oracle, DataSourceKind.MariaDb]));
|
||||
}
|
||||
|
||||
private static Task<OperatorCatalogMutationReceipt> Receipt(
|
||||
OperatorCatalogMutationKind kind) =>
|
||||
Task.FromResult(new OperatorCatalogMutationReceipt(
|
||||
Guid.NewGuid(),
|
||||
kind,
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyTradingHaltWorkflowTests
|
||||
{
|
||||
private static readonly TradingHaltSelection KospiAlpha =
|
||||
new(TradingHaltMarket.Kospi, "005930", "가온전자");
|
||||
|
||||
private static readonly TradingHaltSelection KospiBeta =
|
||||
new(TradingHaltMarket.Kospi, "000660", "한빛반도체");
|
||||
|
||||
private static readonly TradingHaltSelection KosdaqAlpha =
|
||||
new(TradingHaltMarket.Kosdaq, "035720", "다온테크");
|
||||
|
||||
[Fact]
|
||||
public async Task SearchSelect_MaterializesClosedCurrentAndCandleDraftsFromActionIdOnly()
|
||||
{
|
||||
var service = new DelegateTradingHaltService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(
|
||||
SearchResult(query, KospiAlpha, KospiBeta, KosdaqAlpha))
|
||||
};
|
||||
var controller = new LegacyTradingHaltWorkflowController(service);
|
||||
|
||||
var search = await controller.SearchAsync(" 거래 ");
|
||||
var selected = controller.SelectResult(search.Results[2].SelectionId);
|
||||
controller.SetMovingAverages(ma5: true, ma20: true);
|
||||
var current = controller.MaterializeSelectedAction(
|
||||
LegacyTradingHaltWorkflowController.CurrentActionId);
|
||||
var candle = controller.MaterializeSelectedAction(
|
||||
LegacyTradingHaltWorkflowController.CandleActionId);
|
||||
|
||||
Assert.Equal("거래", service.SearchQuery);
|
||||
Assert.Equal(500, service.SearchMaximum);
|
||||
Assert.Equal("다온테크", selected.Selected?.DisplayName);
|
||||
Assert.Collection(
|
||||
selected.Actions,
|
||||
action =>
|
||||
{
|
||||
Assert.Equal("current", action.ActionId);
|
||||
Assert.True(action.IsAvailable);
|
||||
},
|
||||
action =>
|
||||
{
|
||||
Assert.Equal("candle-120d", action.ActionId);
|
||||
Assert.True(action.IsAvailable);
|
||||
});
|
||||
|
||||
Assert.Equal("s5001", current.BuilderKey);
|
||||
Assert.Equal("5001", current.CutCode);
|
||||
Assert.Equal(new[] { "5001" }, current.Aliases);
|
||||
Assert.Equal("거래정지-현재가", current.Detail);
|
||||
Assert.Equal("stock", current.Category);
|
||||
Assert.Equal("kosdaq", current.Market);
|
||||
Assert.Equal("KOSDAQ", current.Selection.GroupCode);
|
||||
Assert.Equal("다온테크", current.Selection.Subject);
|
||||
Assert.Equal("1열판기본", current.Selection.GraphicType);
|
||||
Assert.Equal("HALTED", current.Selection.Subtype);
|
||||
Assert.Equal("035720", current.Selection.DataCode);
|
||||
Assert.False(current.Ma5);
|
||||
Assert.False(current.Ma20);
|
||||
Assert.Null(current.CandlePeriodDays);
|
||||
|
||||
Assert.Equal("s8010", candle.BuilderKey);
|
||||
Assert.Equal("8051", candle.CutCode);
|
||||
Assert.Equal(new[] { "8051" }, candle.Aliases);
|
||||
Assert.Equal("캔들그래프-거래정지 · 120일", candle.Detail);
|
||||
Assert.Equal("chart", candle.Category);
|
||||
Assert.Equal("kosdaq", candle.Market);
|
||||
Assert.Equal("KOSDAQ_STOCK", candle.Selection.GroupCode);
|
||||
Assert.Equal("다온테크", candle.Selection.Subject);
|
||||
Assert.Equal("MA5,MA20", candle.Selection.GraphicType);
|
||||
Assert.Equal("TRADING_HALT", candle.Selection.Subtype);
|
||||
Assert.Equal("035720", candle.Selection.DataCode);
|
||||
Assert.True(candle.Ma5);
|
||||
Assert.True(candle.Ma20);
|
||||
Assert.Equal(120, candle.CandlePeriodDays);
|
||||
Assert.Equal("1/1", candle.PageText);
|
||||
Assert.True(candle.IsEnabled);
|
||||
Assert.Equal(6, candle.FadeDuration);
|
||||
Assert.Equal("legacy-trading-halt-workflow", candle.Source);
|
||||
Assert.Equal(candle.DraftId, candle.ToLegacyPlaylistEntry().EntryId);
|
||||
|
||||
var method = typeof(LegacyTradingHaltWorkflowController).GetMethod(
|
||||
nameof(LegacyTradingHaltWorkflowController.MaterializeSelectedAction));
|
||||
Assert.NotNull(method);
|
||||
Assert.Collection(
|
||||
method.GetParameters(),
|
||||
parameter => Assert.Equal("actionId", parameter.Name));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => controller.MaterializeSelectedAction("8051-caller-override"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSort_FirstClickIsDescendingThenAscending_AndKeepsSelection()
|
||||
{
|
||||
var service = new DelegateTradingHaltService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(
|
||||
SearchResult(query, KospiAlpha, KospiBeta, KosdaqAlpha))
|
||||
};
|
||||
var controller = new LegacyTradingHaltWorkflowController(service);
|
||||
var source = await controller.SearchAsync(string.Empty);
|
||||
controller.SelectResult(source.Results[0].SelectionId);
|
||||
|
||||
var descending = controller.ToggleNameSort();
|
||||
var ascending = controller.ToggleNameSort();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "가온전자", "한빛반도체", "다온테크" },
|
||||
source.Results.Select(row => row.DisplayName));
|
||||
Assert.Equal(LegacyTradingHaltNameSort.Descending, descending.NameSort);
|
||||
Assert.Equal(
|
||||
new[] { "한빛반도체", "다온테크", "가온전자" },
|
||||
descending.Results.Select(row => row.DisplayName));
|
||||
Assert.Equal(LegacyTradingHaltNameSort.Ascending, ascending.NameSort);
|
||||
Assert.Equal(
|
||||
new[] { "가온전자", "다온테크", "한빛반도체" },
|
||||
ascending.Results.Select(row => row.DisplayName));
|
||||
Assert.Equal("005930", ascending.Selected?.StockCode);
|
||||
Assert.True(Assert.Single(ascending.Results, row => row.IsSelected).IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LateSearchResponse_CannotReplaceNewerTradingHaltResult()
|
||||
{
|
||||
var first = new TaskCompletionSource<TradingHaltSelectionResult>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var second = new TaskCompletionSource<TradingHaltSelectionResult>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var service = new DelegateTradingHaltService
|
||||
{
|
||||
Search = (query, _, _) => query == "첫째" ? first.Task : second.Task
|
||||
};
|
||||
var controller = new LegacyTradingHaltWorkflowController(service);
|
||||
|
||||
var firstRequest = controller.SearchAsync("첫째");
|
||||
var secondRequest = controller.SearchAsync("둘째");
|
||||
second.SetResult(SearchResult("둘째", KosdaqAlpha));
|
||||
var newest = await secondRequest;
|
||||
first.SetResult(SearchResult("첫째", KospiAlpha));
|
||||
var staleCompletion = await firstRequest;
|
||||
|
||||
Assert.Equal("둘째", newest.Query);
|
||||
Assert.Equal("다온테크", Assert.Single(newest.Results).DisplayName);
|
||||
Assert.Equal("둘째", staleCompletion.Query);
|
||||
Assert.Equal("다온테크", Assert.Single(staleCompletion.Results).DisplayName);
|
||||
Assert.Equal("둘째", controller.Current.Query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidIdentityOrSourceOrder_IsRejectedAndLeavesErrorSnapshot()
|
||||
{
|
||||
var duplicateName = new TradingHaltSelection(
|
||||
TradingHaltMarket.Kospi,
|
||||
"005931",
|
||||
KospiAlpha.DisplayName);
|
||||
var service = new DelegateTradingHaltService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(
|
||||
SearchResult(query, KospiAlpha, duplicateName))
|
||||
};
|
||||
var controller = new LegacyTradingHaltWorkflowController(service);
|
||||
|
||||
await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
|
||||
() => controller.SearchAsync(string.Empty));
|
||||
|
||||
Assert.Equal(LegacyTradingHaltRequestStatus.Error, controller.Current.Status);
|
||||
Assert.Empty(controller.Current.Results);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TradingHaltSnapshots_AreImmutableAcrossLaterQueries()
|
||||
{
|
||||
var service = new DelegateTradingHaltService
|
||||
{
|
||||
Search = (query, _, _) => Task.FromResult(
|
||||
query == "A"
|
||||
? SearchResult(query, KospiAlpha)
|
||||
: SearchResult(query, KosdaqAlpha))
|
||||
};
|
||||
var controller = new LegacyTradingHaltWorkflowController(service);
|
||||
var before = await controller.SearchAsync("A");
|
||||
|
||||
var after = await controller.SearchAsync("B");
|
||||
|
||||
Assert.Equal("가온전자", Assert.Single(before.Results).DisplayName);
|
||||
Assert.Equal("다온테크", Assert.Single(after.Results).DisplayName);
|
||||
var rows = Assert.IsAssignableFrom<ICollection<LegacyTradingHaltResultView>>(
|
||||
before.Results);
|
||||
Assert.Throws<NotSupportedException>(() => rows.Clear());
|
||||
var actions = Assert.IsAssignableFrom<ICollection<LegacyTradingHaltActionView>>(
|
||||
before.Actions);
|
||||
Assert.Throws<NotSupportedException>(() => actions.Add(before.Actions[0]));
|
||||
}
|
||||
|
||||
private static TradingHaltSelectionResult SearchResult(
|
||||
string query,
|
||||
params TradingHaltSelection[] selections) =>
|
||||
new(
|
||||
query,
|
||||
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
|
||||
selections,
|
||||
false);
|
||||
|
||||
private sealed class DelegateTradingHaltService : ITradingHaltSelectionService
|
||||
{
|
||||
public Func<string, int, CancellationToken, Task<TradingHaltSelectionResult>> Search
|
||||
{
|
||||
get;
|
||||
init;
|
||||
} = (query, _, _) => Task.FromResult(SearchResult(query));
|
||||
|
||||
public string? SearchQuery { get; private set; }
|
||||
|
||||
public int SearchMaximum { get; private set; }
|
||||
|
||||
public Task<TradingHaltSelectionResult> SearchAsync(
|
||||
string query = "",
|
||||
int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SearchQuery = query;
|
||||
SearchMaximum = maximumResults;
|
||||
return Search(query, maximumResults, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user