Files
LlamaCasty/ytLive.Tests/FfmpegFrameRateParserTests.cs
T
gramps 8fa78423e6 TASK 21 slice 2a: native-FPS probe seam (ffprobe parse + derive sibling)
- FfmpegFrameRateParser (pure): prefers avg_frame_rate= then r_frame_rate=,
  rational N/N/M, unknown/0 -> null.
- IFrameRateProbe + FfmpegFrameRateProbe: derives sibling ffprobe.exe from the
  located ffmpeg dir, reuses the IDecodeProcess seam for the ffprobe subprocess
  text; null if ffprobe absent.
- FfmpegLocator now also extracts ffprobe.exe (ProbeFileName) from the pinned
  archive, conditional so old caches without it degrade to no pacing.
- Tests: 6 pure parser units + 1 probe integration via fake locator/process;
  FfmpegLocatorTests still green. 15/15, 0 warnings.

Pacing (probe->delay) is slice 2b. Derivative reference: standard ffprobe
avg_frame_rate probing used across OBS/media tooling.
2026-08-31 19:45:48 -07:00

117 lines
3.9 KiB
C#

using System.Diagnostics;
using System.IO;
using Xunit;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// TASK 21 (slice 2): the ffprobe frame-rate parser is pure (synthetic text, no
/// probe); the one integration test drives the real <see cref="FfmpegFrameRateProbe"/>
/// against a fake locator + fake probe subprocess end to end.
/// </summary>
public class FfmpegFrameRateParserTests
{
// ─── Pure parser (unit) ───
[Fact]
public void Parse_AvgFrameRate_Rational()
{
var out0 = "codec_name=h264\navg_frame_rate=30/1\nr_frame_rate=30/1\n";
Assert.Equal(30.0, FfmpegFrameRateParser.ParseFrameRate(out0));
}
[Fact]
public void Parse_AvgFrameRate_29_97()
{
var out0 = "avg_frame_rate=30000/1001\nr_frame_rate=30/1\n";
Assert.Equal(30000.0 / 1001.0, FfmpegFrameRateParser.ParseFrameRate(out0)!.Value, 3);
}
[Fact]
public void Parse_PrefersAvgOverRFrame()
{
var out0 = "avg_frame_rate=25/1\nr_frame_rate=30000/1001\n";
Assert.Equal(25.0, FfmpegFrameRateParser.ParseFrameRate(out0));
}
[Fact]
public void Parse_FallsBackToRFrame_WhenAvgUnknown()
{
var out0 = "avg_frame_rate=0/0\nr_frame_rate=50/1\n";
Assert.Equal(50.0, FfmpegFrameRateParser.ParseFrameRate(out0));
}
[Fact]
public void Parse_IntegerValue_HasUnitDenominator()
{
var out0 = "avg_frame_rate=60\n";
Assert.Equal(60.0, FfmpegFrameRateParser.ParseFrameRate(out0));
}
[Theory]
[InlineData("avg_frame_rate=0/0\nr_frame_rate=0/0\n")]
[InlineData("")]
[InlineData("codec_name=h264\n")]
public void Parse_Unknown_ReturnsNull(string output)
{
Assert.Null(FfmpegFrameRateParser.ParseFrameRate(output));
}
// ─── The one integration test: probe against fake locator + fake subprocess ───
[Fact]
public async Task Probe_ReturnsParsedFps_FromFakeSubprocess()
{
var toolsDir = CreateTempDir();
try
{
// ffprobe.exe must exist next to the located ffmpeg for the sibling
// derivation to return a usable path.
var probeExe = Path.Combine(toolsDir, "ffprobe.exe");
File.WriteAllText(probeExe, "ffprobe");
var fakeOutput = "codec_name=h264\navg_frame_rate=24000/1001\nr_frame_rate=24000/1001\n";
var probe = new FfmpegFrameRateProbe(
new FakeLocator(toolsDir),
() => new FakeProbeProcess(System.Text.Encoding.UTF8.GetBytes(fakeOutput)));
var fps = await probe.ProbeAsync(@"C:\videos\clip.mp4");
Assert.Equal(24000.0 / 1001.0, fps!.Value, 3);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
private static string CreateTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
private sealed class FakeLocator : IFfmpegLocator
{
private readonly string _dir;
public FakeLocator(string dir) => _dir = dir;
public Task<string> LocateAsync(CancellationToken ct = default)
=> Task.FromResult(Path.Combine(_dir, "ffmpeg.exe"));
}
private sealed class FakeProbeProcess : IDecodeProcess
{
private readonly MemoryStream _stream;
public FakeProbeProcess(byte[] bytes) => _stream = new MemoryStream(bytes);
public void Start(ProcessStartInfo startInfo) { }
public Stream StandardOutput => _stream;
public bool HasExited => _stream.Position >= _stream.Length;
public int ExitCode => 0;
public void Kill() { }
public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask;
public void Dispose() => _stream.Dispose();
}
}