410 lines
11 KiB
Go
410 lines
11 KiB
Go
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() }
|