fix: restore legacy playout parity and live startup

This commit is contained in:
2026-07-29 14:07:36 +09:00
parent 80b50c80b3
commit b35c000796
31 changed files with 3356 additions and 382 deletions

View File

@@ -1,9 +1,104 @@
using MMoneyCoderSharp.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyStockCutInteractionParityTests
{
public static TheoryData<
LegacyDomesticStockMarket,
int,
string,
string> DomesticStockCandleCases
{
get
{
var cases = new TheoryData<
LegacyDomesticStockMarket,
int,
string,
string>();
var cuts = new (int PhysicalIndex, string Alias, string Mode)[]
{
(14, "8035", "PRICE"),
(15, "8061", "PRICE"),
(16, "8040", "PRICE"),
(17, "8046", "PRICE"),
(18, "8051", "PRICE"),
(19, "8056", "PRICE"),
(20, "8035", "VOLUME"),
(21, "8061", "VOLUME"),
(22, "8040", "VOLUME"),
(23, "8046", "VOLUME"),
(24, "8051", "VOLUME"),
(25, "8056", "VOLUME"),
(26, "8061", "EXPECTED"),
(27, "8040", "EXPECTED"),
(28, "8046", "EXPECTED"),
(29, "8051", "EXPECTED"),
(30, "8056", "EXPECTED")
};
foreach (var market in new[]
{
LegacyDomesticStockMarket.Kospi,
LegacyDomesticStockMarket.Kosdaq
})
{
foreach (var cut in cuts)
{
cases.Add(market, cut.PhysicalIndex, cut.Alias, cut.Mode);
}
}
return cases;
}
}
[Theory]
[MemberData(nameof(DomesticStockCandleCases))]
public async Task DomesticStockCandlePointerDropCreatesCanonicalPlayoutSelection(
LegacyDomesticStockMarket market,
int physicalIndex,
string expectedAlias,
string expectedMode)
{
var stockName = market == LegacyDomesticStockMarket.Kospi
? "Samsung Electronics"
: "Kakao Games";
var stockCode = market == LegacyDomesticStockMarket.Kospi
? "005930"
: "293490";
var controller = new LegacyOperatorController(
new SingleStockLookup(market, stockName, stockCode));
await controller.SearchStocksAsync(stockName, LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(
physicalIndex,
control: false,
shift: false,
x: 2,
y: physicalIndex * 23,
timestampMilliseconds: 1_000);
var state = controller.DropSelectedCutsOnPlaylist();
var row = Assert.Single(state.Playlist);
var expectedSelection = new LegacySceneSelection(
market == LegacyDomesticStockMarket.Kospi
? "KOSPI_STOCK"
: "KOSDAQ_STOCK",
stockName,
string.Empty,
expectedMode,
stockCode);
Assert.Equal(expectedSelection, row.PlayoutSelection);
var entry = row.ToPlayoutEntry();
Assert.Equal(expectedAlias, entry.CutCode);
Assert.Equal(expectedSelection, entry.Selection);
}
[Fact]
public async Task ThreeOrderedProgramClicksSelectThenAppendExactlyOneSafeTailRow()
{
@@ -463,4 +558,25 @@ public sealed class LegacyStockCutInteractionParityTests
"Next stock(NXT)",
"A00001")]));
}
private sealed class SingleStockLookup : ILegacyStockLookup
{
private readonly LegacyStockIdentity _stock;
public SingleStockLookup(
LegacyDomesticStockMarket market,
string displayName,
string stockCode)
{
_stock = new LegacyStockIdentity(market, displayName, stockCode);
}
public Task<LegacyStockSearchData> SearchAsync(
string text,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(
text,
[_stock.DisplayName],
[_stock]));
}
}

View File

