package analysis import ( "bytes" "context" "image" "image/color" "image/png" "testing" "time" "go-places-api/internal/domain" ) // pngOf encodes an RGBA image as PNG bytes. func pngOf(t *testing.T, img image.Image) []byte { t.Helper() var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatalf("encode png: %v", err) } return buf.Bytes() } // solid returns a uniformly coloured picture. func solid(w, h int, c color.RGBA) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, w, h)) for y := 0; y < h; y++ { for x := 0; x < w; x++ { img.SetRGBA(x, y, c) } } return img } // banded returns a picture whose horizontal thirds are the given colours, which // is how the tests construct skies, ground and horizons. func banded(top, middle, bottom color.RGBA) *image.RGBA { const w, h = 60, 60 img := image.NewRGBA(image.Rect(0, 0, w, h)) for y := 0; y < h; y++ { c := top switch { case y >= h*2/3: c = bottom case y >= h/3: c = middle } for x := 0; x < w; x++ { img.SetRGBA(x, y, c) } } return img } func analyzePNG(t *testing.T, img image.Image) *Result { t.Helper() a := NewLocal(DefaultMaxDimension) res, err := a.Analyze(context.Background(), Input{ ContentType: "image/png", Bytes: pngOf(t, img), }) if err != nil { t.Fatalf("Analyze: %v", err) } return res } // labels collects the badge labels from a result. func labels(res *Result) []string { out := make([]string, 0, len(res.Badges)) for _, b := range res.Badges { out = append(out, b.Label) } return out } func hasBadge(res *Result, label string) bool { for _, b := range res.Badges { if b.Label == label { return true } } return false } func confidenceOf(res *Result, label string) (float64, bool) { for _, b := range res.Badges { if b.Label == label { return b.Confidence, true } } return 0, false } var ( skyBlue = color.RGBA{R: 60, G: 130, B: 220, A: 255} grassGreen = color.RGBA{R: 60, G: 160, B: 70, A: 255} sandColor = color.RGBA{R: 220, G: 195, B: 140, A: 255} sunsetRed = color.RGBA{R: 230, G: 110, B: 60, A: 255} midGrey = color.RGBA{R: 128, G: 128, B: 128, A: 255} darkGrey = color.RGBA{R: 30, G: 30, B: 30, A: 255} white = color.RGBA{R: 250, G: 250, B: 250, A: 255} ) func TestAnalyzeReportsTrueDimensions(t *testing.T) { t.Parallel() // A small picture is sampled at its own size; a large one is downscaled, // but the reported dimensions must remain the encoded ones. for _, dims := range [][2]int{{40, 30}, {800, 600}} { w, h := dims[0], dims[1] res := analyzePNG(t, solid(w, h, skyBlue)) if res.Width != w || res.Height != h { t.Errorf("dimensions = %dx%d, want %dx%d", res.Width, res.Height, w, h) } } } func TestAnalyzeDetectsSky(t *testing.T) { t.Parallel() // Blue upper band over green ground: the classic outdoor landscape. res := analyzePNG(t, banded(skyBlue, grassGreen, grassGreen)) if !hasBadge(res, labelSky) { t.Errorf("expected a sky badge, got %v", labels(res)) } if !hasBadge(res, labelVegetation) { t.Errorf("expected a vegetation badge, got %v", labels(res)) } conf, ok := confidenceOf(res, labelSky) if !ok { t.Fatal("sky badge has no confidence") } if conf <= 0 || conf > 1 { t.Errorf("sky confidence = %v, want a value in (0,1]", conf) } } func TestAnalyzeDetectsBeach(t *testing.T) { t.Parallel() // Sky above warm sand below. res := analyzePNG(t, banded(skyBlue, sandColor, sandColor)) if !hasBadge(res, labelBeach) { t.Errorf("expected a beach badge, got %v", labels(res)) } } func TestAnalyzeDetectsWater(t *testing.T) { t.Parallel() // Blue concentrated in the lower band is water rather than sky. res := analyzePNG(t, banded(midGrey, skyBlue, skyBlue)) if !hasBadge(res, labelWater) { t.Errorf("expected a water badge, got %v", labels(res)) } if hasBadge(res, labelSky) { t.Errorf("did not expect a sky badge when the blue is in the lower band, got %v", labels(res)) } } func TestAnalyzeDetectsMonochrome(t *testing.T) { t.Parallel() res := analyzePNG(t, solid(50, 50, midGrey)) if !hasBadge(res, labelMonochrome) { t.Errorf("expected a monochrome badge, got %v", labels(res)) } if hasBadge(res, labelVivid) { t.Errorf("did not expect a vivid badge from a grey picture, got %v", labels(res)) } } func TestAnalyzeDetectsLowLightAndNight(t *testing.T) { t.Parallel() dark := analyzePNG(t, solid(50, 50, darkGrey)) if !hasBadge(dark, labelLowLight) { t.Errorf("expected a low-light badge, got %v", labels(dark)) } if !hasBadge(dark, labelNight) { t.Errorf("expected a night badge from brightness alone, got %v", labels(dark)) } } func TestAnalyzeDetectsOverexposed(t *testing.T) { t.Parallel() bright := analyzePNG(t, solid(50, 50, white)) if !hasBadge(bright, labelOverexposed) { t.Errorf("expected an overexposed badge, got %v", labels(bright)) } } func TestAnalyzeDetectsOrientation(t *testing.T) { t.Parallel() cases := []struct { name string w, h int want, notWant string }{ // Ratios sit comfortably inside each band; a picture whose ratio lands // exactly on a band edge scores zero confidence, which is by design. {"tall", 45, 60, labelPortrait, labelLandscape}, {"wide", 60, 45, labelLandscape, labelPortrait}, {"square", 40, 40, "", labelPanorama}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() res := analyzePNG(t, solid(tc.w, tc.h, midGrey)) if tc.want != "" && !hasBadge(res, tc.want) { t.Errorf("expected %q badge, got %v", tc.want, labels(res)) } if tc.notWant != "" && hasBadge(res, tc.notWant) { t.Errorf("did not expect %q badge, got %v", tc.notWant, labels(res)) } }) } } func TestAnalyzeDetectsPanorama(t *testing.T) { t.Parallel() res := analyzePNG(t, solid(120, 40, midGrey)) if !hasBadge(res, labelPanorama) { t.Errorf("expected a panorama badge for a 3:1 picture, got %v", labels(res)) } } func TestAnalyzeIsDeterministic(t *testing.T) { t.Parallel() img := banded(skyBlue, grassGreen, sandColor) first := labels(analyzePNG(t, img)) for i := 0; i < 3; i++ { got := labels(analyzePNG(t, img)) if len(got) != len(first) { t.Fatalf("run %d produced %v, want %v", i, got, first) } for j := range got { if got[j] != first[j] { t.Errorf("run %d produced %v, want %v", i, got, first) break } } } } func TestAnalyzeRejectsBadInput(t *testing.T) { t.Parallel() a := NewLocal(DefaultMaxDimension) if _, err := a.Analyze(context.Background(), Input{Bytes: nil}); err == nil { t.Error("expected an error for an empty payload") } if _, err := a.Analyze(context.Background(), Input{Bytes: []byte("not an image")}); err == nil { t.Error("expected an error for a payload that is not an image") } } func TestAnalyzeRespectsContextCancellation(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) cancel() a := NewLocal(DefaultMaxDimension) _, err := a.Analyze(ctx, Input{ContentType: "image/png", Bytes: pngOf(t, solid(20, 20, midGrey))}) if err == nil { t.Error("expected a cancelled context to fail the analysis") } } // TestCombinePrefersModelLabels checks the merge rule: a model label wins the // display name, but a confident heuristic is not thrown away. func TestCombinePrefersModelLabels(t *testing.T) { t.Parallel() local := []domain.Badge{ {Label: labelSky, Display: "Sky", Confidence: 0.9, Source: SourceLocal}, {Label: "beach", Display: "Beach", Confidence: 0.8, Source: SourceLocal}, } model := []domain.Badge{ {Label: "dog", Display: "Dog", Confidence: 0.95, Source: SourceRemote}, // The model also claims sky, less confidently than the heuristic. {Label: labelSky, Display: "Clear sky", Confidence: 0.4, Source: SourceRemote}, } got := Combine(local, model) byLabel := map[string]domain.Badge{} for _, b := range got { byLabel[b.Label] = b } if len(got) != 3 { t.Fatalf("expected 3 distinct badges, got %d: %v", len(got), got) } if _, ok := byLabel["dog"]; !ok { t.Error("the model label was dropped") } sky := byLabel[labelSky] if sky.Source != SourceRemote { t.Errorf("sky source = %q, want %q", sky.Source, SourceRemote) } if sky.Display != "Clear sky" { t.Errorf("sky display = %q, want the model's %q", sky.Display, "Clear sky") } if sky.Confidence != 0.9 { t.Errorf("sky confidence = %v, want the heuristic's higher 0.9", sky.Confidence) } } func TestCombineIsSortedByConfidence(t *testing.T) { t.Parallel() badges := Combine( []domain.Badge{{Label: "a", Confidence: 0.2}, {Label: "b", Confidence: 0.9}}, []domain.Badge{{Label: "c", Confidence: 0.5, Source: SourceRemote}}, ) for i := 1; i < len(badges); i++ { if badges[i-1].Confidence < badges[i].Confidence { t.Errorf("badges are not sorted by confidence: %v", badges) break } } } func TestCombineFillsMissingDisplayName(t *testing.T) { t.Parallel() got := Combine(nil, []domain.Badge{{Label: "low-light", Confidence: 0.7, Source: SourceRemote}}) if len(got) != 1 { t.Fatalf("expected 1 badge, got %d", len(got)) } if got[0].Display != "Low light" { t.Errorf("display = %q, want %q", got[0].Display, "Low light") } } func TestHumanize(t *testing.T) { t.Parallel() cases := map[string]string{ "dog": "Dog", "low-light": "Low light", "close_up": "Close up", "already Done": "Already done", "": "", "sandwich-board": "Sandwich board", } for in, want := range cases { if got := Humanize(in); got != want { t.Errorf("Humanize(%q) = %q, want %q", in, got, want) } } } // TestRuleNightPrefersClockTime confirms the EXIF timestamp takes precedence // over brightness, so a dark frame shot at noon is not called a night scene. func TestRuleNightPrefersClockTime(t *testing.T) { t.Parallel() dark := Signals{Brightness: 0.01, Width: 100, Height: 100} noon := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) if got := ruleNight(dark, Metadata{TakenAt: &noon}); got != 0 { t.Errorf("a dark midday frame scored %v for night, want 0", got) } midnight := time.Date(2026, 6, 1, 23, 30, 0, 0, time.UTC) if got := ruleNight(dark, Metadata{TakenAt: &midnight}); got <= 0 { t.Errorf("a bright frame shot at 23:30 scored %v for night, want > 0", got) } } // TestAllBadgesCarrySourceAndBounds checks the invariants every badge must // satisfy regardless of which rule produced it. func TestAllBadgesCarrySourceAndBounds(t *testing.T) { t.Parallel() res := analyzePNG(t, banded(skyBlue, grassGreen, sandColor)) if len(res.Badges) == 0 { t.Fatal("expected the sample picture to produce badges") } for _, b := range res.Badges { if b.Label == "" { t.Error("a badge has an empty label") } if b.Source != SourceLocal { t.Errorf("badge %q has source %q, want %q", b.Label, b.Source, SourceLocal) } if b.Confidence <= 0 || b.Confidence > 1 { t.Errorf("badge %q confidence = %v, want a value in (0,1]", b.Label, b.Confidence) } if b.Display == "" { t.Errorf("badge %q has no display name", b.Label) } } } // newTestContext returns a background context, kept as a helper so tests that // build payloads read consistently. func newTestContext() context.Context { return context.Background() }