Files
memby/server/internal/api/requests_progress_test.go
T
2026-08-19 14:25:44 +12:00

256 lines
9.3 KiB
Go

package api
import (
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
)
func TestWorkStateReadsTheThreeVocabulariesInOrder(t *testing.T) {
cases := []struct {
name string
work requestWork
want string
}{
{
// The case the ordering exists for: everything about this row looks finished,
// and the *arr has decided it cannot use what arrived.
name: "an error verdict outranks a completed download",
work: requestWork{Status: "completed", TrackedState: "importPending", TrackedStatus: "error"},
want: RequestStatusFailed,
},
{
name: "importing is processing even while the client still says downloading",
work: requestWork{Status: "downloading", TrackedState: "importing", TrackedStatus: "ok"},
want: RequestStatusProcessing,
},
{
name: "moving bytes",
work: requestWork{Status: "downloading", TrackedState: "downloading", TrackedStatus: "ok"},
want: RequestStatusDownloading,
},
{
name: "queued is a wait, not a download",
work: requestWork{Status: "queued", TrackedState: "downloading", TrackedStatus: "ok"},
want: RequestStatusFound,
},
{
name: "a delay profile is a wait",
work: requestWork{Status: "delay", TrackedStatus: "ok"},
want: RequestStatusFound,
},
{
// A future Radarr inventing a word must not tell a viewer their film is broken.
name: "an unknown word degrades to found",
work: requestWork{Status: "reticulatingSplines", TrackedState: "somethingNew"},
want: RequestStatusFound,
},
{
// A warning is not a failure. Radarr flags a stalled download this way and will
// carry on with it.
name: "a warning verdict is not a failure",
work: requestWork{Status: "downloading", TrackedStatus: "warning"},
want: RequestStatusDownloading,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := workState(tc.work); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestDownloadProgressSaysNothingAboutAnEmptyQueue(t *testing.T) {
// The absence of a queue row is the caller's question, not a state: it means something
// different for a film already on the shelf, one nobody has released, and one that has
// been searched for all afternoon.
got := downloadProgress(nil)
if got.Status != "" {
t.Fatalf("an empty queue must claim no state, got %q", got.Status)
}
if got.Progress != 0 || got.EstimatedReadySeconds != 0 {
t.Fatal("an empty queue must carry no figures")
}
}
func TestDownloadProgressReportsPercentAndEstimate(t *testing.T) {
got := downloadProgress([]requestWork{{
Size: 1000, SizeLeft: 320, TimeLeft: "00:14:00",
Status: "downloading", TrackedState: "downloading", TrackedStatus: "ok",
}})
if got.Status != RequestStatusDownloading {
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got.Status)
}
if got.Progress != 68 {
t.Fatalf("expected 68%%, got %d%%", got.Progress)
}
if got.EstimatedReadySeconds != 840 {
t.Fatalf("expected 840 seconds, got %d", got.EstimatedReadySeconds)
}
}
func TestASeasonIsOneAnswerOverManyRows(t *testing.T) {
// A season pack is one row per episode at a dozen different stages. What the card says
// is the most active of them, and the percentage is over the whole pack rather than
// whichever episode happens to be first.
work := []requestWork{
{Size: 100, SizeLeft: 0, Status: "completed", TrackedState: "imported"},
{Size: 100, SizeLeft: 50, TimeLeft: "00:05:00", Status: "downloading", TrackedState: "downloading"},
{Size: 100, SizeLeft: 100, TimeLeft: "00:20:00", Status: "queued", TrackedState: "downloading"},
}
got := downloadProgress(work)
if got.Status != RequestStatusDownloading {
t.Fatalf("a pack with one episode downloading is downloading, got %q", got.Status)
}
if got.Progress != 50 {
t.Fatalf("expected 50%% over the whole pack, got %d%%", got.Progress)
}
// The pack is ready when its slowest episode is, not its fastest.
if got.EstimatedReadySeconds != 1200 {
t.Fatalf("expected the longest remaining time, got %d", got.EstimatedReadySeconds)
}
}
func TestOneFailedEpisodeDoesNotFailTheWholeShow(t *testing.T) {
work := []requestWork{
{Size: 100, SizeLeft: 100, Status: "failed", TrackedStatus: "error"},
{Size: 100, SizeLeft: 40, TimeLeft: "00:03:00", Status: "downloading"},
}
if got := downloadProgress(work).Status; got != RequestStatusDownloading {
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got)
}
// With nothing else happening, though, failed is the honest answer.
only := []requestWork{{Size: 100, SizeLeft: 100, Status: "failed", TrackedStatus: "error"}}
if got := downloadProgress(only).Status; got != RequestStatusFailed {
t.Fatalf("expected %q, got %q", RequestStatusFailed, got)
}
}
func TestAnEstimateIsNeverManufactured(t *testing.T) {
// A download client that will not say how long it has left leaves the estimate absent.
// This is the acceptance criterion the feature is judged on, so it is asserted from
// several directions.
silent := downloadProgress([]requestWork{{Size: 100, SizeLeft: 60, Status: "downloading"}})
if silent.EstimatedReadySeconds != 0 {
t.Fatalf("no time left reported must produce no estimate, got %d", silent.EstimatedReadySeconds)
}
if silent.Progress != 40 {
t.Fatalf("a missing estimate must not cost the percentage, got %d%%", silent.Progress)
}
// One silent row makes the whole pack unknowable: reporting the rest of it as the whole
// would be an estimate that quietly expires while the download carries on.
partly := downloadProgress([]requestWork{
{Size: 100, SizeLeft: 50, TimeLeft: "00:05:00", Status: "downloading"},
{Size: 100, SizeLeft: 90, Status: "downloading"},
})
if partly.EstimatedReadySeconds != 0 {
t.Fatalf("one silent row must make the pack unknowable, got %d", partly.EstimatedReadySeconds)
}
// An import has no measured duration, so its "few minutes" is wording rather than a
// number — see requestStatusDetail.
importing := downloadProgress([]requestWork{
{Size: 100, SizeLeft: 0, TimeLeft: "00:00:30", Status: "completed", TrackedState: "importing"},
})
if importing.EstimatedReadySeconds != 0 {
t.Fatalf("an import must not carry an estimate, got %d", importing.EstimatedReadySeconds)
}
}
func TestSizeIsOnlyReadWhenTheClientGaveOne(t *testing.T) {
// Zero size and zero left is a client that has not said, and reading it as
// (0-0)/0 finished would draw a full bar over a download that has not started.
got := downloadProgress([]requestWork{{Status: "downloading"}})
if got.Progress != 0 {
t.Fatalf("an unsized download must report no percentage, got %d%%", got.Progress)
}
// A client reporting more left than the whole is clamped rather than trusted into a
// negative percentage.
odd := downloadProgress([]requestWork{{Size: 100, SizeLeft: 400, Status: "downloading"}})
if odd.Progress != 0 {
t.Fatalf("expected a clamped 0%%, got %d%%", odd.Progress)
}
}
func TestParseTimeLeftReadsTheArrsFormat(t *testing.T) {
cases := []struct {
in string
want time.Duration
ok bool
}{
{"00:14:32", 14*time.Minute + 32*time.Second, true},
{"01:00:00", time.Hour, true},
{"1.02:03:04", 26*time.Hour + 3*time.Minute + 4*time.Second, true},
{"00:00:30.5000000", 30 * time.Second, true},
// Everything it cannot read completely is refused rather than salvaged. This is the
// one input that becomes a promise to a viewer.
{"", 0, false},
{"14:32", 0, false},
{"soon", 0, false},
{"00:xx:32", 0, false},
{"-1.00:00:01", 0, false},
}
for _, tc := range cases {
got, ok := parseTimeLeft(tc.in)
if ok != tc.ok {
t.Fatalf("%q: expected ok=%v, got %v", tc.in, tc.ok, ok)
}
if ok && got != tc.want {
t.Fatalf("%q: expected %s, got %s", tc.in, tc.want, got)
}
}
}
func TestEstimatedReadyLabelIsCoarserThanTheNumberItWasGiven(t *testing.T) {
// The download client's seconds are not precision anybody has, so printing them would
// claim an accuracy the estimate does not carry.
cases := []struct {
seconds int
want string
}{
{0, ""},
{-5, ""},
{45, "Estimated ready in under a minute"},
{840, "Estimated ready in ~14 minutes"},
{4500, "Estimated ready in about an hour"},
{5 * 3600, "Estimated ready in ~5 hours"},
{50 * 3600, "Estimated ready in over a day"},
}
for _, tc := range cases {
if got := estimatedReadyLabel(tc.seconds); got != tc.want {
t.Fatalf("%d seconds: expected %q, got %q", tc.seconds, tc.want, got)
}
}
}
func TestQueueIsGroupedByTheIdTheRequestWasRecordedAgainst(t *testing.T) {
// A queue row names the *arr's own internal id, which nothing outside it has seen. The
// translation to TMDb is what lets a stored request find its own download.
work := groupMovieWork(
[]radarr.QueueItem{
{MovieID: 7, Size: 100, Sizeleft: 25, Status: "downloading"},
// Radarr id 99 is not in the catalogue map: a film added since it was cached.
{MovieID: 99, Size: 100, Sizeleft: 100, Status: "queued"},
},
map[int]int{7: 550, 8: 603},
)
if len(work[550]) != 1 {
t.Fatalf("expected one row against tmdb 550, got %d", len(work[550]))
}
if work[550][0].Status != "downloading" {
t.Fatalf("the row lost its status: %+v", work[550][0])
}
// A download for a film added since the catalogue was cached cannot be translated, and
// is dropped rather than guessed at.
if _, ok := work[0]; ok {
t.Fatal("an untranslatable row must not be filed under a zero id")
}
if len(work) != 1 {
t.Fatalf("expected one translated title, got %d", len(work))
}
}