feat: align legacy operator and playout behavior

This commit is contained in:
2026-07-15 21:04:05 +09:00
parent 06a16e5741
commit 83398044c6
77 changed files with 6377 additions and 391 deletions

View File

@@ -0,0 +1,169 @@
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyCutKeyboardParityTests
{
[Fact]
public async Task ControlNavigationMovesOnlyFocusAndShiftKeepsTheNativeAnchor()
{
var controller = await CreateReadyControllerAsync();
controller.CutPointerDown(2, false, false, 1, 47, 1_000);
var controlDown = controller.CutKeyDown(
LegacyCutKeyboardKey.ArrowDown,
control: true,
shift: false);
var shiftSpace = controller.CutKeyDown(
LegacyCutKeyboardKey.Space,
control: false,
shift: true);
var plainDown = controller.CutKeyDown(
LegacyCutKeyboardKey.ArrowDown,
control: false,
shift: false);
var shiftUp = controller.CutKeyDown(
LegacyCutKeyboardKey.ArrowUp,
control: false,
shift: true);
AssertCutState(controlDown, [2], focused: 3);
AssertCutState(shiftSpace, [2, 3], focused: 3);
AssertCutState(plainDown, [4], focused: 4);
AssertCutState(shiftUp, [3, 4], focused: 3);
}
[Fact]
public async Task ControlShiftNavigationAddsRangeWithoutDiscardingExistingSelection()
{
var controller = await CreateReadyControllerAsync();
controller.CutPointerDown(1, false, false, 1, 24, 1_000);
controller.CutPointerDown(4, true, false, 1, 93, 2_000);
controller.CutKeyDown(
LegacyCutKeyboardKey.ArrowUp,
control: true,
shift: false);
var state = controller.CutKeyDown(
LegacyCutKeyboardKey.ArrowUp,
control: true,
shift: true);
AssertCutState(state, [1, 2, 3, 4], focused: 2);
}
[Fact]
public async Task HomeAndEndUsePhysicalBoundariesIncludingSeparatorRows()
{
var controller = await CreateReadyControllerAsync();
controller.CutPointerDown(2, false, false, 1, 47, 1_000);
var end = controller.CutKeyDown(
LegacyCutKeyboardKey.End,
control: false,
shift: false);
var controlHome = controller.CutKeyDown(
LegacyCutKeyboardKey.Home,
control: true,
shift: false);
var shiftSpace = controller.CutKeyDown(
LegacyCutKeyboardKey.Space,
control: false,
shift: true);
var last = end.CutRows.Count - 1;
AssertCutState(end, [last], focused: last);
AssertCutState(controlHome, [last], focused: 0);
AssertCutState(shiftSpace, Enumerable.Range(0, last + 1), focused: 0);
}
[Fact]
public async Task ControlSpaceTogglesFocusedRowAndMovesTheAnchor()
{
var controller = await CreateReadyControllerAsync();
controller.CutPointerDown(1, false, false, 1, 24, 1_000);
var toggledOff = controller.CutKeyDown(
LegacyCutKeyboardKey.Space,
control: true,
shift: false);
controller.CutKeyDown(
LegacyCutKeyboardKey.ArrowDown,
control: true,
shift: false);
var extended = controller.CutKeyDown(
LegacyCutKeyboardKey.Space,
control: false,
shift: true);
AssertCutState(toggledOff, [], focused: 1);
AssertCutState(extended, [1, 2], focused: 2);
}
[Fact]
public async Task KeyboardSelectionFeedsTheSameNativeDropAuthority()
{
var controller = await CreateReadyControllerAsync();
var selected = controller.CutKeyDown(
LegacyCutKeyboardKey.Space,
control: false,
shift: false);
var dropped = controller.DropSelectedCutsOnPlaylist();
AssertCutState(selected, [0], focused: 0);
Assert.Equal(
LegacyStockCutCatalog.Rows[0].RawLabel,
Assert.Single(dropped.Playlist).RawCutLabel);
}
[Fact]
public void KeyboardSelectionIsIgnoredUntilAStockIsSelected()
{
var controller = new LegacyOperatorController(new OneStockLookup());
var before = controller.Current;
var after = controller.CutKeyDown(
LegacyCutKeyboardKey.End,
control: false,
shift: false);
Assert.Equal(before.Revision, after.Revision);
Assert.DoesNotContain(after.CutRows, row => row.IsFocused || row.IsSelected);
}
private static async Task<LegacyOperatorController> CreateReadyControllerAsync()
{
var controller = new LegacyOperatorController(new OneStockLookup());
await controller.SearchStocksAsync("stock", LegacySearchTrigger.Button);
controller.SelectStock(0);
return controller;
}
private static void AssertCutState(
LegacyOperatorSnapshot snapshot,
IEnumerable<int> selected,
int focused)
{
Assert.Equal(
selected,
snapshot.CutRows
.Where(row => row.IsSelected)
.Select(row => row.PhysicalIndex));
Assert.Equal(
focused,
Assert.Single(snapshot.CutRows, row => row.IsFocused).PhysicalIndex);
}
private sealed class OneStockLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(
query,
["Stock"],
[new LegacyStockIdentity(
LegacyDomesticStockMarket.Kospi,
"Stock",
"000001")]));
}
}

View File

