All checks were successful
Forgejo Release / release (push) Successful in 13m7s
Remember detected capture devices, camera and microphone selections, and the default podcast title in user settings. Restore preferences on page creation and surface save failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
266 lines
13 KiB
C#
266 lines
13 KiB
C#
using System.Net;
|
|
using Avalonia.Headless.XUnit;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Controls;
|
|
using PostIt.Services;
|
|
using PostIt.ViewModels;
|
|
using PostIt.Views;
|
|
using Yavsc.Abstract.Streaming;
|
|
using Yavsc.Api.Client;
|
|
|
|
namespace PostIt.Tests;
|
|
|
|
public sealed class PodcastBroadcasterTests
|
|
{
|
|
[Fact]
|
|
public Task Shared_page_exposes_running_capture_preview_and_clears_it_before_disposal()
|
|
=> HeadlessUnitTestSession.GetOrStartForAssembly(typeof(PodcastBroadcasterTests).Assembly).Dispatch(async () =>
|
|
{
|
|
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-preview-{Guid.NewGuid():N}.json");
|
|
var devices = Platform.GetPodcastCaptureDevices;
|
|
var permissions = Platform.RequestPodcastPermissionsAsync;
|
|
var factory = Platform.CreatePodcastCaptureAsync;
|
|
var store = new TokenStore(tokenPath);
|
|
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
|
|
await using var api = new YavscApiClient(new Settings(tokenPath + ".settings"), store);
|
|
await using var transport = new Transport();
|
|
FakeCapture? capture = null;
|
|
PodcastsPageViewModel? vm = null;
|
|
try
|
|
{
|
|
var preview = new Border();
|
|
var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
Platform.GetPodcastCaptureDevices = () => new([new("Camera", "camera")], [new("None", "")]);
|
|
Platform.RequestPodcastPermissionsAsync = (_, _) => Task.CompletedTask;
|
|
Platform.CreatePodcastCaptureAsync = (_, _, directory, _) =>
|
|
{
|
|
capture = new FakeCapture(directory, preview)
|
|
{
|
|
OnDispose = () =>
|
|
{
|
|
Assert.Null(vm!.CapturePreview);
|
|
disposed.TrySetResult();
|
|
}
|
|
};
|
|
return Task.FromResult<IPodcastCapture>(capture);
|
|
};
|
|
var session = new SessionStatusViewModel { Api = api };
|
|
session.Refresh();
|
|
vm = new PodcastsPageViewModel(new PodcastApiClient(transport, () => "https://api.example/"), api, session)
|
|
{
|
|
Title = "Preview"
|
|
};
|
|
vm.DetectDevicesCommand.Execute(null);
|
|
await vm.StartCommand.ExecuteAsync(null);
|
|
Assert.Same(preview, vm.CapturePreview);
|
|
Assert.True(vm.IsBroadcasting);
|
|
var page = new PodcastsPage { DataContext = vm };
|
|
var host = page.FindControl<ContentControl>("CapturePreviewHost");
|
|
Assert.NotNull(host);
|
|
Assert.Same(preview, host.Content);
|
|
await vm.DeactivateAsync();
|
|
await disposed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
|
Assert.Null(vm.CapturePreview);
|
|
Assert.Null(host.Content);
|
|
Assert.True(capture!.Stopped);
|
|
return true;
|
|
}
|
|
finally
|
|
{
|
|
if (vm?.IsBroadcasting == true) await vm.StopAsync();
|
|
Platform.GetPodcastCaptureDevices = devices;
|
|
Platform.RequestPodcastPermissionsAsync = permissions;
|
|
Platform.CreatePodcastCaptureAsync = factory;
|
|
File.Delete(tokenPath);
|
|
File.Delete(tokenPath + ".settings");
|
|
}
|
|
}, TestContext.Current.CancellationToken);
|
|
|
|
[AvaloniaFact]
|
|
public async Task Shared_page_uses_platform_devices_and_requests_permissions_before_creating_server_broadcast()
|
|
{
|
|
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-podcast-{Guid.NewGuid():N}.json");
|
|
var devices = Platform.GetPodcastCaptureDevices;
|
|
var permissions = Platform.RequestPodcastPermissionsAsync;
|
|
var factory = Platform.CreatePodcastCaptureAsync;
|
|
var store = new TokenStore(tokenPath);
|
|
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
|
|
await using var api = new YavscApiClient(new Settings(tokenPath + ".settings"), store);
|
|
await using var transport = new Transport();
|
|
try
|
|
{
|
|
Platform.GetPodcastCaptureDevices = () => new(
|
|
[new("Front", "front"), new("Rear", "rear")],
|
|
[new("None", ""), new("Android microphone", "microphone")]);
|
|
Platform.RequestPodcastPermissionsAsync = (_, _) =>
|
|
Task.FromException(new UnauthorizedAccessException("Camera denied"));
|
|
Platform.CreatePodcastCaptureAsync = (_, _, _, _) =>
|
|
throw new InvalidOperationException("Capture must not start after permission denial.");
|
|
var session = new SessionStatusViewModel { Api = api };
|
|
session.Refresh();
|
|
var vm = new PodcastsPageViewModel(new PodcastApiClient(transport, () => "https://api.example/"), api, session)
|
|
{
|
|
Title = "Android live"
|
|
};
|
|
Assert.True(vm.CanBroadcast);
|
|
vm.DetectDevicesCommand.Execute(null);
|
|
Assert.Equal(2, vm.Cameras.Count);
|
|
Assert.Equal("front", vm.SelectedCamera?.Value);
|
|
Assert.Equal("microphone", vm.SelectedMicrophone?.Value);
|
|
Assert.True(vm.StartCommand.CanExecute(null));
|
|
await vm.StartCommand.ExecuteAsync(null);
|
|
Assert.Contains("Camera denied", vm.Status);
|
|
Assert.Equal(0, transport.CreateCount);
|
|
Assert.False(vm.IsBusy);
|
|
Assert.False(vm.IsBroadcasting);
|
|
}
|
|
finally
|
|
{
|
|
Platform.GetPodcastCaptureDevices = devices;
|
|
Platform.RequestPodcastPermissionsAsync = permissions;
|
|
Platform.CreatePodcastCaptureAsync = factory;
|
|
File.Delete(tokenPath);
|
|
File.Delete(tokenPath + ".settings");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Prepared_broadcast_starts_the_existing_emission_instead_of_creating_another()
|
|
{
|
|
await using var transport = new Transport();
|
|
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
|
FakeCapture? capture = null;
|
|
await using var broadcaster = await PodcastBroadcaster.StartAsync(client, "Prepared",
|
|
(directory, _) => Task.FromResult<IPodcastCapture>(capture = new FakeCapture(directory)),
|
|
TestContext.Current.CancellationToken, transport.PodcastId);
|
|
Assert.Equal(transport.PodcastId, broadcaster.Podcast.Id);
|
|
Assert.Equal(transport.PodcastId, transport.StartedId);
|
|
Assert.Equal(0, transport.CreateCount);
|
|
await capture!.StopAsync();
|
|
await broadcaster.Completion.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Native_stop_flushes_final_segment_before_completing_server_replay()
|
|
{
|
|
await using var transport = new Transport();
|
|
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
|
FakeCapture? capture = null;
|
|
await using var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
|
|
(directory, _) =>
|
|
{
|
|
capture = new FakeCapture(directory);
|
|
return Task.FromResult<IPodcastCapture>(capture);
|
|
}, TestContext.Current.CancellationToken);
|
|
await transport.FirstUpload.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
|
await capture!.StopAsync();
|
|
await broadcaster.Completion.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
|
Assert.Equal(new[] { 0, 1 }, transport.Uploads);
|
|
Assert.Equal(new[] { "upload:0", "upload:1", "complete" }, transport.Events);
|
|
Assert.True(capture.Stopped);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Native_failure_stops_capture_and_preserves_unacknowledged_segments()
|
|
{
|
|
await using var transport = new Transport { FailUpload = true };
|
|
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
|
FakeCapture? capture = null;
|
|
var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
|
|
(directory, _) =>
|
|
{
|
|
capture = new FakeCapture(directory);
|
|
return Task.FromResult<IPodcastCapture>(capture);
|
|
}, TestContext.Current.CancellationToken);
|
|
try
|
|
{
|
|
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.Completion);
|
|
Assert.True(capture!.Stopped);
|
|
Assert.True(File.Exists(Path.Combine(capture.Directory, "segment-00000000.ts")));
|
|
Assert.DoesNotContain("complete", transport.Events);
|
|
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.DisposeAsync().AsTask());
|
|
Assert.True(capture.Disposed);
|
|
}
|
|
finally
|
|
{
|
|
if (capture is not null) Directory.Delete(capture.Directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Startup_failure_finalizes_empty_server_broadcast()
|
|
{
|
|
await using var transport = new Transport();
|
|
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => PodcastBroadcaster.StartAsync(client, "Denied",
|
|
(_, _) => Task.FromException<IPodcastCapture>(new UnauthorizedAccessException("Camera denied")),
|
|
TestContext.Current.CancellationToken));
|
|
Assert.Equal(new[] { "complete" }, transport.Events);
|
|
}
|
|
|
|
private sealed class FakeCapture : IPodcastCapture
|
|
{
|
|
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public string Directory { get; }
|
|
public bool Stopped { get; private set; }
|
|
public bool Disposed { get; private set; }
|
|
public Control? Preview { get; }
|
|
public Action? OnDispose { get; init; }
|
|
public Task Completion => _completion.Task;
|
|
public FakeCapture(string directory, Control? preview = null)
|
|
{
|
|
Preview = preview;
|
|
Directory = directory;
|
|
File.WriteAllBytes(Path.Combine(directory, "segment-00000000.ts"), [0x47, 1]);
|
|
File.WriteAllText(Path.Combine(directory, "index.m3u8"), "#EXTM3U\n#EXTINF:2,\nsegment-00000000.ts\n");
|
|
}
|
|
public Task StopAsync()
|
|
{
|
|
if (Stopped) return Task.CompletedTask;
|
|
File.WriteAllBytes(Path.Combine(Directory, "segment-00000001.ts"), [0x47, 2]);
|
|
File.AppendAllText(Path.Combine(Directory, "index.m3u8"), "#EXTINF:0.5,\nsegment-00000001.ts\n");
|
|
Stopped = true;
|
|
_completion.TrySetResult();
|
|
return Task.CompletedTask;
|
|
}
|
|
public ValueTask DisposeAsync() { Disposed = true; OnDispose?.Invoke(); return ValueTask.CompletedTask; }
|
|
}
|
|
|
|
private sealed class Transport : IYavscApiClient
|
|
{
|
|
private readonly PodcastResponse _podcast = new(Guid.NewGuid(), "Test", "alice", "Alice",
|
|
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Live, 0, 0, 0);
|
|
public HttpClient Http { get; } = new();
|
|
public List<int> Uploads { get; } = [];
|
|
public List<string> Events { get; } = [];
|
|
public bool FailUpload { get; init; }
|
|
public int CreateCount { get; private set; }
|
|
public Guid PodcastId => _podcast.Id;
|
|
public Guid? StartedId { get; private set; }
|
|
public TaskCompletionSource FirstUpload { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
|
{
|
|
if (path.EndsWith("/complete", StringComparison.Ordinal)) Events.Add("complete");
|
|
else if (path.EndsWith("/start", StringComparison.Ordinal))
|
|
StartedId = Guid.Parse(new Uri(path).Segments[^2].TrimEnd('/'));
|
|
else CreateCount++;
|
|
return Task.FromResult((T)(object)_podcast);
|
|
}
|
|
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
|
{
|
|
if (FailUpload) throw new HttpRequestException("Denied", null, HttpStatusCode.Forbidden);
|
|
var sequence = int.Parse(new Uri(path).Segments[^1], System.Globalization.CultureInfo.InvariantCulture);
|
|
Uploads.Add(sequence);
|
|
Events.Add($"upload:{sequence}");
|
|
FirstUpload.TrySetResult();
|
|
return Task.FromResult((T)(object)_podcast);
|
|
}
|
|
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
|
=> CallAsync<PodcastResponse>(method, path, body, ct);
|
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
|
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
|
|
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
|
=> throw new NotSupportedException();
|
|
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
|
|
}
|
|
}
|