fix: advance NEXT past unplayable cuts

This commit is contained in:
2026-07-28 17:26:48 +09:00
parent ea96a08ad5
commit 18d40e892f
9 changed files with 323 additions and 38 deletions

View File

@@ -194,7 +194,17 @@ public sealed record LegacyPlayoutWorkflowState(
string? BuilderKey, string? BuilderKey,
int CurrentPageItemCount, int CurrentPageItemCount,
IReadOnlyList<LegacyScenePreviewField> PreviewFields, IReadOnlyList<LegacyScenePreviewField> PreviewFields,
bool IsPageNavigationEnabled); bool IsPageNavigationEnabled)
{
/// <summary>
/// Stable playlist identity of the scene that is actually on air. NEXT can move
/// <see cref="CurrentCueIndexZeroBased"/> to a known-unplayable row without changing
/// this identity.
/// </summary>
public int OnAirCueIndexZeroBased { get; init; } = -1;
public string? OnAirEntryId { get; init; }
}
/// <summary> /// <summary>
/// Reproduces legacy PREPARE/TAKE IN/NEXT/TAKE OUT state transitions above IPlayoutEngine. /// Reproduces legacy PREPARE/TAKE IN/NEXT/TAKE OUT state transitions above IPlayoutEngine.
@@ -212,6 +222,8 @@ public sealed class LegacyPlayoutWorkflow
private IReadOnlyList<LegacyPlaylistEntry> _playlist = []; private IReadOnlyList<LegacyPlaylistEntry> _playlist = [];
private ActivePage? _prepared; private ActivePage? _prepared;
private ActivePage? _onAir; private ActivePage? _onAir;
private int _nextCursorIndexZeroBased = -1;
private string? _nextCursorEntryId;
private int _engineClearRequested; private int _engineClearRequested;
private long _lastObservedEngineStatusSequence = long.MinValue; private long _lastObservedEngineStatusSequence = long.MinValue;
@@ -360,6 +372,7 @@ public sealed class LegacyPlayoutWorkflow
_prepared = null; _prepared = null;
_onAir = null; _onAir = null;
_playlist = []; _playlist = [];
ResetNextCursor();
State = EmptyState; State = EmptyState;
} }
@@ -386,6 +399,7 @@ public sealed class LegacyPlayoutWorkflow
{ {
_playlist = snapshot; _playlist = snapshot;
_prepared = page; _prepared = page;
SetNextCursor(page.CueIndexZeroBased);
PublishState(); PublishState();
} }
@@ -448,8 +462,13 @@ public sealed class LegacyPlayoutWorkflow
// Once PREPARE succeeds the engine owns this scene. Keep that prepared // 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. // state if TAKE IN fails or its outcome is unknown; never reload or retry.
var hadOnAirScene = _onAir is not null;
_playlist = snapshot; _playlist = snapshot;
_prepared = page; _prepared = page;
if (!hadOnAirScene)
{
SetNextCursor(page.CueIndexZeroBased);
}
PublishState(); PublishState();
var takeIn = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false); var takeIn = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
@@ -457,6 +476,7 @@ public sealed class LegacyPlayoutWorkflow
{ {
_onAir = page; _onAir = page;
_prepared = null; _prepared = null;
SetNextCursor(page.CueIndexZeroBased);
PublishState(); PublishState();
} }
@@ -518,6 +538,7 @@ public sealed class LegacyPlayoutWorkflow
{ {
_onAir = refreshed; _onAir = refreshed;
_prepared = null; _prepared = null;
SetNextCursor(refreshed.CueIndexZeroBased);
PublishState(); PublishState();
} }
@@ -593,26 +614,72 @@ public sealed class LegacyPlayoutWorkflow
return result; 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) if (targetIndex is null)
{ {
PublishState(LegacyWorkflowNextKind.EndOfPlaylist); PublishState(LegacyWorkflowNextKind.EndOfPlaylist);
return Rejected(PlayoutOperation.Next, "플레이리스트의 마지막 장면입니다."); return Rejected(PlayoutOperation.Next, "플레이리스트의 마지막 장면입니다.");
} }
var nextCue = await CreatePageAsync( // MainForm advanced m_crow before Show_PlayList attempted to build the
_playlist, // selected scene. Keep that progress cursor independent from _onAir: a
targetIndex.Value, // known-unplayable row consumes exactly one NEXT, while the previous
pageIndexZeroBased: 0, // scene remains the truthful PGM identity.
cancellationToken).ConfigureAwait(false); SetNextCursor(targetIndex.Value);
var nextResult = await _engine.NextAsync(nextCue.Page.Cue, cancellationToken) PublishState();
.ConfigureAwait(false);
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) if (nextResult.IsSuccess)
{ {
_onAir = nextCue; _onAir = nextCue;
_prepared = null; _prepared = null;
PublishState(); 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; return nextResult;
} }
@@ -666,7 +733,29 @@ public sealed class LegacyPlayoutWorkflow
} }
} }
var retainedCursorEntryId = _nextCursorEntryId;
_playlist = candidate; _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(); PublishState();
return true; return true;
} }
@@ -729,6 +818,7 @@ public sealed class LegacyPlayoutWorkflow
_prepared = null; _prepared = null;
_onAir = null; _onAir = null;
_playlist = []; _playlist = [];
ResetNextCursor();
State = EmptyState; State = EmptyState;
} }
@@ -880,6 +970,55 @@ public sealed class LegacyPlayoutWorkflow
return Array.AsReadOnly(entries); 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) private int? FindNextEnabledCue(int startIndex)
=> FindNextEnabledCue(_playlist, startIndex); => FindNextEnabledCue(_playlist, startIndex);
@@ -910,13 +1049,14 @@ public sealed class LegacyPlayoutWorkflow
var pageCount = active.Plan?.TotalPages ?? 1; var pageCount = active.Plan?.TotalPages ?? 1;
var pageSize = active.Page.PageSize is { } size ? (int)size : 0; var pageSize = active.Page.PageSize is { } size ? (int)size : 0;
var isLastPage = active.PageIndexZeroBased + 1 >= pageCount; var isLastPage = active.PageIndexZeroBased + 1 >= pageCount;
var cursorIndex = ResolveNextCursorIndex(active.CueIndexZeroBased);
var nextKind = forcedNextKind ?? (!isLastPage var nextKind = forcedNextKind ?? (!isLastPage
? LegacyWorkflowNextKind.PageNext ? LegacyWorkflowNextKind.PageNext
: FindNextEnabledCue(active.CueIndexZeroBased + 1).HasValue : FindNextEnabledCue(cursorIndex + 1).HasValue
? LegacyWorkflowNextKind.PlaylistNext ? LegacyWorkflowNextKind.PlaylistNext
: LegacyWorkflowNextKind.EndOfPlaylist); : LegacyWorkflowNextKind.EndOfPlaylist);
State = new LegacyPlayoutWorkflowState( State = new LegacyPlayoutWorkflowState(
active.CueIndexZeroBased, cursorIndex,
_prepared?.Page.Cue.SceneName, _prepared?.Page.Cue.SceneName,
_onAir?.Page.Cue.SceneName, _onAir?.Page.Cue.SceneName,
active.PageIndexZeroBased, active.PageIndexZeroBased,
@@ -925,7 +1065,7 @@ public sealed class LegacyPlayoutWorkflow
active.Page.ItemCount, active.Page.ItemCount,
isLastPage, isLastPage,
nextKind, nextKind,
_playlist[active.CueIndexZeroBased].EntryId, _playlist[cursorIndex].EntryId,
active.Page.BuilderKey, active.Page.BuilderKey,
active.Plan is null active.Plan is null
? active.Page.ItemCount ? active.Page.ItemCount
@@ -933,7 +1073,13 @@ public sealed class LegacyPlayoutWorkflow
pageSize, pageSize,
Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))), Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))),
active.Page.PreviewFields ?? Array.Empty<LegacyScenePreviewField>(), active.Page.PreviewFields ?? Array.Empty<LegacyScenePreviewField>(),
active.IsPageNavigationEnabled); active.IsPageNavigationEnabled)
{
OnAirCueIndexZeroBased = _onAir?.CueIndexZeroBased ?? -1,
OnAirEntryId = _onAir is null
? null
: _playlist[_onAir.CueIndexZeroBased].EntryId
};
} }
private static bool IsSafeToken(string? value) => private static bool IsSafeToken(string? value) =>
@@ -1008,6 +1154,7 @@ public sealed class LegacyPlayoutWorkflow
_prepared = null; _prepared = null;
_onAir = null; _onAir = null;
_playlist = []; _playlist = [];
ResetNextCursor();
State = EmptyState; State = EmptyState;
} }
} }

