- commit
- 6f8e0d129705da5abe5b8d81b86975f0a580359b
- parent
- fd8090b7115726eb8333f22161cca1253b68611c
- author
- Dami <dami.etoile@gmail.com>
| A | lightsout/ | 57 | +++++ |
| M | lightsout/ | 57 | +++++ |
| A | wordsearch/ | 139 | +++++++++++++ |
| M | wordsearch/ | 313 | ++++++++++++++++++++++++++---- |
| M | wordsearch/ | 277 | +++++++++++++++++++++++++++ |
@@ -0,0 +1,57 @@
1+package lightsout2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func BenchmarkGenerateByMode(b *testing.B) {9+ cases := []struct {10+ name string11+ w int12+ h int13+ }{14+ {name: "easy_3x3", w: 3, h: 3},15+ {name: "medium_5x5", w: 5, h: 5},16+ {name: "hard_7x7", w: 7, h: 7},17+ {name: "extreme_9x9", w: 9, h: 9},18+ }19+20+ for _, tt := range cases {21+ b.Run(tt.name, func(b *testing.B) {22+ rng := rand.New(rand.NewPCG(11, 12))23+ b.ReportAllocs()24+25+ for range b.N {26+ _ = GenerateSeeded(tt.w, tt.h, rng)27+ }28+ })29+ }30+}31+32+func BenchmarkToggle(b *testing.B) {33+ cases := []struct {34+ name string35+ w int36+ h int37+ x int38+ y int39+ }{40+ {name: "easy_center", w: 3, h: 3, x: 1, y: 1},41+ {name: "medium_center", w: 5, h: 5, x: 2, y: 2},42+ {name: "hard_center", w: 7, h: 7, x: 3, y: 3},43+ {name: "extreme_center", w: 9, h: 9, x: 4, y: 4},44+ {name: "extreme_corner", w: 9, h: 9, x: 0, y: 0},45+ }46+47+ for _, tt := range cases {48+ b.Run(tt.name, func(b *testing.B) {49+ g := makeGrid(tt.h, tt.w, false)50+ b.ReportAllocs()51+52+ for range b.N {53+ Toggle(g, tt.x, tt.y)54+ }55+ })56+ }57+}@@ -2,6 +2,7 @@ package lightsout
22 33 import (44 "encoding/json"5+ "math/rand/v2"56 "testing"67 78 "github.com/FelineStateMachine/puzzletea/game"@@ -43,6 +44,18 @@ func makeGrid(h, w int, val bool) [][]bool {
4344 return g4445 }4546 47+func countLightsOn(g [][]bool) int {48+ total := 049+ for y := range g {50+ for x := range g[y] {51+ if g[y][x] {52+ total++53+ }54+ }55+ }56+ return total57+}58+4659 // --- Toggle (P0) ---4760 4861 func TestToggle_Center(t *testing.T) {@@ -232,6 +245,50 @@ func TestGenerate_HasLightsOn(t *testing.T) {
232245 }233246 }234247 248+func TestGenerateSeeded_DistributionNotSolved(t *testing.T) {249+ tests := []struct {250+ name string251+ w int252+ h int253+ }{254+ {name: "easy_3x3", w: 3, h: 3},255+ {name: "medium_5x5", w: 5, h: 5},256+ {name: "hard_7x7", w: 7, h: 7},257+ {name: "extreme_9x9", w: 9, h: 9},258+ }259+260+ for _, tt := range tests {261+ t.Run(tt.name, func(t *testing.T) {262+ minLightsOn := tt.w * tt.h263+ maxLightsOn := 0264+265+ for seed := range 128 {266+ rng := rand.New(rand.NewPCG(uint64(seed+1), uint64(seed+2)))267+ g := GenerateSeeded(tt.w, tt.h, rng)268+269+ if IsSolved(g) {270+ t.Fatalf("GenerateSeeded(%d,%d) produced solved grid at seed %d", tt.w, tt.h, seed)271+ }272+273+ lightsOn := countLightsOn(g)274+ if lightsOn < minLightsOn {275+ minLightsOn = lightsOn276+ }277+ if lightsOn > maxLightsOn {278+ maxLightsOn = lightsOn279+ }280+ }281+282+ if minLightsOn <= 0 {283+ t.Fatalf("min lights on = %d, want > 0", minLightsOn)284+ }285+ if minLightsOn == maxLightsOn {286+ t.Fatalf("lights-on distribution did not vary: min=max=%d", minLightsOn)287+ }288+ })289+ }290+}291+235292 // --- New (P1) ---236293 237294 func TestNew(t *testing.T) {@@ -0,0 +1,139 @@
1+package wordsearch2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+type wordSearchBenchCase struct {9+ name string10+ width int11+ height int12+ wordCount int13+ minWordLen int14+ maxWordLen int15+ allowedDirs []Direction16+}17+18+func BenchmarkGenerateWordSearchByMode(b *testing.B) {19+ cases := []wordSearchBenchCase{20+ {21+ name: "easy_10x10",22+ width: 10,23+ height: 10,24+ wordCount: 6,25+ minWordLen: 3,26+ maxWordLen: 5,27+ allowedDirs: []Direction{Right, Down, DownRight},28+ },29+ {30+ name: "medium_15x15",31+ width: 15,32+ height: 15,33+ wordCount: 10,34+ minWordLen: 4,35+ maxWordLen: 7,36+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},37+ },38+ {39+ name: "hard_20x20",40+ width: 20,41+ height: 20,42+ wordCount: 15,43+ minWordLen: 5,44+ maxWordLen: 10,45+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},46+ },47+ }48+49+ for _, tt := range cases {50+ b.Run(tt.name, func(b *testing.B) {51+ rng := rand.New(rand.NewPCG(1, 2))52+ var aggregate placementStats53+ b.ReportAllocs()54+55+ for range b.N {56+ _, _, stats := generateWordSearchSeededWithStats(57+ tt.width,58+ tt.height,59+ tt.wordCount,60+ tt.minWordLen,61+ tt.maxWordLen,62+ tt.allowedDirs,63+ rng,64+ )65+ aggregate.TargetWords += stats.TargetWords66+ aggregate.PlacedWords += stats.PlacedWords67+ aggregate.FailedWords += stats.FailedWords68+ aggregate.RandomAttempts += stats.RandomAttempts69+ aggregate.FallbackAttempts += stats.FallbackAttempts70+ aggregate.FallbackUsed += stats.FallbackUsed71+ aggregate.FallbackPlaced += stats.FallbackPlaced72+ }73+74+ b.ReportMetric(aggregate.SuccessRate()*100, "place_pct")75+ b.ReportMetric(aggregate.AttemptsPerWord(), "attempts/word")76+ if aggregate.TargetWords > 0 {77+ fallbackRate := float64(aggregate.FallbackUsed) * 100 / float64(aggregate.TargetWords)78+ b.ReportMetric(fallbackRate, "fallback_pct")79+ }80+ })81+ }82+}83+84+func BenchmarkTryPlaceWord(b *testing.B) {85+ cases := []struct {86+ name string87+ width int88+ height int89+ text string90+ allowedDirs []Direction91+ }{92+ {93+ name: "easy_10x10",94+ width: 10,95+ height: 10,96+ text: "HELLO",97+ allowedDirs: []Direction{Right, Down, DownRight},98+ },99+ {100+ name: "medium_15x15",101+ width: 15,102+ height: 15,103+ text: "PUZZLE",104+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},105+ },106+ {107+ name: "hard_20x20",108+ width: 20,109+ height: 20,110+ text: "WORDSEARCH",111+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},112+ },113+ }114+115+ for _, tt := range cases {116+ b.Run(tt.name, func(b *testing.B) {117+ rng := rand.New(rand.NewPCG(3, 4))118+ g := createEmptyGrid(tt.height, tt.width)119+ b.ReportAllocs()120+121+ for range b.N {122+ w := tryPlaceWordSeeded(g, tt.text, tt.allowedDirs, 100, rng)123+ if w == nil {124+ b.Fatalf("tryPlaceWordSeeded returned nil for %q", tt.text)125+ }126+ clearWord(g, *w)127+ }128+ })129+ }130+}131+132+func clearWord(g grid, w Word) {133+ dx, dy := w.Direction.Vector()134+ for i := range len(w.Text) {135+ x := w.Start.X + dx*i136+ y := w.Start.Y + dy*i137+ g.Set(x, y, ' ')138+ }139+}@@ -2,29 +2,137 @@ package wordsearch
22 33 import (44 "math/rand/v2"5+ "sort"56 "strings"67 )78 9+type placementStats struct {10+ TargetWords int11+ PlacedWords int12+ FailedWords int13+ RandomAttempts int14+ FallbackAttempts int15+ FallbackUsed int16+ FallbackPlaced int17+}18+19+func (s placementStats) SuccessRate() float64 {20+ if s.TargetWords == 0 {21+ return 122+ }23+ return float64(s.PlacedWords) / float64(s.TargetWords)24+}25+26+func (s placementStats) AttemptsPerWord() float64 {27+ if s.TargetWords == 0 {28+ return 029+ }30+ attempts := s.RandomAttempts + s.FallbackAttempts31+ return float64(attempts) / float64(s.TargetWords)32+}33+34+type placementAttemptStats struct {35+ RandomAttempts int36+ FallbackAttempts int37+ UsedFallback bool38+}39+40+type wordBucketSampler struct {41+ words []string42+ remaining int43+ swaps map[int]int44+}45+46+func (s *wordBucketSampler) resolve(pos int) int {47+ if s.swaps == nil {48+ return pos49+ }50+ if mapped, ok := s.swaps[pos]; ok {51+ return mapped52+ }53+ return pos54+}55+56+func (s *wordBucketSampler) draw(rng *rand.Rand) string {57+ drawPos := rng.IntN(s.remaining)58+ wordIndex := s.resolve(drawPos)59+60+ lastPos := s.remaining - 161+ lastIndex := s.resolve(lastPos)62+63+ if drawPos != lastPos {64+ if s.swaps == nil {65+ s.swaps = map[int]int{}66+ }67+ s.swaps[drawPos] = lastIndex68+ }69+70+ if s.swaps != nil {71+ delete(s.swaps, lastPos)72+ }73+74+ s.remaining--75+ return s.words[wordIndex]76+}77+878 func GenerateWordSearch(width, height, wordCount, minLen, maxLen int, allowedDirs []Direction) (grid, []Word) {979 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))1080 return GenerateWordSearchSeeded(width, height, wordCount, minLen, maxLen, allowedDirs, rng)1181 }1282 1383 func GenerateWordSearchSeeded(width, height, wordCount, minLen, maxLen int, allowedDirs []Direction, rng *rand.Rand) (grid, []Word) {84+ g, placedWords, _ := generateWordSearchSeededWithStats(85+ width,86+ height,87+ wordCount,88+ minLen,89+ maxLen,90+ allowedDirs,91+ rng,92+ )93+ return g, placedWords94+}95+96+func generateWordSearchSeededWithStats(97+ width,98+ height,99+ wordCount,100+ minLen,101+ maxLen int,102+ allowedDirs []Direction,103+ rng *rand.Rand,104+) (grid, []Word, placementStats) {14105 g := createEmptyGrid(height, width)15106 words := selectWordsSeeded(wordCount, minLen, maxLen, rng)107+ orderWordsForPlacement(words)108+16109 placedWords := make([]Word, 0, len(words))110+ stats := placementStats{TargetWords: len(words)}17111 18112 for _, wordText := range words {19- word := tryPlaceWordSeeded(g, wordText, allowedDirs, 100, rng)20- if word != nil {21- placedWords = append(placedWords, *word)113+ word, attemptStats := tryPlaceWordSeededWithFallback(g, wordText, allowedDirs, 100, rng)114+115+ stats.RandomAttempts += attemptStats.RandomAttempts116+ stats.FallbackAttempts += attemptStats.FallbackAttempts117+ if attemptStats.UsedFallback {118+ stats.FallbackUsed++22119 }120+121+ if word == nil {122+ stats.FailedWords++123+ continue124+ }125+126+ stats.PlacedWords++127+ if attemptStats.UsedFallback {128+ stats.FallbackPlaced++129+ }130+ placedWords = append(placedWords, *word)23131 }24132 25133 fillEmptyCellsSeeded(g, rng)26134 27- return g, placedWords135+ return g, placedWords, stats28136 }29137 30138 func selectWords(count, minLen, maxLen int) []string {@@ -33,22 +141,61 @@ func selectWords(count, minLen, maxLen int) []string {
33141 }34142 35143 func selectWordsSeeded(count, minLen, maxLen int, rng *rand.Rand) []string {36- var candidates []string144+ if count <= 0 || minLen > maxLen {145+ return nil146+ }147+148+ buckets := make([]wordBucketSampler, 0, maxLen-minLen+1)149+ totalAvailable := 0150+37151 for length := minLen; length <= maxLen; length++ {38- if words, ok := wordsByLength[length]; ok {39- candidates = append(candidates, words...)152+ words, ok := wordsByLength[length]153+ if !ok || len(words) == 0 {154+ continue40155 }156+157+ buckets = append(buckets, wordBucketSampler{158+ words: words,159+ remaining: len(words),160+ })161+ totalAvailable += len(words)41162 }42163 43- rng.Shuffle(len(candidates), func(i, j int) {44- candidates[i], candidates[j] = candidates[j], candidates[i]45- })164+ if totalAvailable == 0 {165+ return nil166+ }167+168+ if count > totalAvailable {169+ count = totalAvailable170+ }46171 47- if count > len(candidates) {48- count = len(candidates)172+ selected := make([]string, 0, count)173+ for range count {174+ bucketIndex := pickBucketByRemaining(buckets, totalAvailable, rng)175+ selected = append(selected, buckets[bucketIndex].draw(rng))176+ totalAvailable--49177 }50178 51- return candidates[:count]179+ return selected180+}181+182+func pickBucketByRemaining(buckets []wordBucketSampler, totalRemaining int, rng *rand.Rand) int {183+ randomOffset := rng.IntN(totalRemaining)184+185+ for i := range buckets {186+ if randomOffset < buckets[i].remaining {187+ return i188+ }189+ randomOffset -= buckets[i].remaining190+ }191+192+ return len(buckets) - 1193+}194+195+func orderWordsForPlacement(words []string) {196+ sort.SliceStable(words, func(i, j int) bool {197+ return len(words[i]) > len(words[j])198+ })52199 }53200 54201 func tryPlaceWord(g grid, text string, allowedDirs []Direction, maxAttempts int) *Word {@@ -57,56 +204,140 @@ func tryPlaceWord(g grid, text string, allowedDirs []Direction, maxAttempts int)
57204 }58205 59206 func tryPlaceWordSeeded(g grid, text string, allowedDirs []Direction, maxAttempts int, rng *rand.Rand) *Word {207+ word, _ := tryPlaceWordSeededWithFallback(g, text, allowedDirs, maxAttempts, rng)208+ return word209+}210+211+func tryPlaceWordSeededWithFallback(212+ g grid,213+ text string,214+ allowedDirs []Direction,215+ maxAttempts int,216+ rng *rand.Rand,217+) (*Word, placementAttemptStats) {218+ word, randomAttempts := tryPlaceWordSeededRandom(g, text, allowedDirs, maxAttempts, rng)219+ if word != nil {220+ return word, placementAttemptStats{RandomAttempts: randomAttempts}221+ }222+223+ fallbackWord, fallbackAttempts := tryPlaceWordSeededFallback(g, text, allowedDirs, rng)224+ return fallbackWord, placementAttemptStats{225+ RandomAttempts: randomAttempts,226+ FallbackAttempts: fallbackAttempts,227+ UsedFallback: fallbackAttempts > 0,228+ }229+}230+231+func tryPlaceWordSeededRandom(g grid, text string, allowedDirs []Direction, maxAttempts int, rng *rand.Rand) (*Word, int) {60232 text = strings.ToUpper(text)233+ if maxAttempts <= 0 || len(text) == 0 || len(allowedDirs) == 0 || len(g) == 0 || len(g[0]) == 0 {234+ return nil, 0235+ }236+61237 height := len(g)62238 width := len(g[0])63239 64- for range maxAttempts {240+ for attempt := 1; attempt <= maxAttempts; attempt++ {65241 x := rng.IntN(width)66242 y := rng.IntN(height)67243 68244 dir := allowedDirs[rng.IntN(len(allowedDirs))]69245 dx, dy := dir.Vector()70246 71- endX := x + dx*(len(text)-1)72- endY := y + dy*(len(text)-1)73-74- if endX < 0 || endX >= width || endY < 0 || endY >= height {247+ if !wordInBounds(x, y, dx, dy, len(text), width, height) {75248 continue76249 }250+ if !canPlaceWordAt(g, text, x, y, dx, dy) {251+ continue252+ }253+254+ placeWordAt(g, text, x, y, dx, dy)255+ return newWord(text, x, y, dx, dy, dir), attempt256+ }257+258+ return nil, maxAttempts259+}77260 78- valid := true79- for i := range len(text) {80- cx := x + dx*i81- cy := y + dy*i82- existing := g.Get(cx, cy)261+func tryPlaceWordSeededFallback(g grid, text string, allowedDirs []Direction, rng *rand.Rand) (*Word, int) {262+ text = strings.ToUpper(text)263+ if len(text) == 0 || len(allowedDirs) == 0 || len(g) == 0 || len(g[0]) == 0 {264+ return nil, 0265+ }266+267+ height := len(g)268+ width := len(g[0])269+270+ dirs := append([]Direction(nil), allowedDirs...)271+ rng.Shuffle(len(dirs), func(i, j int) {272+ dirs[i], dirs[j] = dirs[j], dirs[i]273+ })274+275+ startX := rng.IntN(width)276+ startY := rng.IntN(height)277+278+ attempts := 0279+ for _, dir := range dirs {280+ dx, dy := dir.Vector()281+ for yStep := range height {282+ y := (startY + yStep) % height283+ for xStep := range width {284+ x := (startX + xStep) % width285+ attempts++286+287+ if !wordInBounds(x, y, dx, dy, len(text), width, height) {288+ continue289+ }290+ if !canPlaceWordAt(g, text, x, y, dx, dy) {291+ continue292+ }83293 84- if existing != ' ' && existing != rune(text[i]) {85- valid = false86- break294+ placeWordAt(g, text, x, y, dx, dy)295+ return newWord(text, x, y, dx, dy, dir), attempts87296 }88297 }298+ }89299 90- if !valid {91- continue92- }300+ return nil, attempts301+}93302 94- for i := range len(text) {95- cx := x + dx*i96- cy := y + dy*i97- g.Set(cx, cy, rune(text[i]))98- }303+func wordInBounds(x, y, dx, dy, wordLen, width, height int) bool {304+ endX := x + dx*(wordLen-1)305+ endY := y + dy*(wordLen-1)306+ return endX >= 0 && endX < width && endY >= 0 && endY < height307+}99308 100- return &Word{101- Text: text,102- Start: Position{X: x, Y: y},103- End: Position{X: endX, Y: endY},104- Direction: dir,105- Found: false,309+func canPlaceWordAt(g grid, text string, x, y, dx, dy int) bool {310+ for i := range len(text) {311+ cx := x + dx*i312+ cy := y + dy*i313+ existing := g.Get(cx, cy)314+ want := rune(text[i])315+ if existing != ' ' && existing != want {316+ return false106317 }107318 }319+ return true320+}108321 109- return nil322+func placeWordAt(g grid, text string, x, y, dx, dy int) {323+ for i := range len(text) {324+ cx := x + dx*i325+ cy := y + dy*i326+ g.Set(cx, cy, rune(text[i]))327+ }328+}329+330+func newWord(text string, x, y, dx, dy int, dir Direction) *Word {331+ endX := x + dx*(len(text)-1)332+ endY := y + dy*(len(text)-1)333+334+ return &Word{335+ Text: text,336+ Start: Position{X: x, Y: y},337+ End: Position{X: endX, Y: endY},338+ Direction: dir,339+ Found: false,340+ }110341 }111342 112343 func fillEmptyCells(g grid) {@@ -2,11 +2,16 @@ package wordsearch
22 33 import (44 "encoding/json"5+ "math/rand/v2"56 "testing"67 78 "github.com/FelineStateMachine/puzzletea/game"89 )910 11+func testRNG(seed uint64) *rand.Rand {12+ return rand.New(rand.NewPCG(seed, seed+1))13+}14+1015 // --- Direction.Vector (P0) ---1116 1217 func TestDirectionVector(t *testing.T) {@@ -462,6 +467,28 @@ func TestTryPlaceWord(t *testing.T) {
462467 })463468 }464469 470+func TestTryPlaceWordSeededWithFallback(t *testing.T) {471+ g := createEmptyGrid(1, 5)472+ rng := testRNG(123)473+474+ word, stats := tryPlaceWordSeededWithFallback(g, "HELLO", []Direction{Right}, 0, rng)475+ if word == nil {476+ t.Fatal("expected fallback to place word")477+ }478+ if !stats.UsedFallback {479+ t.Fatal("expected UsedFallback to be true")480+ }481+ if stats.RandomAttempts != 0 {482+ t.Fatalf("RandomAttempts = %d, want 0", stats.RandomAttempts)483+ }484+ if stats.FallbackAttempts == 0 {485+ t.Fatal("expected fallback attempts to be > 0")486+ }487+ if word.Start != (Position{X: 0, Y: 0}) || word.End != (Position{X: 4, Y: 0}) {488+ t.Fatalf("unexpected placement: start=%v end=%v", word.Start, word.End)489+ }490+}491+465492 // --- selectWords (P2) ---466493 467494 func TestSelectWords(t *testing.T) {@@ -491,6 +518,31 @@ func TestSelectWords(t *testing.T) {
491518 t.Error("expected some words")492519 }493520 })521+522+ t.Run("no duplicates", func(t *testing.T) {523+ rng := testRNG(42)524+ words := selectWordsSeeded(500, 3, 10, rng)525+ seen := make(map[string]struct{}, len(words))526+527+ for _, w := range words {528+ if _, ok := seen[w]; ok {529+ t.Fatalf("duplicate word selected: %q", w)530+ }531+ seen[w] = struct{}{}532+ }533+ })534+}535+536+func TestOrderWordsForPlacement(t *testing.T) {537+ words := []string{"AA", "BBBB", "CCC", "DDDD", "E"}538+ orderWordsForPlacement(words)539+540+ want := []string{"BBBB", "DDDD", "CCC", "AA", "E"}541+ for i := range want {542+ if words[i] != want[i] {543+ t.Fatalf("words[%d]=%q, want %q", i, words[i], want[i])544+ }545+ }494546 }495547 496548 // --- fillEmptyCells (P2) ---@@ -566,6 +618,231 @@ func TestGenerateWordSearch(t *testing.T) {
566618 })567619 }568620 621+// --- generation integrity regression (P0) ---622+623+func TestGenerateWordSearchSeeded_WordCountRegression(t *testing.T) {624+ tests := []struct {625+ name string626+ width int627+ height int628+ wordCount int629+ minWordLen int630+ maxWordLen int631+ allowedDirs []Direction632+ minPlaced int633+ }{634+ {635+ name: "easy_10x10",636+ width: 10,637+ height: 10,638+ wordCount: 6,639+ minWordLen: 3,640+ maxWordLen: 5,641+ allowedDirs: []Direction{Right, Down, DownRight},642+ minPlaced: 6,643+ },644+ {645+ name: "medium_15x15",646+ width: 15,647+ height: 15,648+ wordCount: 10,649+ minWordLen: 4,650+ maxWordLen: 7,651+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},652+ minPlaced: 10,653+ },654+ {655+ name: "hard_20x20",656+ width: 20,657+ height: 20,658+ wordCount: 15,659+ minWordLen: 5,660+ maxWordLen: 10,661+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},662+ minPlaced: 13,663+ },664+ }665+666+ for _, tt := range tests {667+ t.Run(tt.name, func(t *testing.T) {668+ for seed := range 128 {669+ rng := testRNG(uint64(seed + 1))670+ _, words := GenerateWordSearchSeeded(671+ tt.width,672+ tt.height,673+ tt.wordCount,674+ tt.minWordLen,675+ tt.maxWordLen,676+ tt.allowedDirs,677+ rng,678+ )679+680+ if len(words) > tt.wordCount {681+ t.Fatalf("seed %d: len(words)=%d, want <= %d", seed, len(words), tt.wordCount)682+ }683+ if len(words) < tt.minPlaced {684+ t.Fatalf("seed %d: len(words)=%d, want >= %d", seed, len(words), tt.minPlaced)685+ }686+ }687+ })688+ }689+}690+691+func TestGenerateWordSearchSeeded_PlacementValidityRegression(t *testing.T) {692+ tests := []struct {693+ name string694+ width int695+ height int696+ wordCount int697+ minWordLen int698+ maxWordLen int699+ allowedDirs []Direction700+ }{701+ {702+ name: "easy_10x10",703+ width: 10,704+ height: 10,705+ wordCount: 6,706+ minWordLen: 3,707+ maxWordLen: 5,708+ allowedDirs: []Direction{Right, Down, DownRight},709+ },710+ {711+ name: "medium_15x15",712+ width: 15,713+ height: 15,714+ wordCount: 10,715+ minWordLen: 4,716+ maxWordLen: 7,717+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},718+ },719+ {720+ name: "hard_20x20",721+ width: 20,722+ height: 20,723+ wordCount: 15,724+ minWordLen: 5,725+ maxWordLen: 10,726+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},727+ },728+ }729+730+ for _, tt := range tests {731+ t.Run(tt.name, func(t *testing.T) {732+ for seed := range 64 {733+ rng := testRNG(uint64(seed + 1))734+ g, words := GenerateWordSearchSeeded(735+ tt.width,736+ tt.height,737+ tt.wordCount,738+ tt.minWordLen,739+ tt.maxWordLen,740+ tt.allowedDirs,741+ rng,742+ )743+ assertWordPlacementsValid(t, g, words)744+ }745+ })746+ }747+}748+749+func assertWordPlacementsValid(t *testing.T, g grid, words []Word) {750+ t.Helper()751+752+ type cell struct {753+ x int754+ y int755+ }756+757+ occupied := map[cell]rune{}758+759+ for wi, w := range words {760+ positions := w.Positions()761+ if len(positions) != len(w.Text) {762+ t.Fatalf("word[%d]=%q positions=%d, want %d", wi, w.Text, len(positions), len(w.Text))763+ }764+ if positions[0] != w.Start {765+ t.Fatalf("word[%d]=%q start=%v, want %v", wi, w.Text, w.Start, positions[0])766+ }767+ if positions[len(positions)-1] != w.End {768+ t.Fatalf("word[%d]=%q end=%v, want %v", wi, w.Text, w.End, positions[len(positions)-1])769+ }770+771+ for i, pos := range positions {772+ if pos.X < 0 || pos.X >= len(g[0]) || pos.Y < 0 || pos.Y >= len(g) {773+ t.Fatalf("word[%d]=%q pos[%d]=(%d,%d) out of bounds", wi, w.Text, i, pos.X, pos.Y)774+ }775+776+ want := rune(w.Text[i])777+ if got := g.Get(pos.X, pos.Y); got != want {778+ t.Fatalf(779+ "word[%d]=%q pos[%d]=(%d,%d) grid=%q, want %q",780+ wi,781+ w.Text,782+ i,783+ pos.X,784+ pos.Y,785+ got,786+ want,787+ )788+ }789+790+ key := cell{x: pos.X, y: pos.Y}791+ if prev, ok := occupied[key]; ok && prev != want {792+ t.Fatalf(793+ "conflicting overlap at (%d,%d): had %q, word[%d]=%q wants %q",794+ pos.X,795+ pos.Y,796+ prev,797+ wi,798+ w.Text,799+ want,800+ )801+ }802+ occupied[key] = want803+ }804+ }805+}806+807+func TestGenerateWordSearchSeeded_PlacementStats(t *testing.T) {808+ rng := testRNG(99)809+ _, words, stats := generateWordSearchSeededWithStats(810+ 20,811+ 20,812+ 15,813+ 5,814+ 10,815+ []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},816+ rng,817+ )818+819+ if stats.TargetWords != 15 {820+ t.Fatalf("TargetWords = %d, want 15", stats.TargetWords)821+ }822+ if stats.PlacedWords != len(words) {823+ t.Fatalf("PlacedWords = %d, want %d", stats.PlacedWords, len(words))824+ }825+ if stats.PlacedWords+stats.FailedWords != stats.TargetWords {826+ t.Fatalf(827+ "placed+failed = %d, want %d",828+ stats.PlacedWords+stats.FailedWords,829+ stats.TargetWords,830+ )831+ }832+ if stats.RandomAttempts < stats.TargetWords {833+ t.Fatalf("RandomAttempts = %d, want >= %d", stats.RandomAttempts, stats.TargetWords)834+ }835+ if stats.FallbackPlaced > stats.FallbackUsed {836+ t.Fatalf("FallbackPlaced = %d, want <= FallbackUsed = %d", stats.FallbackPlaced, stats.FallbackUsed)837+ }838+ if got := stats.SuccessRate(); got <= 0 || got > 1 {839+ t.Fatalf("SuccessRate = %.4f, want (0,1]", got)840+ }841+ if got := stats.AttemptsPerWord(); got <= 0 {842+ t.Fatalf("AttemptsPerWord = %.4f, want > 0", got)843+ }844+}845+569846 // --- screenToGrid (P1) ---570847 571848 func TestScreenToGrid(t *testing.T) {