Part 2 of the faceted plan, Phase 5 (steps 5-6): the scan that used to print ~4 lines per unchanged file now prints ONE summary line, and a poll tick landing mid-scan no longer stacks a second full scan. - Quiet scans (§4): the walk loop is silent — inserts, changes, and errors log inside processMediaFile; the duplicate 'Successfully processed' branch (both branches printed it) and the per-file 'processing / already exists / same size, skipping' chatter are gone. Summary carries the trigger and a changed counter: 'Scan of <folders> (trigger: poll): N files, N changed, N new, Xs, N errors'. Triggers threaded through every enqueue site: poll (startBackupScan), watcher (dirty-dirs flush), admin (scan API), initial (startup scan). - Enqueue coalescing (§5): Worker.HasActiveScanForLibrary (JobResult gains additive job_type so only SCAN jobs coalesce — an import sharing the library id never blocks one). enqueueLibraryScan skips silently; the admin scan API answers 409 with the active job id. Enforces the one-scan-per-library invariant the bulk scan-state map leans on. - Watcher capability probe (§4): ONE Add on a root folder; failure disables the watcher with a single startup line (replaces the per-directory warning spam on inotify-broken deployments). The watcher stays an optional latency reducer over scheduled scans. - Per-directory sibling cache (§6): hasSiblingBookFileCached caches the ReadDir per directory for one scan — loose-image manga re-stats thousands of images sharing a handful of directories. Tests: TestHasActiveScanForLibrary (unit — running/pending coalesce, terminal jobs don't, non-scan jobs don't, empty library never, nil worker safe). Full scanner suite green in the container.
104 lines
3.5 KiB
Go
104 lines
3.5 KiB
Go
package services
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// GetActiveScanJobs backs the refresh-survival contract: a client that
|
|
// loads the page mid-scan must be able to discover pending/running jobs
|
|
// (with their library) rather than only learning about scans from
|
|
// websocket events emitted after the page opened.
|
|
func TestGetActiveScanJobs(t *testing.T) {
|
|
w := NewWorker(1, nil)
|
|
|
|
seed := map[string]*JobResult{
|
|
"running-scan": {JobID: "running-scan", Status: JobStatusRunning, LibraryID: "lib-1", Progress: 0.42},
|
|
"pending-scan": {JobID: "pending-scan", Status: JobStatusPending, LibraryID: "lib-2"},
|
|
"done-scan": {JobID: "done-scan", Status: JobStatusCompleted, LibraryID: "lib-3"},
|
|
"failed-scan": {JobID: "failed-scan", Status: JobStatusFailed},
|
|
"cancelled": {JobID: "cancelled", Status: JobStatusCancelled},
|
|
}
|
|
w.mu.Lock()
|
|
w.results = seed
|
|
w.mu.Unlock()
|
|
|
|
active := w.GetActiveScanJobs()
|
|
if len(active) != 2 {
|
|
t.Fatalf("expected 2 active jobs, got %d (%+v)", len(active), active)
|
|
}
|
|
|
|
seen := map[string]JobResult{}
|
|
for _, job := range active {
|
|
seen[job.JobID] = job
|
|
}
|
|
|
|
running, ok := seen["running-scan"]
|
|
if !ok {
|
|
t.Fatal("running scan missing from active jobs")
|
|
}
|
|
if running.LibraryID != "lib-1" {
|
|
t.Errorf("expected library_id lib-1, got %q", running.LibraryID)
|
|
}
|
|
if running.Progress != 0.42 {
|
|
t.Errorf("expected progress 0.42, got %v", running.Progress)
|
|
}
|
|
if _, ok := seen["pending-scan"]; !ok {
|
|
t.Error("pending scan missing from active jobs")
|
|
}
|
|
for _, id := range []string{"done-scan", "failed-scan", "cancelled"} {
|
|
if _, ok := seen[id]; ok {
|
|
t.Errorf("terminal job %q must not appear as active", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetActiveScanJobsEmpty(t *testing.T) {
|
|
w := NewWorker(1, nil)
|
|
if jobs := w.GetActiveScanJobs(); len(jobs) != 0 {
|
|
t.Fatalf("expected no active jobs, got %d", len(jobs))
|
|
}
|
|
}
|
|
|
|
// HasActiveScanForLibrary is the enqueue-coalescing predicate (plan
|
|
// Part 2 §5): a pending/running SCAN for the same library makes the
|
|
// next poll tick / watcher event a no-op. Only scans coalesce — an
|
|
// import or convert sharing the library id must not block one.
|
|
func TestHasActiveScanForLibrary(t *testing.T) {
|
|
w := NewWorker(1, nil)
|
|
|
|
seed := map[string]*JobResult{
|
|
"running-scan": {JobID: "running-scan", Status: JobStatusRunning, LibraryID: "lib-1", JobType: JobTypeScan},
|
|
"pending-scan": {JobID: "pending-scan", Status: JobStatusPending, LibraryID: "lib-2", JobType: JobTypeScan},
|
|
"done-scan": {JobID: "done-scan", Status: JobStatusCompleted, LibraryID: "lib-3", JobType: JobTypeScan},
|
|
"failed-scan": {JobID: "failed-scan", Status: JobStatusFailed, LibraryID: "lib-4", JobType: JobTypeScan},
|
|
"import-lib1": {JobID: "import-lib1", Status: JobStatusRunning, LibraryID: "lib-1", JobType: JobTypeImport},
|
|
"no-library": {JobID: "no-library", Status: JobStatusRunning, JobType: JobTypeScan},
|
|
}
|
|
w.mu.Lock()
|
|
w.results = seed
|
|
w.mu.Unlock()
|
|
|
|
cases := []struct {
|
|
library string
|
|
want bool
|
|
}{
|
|
{"lib-1", true}, // running scan
|
|
{"lib-2", true}, // pending scan
|
|
{"lib-3", false}, // completed = terminal
|
|
{"lib-4", false}, // failed = terminal
|
|
{"lib-9", false}, // unknown library
|
|
{"", false}, // jobs without a library never coalesce
|
|
}
|
|
for _, c := range cases {
|
|
if got := w.HasActiveScanForLibrary(c.library); got != c.want {
|
|
t.Errorf("HasActiveScanForLibrary(%q) = %v, want %v", c.library, got, c.want)
|
|
}
|
|
}
|
|
|
|
// A nil worker (tests, early startup) must not panic.
|
|
var nilWorker *Worker
|
|
if nilWorker.HasActiveScanForLibrary("lib-1") {
|
|
t.Error("nil worker must report no active scans")
|
|
}
|
|
}
|