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 }