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)) } }