diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs index 7585f81..410106c 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs @@ -74,10 +74,12 @@ public sealed class LegacyPlayoutWorkflow private readonly IPlayoutEngine _engine; private readonly ILegacySceneCueProvider _cueProvider; private readonly SemaphoreSlim _gate = new(1, 1); + private readonly object _engineStatusSync = new(); private IReadOnlyList _playlist = []; private ActivePage? _prepared; private ActivePage? _onAir; private int _engineClearRequested; + private long _lastObservedEngineStatusSequence = long.MinValue; public LegacyPlayoutWorkflow( IPlayoutEngine engine, @@ -98,13 +100,27 @@ public sealed class LegacyPlayoutWorkflow public void ObserveEngineStatus(PlayoutStatus status) { ArgumentNullException.ThrowIfNull(status); - if (!string.IsNullOrWhiteSpace(status.PreparedSceneName) || - !string.IsNullOrWhiteSpace(status.OnAirSceneName)) + var hasActiveScene = !string.IsNullOrWhiteSpace(status.PreparedSceneName) || + !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; } - Interlocked.Exchange(ref _engineClearRequested, 1); if (!_gate.Wait(0)) { return; @@ -538,15 +554,18 @@ public sealed class LegacyPlayoutWorkflow private void ApplyRequestedEngineClear() { - if (Interlocked.Exchange(ref _engineClearRequested, 0) == 0) + lock (_engineStatusSync) { - return; - } + if (Interlocked.Exchange(ref _engineClearRequested, 0) == 0) + { + return; + } - _prepared = null; - _onAir = null; - _playlist = []; - State = EmptyState; + _prepared = null; + _onAir = null; + _playlist = []; + State = EmptyState; + } } private static readonly LegacyPlayoutWorkflowState EmptyState = new( diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs index 6256369..1f7412c 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs @@ -352,6 +352,79 @@ public sealed class LegacyPlayoutWorkflowTests 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 + { + ["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 + { + ["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( IReadOnlyDictionary definitions) : ILegacySceneCueProvider @@ -360,6 +433,10 @@ public sealed class LegacyPlayoutWorkflowTests public Func? SceneNameOverride { get; init; } + public string? BuilderKey { get; init; } + + public IReadOnlyList? PreviewFields { get; init; } + public Task CreatePageAsync( LegacyPlaylistEntry entry, int pageIndexZeroBased, @@ -375,7 +452,9 @@ public sealed class LegacyPlayoutWorkflowTests name, Mutations: [new PlayoutSetValue("page", $"p{pageIndexZeroBased}")]), definition.Count, - definition.Size)); + definition.Size, + BuilderKey, + PreviewFields)); } } @@ -387,6 +466,8 @@ public sealed class LegacyPlayoutWorkflowTests public Queue UpdateOnAirResults { get; } = []; + public Action? PrepareCallback { get; set; } + public PlayoutStatus Status { get; } = new( PlayoutMode.DryRun, PlayoutConnectionState.DryRunReady, @@ -417,6 +498,7 @@ public sealed class LegacyPlayoutWorkflowTests CancellationToken cancellationToken = default) { Calls.Add("Prepare:" + Describe(cue)); + PrepareCallback?.Invoke(); return Complete(PlayoutOperation.Prepare, PrepareResults); }