View File

@@ -55,6 +55,10 @@ public sealed record LegacyOperatorPlayoutSnapshot(
bool CanChangeBackground, bool CanChangeBackground,
string Message) string Message)
{ {
public int OnAirCueIndexZeroBased { get; init; } = -1;
public string? OnAirEntryId { get; init; }
public bool IsTakeOutCompletionPending { get; init; } public bool IsTakeOutCompletionPending { get; init; }
public DateTimeOffset? RefreshNextAtUtc { get; init; } public DateTimeOffset? RefreshNextAtUtc { get; init; }

View File

@@ -1193,6 +1193,8 @@ public static class LegacyBridgeProtocol
playout.OnAirCode, playout.OnAirCode,
playout.CurrentCueIndexZeroBased, playout.CurrentCueIndexZeroBased,
playout.CurrentEntryId, playout.CurrentEntryId,
playout.OnAirCueIndexZeroBased,
playout.OnAirEntryId,
playout.PageIndexZeroBased, playout.PageIndexZeroBased,
playout.PageCount, playout.PageCount,
playout.PageSize, playout.PageSize,

View File

@@ -804,22 +804,22 @@ public sealed partial class MainWindow
// the newly appended row is then guaranteed to be in the future tail. // the newly appended row is then guaranteed to be in the future tail.
if (string.IsNullOrWhiteSpace(status.OnAirSceneName) || if (string.IsNullOrWhiteSpace(status.OnAirSceneName) ||
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) || string.IsNullOrWhiteSpace(workflow.OnAirCutCode) ||
string.IsNullOrWhiteSpace(workflow.CurrentEntryId) || string.IsNullOrWhiteSpace(workflow.OnAirEntryId) ||
workflow.CurrentCueIndexZeroBased < 0) workflow.OnAirCueIndexZeroBased < 0)
{ {
return false; return false;
} }
var playlist = _controller.Current.Playlist; var playlist = _controller.Current.Playlist;
var currentIndex = workflow.CurrentCueIndexZeroBased; var currentIndex = workflow.OnAirCueIndexZeroBased;
return currentIndex < playlist.Count && return currentIndex < playlist.Count &&
string.Equals( string.Equals(
playlist[currentIndex].RowId, playlist[currentIndex].RowId,
workflow.CurrentEntryId, workflow.OnAirEntryId,
StringComparison.Ordinal) && StringComparison.Ordinal) &&
playlist.Count(row => string.Equals( playlist.Count(row => string.Equals(
row.RowId, row.RowId,
workflow.CurrentEntryId, workflow.OnAirEntryId,
StringComparison.Ordinal)) == 1; StringComparison.Ordinal)) == 1;
} }
@@ -849,8 +849,8 @@ public sealed partial class MainWindow
return true; return true;
} }
if (string.IsNullOrWhiteSpace(workflow.CurrentEntryId) || if (string.IsNullOrWhiteSpace(workflow.OnAirEntryId) ||
workflow.CurrentCueIndexZeroBased < 0) workflow.OnAirCueIndexZeroBased < 0)
{ {
return false; return false;
} }
@@ -860,7 +860,7 @@ public sealed partial class MainWindow
.Select(static (row, index) => (row, index)) .Select(static (row, index) => (row, index))
.Where(candidate => string.Equals( .Where(candidate => string.Equals(
candidate.row.RowId, candidate.row.RowId,
workflow.CurrentEntryId, workflow.OnAirEntryId,
StringComparison.Ordinal)) StringComparison.Ordinal))
.Select(static candidate => candidate.index) .Select(static candidate => candidate.index)
.Take(2) .Take(2)
@@ -871,7 +871,7 @@ public sealed partial class MainWindow
} }
var currentIndex = currentMatches[0]; var currentIndex = currentMatches[0];
if (currentIndex != workflow.CurrentCueIndexZeroBased) if (currentIndex != workflow.OnAirCueIndexZeroBased)
{ {
return false; return false;
} }
@@ -895,22 +895,22 @@ public sealed partial class MainWindow
// evidence does not establish a safe checkbox contract for that phase. // evidence does not establish a safe checkbox contract for that phase.
if (string.IsNullOrWhiteSpace(status.OnAirSceneName) || if (string.IsNullOrWhiteSpace(status.OnAirSceneName) ||
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) || string.IsNullOrWhiteSpace(workflow.OnAirCutCode) ||
string.IsNullOrWhiteSpace(workflow.CurrentEntryId) || string.IsNullOrWhiteSpace(workflow.OnAirEntryId) ||
workflow.CurrentCueIndexZeroBased < 0) workflow.OnAirCueIndexZeroBased < 0)
{ {
return false; return false;
} }
var playlist = _controller.Current.Playlist; var playlist = _controller.Current.Playlist;
var currentIndex = workflow.CurrentCueIndexZeroBased; var currentIndex = workflow.OnAirCueIndexZeroBased;
return currentIndex < playlist.Count && return currentIndex < playlist.Count &&
string.Equals( string.Equals(
playlist[currentIndex].RowId, playlist[currentIndex].RowId,
workflow.CurrentEntryId, workflow.OnAirEntryId,
StringComparison.Ordinal) && StringComparison.Ordinal) &&
playlist.Count(row => string.Equals( playlist.Count(row => string.Equals(
row.RowId, row.RowId,
workflow.CurrentEntryId, workflow.OnAirEntryId,
StringComparison.Ordinal)) == 1; StringComparison.Ordinal)) == 1;
} }
@@ -1132,6 +1132,8 @@ public sealed partial class MainWindow
"송출 어댑터를 사용할 수 없습니다."); "송출 어댑터를 사용할 수 없습니다.");
return snapshot with return snapshot with
{ {
OnAirCueIndexZeroBased = workflow?.OnAirCueIndexZeroBased ?? -1,
OnAirEntryId = workflow?.OnAirEntryId,
IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false, IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false,
RefreshNextAtUtc = refresh.NextAtUtc, RefreshNextAtUtc = refresh.NextAtUtc,
RefreshLastSuccessAtUtc = refresh.LastSuccessAtUtc, RefreshLastSuccessAtUtc = refresh.LastSuccessAtUtc,

View File

@@ -805,9 +805,12 @@
if (selectedIndices.length === 0) return false; if (selectedIndices.length === 0) return false;
const playout = state.playout; const playout = state.playout;
if (!playout || playout.phase === "idle") return true; if (!playout || playout.phase === "idle") return true;
const currentIndex = Number(playout.currentCueIndexZeroBased); const onAirIndex = Number(playout.onAirCueIndexZeroBased);
return Number.isSafeInteger(currentIndex) && currentIndex >= 0 && const protectedIndex = Number.isSafeInteger(onAirIndex) && onAirIndex >= 0
selectedIndices.every(function (index) { return index > currentIndex; }); ? onAirIndex
: Number(playout.currentCueIndexZeroBased);
return Number.isSafeInteger(protectedIndex) && protectedIndex >= 0 &&
selectedIndices.every(function (index) { return index > protectedIndex; });
} }
function isCutSelectionLocked(state) { function isCutSelectionLocked(state) {
@@ -832,19 +835,30 @@
playout.currentEntryId.length > 0 ? playout.currentEntryId : null; playout.currentEntryId.length > 0 ? playout.currentEntryId : null;
const currentIndex = playout && Number.isInteger(playout.currentCueIndexZeroBased) const currentIndex = playout && Number.isInteger(playout.currentCueIndexZeroBased)
? playout.currentCueIndexZeroBased : -1; ? 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 const isCurrent = hasActiveScene && (currentEntryId !== null
? currentEntryId === row.rowId ? currentEntryId === row.rowId
: currentIndex === position); : 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; 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 >= 0 && playout.pageIndexZeroBased < pageCount
? playout.pageIndexZeroBased : -1; ? playout.pageIndexZeroBased : -1;
return { return {
isSelected: row.isSelected === true, isSelected: row.isSelected === true,
isCurrent: isCurrent, isCurrent: isCurrent,
isOnAir: isCurrent && phase === "program", isOnAir: isOnAir,
pageText: pageIndex >= 0 pageText: pageIndex >= 0
? String(pageIndex + 1) + "/" + String(pageCount) ? String(pageIndex + 1) + "/" + String(pageCount)
: row.pageText, : row.pageText,

View File

@@ -116,6 +116,7 @@ test("program keeps cut append available while structural playlist edits stay lo
playout: { playout: {
phase: "program", phase: "program",
currentCueIndexZeroBased: 0, currentCueIndexZeroBased: 0,
onAirCueIndexZeroBased: 0,
isBusy: false, isBusy: false,
outcomeUnknown: false, outcomeUnknown: false,
isPlayCompletionPending: false, isPlayCompletionPending: false,
@@ -211,6 +212,8 @@ test("playlist keeps candidate, selection, and pink on-air states independent",
phase: "program", phase: "program",
currentEntryId: "row-current", currentEntryId: "row-current",
currentCueIndexZeroBased: 1, currentCueIndexZeroBased: 1,
onAirEntryId: "row-current",
onAirCueIndexZeroBased: 1,
pageIndexZeroBased: 1, pageIndexZeroBased: 1,
pageCount: 3 pageCount: 3
} }
@@ -237,6 +240,34 @@ test("playlist keeps candidate, selection, and pink on-air states independent",
assert.equal(presentation({ assert.equal(presentation({
playout: { ...state.playout, currentEntryId: null } playout: { ...state.playout, currentEntryId: null }
}, { rowId: "row-fallback", isSelected: false, pageText: "1/3" }, 1).isCurrent, true); }, { 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\.isOnAir \? " on-air"/);
assert.match(app, /presentation\.isSelected \|\| isCandidate \? " selected"/); assert.match(app, /presentation\.isSelected \|\| isCandidate \? " selected"/);
assert.match(app, /presentation\.pageText/); assert.match(app, /presentation\.pageText/);

View File

@@ -312,6 +312,78 @@ public sealed class LegacyPlayoutWorkflowTests
Assert.DoesNotContain("Next:5074:p0", engine.Calls); Assert.DoesNotContain("Next:5074:p0", engine.Calls);
} }
[Fact]
public async Task Next_WhenEnabledMiddleCueIsUnplayable_ConsumesOneNextAndFollowingNextPlaysThirdCue()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["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<LegacySceneDataException>(() => 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<string, (int, ScenePageSize?)>
{
["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] [Fact]
public async Task Next_WhenProgramAllRowsAreDisabled_EndsWithoutDispatchingTheEngine() public async Task Next_WhenProgramAllRowsAreDisabled_EndsWithoutDispatchingTheEngine()
{ {
@@ -1128,6 +1200,8 @@ public sealed class LegacyPlayoutWorkflowTests
public List<string?> SelectionGraphicTypes { get; } = []; public List<string?> SelectionGraphicTypes { get; } = [];
public HashSet<string> UnplayableCutCodes { get; } = new(StringComparer.Ordinal);
public Func<string, int, string>? SceneNameOverride { get; init; } public Func<string, int, string>? SceneNameOverride { get; init; }
public string? BuilderKey { get; init; } public string? BuilderKey { get; init; }
@@ -1142,6 +1216,11 @@ public sealed class LegacyPlayoutWorkflowTests
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
Calls.Add($"{entry.CutCode}:{pageIndexZeroBased}"); Calls.Add($"{entry.CutCode}:{pageIndexZeroBased}");
SelectionGraphicTypes.Add(entry.Selection?.GraphicType); 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 definition = definitions[entry.CutCode];
var name = SceneNameOverride?.Invoke(entry.CutCode, pageIndexZeroBased) ?? entry.CutCode; var name = SceneNameOverride?.Invoke(entry.CutCode, pageIndexZeroBased) ?? entry.CutCode;
return Task.FromResult(new LegacySceneCuePage( return Task.FromResult(new LegacySceneCuePage(
@@ -1199,6 +1278,8 @@ public sealed class LegacyPlayoutWorkflowTests
public Queue<PlayoutResultCode> TakeInResults { get; } = []; public Queue<PlayoutResultCode> TakeInResults { get; } = [];
public Queue<PlayoutResultCode> NextResults { get; } = [];
public Queue<PlayoutResultCode> UpdateOnAirResults { get; } = []; public Queue<PlayoutResultCode> UpdateOnAirResults { get; } = [];
public Action? PrepareCallback { get; set; } public Action? PrepareCallback { get; set; }
@@ -1248,7 +1329,7 @@ public sealed class LegacyPlayoutWorkflowTests
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
Calls.Add("Next:" + Describe(cue)); Calls.Add("Next:" + Describe(cue));
return Success(PlayoutOperation.Next); return Complete(PlayoutOperation.Next, NextResults);
} }
public Task<PlayoutResult> UpdateOnAirAsync( public Task<PlayoutResult> UpdateOnAirAsync(

View File

@@ -183,6 +183,8 @@ public sealed class LegacyPlayoutBridgeTests
CanChangeBackground: false, CanChangeBackground: false,
Message: "준비 완료") Message: "준비 완료")
{ {
OnAirCueIndexZeroBased = -1,
OnAirEntryId = null,
IsTakeOutCompletionPending = true, IsTakeOutCompletionPending = true,
RefreshNextAtUtc = new DateTimeOffset( RefreshNextAtUtc = new DateTimeOffset(
2026, 7, 17, 2, 3, 4, TimeSpan.Zero), 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(2, projected.GetProperty("pageCount").GetInt32());
Assert.Equal("기본.vrv", projected.GetProperty("backgroundFileName").GetString()); Assert.Equal("기본.vrv", projected.GetProperty("backgroundFileName").GetString());
Assert.Equal("entry-1", projected.GetProperty("currentEntryId").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.True(projected.GetProperty("isTakeOutCompletionPending").GetBoolean());
Assert.Equal( Assert.Equal(
new DateTimeOffset(2026, 7, 17, 2, 3, 4, TimeSpan.Zero), new DateTimeOffset(2026, 7, 17, 2, 3, 4, TimeSpan.Zero),

View File

@@ -527,9 +527,9 @@ public sealed class LegacyPlayoutNativeContractTests
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("workflow.OnAirCutCode", programEnabledAdmission, Assert.Contains("workflow.OnAirCutCode", programEnabledAdmission,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("workflow.CurrentEntryId", programEnabledAdmission, Assert.Contains("workflow.OnAirEntryId", programEnabledAdmission,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("workflow.CurrentCueIndexZeroBased", programEnabledAdmission, Assert.Contains("workflow.OnAirCueIndexZeroBased", programEnabledAdmission,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("IsPlaylistMutationIdle(status, workflow)", programEnabledAdmission, Assert.Contains("IsPlaylistMutationIdle(status, workflow)", programEnabledAdmission,
StringComparison.Ordinal); StringComparison.Ordinal);
@@ -582,9 +582,9 @@ public sealed class LegacyPlayoutNativeContractTests
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("workflow.OnAirCutCode", safeTailAdmission, Assert.Contains("workflow.OnAirCutCode", safeTailAdmission,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("workflow.CurrentEntryId", safeTailAdmission, Assert.Contains("workflow.OnAirEntryId", safeTailAdmission,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("workflow.CurrentCueIndexZeroBased", safeTailAdmission, Assert.Contains("workflow.OnAirCueIndexZeroBased", safeTailAdmission,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("currentIndex < playlist.Count", safeTailAdmission, Assert.Contains("currentIndex < playlist.Count", safeTailAdmission,
StringComparison.Ordinal); StringComparison.Ordinal);