initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

View 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() }