fix: preserve prepared workflow state
This commit is contained in:
@@ -74,10 +74,12 @@ public sealed class LegacyPlayoutWorkflow
|
|||||||
private readonly IPlayoutEngine _engine;
|
private readonly IPlayoutEngine _engine;
|
||||||
private readonly ILegacySceneCueProvider _cueProvider;
|
private readonly ILegacySceneCueProvider _cueProvider;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
private readonly object _engineStatusSync = new();
|
||||||
private IReadOnlyList<LegacyPlaylistEntry> _playlist = [];
|
private IReadOnlyList<LegacyPlaylistEntry> _playlist = [];
|
||||||
private ActivePage? _prepared;
|
private ActivePage? _prepared;
|
||||||
private ActivePage? _onAir;
|
private ActivePage? _onAir;
|
||||||
private int _engineClearRequested;
|
private int _engineClearRequested;
|
||||||
|
private long _lastObservedEngineStatusSequence = long.MinValue;
|
||||||
|
|
||||||
public LegacyPlayoutWorkflow(
|
public LegacyPlayoutWorkflow(
|
||||||
IPlayoutEngine engine,
|
IPlayoutEngine engine,
|
||||||
@@ -98,13 +100,27 @@ public sealed class LegacyPlayoutWorkflow
|
|||||||
public void ObserveEngineStatus(PlayoutStatus status)
|
public void ObserveEngineStatus(PlayoutStatus status)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(status);
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
if (!string.IsNullOrWhiteSpace(status.PreparedSceneName) ||
|
var hasActiveScene = !string.IsNullOrWhiteSpace(status.PreparedSceneName) ||
|
||||||
!string.IsNullOrWhiteSpace(status.OnAirSceneName))
|
!string.IsNullOrWhiteSpace(status.OnAirSceneName);
|
||||||
|
lock (_engineStatusSync)
|
||||||
|
{
|
||||||
|
if (status.Sequence <= _lastObservedEngineStatusSequence)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastObservedEngineStatusSequence = status.Sequence;
|
||||||
|
// Availability/OnHello updates can report no scene while PREPARE still owns
|
||||||
|
// the workflow gate. A later authoritative PREPARED/PROGRAM status must
|
||||||
|
// cancel that deferred clear before the command publishes its page metadata.
|
||||||
|
Interlocked.Exchange(ref _engineClearRequested, hasActiveScene ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasActiveScene)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Interlocked.Exchange(ref _engineClearRequested, 1);
|
|
||||||
if (!_gate.Wait(0))
|
if (!_gate.Wait(0))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -538,15 +554,18 @@ public sealed class LegacyPlayoutWorkflow
|
|||||||
|
|
||||||
private void ApplyRequestedEngineClear()
|
private void ApplyRequestedEngineClear()
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref _engineClearRequested, 0) == 0)
|
lock (_engineStatusSync)
|
||||||
{
|
{
|
||||||
return;
|
if (Interlocked.Exchange(ref _engineClearRequested, 0) == 0)
|
||||||
}
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_prepared = null;
|
_prepared = null;
|
||||||
_onAir = null;
|
_onAir = null;
|
||||||
_playlist = [];
|
_playlist = [];
|
||||||
State = EmptyState;
|
State = EmptyState;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly LegacyPlayoutWorkflowState EmptyState = new(
|
private static readonly LegacyPlayoutWorkflowState EmptyState = new(
|
||||||
|
|||||||
@@ -352,6 +352,79 @@ public sealed class LegacyPlayoutWorkflowTests
|
|||||||
Assert.DoesNotContain(engine.Calls, call => call.StartsWith("UpdateOnAir", StringComparison.Ordinal));
|
Assert.DoesNotContain(engine.Calls, call => call.StartsWith("UpdateOnAir", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Prepare_EmptyThenPreparedEngineStatusWhileGateIsHeld_PreservesPublishedPageState()
|
||||||
|
{
|
||||||
|
var engine = new RecordingEngine();
|
||||||
|
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
|
||||||
|
{
|
||||||
|
["5074"] = (6, ScenePageSize.Five)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
BuilderKey = "kospi-five-row",
|
||||||
|
PreviewFields = [new("text", "title", "KOSPI")]
|
||||||
|
};
|
||||||
|
var workflow = new LegacyPlayoutWorkflow(engine, provider);
|
||||||
|
engine.PrepareCallback = () =>
|
||||||
|
{
|
||||||
|
workflow.ObserveEngineStatus(engine.Status with
|
||||||
|
{
|
||||||
|
PreparedSceneName = null,
|
||||||
|
OnAirSceneName = null,
|
||||||
|
Sequence = 10
|
||||||
|
});
|
||||||
|
workflow.ObserveEngineStatus(engine.Status with
|
||||||
|
{
|
||||||
|
PreparedSceneName = "5074",
|
||||||
|
OnAirSceneName = null,
|
||||||
|
Sequence = 11
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await workflow.PrepareAsync([new("playlist-entry", "5074")], 0);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("5074", workflow.State.PreparedCutCode);
|
||||||
|
Assert.Null(workflow.State.OnAirCutCode);
|
||||||
|
Assert.Equal("kospi-five-row", workflow.State.BuilderKey);
|
||||||
|
Assert.Equal(0, workflow.State.PageIndexZeroBased);
|
||||||
|
Assert.Equal(2, workflow.State.PageCount);
|
||||||
|
Assert.Equal(5, workflow.State.PageSize);
|
||||||
|
Assert.Equal(6, workflow.State.ItemCount);
|
||||||
|
Assert.Equal(5, workflow.State.CurrentPageItemCount);
|
||||||
|
Assert.Equal("playlist-entry", workflow.State.CurrentEntryId);
|
||||||
|
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
|
||||||
|
Assert.Equal(provider.PreviewFields, workflow.State.PreviewFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ObserveEngineStatus_OlderEmptyStatusCannotOverrideNewerPreparedStatus()
|
||||||
|
{
|
||||||
|
var engine = new RecordingEngine();
|
||||||
|
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
|
||||||
|
{
|
||||||
|
["5001"] = (1, null)
|
||||||
|
});
|
||||||
|
var workflow = new LegacyPlayoutWorkflow(engine, provider);
|
||||||
|
Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
|
||||||
|
|
||||||
|
workflow.ObserveEngineStatus(engine.Status with
|
||||||
|
{
|
||||||
|
PreparedSceneName = "5001",
|
||||||
|
OnAirSceneName = null,
|
||||||
|
Sequence = 20
|
||||||
|
});
|
||||||
|
workflow.ObserveEngineStatus(engine.Status with
|
||||||
|
{
|
||||||
|
PreparedSceneName = null,
|
||||||
|
OnAirSceneName = null,
|
||||||
|
Sequence = 19
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("5001", workflow.State.PreparedCutCode);
|
||||||
|
Assert.Equal("one", workflow.State.CurrentEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class RecordingProvider(
|
private sealed class RecordingProvider(
|
||||||
IReadOnlyDictionary<string, (int Count, ScenePageSize? Size)> definitions)
|
IReadOnlyDictionary<string, (int Count, ScenePageSize? Size)> definitions)
|
||||||
: ILegacySceneCueProvider
|
: ILegacySceneCueProvider
|
||||||
@@ -360,6 +433,10 @@ public sealed class LegacyPlayoutWorkflowTests
|
|||||||
|
|
||||||
public Func<string, int, string>? SceneNameOverride { get; init; }
|
public Func<string, int, string>? SceneNameOverride { get; init; }
|
||||||
|
|
||||||
|
public string? BuilderKey { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<LegacyScenePreviewField>? PreviewFields { get; init; }
|
||||||
|
|
||||||
public Task<LegacySceneCuePage> CreatePageAsync(
|
public Task<LegacySceneCuePage> CreatePageAsync(
|
||||||
LegacyPlaylistEntry entry,
|
LegacyPlaylistEntry entry,
|
||||||
int pageIndexZeroBased,
|
int pageIndexZeroBased,
|
||||||
@@ -375,7 +452,9 @@ public sealed class LegacyPlayoutWorkflowTests
|
|||||||
name,
|
name,
|
||||||
Mutations: [new PlayoutSetValue("page", $"p{pageIndexZeroBased}")]),
|
Mutations: [new PlayoutSetValue("page", $"p{pageIndexZeroBased}")]),
|
||||||
definition.Count,
|
definition.Count,
|
||||||
definition.Size));
|
definition.Size,
|
||||||
|
BuilderKey,
|
||||||
|
PreviewFields));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +466,8 @@ public sealed class LegacyPlayoutWorkflowTests
|
|||||||
|
|
||||||
public Queue<PlayoutResultCode> UpdateOnAirResults { get; } = [];
|
public Queue<PlayoutResultCode> UpdateOnAirResults { get; } = [];
|
||||||
|
|
||||||
|
public Action? PrepareCallback { get; set; }
|
||||||
|
|
||||||
public PlayoutStatus Status { get; } = new(
|
public PlayoutStatus Status { get; } = new(
|
||||||
PlayoutMode.DryRun,
|
PlayoutMode.DryRun,
|
||||||
PlayoutConnectionState.DryRunReady,
|
PlayoutConnectionState.DryRunReady,
|
||||||
@@ -417,6 +498,7 @@ public sealed class LegacyPlayoutWorkflowTests
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Calls.Add("Prepare:" + Describe(cue));
|
Calls.Add("Prepare:" + Describe(cue));
|
||||||
|
PrepareCallback?.Invoke();
|
||||||
return Complete(PlayoutOperation.Prepare, PrepareResults);
|
return Complete(PlayoutOperation.Prepare, PrepareResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user