fix: advance NEXT past unplayable cuts
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user