- Replace `upgrade` stub with real command (alias for `self-update`) - Also register `update` as alias: `unarr update` works too - Rewrite `status` to show full config, disk usage, daemon state, and update availability with colored sections - Add version check cache (1h TTL) so `status` is instant on repeat runs - Guard against division by zero on empty filesystems - Guard against negative durations from clock skew - Guard against stale PID via heartbeat recency check (2 min) - Add comprehensive test coverage across agent, engine, upgrade, usenet, arr, library, mediaserver, and UI packages - Improve Makefile coverage target to exclude cmd/ glue code - Fix stream handler resource cleanup and ffprobe error handling
632 lines
16 KiB
Go
632 lines
16 KiB
Go
package download
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/torrentclaw/unarr/internal/usenet/nzb"
|
|
)
|
|
|
|
// --- Fingerprint ---
|
|
|
|
func TestFingerprint_EmptyNZB(t *testing.T) {
|
|
n := &nzb.NZB{}
|
|
fp := Fingerprint(n)
|
|
// Empty NZB should still produce a deterministic hash (of zero message IDs).
|
|
fp2 := Fingerprint(n)
|
|
if fp != fp2 {
|
|
t.Fatal("fingerprint of empty NZB should be deterministic")
|
|
}
|
|
}
|
|
|
|
func TestFingerprint_OrderIndependent(t *testing.T) {
|
|
// Fingerprint sorts IDs, so different file order should produce the same hash.
|
|
n1 := &nzb.NZB{
|
|
Files: []nzb.File{
|
|
{Segments: []nzb.Segment{{MessageID: "a@x"}, {MessageID: "b@x"}}},
|
|
{Segments: []nzb.Segment{{MessageID: "c@x"}}},
|
|
},
|
|
}
|
|
n2 := &nzb.NZB{
|
|
Files: []nzb.File{
|
|
{Segments: []nzb.Segment{{MessageID: "c@x"}}},
|
|
{Segments: []nzb.Segment{{MessageID: "b@x"}, {MessageID: "a@x"}}},
|
|
},
|
|
}
|
|
if Fingerprint(n1) != Fingerprint(n2) {
|
|
t.Fatal("fingerprint should be order-independent (sorted by message ID)")
|
|
}
|
|
}
|
|
|
|
func TestFingerprint_SingleSegment(t *testing.T) {
|
|
n := &nzb.NZB{
|
|
Files: []nzb.File{
|
|
{Segments: []nzb.Segment{{MessageID: "only@one"}}},
|
|
},
|
|
}
|
|
fp := Fingerprint(n)
|
|
if fp == [32]byte{} {
|
|
t.Fatal("fingerprint should not be zero for a non-empty NZB")
|
|
}
|
|
}
|
|
|
|
// --- ProgressTracker MarkDone idempotency ---
|
|
|
|
func TestProgressTracker_MarkDoneIdempotent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 5)
|
|
tracker := NewProgressTracker("idem", n, dir)
|
|
|
|
tracker.MarkDone(0, 2)
|
|
if tracker.CompletedSegments(0) != 1 {
|
|
t.Fatalf("expected 1, got %d", tracker.CompletedSegments(0))
|
|
}
|
|
|
|
// Mark the same segment again — count should not increase.
|
|
tracker.MarkDone(0, 2)
|
|
if tracker.CompletedSegments(0) != 1 {
|
|
t.Fatalf("idempotent mark: expected 1, got %d", tracker.CompletedSegments(0))
|
|
}
|
|
}
|
|
|
|
// --- ProgressTracker TotalCompleted ---
|
|
|
|
func TestProgressTracker_TotalCompleted(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(3, 4) // 3 files, 4 segs each
|
|
tracker := NewProgressTracker("total", n, dir)
|
|
|
|
tracker.MarkDone(0, 0)
|
|
tracker.MarkDone(0, 1)
|
|
tracker.MarkDone(1, 3)
|
|
tracker.MarkDone(2, 0)
|
|
tracker.MarkDone(2, 1)
|
|
tracker.MarkDone(2, 2)
|
|
|
|
if got := tracker.TotalCompleted(); got != 6 {
|
|
t.Errorf("TotalCompleted: got %d, want 6", got)
|
|
}
|
|
}
|
|
|
|
func TestProgressTracker_TotalCompleted_Empty(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(2, 3)
|
|
tracker := NewProgressTracker("empty-total", n, dir)
|
|
|
|
if got := tracker.TotalCompleted(); got != 0 {
|
|
t.Errorf("TotalCompleted on fresh tracker: got %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// --- CompletedBytes edge cases ---
|
|
|
|
func TestProgressTracker_CompletedBytes_OutOfBounds(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 3)
|
|
tracker := NewProgressTracker("cb-oob", n, dir)
|
|
|
|
if got := tracker.CompletedBytes(-1, n.Files[0].Segments); got != 0 {
|
|
t.Errorf("CompletedBytes with file -1: got %d, want 0", got)
|
|
}
|
|
if got := tracker.CompletedBytes(5, n.Files[0].Segments); got != 0 {
|
|
t.Errorf("CompletedBytes with file 5: got %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestProgressTracker_CompletedBytes_AllDone(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 3)
|
|
tracker := NewProgressTracker("cb-all", n, dir)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
tracker.MarkDone(0, i)
|
|
}
|
|
|
|
got := tracker.CompletedBytes(0, n.Files[0].Segments)
|
|
expected := int64(3 * 750 * 1024)
|
|
if got != expected {
|
|
t.Errorf("CompletedBytes all done: got %d, want %d", got, expected)
|
|
}
|
|
}
|
|
|
|
// --- CompletedSegments out of bounds ---
|
|
|
|
func TestProgressTracker_CompletedSegments_OutOfBounds(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 3)
|
|
tracker := NewProgressTracker("cs-oob", n, dir)
|
|
|
|
if got := tracker.CompletedSegments(-1); got != 0 {
|
|
t.Errorf("CompletedSegments(-1) = %d, want 0", got)
|
|
}
|
|
if got := tracker.CompletedSegments(99); got != 0 {
|
|
t.Errorf("CompletedSegments(99) = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// --- Load with corrupted / truncated data ---
|
|
|
|
func TestProgressTracker_Load_TruncatedHeader(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 3)
|
|
tracker := NewProgressTracker("trunc", n, dir)
|
|
|
|
// Write too-short data
|
|
os.WriteFile(tracker.progressPath(), []byte("UNR"), 0o644)
|
|
|
|
loaded, err := tracker.Load()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if loaded {
|
|
t.Error("truncated header should not load")
|
|
}
|
|
}
|
|
|
|
func TestProgressTracker_Load_BadMagic(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 3)
|
|
tracker := NewProgressTracker("badmagic", n, dir)
|
|
|
|
// Write data with wrong magic bytes
|
|
data := make([]byte, headerSize+10)
|
|
copy(data[0:4], []byte("BAAD"))
|
|
os.WriteFile(tracker.progressPath(), data, 0o644)
|
|
|
|
loaded, err := tracker.Load()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if loaded {
|
|
t.Error("bad magic should not load")
|
|
}
|
|
}
|
|
|
|
func TestProgressTracker_Load_BadVersion(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 3)
|
|
tracker := NewProgressTracker("badver", n, dir)
|
|
|
|
data := make([]byte, headerSize+10)
|
|
copy(data[0:4], progressMagic[:])
|
|
data[4] = 99 // unsupported version
|
|
os.WriteFile(tracker.progressPath(), data, 0o644)
|
|
|
|
loaded, err := tracker.Load()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if loaded {
|
|
t.Error("bad version should not load")
|
|
}
|
|
}
|
|
|
|
func TestProgressTracker_Load_WrongFileCount(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(2, 3)
|
|
tracker := NewProgressTracker("wrongfc", n, dir)
|
|
|
|
data := make([]byte, headerSize+20)
|
|
copy(data[0:4], progressMagic[:])
|
|
data[4] = progressVersion
|
|
binary.LittleEndian.PutUint16(data[6:8], 99) // wrong file count
|
|
copy(data[8:40], tracker.fingerprint[:])
|
|
os.WriteFile(tracker.progressPath(), data, 0o644)
|
|
|
|
loaded, err := tracker.Load()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if loaded {
|
|
t.Error("wrong file count should not load")
|
|
}
|
|
}
|
|
|
|
func TestProgressTracker_Load_TruncatedBitset(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 16)
|
|
tracker := NewProgressTracker("truncbit", n, dir)
|
|
|
|
// Build a valid header but truncate the bitset data
|
|
data := make([]byte, headerSize+4) // header + segCount but no bitset
|
|
copy(data[0:4], progressMagic[:])
|
|
data[4] = progressVersion
|
|
binary.LittleEndian.PutUint16(data[6:8], 1) // 1 file
|
|
copy(data[8:40], tracker.fingerprint[:])
|
|
binary.LittleEndian.PutUint32(data[headerSize:headerSize+4], 16) // 16 segs
|
|
// No bitset data follows — truncated
|
|
os.WriteFile(tracker.progressPath(), data, 0o644)
|
|
|
|
loaded, err := tracker.Load()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if loaded {
|
|
t.Error("truncated bitset should not load")
|
|
}
|
|
}
|
|
|
|
func TestProgressTracker_Load_SegCountMismatch(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 5)
|
|
tracker := NewProgressTracker("segmis", n, dir)
|
|
|
|
// Build valid header with correct file count and fingerprint, but wrong segCount
|
|
bitsetLen := (999 + 7) / 8
|
|
data := make([]byte, headerSize+4+bitsetLen)
|
|
copy(data[0:4], progressMagic[:])
|
|
data[4] = progressVersion
|
|
binary.LittleEndian.PutUint16(data[6:8], 1)
|
|
copy(data[8:40], tracker.fingerprint[:])
|
|
binary.LittleEndian.PutUint32(data[headerSize:headerSize+4], 999) // wrong seg count
|
|
os.WriteFile(tracker.progressPath(), data, 0o644)
|
|
|
|
loaded, err := tracker.Load()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if loaded {
|
|
t.Error("segment count mismatch should not load")
|
|
}
|
|
}
|
|
|
|
func TestProgressTracker_Load_NonexistentFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 3)
|
|
tracker := NewProgressTracker("nofile", n, dir)
|
|
|
|
loaded, err := tracker.Load()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for missing file: %v", err)
|
|
}
|
|
if loaded {
|
|
t.Error("nonexistent file should return false")
|
|
}
|
|
}
|
|
|
|
// --- Flush and Load round-trip with multiple files ---
|
|
|
|
func TestProgressTracker_MultiFileRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(5, 10) // 5 files, 10 segments each
|
|
tracker := NewProgressTracker("multi-rt", n, dir)
|
|
|
|
// Mark various segments across files
|
|
tracker.MarkDone(0, 0)
|
|
tracker.MarkDone(0, 9)
|
|
tracker.MarkDone(1, 5)
|
|
tracker.MarkDone(2, 0)
|
|
tracker.MarkDone(2, 1)
|
|
tracker.MarkDone(2, 2)
|
|
tracker.MarkDone(2, 3)
|
|
tracker.MarkDone(2, 4)
|
|
tracker.MarkDone(2, 5)
|
|
tracker.MarkDone(2, 6)
|
|
tracker.MarkDone(2, 7)
|
|
tracker.MarkDone(2, 8)
|
|
tracker.MarkDone(2, 9) // file 2 fully done
|
|
tracker.MarkDone(4, 7)
|
|
|
|
if err := tracker.Flush(); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
|
|
// Reload
|
|
tracker2 := NewProgressTracker("multi-rt", n, dir)
|
|
loaded, err := tracker2.Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if !loaded {
|
|
t.Fatal("should load")
|
|
}
|
|
|
|
if tracker2.CompletedSegments(0) != 2 {
|
|
t.Errorf("file 0: got %d, want 2", tracker2.CompletedSegments(0))
|
|
}
|
|
if tracker2.CompletedSegments(1) != 1 {
|
|
t.Errorf("file 1: got %d, want 1", tracker2.CompletedSegments(1))
|
|
}
|
|
if !tracker2.IsFileDone(2) {
|
|
t.Error("file 2 should be done")
|
|
}
|
|
if tracker2.CompletedSegments(3) != 0 {
|
|
t.Errorf("file 3: got %d, want 0", tracker2.CompletedSegments(3))
|
|
}
|
|
if tracker2.CompletedSegments(4) != 1 {
|
|
t.Errorf("file 4: got %d, want 1", tracker2.CompletedSegments(4))
|
|
}
|
|
if tracker2.TotalCompleted() != 14 {
|
|
t.Errorf("TotalCompleted: got %d, want 14", tracker2.TotalCompleted())
|
|
}
|
|
}
|
|
|
|
// --- Concurrent mark + IsDone reads ---
|
|
|
|
func TestProgressTracker_ConcurrentMarkAndRead(t *testing.T) {
|
|
dir := t.TempDir()
|
|
segCount := 500
|
|
n := makeTestNZB(2, segCount)
|
|
tracker := NewProgressTracker("conc-rw", n, dir)
|
|
|
|
// Use separate WaitGroups for writers and readers
|
|
var writerWg sync.WaitGroup
|
|
stop := make(chan struct{})
|
|
|
|
// Writers
|
|
for file := 0; file < 2; file++ {
|
|
for seg := 0; seg < segCount; seg++ {
|
|
writerWg.Add(1)
|
|
go func(f, s int) {
|
|
defer writerWg.Done()
|
|
tracker.MarkDone(f, s)
|
|
}(file, seg)
|
|
}
|
|
}
|
|
|
|
// Readers — continuously read while writes happen
|
|
var readerWg sync.WaitGroup
|
|
for i := 0; i < 4; i++ {
|
|
readerWg.Add(1)
|
|
go func() {
|
|
defer readerWg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
// These should never panic
|
|
tracker.IsDone(0, 0)
|
|
tracker.IsDone(1, segCount-1)
|
|
tracker.IsFileDone(0)
|
|
tracker.CompletedSegments(1)
|
|
tracker.TotalCompleted()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Wait for all writers to finish, then stop readers
|
|
writerWg.Wait()
|
|
close(stop)
|
|
readerWg.Wait()
|
|
|
|
// After all goroutines complete, everything should be done
|
|
if !tracker.IsFileDone(0) {
|
|
t.Errorf("file 0 should be done, got %d/%d", tracker.CompletedSegments(0), segCount)
|
|
}
|
|
if !tracker.IsFileDone(1) {
|
|
t.Errorf("file 1 should be done, got %d/%d", tracker.CompletedSegments(1), segCount)
|
|
}
|
|
}
|
|
|
|
// --- Concurrent flush safety ---
|
|
|
|
func TestProgressTracker_ConcurrentFlush(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 100)
|
|
tracker := NewProgressTracker("conc-flush", n, dir)
|
|
|
|
// Mark some segments
|
|
for i := 0; i < 50; i++ {
|
|
tracker.MarkDone(0, i)
|
|
}
|
|
|
|
// Multiple concurrent flushes should not panic or corrupt
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
tracker.Flush()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// Verify state is loadable
|
|
tracker2 := NewProgressTracker("conc-flush", n, dir)
|
|
loaded, err := tracker2.Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if !loaded {
|
|
t.Fatal("should load after concurrent flushes")
|
|
}
|
|
if tracker2.CompletedSegments(0) != 50 {
|
|
t.Errorf("after concurrent flush: got %d, want 50", tracker2.CompletedSegments(0))
|
|
}
|
|
}
|
|
|
|
// --- Remove with .tmp file ---
|
|
|
|
func TestProgressTracker_Remove_WithTmpFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 3)
|
|
tracker := NewProgressTracker("rm-tmp", n, dir)
|
|
|
|
// Create all three files that Remove should clean up
|
|
os.WriteFile(tracker.progressPath(), []byte("data"), 0o644)
|
|
os.WriteFile(tracker.nzbPath(), []byte("<nzb/>"), 0o644)
|
|
os.WriteFile(tracker.progressPath()+".tmp", []byte("tmp"), 0o644)
|
|
|
|
tracker.Remove()
|
|
|
|
for _, p := range []string{tracker.progressPath(), tracker.nzbPath(), tracker.progressPath() + ".tmp"} {
|
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
|
t.Errorf("file should be removed: %s", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- CleanStaleFiles edge cases ---
|
|
|
|
func TestCleanStaleFiles_EmptyDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if got := CleanStaleFiles(dir, time.Hour); got != 0 {
|
|
t.Errorf("empty dir: got %d removed, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestCleanStaleFiles_NonexistentDir(t *testing.T) {
|
|
if got := CleanStaleFiles("/nonexistent/path/that/does/not/exist", time.Hour); got != 0 {
|
|
t.Errorf("nonexistent dir: got %d removed, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestCleanStaleFiles_AllFresh(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "a.progress"), []byte("a"), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "b.progress"), []byte("b"), 0o644)
|
|
|
|
if got := CleanStaleFiles(dir, 24*time.Hour); got != 0 {
|
|
t.Errorf("all fresh: got %d removed, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestCleanStaleFiles_SkipsSubdirs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
subDir := filepath.Join(dir, "subdir")
|
|
os.MkdirAll(subDir, 0o755)
|
|
|
|
// Backdate the subdir (it should not be removed)
|
|
os.Chtimes(subDir, fixedPast, fixedPast)
|
|
|
|
if got := CleanStaleFiles(dir, 24*time.Hour); got != 0 {
|
|
t.Errorf("should skip subdirs: got %d removed, want 0", got)
|
|
}
|
|
if _, err := os.Stat(subDir); err != nil {
|
|
t.Error("subdir should still exist")
|
|
}
|
|
}
|
|
|
|
func TestCleanStaleFiles_MixedAges(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
stale1 := filepath.Join(dir, "old1.progress")
|
|
stale2 := filepath.Join(dir, "old2.nzb")
|
|
fresh := filepath.Join(dir, "new.progress")
|
|
|
|
os.WriteFile(stale1, []byte("x"), 0o644)
|
|
os.WriteFile(stale2, []byte("x"), 0o644)
|
|
os.WriteFile(fresh, []byte("x"), 0o644)
|
|
|
|
os.Chtimes(stale1, fixedPast, fixedPast)
|
|
os.Chtimes(stale2, fixedPast, fixedPast)
|
|
|
|
if got := CleanStaleFiles(dir, 7*24*time.Hour); got != 2 {
|
|
t.Errorf("mixed ages: got %d removed, want 2", got)
|
|
}
|
|
if _, err := os.Stat(fresh); err != nil {
|
|
t.Error("fresh file should still exist")
|
|
}
|
|
}
|
|
|
|
// --- progressPath / nzbPath ---
|
|
|
|
func TestProgressTracker_Paths(t *testing.T) {
|
|
dir := "/some/dir"
|
|
n := makeTestNZB(1, 1)
|
|
tracker := NewProgressTracker("my-task", n, dir)
|
|
|
|
if got := tracker.progressPath(); got != filepath.Join(dir, "my-task.progress") {
|
|
t.Errorf("progressPath: got %q", got)
|
|
}
|
|
if got := tracker.nzbPath(); got != filepath.Join(dir, "my-task.nzb") {
|
|
t.Errorf("nzbPath: got %q", got)
|
|
}
|
|
}
|
|
|
|
// --- formatBytes ---
|
|
|
|
func TestFormatBytes(t *testing.T) {
|
|
tests := []struct {
|
|
input int64
|
|
want string
|
|
}{
|
|
{0, "0 B"},
|
|
{500, "500 B"},
|
|
{1023, "1023 B"},
|
|
{1024, "1.0 KB"},
|
|
{1536, "1.5 KB"},
|
|
{1048576, "1.0 MB"},
|
|
{1073741824, "1.0 GB"},
|
|
{1099511627776, "1.0 TB"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := formatBytes(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("formatBytes(%d) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Single file with 1 segment (boundary) ---
|
|
|
|
func TestProgressTracker_SingleSegment(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 1)
|
|
tracker := NewProgressTracker("single-seg", n, dir)
|
|
|
|
if tracker.IsFileDone(0) {
|
|
t.Error("should not be done initially")
|
|
}
|
|
|
|
tracker.MarkDone(0, 0)
|
|
|
|
if !tracker.IsFileDone(0) {
|
|
t.Error("should be done after marking the only segment")
|
|
}
|
|
|
|
if err := tracker.Flush(); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
|
|
tracker2 := NewProgressTracker("single-seg", n, dir)
|
|
loaded, _ := tracker2.Load()
|
|
if !loaded {
|
|
t.Fatal("should load")
|
|
}
|
|
if !tracker2.IsFileDone(0) {
|
|
t.Error("should be done after reload")
|
|
}
|
|
}
|
|
|
|
// --- Flush creates directory if missing ---
|
|
|
|
func TestProgressTracker_FlushCreatesDir(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir := filepath.Join(base, "nested", "resume")
|
|
n := makeTestNZB(1, 2)
|
|
tracker := NewProgressTracker("mkdir-test", n, dir)
|
|
|
|
tracker.MarkDone(0, 0)
|
|
if err := tracker.Flush(); err != nil {
|
|
t.Fatalf("flush should create dir: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(tracker.progressPath()); err != nil {
|
|
t.Fatalf("progress file should exist: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Double flush after no new marks ---
|
|
|
|
func TestProgressTracker_DoubleFlush(t *testing.T) {
|
|
dir := t.TempDir()
|
|
n := makeTestNZB(1, 3)
|
|
tracker := NewProgressTracker("dbl-flush", n, dir)
|
|
|
|
tracker.MarkDone(0, 0)
|
|
if err := tracker.Flush(); err != nil {
|
|
t.Fatalf("first flush: %v", err)
|
|
}
|
|
|
|
// Second flush without new marks should be a no-op (dirty=false)
|
|
if err := tracker.Flush(); err != nil {
|
|
t.Fatalf("second flush: %v", err)
|
|
}
|
|
}
|