fix: preserve operator window between scene refreshes

This commit is contained in:
2026-07-11 14:55:36 +09:00
parent 673d40b09f
commit fa0eae7b61
6 changed files with 721 additions and 81 deletions

View File

@@ -0,0 +1,62 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyRefreshEpochTests
{
[Fact]
public void Stop_PreventsAStaleWriteFromReactivatingState()
{
using var cancellation = new CancellationTokenSource();
var epoch = new LegacyRefreshEpoch<string>("idle");
epoch.Replace(cancellation, _ => "active");
epoch.Stop(_ => "stopped");
var staleWriteAccepted = epoch.TryUpdate(cancellation, _ => "active-again");
Assert.True(cancellation.IsCancellationRequested);
Assert.False(staleWriteAccepted);
Assert.Equal("stopped", epoch.ReadState());
}
[Fact]
public void Replacement_RejectsAllWritesFromTheOldGeneration()
{
using var oldCancellation = new CancellationTokenSource();
using var newCancellation = new CancellationTokenSource();
var epoch = new LegacyRefreshEpoch<string>("idle");
epoch.Replace(oldCancellation, _ => "old-active");
epoch.Replace(newCancellation, _ => "new-active");
var staleFaultAccepted = epoch.TryUpdate(oldCancellation, _ => "old-fault");
var currentWriteAccepted = epoch.TryUpdate(newCancellation, _ => "new-scheduled");
Assert.True(oldCancellation.IsCancellationRequested);
Assert.False(newCancellation.IsCancellationRequested);
Assert.False(staleFaultAccepted);
Assert.True(currentWriteAccepted);
Assert.Equal("new-scheduled", epoch.ReadState());
}
[Fact]
public void OldCompletion_DoesNotClearOrRewriteTheReplacement()
{
using var oldCancellation = new CancellationTokenSource();
using var newCancellation = new CancellationTokenSource();
var epoch = new LegacyRefreshEpoch<string>("idle");
epoch.Replace(oldCancellation, _ => "old-active");
epoch.Replace(newCancellation, _ => "new-active");
var oldCompletionAccepted = epoch.TryComplete(
oldCancellation,
_ => "old-complete");
Assert.False(oldCompletionAccepted);
Assert.True(epoch.IsCurrent(newCancellation));
Assert.Equal("new-active", epoch.ReadState());
Assert.True(epoch.TryComplete(newCancellation, _ => "new-complete"));
Assert.False(epoch.IsCurrent(newCancellation));
Assert.Equal("new-complete", epoch.ReadState());
}
}

View File

