211 lines
6.5 KiB
Go
211 lines
6.5 KiB
Go
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
|
||
}
|