clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

bug/architecture-test-trim-path
puzzletea / games / shikaku / shikaku_test.go
shikaku_test.go 30.4K
1package shikaku23import (4 "encoding/json"5 "math/rand/v2"6 "strings"7 "testing"89 tea "charm.land/bubbletea/v2"10 "charm.land/lipgloss/v2"11 "github.com/FelineStateMachine/puzzletea/game"12 "github.com/FelineStateMachine/puzzletea/theme"13 "github.com/charmbracelet/x/ansi"14)1516// --- helpers ---1718// simplePuzzle builds a 4x4 puzzle with 4 clues:19//20// [4] . . .21// . . . .22// . . [2] .23// . [6] . .24//25// Solution: 2x2 rect for clue 0, 1x2 rect for clue 2, 2x3 rect for clue 1, ...26func simplePuzzle() *Puzzle {27 return &Puzzle{28 Width: 4,29 Height: 4,30 Clues: []Clue{31 {ID: 0, X: 0, Y: 0, Value: 4},32 {ID: 1, X: 1, Y: 3, Value: 6},33 {ID: 2, X: 2, Y: 2, Value: 2},34 {ID: 3, X: 3, Y: 0, Value: 4},35 },36 }37}3839// tinyPuzzle builds a 2x2 puzzle with 2 clues for minimal testing.40func tinyPuzzle() *Puzzle {41 return &Puzzle{42 Width: 2,43 Height: 2,44 Clues: []Clue{45 {ID: 0, X: 0, Y: 0, Value: 2},46 {ID: 1, X: 1, Y: 1, Value: 2},47 },48 }49}5051func forcedCascadePuzzle() *Puzzle {52 return &Puzzle{53 Width: 3,54 Height: 2,55 Clues: []Clue{56 {ID: 0, X: 0, Y: 0, Value: 4},57 {ID: 1, X: 2, Y: 1, Value: 2},58 },59 }60}6162// --- FindClueAt ---6364func TestFindClueAt(t *testing.T) {65 t.Run("clue exists", func(t *testing.T) {66 p := simplePuzzle()67 c := p.FindClueAt(0, 0)68 if c == nil {69 t.Fatal("expected clue at (0,0)")70 }71 if c.ID != 0 || c.Value != 4 {72 t.Errorf("clue = %+v, want ID=0 Value=4", c)73 }74 })7576 t.Run("no clue", func(t *testing.T) {77 p := simplePuzzle()78 c := p.FindClueAt(1, 1)79 if c != nil {80 t.Errorf("expected nil at (1,1), got %+v", c)81 }82 })8384 t.Run("lazy index build", func(t *testing.T) {85 p := simplePuzzle()86 if p.clueIndex != nil {87 t.Fatal("clueIndex should be nil before first call")88 }89 _ = p.FindClueAt(0, 0)90 if p.clueIndex == nil {91 t.Error("clueIndex should be built after first call")92 }93 })94}9596// --- FindClueByID ---9798func TestFindClueByID(t *testing.T) {99 t.Run("found by sequential ID", func(t *testing.T) {100 p := simplePuzzle()101 c := p.FindClueByID(2)102 if c == nil {103 t.Fatal("expected clue with ID=2")104 }105 if c.X != 2 || c.Y != 2 {106 t.Errorf("clue 2 at (%d,%d), want (2,2)", c.X, c.Y)107 }108 })109110 t.Run("invalid ID", func(t *testing.T) {111 p := simplePuzzle()112 if c := p.FindClueByID(99); c != nil {113 t.Errorf("expected nil, got %+v", c)114 }115 })116}117118// --- Rectangle methods ---119120func TestRectangleContains(t *testing.T) {121 r := Rectangle{ClueID: 0, X: 1, Y: 1, W: 2, H: 3}122123 if !r.Contains(1, 1) {124 t.Error("should contain top-left corner")125 }126 if !r.Contains(2, 3) {127 t.Error("should contain bottom-right cell")128 }129 if r.Contains(0, 0) {130 t.Error("should not contain (0,0)")131 }132 if r.Contains(3, 1) {133 t.Error("should not contain cell just outside right edge")134 }135}136137func TestRectangleArea(t *testing.T) {138 r := Rectangle{W: 3, H: 4}139 if r.Area() != 12 {140 t.Errorf("Area() = %d, want 12", r.Area())141 }142}143144// --- CellOwner ---145146func TestCellOwner(t *testing.T) {147 p := simplePuzzle()148 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2})149150 t.Run("owned cell", func(t *testing.T) {151 if owner := p.CellOwner(1, 1); owner != 0 {152 t.Errorf("CellOwner(1,1) = %d, want 0", owner)153 }154 })155156 t.Run("unowned cell", func(t *testing.T) {157 if owner := p.CellOwner(3, 3); owner != -1 {158 t.Errorf("CellOwner(3,3) = %d, want -1", owner)159 }160 })161}162163// --- Overlaps ---164165func TestOverlaps(t *testing.T) {166 p := simplePuzzle()167 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2})168169 t.Run("overlapping", func(t *testing.T) {170 r := Rectangle{ClueID: 1, X: 1, Y: 1, W: 2, H: 2}171 if !p.Overlaps(r, 1) {172 t.Error("should overlap with existing rectangle")173 }174 })175176 t.Run("non-overlapping", func(t *testing.T) {177 r := Rectangle{ClueID: 1, X: 2, Y: 0, W: 2, H: 2}178 if p.Overlaps(r, 1) {179 t.Error("should not overlap")180 }181 })182183 t.Run("exclude self", func(t *testing.T) {184 // Replacing the same clue's rectangle should not count as overlap.185 r := Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}186 if p.Overlaps(r, 0) {187 t.Error("should not overlap when excluding self")188 }189 })190}191192// --- SetRectangle / RemoveRectangle ---193194func TestSetAndRemoveRectangle(t *testing.T) {195 p := simplePuzzle()196197 t.Run("set new", func(t *testing.T) {198 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2})199 if r := p.FindRectangleForClue(0); r == nil {200 t.Fatal("expected rectangle for clue 0")201 }202 })203204 t.Run("replace existing", func(t *testing.T) {205 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 4, H: 1})206 r := p.FindRectangleForClue(0)207 if r == nil {208 t.Fatal("expected rectangle for clue 0")209 }210 if r.W != 4 || r.H != 1 {211 t.Errorf("rectangle = %dx%d, want 4x1", r.W, r.H)212 }213 if len(p.Rectangles) != 1 {214 t.Errorf("len(Rectangles) = %d, want 1", len(p.Rectangles))215 }216 })217218 t.Run("remove", func(t *testing.T) {219 p.RemoveRectangle(0)220 if r := p.FindRectangleForClue(0); r != nil {221 t.Error("expected nil after removal")222 }223 })224225 t.Run("remove nonexistent", func(t *testing.T) {226 p.RemoveRectangle(99) // must not panic227 })228}229230func TestCandidateRectanglesForClue(t *testing.T) {231 t.Run("single forced rectangle", func(t *testing.T) {232 p := forcedCascadePuzzle()233 got := p.CandidateRectanglesForClue(0)234 if len(got) != 1 {235 t.Fatalf("candidate count = %d, want 1", len(got))236 }237 want := Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}238 if got[0] != want {239 t.Fatalf("candidate = %+v, want %+v", got[0], want)240 }241 })242243 t.Run("multiple candidates remain", func(t *testing.T) {244 p := tinyPuzzle()245 if got := p.CandidateRectanglesForClue(0); len(got) != 2 {246 t.Fatalf("candidate count = %d, want 2", len(got))247 }248 })249}250251func TestAutoPlaceForcedRectangles(t *testing.T) {252 t.Run("places single-cell clues", func(t *testing.T) {253 p := &Puzzle{254 Width: 3,255 Height: 2,256 Clues: []Clue{257 {ID: 0, X: 0, Y: 0, Value: 1},258 {ID: 1, X: 2, Y: 1, Value: 2},259 },260 }261262 p.autoPlaceForcedRectangles()263264 if len(p.Rectangles) != 1 {265 t.Fatalf("rectangle count = %d, want 1", len(p.Rectangles))266 }267 if r := p.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 1}) {268 t.Fatalf("clue 0 rectangle = %+v, want forced 1x1", r)269 }270 })271272 t.Run("does not place from global uniqueness alone", func(t *testing.T) {273 p := forcedCascadePuzzle()274 p.autoPlaceForcedRectangles()275 if len(p.Rectangles) != 0 {276 t.Fatalf("rectangle count = %d, want 0", len(p.Rectangles))277 }278 })279280 t.Run("does not place when multiple candidates exist", func(t *testing.T) {281 p := tinyPuzzle()282 p.autoPlaceForcedRectangles()283 if len(p.Rectangles) != 0 {284 t.Fatalf("rectangle count = %d, want 0", len(p.Rectangles))285 }286 })287288 t.Run("places encased rectangle and cascades", func(t *testing.T) {289 p := forcedCascadePuzzle()290 p.SetRectangle(Rectangle{ClueID: 1, X: 2, Y: 0, W: 1, H: 2})291 p.autoPlaceForcedRectangles()292 if len(p.Rectangles) != 2 {293 t.Fatalf("rectangle count = %d, want 2", len(p.Rectangles))294 }295 if r := p.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}) {296 t.Fatalf("clue 0 rectangle = %+v, want 2x2 at origin", r)297 }298 if r := p.FindRectangleForClue(1); r == nil || *r != (Rectangle{ClueID: 1, X: 2, Y: 0, W: 1, H: 2}) {299 t.Fatalf("clue 1 rectangle = %+v, want right column", r)300 }301 })302303 t.Run("does not overwrite existing rectangle", func(t *testing.T) {304 p := tinyPuzzle()305 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 1})306 p.autoPlaceForcedRectangles()307 if r := p.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 1}) {308 t.Fatalf("existing rectangle changed to %+v", r)309 }310 })311}312313// --- IsSolved ---314315func TestIsSolved(t *testing.T) {316 t.Run("solved 2x2", func(t *testing.T) {317 p := tinyPuzzle()318 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 2})319 p.SetRectangle(Rectangle{ClueID: 1, X: 1, Y: 0, W: 1, H: 2})320321 if !p.IsSolved() {322 t.Error("puzzle should be solved")323 }324 })325326 t.Run("incomplete coverage", func(t *testing.T) {327 p := tinyPuzzle()328 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 2})329 // Only one rectangle placed.330331 if p.IsSolved() {332 t.Error("incomplete coverage should not be solved")333 }334 })335336 t.Run("wrong area", func(t *testing.T) {337 p := tinyPuzzle()338 // Both clues want area 2 but we give them area 1 rects339 // (which won't cover everything either).340 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 1})341 p.SetRectangle(Rectangle{ClueID: 1, X: 1, Y: 1, W: 1, H: 1})342343 if p.IsSolved() {344 t.Error("wrong area should not be solved")345 }346 })347348 t.Run("overlapping rectangles", func(t *testing.T) {349 p := tinyPuzzle()350 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 1})351 p.SetRectangle(Rectangle{ClueID: 1, X: 0, Y: 0, W: 2, H: 1}) // same area352353 if p.IsSolved() {354 t.Error("overlapping rectangles should not be solved")355 }356 })357358 t.Run("no rectangles", func(t *testing.T) {359 p := simplePuzzle()360 if p.IsSolved() {361 t.Error("no rectangles should not be solved")362 }363 })364365 t.Run("clue outside its rectangle", func(t *testing.T) {366 p := &Puzzle{367 Width: 2,368 Height: 2,369 Clues: []Clue{370 {ID: 0, X: 0, Y: 0, Value: 2},371 {ID: 1, X: 1, Y: 0, Value: 2},372 },373 }374 // Place clue 0's rect in row 1 (doesn't contain clue 0).375 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 1, W: 2, H: 1})376 p.SetRectangle(Rectangle{ClueID: 1, X: 0, Y: 0, W: 2, H: 1})377378 if p.IsSolved() {379 t.Error("clue outside its rectangle should not be solved")380 }381 })382383 t.Run("two clues in same rectangle", func(t *testing.T) {384 p := &Puzzle{385 Width: 4,386 Height: 1,387 Clues: []Clue{388 {ID: 0, X: 0, Y: 0, Value: 4},389 {ID: 1, X: 3, Y: 0, Value: 4},390 },391 }392 // One huge rectangle containing both clues.393 p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 4, H: 1})394 // No rect for clue 1 — count mismatch.395 if p.IsSolved() {396 t.Error("two clues in same rectangle should not be solved")397 }398 })399}400401// --- expansion.rect ---402403func TestExpansionRect(t *testing.T) {404 clue := &Clue{ID: 0, X: 3, Y: 3, Value: 6}405 e := expansion{clueID: 0, left: 1, right: 2, up: 0, down: 1}406 r := e.rect(clue)407408 if r.X != 2 || r.Y != 3 || r.W != 4 || r.H != 2 {409 t.Errorf("rect = (%d,%d) %dx%d, want (2,3) 4x2", r.X, r.Y, r.W, r.H)410 }411 if r.Area() != 8 {412 t.Errorf("Area() = %d, want 8", r.Area())413 }414}415416// --- Save/Load round-trip ---417418func TestSaveLoadRoundTrip(t *testing.T) {419 t.Run("full round trip", func(t *testing.T) {420 original := &Model{421 puzzle: Puzzle{422 Width: 5,423 Height: 5,424 Clues: []Clue{425 {ID: 0, X: 0, Y: 0, Value: 4},426 {ID: 1, X: 2, Y: 0, Value: 6},427 {ID: 2, X: 0, Y: 2, Value: 3},428 },429 Rectangles: []Rectangle{430 {ClueID: 0, X: 0, Y: 0, W: 2, H: 2},431 {ClueID: 1, X: 2, Y: 0, W: 3, H: 2},432 },433 },434 keys: DefaultKeyMap,435 modeTitle: "Test Mode",436 }437438 data, err := original.GetSave()439 if err != nil {440 t.Fatal(err)441 }442443 got, err := ImportModel(data)444 if err != nil {445 t.Fatal(err)446 }447448 if got.puzzle.Width != 5 || got.puzzle.Height != 5 {449 t.Errorf("dimensions = %dx%d, want 5x5", got.puzzle.Width, got.puzzle.Height)450 }451 if len(got.puzzle.Clues) != 3 {452 t.Fatalf("clue count = %d, want 3", len(got.puzzle.Clues))453 }454 if len(got.puzzle.Rectangles) != 2 {455 t.Fatalf("rectangle count = %d, want 2", len(got.puzzle.Rectangles))456 }457 if got.modeTitle != "Test Mode" {458 t.Errorf("modeTitle = %q, want %q", got.modeTitle, "Test Mode")459 }460461 for i, c := range original.puzzle.Clues {462 g := got.puzzle.Clues[i]463 if g.ID != c.ID || g.X != c.X || g.Y != c.Y || g.Value != c.Value {464 t.Errorf("clue[%d] = %+v, want %+v", i, g, c)465 }466 }467 for i, r := range original.puzzle.Rectangles {468 g := got.puzzle.Rectangles[i]469 if g.ClueID != r.ClueID || g.X != r.X || g.Y != r.Y || g.W != r.W || g.H != r.H {470 t.Errorf("rectangle[%d] = %+v, want %+v", i, g, r)471 }472 }473 })474475 t.Run("invalid JSON", func(t *testing.T) {476 _, err := ImportModel([]byte("not json"))477 if err == nil {478 t.Fatal("expected error for invalid JSON")479 }480 })481482 t.Run("import preserves saved rectangles without auto-placement", func(t *testing.T) {483 original := &Model{484 puzzle: Puzzle{485 Width: 3,486 Height: 2,487 Clues: []Clue{488 {ID: 0, X: 0, Y: 0, Value: 4},489 {ID: 1, X: 2, Y: 1, Value: 2},490 },491 Rectangles: []Rectangle{492 {ClueID: 1, X: 2, Y: 0, W: 1, H: 2},493 },494 },495 keys: DefaultKeyMap,496 modeTitle: "Test Mode",497 }498499 data, err := original.GetSave()500 if err != nil {501 t.Fatal(err)502 }503504 got, err := ImportModel(data)505 if err != nil {506 t.Fatal(err)507 }508 if len(got.puzzle.Rectangles) != 1 {509 t.Fatalf("rectangle count = %d, want 1", len(got.puzzle.Rectangles))510 }511 if r := got.puzzle.FindRectangleForClue(0); r != nil {512 t.Fatalf("unexpected auto-placed rectangle for clue 0: %+v", *r)513 }514 })515}516517func TestDeleteReplacesForcedRectangle(t *testing.T) {518 m := New(NewMode("Test", "test", 3, 2, 4), *forcedCascadePuzzle()).(Model)519 m.commitRectangle(Rectangle{ClueID: 1, X: 2, Y: 0, W: 1, H: 2})520 if len(m.puzzle.Rectangles) != 2 {521 t.Fatalf("rectangle count = %d, want 2", len(m.puzzle.Rectangles))522 }523524 m.deleteRectangle(0)525526 if len(m.puzzle.Rectangles) != 2 {527 t.Fatalf("rectangle count after delete = %d, want 2", len(m.puzzle.Rectangles))528 }529 if r := m.puzzle.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}) {530 t.Fatalf("clue 0 rectangle = %+v, want forced replacement", r)531 }532}533534func TestDeleteReplacesSingleCellForcedRectangle(t *testing.T) {535 m := New(NewMode("Test", "test", 2, 1, 2), Puzzle{536 Width: 2,537 Height: 1,538 Clues: []Clue{539 {ID: 0, X: 0, Y: 0, Value: 1},540 {ID: 1, X: 1, Y: 0, Value: 1},541 },542 }).(Model)543544 if len(m.puzzle.Rectangles) != 2 {545 t.Fatalf("rectangle count = %d, want 2", len(m.puzzle.Rectangles))546 }547548 m.deleteRectangle(0)549550 if len(m.puzzle.Rectangles) != 2 {551 t.Fatalf("rectangle count after delete = %d, want 2", len(m.puzzle.Rectangles))552 }553 if r := m.puzzle.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 1}) {554 t.Fatalf("clue 0 rectangle = %+v, want forced 1x1 replacement", r)555 }556}557558func TestSaveJSON(t *testing.T) {559 m := Model{560 puzzle: Puzzle{561 Width: 4,562 Height: 4,563 Clues: []Clue{564 {ID: 0, X: 0, Y: 0, Value: 4},565 },566 Rectangles: []Rectangle{567 {ClueID: 0, X: 0, Y: 0, W: 2, H: 2},568 },569 },570 keys: DefaultKeyMap,571 modeTitle: "Test",572 }573574 data, err := m.GetSave()575 if err != nil {576 t.Fatal(err)577 }578579 var raw map[string]json.RawMessage580 if err := json.Unmarshal(data, &raw); err != nil {581 t.Fatal(err)582 }583584 for _, key := range []string{"width", "height", "clues", "rectangles", "mode_title"} {585 if _, ok := raw[key]; !ok {586 t.Errorf("missing key %q in save JSON", key)587 }588 }589}590591// --- Generator ---592593func TestGeneratePuzzle(t *testing.T) {594 t.Run("produces valid puzzle", func(t *testing.T) {595 puzzle, err := GeneratePuzzle(5, 5, 5)596 if err != nil {597 t.Fatal(err)598 }599 if puzzle.Width != 5 || puzzle.Height != 5 {600 t.Errorf("dimensions = %dx%d, want 5x5", puzzle.Width, puzzle.Height)601 }602 if len(puzzle.Clues) == 0 {603 t.Error("expected at least one clue")604 }605 // Sum of clue values should equal grid area.606 sum := 0607 for _, c := range puzzle.Clues {608 sum += c.Value609 }610 if sum != 25 {611 t.Errorf("sum of clue values = %d, want 25", sum)612 }613 })614615 t.Run("no rectangles placed", func(t *testing.T) {616 puzzle, err := GeneratePuzzle(5, 5, 5)617 if err != nil {618 t.Fatal(err)619 }620 if len(puzzle.Rectangles) != 0 {621 t.Errorf("len(Rectangles) = %d, want 0", len(puzzle.Rectangles))622 }623 })624625 t.Run("clues within bounds", func(t *testing.T) {626 puzzle, err := GeneratePuzzle(7, 7, 8)627 if err != nil {628 t.Fatal(err)629 }630 for _, c := range puzzle.Clues {631 if c.X < 0 || c.X >= 7 || c.Y < 0 || c.Y >= 7 {632 t.Errorf("clue %d at (%d,%d) out of bounds", c.ID, c.X, c.Y)633 }634 }635 })636637 t.Run("seeded deterministic", func(t *testing.T) {638 rng1 := rand.New(rand.NewPCG(42, 42))639 rng2 := rand.New(rand.NewPCG(42, 42))640641 p1, err := GeneratePuzzleSeeded(5, 5, 5, rng1)642 if err != nil {643 t.Fatal(err)644 }645 p2, err := GeneratePuzzleSeeded(5, 5, 5, rng2)646 if err != nil {647 t.Fatal(err)648 }649650 if len(p1.Clues) != len(p2.Clues) {651 t.Fatalf("different clue counts: %d vs %d", len(p1.Clues), len(p2.Clues))652 }653 for i := range p1.Clues {654 if p1.Clues[i] != p2.Clues[i] {655 t.Errorf("clue[%d] differs: %+v vs %+v", i, p1.Clues[i], p2.Clues[i])656 }657 }658 })659660 t.Run("larger grid", func(t *testing.T) {661 puzzle, err := GeneratePuzzle(10, 10, 15)662 if err != nil {663 t.Fatal(err)664 }665 sum := 0666 for _, c := range puzzle.Clues {667 sum += c.Value668 }669 if sum != 100 {670 t.Errorf("sum of clue values = %d, want 100", sum)671 }672 })673}674675// --- screenToGrid (P1) ---676677func TestScreenToGrid(t *testing.T) {678 m := &Model{679 puzzle: Puzzle{680 Width: 5,681 Height: 5,682 Clues: []Clue{683 {ID: 0, X: 0, Y: 0, Value: 4},684 },685 },686 keys: DefaultKeyMap,687 modeTitle: "Test",688 termWidth: 120,689 termHeight: 40,690 }691692 ox, oy := m.gridOrigin()693694 tests := []struct {695 name string696 screenX int697 screenY int698 wantCol int699 wantRow int700 wantOk bool701 }{702 {"origin cell", ox, oy, 0, 0, true},703 {"cell (1,0)", ox + cellWidth + 1, oy, 1, 0, true},704 {"cell (0,1)", ox, oy + 2, 0, 1, true},705 {"cell (2,3)", ox + 2*(cellWidth+1), oy + 3*2, 2, 3, true},706 {"cell (4,4) last", ox + 4*(cellWidth+1), oy + 4*2, 4, 4, true},707 {"vertical border miss", ox + cellWidth, oy, 0, 0, false},708 {"horizontal border miss", ox, oy + 1, 0, 0, false},709 {"outside left", ox - 1, oy, 0, 0, false},710 {"outside top", ox, oy - 1, 0, 0, false},711 {"outside right", ox + 5*(cellWidth+1), oy, 0, 0, false},712 {"outside bottom", ox, oy + 10, 0, 0, false},713 {"far outside", 0, 0, 0, 0, false},714 }715716 for _, tt := range tests {717 t.Run(tt.name, func(t *testing.T) {718 col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)719 if ok != tt.wantOk {720 t.Errorf("ok = %v, want %v", ok, tt.wantOk)721 }722 if ok && (col != tt.wantCol || row != tt.wantRow) {723 t.Errorf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)",724 tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)725 }726 })727 }728}729730func TestMouseClickStartsDragOnContentCell(t *testing.T) {731 m := Model{732 puzzle: Puzzle{733 Width: 5,734 Height: 5,735 Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}},736 },737 keys: DefaultKeyMap,738 modeTitle: "Test",739 termWidth: 120,740 termHeight: 40,741 }742743 x, y := cellScreenCoords(&m, 0, 0)744 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})745 got := next.(Model)746747 if !got.lastMouseHit {748 t.Fatal("expected content-cell click to register as a hit")749 }750 if got.mouseDragAnchor == nil || *got.mouseDragAnchor != ([2]int{0, 0}) {751 t.Fatalf("mouseDragAnchor = %#v, want {0,0}", got.mouseDragAnchor)752 }753 if got.mousePreview == nil || *got.mousePreview != (Rectangle{X: 0, Y: 0, W: 1, H: 1}) {754 t.Fatalf("mousePreview = %#v, want 1x1 preview at origin", got.mousePreview)755 }756}757758func TestMouseClickOnBorderMisses(t *testing.T) {759 m := Model{760 puzzle: Puzzle{761 Width: 5,762 Height: 5,763 Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}},764 },765 keys: DefaultKeyMap,766 modeTitle: "Test",767 termWidth: 120,768 termHeight: 40,769 }770771 x, y := cellScreenCoords(&m, 0, 0)772 next, _ := m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})773 got := next.(Model)774775 if got.lastMouseHit {776 t.Fatal("expected border click to miss")777 }778 if got.mouseDragAnchor != nil {779 t.Fatal("expected border click not to start drag")780 }781}782783func TestMouseDragReleasePlacesRectangle(t *testing.T) {784 m := Model{785 puzzle: *simplePuzzle(),786 keys: DefaultKeyMap,787 modeTitle: "Test",788 termWidth: 120,789 termHeight: 40,790 originValid: false,791 }792793 startX, startY := cellScreenCoords(&m, 0, 0)794 endX, endY := cellScreenCoords(&m, 1, 1)795796 next, _ := m.Update(tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})797 dragging := next.(Model)798 next, _ = dragging.Update(tea.MouseMotionMsg{X: endX, Y: endY, Button: tea.MouseLeft})799 dragging = next.(Model)800 next, _ = dragging.Update(tea.MouseReleaseMsg{X: endX, Y: endY, Button: tea.MouseLeft})801 got := next.(Model)802803 if got.mouseDragAnchor != nil || got.mousePreview != nil {804 t.Fatal("expected drag state to be cleared after release")805 }806 if r := got.puzzle.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}) {807 t.Fatalf("rectangle = %+v, want 2x2 rectangle for clue 0", r)808 }809}810811func TestMouseDragAcrossSeparatorsPlacesRectangle(t *testing.T) {812 m := Model{813 puzzle: *simplePuzzle(),814 keys: DefaultKeyMap,815 modeTitle: "Test",816 termWidth: 120,817 termHeight: 40,818 originValid: false,819 }820821 startX, startY := cellScreenCoords(&m, 0, 0)822 endX, endY := cellScreenCoords(&m, 1, 1)823824 next, _ := m.Update(tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})825 dragging := next.(Model)826 next, _ = dragging.Update(tea.MouseMotionMsg{X: endX - 1, Y: endY - 1, Button: tea.MouseLeft})827 dragging = next.(Model)828 next, _ = dragging.Update(tea.MouseReleaseMsg{X: endX - 1, Y: endY - 1, Button: tea.MouseLeft})829 got := next.(Model)830831 if got.selectedClue != nil {832 t.Fatal("expected drag release over separators not to fall into expansion mode")833 }834 if r := got.puzzle.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}) {835 t.Fatalf("rectangle = %+v, want 2x2 rectangle for clue 0", r)836 }837}838839func TestMouseRightClickDeletesRectangle(t *testing.T) {840 m := Model{841 puzzle: Puzzle{842 Width: 2,843 Height: 2,844 Clues: []Clue{845 {ID: 0, X: 0, Y: 0, Value: 2},846 {ID: 1, X: 1, Y: 1, Value: 2},847 },848 Rectangles: []Rectangle{849 {ClueID: 0, X: 0, Y: 0, W: 2, H: 1},850 },851 },852 keys: DefaultKeyMap,853 modeTitle: "Test",854 termWidth: 120,855 termHeight: 40,856 }857858 x, y := cellScreenCoords(&m, 0, 0)859 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseRight})860 got := next.(Model)861862 if got.puzzle.FindRectangleForClue(0) != nil {863 t.Fatal("expected right-click to delete rectangle")864 }865}866867func TestGridViewRendersCommittedRectangleBoundaries(t *testing.T) {868 m := Model{869 puzzle: Puzzle{870 Width: 4,871 Height: 2,872 Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}, {ID: 1, X: 2, Y: 0, Value: 4}},873 Rectangles: []Rectangle{874 {ClueID: 0, X: 0, Y: 0, W: 2, H: 2},875 {ClueID: 1, X: 2, Y: 0, W: 2, H: 2},876 },877 },878 }879880 lines := strings.Split(ansi.Strip(gridView(m, false)), "\n")881 content := []rune(lines[1])882 if got := content[4]; got != ' ' {883 t.Fatalf("interior separator inside rectangle = %q, want space", got)884 }885 if got := content[8]; got != '│' {886 t.Fatalf("separator between rectangles = %q, want vertical wall", got)887 }888889 boundary := []rune(lines[2])890 if got := boundary[1]; got != ' ' {891 t.Fatalf("row separator inside rectangle = %q, want space", got)892 }893}894895func TestGridViewPreviewRemovesCommittedWall(t *testing.T) {896 preview := &Rectangle{X: 0, Y: 0, W: 2, H: 1}897 m := Model{898 puzzle: Puzzle{899 Width: 2,900 Height: 1,901 Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 1}, {ID: 1, X: 1, Y: 0, Value: 1}},902 Rectangles: []Rectangle{903 {ClueID: 0, X: 0, Y: 0, W: 1, H: 1},904 {ClueID: 1, X: 1, Y: 0, W: 1, H: 1},905 },906 },907 mousePreview: preview,908 }909910 withPreview := []rune(strings.Split(ansi.Strip(gridView(m, false)), "\n")[1])911 if got := withPreview[4]; got != ' ' {912 t.Fatalf("preview separator = %q, want space", got)913 }914915 m.mousePreview = nil916 withoutPreview := []rune(strings.Split(ansi.Strip(gridView(m, false)), "\n")[1])917 if got := withoutPreview[4]; got != '│' {918 t.Fatalf("committed separator = %q, want vertical wall", got)919 }920}921922func TestGridViewUsesUniformRows(t *testing.T) {923 m := Model{924 puzzle: Puzzle{925 Width: 3,926 Height: 2,927 Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}, {ID: 1, X: 2, Y: 1, Value: 2}},928 Rectangles: []Rectangle{929 {ClueID: 0, X: 0, Y: 0, W: 2, H: 2},930 {ClueID: 1, X: 2, Y: 0, W: 1, H: 2},931 },932 },933 cursor: game.Cursor{X: 1, Y: 0},934 }935936 view := gridView(m, false)937 lines := strings.Split(view, "\n")938 if got, want := len(lines), m.puzzle.Height*2+1; got != want {939 t.Fatalf("grid line count = %d, want %d", got, want)940 }941942 wantWidth := lipgloss.Width(lines[0])943 for i, line := range lines {944 if width := lipgloss.Width(line); width != wantWidth {945 t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)946 }947 }948}949950func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) {951 m := Model{952 puzzle: Puzzle{Width: 1, Height: 1},953 cursor: game.Cursor{X: 0, Y: 0},954 }955956 got := cellView(m, 0, 0, nil, nil, false, false)957958 p := theme.Current()959 want := lipgloss.NewStyle().960 Width(cellWidth).961 AlignHorizontal(lipgloss.Center).962 Foreground(p.TextDim).963 Background(p.BG).964 Render(game.CursorLeft + "·" + game.CursorRight)965966 if got != want {967 t.Fatalf("cursor empty cell = %q, want %q", got, want)968 }969}970971func TestCellViewCursorPreservesSelectionColors(t *testing.T) {972 selected := 0973 m := Model{974 puzzle: Puzzle{975 Width: 1,976 Height: 1,977 Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}},978 },979 cursor: game.Cursor{X: 0, Y: 0},980 selectedClue: &selected,981 }982983 got := cellView(m, 0, 0, nil, nil, false, false)984985 bg := theme.Current().SelectionBG986 want := lipgloss.NewStyle().987 Width(cellWidth).988 AlignHorizontal(lipgloss.Center).989 Bold(true).990 Foreground(theme.TextOnBG(bg)).991 Background(bg).992 Render(game.CursorLeft + "4" + game.CursorRight)993994 if got != want {995 t.Fatalf("cursor selected clue = %q, want %q", got, want)996 }997}998999func TestCellViewCursorKeepsDoubleDigitClueVisible(t *testing.T) {1000 m := Model{1001 puzzle: Puzzle{1002 Width: 1,1003 Height: 1,1004 Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 12}},1005 },1006 cursor: game.Cursor{X: 0, Y: 0},1007 }10081009 got := cellView(m, 0, 0, nil, nil, false, false)10101011 p := theme.Current()1012 want := lipgloss.NewStyle().1013 Width(cellWidth).1014 AlignHorizontal(lipgloss.Center).1015 Bold(true).1016 Foreground(p.Given).1017 Background(p.BG).1018 Render(game.CursorLeft + "12")10191020 if got != want {1021 t.Fatalf("cursor double-digit clue = %q, want %q", got, want)1022 }1023}10241025func TestHelpToggleInvalidatesOriginCache(t *testing.T) {1026 m := Model{1027 puzzle: *simplePuzzle(),1028 keys: DefaultKeyMap,1029 modeTitle: "Test",1030 originX: 7,1031 originY: 11,1032 originValid: true,1033 }10341035 next, _ := m.Update(game.HelpToggleMsg{Show: true})1036 got := next.(Model)10371038 if !got.showFullHelp {1039 t.Fatal("expected showFullHelp to be true")1040 }1041 if got.originValid {1042 t.Fatal("expected origin cache to be invalidated")1043 }1044}10451046func TestViewWidthStableWhenFullHelpExpanded(t *testing.T) {1047 m := Model{1048 puzzle: *simplePuzzle(),1049 keys: DefaultKeyMap,1050 modeTitle: "Test",1051 }10521053 shortWidth := lipgloss.Width(m.View())1054 next, _ := m.Update(game.HelpToggleMsg{Show: true})1055 fullWidth := lipgloss.Width(next.(Model).View())10561057 if fullWidth > shortWidth {1058 t.Errorf("full-help width = %d, want <= %d", fullWidth, shortWidth)1059 }1060}10611062func TestViewHeightStableAcrossStatusModes(t *testing.T) {1063 selected := 01064 views := []Model{1065 {puzzle: *simplePuzzle(), keys: DefaultKeyMap, modeTitle: "Test"},1066 {puzzle: *simplePuzzle(), keys: DefaultKeyMap, modeTitle: "Test", showFullHelp: true},1067 {puzzle: *simplePuzzle(), keys: DefaultKeyMap, modeTitle: "Test", selectedClue: &selected},1068 {puzzle: *simplePuzzle(), keys: DefaultKeyMap, modeTitle: "Test", selectedClue: &selected, showFullHelp: true},1069 }10701071 wantHeight := lipgloss.Height(views[0].View())1072 for i, m := range views[1:] {1073 if got := lipgloss.Height(m.View()); got != wantHeight {1074 t.Fatalf("view %d height = %d, want %d", i+1, got, wantHeight)1075 }1076 }1077}10781079// --- rectFromCorners (P1) ---10801081func TestRectFromCorners(t *testing.T) {1082 tests := []struct {1083 name string1084 x1, y1, x2, y2 int1085 wantX, wantY, wantW, wantH int1086 }{1087 {"same cell", 3, 3, 3, 3, 3, 3, 1, 1},1088 {"drag right", 1, 2, 4, 2, 1, 2, 4, 1},1089 {"drag left", 4, 2, 1, 2, 1, 2, 4, 1},1090 {"drag down", 2, 1, 2, 5, 2, 1, 1, 5},1091 {"drag up", 2, 5, 2, 1, 2, 1, 1, 5},1092 {"drag down-right", 1, 1, 4, 3, 1, 1, 4, 3},1093 {"drag up-left", 4, 3, 1, 1, 1, 1, 4, 3},1094 }10951096 for _, tt := range tests {1097 t.Run(tt.name, func(t *testing.T) {1098 r := rectFromCorners(tt.x1, tt.y1, tt.x2, tt.y2)1099 if r.X != tt.wantX || r.Y != tt.wantY || r.W != tt.wantW || r.H != tt.wantH {1100 t.Errorf("rectFromCorners(%d,%d, %d,%d) = (%d,%d) %dx%d, want (%d,%d) %dx%d",1101 tt.x1, tt.y1, tt.x2, tt.y2,1102 r.X, r.Y, r.W, r.H,1103 tt.wantX, tt.wantY, tt.wantW, tt.wantH)1104 }1105 })1106 }1107}11081109// --- Backspace cancel behavior (P1) ---11101111func TestBackspaceCancelsPendingExpansion(t *testing.T) {1112 p := simplePuzzle()1113 selected := 01114 m := Model{1115 puzzle: *p,1116 selectedClue: &selected,1117 expansion: expansion{1118 clueID: 0,1119 right: 1,1120 },1121 keys: DefaultKeyMap,1122 }11231124 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})1125 got := next.(Model)11261127 if got.selectedClue != nil {1128 t.Fatal("expected selected clue to be cleared on backspace")1129 }1130}11311132func TestBackspaceCancelsPendingMousePreviewInNavMode(t *testing.T) {1133 p := simplePuzzle()1134 anchor := [2]int{0, 0}1135 preview := Rectangle{X: 0, Y: 0, W: 2, H: 2}1136 m := Model{1137 puzzle: *p,1138 keys: DefaultKeyMap,1139 mouseDragAnchor: &anchor,1140 mousePreview: &preview,1141 }11421143 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})1144 got := next.(Model)11451146 if got.mouseDragAnchor != nil {1147 t.Fatal("expected mouse drag anchor to be cleared on backspace")1148 }1149 if got.mousePreview != nil {1150 t.Fatal("expected mouse preview to be cleared on backspace")1151 }1152}11531154// --- CluesInRect (P1) ---11551156func TestCluesInRect(t *testing.T) {1157 p := simplePuzzle()11581159 tests := []struct {1160 name string1161 rect Rectangle1162 wantCount int1163 wantIDs []int1164 }{1165 {"single clue", Rectangle{X: 0, Y: 0, W: 1, H: 1}, 1, []int{0}},1166 {"no clues", Rectangle{X: 1, Y: 1, W: 1, H: 1}, 0, nil},1167 {"two clues", Rectangle{X: 0, Y: 0, W: 4, H: 1}, 2, []int{0, 3}},1168 {"all clues", Rectangle{X: 0, Y: 0, W: 4, H: 4}, 4, []int{0, 1, 2, 3}},1169 }11701171 for _, tt := range tests {1172 t.Run(tt.name, func(t *testing.T) {1173 clues := p.CluesInRect(tt.rect)1174 if len(clues) != tt.wantCount {1175 t.Fatalf("CluesInRect returned %d clues, want %d", len(clues), tt.wantCount)1176 }1177 for i, c := range clues {1178 if c.ID != tt.wantIDs[i] {1179 t.Errorf("clue[%d].ID = %d, want %d", i, c.ID, tt.wantIDs[i])1180 }1181 }1182 })1183 }1184}11851186// --- Mode Spawn ---11871188func TestModeSpawn(t *testing.T) {1189 mode := NewMode("Test", "test mode", 5, 5, 5)11901191 t.Run("Spawn", func(t *testing.T) {1192 g, err := mode.Spawn()1193 if err != nil {1194 t.Fatal(err)1195 }1196 if g == nil {1197 t.Fatal("expected non-nil game")1198 }1199 if g.IsSolved() {1200 t.Error("new game should not be solved")1201 }1202 })12031204 t.Run("SpawnSeeded", func(t *testing.T) {1205 rng := rand.New(rand.NewPCG(123, 456))1206 g, err := mode.SpawnSeeded(rng)1207 if err != nil {1208 t.Fatal(err)1209 }1210 if g == nil {1211 t.Fatal("expected non-nil game")1212 }1213 })1214}12151216func cellScreenCoords(m *Model, col, row int) (int, int) {1217 ox, oy := m.gridOrigin()1218 return ox + col*(cellWidth+1), oy + row*21219}