qsl designer

This commit is contained in:
2026-06-11 21:54:35 +02:00
parent 6150498a9e
commit 408b29896c
252 changed files with 13989 additions and 277 deletions
+206
View File
@@ -0,0 +1,206 @@
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 use three distinct call styles from the palette
// (the user asked for variety in both style and position).
allowed := map[string]bool{"gel_gold": true, "gel_silver": true, "classic_white_outline": true}
seen := map[string]bool{}
for i, tmpl := range out {
s := findElement(t, tmpl, ElemCallsign).StylePreset
if !allowed[s] {
t.Errorf("proposal %d: unexpected call style %q", i, s)
}
if seen[s] {
t.Errorf("proposal %d reuses call style %q", i, s)
}
seen[s] = 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
}