@@ -125,33 +125,25 @@ public sealed class LegacyFixedActionCatalogTests
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)
[Fact]
public void Materialize_LeavesMovingAverageStateOutOfTheActionIdentity()
{
var result = LegacyFixedActionCatalog.Default.Materialize(
"fixed-136",
new DateTimeOffset(2026, 7, 15, 9, 0, 0, TimeSpan.FromHours(9)),
new LegacyMovingAverageSelection(ma5, ma20));
new DateTimeOffset(2026, 7, 15, 9, 0, 0, TimeSpan.FromHours(9)));
Assert.Equal(expected, result.Selection.GraphicType);
Assert.Equal(new LegacyMovingAverageSelection(ma5, ma20), result.MovingAverages);
Assert.Equal(string.Empty, result.Selection.GraphicType);
Assert.Equal("", result.Action.Selection.GraphicType);
}
[Fact]
public void Materialize_IgnoresMovingAverageFlagsForOtherBuilders()
public void Materialize_RetainsGraphicTypeForOtherBuilders()
{
var result = LegacyFixedActionCatalog.Default.Materialize(
"fixed-001",
DateTimeOffset.Now,
new LegacyMovingAverageSelection(true, true));
DateTimeOffset.Now);
Assert.Equal("1열판기본", result.Selection.GraphicType);
Assert.Equal(default, result.MovingAverages);
}
[Fact]
@@ -166,8 +158,7 @@ public sealed class LegacyFixedActionCatalogTests
{
var before = catalog.Materialize(
action.Id,
beforeBoundary,
new LegacyMovingAverageSelection(true, true));
beforeBoundary);
Assert.DoesNotContain('$', before.Selection.GroupCode);
Assert.DoesNotContain('$', before.Selection.Subject);
Assert.DoesNotContain('$', before.Selection.GraphicType);
@@ -187,7 +178,7 @@ public sealed class LegacyFixedActionCatalogTests
}
else if (action.SupportsMovingAverages)
{
Assert.Equal("MA5,MA20", before.Selection.GraphicType);
Assert.Equal(action.Selection.GraphicType, before.Selection.GraphicType);
}
else
{

View File

@@ -181,9 +181,12 @@ public sealed class LegacyOperatorControllerTests
var candleRow = candle.Playlist[1];
Assert.Equal("8051", candleRow.ToPlayoutEntry().CutCode);
Assert.Equal(
"MA5",
string.Empty,
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
candleRow.ToPlayoutEntry().Selection).GraphicType);
Assert.Equal(
"MA5",
controller.CreatePlayoutRequest().Playlist[1].Selection!.GraphicType);
}
[Fact]
@@ -242,17 +245,24 @@ public sealed class LegacyOperatorControllerTests
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
var rows = controller.Current.Playlist;
controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false);
controller.SelectPlaylistRow(rows[1].RowId, control: true, shift: false);
var last = controller.SelectPlaylistBoundary(last: true);
Assert.True(last.Playlist[0].IsEnabled);
Assert.True(last.Playlist[2].IsSelected);
Assert.True(last.Playlist[2].IsActive);
Assert.Equal(
rows.Take(2).Select(row => row.RowId),
last.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
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);
Assert.True(first.Playlist[0].IsActive);
Assert.Equal(
rows.Take(2).Select(row => row.RowId),
first.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
}
[Fact]
@@ -307,7 +317,7 @@ public sealed class LegacyOperatorControllerTests
}
[Fact]
public void MovingAverageFlagsAreCapturedInFixedCandleSelection()
public void FixedCandleIdentityStaysStableWhileF8SamplesCurrentMovingAverages()
{
var controller = new LegacyOperatorController(Lookup());
var candle = LegacyFixedActionCatalog.Default
@@ -319,11 +329,23 @@ public sealed class LegacyOperatorControllerTests
var state = controller.ActivateFixedAction(candle.Id);
Assert.Equal(
"MA20",
string.Empty,
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
Assert.Single(state.Playlist).ToPlayoutEntry().Selection).GraphicType);
Assert.Equal(
"MA20",
Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType);
Assert.False(state.MovingAverages.Ma5);
Assert.True(state.MovingAverages.Ma20);
controller.SetMovingAverages(ma5: true, ma20: false);
Assert.Equal(
"MA5",
Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType);
Assert.Equal(
string.Empty,
Assert.Single(controller.Current.Playlist).PlayoutSelection!.GraphicType);
}
[Fact]
@@ -356,6 +378,9 @@ public sealed class LegacyOperatorControllerTests
Assert.Equal("현재가", row.Subtype);
Assert.Equal("005930", row.StockCode);
Assert.Equal("5001", row.ToPlayoutEntry().CutCode);
Assert.True(row.IsActive);
Assert.False(row.IsSelected);
Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
@@ -558,6 +583,89 @@ public sealed class LegacyOperatorControllerTests
Assert.All(state.Playlist, row => Assert.True(row.IsSelected));
}
[Fact]
public async Task CheckboxActivationChangesF8CandidateWithoutReplacingDeleteSelection()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
controller.SelectPlaylistRow(ids[0], control: false, shift: false);
controller.SelectPlaylistRow(ids[1], control: true, shift: false);
var state = controller.ActivatePlaylistRow(ids[2]);
Assert.Equal(
ids[..2],
state.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
Assert.Equal(ids[2], Assert.Single(state.Playlist, row => row.IsActive).RowId);
Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
public async Task CheckboxActivationDoesNotMoveTheFollowingShiftSelectionAnchor()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
controller.SelectPlaylistRow(ids[1], control: false, shift: false);
controller.ActivatePlaylistRow(ids[3]);
var range = controller.SelectPlaylistRow(ids[2], control: false, shift: true);
Assert.Equal(
ids[1..3],
range.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
Assert.Equal(ids[2], Assert.Single(range.Playlist, row => row.IsActive).RowId);
}
[Fact]
public async Task HomeAndEndChangeOnlyTheF8CandidateAndKeepProgramPageProjection()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
var rows = controller.Current.Playlist;
controller.SelectPlaylistRow(rows[1].RowId, control: false, shift: false);
controller.SelectPlaylistRow(rows[3].RowId, control: true, shift: false);
controller.ReflectSuccessfulPlayout(
rows[1].RowId,
pageIndexZeroBased: 1,
pageCount: 3,
synchronizeOperatorSelection: false);
var home = controller.SelectPlaylistBoundary(last: false);
Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
Assert.Equal("2/3", home.Playlist[1].PageText);
Assert.True(home.Playlist[0].IsActive);
Assert.Equal(
[rows[1].RowId, rows[3].RowId],
home.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
var end = controller.SelectPlaylistBoundary(last: true);
Assert.Equal(3, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
Assert.Equal("2/3", end.Playlist[1].PageText);
Assert.True(end.Playlist[3].IsActive);
var shift = controller.SelectPlaylistRow(
rows[2].RowId,
control: false,
shift: true);
Assert.Equal(
[rows[1].RowId, rows[2].RowId],
shift.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
}
[Fact]
public async Task FirstAppendedRowIsAnF8CandidateButNotAnImplicitDeleteSelection()
{
var controller = await ControllerWithPlaylistAsync(0);
var before = controller.Current;
Assert.True(Assert.Single(before.Playlist).IsActive);
Assert.DoesNotContain(before.Playlist, row => row.IsSelected);
Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
var after = controller.DeleteSelectedPlaylistRows();
Assert.Equal(before.Revision, after.Revision);
Assert.Single(after.Playlist);
}
[Fact]
public async Task PlaylistEnabledSupportsIndividualToggleAndAllRows()
{
@@ -591,6 +699,73 @@ public sealed class LegacyOperatorControllerTests
[initial[0].RowId, initial[2].RowId],
state.Playlist.Select(row => row.RowId));
Assert.DoesNotContain(state.Playlist, row => row.IsSelected);
Assert.Equal(initial[2].RowId, Assert.Single(state.Playlist, row => row.IsActive).RowId);
Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
public async Task DeletePreservesAnIndependentCheckboxCandidateByStableIdentity()
{
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[1].RowId, control: true, shift: false);
controller.ActivatePlaylistRow(initial[3].RowId);
var state = controller.DeleteSelectedPlaylistRows();
Assert.Equal(
[initial[2].RowId, initial[3].RowId],
state.Playlist.Select(row => row.RowId));
Assert.DoesNotContain(state.Playlist, row => row.IsSelected);
Assert.Equal(initial[3].RowId, Assert.Single(state.Playlist, row => row.IsActive).RowId);
Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
public async Task DeleteActiveCandidateChoosesTheNextSurvivorThenThePreviousAtEnd()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
var initial = controller.Current.Playlist.ToArray();
controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false);
var afterMiddle = controller.DeleteSelectedPlaylistRows();
Assert.Equal(initial[2].RowId, Assert.Single(afterMiddle.Playlist, row => row.IsActive).RowId);
Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
controller.SelectPlaylistRow(initial[3].RowId, control: false, shift: false);
var afterLast = controller.DeleteSelectedPlaylistRows();
Assert.Equal(initial[2].RowId, Assert.Single(afterLast.Playlist, row => row.IsActive).RowId);
Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
public async Task ShiftSelectionAndDeleteAreNotLimitedToTheOriginalHundredRowBuffers()
{
var controller = new LegacyOperatorController(Lookup());
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(1, false, false, 1, 24, 1_000);
for (var index = 0; index < 101; index++)
{
controller.DropSelectedCutsOnPlaylist();
}
var rows = controller.Current.Playlist;
Assert.Equal(101, rows.Count);
controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false);
var selected = controller.SelectPlaylistRow(
rows[^1].RowId,
control: false,
shift: true);
Assert.Equal(101, selected.Playlist.Count(row => row.IsSelected));
var deleted = controller.DeleteSelectedPlaylistRows();
Assert.Empty(deleted.Playlist);
Assert.Throws<InvalidOperationException>(() => controller.CreatePlayoutRequest());
}
[Fact]
@@ -610,6 +785,21 @@ public sealed class LegacyOperatorControllerTests
Assert.Equal(unchangedRevision, stillEmpty.Revision);
}
[Fact]
public async Task FirstAppendAfterDeleteAllReestablishesTheVisibleF8Candidate()
{
var controller = await ControllerWithPlaylistAsync(0, 1);
controller.DeleteAllPlaylistRows();
controller.CutPointerDown(2, false, false, 1, 47, 10_000);
var state = controller.DropSelectedCutsOnPlaylist();
var row = Assert.Single(state.Playlist);
Assert.True(row.IsActive);
Assert.False(row.IsSelected);
Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
public async Task MoveUsesOnlyActiveRowAndCollapsesMultiSelectionToMovedRow()
{
@@ -626,12 +816,17 @@ public sealed class LegacyOperatorControllerTests
Assert.Equal(
initial[2].RowId,
Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
Assert.Equal(initial[2].ToPlayoutEntry(), moved.Playlist[1].ToPlayoutEntry());
Assert.Equal(initial[2].PageText, moved.Playlist[1].PageText);
Assert.Equal(initial[2].RawCutLabel, moved.Playlist[1].RawCutLabel);
Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
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);
Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
@@ -651,7 +846,7 @@ public sealed class LegacyOperatorControllerTests
}
[Fact]
public async Task PlaylistDragMovesNoncontiguousSelectionAsOneOrderedBlockAndIsIdempotent()
public async Task PlaylistDragMovesOnlySourceRowCollapsesSelectionAndIsIdempotent()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5, 6);
var initial = controller.Current.Playlist.ToArray();
@@ -666,16 +861,19 @@ public sealed class LegacyOperatorControllerTests
Assert.Equal(
[
initial[0].RowId,
initial[1].RowId,
initial[2].RowId,
initial[4].RowId,
initial[1].RowId,
initial[3].RowId,
initial[5].RowId
],
moved.Playlist.Select(row => row.RowId));
Assert.Equal(
[initial[1].RowId, initial[3].RowId],
moved.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
initial[3].RowId,
Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
Assert.Equal(
initial[3].RowId,
Assert.Single(moved.Playlist, row => row.IsActive).RowId);
var revision = moved.Revision;
var duplicate = controller.ReorderPlaylistRows(
@@ -686,6 +884,9 @@ public sealed class LegacyOperatorControllerTests
Assert.Equal(
moved.Playlist.Select(row => row.RowId),
duplicate.Playlist.Select(row => row.RowId));
Assert.Equal(
initial[3].RowId,
Assert.Single(duplicate.Playlist, row => row.IsSelected).RowId);
}
[Fact]

View File

@@ -1,4 +1,6 @@
using MMoneyCoderSharp.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
@@ -36,6 +38,229 @@ public sealed class LegacyOperatorPlayoutRequestTests
Assert.Equal("005930", entry.Selection?.DataCode));
}
[Theory]
[InlineData(false, false, "")]
[InlineData(true, false, "MA5")]
[InlineData(false, true, "MA20")]
[InlineData(true, true, "MA5,MA20")]
public async Task CandleAddedBeforeToggleSamplesCurrentMovingAveragesAtF8Request(
bool ma5,
bool ma20,
string expectedGraphicType)
{
var controller = new LegacyOperatorController(new StaticLookup());
await controller.SearchStocksAsync("stock", LegacySearchTrigger.Button);
controller.SelectStock(0);
var candle = LegacyStockCutCatalog.Rows.First(row =>
!row.IsSeparator &&
LegacyStockCutCatalog.ResolveCutAlias(row.RawLabel, isNxt: false) == "8051");
AddCut(controller, candle.PhysicalIndex, 1_000);
var rawBeforeToggle = Assert.Single(controller.Current.Playlist);
var rawGraphicType = rawBeforeToggle.ToPlayoutEntry().Selection!.GraphicType;
controller.SetMovingAverages(ma5, ma20);
var entry = Assert.Single(controller.CreatePlayoutRequest().Playlist);
Assert.Equal(expectedGraphicType, entry.Selection!.GraphicType);
Assert.Equal(rawGraphicType,
Assert.Single(controller.Current.Playlist).ToPlayoutEntry().Selection!.GraphicType);
Assert.Equal(
new LegacySceneMovingAverageSelection(ma5, ma20),
entry.MovingAverageSelectionSource!.Current);
}
[Fact]
public async Task MovingAverageOverlayDoesNotAlterNonCandleRequest()
{
var controller = new LegacyOperatorController(new StaticLookup());
await controller.SearchStocksAsync("stock", LegacySearchTrigger.Button);
controller.SelectStock(0);
var current = LegacyStockCutCatalog.Rows.First(row =>
!row.IsSeparator &&
LegacyStockCutCatalog.ResolveCutAlias(row.RawLabel, isNxt: false) == "5001");
AddCut(controller, current.PhysicalIndex, 1_000);
var raw = Assert.Single(controller.Current.Playlist).ToPlayoutEntry();
controller.SetMovingAverages(ma5: false, ma20: false);
var requested = Assert.Single(controller.CreatePlayoutRequest().Playlist);
Assert.Equal(raw.Selection, requested.Selection);
Assert.Null(requested.MovingAverageSelectionSource);
}
[Theory]
[InlineData(0)]
[InlineData(6)]
[InlineData(19)]
public async Task RequestAppliesCurrentGlobalDissolveIndexToEveryExistingRow(
int globalFadeDuration)
{
var controller = await CreateControllerWithCutsAsync(3);
Assert.All(controller.Current.Playlist, row => Assert.Equal(6, row.FadeDuration));
var request = controller.CreatePlayoutRequest(globalFadeDuration);
Assert.All(request.Playlist, entry =>
Assert.Equal(globalFadeDuration, entry.FadeDuration));
Assert.All(controller.Current.Playlist, row => Assert.Equal(6, row.FadeDuration));
}
[Theory]
[InlineData(-1)]
[InlineData(20)]
[InlineData(60)]
public async Task RequestRejectsGlobalDissolveIndexOutsideOriginalComboRange(
int globalFadeDuration)
{
var controller = await CreateControllerWithCutsAsync(1);
Assert.Throws<ArgumentOutOfRangeException>(() =>
controller.CreatePlayoutRequest(globalFadeDuration));
}
[Theory]
[InlineData("5단 표그래프", ScenePageSize.Five)]
[InlineData("6종목 현재가", ScenePageSize.Six)]
[InlineData("12종목 현재가", ScenePageSize.Twelve)]
public void ExactLegacyGraphicTypeFreezesTheOperatorPageSuffix(
string graphicType,
ScenePageSize expectedPageSize)
{
var entry = OperatorRow(graphicType, "2/3").ToPlayoutEntry();
Assert.Equal(expectedPageSize, entry.PageNavigation?.PageSize);
Assert.Equal(3, entry.PageNavigation?.FrozenPageCount);
Assert.True(entry.PageNavigation?.IsEnabled);
}
[Theory]
[InlineData("5단 표그래프 ")]
[InlineData("5단표그래프")]
[InlineData("five-row")]
public void NonCanonicalGraphicTypeExplicitlyDisablesPageNext(string graphicType)
{
var entry = OperatorRow(graphicType, "1/9").ToPlayoutEntry();
Assert.NotNull(entry.PageNavigation);
Assert.False(entry.PageNavigation.IsEnabled);
Assert.Null(entry.PageNavigation.PageSize);
Assert.Null(entry.PageNavigation.FrozenPageCount);
}
[Theory]
[InlineData("1/0")]
[InlineData("1/21")]
[InlineData("2/1")]
[InlineData("1 /2")]
public void CanonicalPagedGraphicRejectsInvalidOperatorPageText(string pageText)
{
var row = OperatorRow("5단 표그래프", pageText);
Assert.Throws<InvalidOperationException>(row.ToPlayoutEntry);
}
[Fact]
public async Task NextProgressUpdatesProgramPageWithoutReplacingOperatorSelection()
{
var controller = await CreateControllerWithCutsAsync(3);
var rows = controller.Current.Playlist;
controller.SelectPlaylistRow(rows[2].RowId, control: false, shift: false);
var state = controller.ReflectSuccessfulPlayout(
rows[0].RowId,
pageIndexZeroBased: 1,
pageCount: 3,
synchronizeOperatorSelection: false);
Assert.Equal("2/3", state.Playlist[0].PageText);
Assert.True(state.Playlist[2].IsSelected);
Assert.False(state.Playlist[0].IsSelected);
Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
public async Task TakeSkipMovesOnlyTheF8CandidateAndPreservesDeleteSelection()
{
var controller = await CreateControllerWithCutsAsync(2);
var rows = controller.Current.Playlist;
controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false);
controller.ReflectSuccessfulPlayout(
rows[0].RowId,
pageIndexZeroBased: 2,
pageCount: 3,
synchronizeOperatorSelection: false);
var state = controller.ReflectSuccessfulPlayout(
rows[1].RowId,
pageIndexZeroBased: 0,
pageCount: 2,
synchronizeOperatorSelection: true);
Assert.Equal("1/2", state.Playlist[1].PageText);
Assert.True(state.Playlist[0].IsSelected);
Assert.False(state.Playlist[1].IsSelected);
Assert.True(state.Playlist[1].IsActive);
Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
public async Task SuccessfulF8DoesNotReplaceTheRowsAccumulatedForDelete()
{
var controller = await CreateControllerWithCutsAsync(3);
var rows = controller.Current.Playlist;
controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false);
controller.SelectPlaylistRow(rows[2].RowId, control: true, shift: false);
var reflected = controller.ReflectSuccessfulPlayout(
rows[2].RowId,
pageIndexZeroBased: 0,
pageCount: 1,
synchronizeOperatorSelection: true);
Assert.Equal(
[rows[0].RowId, rows[2].RowId],
reflected.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
Assert.True(reflected.Playlist[2].IsActive);
var deleted = controller.DeleteSelectedPlaylistRows();
Assert.Equal(rows[1].RowId, Assert.Single(deleted.Playlist).RowId);
}
private static async Task<LegacyOperatorController> CreateControllerWithCutsAsync(int count)
{
var controller = new LegacyOperatorController(new StaticLookup());
await controller.SearchStocksAsync("?쇱꽦", LegacySearchTrigger.Button);
controller.SelectStock(0);
for (var index = 0; index < count; index++)
{
AddCut(controller, index, 1_000 + (index * 1_000));
}
return controller;
}
private static LegacyOperatorPlaylistRow OperatorRow(
string graphicType,
string pageText) =>
new(
"row-1",
true,
"테마",
"반도체",
graphicType,
"현재가",
pageText,
"00123",
"paged-cut",
6,
SceneAlias: "5074",
PlayoutSelection: new(
"테마",
"반도체",
graphicType,
"현재가",
"00123"));
private static void AddCut(
LegacyOperatorController controller,
int physicalIndex,

View File

@@ -34,13 +34,16 @@ public sealed class LegacyOverseasOperatorControllerTests
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("캔들그래프", 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);
Assert.Equal(
"MA20",
Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType);
}
[Fact]
@@ -72,11 +75,15 @@ public sealed class LegacyOverseasOperatorControllerTests
Assert.Equal(2, stockState.Playlist.Count);
var stockRow = stockState.Playlist[1];
Assert.Equal("8061", stockRow.ToPlayoutEntry().CutCode);
Assert.Equal("캔들그래프", stockRow.GraphicType);
Assert.Equal("FOREIGN_STOCK", stockRow.PlayoutSelection!.GroupCode);
Assert.Equal("Apple", stockRow.PlayoutSelection.Subject);
Assert.Equal("MA5,MA20", stockRow.PlayoutSelection.GraphicType);
Assert.Equal(string.Empty, stockRow.PlayoutSelection.GraphicType);
Assert.Equal("PRICE", stockRow.PlayoutSelection.Subtype);
Assert.Equal("NAS@AAPL|US|1", stockRow.PlayoutSelection.DataCode);
Assert.Equal(
"MA5,MA20",
controller.CreatePlayoutRequest().Playlist[1].Selection!.GraphicType);
}
[Fact]