@@ -0,0 +1,283 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyRefreshSchedulerTests
{
[Fact]
public async Task LongPlayCallback_DoesNotConsumeTheInitialDelay()
{
var callbackCompletion = NewCompletion<bool>();
var delays = new ControlledDelay();
var scheduler = CreateScheduler(delays);
var opportunity = scheduler.WaitForNextRefreshAsync(
cancellationToken => callbackCompletion.Task.WaitAsync(cancellationToken),
null,
CancellationToken.None);
await Task.Yield();
Assert.Equal(0, delays.CallCount);
callbackCompletion.SetResult(true);
Assert.Equal(TimeSpan.FromSeconds(2), await delays.WaitForCallAsync(0));
Assert.False(opportunity.IsCompleted);
delays.Complete(0);
Assert.True(await opportunity);
}
[Fact]
public async Task OneShotScheduling_CoalescesMissedTicksAndPreventsBackToBackPlays()
{
var delays = new ControlledDelay();
var scheduler = CreateScheduler(delays);
var playCount = 0;
var firstOpportunity = scheduler.WaitForNextRefreshAsync(
_ => Task.FromResult(true),
null,
CancellationToken.None);
Assert.Equal(TimeSpan.FromSeconds(2), await delays.WaitForCallAsync(0));
delays.Complete(0);
Assert.True(await firstOpportunity);
Assert.True(await scheduler.ExecuteRefreshAsync(
_ => Task.FromResult(++playCount == 1),
CancellationToken.None));
scheduler.MarkRefreshSucceeded();
var firstPlayCompletion = NewCompletion<bool>();
var secondOpportunity = scheduler.WaitForNextRefreshAsync(
cancellationToken => firstPlayCompletion.Task.WaitAsync(cancellationToken),
null,
CancellationToken.None);
await Task.Yield();
Assert.Equal(1, delays.CallCount);
Assert.Equal(1, playCount);
firstPlayCompletion.SetResult(true);
Assert.Equal(TimeSpan.FromSeconds(3), await delays.WaitForCallAsync(1));
Assert.Equal(1, playCount);
Assert.Equal(2, delays.CallCount);
delays.Complete(1);
Assert.True(await secondOpportunity);
Assert.True(await scheduler.ExecuteRefreshAsync(
_ => Task.FromResult(++playCount == 2),
CancellationToken.None));
Assert.Equal(2, playCount);
Assert.Equal(2, delays.CallCount);
}
[Fact]
public async Task ScheduledDelay_LeavesAQuiescentWindowForOperatorNext()
{
var delays = new ControlledDelay();
var scheduler = CreateScheduler(delays);
using var commandGate = new SemaphoreSlim(1, 1);
using var cancellation = new CancellationTokenSource();
var refreshCalled = false;
var opportunity = RunOneRefreshAsync(
scheduler,
_ => Task.FromResult(true),
async cancellationToken =>
{
await commandGate.WaitAsync(cancellationToken);
try
{
refreshCalled = true;
return true;
}
finally
{
commandGate.Release();
}
},
cancellation.Token);
await delays.WaitForCallAsync(0);
Assert.False(scheduler.IsRefreshInFlight);
Assert.True(await commandGate.WaitAsync(0));
commandGate.Release();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => opportunity);
Assert.False(refreshCalled);
}
[Fact]
public async Task StopCancellation_DropsTheScheduledRefresh()
{
var delays = new ControlledDelay();
var scheduler = CreateScheduler(delays);
using var cancellation = new CancellationTokenSource();
var refreshCalled = false;
var opportunity = RunOneRefreshAsync(
scheduler,
_ => Task.FromResult(true),
_ =>
{
refreshCalled = true;
return Task.FromResult(true);
},
cancellation.Token);
await delays.WaitForCallAsync(0);
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => opportunity);
Assert.False(refreshCalled);
Assert.False(scheduler.IsRefreshInFlight);
Assert.Equal(1, delays.CallCount);
}
[Fact]
public async Task ExecuteRefresh_RejectsASecondInFlightRefresh()
{
var scheduler = CreateScheduler(new ControlledDelay());
var releaseRefresh = NewCompletion<bool>();
var refreshEntered = NewCompletion<bool>();
var firstRefresh = scheduler.ExecuteRefreshAsync(
async cancellationToken =>
{
refreshEntered.SetResult(true);
return await releaseRefresh.Task.WaitAsync(cancellationToken);
},
CancellationToken.None);
await refreshEntered.Task;
Assert.True(scheduler.IsRefreshInFlight);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
scheduler.ExecuteRefreshAsync(_ => Task.FromResult(true), CancellationToken.None));
releaseRefresh.SetResult(true);
Assert.True(await firstRefresh);
Assert.False(scheduler.IsRefreshInFlight);
}
[Fact]
public async Task CallbackTimeout_DoesNotScheduleOrDispatchARefresh()
{
var delays = new ControlledDelay();
var scheduler = CreateScheduler(delays);
var refreshCalled = false;
var ready = await RunOneRefreshAsync(
scheduler,
_ => Task.FromResult(false),
_ =>
{
refreshCalled = true;
return Task.FromResult(true);
},
CancellationToken.None);
Assert.False(ready);
Assert.False(refreshCalled);
Assert.Equal(0, delays.CallCount);
}
private static LegacyRefreshScheduler CreateScheduler(ControlledDelay delays) =>
new(
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(3),
delays.DelayAsync);
private static async Task<bool> RunOneRefreshAsync(
LegacyRefreshScheduler scheduler,
Func<CancellationToken, Task<bool>> waitForPlayCompletionAsync,
Func<CancellationToken, Task<bool>> refreshAsync,
CancellationToken cancellationToken)
{
if (!await scheduler.WaitForNextRefreshAsync(
waitForPlayCompletionAsync,
null,
cancellationToken))
{
return false;
}
return await scheduler.ExecuteRefreshAsync(refreshAsync, cancellationToken);
}
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
private sealed class ControlledDelay
{
private readonly object _gate = new();
private readonly List<DelayRequest> _requests = [];
private TaskCompletionSource<bool> _requestAdded = NewCompletion<bool>();
public int CallCount
{
get
{
lock (_gate)
{
return _requests.Count;
}
}
}
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
DelayRequest request;
TaskCompletionSource<bool> requestAdded;
lock (_gate)
{
request = new DelayRequest(delay);
_requests.Add(request);
requestAdded = _requestAdded;
_requestAdded = NewCompletion<bool>();
}
requestAdded.TrySetResult(true);
return request.Release.Task.WaitAsync(cancellationToken);
}
public async Task<TimeSpan> WaitForCallAsync(int index)
{
while (true)
{
Task requestAdded;
lock (_gate)
{
if (_requests.Count > index)
{
return _requests[index].Delay;
}
requestAdded = _requestAdded.Task;
}
await requestAdded.WaitAsync(TimeSpan.FromSeconds(5));
}
}
public void Complete(int index)
{
TaskCompletionSource<bool> release;
lock (_gate)
{
release = _requests[index].Release;
}
release.SetResult(true);
}
private sealed record DelayRequest(
TimeSpan Delay,
TaskCompletionSource<bool> Release)
{
public DelayRequest(TimeSpan delay)
: this(delay, NewCompletion<bool>())
{
}
}
}
}