@@ -133,6 +133,119 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Assert.Equal(new NamedPlaylistPageState(1, 1), stored.Page);
}
[Theory]
[InlineData(
LegacyDomesticStockMarket.Kospi,
"코스피",
"삼성전자",
"005930",
15,
"캔들그래프",
"PRICE")]
[InlineData(
LegacyDomesticStockMarket.Kosdaq,
"코스닥",
"카카오게임즈",
"293490",
15,
"캔들그래프",
"PRICE")]
[InlineData(
LegacyDomesticStockMarket.Kospi,
"코스피",
"삼성전자",
"005930",
21,
"캔들그래프(거래량)",
"VOLUME")]
[InlineData(
LegacyDomesticStockMarket.Kosdaq,
"코스닥",
"카카오게임즈",
"293490",
21,
"캔들그래프(거래량)",
"VOLUME")]
[InlineData(
LegacyDomesticStockMarket.Kospi,
"코스피",
"삼성전자",
"005930",
26,
"캔들그래프(예상체결가)",
"EXPECTED")]
[InlineData(
LegacyDomesticStockMarket.Kosdaq,
"코스닥",
"카카오게임즈",
"293490",
26,
"캔들그래프(예상체결가)",
"EXPECTED")]
public async Task DirectDomesticStockCandleSaveAndLoadRestoresCanonicalPlayoutSelection(
LegacyDomesticStockMarket market,
string storedMarket,
string stockName,
string stockCode,
int physicalIndex,
string visibleGraphicType,
string runtimeMode)
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = new LegacyOperatorController(
new FixedStockLookup(market, stockName, stockCode),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
controller.SetMovingAverages(ma5: false, ma20: false);
await controller.SearchStocksAsync(stockName, LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(
physicalIndex,
control: false,
shift: false,
x: 2,
y: physicalIndex * 23,
timestampMilliseconds: 1_000);
var added = controller.DropSelectedCutsOnPlaylist();
var expectedCanonical = new LegacySceneSelection(
market == LegacyDomesticStockMarket.Kospi
? "KOSPI_STOCK"
: "KOSDAQ_STOCK",
stockName,
string.Empty,
runtimeMode,
stockCode);
Assert.Equal(expectedCanonical, Assert.Single(added.Playlist).PlayoutSelection);
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
controller.SelectNamedPlaylist(definitionId);
await controller.SaveCurrentNamedPlaylistAsync();
var stored = Assert.Single(persistence.LastReplaceItems!);
Assert.Equal(storedMarket, stored.Selection.GroupCode);
Assert.Equal(stockName, stored.Selection.Subject);
Assert.Equal(visibleGraphicType, stored.Selection.GraphicType);
Assert.Equal("5일", stored.Selection.Subtype);
Assert.Equal(stockCode, stored.Selection.DataCode);
Assert.Equal(new NamedPlaylistPageState(1, 1), stored.Page);
var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId);
Assert.True(loaded.CommandResult?.Succeeded);
var loadedRow = Assert.Single(loaded.Playlist);
Assert.Equal(storedMarket, loadedRow.MarketText);
Assert.Equal(stockName, loadedRow.StockName);
Assert.Equal(visibleGraphicType, loadedRow.GraphicType);
Assert.Equal("5일", loadedRow.Subtype);
Assert.Equal(stockCode, loadedRow.StockCode);
Assert.Equal("8061", loadedRow.SceneAlias);
Assert.Equal(expectedCanonical, loadedRow.PlayoutSelection);
var runtimeEntry = Assert.Single(controller.CreatePlayoutRequest().Playlist);
Assert.Equal("8061", runtimeEntry.CutCode);
Assert.Equal(expectedCanonical, runtimeEntry.Selection);
}
[Fact]
public async Task SaveFixedPagedActionPersistsInitialDatabasePageNBeforePlayout()
{
@@ -635,6 +748,31 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
saved.NamedPlaylist?.LastMutationOutcome);
}
[Fact]
public async Task SaveCurrent_PersistsTheConsumedPageProjection()
{
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 row = Assert.Single(added.Playlist);
var listed = await controller.RefreshNamedPlaylistsAsync();
controller.SelectNamedPlaylist(
listed.NamedPlaylist!.Definitions.Single().DefinitionId);
controller.ReflectSuccessfulPlayout(
row.RowId,
pageIndexZeroBased: 1,
pageCount: 3,
synchronizeOperatorSelection: false);
await controller.SaveCurrentNamedPlaylistAsync();
var stored = Assert.Single(persistence.LastReplaceItems!);
Assert.Equal("2/3", stored.Page?.ToString());
}
[Theory]
[InlineData(ThemeMarket.Krx, " Edge Theme ", " Edge Theme ")]
[InlineData(ThemeMarket.Nxt, "Robotics(NXT)", "Robotics(NXT)(NXT)")]
@@ -955,6 +1093,27 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class FixedStockLookup : ILegacyStockLookup
{
private readonly LegacyStockIdentity _stock;
public FixedStockLookup(
LegacyDomesticStockMarket market,
string displayName,
string stockCode)
{
_stock = new LegacyStockIdentity(market, displayName, stockCode);
}
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(
query,
[_stock.DisplayName],
[_stock]));
}
private sealed class FixedTimeProvider(DateTimeOffset localNow) : TimeProvider
{
private static readonly TimeZoneInfo KoreaStandardTime = TimeZoneInfo.CreateCustomTimeZone(

View File

@@ -0,0 +1,130 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyNamedPlaylistPagedPlaceholderFixtureTests
{
[Fact]
public async Task Raw5074PlaceholdersBeforeSelectedIndexCandleReproduceWholeListPreflightHazard()
{
var persistence = new ReadOnlyNamedPlaylistPersistence(
[
new NamedPlaylistStoredItem(
0,
true,
new LegacySceneSelection(
"테마",
"=========================",
"5단 표그래프",
"테마-현재가",
string.Empty),
new NamedPlaylistPageState(1, 1)),
new NamedPlaylistStoredItem(
1,
true,
new LegacySceneSelection(
"테마",
"=========================",
"5단 표그래프",
"테마-현재가",
string.Empty),
new NamedPlaylistPageState(1, 1)),
new NamedPlaylistStoredItem(
2,
true,
new LegacySceneSelection(
"코스피",
"지수",
"캔들그래프(거래량)",
"5일",
string.Empty),
new NamedPlaylistPageState(1, 1))
]);
var controller = new LegacyOperatorController(
new UnusedStockLookup(),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
var listed = await controller.RefreshNamedPlaylistsAsync();
var loaded = await controller.LoadNamedPlaylistByIdAsync(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
var candle = loaded.Playlist[2];
controller.SetMovingAverages(ma5: false, ma20: false);
controller.SelectPlaylistRow(candle.RowId, control: false, shift: false);
var request = controller.CreatePlayoutRequest();
Assert.All(loaded.Playlist, row => Assert.True(row.IsSceneResolved));
Assert.All(request.Playlist.Take(2), entry =>
{
Assert.Equal("5074", entry.CutCode);
Assert.Equal("테마", entry.Selection?.GroupCode);
Assert.Equal(ScenePageSize.Five, entry.PageNavigation?.PageSize);
});
Assert.Equal("8061", request.Playlist[2].CutCode);
Assert.Equal(
new LegacySceneSelection(
"KOSPI_INDEX",
"INDEX",
string.Empty,
"VOLUME",
string.Empty),
request.Playlist[2].Selection);
Assert.False(request.Playlist[2].PageNavigation?.IsEnabled);
Assert.Equal(2, request.SelectedIndexZeroBased);
}
private sealed class UnusedStockLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class ReadOnlyNamedPlaylistPersistence(
IReadOnlyList<NamedPlaylistStoredItem> items)
: INamedPlaylistPersistenceService
{
private const string ProgramCode = "00000004";
public bool CanMutate => false;
public Task<NamedPlaylistListResult> ListAsync(
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
CancellationToken cancellationToken = default) =>
Task.FromResult(new NamedPlaylistListResult(
DateTimeOffset.UtcNow,
[new NamedPlaylistSummary(ProgramCode, "생쇼")],
false));
public Task<string> SuggestNextProgramCodeAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult("00000005");
public Task<NamedPlaylistDocument> LoadAsync(
string programCode,
CancellationToken cancellationToken = default) =>
Task.FromResult(new NamedPlaylistDocument(
new NamedPlaylistSummary(ProgramCode, "생쇼"),
DateTimeOffset.UtcNow,
items));
public Task CreateDefinitionAsync(
string programCode,
string title,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task ReplaceItemsAsync(
string programCode,
IReadOnlyList<NamedPlaylistStoredItem> replacement,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task DeleteAsync(
string programCode,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}