Files
ersatztv/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs
T
timothyandClaude Opus 4.8 de63603aab
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m45s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
test(320): don't blanket-delete foreign *.ts in the shared troubleshooting folder
Re-review nit: the regression test deleted every *.ts in the machine-global
TranscodeTroubleshootingFolder, which could nuke a live troubleshooting session's
segments on a dev machine (reaping files it didn't create). Drop the sweep and
keep only Directory.CreateDirectory — the folder-exists guarantee is what closes
the false-pass hole; NUnit runs serially and no test leaves >= 2 stray .ts, so
determinism is unaffected (negative control re-verified: still fails in ~10s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:03:10 +02:00

613 lines
26 KiB
C#

using System.IO.Abstractions;
using System.Reflection;
using System.Text.Json;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Troubleshooting;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Troubleshooting;
using ErsatzTV.Core.Notifications;
using ErsatzTV.Core.Troubleshooting;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class TroubleshootControllerTests
{
private TroubleshootController _controller = null!;
private IMediator _mediator = null!;
private IEntityLocker _entityLocker = null!;
private TroubleshootingPlaybackStatusStore _statusStore = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_entityLocker = Substitute.For<IEntityLocker>();
_statusStore = new TroubleshootingPlaybackStatusStore();
_controller = new TroubleshootController(
System.Threading.Channels.Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
Substitute.For<IFileSystem>(),
Substitute.For<IConfigElementRepository>(),
Substitute.For<ITroubleshootingNotifier>(),
_entityLocker,
_statusStore,
_mediator);
}
private static MediaItemInfo FakeMediaItemInfo() =>
new(
1,
"Title",
"Movie",
"LocalLibrary",
null,
"Movies",
MediaItemState.Normal,
TimeSpan.FromMinutes(90),
"1:1",
"16:9",
"24000/1001",
VideoScanKind.Progressive,
null,
1920,
1080,
[],
[]);
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Info()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetInfo))
?? throw new AssertionException("Missing action GetInfo");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("/api/v1/troubleshoot/info");
attribute.Name.ShouldBe("GetTroubleshootingInfo");
}
[Test]
public async Task GetInfo_Should_Serialize_General_Section_And_Carry_Platform_Capabilities()
{
var info = new TroubleshootingInfo(
"1.2.3",
new Dictionary<string, string> { ["ETV_FOO"] = "bar" },
[],
[],
[],
new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(),
[],
[],
[],
false,
false,
"nvidia output",
"qsv output",
"vaapi output",
"videotoolbox output");
_mediator.Send(Arg.Any<GetTroubleshootingInfo>(), Arg.Any<CancellationToken>())
.Returns(info);
TroubleshootingInfoResponseModel result = await _controller.GetInfo(CancellationToken.None);
result.NvidiaCapabilities.ShouldBe("nvidia output");
result.QsvCapabilities.ShouldBe("qsv output");
result.VaapiCapabilities.ShouldBe("vaapi output");
result.VideoToolboxCapabilities.ShouldBe("videotoolbox output");
using JsonDocument document = JsonDocument.Parse(result.GeneralJson);
document.RootElement.GetProperty("Version").GetString().ShouldBe("1.2.3");
document.RootElement.GetProperty("Environment").GetProperty("ETV_FOO").GetString().ShouldBe("bar");
document.RootElement.GetProperty("AviSynth").GetProperty("Demuxer").GetBoolean().ShouldBeFalse();
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_ValidateSchedule()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.ValidateSchedule))
?? throw new AssertionException("Missing action ValidateSchedule");
var attribute = action.GetCustomAttributes<HttpPostAttribute>().Single();
attribute.Template.ShouldBe("/api/v1/troubleshoot/validate-schedule");
attribute.Name.ShouldBe("ValidateSequentialSchedule");
}
[Test]
public void ValidateSchedule_Should_Use_Stable_Request_Dto()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.ValidateSchedule))
?? throw new AssertionException("Missing action ValidateSchedule");
action.GetParameters()[0].ParameterType.ShouldBe(typeof(ValidateSequentialScheduleRequest));
}
[TestCase("")]
[TestCase(" ")]
public async Task ValidateSchedule_Should_Return_400_For_Empty_Yaml(string yaml)
{
IActionResult result = await _controller.ValidateSchedule(
new ValidateSequentialScheduleRequest(yaml, false),
CancellationToken.None);
var badRequest = result.ShouldBeOfType<BadRequestObjectResult>();
badRequest.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(400);
await _mediator.DidNotReceive().Send(Arg.Any<ValidateSequentialSchedule>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task ValidateSchedule_Should_Map_Result_To_Response()
{
_mediator.Send(Arg.Any<ValidateSequentialSchedule>(), Arg.Any<CancellationToken>())
.Returns(new ValidateSequentialScheduleViewModel(false, ["boom"], "{}"));
IActionResult result = await _controller.ValidateSchedule(
new ValidateSequentialScheduleRequest("content: []", true),
CancellationToken.None);
var response = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<ValidateSequentialScheduleResponseModel>();
response.IsValid.ShouldBeFalse();
response.Messages.ShouldBe(["boom"]);
response.Json.ShouldBe("{}");
await _mediator.Received(1).Send(
Arg.Is<ValidateSequentialSchedule>(q => q.Yaml == "content: []" && q.IsImport),
Arg.Any<CancellationToken>());
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_StreamSelectors()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetStreamSelectors))
?? throw new AssertionException("Missing action GetStreamSelectors");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/stream-selectors");
attribute.Name.ShouldBe("GetTroubleshootingStreamSelectors");
}
[Test]
public async Task GetStreamSelectors_Should_Return_Selector_Names()
{
_mediator.Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>())
.Returns(["a.yml", "b.yml"]);
List<string> result = await _controller.GetStreamSelectors(CancellationToken.None);
result.ShouldBe(["a.yml", "b.yml"]);
await _mediator.Received(1).Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>());
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Subtitles()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetSubtitles))
?? throw new AssertionException("Missing action GetSubtitles");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/subtitles/{mediaItemId:int}");
attribute.Name.ShouldBe("GetTroubleshootingSubtitles");
}
[Test]
public async Task GetSubtitles_Should_Return_404_For_Unknown_Media_Item()
{
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Left(BaseError.New("nope")));
IActionResult result = await _controller.GetSubtitles(999, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
await _mediator.DidNotReceive().Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task GetSubtitles_Should_Map_ViewModels_To_Response()
{
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Right(FakeMediaItemInfo()));
_mediator.Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>())
.Returns(new List<SubtitleViewModel> { new(7, "eng", "English", "subrip") });
IActionResult result = await _controller.GetSubtitles(1, CancellationToken.None);
var subtitles = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<List<TroubleshootingSubtitleResponseModel>>();
subtitles.Count.ShouldBe(1);
subtitles[0].Id.ShouldBe(7);
subtitles[0].Language.ShouldBe("eng");
subtitles[0].Title.ShouldBe("English");
subtitles[0].Codec.ShouldBe("subrip");
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_PlaybackStatus()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetPlaybackStatus))
?? throw new AssertionException("Missing action GetPlaybackStatus");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/status");
attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus");
}
private static StartTroubleshootingPlaybackRequest DefaultPlaybackRequest(int mediaItem = 1, int channel = 0) =>
new(
mediaItem,
channel,
FfmpegProfile: 1,
StreamingMode.HttpLiveStreamingSegmenter,
Watermark: [],
GraphicsElement: [],
StreamSelector: string.Empty,
SubtitleId: null,
SeekSeconds: 0,
Start: null);
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_TroubleshootPlayback()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.TroubleshootPlayback))
?? throw new AssertionException("Missing action TroubleshootPlayback");
var attribute = action.GetCustomAttributes<HttpPostAttribute>().Single();
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/start");
attribute.Name.ShouldBe("StartTroubleshootingPlayback");
action.GetParameters()[0].ParameterType.ShouldBe(typeof(StartTroubleshootingPlaybackRequest));
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_PlaybackArchive()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.TroubleshootPlaybackArchive))
?? throw new AssertionException("Missing action TroubleshootPlaybackArchive");
var attribute = action.GetCustomAttributes<HttpPostAttribute>().Single();
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/archive");
attribute.Name.ShouldBe("DownloadTroubleshootingArchive");
action.GetCustomAttributes<HttpGetAttribute>().ShouldBeEmpty();
action.GetCustomAttributes<HttpHeadAttribute>().ShouldBeEmpty();
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_PlaybackSample()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.TroubleshootPlaybackSample))
?? throw new AssertionException("Missing action TroubleshootPlaybackSample");
var attribute = action.GetCustomAttributes<HttpPostAttribute>().Single();
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/sample/{mediaItemId:int}");
attribute.Name.ShouldBe("DownloadTroubleshootingMediaSample");
action.GetCustomAttributes<HttpGetAttribute>().ShouldBeEmpty();
action.GetCustomAttributes<HttpHeadAttribute>().ShouldBeEmpty();
}
[Test]
public async Task TroubleshootPlayback_Should_Return_409_When_Already_Locked()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(true);
IActionResult result = await _controller.TroubleshootPlayback(
DefaultPlaybackRequest(),
CancellationToken.None);
ConflictObjectResult conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
// pre-check short-circuits before dispatching any work
await _mediator.DidNotReceive()
.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task TroubleshootPlayback_Should_Return_409_ProblemDetails_When_Prepare_Loses_Lock_Race()
{
// pre-check passes, but the handler's atomic acquire loses the race to another session
// that grabbed the lock in between -> still a 409, not a 422 (ersatztv#316 review)
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, PlayoutItemResult>.Left(
new ErsatzTV.Core.Errors.LockedError("Troubleshooting playback is locked")));
IActionResult result = await _controller.TroubleshootPlayback(
DefaultPlaybackRequest(),
CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
}
[Test]
public async Task TroubleshootPlayback_Should_Return_404_ProblemDetails_When_Prepare_Not_Found()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, PlayoutItemResult>.Left(
new ErsatzTV.Core.Errors.NotFoundError("no such media item")));
IActionResult result = await _controller.TroubleshootPlayback(
DefaultPlaybackRequest(mediaItem: 999),
CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
}
[Test]
public async Task TroubleshootPlayback_Should_Return_422_ProblemDetails_When_Prepare_Fails_Validation()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, PlayoutItemResult>.Left(BaseError.New("unable to prepare")));
IActionResult result = await _controller.TroubleshootPlayback(
DefaultPlaybackRequest(),
CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(422);
}
[Test]
public async Task TroubleshootPlayback_Should_Return_200_With_Rooted_Iptv_Url_On_Success()
{
// The segment-readiness poll shells out to the real filesystem (Directory.GetFiles), not the
// injected IFileSystem abstraction, so this test seeds real files under the troubleshooting
// transcode folder rather than mocking it.
Directory.CreateDirectory(FileSystemLayout.TranscodeTroubleshootingFolder);
string segmentPath = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "seg-test-0.ts");
string segmentPath2 = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "seg-test-1.ts");
await File.WriteAllTextAsync(segmentPath, string.Empty);
await File.WriteAllTextAsync(segmentPath2, string.Empty);
try
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
var playoutItemResult = new PlayoutItemResult(
new CliWrap.Command("ffmpeg"),
Option<ErsatzTV.Core.Interfaces.Streaming.GraphicsEngineContext>.None,
Option<int>.Some(1));
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, PlayoutItemResult>.Right(playoutItemResult));
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Right(FakeMediaItemInfo()));
_mediator.Send(Arg.Any<GetTroubleshootingInfo>(), Arg.Any<CancellationToken>())
.Returns(new TroubleshootingInfo(
"1.2.3",
new Dictionary<string, string>(),
[],
[],
[],
new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(),
[],
[],
[],
false,
false,
null,
null,
null,
null));
var fileSystem = Substitute.For<IFileSystem>();
fileSystem.File.Exists(Arg.Any<string>()).Returns(true);
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(2));
var controller = new TroubleshootController(
System.Threading.Channels.Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
fileSystem,
configElementRepository,
Substitute.For<ITroubleshootingNotifier>(),
_entityLocker,
_statusStore,
_mediator)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { Request = { PathBase = "/etv" } }
}
};
IActionResult result = await controller.TroubleshootPlayback(
DefaultPlaybackRequest(),
CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
var body = ok.Value.ShouldBeOfType<TroubleshootingPlaybackStartedResponseModel>();
body.Url.ShouldBe("/etv/iptv/session/.troubleshooting/live.m3u8");
}
finally
{
File.Delete(segmentPath);
File.Delete(segmentPath2);
}
}
[Test]
public async Task TroubleshootPlayback_Should_Return_404_And_Not_Spin_When_Ffmpeg_Fails_Before_Segments()
{
// ersatztv#320: if ffmpeg dies after writing the playlist but before any segments appear, the
// segment-readiness poll must break on notifier.IsFailed instead of spinning until the client
// cancels (which would tie up the request thread + hold the troubleshooting lock).
//
// The segment scan uses the real Directory.GetFiles on the shared troubleshooting folder, so
// ensure the folder EXISTS — otherwise Directory.GetFiles throws DirectoryNotFoundException,
// which the pre-fix loop would surface as a fast 404 (a false pass that hides the regression).
// We deliberately do NOT delete stray *.ts here: this folder is machine-global and may hold a
// live troubleshooting session's segments (don't reap files this test didn't create). It isn't
// needed for determinism — NUnit runs serially and the sibling Should_Return_200 test cleans up
// its own seg-test-*.ts in a finally, so no test leaves >= initialSegmentCount(2) files behind;
// and the fixed code breaks on IsFailed before the scan runs regardless of folder contents.
Directory.CreateDirectory(FileSystemLayout.TranscodeTroubleshootingFolder);
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
var playoutItemResult = new PlayoutItemResult(
new CliWrap.Command("ffmpeg"),
Option<ErsatzTV.Core.Interfaces.Streaming.GraphicsEngineContext>.None,
Option<int>.Some(1));
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, PlayoutItemResult>.Right(playoutItemResult));
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Right(FakeMediaItemInfo()));
_mediator.Send(Arg.Any<GetTroubleshootingInfo>(), Arg.Any<CancellationToken>())
.Returns(new TroubleshootingInfo(
"1.2.3",
new Dictionary<string, string>(),
[],
[],
[],
new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(),
[],
[],
[],
false,
false,
null,
null,
null,
null));
var fileSystem = Substitute.For<IFileSystem>();
// playlist "exists" so the first wait loop exits immediately and we reach the segment poll
fileSystem.File.Exists(Arg.Any<string>()).Returns(true);
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(2));
var notifier = Substitute.For<ITroubleshootingNotifier>();
notifier.IsFailed(Arg.Any<Guid>()).Returns(true);
var controller = new TroubleshootController(
System.Threading.Channels.Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
fileSystem,
configElementRepository,
notifier,
_entityLocker,
_statusStore,
_mediator)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { Request = { PathBase = "/etv" } }
}
};
// safety deadline so a regression (the loop spinning) fails the test in bounded time instead
// of hanging CI; the fixed code exits via IsFailed long before this fires
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
IActionResult result = await controller.TroubleshootPlayback(DefaultPlaybackRequest(), cts.Token);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
// the loop must have exited via IsFailed, NOT by hitting the cancellation deadline — this is
// what makes the test non-vacuous (it fails on the pre-fix spinning loop)
cts.IsCancellationRequested.ShouldBeFalse();
notifier.Received().RemoveSession(Arg.Any<Guid>());
}
[Test]
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
TroubleshootingPlaybackStatusResponseModel result =
await _controller.GetPlaybackStatus(CancellationToken.None);
result.State.ShouldBe("idle");
result.ExitCode.ShouldBeNull();
result.Speed.ShouldBeNull();
}
[Test]
public async Task GetPlaybackStatus_Should_Report_Running_When_Locked()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(true);
TroubleshootingPlaybackStatusResponseModel result =
await _controller.GetPlaybackStatus(CancellationToken.None);
result.State.ShouldBe("running");
}
[Test]
public async Task GetPlaybackStatus_Should_Report_Completed_With_ExitCode_And_Speed()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
_statusStore.RecordCompletion(0, 1.5);
TroubleshootingPlaybackStatusResponseModel result =
await _controller.GetPlaybackStatus(CancellationToken.None);
result.State.ShouldBe("completed");
result.ExitCode.ShouldBe(0);
result.Speed.ShouldBe(1.5);
}
[Test]
public async Task GetPlaybackStatus_Should_Report_Failed_For_NonZero_ExitCode()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
_statusStore.RecordCompletion(1, Option<double>.None);
TroubleshootingPlaybackStatusResponseModel result =
await _controller.GetPlaybackStatus(CancellationToken.None);
result.State.ShouldBe("failed");
result.ExitCode.ShouldBe(1);
result.Speed.ShouldBeNull();
}
[Test]
public async Task RecordTroubleshootingPlaybackStatusHandler_Should_Record_Result_In_Store()
{
var store = new TroubleshootingPlaybackStatusStore();
var handler = new RecordTroubleshootingPlaybackStatusHandler(store);
store.CurrentResult.IsNone.ShouldBeTrue();
await handler.Handle(
new PlaybackTroubleshootingCompletedNotification(0, Option<Exception>.None, 2.0),
CancellationToken.None);
store.CurrentResult.IsSome.ShouldBeTrue();
TroubleshootingPlaybackResult recorded =
store.CurrentResult.IfNone(() => throw new AssertionException("Expected a recorded result"));
recorded.ExitCode.ShouldBe(0);
recorded.Speed.IfNone(-1).ShouldBe(2.0);
store.Reset();
store.CurrentResult.IsNone.ShouldBeTrue();
}
}