qsl designer
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user