From 18d40e892f9c6d7cb3d1db7dd5dfc83d69d9f4ed Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 28 Jul 2026 17:26:48 +0900 Subject: [PATCH] fix: advance NEXT past unplayable cuts --- .../Playout/Scenes/LegacyPlayoutWorkflow.cs | 173 ++++++++++++++++-- .../LegacyOperatorPlayoutModels.cs | 4 + .../LegacyBridgeProtocol.cs | 2 + .../MainWindow.Playout.cs | 30 +-- .../Web/app.js | 26 ++- tests/LegacyParityWeb/thin-client.test.cjs | 31 ++++ .../LegacyPlayoutWorkflowTests.cs | 83 ++++++++- .../LegacyPlayoutBridgeTests.cs | 4 + .../LegacyPlayoutNativeContractTests.cs | 8 +- 9 files changed, 323 insertions(+), 38 deletions(-) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs index 0c6d29e..08edaed 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs @@ -194,7 +194,17 @@ public sealed record LegacyPlayoutWorkflowState( string? BuilderKey, int CurrentPageItemCount, IReadOnlyList PreviewFields, - bool IsPageNavigationEnabled); + bool IsPageNavigationEnabled) +{ + /// + /// Stable playlist identity of the scene that is actually on air. NEXT can move + /// to a known-unplayable row without changing + /// this identity. + /// + public int OnAirCueIndexZeroBased { get; init; } = -1; + + public string? OnAirEntryId { get; init; } +} /// /// Reproduces legacy PREPARE/TAKE IN/NEXT/TAKE OUT state transitions above IPlayoutEngine. @@ -212,6 +222,8 @@ public sealed class LegacyPlayoutWorkflow private IReadOnlyList _playlist = []; private ActivePage? _prepared; private ActivePage? _onAir; + private int _nextCursorIndexZeroBased = -1; + private string? _nextCursorEntryId; private int _engineClearRequested; private long _lastObservedEngineStatusSequence = long.MinValue; @@ -360,6 +372,7 @@ public sealed class LegacyPlayoutWorkflow _prepared = null; _onAir = null; _playlist = []; + ResetNextCursor(); State = EmptyState; } @@ -386,6 +399,7 @@ public sealed class LegacyPlayoutWorkflow { _playlist = snapshot; _prepared = page; + SetNextCursor(page.CueIndexZeroBased); PublishState(); } @@ -448,8 +462,13 @@ public sealed class LegacyPlayoutWorkflow // Once PREPARE succeeds the engine owns this scene. Keep that prepared // state if TAKE IN fails or its outcome is unknown; never reload or retry. + var hadOnAirScene = _onAir is not null; _playlist = snapshot; _prepared = page; + if (!hadOnAirScene) + { + SetNextCursor(page.CueIndexZeroBased); + } PublishState(); var takeIn = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false); @@ -457,6 +476,7 @@ public sealed class LegacyPlayoutWorkflow { _onAir = page; _prepared = null; + SetNextCursor(page.CueIndexZeroBased); PublishState(); } @@ -518,6 +538,7 @@ public sealed class LegacyPlayoutWorkflow { _onAir = refreshed; _prepared = null; + SetNextCursor(refreshed.CueIndexZeroBased); PublishState(); } @@ -593,26 +614,72 @@ public sealed class LegacyPlayoutWorkflow return result; } - var targetIndex = FindNextEnabledCue(_onAir.CueIndexZeroBased + 1); + var previousCursorIndex = _nextCursorIndexZeroBased; + var previousCursorEntryId = _nextCursorEntryId; + var cursorIndex = ResolveNextCursorIndex(_onAir.CueIndexZeroBased); + var targetIndex = FindNextEnabledCue(cursorIndex + 1); if (targetIndex is null) { PublishState(LegacyWorkflowNextKind.EndOfPlaylist); return Rejected(PlayoutOperation.Next, "플레이리스트의 마지막 장면입니다."); } - var nextCue = await CreatePageAsync( - _playlist, - targetIndex.Value, - pageIndexZeroBased: 0, - cancellationToken).ConfigureAwait(false); - var nextResult = await _engine.NextAsync(nextCue.Page.Cue, cancellationToken) - .ConfigureAwait(false); + // MainForm advanced m_crow before Show_PlayList attempted to build the + // selected scene. Keep that progress cursor independent from _onAir: a + // known-unplayable row consumes exactly one NEXT, while the previous + // scene remains the truthful PGM identity. + SetNextCursor(targetIndex.Value); + PublishState(); + + ActivePage nextCue; + try + { + nextCue = await CreatePageAsync( + _playlist, + targetIndex.Value, + pageIndexZeroBased: 0, + cancellationToken).ConfigureAwait(false); + } + catch (LegacySceneDataException) + { + // This is a deterministic, pre-dispatch failure for this row. Keep + // the cursor on it so the following NEXT starts after it. + throw; + } + catch + { + RestoreNextCursor(previousCursorIndex, previousCursorEntryId); + PublishState(); + throw; + } + + PlayoutResult nextResult; + try + { + nextResult = await _engine.NextAsync(nextCue.Page.Cue, cancellationToken) + .ConfigureAwait(false); + } + catch + { + RestoreNextCursor(previousCursorIndex, previousCursorEntryId); + PublishState(); + throw; + } + if (nextResult.IsSuccess) { _onAir = nextCue; _prepared = null; PublishState(); } + else if (nextResult.Code != PlayoutResultCode.Rejected) + { + // A deterministic rejection means this specific row was not sent and + // may be left behind. Cancellation, unavailability, failure, timeout, + // and OutcomeUnknown do not prove a row-specific no-output result. + RestoreNextCursor(previousCursorIndex, previousCursorEntryId); + PublishState(); + } return nextResult; } @@ -666,7 +733,29 @@ public sealed class LegacyPlayoutWorkflow } } + var retainedCursorEntryId = _nextCursorEntryId; _playlist = candidate; + var retainedCursorIndex = retainedCursorEntryId is null + ? -1 + : candidate + .Select(static (entry, index) => (entry, index)) + .Where(item => string.Equals( + item.entry.EntryId, + retainedCursorEntryId, + StringComparison.Ordinal)) + .Select(static item => item.index) + .DefaultIfEmpty(-1) + .Single(); + if (retainedCursorIndex >= currentIndex) + { + SetNextCursor(retainedCursorIndex); + } + else + { + // If a mutable future tail removed the last attempted row, resume from + // the still-verified on-air anchor in the newly adopted list. + SetNextCursor(currentIndex); + } PublishState(); return true; } @@ -729,6 +818,7 @@ public sealed class LegacyPlayoutWorkflow _prepared = null; _onAir = null; _playlist = []; + ResetNextCursor(); State = EmptyState; } @@ -880,6 +970,55 @@ public sealed class LegacyPlayoutWorkflow return Array.AsReadOnly(entries); } + private int ResolveNextCursorIndex(int fallbackIndex) + { + if (_nextCursorEntryId is not null && + _nextCursorIndexZeroBased >= 0 && + _nextCursorIndexZeroBased < _playlist.Count && + string.Equals( + _playlist[_nextCursorIndexZeroBased].EntryId, + _nextCursorEntryId, + StringComparison.Ordinal)) + { + return _nextCursorIndexZeroBased; + } + + SetNextCursor(fallbackIndex); + return _nextCursorIndexZeroBased; + } + + private void SetNextCursor(int index) + { + if (index < 0 || index >= _playlist.Count) + { + throw new InvalidOperationException("The NEXT cursor is outside the retained playlist."); + } + + _nextCursorIndexZeroBased = index; + _nextCursorEntryId = _playlist[index].EntryId; + } + + private void RestoreNextCursor(int index, string? entryId) + { + if (entryId is not null && + index >= 0 && + index < _playlist.Count && + string.Equals(_playlist[index].EntryId, entryId, StringComparison.Ordinal)) + { + _nextCursorIndexZeroBased = index; + _nextCursorEntryId = entryId; + return; + } + + ResetNextCursor(); + } + + private void ResetNextCursor() + { + _nextCursorIndexZeroBased = -1; + _nextCursorEntryId = null; + } + private int? FindNextEnabledCue(int startIndex) => FindNextEnabledCue(_playlist, startIndex); @@ -910,13 +1049,14 @@ public sealed class LegacyPlayoutWorkflow var pageCount = active.Plan?.TotalPages ?? 1; var pageSize = active.Page.PageSize is { } size ? (int)size : 0; var isLastPage = active.PageIndexZeroBased + 1 >= pageCount; + var cursorIndex = ResolveNextCursorIndex(active.CueIndexZeroBased); var nextKind = forcedNextKind ?? (!isLastPage ? LegacyWorkflowNextKind.PageNext - : FindNextEnabledCue(active.CueIndexZeroBased + 1).HasValue + : FindNextEnabledCue(cursorIndex + 1).HasValue ? LegacyWorkflowNextKind.PlaylistNext : LegacyWorkflowNextKind.EndOfPlaylist); State = new LegacyPlayoutWorkflowState( - active.CueIndexZeroBased, + cursorIndex, _prepared?.Page.Cue.SceneName, _onAir?.Page.Cue.SceneName, active.PageIndexZeroBased, @@ -925,7 +1065,7 @@ public sealed class LegacyPlayoutWorkflow active.Page.ItemCount, isLastPage, nextKind, - _playlist[active.CueIndexZeroBased].EntryId, + _playlist[cursorIndex].EntryId, active.Page.BuilderKey, active.Plan is null ? active.Page.ItemCount @@ -933,7 +1073,13 @@ public sealed class LegacyPlayoutWorkflow pageSize, Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))), active.Page.PreviewFields ?? Array.Empty(), - active.IsPageNavigationEnabled); + active.IsPageNavigationEnabled) + { + OnAirCueIndexZeroBased = _onAir?.CueIndexZeroBased ?? -1, + OnAirEntryId = _onAir is null + ? null + : _playlist[_onAir.CueIndexZeroBased].EntryId + }; } private static bool IsSafeToken(string? value) => @@ -1008,6 +1154,7 @@ public sealed class LegacyPlayoutWorkflow _prepared = null; _onAir = null; _playlist = []; + ResetNextCursor(); State = EmptyState; } } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs index 65dc82a..a0df07a 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs @@ -55,6 +55,10 @@ public sealed record LegacyOperatorPlayoutSnapshot( bool CanChangeBackground, string Message) { + public int OnAirCueIndexZeroBased { get; init; } = -1; + + public string? OnAirEntryId { get; init; } + public bool IsTakeOutCompletionPending { get; init; } public DateTimeOffset? RefreshNextAtUtc { get; init; } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs index bf4e4ec..bd3720d 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -1193,6 +1193,8 @@ public static class LegacyBridgeProtocol playout.OnAirCode, playout.CurrentCueIndexZeroBased, playout.CurrentEntryId, + playout.OnAirCueIndexZeroBased, + playout.OnAirEntryId, playout.PageIndexZeroBased, playout.PageCount, playout.PageSize, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs index 524f7fe..aa159ed 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -804,22 +804,22 @@ public sealed partial class MainWindow // the newly appended row is then guaranteed to be in the future tail. if (string.IsNullOrWhiteSpace(status.OnAirSceneName) || string.IsNullOrWhiteSpace(workflow.OnAirCutCode) || - string.IsNullOrWhiteSpace(workflow.CurrentEntryId) || - workflow.CurrentCueIndexZeroBased < 0) + string.IsNullOrWhiteSpace(workflow.OnAirEntryId) || + workflow.OnAirCueIndexZeroBased < 0) { return false; } var playlist = _controller.Current.Playlist; - var currentIndex = workflow.CurrentCueIndexZeroBased; + var currentIndex = workflow.OnAirCueIndexZeroBased; return currentIndex < playlist.Count && string.Equals( playlist[currentIndex].RowId, - workflow.CurrentEntryId, + workflow.OnAirEntryId, StringComparison.Ordinal) && playlist.Count(row => string.Equals( row.RowId, - workflow.CurrentEntryId, + workflow.OnAirEntryId, StringComparison.Ordinal)) == 1; } @@ -849,8 +849,8 @@ public sealed partial class MainWindow return true; } - if (string.IsNullOrWhiteSpace(workflow.CurrentEntryId) || - workflow.CurrentCueIndexZeroBased < 0) + if (string.IsNullOrWhiteSpace(workflow.OnAirEntryId) || + workflow.OnAirCueIndexZeroBased < 0) { return false; } @@ -860,7 +860,7 @@ public sealed partial class MainWindow .Select(static (row, index) => (row, index)) .Where(candidate => string.Equals( candidate.row.RowId, - workflow.CurrentEntryId, + workflow.OnAirEntryId, StringComparison.Ordinal)) .Select(static candidate => candidate.index) .Take(2) @@ -871,7 +871,7 @@ public sealed partial class MainWindow } var currentIndex = currentMatches[0]; - if (currentIndex != workflow.CurrentCueIndexZeroBased) + if (currentIndex != workflow.OnAirCueIndexZeroBased) { return false; } @@ -895,22 +895,22 @@ public sealed partial class MainWindow // evidence does not establish a safe checkbox contract for that phase. if (string.IsNullOrWhiteSpace(status.OnAirSceneName) || string.IsNullOrWhiteSpace(workflow.OnAirCutCode) || - string.IsNullOrWhiteSpace(workflow.CurrentEntryId) || - workflow.CurrentCueIndexZeroBased < 0) + string.IsNullOrWhiteSpace(workflow.OnAirEntryId) || + workflow.OnAirCueIndexZeroBased < 0) { return false; } var playlist = _controller.Current.Playlist; - var currentIndex = workflow.CurrentCueIndexZeroBased; + var currentIndex = workflow.OnAirCueIndexZeroBased; return currentIndex < playlist.Count && string.Equals( playlist[currentIndex].RowId, - workflow.CurrentEntryId, + workflow.OnAirEntryId, StringComparison.Ordinal) && playlist.Count(row => string.Equals( row.RowId, - workflow.CurrentEntryId, + workflow.OnAirEntryId, StringComparison.Ordinal)) == 1; } @@ -1132,6 +1132,8 @@ public sealed partial class MainWindow "송출 어댑터를 사용할 수 없습니다."); return snapshot with { + OnAirCueIndexZeroBased = workflow?.OnAirCueIndexZeroBased ?? -1, + OnAirEntryId = workflow?.OnAirEntryId, IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false, RefreshNextAtUtc = refresh.NextAtUtc, RefreshLastSuccessAtUtc = refresh.LastSuccessAtUtc, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index cc60b00..5c74c78 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -805,9 +805,12 @@ if (selectedIndices.length === 0) return false; const playout = state.playout; if (!playout || playout.phase === "idle") return true; - const currentIndex = Number(playout.currentCueIndexZeroBased); - return Number.isSafeInteger(currentIndex) && currentIndex >= 0 && - selectedIndices.every(function (index) { return index > currentIndex; }); + const onAirIndex = Number(playout.onAirCueIndexZeroBased); + const protectedIndex = Number.isSafeInteger(onAirIndex) && onAirIndex >= 0 + ? onAirIndex + : Number(playout.currentCueIndexZeroBased); + return Number.isSafeInteger(protectedIndex) && protectedIndex >= 0 && + selectedIndices.every(function (index) { return index > protectedIndex; }); } function isCutSelectionLocked(state) { @@ -832,19 +835,30 @@ playout.currentEntryId.length > 0 ? playout.currentEntryId : null; const currentIndex = playout && Number.isInteger(playout.currentCueIndexZeroBased) ? playout.currentCueIndexZeroBased : -1; + const hasExplicitOnAirIdentity = playout && ( + Object.prototype.hasOwnProperty.call(playout, "onAirEntryId") || + Object.prototype.hasOwnProperty.call(playout, "onAirCueIndexZeroBased")); + const onAirEntryId = playout && typeof playout.onAirEntryId === "string" && + playout.onAirEntryId.length > 0 ? playout.onAirEntryId : null; + const onAirIndex = playout && Number.isInteger(playout.onAirCueIndexZeroBased) + ? playout.onAirCueIndexZeroBased : -1; const isCurrent = hasActiveScene && (currentEntryId !== null ? currentEntryId === row.rowId : currentIndex === position); - const pageCount = isCurrent && Number.isInteger(playout.pageCount) && + const isOnAir = phase === "program" && (hasExplicitOnAirIdentity + ? (onAirEntryId !== null ? onAirEntryId === row.rowId : onAirIndex === position) + : isCurrent); + const ownsPage = phase === "program" ? isOnAir : isCurrent; + const pageCount = ownsPage && Number.isInteger(playout.pageCount) && playout.pageCount > 0 ? playout.pageCount : 0; - const pageIndex = isCurrent && Number.isInteger(playout.pageIndexZeroBased) && + const pageIndex = ownsPage && Number.isInteger(playout.pageIndexZeroBased) && playout.pageIndexZeroBased >= 0 && playout.pageIndexZeroBased < pageCount ? playout.pageIndexZeroBased : -1; return { isSelected: row.isSelected === true, isCurrent: isCurrent, - isOnAir: isCurrent && phase === "program", + isOnAir: isOnAir, pageText: pageIndex >= 0 ? String(pageIndex + 1) + "/" + String(pageCount) : row.pageText, diff --git a/tests/LegacyParityWeb/thin-client.test.cjs b/tests/LegacyParityWeb/thin-client.test.cjs index e0c7403..30d0f85 100644 --- a/tests/LegacyParityWeb/thin-client.test.cjs +++ b/tests/LegacyParityWeb/thin-client.test.cjs @@ -116,6 +116,7 @@ test("program keeps cut append available while structural playlist edits stay lo playout: { phase: "program", currentCueIndexZeroBased: 0, + onAirCueIndexZeroBased: 0, isBusy: false, outcomeUnknown: false, isPlayCompletionPending: false, @@ -211,6 +212,8 @@ test("playlist keeps candidate, selection, and pink on-air states independent", phase: "program", currentEntryId: "row-current", currentCueIndexZeroBased: 1, + onAirEntryId: "row-current", + onAirCueIndexZeroBased: 1, pageIndexZeroBased: 1, pageCount: 3 } @@ -237,6 +240,34 @@ test("playlist keeps candidate, selection, and pink on-air states independent", assert.equal(presentation({ playout: { ...state.playout, currentEntryId: null } }, { rowId: "row-fallback", isSelected: false, pageText: "1/3" }, 1).isCurrent, true); + + const failedNextState = { + playout: { + ...state.playout, + currentEntryId: "row-unplayable", + currentCueIndexZeroBased: 2, + onAirEntryId: "row-current", + onAirCueIndexZeroBased: 1 + } + }; + assert.deepEqual(presentation(failedNextState, { + rowId: "row-unplayable", isSelected: false, pageText: "1/1" + }, 2), { + isSelected: false, + isCurrent: true, + isOnAir: false, + pageText: "1/1", + focusKey: "program:row-unplayable:-1:0" + }); + assert.deepEqual(presentation(failedNextState, { + rowId: "row-current", isSelected: false, pageText: "1/3" + }, 1), { + isSelected: false, + isCurrent: false, + isOnAir: true, + pageText: "2/3", + focusKey: "" + }); assert.match(app, /presentation\.isOnAir \? " on-air"/); assert.match(app, /presentation\.isSelected \|\| isCandidate \? " selected"/); assert.match(app, /presentation\.pageText/); diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs index 1e3f700..0164fd9 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs @@ -312,6 +312,78 @@ public sealed class LegacyPlayoutWorkflowTests Assert.DoesNotContain("Next:5074:p0", engine.Calls); } + [Fact] + public async Task Next_WhenEnabledMiddleCueIsUnplayable_ConsumesOneNextAndFollowingNextPlaysThirdCue() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null), + [LegacyPlaylistEntry.UnresolvedCutCode] = (1, null), + ["5088"] = (1, null) + }); + provider.UnplayableCutCodes.Add(LegacyPlaylistEntry.UnresolvedCutCode); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("first", "5001"), + new("unplayable", LegacyPlaylistEntry.UnresolvedCutCode), + new("third", "5088") + ]; + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + + await Assert.ThrowsAsync(() => workflow.NextAsync()); + + Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("unplayable", workflow.State.CurrentEntryId); + Assert.Equal(0, workflow.State.OnAirCueIndexZeroBased); + Assert.Equal("first", workflow.State.OnAirEntryId); + Assert.Equal("5001", workflow.State.OnAirCutCode); + Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind); + Assert.Equal( + ["5001:0", LegacyPlaylistEntry.UnresolvedCutCode + ":0"], + provider.Calls); + Assert.DoesNotContain(engine.Calls, call => call.StartsWith("Next:", StringComparison.Ordinal)); + + var result = await workflow.NextAsync(); + + Assert.True(result.IsSuccess); + Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("third", workflow.State.CurrentEntryId); + Assert.Equal(2, workflow.State.OnAirCueIndexZeroBased); + Assert.Equal("third", workflow.State.OnAirEntryId); + Assert.Equal("5088", workflow.State.OnAirCutCode); + Assert.Equal("Next:5088:p0", engine.Calls[^1]); + } + + [Fact] + public async Task Next_WhenOutcomeIsUnknown_DoesNotAdvanceTheProgressCursor() + { + var engine = new RecordingEngine(); + engine.NextResults.Enqueue(PlayoutResultCode.OutcomeUnknown); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null), + ["5088"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("first", "5001"), + new("second", "5088") + ]; + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + + var result = await workflow.NextAsync(); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); + Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("first", workflow.State.CurrentEntryId); + Assert.Equal(0, workflow.State.OnAirCueIndexZeroBased); + Assert.Equal("first", workflow.State.OnAirEntryId); + Assert.Equal("5001", workflow.State.OnAirCutCode); + } + [Fact] public async Task Next_WhenProgramAllRowsAreDisabled_EndsWithoutDispatchingTheEngine() { @@ -1128,6 +1200,8 @@ public sealed class LegacyPlayoutWorkflowTests public List SelectionGraphicTypes { get; } = []; + public HashSet UnplayableCutCodes { get; } = new(StringComparer.Ordinal); + public Func? SceneNameOverride { get; init; } public string? BuilderKey { get; init; } @@ -1142,6 +1216,11 @@ public sealed class LegacyPlayoutWorkflowTests cancellationToken.ThrowIfCancellationRequested(); Calls.Add($"{entry.CutCode}:{pageIndexZeroBased}"); SelectionGraphicTypes.Add(entry.Selection?.GraphicType); + if (UnplayableCutCodes.Contains(entry.CutCode)) + { + throw new LegacySceneDataException("The selected playlist row is not playable."); + } + var definition = definitions[entry.CutCode]; var name = SceneNameOverride?.Invoke(entry.CutCode, pageIndexZeroBased) ?? entry.CutCode; return Task.FromResult(new LegacySceneCuePage( @@ -1199,6 +1278,8 @@ public sealed class LegacyPlayoutWorkflowTests public Queue TakeInResults { get; } = []; + public Queue NextResults { get; } = []; + public Queue UpdateOnAirResults { get; } = []; public Action? PrepareCallback { get; set; } @@ -1248,7 +1329,7 @@ public sealed class LegacyPlayoutWorkflowTests CancellationToken cancellationToken = default) { Calls.Add("Next:" + Describe(cue)); - return Success(PlayoutOperation.Next); + return Complete(PlayoutOperation.Next, NextResults); } public Task UpdateOnAirAsync( diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs index 3770101..5952efb 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs @@ -183,6 +183,8 @@ public sealed class LegacyPlayoutBridgeTests CanChangeBackground: false, Message: "준비 완료") { + OnAirCueIndexZeroBased = -1, + OnAirEntryId = null, IsTakeOutCompletionPending = true, RefreshNextAtUtc = new DateTimeOffset( 2026, 7, 17, 2, 3, 4, TimeSpan.Zero), @@ -203,6 +205,8 @@ public sealed class LegacyPlayoutBridgeTests Assert.Equal(2, projected.GetProperty("pageCount").GetInt32()); Assert.Equal("기본.vrv", projected.GetProperty("backgroundFileName").GetString()); Assert.Equal("entry-1", projected.GetProperty("currentEntryId").GetString()); + Assert.Equal(-1, projected.GetProperty("onAirCueIndexZeroBased").GetInt32()); + Assert.Equal(JsonValueKind.Null, projected.GetProperty("onAirEntryId").ValueKind); Assert.True(projected.GetProperty("isTakeOutCompletionPending").GetBoolean()); Assert.Equal( new DateTimeOffset(2026, 7, 17, 2, 3, 4, TimeSpan.Zero), diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs index 34d46bb..a7d7132 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs @@ -527,9 +527,9 @@ public sealed class LegacyPlayoutNativeContractTests StringComparison.Ordinal); Assert.Contains("workflow.OnAirCutCode", programEnabledAdmission, StringComparison.Ordinal); - Assert.Contains("workflow.CurrentEntryId", programEnabledAdmission, + Assert.Contains("workflow.OnAirEntryId", programEnabledAdmission, StringComparison.Ordinal); - Assert.Contains("workflow.CurrentCueIndexZeroBased", programEnabledAdmission, + Assert.Contains("workflow.OnAirCueIndexZeroBased", programEnabledAdmission, StringComparison.Ordinal); Assert.Contains("IsPlaylistMutationIdle(status, workflow)", programEnabledAdmission, StringComparison.Ordinal); @@ -582,9 +582,9 @@ public sealed class LegacyPlayoutNativeContractTests StringComparison.Ordinal); Assert.Contains("workflow.OnAirCutCode", safeTailAdmission, StringComparison.Ordinal); - Assert.Contains("workflow.CurrentEntryId", safeTailAdmission, + Assert.Contains("workflow.OnAirEntryId", safeTailAdmission, StringComparison.Ordinal); - Assert.Contains("workflow.CurrentCueIndexZeroBased", safeTailAdmission, + Assert.Contains("workflow.OnAirCueIndexZeroBased", safeTailAdmission, StringComparison.Ordinal); Assert.Contains("currentIndex < playlist.Count", safeTailAdmission, StringComparison.Ordinal);