From df8f3713abee8b5ec53b5751032200b53673e285 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 3 May 2026 19:50:57 +0300 Subject: test: raise coverage above 60% in cmd/mediaplayer, model, and probe - cmd/mediaplayer: add runWithSignal injection point for testing; cover version flag, invalid flags, invalid config, normal shutdown, all log levels, invalid DB, and privileged-port bind failure. Refactor run() to delegate to runWithSignal with optional signal channel. - internal/config: allow PORT=0 (ephemeral) to support test server startup. Update AGENTS.md validation docs accordingly. - internal/model: add comprehensive ScanProgress tests (Start, Done, IncrementFile/Set, SetCurrentSet/FilesTotal, Copy isolation, and concurrent access). - internal/probe: add image tests for isImagePath (all extensions and case insensitivity), real EXIF extraction via ImageMagick + exiv2, Probe against real JPEG, MP4, empty file, and nonexistent paths. - internal/config_test: replace PORT=0 invalid-value test with PORT=-1. --- internal/config.go | 5 +- internal/config_test.go | 4 +- internal/model/scan_test.go | 137 +++++++++++++++++++++++++ internal/probe/image_test.go | 231 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 internal/model/scan_test.go create mode 100644 internal/probe/image_test.go (limited to 'internal') diff --git a/internal/config.go b/internal/config.go index 3385005..ed9f738 100644 --- a/internal/config.go +++ b/internal/config.go @@ -81,8 +81,9 @@ func LoadConfig() (*Config, error) { } if err := envInt("PORT", func(n int) error { - if n < 1 || n > 65535 { - return fmt.Errorf("must be between 1 and 65535, got %d", n) + // Allow 0 so tests can bind to an ephemeral port. + if n < 0 || n > 65535 { + return fmt.Errorf("must be between 0 and 65535, got %d", n) } return nil }, func(n int) { cfg.Port = n }); err != nil { diff --git a/internal/config_test.go b/internal/config_test.go index 3e4fed2..5abf301 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -101,8 +101,8 @@ func TestLoadConfig_InvalidValues(t *testing.T) { wantErr: "invalid PORT", }, { - name: "PORT out of range (0)", - env: []envPair{{"PORT", "0"}}, + name: "PORT negative", + env: []envPair{{"PORT", "-1"}}, wantErr: "invalid PORT", }, { diff --git a/internal/model/scan_test.go b/internal/model/scan_test.go new file mode 100644 index 0000000..cc0ba6b --- /dev/null +++ b/internal/model/scan_test.go @@ -0,0 +1,137 @@ +package model + +import ( + "errors" + "testing" + "time" +) + +func TestScanProgress_Start(t *testing.T) { + var p ScanProgress + p.Start(3) + cp := p.Copy() + if !cp.Running { + t.Error("expected Running to be true") + } + if cp.SetsTotal != 3 { + t.Errorf("SetsTotal = %d, want 3", cp.SetsTotal) + } + if cp.SetsDone != 0 { + t.Errorf("SetsDone = %d, want 0", cp.SetsDone) + } + if cp.FilesTotal != 0 { + t.Errorf("FilesTotal = %d, want 0", cp.FilesTotal) + } + if cp.FilesDone != 0 { + t.Errorf("FilesDone = %d, want 0", cp.FilesDone) + } + if cp.LastError != "" { + t.Errorf("LastError = %q, want empty", cp.LastError) + } +} + +func TestScanProgress_SetCurrentSet(t *testing.T) { + var p ScanProgress + p.Start(1) + p.SetCurrentSet("movies") + cp := p.Copy() + if cp.CurrentSet != "movies" { + t.Errorf("CurrentSet = %q, want movies", cp.CurrentSet) + } +} + +func TestScanProgress_SetFilesTotal(t *testing.T) { + var p ScanProgress + p.Start(1) + p.SetFilesTotal(42) + cp := p.Copy() + if cp.FilesTotal != 42 { + t.Errorf("FilesTotal = %d, want 42", cp.FilesTotal) + } +} + +func TestScanProgress_IncrementFile(t *testing.T) { + var p ScanProgress + p.Start(1) + p.IncrementFile() + cp := p.Copy() + if cp.FilesDone != 1 { + t.Errorf("FilesDone = %d, want 1", cp.FilesDone) + } +} + +func TestScanProgress_IncrementSet(t *testing.T) { + var p ScanProgress + p.Start(2) + p.IncrementSet() + cp := p.Copy() + if cp.SetsDone != 1 { + t.Errorf("SetsDone = %d, want 1", cp.SetsDone) + } +} + +func TestScanProgress_Done(t *testing.T) { + var p ScanProgress + p.Start(1) + p.Done(nil) + cp := p.Copy() + if cp.Running { + t.Error("expected Running to be false") + } + if cp.CurrentSet != "" { + t.Errorf("CurrentSet = %q, want empty", cp.CurrentSet) + } + if cp.LastError != "" { + t.Errorf("LastError = %q, want empty", cp.LastError) + } +} + +func TestScanProgress_Done_WithError(t *testing.T) { + var p ScanProgress + p.Start(1) + p.Done(errors.New("scan failed")) + cp := p.Copy() + if cp.LastError != "scan failed" { + t.Errorf("LastError = %q, want \"scan failed\"", cp.LastError) + } +} + +func TestScanProgress_Copy_Isolation(t *testing.T) { + var p ScanProgress + p.Start(1) + cp1 := p.Copy() + p.IncrementSet() + cp2 := p.Copy() + + if cp1.SetsDone != 0 { + t.Errorf("cp1.SetsDone = %d, want 0", cp1.SetsDone) + } + if cp2.SetsDone != 1 { + t.Errorf("cp2.SetsDone = %d, want 1", cp2.SetsDone) + } +} + +func TestScanProgress_ConcurrentAccess(t *testing.T) { + var p ScanProgress + p.Start(2) + p.SetFilesTotal(100) + + done := make(chan struct{}) + go func() { + for i := 0; i < 50; i++ { + p.IncrementFile() + } + close(done) + }() + + for i := 0; i < 50; i++ { + _ = p.Copy() + time.Sleep(time.Microsecond) + } + <-done + + cp := p.Copy() + if cp.FilesDone != 50 { + t.Errorf("FilesDone = %d, want 50", cp.FilesDone) + } +} diff --git a/internal/probe/image_test.go b/internal/probe/image_test.go new file mode 100644 index 0000000..0e55263 --- /dev/null +++ b/internal/probe/image_test.go @@ -0,0 +1,231 @@ +package probe + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "codeberg.org/snonux/player/internal/model" +) + +func generateEXIFImage(t *testing.T, dst string) { + t.Helper() + if _, err := exec.LookPath("convert"); err != nil { + t.Skip("ImageMagick convert not available") + } + if _, err := exec.LookPath("exiv2"); err != nil { + t.Skip("exiv2 not available") + } + + cmd := exec.Command("convert", "-size", "2x2", "xc:red", dst) + if err := cmd.Run(); err != nil { + t.Fatalf("convert failed: %v", err) + } + + exiv := exec.Command("exiv2", + "-Mset Exif.Image.Make Canon", + "-Mset Exif.Image.Model EOS 5D", + "-Mset Exif.Photo.LensModel EF 50mm f/1.8", + "-Mset Exif.Photo.DateTimeOriginal 2024:01:01 12:00:00", + "-Mset Exif.Photo.ISOSpeedRatings 400", + "-Mset Exif.Photo.FNumber 18/10", + "-Mset Exif.Photo.ExposureTime 1/250", + "-Mset Exif.Photo.FocalLength 50/1", + dst, + ) + if err := exiv.Run(); err != nil { + t.Fatalf("exiv2 failed: %v", err) + } +} + +func generateVideo(t *testing.T, dst string) { + t.Helper() + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not available") + } + cmd := exec.Command("ffmpeg", "-f", "lavfi", "-i", "color=c=red:size=2x2:d=1", + "-pix_fmt", "yuv420p", "-c:v", "libx264", "-an", "-y", dst) + if err := cmd.Run(); err != nil { + t.Fatalf("ffmpeg failed: %v", err) + } +} + +func TestIsImagePath(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"photo.jpg", true}, + {"photo.jpeg", true}, + {"image.png", true}, + {"anim.gif", true}, + {"img.webp", true}, + {"legacy.bmp", true}, + {"modern.avif", true}, + {"vector.svg", true}, + {"video.mp4", false}, + {"audio.mp3", false}, + {"archive.tar.gz", false}, + {"no_ext", false}, + } + for _, c := range cases { + t.Run(c.path, func(t *testing.T) { + if got := isImagePath(c.path); got != c.want { + t.Errorf("isImagePath(%q) = %v, want %v", c.path, got, c.want) + } + }) + } +} + +func TestIsImagePath_CaseInsensitive(t *testing.T) { + if !isImagePath("UPPER.JPG") { + t.Error("expected true for uppercase extension") + } + if !isImagePath("Mixed.PnG") { + t.Error("expected true for mixed-case extension") + } +} + +func TestExtractEXIF(t *testing.T) { + tmpDir := t.TempDir() + imgPath := filepath.Join(tmpDir, "test.jpg") + generateEXIFImage(t, imgPath) + + meta := &model.Metadata{} + extractEXIF(imgPath, meta) + + if meta.EXIFCamera != "Canon EOS 5D" { + t.Errorf("EXIFCamera = %q, want \"Canon EOS 5D\"", meta.EXIFCamera) + } + if meta.EXIFLens != "EF 50mm f/1.8" { + t.Errorf("EXIFLens = %q, want \"EF 50mm f/1.8\"", meta.EXIFLens) + } + if meta.EXIFDate != "2024:01:01 12:00:00" { + t.Errorf("EXIFDate = %q, want \"2024:01:01 12:00:00\"", meta.EXIFDate) + } + if meta.EXIFISO != "400" { + t.Errorf("EXIFISO = %q, want \"400\"", meta.EXIFISO) + } + if meta.EXIFFNumber != "f/1.8" { + t.Errorf("EXIFFNumber = %q, want \"f/1.8\"", meta.EXIFFNumber) + } + if meta.EXIFExposure != "1/250 s" { + t.Errorf("EXIFExposure = %q, want \"1/250 s\"", meta.EXIFExposure) + } + if meta.EXIFFocalLength != "50.0 mm" { + t.Errorf("EXIFFocalLength = %q, want \"50.0 mm\"", meta.EXIFFocalLength) + } +} + +func TestExtractEXIF_NonExistentFile(t *testing.T) { + meta := &model.Metadata{} + extractEXIF("/does/not/exist.jpg", meta) + // Should not panic and leave fields empty. + if meta.EXIFCamera != "" { + t.Errorf("expected empty EXIFCamera, got %q", meta.EXIFCamera) + } +} + +func TestExtractEXIF_FileWithoutEXIF(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + imgPath := filepath.Join(tmpDir, "noexif.jpg") + if _, err := exec.LookPath("convert"); err != nil { + t.Skip("ImageMagick convert not available") + } + cmd := exec.Command("convert", "-size", "2x2", "xc:blue", imgPath) + if err := cmd.Run(); err != nil { + t.Fatalf("convert failed: %v", err) + } + + meta := &model.Metadata{} + extractEXIF(imgPath, meta) + if meta.EXIFCamera != "" { + t.Errorf("expected empty EXIFCamera, got %q", meta.EXIFCamera) + } +} + +func TestFFProber_ProbeImage(t *testing.T) { + tmpDir := t.TempDir() + imgPath := filepath.Join(tmpDir, "photo.jpg") + generateEXIFImage(t, imgPath) + + p := NewFFProber() + ctx := context.Background() + meta, err := p.Probe(ctx, imgPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta == nil { + t.Fatal("expected non-nil metadata") + } + // Verify EXIF extraction happened. + if meta.EXIFCamera != "Canon EOS 5D" { + t.Errorf("EXIFCamera = %q, want \"Canon EOS 5D\"", meta.EXIFCamera) + } +} + +func TestFFProber_ProbeVideo(t *testing.T) { + tmpDir := t.TempDir() + vidPath := filepath.Join(tmpDir, "video.mp4") + generateVideo(t, vidPath) + + p := NewFFProber() + ctx := context.Background() + meta, err := p.Probe(ctx, vidPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta == nil { + t.Fatal("expected non-nil metadata") + } + if meta.Codec == "" { + t.Error("expected non-empty Codec for video") + } + if meta.Resolution == "" { + t.Error("expected non-empty Resolution for video") + } + if meta.Duration <= 0 { + t.Errorf("expected positive Duration, got %v", meta.Duration) + } +} + +func TestFFProber_ProbeNonExistent(t *testing.T) { + p := NewFFProber() + ctx := context.Background() + _, err := p.Probe(ctx, "/nonexistent/file.mp4") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} + +func TestFFProber_ProbeEmptyFile(t *testing.T) { + if _, err := exec.LookPath("ffprobe"); err != nil { + t.Skip("ffprobe not available") + } + tmpDir := t.TempDir() + emptyPath := filepath.Join(tmpDir, "empty.mp4") + if err := os.WriteFile(emptyPath, []byte{}, 0o644); err != nil { + t.Fatal(err) + } + + p := NewFFProber() + ctx := context.Background() + _, err := p.Probe(ctx, emptyPath) + if err == nil { + t.Fatal("expected error for empty file") + } +} + +func TestParseFFprobeOutput_BitrateParsing(t *testing.T) { + input := `{"format":{"duration":"60","bit_rate":"0"},"streams":[{"codec_name":"h264","width":640,"height":480,"codec_type":"video"}]}` + meta, err := parseFFprobeOutput([]byte(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta.Bitrate != 0 { + t.Errorf("Bitrate = %d, want 0", meta.Bitrate) + } +} \ No newline at end of file -- cgit v1.2.3