View File

@@ -0,0 +1,420 @@
using System.Text;
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyRuntimeStockCutMenuLoaderTests
{
[Fact]
public void TryParse_UsesCp949AndPreservesTrimmedPhysicalOrder()
{
const string first = "1열판기본_현재가";
Assert.Equal(first, LegacyStockCutCatalog.Rows[1].RawLabel);
var second = LegacyStockCutCatalog.Rows[0].RawLabel;
var bytes = EncodeCp949($" \r\n [종목] \r\n {first} \r\n\r\n - \r\n{second}\r\n{first}\r\n");
var loaded = LegacyRuntimeStockCutMenuLoader.TryParse(
bytes,
out var rows,
out var failure);
Assert.True(loaded);
Assert.Equal(LegacyRuntimeStockCutMenuFailure.None, failure);
Assert.Collection(
rows,
row => Assert.Equal((0, 1, first, false), Fields(row)),
row => Assert.Equal((1, null, string.Empty, true), Fields(row)),
row => Assert.Equal((2, 2, second, false), Fields(row)),
row => Assert.Equal((3, 3, first, false), Fields(row)));
}
[Fact]
public void TryLoad_ReadsAStandaloneRuntimeFileWithoutBundlingTheAsset()
{
var directory = Path.Combine(
Path.GetTempPath(),
$"mbn-stock-cut-menu-{Guid.NewGuid():N}");
var path = Path.Combine(directory, "종목.ini");
Directory.CreateDirectory(directory);
try
{
var label = LegacyStockCutCatalog.Rows[0].RawLabel;
File.WriteAllBytes(path, EncodeCp949($"[종목]\r\n{label}\r\n"));
var loaded = LegacyRuntimeStockCutMenuLoader.TryLoad(
path,
out var rows,
out var failure);
Assert.True(loaded);
Assert.Equal(LegacyRuntimeStockCutMenuFailure.None, failure);
var row = Assert.Single(rows);
Assert.Equal((0, 1, label, false), Fields(row));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Theory]
[InlineData(50, true, LegacyRuntimeStockCutMenuFailure.None)]
[InlineData(51, false, LegacyRuntimeStockCutMenuFailure.TooManyRows)]
public void TryParse_EnforcesTheLegacyFiftyPhysicalRowLimit(
int rowCount,
bool expectedSuccess,
LegacyRuntimeStockCutMenuFailure expectedFailure)
{
var label = LegacyStockCutCatalog.Rows[0].RawLabel;
var text = LegacyRuntimeStockCutMenuLoader.StockSectionHeader + "\r\n" +
string.Join("\r\n", Enumerable.Repeat(label, rowCount));
var loaded = LegacyRuntimeStockCutMenuLoader.TryParse(
EncodeCp949(text),
out var rows,
out var failure);
Assert.Equal(expectedSuccess, loaded);
Assert.Equal(expectedFailure, failure);
Assert.Equal(expectedSuccess ? rowCount : 0, rows.Count);
}
public static TheoryData<string, LegacyRuntimeStockCutMenuFailure> MalformedMenus => new()
{
{ string.Empty, LegacyRuntimeStockCutMenuFailure.InvalidFormat },
{ "[종목", LegacyRuntimeStockCutMenuFailure.InvalidFormat },
{ LegacyStockCutCatalog.Rows[0].RawLabel, LegacyRuntimeStockCutMenuFailure.InvalidFormat },
{ "[다른메뉴]\r\n-", LegacyRuntimeStockCutMenuFailure.InvalidFormat },
{ "[종목]\r\n[다른메뉴]", LegacyRuntimeStockCutMenuFailure.InvalidFormat },
{ "[종목]", LegacyRuntimeStockCutMenuFailure.EmptyMenu },
{ "[종목]\r\n-", LegacyRuntimeStockCutMenuFailure.EmptyMenu },
{ "[종목]\r\n지원하지않는_컷", LegacyRuntimeStockCutMenuFailure.UnknownCut }
};
[Theory]
[MemberData(nameof(MalformedMenus))]
public void TryParse_FailsClosedWithoutReturningPartialRows(
string text,
LegacyRuntimeStockCutMenuFailure expectedFailure)
{
var loaded = LegacyRuntimeStockCutMenuLoader.TryParse(
EncodeCp949(text),
out var rows,
out var failure);
Assert.False(loaded);
Assert.Empty(rows);
Assert.Equal(expectedFailure, failure);
}
[Fact]
public void TryParse_RejectsAnIncompleteCp949Sequence()
{
var loaded = LegacyRuntimeStockCutMenuLoader.TryParse(
new byte[] { 0x81 },
out var rows,
out var failure);
Assert.False(loaded);
Assert.Empty(rows);
Assert.Equal(LegacyRuntimeStockCutMenuFailure.InvalidEncoding, failure);
}
[Fact]
public void TryLoad_MissingOrBlankPathLetsTheCallerUseTheBuiltInCatalog()
{
var missing = Path.Combine(
Path.GetTempPath(),
$"missing-cut-menu-{Guid.NewGuid():N}.ini");
Assert.False(LegacyRuntimeStockCutMenuLoader.TryLoad(
missing,
out var missingRows,
out var missingFailure));
Assert.Empty(missingRows);
Assert.Equal(LegacyRuntimeStockCutMenuFailure.FileNotFound, missingFailure);
Assert.False(LegacyRuntimeStockCutMenuLoader.TryLoad(
" ",
out var blankRows,
out var blankFailure));
Assert.Empty(blankRows);
Assert.Equal(LegacyRuntimeStockCutMenuFailure.InvalidPath, blankFailure);
Assert.NotEmpty(LegacyStockCutCatalog.Rows);
}
[Fact]
public void TryLoad_RejectsAnOversizedFileBeforeAllocatingOrDecodingItsContent()
{
var path = Path.Combine(
Path.GetTempPath(),
$"oversized-cut-menu-{Guid.NewGuid():N}.ini");
try
{
using (var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write))
{
stream.SetLength(LegacyRuntimeStockCutMenuLoader.MaximumFileBytes + 1L);
}
var loaded = LegacyRuntimeStockCutMenuLoader.TryLoad(
path,
out var rows,
out var failure);
Assert.False(loaded);
Assert.Empty(rows);
Assert.Equal(LegacyRuntimeStockCutMenuFailure.FileTooLarge, failure);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void ResolveFromBaseDirectory_MissingFileReturnsBoundedPathFreeFallback()
{
var directory = Path.Combine(
Path.GetTempPath(),
$"mbn-stock-cut-composition-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
var composition =
LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(directory);
Assert.True(composition.UsesBuiltInCatalog);
Assert.Equal(LegacyRuntimeStockCutMenuSource.BuiltIn, composition.Source);
Assert.Same(LegacyStockCutCatalog.Rows, composition.Rows);
Assert.Equal(
LegacyRuntimeStockCutMenuFailure.FileNotFound,
composition.Failure);
Assert.Equal(
LegacyRuntimeStockCutMenuLoader.FallbackWarningMessage,
composition.WarningMessage);
var warning = Assert.IsType<string>(composition.WarningMessage);
Assert.InRange(warning.Length, 1, 80);
Assert.DoesNotContain(directory, warning,
StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("종목.ini", warning,
StringComparison.Ordinal);
Assert.DoesNotContain('\\', warning);
Assert.DoesNotContain('/', warning);
var controller = new LegacyOperatorController(
new EmptyStockLookup(),
cutRows: composition.Rows);
var warningState = controller.ReportWarning(warning);
Assert.Equal(LegacyOperatorStatusKind.Warning, warningState.StatusKind);
Assert.Equal(composition.WarningMessage, warningState.StatusMessage);
var resourceDirectory = Path.Combine(directory, "Res");
Directory.CreateDirectory(resourceDirectory);
File.WriteAllBytes(
Path.Combine(resourceDirectory, "종목.ini"),
EncodeCp949("[종목]\r\n지원하지않는_컷\r\n"));
var invalid =
LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(directory);
Assert.True(invalid.UsesBuiltInCatalog);
Assert.Same(LegacyStockCutCatalog.Rows, invalid.Rows);
Assert.Equal(LegacyRuntimeStockCutMenuFailure.UnknownCut, invalid.Failure);
Assert.Equal(composition.WarningMessage, invalid.WarningMessage);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ResolveFromBaseDirectory_ValidRuntimeFileReturnsOnlyItsRows()
{
var directory = Path.Combine(
Path.GetTempPath(),
$"mbn-stock-cut-composition-{Guid.NewGuid():N}");
var resourceDirectory = Path.Combine(directory, "Res");
Directory.CreateDirectory(resourceDirectory);
try
{
const string label = "1열판기본_현재가";
File.WriteAllBytes(
Path.Combine(resourceDirectory, "종목.ini"),
EncodeCp949($"[종목]\r\n{label}\r\n"));
var composition =
LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(directory);
Assert.False(composition.UsesBuiltInCatalog);
Assert.Equal(LegacyRuntimeStockCutMenuSource.Packaged, composition.Source);
Assert.Equal(LegacyRuntimeStockCutMenuFailure.None, composition.Failure);
Assert.Null(composition.WarningMessage);
Assert.Equal(label, Assert.Single(composition.Rows).RawLabel);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ResolveFromCandidates_ExternalOverrideTakesPrecedenceOverPackagedMenu()
{
var paths = CreateCandidatePaths();
try
{
WriteMenu(paths.ExternalPath, "1열판기본_현재가");
WriteMenu(paths.PackagedPath, "1열판기본_예상체결가");
var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
paths.ExternalPath,
paths.PackagedBaseDirectory);
Assert.Equal(
LegacyRuntimeStockCutMenuSource.ExternalOverride,
composition.Source);
Assert.False(composition.UsesBuiltInCatalog);
Assert.Null(composition.WarningMessage);
Assert.Equal(
"1열판기본_현재가",
Assert.Single(composition.Rows).RawLabel);
}
finally
{
Directory.Delete(paths.Root, recursive: true);
}
}
[Fact]
public void ResolveFromCandidates_MissingExternalContinuesToPackagedMenu()
{
var paths = CreateCandidatePaths();
try
{
WriteMenu(paths.PackagedPath, "1열판기본_예상체결가");
var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
paths.ExternalPath,
paths.PackagedBaseDirectory);
Assert.Equal(LegacyRuntimeStockCutMenuSource.Packaged, composition.Source);
Assert.False(composition.UsesBuiltInCatalog);
Assert.Null(composition.WarningMessage);
Assert.Equal(
"1열판기본_예상체결가",
Assert.Single(composition.Rows).RawLabel);
}
finally
{
Directory.Delete(paths.Root, recursive: true);
}
}
[Fact]
public void ResolveFromCandidates_InvalidExternalFailsClosedWithoutPackagedFallback()
{
var paths = CreateCandidatePaths();
try
{
WriteMenu(paths.ExternalPath, "지원하지않는_컷");
WriteMenu(paths.PackagedPath, "1열판기본_예상체결가");
var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
paths.ExternalPath,
paths.PackagedBaseDirectory);
Assert.Equal(LegacyRuntimeStockCutMenuSource.BuiltIn, composition.Source);
Assert.True(composition.UsesBuiltInCatalog);
Assert.Same(LegacyStockCutCatalog.Rows, composition.Rows);
Assert.Equal(LegacyRuntimeStockCutMenuFailure.UnknownCut, composition.Failure);
Assert.Equal(
LegacyRuntimeStockCutMenuLoader.FallbackWarningMessage,
composition.WarningMessage);
}
finally
{
Directory.Delete(paths.Root, recursive: true);
}
}
[Fact]
public void ResolveFromBaseDirectory_RejectsInvalidOrControlContainingPathsWithoutLeakage()
{
string?[] invalidPaths =
[
null,
string.Empty,
" ",
"relative\\package",
"C:\\sensitive-root\nprivate",
"C:\\sensitive-root\0private"
];
foreach (var invalidPath in invalidPaths)
{
var composition =
LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(invalidPath);
Assert.True(composition.UsesBuiltInCatalog);
Assert.Equal(LegacyRuntimeStockCutMenuSource.BuiltIn, composition.Source);
Assert.Same(LegacyStockCutCatalog.Rows, composition.Rows);
Assert.Equal(
LegacyRuntimeStockCutMenuFailure.InvalidPath,
composition.Failure);
Assert.Equal(
LegacyRuntimeStockCutMenuLoader.FallbackWarningMessage,
composition.WarningMessage);
Assert.DoesNotContain("sensitive-root", composition.WarningMessage!,
StringComparison.OrdinalIgnoreCase);
}
}
private static (int PhysicalIndex, int? DisplayOrdinal, string RawLabel, bool IsSeparator)
Fields(LegacyCutCatalogRow row) =>
(row.PhysicalIndex, row.DisplayOrdinal, row.RawLabel, row.IsSeparator);
private static byte[] EncodeCp949(string text)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp949 = Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
return cp949.GetBytes(text);
}
private static (
string Root,
string ExternalPath,
string PackagedBaseDirectory,
string PackagedPath) CreateCandidatePaths()
{
var root = Path.Combine(
Path.GetTempPath(),
$"mbn-stock-cut-candidates-{Guid.NewGuid():N}");
var externalPath = Path.Combine(
root,
"local",
"MBN_STOCK_WEBVIEW",
"Res",
"종목.ini");
var packagedBaseDirectory = Path.Combine(root, "package");
return (
root,
externalPath,
packagedBaseDirectory,
Path.Combine(packagedBaseDirectory, "Res", "종목.ini"));
}
private static void WriteMenu(string path, string label)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, EncodeCp949($"[종목]\r\n{label}\r\n"));
}
private sealed class EmptyStockLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
}

View File

@@ -0,0 +1,362 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyStockCutInteractionParityTests
{
[Fact]
public async Task EmptySearchButtonPreservesResultsStockAndCutSelection()
{
var lookup = new QueryLookup();
var controller = new LegacyOperatorController(lookup);
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(1, control: false, shift: false, x: 2, y: 24,
timestampMilliseconds: 1_000);
var state = await controller.SearchStocksAsync(
string.Empty,
LegacySearchTrigger.Button);
Assert.Equal(1, lookup.CallCount);
Assert.Equal(string.Empty, state.SearchText);
Assert.Equal("First stock", Assert.Single(state.SearchResults).DisplayName);
Assert.Equal(0, state.SelectedStockIndex);
Assert.Equal(
[1],
state.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
Assert.Empty(state.Playlist);
}
[Fact]
public async Task EmptyEnterQueriesAndClearsOnlyTheStockResultSelection()
{
var lookup = new QueryLookup();
var controller = new LegacyOperatorController(lookup);
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(1, control: false, shift: false, x: 2, y: 24,
timestampMilliseconds: 1_000);
var state = await controller.SearchStocksAsync(
string.Empty,
LegacySearchTrigger.Enter);
Assert.Equal(2, lookup.CallCount);
Assert.Null(state.SelectedStockIndex);
Assert.Equal("All stock", Assert.Single(state.SearchResults).DisplayName);
Assert.Equal(
[1],
state.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
}
[Fact]
public async Task RetainedCutSelectionUsesTheNewlySelectedStockAtDropTime()
{
var lookup = new QueryLookup();
var controller = new LegacyOperatorController(lookup);
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(1, control: false, shift: false, x: 2, y: 24,
timestampMilliseconds: 1_000);
await controller.SearchStocksAsync("second", LegacySearchTrigger.Button);
controller.SelectStock(0);
var state = controller.DropSelectedCutsOnPlaylist();
var row = Assert.Single(state.Playlist);
Assert.Equal("Second stock", row.StockName);
Assert.Equal("000002", row.StockCode);
Assert.Equal(LegacyStockCutCatalog.Rows[1].RawLabel, row.RawCutLabel);
}
[Fact]
public async Task OrdinaryMarketSeparatorIsIgnoredWithoutOpeningTheNxtWarning()
{
var controller = new LegacyOperatorController(new QueryLookup());
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(3, control: false, shift: false, x: 2, y: 70,
timestampMilliseconds: 1_000);
var state = controller.DropSelectedCutsOnPlaylist();
Assert.Empty(state.Playlist);
Assert.Null(state.Dialog);
}
[Fact]
public async Task InjectedRuntimeMenuIsCopiedAndDrivesViewSelectionAndDropByPhysicalOrder()
{
var injected = RuntimeMenu();
var expectedFirstLabel = injected[0].RawLabel;
var expectedLastLabel = injected[2].RawLabel;
var controller = new LegacyOperatorController(
new QueryLookup(),
cutRows: injected);
injected[0] = injected[0] with
{
RawLabel = LegacyStockCutCatalog.Rows[2].RawLabel
};
Assert.Equal(
[expectedFirstLabel, string.Empty, expectedLastLabel],
controller.Current.CutRows.Select(row => row.RawLabel));
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(0, control: false, shift: false, x: 1, y: 1,
timestampMilliseconds: 1_000);
controller.CutPointerDown(2, control: true, shift: false, x: 1, y: 47,
timestampMilliseconds: 2_000);
var dropped = controller.DropSelectedCutsOnPlaylist();
Assert.Equal(
[0, 2],
dropped.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
Assert.Equal(
[expectedFirstLabel, expectedLastLabel],
dropped.Playlist.Select(row => row.RawCutLabel));
var revision = dropped.Revision;
var invalidIndex = controller.CutPointerDown(
3,
control: false,
shift: false,
x: 1,
y: 70,
timestampMilliseconds: 3_000);
Assert.Equal(revision, invalidIndex.Revision);
}
[Fact]
public async Task InjectedRuntimeMenuPreservesTheNxtCurrentLabelRuleAfterReorder()
{
var menu = RuntimeMenu();
var controller = new LegacyOperatorController(
new NxtLookup(),
cutRows: menu);
await controller.SearchStocksAsync("nxt", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(2, control: false, shift: false, x: 1, y: 47,
timestampMilliseconds: 1_000);
var warning = controller.DropSelectedCutsOnPlaylist();
Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message);
Assert.Empty(warning.Playlist);
controller.DismissDialog();
controller.CutPointerDown(0, control: false, shift: false, x: 1, y: 1,
timestampMilliseconds: 2_000);
var accepted = controller.DropSelectedCutsOnPlaylist();
Assert.Equal("N5001", Assert.Single(accepted.Playlist).ToPlayoutEntry().CutCode);
}
[Fact]
public async Task FiftyRowRuntimeMenuCanSelectAndDropItsLastPhysicalRow()
{
var rows = Enumerable.Range(0, LegacyRuntimeStockCutMenuLoader.MaximumRows)
.Select(index => new LegacyCutCatalogRow(
index,
index + 1,
LegacyStockCutCatalog.Rows[1].RawLabel,
IsSeparator: false))
.ToArray();
var controller = new LegacyOperatorController(new QueryLookup(), cutRows: rows);
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(
LegacyRuntimeStockCutMenuLoader.MaximumRows - 1,
control: false,
shift: false,
x: 1,
y: 1,
timestampMilliseconds: 1_000);
var state = controller.DropSelectedCutsOnPlaylist();
Assert.Equal(
LegacyRuntimeStockCutMenuLoader.MaximumRows - 1,
Assert.Single(state.CutRows, row => row.IsSelected).PhysicalIndex);
Assert.Equal(
LegacyStockCutCatalog.Rows[1].RawLabel,
Assert.Single(state.Playlist).RawCutLabel);
}
[Fact]
public async Task ConsecutiveShiftGesturesKeepTheOriginalNativeListAnchor()
{
var controller = new LegacyOperatorController(new QueryLookup());
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(0, control: false, shift: false, 1, 1, 1_000);
var first = controller.CutPointerDown(
2, control: false, shift: true, 1, 47, 2_000);
var second = controller.CutPointerDown(
4, control: false, shift: true, 1, 93, 3_000);
Assert.Equal(
[0, 1, 2],
first.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
Assert.Equal(
[0, 1, 2, 3, 4],
second.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
}
[Fact]
public async Task EmptyCutListAreaClearsSelectionSoAStaleDropAddsNothing()
{
var controller = new LegacyOperatorController(new QueryLookup());
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(1, false, false, 1, 24, 1_000);
var cleared = controller.ClearCutSelection();
var dropped = controller.DropSelectedCutsOnPlaylist();
Assert.DoesNotContain(cleared.CutRows, row => row.IsSelected);
Assert.Empty(dropped.Playlist);
}
[Theory]
[InlineData(0, 2, 0, 1, 2)]
[InlineData(3, 1, 1, 2, 3)]
public async Task EmptyCutListAreaPreservesNativeAnchorForFollowingShift(
int anchor,
int target,
params int[] expected)
{
var controller = new LegacyOperatorController(new QueryLookup());
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(anchor, false, false, 1, 1, 1_000);
controller.ClearCutSelection();
var state = controller.CutPointerDown(target, false, true, 2, 2, 2_000);
Assert.Equal(
expected,
state.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
}
[Fact]
public void EmptyCutListAreaIsIgnoredUntilAStockIsSelected()
{
var controller = new LegacyOperatorController(new QueryLookup());
var revision = controller.Current.Revision;
var state = controller.ClearCutSelection();
Assert.Equal(revision, state.Revision);
}
[Fact]
public async Task BlankClickResetsLegacyAnchorEvenAfterControlClearsTheLastRow()
{
var controller = new LegacyOperatorController(new QueryLookup());
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(1, false, false, 1, 24, 1_000);
controller.CutPointerDown(1, true, false, 1, 24, 2_000);
controller.ClearCutSelection();
controller.CutPointerDown(2, false, true, 2, 47, 3_000);
var state = controller.CutPointerDown(2, false, false, 2, 47, 3_400);
Assert.Empty(state.Playlist);
Assert.Equal(
[2],
state.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
}
[Theory]
[InlineData("physical-index")]
[InlineData("display-ordinal")]
[InlineData("unknown-label")]
[InlineData("separator-shape")]
[InlineData("too-many")]
public void InjectedRuntimeMenuRejectsInvalidAuthority(string invalidCase)
{
var baseline = RuntimeMenu();
IReadOnlyList<LegacyCutCatalogRow> rows = invalidCase switch
{
"physical-index" =>
[baseline[0] with { PhysicalIndex = 1 }],
"display-ordinal" =>
[baseline[0] with { DisplayOrdinal = 2 }],
"unknown-label" =>
[baseline[0] with { RawLabel = "지원하지않는_컷" }],
"separator-shape" =>
[new LegacyCutCatalogRow(0, 1, "-", IsSeparator: true)],
"too-many" => Enumerable.Range(
0,
LegacyRuntimeStockCutMenuLoader.MaximumRows + 1)
.Select(index => new LegacyCutCatalogRow(
index,
index + 1,
LegacyStockCutCatalog.Rows[0].RawLabel,
IsSeparator: false))
.ToArray(),
_ => throw new ArgumentOutOfRangeException(nameof(invalidCase))
};
Assert.Throws<ArgumentException>(() => new LegacyOperatorController(
new QueryLookup(),
cutRows: rows));
}
private static LegacyCutCatalogRow[] RuntimeMenu() =>
[
new LegacyCutCatalogRow(
0,
1,
LegacyStockCutCatalog.Rows[1].RawLabel,
IsSeparator: false),
new LegacyCutCatalogRow(1, null, string.Empty, IsSeparator: true),
new LegacyCutCatalogRow(
2,
2,
LegacyStockCutCatalog.Rows[0].RawLabel,
IsSeparator: false)
];
private sealed class QueryLookup : ILegacyStockLookup
{
public int CallCount { get; private set; }
public Task<LegacyStockSearchData> SearchAsync(
string text,
CancellationToken cancellationToken = default)
{
CallCount++;
var (name, code) = text switch
{
"first" => ("First stock", "000001"),
"second" => ("Second stock", "000002"),
_ => ("All stock", "000003")
};
return Task.FromResult(new LegacyStockSearchData(
text,
[name],
[new LegacyStockIdentity(
LegacyDomesticStockMarket.Kospi,
name,
code)]));
}
}
private sealed class NxtLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string text,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(
text,
["Next stock(NXT)"],
[new LegacyStockIdentity(
LegacyDomesticStockMarket.NxtKospi,
"Next stock(NXT)",
"A00001")]));
}
}

View File

@@ -101,6 +101,181 @@ public sealed class LegacyTabActivationParityTests
Assert.Single(retried.Theme!.SearchResults);
}
[Theory]
[InlineData(LegacyOperatorTabId.KospiIndustry, IndustryMarket.Kospi)]
[InlineData(LegacyOperatorTabId.KosdaqIndustry, IndustryMarket.Kosdaq)]
public async Task IndustryEachActivation_ReloadsAndClearsUc2Selections(
LegacyOperatorTabId tabId,
IndustryMarket market)
{
var service = new CountingIndustryService();
var controller = new LegacyOperatorController(
Lookup(),
industryWorkflow: new LegacyIndustrySelectionWorkflow(service));
await controller.SelectTabAsync(tabId);
controller.SelectIndustry(0, doubleClick: true);
var selected = controller.SelectIndustry(1, doubleClick: true);
Assert.NotNull(selected.Industry!.SelectedIndustry);
Assert.NotNull(selected.Industry.FirstIndustry);
Assert.NotNull(selected.Industry.SecondIndustry);
await controller.SelectTabAsync(tabId);
Assert.Single(service.Markets);
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var reentered = await controller.SelectTabAsync(tabId);
Assert.Equal([market, market], service.Markets);
Assert.Equal(2, reentered.Industry!.Industries.Count);
Assert.Null(reentered.Industry.SelectedIndex);
Assert.Null(reentered.Industry.SelectedIndustry);
Assert.Null(reentered.Industry.FirstIndustry);
Assert.Null(reentered.Industry.SecondIndustry);
}
[Fact]
public async Task ComparisonEachActivation_ReloadsPairsAndClearsUc3DraftAndSearch()
{
var store = new CountingComparisonPairStore();
var controller = new LegacyOperatorController(
Lookup(),
comparisonWorkflow: new LegacyComparisonWorkflowController(
new StaticComparisonDataService(),
store));
await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
var searched = await controller.SearchComparisonDomesticAsync("semiconductor");
var results = searched.Comparison!.DomesticSearch.Results;
controller.ChooseComparisonDomesticResult(results[0].SelectionId);
var drafted = controller.ChooseComparisonDomesticResult(results[1].SelectionId);
Assert.NotNull(drafted.Comparison!.FirstTarget);
Assert.NotNull(drafted.Comparison.SecondTarget);
await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
Assert.Equal(1, store.ReadCount);
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var reentered = await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
Assert.Equal(2, store.ReadCount);
Assert.Equal(LegacyComparisonSearchStatus.Idle, reentered.Comparison!.DomesticSearch.Status);
Assert.Empty(reentered.Comparison.DomesticSearch.Results);
Assert.Equal(string.Empty, reentered.Comparison.DomesticSearch.Query);
Assert.Null(reentered.Comparison.FirstTarget);
Assert.Null(reentered.Comparison.SecondTarget);
Assert.Empty(reentered.Comparison.SavedPairs);
}
[Fact]
public async Task ComparisonReentry_InvalidatesPairLoadFromDiscardedUc3()
{
var store = new DelayedComparisonPairStore();
var controller = new LegacyOperatorController(
Lookup(),
comparisonWorkflow: new LegacyComparisonWorkflowController(
new StaticComparisonDataService(),
store));
var firstActivation = controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
await store.FirstReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
controller.SelectTab(LegacyOperatorTabId.Index);
controller.SelectTab(LegacyOperatorTabId.Comparison);
store.ReleaseFirstRead.TrySetResult();
await firstActivation;
Assert.Equal(LegacyOperatorStatusKind.Neutral, controller.Current.Comparison!.StatusKind);
Assert.Equal(string.Empty, controller.Current.Comparison.StatusMessage);
Assert.Empty(controller.Current.Comparison.SavedPairs);
var reloaded = await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
Assert.Equal(2, store.ReadCount);
Assert.Equal(LegacyOperatorStatusKind.Information, reloaded.Comparison!.StatusKind);
}
[Fact]
public async Task OverseasStocksReentry_ResetsUc5StateAndKeepsMainMovingAverages()
{
var workflow = new LegacyOverseasSelectionWorkflow(
new StaticOverseasIndustryService(),
new StaticOverseasStockService());
var controller = new LegacyOperatorController(Lookup(), overseasWorkflow: workflow);
controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
controller.SelectOverseasFixedIndex("nasdaq");
var industries = await controller.SearchOverseasIndustriesAsync("industry");
controller.SelectOverseasIndustry(
Assert.Single(industries.Overseas!.Industry.Results).SelectionId);
var stocks = await controller.SearchOverseasStocksAsync("stock");
controller.SelectOverseasStock(
Assert.Single(stocks.Overseas!.Stock.Results).SelectionId);
controller.SetMovingAverages(ma5: false, ma20: true);
controller.SelectTab(LegacyOperatorTabId.Index);
var reentered = controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
Assert.Null(reentered.Overseas!.SelectedFixedIndex);
Assert.Equal(LegacyOverseasSearchStatus.Idle, reentered.Overseas.Industry.Status);
Assert.Equal(string.Empty, reentered.Overseas.Industry.Query);
Assert.Empty(reentered.Overseas.Industry.Results);
Assert.Null(reentered.Overseas.Industry.Selected);
Assert.Equal(LegacyOverseasSearchStatus.Idle, reentered.Overseas.Stock.Status);
Assert.Equal(string.Empty, reentered.Overseas.Stock.Query);
Assert.Empty(reentered.Overseas.Stock.Results);
Assert.Null(reentered.Overseas.Stock.Selected);
Assert.Equal(
new LegacyOverseasMovingAverageSelection(false, true),
reentered.Overseas.MovingAverages);
}
[Fact]
public async Task TradingHaltReentry_ResetsUc7QuerySelectionAndSort()
{
var controller = new LegacyOperatorController(
Lookup(),
tradingHaltWorkflow: new LegacyTradingHaltWorkflowController(
new StaticTradingHaltService()));
controller.SelectTab(LegacyOperatorTabId.TradingHalt);
var searched = await controller.SearchTradingHaltsAsync("halted");
controller.SelectTradingHalt(
Assert.Single(searched.TradingHalt!.Results).SelectionId);
controller.ToggleTradingHaltSort();
controller.SetMovingAverages(ma5: false, ma20: true);
controller.SelectTab(LegacyOperatorTabId.Index);
var reentered = controller.SelectTab(LegacyOperatorTabId.TradingHalt);
Assert.Equal(LegacyTradingHaltRequestStatus.Idle, reentered.TradingHalt!.Status);
Assert.Equal(string.Empty, reentered.TradingHalt.Query);
Assert.Empty(reentered.TradingHalt.Results);
Assert.Null(reentered.TradingHalt.Selected);
Assert.Equal(LegacyTradingHaltNameSort.None, reentered.TradingHalt.NameSort);
Assert.False(reentered.TradingHalt.Ma5);
Assert.True(reentered.TradingHalt.Ma20);
}
[Fact]
public async Task OverseasReentry_InvalidatesSearchCompletionFromDiscardedUc5()
{
var service = new DelayedOverseasIndustryService();
var controller = new LegacyOperatorController(
Lookup(),
overseasWorkflow: new LegacyOverseasSelectionWorkflow(
service,
new StaticOverseasStockService()));
controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
var pending = controller.SearchOverseasIndustriesAsync("old");
await service.RequestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
controller.SelectTab(LegacyOperatorTabId.Index);
controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
service.ReleaseRequest.TrySetResult();
await pending;
Assert.Equal(LegacyOverseasSearchStatus.Idle, controller.Current.Overseas!.Industry.Status);
Assert.Equal(string.Empty, controller.Current.Overseas.Industry.Query);
Assert.Empty(controller.Current.Overseas.Industry.Results);
}
private static ILegacyStockLookup Lookup() =>
new StaticLookup(new LegacyStockSearchData(string.Empty, [], []));
@@ -213,4 +388,160 @@ public sealed class LegacyTabActivationParityTests
CancellationToken cancellationToken = default) => Task.FromResult(
new ExpertPreviewResult(identity, DateTimeOffset.UtcNow, [], false));
}
private sealed class CountingIndustryService : IIndustrySelectionService
{
public List<IndustryMarket> Markets { get; } = [];
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
IndustryMarket market,
CancellationToken cancellationToken = default)
{
Markets.Add(market);
return Task.FromResult<IReadOnlyList<IndustrySelection>>(
[
new(market, "Industry 1", "001"),
new(market, "Industry 2", "002")
]);
}
}
private sealed class StaticComparisonDataService : ILegacyComparisonDataService
{
private static readonly IReadOnlyList<LegacyComparisonTarget> Domestic =
[
LegacyComparisonTarget.CreateKrxStock(
LegacyComparisonDomesticMarket.Kospi,
"Samsung Electronics",
"005930"),
LegacyComparisonTarget.CreateKrxStock(
LegacyComparisonDomesticMarket.Kospi,
"SK Hynix",
"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 CountingComparisonPairStore : ILegacyComparisonPairStore
{
public int ReadCount { get; private set; }
public Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default)
{
ReadCount++;
return Task.FromResult<byte[]?>(null);
}
public Task WriteCp949Async(
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class DelayedComparisonPairStore : ILegacyComparisonPairStore
{
public TaskCompletionSource FirstReadStarted { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ReleaseFirstRead { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public int ReadCount { get; private set; }
public async Task<byte[]?> ReadCp949Async(
CancellationToken cancellationToken = default)
{
ReadCount++;
if (ReadCount == 1)
{
FirstReadStarted.TrySetResult();
await ReleaseFirstRead.Task.WaitAsync(cancellationToken);
}
return null;
}
public Task WriteCp949Async(
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class StaticOverseasIndustryService : IOverseasIndustryIndexSearchService
{
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) => Task.FromResult(
new OverseasIndustryIndexSearchResult(
query.Trim(),
DateTimeOffset.UtcNow,
[new OverseasIndustryIndexItem("Semiconductor", "US Semiconductor", "NAS@SOX")],
false));
}
private sealed class StaticOverseasStockService : 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", "NAS@AAPL", "US")],
false));
}
private sealed class DelayedOverseasIndustryService : IOverseasIndustryIndexSearchService
{
public TaskCompletionSource RequestStarted { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ReleaseRequest { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public async Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
RequestStarted.TrySetResult();
await ReleaseRequest.Task.WaitAsync(cancellationToken);
return new OverseasIndustryIndexSearchResult(
query.Trim(),
DateTimeOffset.UtcNow,
[new OverseasIndustryIndexItem("Old", "Old result", "NAS@OLD")],
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",
"Samsung Electronics")],
false));
}
}

View File

@@ -89,6 +89,9 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Assert.Equal("005930", row.StockCode);
Assert.Equal("5001", row.SceneAlias);
Assert.Equal("코스피", row.PlayoutSelection?.GroupCode);
Assert.True(row.IsActive);
Assert.False(row.IsSelected);
Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
}
[Fact]
@@ -156,6 +159,45 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
saved.NamedPlaylist?.LastMutationOutcome);
}
[Fact]
public async Task NamedPlaylistIdentityDoesNotChangeWhenMovingAveragesChange()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
controller.SelectTab(LegacyOperatorTabId.Index);
var candle = LegacyFixedActionCatalog.Default
.GetActions(LegacyFixedMarket.Index)
.First(action => action.SupportsMovingAverages);
controller.SetMovingAverages(ma5: false, ma20: false);
var added = controller.ActivateFixedAction(candle.Id);
var raw = Assert.Single(added.Playlist);
Assert.Equal(string.Empty, raw.PlayoutSelection!.GraphicType);
controller.SetMovingAverages(ma5: true, ma20: false);
Assert.Equal(
"MA5",
Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType);
var listed = await controller.RefreshNamedPlaylistsAsync();
controller.SelectNamedPlaylist(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
await controller.SaveCurrentNamedPlaylistAsync();
var firstStored = Assert.Single(persistence.LastReplaceItems!).Selection;
controller.SetMovingAverages(ma5: false, ma20: true);
Assert.Equal(
"MA20",
Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType);
await controller.SaveCurrentNamedPlaylistAsync();
var secondStored = Assert.Single(persistence.LastReplaceItems!).Selection;
Assert.Equal(firstStored, secondStored);
Assert.Equal(raw.GraphicType, secondStored.GraphicType);
Assert.DoesNotContain("MA5", secondStored.GraphicType, StringComparison.Ordinal);
Assert.DoesNotContain("MA20", secondStored.GraphicType, StringComparison.Ordinal);
Assert.Equal(string.Empty,
Assert.Single(controller.Current.Playlist).PlayoutSelection!.GraphicType);
}
[Fact]
public async Task Save_to_opaque_id_selects_and_writes_once_in_one_native_command()
{