Files
2026-06-13 01:34:45 +02:00

211 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package qslcard
import (
"image"
"image/color"
"testing"
)
// gridFrom builds a quiet() callback from a 20×32 rune mask ('.' = quiet).
func gridFrom(rows []string) func(r, c int) bool {
return func(r, c int) bool { return rows[r][c] == '.' }
}
func maskRows(quietRect cellRect) []string {
rows := make([]string, gridRows)
for r := 0; r < gridRows; r++ {
row := make([]byte, gridCols)
for c := 0; c < gridCols; c++ {
if r >= quietRect.r0 && r < quietRect.r1 && c >= quietRect.c0 && c < quietRect.c1 {
row[c] = '.'
} else {
row[c] = '#'
}
}
rows[r] = string(row)
}
return rows
}
func TestMaximalQuietRect(t *testing.T) {
minCols := 18 // ceil(0.55 × 32), same as callsignZone
minRows := 5 // ceil(0.22 × 20)
tests := []struct {
name string
quiet cellRect // the quiet region planted in the mask
want cellRect
found bool
}{
{"whole grid quiet", cellRect{0, 0, gridRows, gridCols}, cellRect{0, 0, gridRows, gridCols}, true},
{"top band", cellRect{0, 0, 6, gridCols}, cellRect{0, 0, 6, gridCols}, true},
{"bottom band", cellRect{14, 4, 20, 26}, cellRect{14, 4, 20, 26}, true},
{"too narrow", cellRect{0, 0, 20, 10}, cellRect{}, false},
{"too short", cellRect{0, 0, 3, gridCols}, cellRect{}, false},
{"nothing quiet", cellRect{0, 0, 0, 0}, cellRect{}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := maximalQuietRect(gridFrom(maskRows(tc.quiet)), minCols, minRows)
if ok != tc.found {
t.Fatalf("found=%v, want %v (rect %+v)", ok, tc.found, got)
}
if ok && got != tc.want {
t.Fatalf("rect %+v, want %+v", got, tc.want)
}
})
}
}
func TestMaximalQuietRectPrefersEdges(t *testing.T) {
// Two equal candidates: a middle band and a top band. The top band must
// win via the position bonus.
rows := make([]string, gridRows)
for r := range rows {
switch {
case r < 5, r >= 8 && r < 13:
rows[r] = "................................"
default:
rows[r] = "################################"
}
}
got, ok := maximalQuietRect(gridFrom(rows), 18, 5)
if !ok {
t.Fatal("no rect found")
}
if got.r0 != 0 {
t.Fatalf("expected the top band to win, got %+v", got)
}
}
// syntheticPhoto paints a flat bright sky over a noisy dark ground.
// skyShare is the fraction of the height that is sky.
func syntheticPhoto(w, h int, skyShare float64) image.Image {
img := image.NewRGBA(image.Rect(0, 0, w, h))
skyH := int(float64(h) * skyShare)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if y < skyH {
img.Set(x, y, color.RGBA{R: 170, G: 200, B: 235, A: 255}) // flat sky
} else if (x+y)%2 == 0 { // per-pixel checkerboard = max detail
img.Set(x, y, color.RGBA{R: 20, G: 60, B: 20, A: 255})
} else {
img.Set(x, y, color.RGBA{R: 120, G: 160, B: 90, A: 255})
}
}
}
return img
}
func TestAnalyzeImage(t *testing.T) {
p := AnalyzeImage(syntheticPhoto(800, 514, 0.5), "sky.jpg")
if p.W != 800 || p.H != 514 {
t.Fatalf("size %dx%d, want 800x514", p.W, p.H)
}
// Sky cells (top rows) must read quiet and bright; ground cells busy.
sky := p.Cells[1][16]
ground := p.Cells[gridRows-2][16]
if sky.Detail >= 0.15 {
t.Fatalf("sky detail %.3f, want < 0.15", sky.Detail)
}
if sky.Luma <= 0.55 {
t.Fatalf("sky luma %.3f, want > 0.55", sky.Luma)
}
if ground.Detail <= 0.3 {
t.Fatalf("ground detail %.3f, want > 0.3", ground.Detail)
}
if p.Quietness <= 0.3 || p.Quietness >= 0.8 {
t.Fatalf("quietness %.3f, want roughly half", p.Quietness)
}
}
func TestProposeCallsignInFlatSky(t *testing.T) {
hero := AnalyzeImage(syntheticPhoto(1600, 1029, 0.45), "hero.jpg")
out, err := HeuristicEngine{}.Propose([]PhotoAnalysis{hero},
ProfileInfo{Callsign: "XV9Q", Operator: "Greg", Grid: "OK30"})
if err != nil {
t.Fatalf("propose: %v", err)
}
if len(out) != 3 {
t.Fatalf("got %d proposals, want 3", len(out))
}
for i, tmpl := range out {
call := findElement(t, tmpl, ElemCallsign)
// The flat sky is the top 45%: the callsign must land there.
if call.Y+call.Size > float64(tmpl.Card.H)*0.5 {
t.Errorf("proposal %d: callsign at y=%g size=%g not in the sky band", i, call.Y, call.Size)
}
// Bright placement zone → dark outline variant (gel presets only; the
// classic-white-outline preset has no halo and keeps its white edge).
if call.StyleParams != nil && call.StyleParams.Halo != nil &&
call.StyleParams.OutlineColor != "#2a3f5c" {
t.Errorf("proposal %d: outline %q, want dark #2a3f5c on bright zone", i, call.StyleParams.OutlineColor)
}
}
}
func TestProposeThreeDistinctWithInserts(t *testing.T) {
hero := AnalyzeImage(syntheticPhoto(1600, 1029, 0.5), "hero.jpg")
in1 := AnalyzeImage(syntheticPhoto(400, 300, 0.2), "in1.jpg")
in2 := AnalyzeImage(syntheticPhoto(400, 300, 0.1), "in2.jpg")
out, err := HeuristicEngine{}.Propose([]PhotoAnalysis{hero, in1, in2},
ProfileInfo{Callsign: "F4XYZ", Operator: "Greg", Grid: "IN88", CQZone: 14, ITUZone: 27})
if err != nil {
t.Fatalf("propose: %v", err)
}
if len(out) != 3 {
t.Fatalf("got %d proposals, want 3", len(out))
}
// Hero must be the big photo, inserts the small ones.
for i, tmpl := range out {
if tmpl.Hero.Photo != "hero.jpg" {
t.Errorf("proposal %d: hero is %q", i, tmpl.Hero.Photo)
}
if err := Validate(tmpl, nil); err != nil {
t.Errorf("proposal %d invalid: %v", i, err)
}
}
// The three proposals must showcase distinct call looks: each style is a
// known preset, and no two proposals share the same font+style pairing.
seenStyle := map[string]bool{}
seenFont := map[string]bool{}
for i, tmpl := range out {
call := findElement(t, tmpl, ElemCallsign)
if _, ok := Presets[call.StylePreset]; !ok {
t.Errorf("proposal %d: unknown call style %q", i, call.StylePreset)
}
if seenStyle[call.StylePreset] {
t.Errorf("proposal %d reuses call style %q", i, call.StylePreset)
}
if seenFont[call.Font] {
t.Errorf("proposal %d reuses call font %q", i, call.Font)
}
seenStyle[call.StylePreset] = true
seenFont[call.Font] = true
}
// At least one proposal uses the insert photos.
if countInserts(out[0]) == 0 && countInserts(out[1]) == 0 && countInserts(out[2]) == 0 {
t.Error("no proposal uses the insert photos")
}
}
func findElement(t *testing.T, tmpl Template, typ string) Element {
t.Helper()
for _, e := range tmpl.Elements {
if e.Type == typ {
return e
}
}
t.Fatalf("no %s element in template %q", typ, tmpl.Name)
return Element{}
}
func countInserts(tmpl Template) int {
n := 0
for _, e := range tmpl.Elements {
if e.Type == ElemInsert {
n++
}
}
return n
}