initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
410
api/internal/analysis/analysis_test.go
Normal file
410
api/internal/analysis/analysis_test.go
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
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() }
|
||||
Loading…
Add table
Add a link
Reference in a new issue