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

@@ -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/);

View File

@@ -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<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]
public async Task Next_WhenProgramAllRowsAreDisabled_EndsWithoutDispatchingTheEngine()
{
@@ -1128,6 +1200,8 @@ public sealed class LegacyPlayoutWorkflowTests
public List<string?> SelectionGraphicTypes { get; } = [];
public HashSet<string> UnplayableCutCodes { get; } = new(StringComparer.Ordinal);
public Func<string, int, string>? 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<PlayoutResultCode> TakeInResults { get; } = [];
public Queue<PlayoutResultCode> NextResults { get; } = [];
public Queue<PlayoutResultCode> 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<PlayoutResult> UpdateOnAirAsync(

View File

@@ -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),

View File

@@ -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);