Files
LlamaCasty/ytLive.Tests/MediaPlaybackRestartTests.cs
T
gramps 242a13ca30 fix(media): a played-out clip can play again; play button beside the Video File label
Reported 2026-11-05: "when I click Loop, the video doesn't play in the preview pane". Two
causes, both real:

- `VideoClipPipe.StartAsync` bailed out once it had run once (`_run != null`), so a clip that
  reached EOF could never be started again — it held its last frame forever.
- Loop is only consulted at end-of-file by a LIVE decoder, so flipping it on a finished clip
  did nothing at all.

Now: the pipe reports running state and is restartable after a natural end; the manager marks a
session finished (keeping its last frame) and exposes `IsRunning`/`RestartAsync`; the layer shows
`IsMediaRunning`/`IsMediaFinished`; flipping Loop on a finished clip restarts it.

UI: `▶ Play` sits to the right of the **Video File** label and is enabled only while Loop is off
(creator ruling: with Loop on the clip already runs continuously, so there is nothing to start).
The panel says "Clip finished — holding the last frame." so a frozen picture explains itself.

Also: verify.sh no longer runs physical-input tests by default. That test moves the real cursor
(SetCursorPos + mouse_event); running it mid-gate hijacks the machine someone is using, which is
exactly how it felt. Opt in with PHYSICAL_TESTS=1 when the desktop is idle; the header documents
the scoped-run command for working hours.

Test: `MediaPlaybackRestartTests` (pipe restart, finished state, Play again, Loop-on-finished).
8 scoped tests green. FULL SUITE NOT RUN — the machine was in use; run scripts/verify.sh in a
quiet window before the next milestone.
2026-11-05 09:15:30 -08:00

167 lines
5.9 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Encoder;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// 2026-11-05 report: "when I click Loop, the video doesn't play in the preview pane." The switch was
/// enabled and saved, but Loop is only consulted at end-of-file by a LIVE decoder — and a finished
/// decoder could never be started again (`StartAsync` bailed out once it had run once). So a short clip
/// froze on its last frame forever. This pins the fix: a played-out clip can be started again, and
/// turning Loop on for a finished clip restarts it.
/// </summary>
[Collection("RealApp")]
public sealed class MediaPlaybackRestartTests
{
private readonly RealAppHost _app;
public MediaPlaybackRestartTests(RealAppHost app) => _app = app;
[Fact]
public async Task Pipe_CanPlayAgainAfterANaturalEnd()
{
var processStarts = 0;
using var pipe = new VideoClipPipe(
"clip.mp4", 1, 1, new FakeLocator(),
() => { processStarts++; return new EmptyProcess(); },
delay: (_, _) => Task.CompletedTask,
audio: false);
var completions = 0;
pipe.Completed += () => completions++;
await pipe.StartAsync();
await WaitUntil(() => completions == 1);
await pipe.StartAsync(); // a finished clip must be startable again
await WaitUntil(() => completions == 2);
Assert.Equal(2, completions);
Assert.True(processStarts >= 2, "the second play did not spawn a decoder");
}
[Fact]
public void PlayedOutClip_ShowsAsFinished_AndPlayAgainRestartsIt()
{
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
var videoPath = Path.Combine(Path.GetTempPath(), "short-loop.mp4");
var decoders = new List<FakeMediaSource>();
MainViewModel.LayoutPathOverride = tempDb;
MainViewModel.MediaFrameSourceFactoryOverride = key =>
{
var decoder = new FakeMediaSource(key);
decoders.Add(decoder);
return decoder;
};
MainViewModel.ChromaProbeOverride = new NoProbe();
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
window.UpdateLayout();
vm.AddMediaSource(videoPath);
var layer = vm.StagedScene!.Elements.OfType<Source>().Single(s => s.IsMediaSource);
var decoder = decoders.Single();
Assert.True(layer.IsMediaRunning, "a freshly added clip must report that it is playing");
// The clip plays out.
decoder.RaiseCompleted();
Assert.False(layer.IsMediaRunning);
Assert.True(layer.IsMediaFinished);
// "Play again" restarts the same session from the top.
vm.ReplayMediaCommand.Execute(layer);
Assert.True(layer.IsMediaRunning);
Assert.Equal(2, decoder.StartCount);
// And turning Loop on for a finished clip restarts it — the reported bug.
decoder.RaiseCompleted();
Assert.False(layer.IsMediaRunning);
layer.MediaIsLooping = true;
Assert.True(layer.IsMediaRunning, "turning Loop on for a finished clip must start it again");
Assert.Equal(3, decoder.StartCount);
}
finally
{
window.Close();
MainViewModel.ChromaProbeOverride = null;
MainViewModel.MediaFrameSourceFactoryOverride = null;
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
});
}
private static async Task WaitUntil(Func<bool> condition)
{
for (var i = 0; i < 200 && !condition(); i++)
await Task.Delay(10);
Assert.True(condition(), "timed out waiting for the clip to finish");
}
private sealed class FakeMediaSource : IMediaFrameSource
{
public FakeMediaSource(string key) => Key = key;
public string Key { get; }
public bool Looping { get; set; }
public int StartCount { get; private set; }
public event Action<VideoFrame>? FrameAvailable { add { } remove { } }
public event Action<ytLive.Services.Audio.AudioSample>? AudioAvailable { add { } remove { } }
public event Action? Completed;
public void RaiseCompleted() => Completed?.Invoke();
public Task StartAsync(CancellationToken cancellationToken = default)
{
StartCount++;
return Task.CompletedTask;
}
public Task StopAsync() => Task.CompletedTask;
public void Dispose()
{
}
}
private sealed class NoProbe : IChromaProbe
{
public Task<VideoFrame?> ProbeAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<VideoFrame?>(null);
}
private sealed class EmptyProcess : IDecodeProcess
{
public void Start(System.Diagnostics.ProcessStartInfo startInfo) { }
public Stream StandardOutput => Stream.Null;
public bool HasExited => true;
public int ExitCode => 0;
public void Kill() { }
public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask;
public void Dispose() { }
}
private sealed class FakeLocator : IFfmpegLocator
{
public Task<string> LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
}
}