- commit
- cd22638aa0e504b8889900fd1d548225530679ba
- parent
- dee3a53964aff46038fa03b8caad78babeeb474f
- author
- Dami <dami.etoile@gmail.com>
| A | game/ | 366 | +++++++++++ |
| A | hashiwokakero/ | 994 | ++++++++++++++++++++++++++++++ |
| A | nonogram/ | 582 | ++++++++++++++++++ |
| A | sudoku/ | 657 | ++++++++++++++++++++ |
| M | takuzu/ | 2 | +- |
| A | takuzu/ | 918 | ++++++++++++++++++++++++++++ |
@@ -0,0 +1,366 @@
1+package game2+3+import (4+ "strings"5+ "testing"6+7+ tea "github.com/charmbracelet/bubbletea"8+)9+10+// --- Cursor.Move (P0) ---11+12+func TestCursorMove(t *testing.T) {13+ keys := DefaultCursorKeyMap14+15+ keyMsg := func(k string) tea.KeyMsg {16+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}17+ }18+ specialKeyMsg := func(k tea.KeyType) tea.KeyMsg {19+ return tea.KeyMsg{Type: k}20+ }21+22+ t.Run("movement within bounds", func(t *testing.T) {23+ tests := []struct {24+ name string25+ startX int26+ startY int27+ msg tea.KeyMsg28+ maxX int29+ maxY int30+ wantX int31+ wantY int32+ wantMoved bool33+ }{34+ {"move up within bounds", 2, 2, specialKeyMsg(tea.KeyUp), 4, 4, 2, 1, true},35+ {"move down within bounds", 2, 2, specialKeyMsg(tea.KeyDown), 4, 4, 2, 3, true},36+ {"move left within bounds", 2, 2, specialKeyMsg(tea.KeyLeft), 4, 4, 1, 2, true},37+ {"move right within bounds", 2, 2, specialKeyMsg(tea.KeyRight), 4, 4, 3, 2, true},38+ }39+40+ for _, tt := range tests {41+ t.Run(tt.name, func(t *testing.T) {42+ c := Cursor{X: tt.startX, Y: tt.startY}43+ moved := c.Move(keys, tt.msg, tt.maxX, tt.maxY)44+ if moved != tt.wantMoved {45+ t.Errorf("Move() = %v, want %v", moved, tt.wantMoved)46+ }47+ if c.X != tt.wantX || c.Y != tt.wantY {48+ t.Errorf("cursor = (%d,%d), want (%d,%d)", c.X, c.Y, tt.wantX, tt.wantY)49+ }50+ })51+ }52+ })53+54+ t.Run("blocked at edges", func(t *testing.T) {55+ tests := []struct {56+ name string57+ startX int58+ startY int59+ msg tea.KeyMsg60+ maxX int61+ maxY int62+ }{63+ {"blocked at top edge", 2, 0, specialKeyMsg(tea.KeyUp), 4, 4},64+ {"blocked at bottom edge", 2, 4, specialKeyMsg(tea.KeyDown), 4, 4},65+ {"blocked at left edge", 0, 2, specialKeyMsg(tea.KeyLeft), 4, 4},66+ {"blocked at right edge", 4, 2, specialKeyMsg(tea.KeyRight), 4, 4},67+ }68+69+ for _, tt := range tests {70+ t.Run(tt.name, func(t *testing.T) {71+ c := Cursor{X: tt.startX, Y: tt.startY}72+ moved := c.Move(keys, tt.msg, tt.maxX, tt.maxY)73+ if moved {74+ t.Error("Move() = true, want false at edge")75+ }76+ if c.X != tt.startX || c.Y != tt.startY {77+ t.Errorf("cursor moved to (%d,%d), want (%d,%d)", c.X, c.Y, tt.startX, tt.startY)78+ }79+ })80+ }81+ })82+83+ t.Run("1x1 grid no movement", func(t *testing.T) {84+ directions := []tea.KeyMsg{85+ specialKeyMsg(tea.KeyUp),86+ specialKeyMsg(tea.KeyDown),87+ specialKeyMsg(tea.KeyLeft),88+ specialKeyMsg(tea.KeyRight),89+ }90+ for _, msg := range directions {91+ c := Cursor{X: 0, Y: 0}92+ moved := c.Move(keys, msg, 0, 0)93+ if moved {94+ t.Errorf("Move() = true on 1x1 grid for key %v", msg)95+ }96+ if c.X != 0 || c.Y != 0 {97+ t.Errorf("cursor = (%d,%d), want (0,0)", c.X, c.Y)98+ }99+ }100+ })101+102+ t.Run("arrow keys work", func(t *testing.T) {103+ tests := []struct {104+ msg tea.KeyMsg105+ wantX int106+ wantY int107+ }{108+ {specialKeyMsg(tea.KeyUp), 2, 1},109+ {specialKeyMsg(tea.KeyDown), 2, 3},110+ {specialKeyMsg(tea.KeyLeft), 1, 2},111+ {specialKeyMsg(tea.KeyRight), 3, 2},112+ }113+ for _, tt := range tests {114+ c := Cursor{X: 2, Y: 2}115+ c.Move(keys, tt.msg, 4, 4)116+ if c.X != tt.wantX || c.Y != tt.wantY {117+ t.Errorf("arrow key: cursor = (%d,%d), want (%d,%d)", c.X, c.Y, tt.wantX, tt.wantY)118+ }119+ }120+ })121+122+ t.Run("WASD keys work", func(t *testing.T) {123+ tests := []struct {124+ key string125+ wantX int126+ wantY int127+ }{128+ {"w", 2, 1},129+ {"s", 2, 3},130+ {"a", 1, 2},131+ {"d", 3, 2},132+ }133+ for _, tt := range tests {134+ c := Cursor{X: 2, Y: 2}135+ c.Move(keys, keyMsg(tt.key), 4, 4)136+ if c.X != tt.wantX || c.Y != tt.wantY {137+ t.Errorf("WASD %q: cursor = (%d,%d), want (%d,%d)", tt.key, c.X, c.Y, tt.wantX, tt.wantY)138+ }139+ }140+ })141+142+ t.Run("vim keys work", func(t *testing.T) {143+ tests := []struct {144+ key string145+ wantX int146+ wantY int147+ }{148+ {"k", 2, 1},149+ {"j", 2, 3},150+ {"h", 1, 2},151+ {"l", 3, 2},152+ }153+ for _, tt := range tests {154+ c := Cursor{X: 2, Y: 2}155+ c.Move(keys, keyMsg(tt.key), 4, 4)156+ if c.X != tt.wantX || c.Y != tt.wantY {157+ t.Errorf("vim %q: cursor = (%d,%d), want (%d,%d)", tt.key, c.X, c.Y, tt.wantX, tt.wantY)158+ }159+ }160+ })161+162+ t.Run("unrelated key no movement", func(t *testing.T) {163+ c := Cursor{X: 2, Y: 2}164+ moved := c.Move(keys, specialKeyMsg(tea.KeyEnter), 4, 4)165+ if moved {166+ t.Error("Move() = true for unrelated key, want false")167+ }168+ if c.X != 2 || c.Y != 2 {169+ t.Errorf("cursor = (%d,%d), want (2,2)", c.X, c.Y)170+ }171+ })172+}173+174+// --- BaseMode (P1) ---175+176+func TestBaseMode(t *testing.T) {177+ t.Run("normal values", func(t *testing.T) {178+ b := NewBaseMode("Easy", "5x5 grid")179+ if got := b.Title(); got != "Easy" {180+ t.Errorf("Title() = %q, want %q", got, "Easy")181+ }182+ if got := b.Description(); got != "5x5 grid" {183+ t.Errorf("Description() = %q, want %q", got, "5x5 grid")184+ }185+ if got := b.FilterValue(); got != "Easy 5x5 grid" {186+ t.Errorf("FilterValue() = %q, want %q", got, "Easy 5x5 grid")187+ }188+ })189+190+ t.Run("empty strings", func(t *testing.T) {191+ b := NewBaseMode("", "")192+ if got := b.Title(); got != "" {193+ t.Errorf("Title() = %q, want %q", got, "")194+ }195+ if got := b.Description(); got != "" {196+ t.Errorf("Description() = %q, want %q", got, "")197+ }198+ if got := b.FilterValue(); got != " " {199+ t.Errorf("FilterValue() = %q, want %q", got, " ")200+ }201+ })202+}203+204+// --- Category (P3) ---205+206+func TestCategory(t *testing.T) {207+ c := Category{Name: "Sudoku", Desc: "Classic"}208+209+ if got := c.Title(); got != "Sudoku" {210+ t.Errorf("Title() = %q, want %q", got, "Sudoku")211+ }212+ if got := c.Description(); got != "Classic" {213+ t.Errorf("Description() = %q, want %q", got, "Classic")214+ }215+ if got := c.FilterValue(); got != "Sudoku" {216+ t.Errorf("FilterValue() = %q, want %q", got, "Sudoku")217+ }218+}219+220+// --- Registry (P1) ---221+222+func TestRegistry(t *testing.T) {223+ // Save and restore the global registry to avoid pollution.224+ origRegistry := make(map[string]func([]byte) (Gamer, error))225+ for k, v := range Registry {226+ origRegistry[k] = v227+ }228+ t.Cleanup(func() {229+ Registry = origRegistry230+ })231+232+ t.Run("Register adds function", func(t *testing.T) {233+ called := false234+ fn := func(data []byte) (Gamer, error) {235+ called = true236+ return nil, nil237+ }238+ Register("test-game", fn)239+ if Registry["test-game"] == nil {240+ t.Fatal("expected registry entry to be set")241+ }242+ if _, err := Registry["test-game"](nil); err != nil {243+ t.Fatal(err)244+ }245+ if !called {246+ t.Error("expected registered function to be called")247+ }248+ })249+250+ t.Run("Register overwrites", func(t *testing.T) {251+ first := false252+ second := false253+ Register("overwrite", func([]byte) (Gamer, error) {254+ first = true255+ return nil, nil256+ })257+ Register("overwrite", func([]byte) (Gamer, error) {258+ second = true259+ return nil, nil260+ })261+ if _, err := Registry["overwrite"](nil); err != nil {262+ t.Fatal(err)263+ }264+ if first {265+ t.Error("first function should not have been called")266+ }267+ if !second {268+ t.Error("second function should have been called")269+ }270+ })271+272+ t.Run("lookup missing key", func(t *testing.T) {273+ if Registry["nonexistent"] != nil {274+ t.Error("expected nil for missing key")275+ }276+ })277+}278+279+// --- TitleBarView (P3) ---280+281+func TestTitleBarView(t *testing.T) {282+ t.Run("unsolved game", func(t *testing.T) {283+ result := TitleBarView("Sudoku", "Easy", false)284+ if !strings.Contains(result, "Sudoku") {285+ t.Error("expected result to contain game name")286+ }287+ if !strings.Contains(result, "Easy") {288+ t.Error("expected result to contain mode name")289+ }290+ if strings.Contains(result, "SOLVED") {291+ t.Error("expected result to not contain SOLVED")292+ }293+ })294+295+ t.Run("solved game", func(t *testing.T) {296+ result := TitleBarView("Sudoku", "Easy", true)297+ if !strings.Contains(result, "SOLVED") {298+ t.Error("expected result to contain SOLVED")299+ }300+ })301+}302+303+// --- DebugHeader (P3) ---304+305+func TestDebugHeader(t *testing.T) {306+ t.Run("with rows", func(t *testing.T) {307+ rows := [][2]string{308+ {"Status", "In Progress"},309+ {"Size", "5x5"},310+ }311+ result := DebugHeader("TestGame", rows)312+ if !strings.Contains(result, "TestGame") {313+ t.Error("expected result to contain title")314+ }315+ if !strings.Contains(result, "Status") || !strings.Contains(result, "In Progress") {316+ t.Error("expected result to contain first key-value pair")317+ }318+ if !strings.Contains(result, "Size") || !strings.Contains(result, "5x5") {319+ t.Error("expected result to contain second key-value pair")320+ }321+ })322+323+ t.Run("empty rows", func(t *testing.T) {324+ result := DebugHeader("TestGame", nil)325+ if !strings.Contains(result, "TestGame") {326+ t.Error("expected result to contain title")327+ }328+ })329+}330+331+// --- DebugTable (P3) ---332+333+func TestDebugTable(t *testing.T) {334+ t.Run("normal table", func(t *testing.T) {335+ headers := []string{"Col", "Hint", "Current"}336+ rows := [][]string{337+ {"0", "1,2", "1,2"},338+ {"1", "3", "3"},339+ }340+ result := DebugTable("Tomography", headers, rows)341+ if !strings.Contains(result, "Tomography") {342+ t.Error("expected result to contain heading")343+ }344+ for _, h := range headers {345+ if !strings.Contains(result, h) {346+ t.Errorf("expected result to contain header %q", h)347+ }348+ }349+ if !strings.Contains(result, "1,2") {350+ t.Error("expected result to contain row values")351+ }352+ })353+354+ t.Run("empty rows", func(t *testing.T) {355+ headers := []string{"Col", "Hint"}356+ result := DebugTable("Heading", headers, nil)357+ if !strings.Contains(result, "Heading") {358+ t.Error("expected result to contain heading")359+ }360+ for _, h := range headers {361+ if !strings.Contains(result, h) {362+ t.Errorf("expected result to contain header %q", h)363+ }364+ }365+ })366+}@@ -0,0 +1,994 @@
1+package hashiwokakero2+3+import (4+ "encoding/json"5+ "testing"6+)7+8+// --- helpers ---9+10+// fourCornerPuzzle builds a 4x4 puzzle with islands at the four corners:11+//12+// 0 . . 113+// . . . .14+// . . . .15+// 2 . . 316+func fourCornerPuzzle() *Puzzle {17+ return &Puzzle{18+ Width: 4,19+ Height: 4,20+ Islands: []Island{21+ {ID: 0, X: 0, Y: 0, Required: 2},22+ {ID: 1, X: 3, Y: 0, Required: 2},23+ {ID: 2, X: 0, Y: 3, Required: 2},24+ {ID: 3, X: 3, Y: 3, Required: 2},25+ },26+ }27+}28+29+// linePuzzle builds a horizontal line of islands: 0 -- 1 -- 2 -- 330+func linePuzzle() *Puzzle {31+ return &Puzzle{32+ Width: 7,33+ Height: 1,34+ Islands: []Island{35+ {ID: 0, X: 0, Y: 0, Required: 1},36+ {ID: 1, X: 2, Y: 0, Required: 2},37+ {ID: 2, X: 4, Y: 0, Required: 2},38+ {ID: 3, X: 6, Y: 0, Required: 1},39+ },40+ }41+}42+43+// --- GetBridge (P0) ---44+45+func TestGetBridge(t *testing.T) {46+ t.Run("bridge exists", func(t *testing.T) {47+ p := fourCornerPuzzle()48+ p.SetBridge(0, 1, 1)49+50+ b := p.GetBridge(0, 1)51+ if b == nil {52+ t.Fatal("expected bridge, got nil")53+ }54+ if b.Count != 1 {55+ t.Errorf("Count = %d, want 1", b.Count)56+ }57+ })58+59+ t.Run("order independence", func(t *testing.T) {60+ p := fourCornerPuzzle()61+ p.SetBridge(0, 1, 1)62+63+ b := p.GetBridge(1, 0)64+ if b == nil {65+ t.Fatal("GetBridge(1,0) should find bridge set as (0,1)")66+ }67+ if b.Count != 1 {68+ t.Errorf("Count = %d, want 1", b.Count)69+ }70+ })71+72+ t.Run("no bridge", func(t *testing.T) {73+ p := fourCornerPuzzle()74+ p.SetBridge(0, 1, 1)75+76+ if b := p.GetBridge(0, 2); b != nil {77+ t.Errorf("expected nil, got bridge with Count=%d", b.Count)78+ }79+ })80+}81+82+// --- SetBridge (P0) ---83+84+func TestSetBridge(t *testing.T) {85+ t.Run("create single bridge", func(t *testing.T) {86+ p := fourCornerPuzzle()87+ p.SetBridge(0, 1, 1)88+89+ b := p.GetBridge(0, 1)90+ if b == nil || b.Count != 1 {91+ t.Fatalf("expected Count=1, got %v", b)92+ }93+ })94+95+ t.Run("create double bridge", func(t *testing.T) {96+ p := fourCornerPuzzle()97+ p.SetBridge(0, 1, 2)98+99+ b := p.GetBridge(0, 1)100+ if b == nil || b.Count != 2 {101+ t.Fatalf("expected Count=2, got %v", b)102+ }103+ })104+105+ t.Run("update bridge", func(t *testing.T) {106+ p := fourCornerPuzzle()107+ p.SetBridge(0, 1, 1)108+ p.SetBridge(0, 1, 2)109+110+ b := p.GetBridge(0, 1)111+ if b == nil || b.Count != 2 {112+ t.Fatalf("expected Count=2 after update, got %v", b)113+ }114+ })115+116+ t.Run("remove bridge", func(t *testing.T) {117+ p := fourCornerPuzzle()118+ p.SetBridge(0, 1, 1)119+ p.SetBridge(0, 1, 0)120+121+ if b := p.GetBridge(0, 1); b != nil {122+ t.Errorf("expected bridge removed, got Count=%d", b.Count)123+ }124+ })125+126+ t.Run("remove nonexistent", func(t *testing.T) {127+ p := fourCornerPuzzle()128+ p.SetBridge(5, 6, 0) // must not panic129+ })130+131+ t.Run("normalizes order", func(t *testing.T) {132+ p := fourCornerPuzzle()133+ p.SetBridge(3, 1, 1)134+135+ if len(p.Bridges) != 1 {136+ t.Fatalf("expected 1 bridge, got %d", len(p.Bridges))137+ }138+ if p.Bridges[0].Island1 != 1 || p.Bridges[0].Island2 != 3 {139+ t.Errorf("bridge stored as (%d,%d), want (1,3)", p.Bridges[0].Island1, p.Bridges[0].Island2)140+ }141+ })142+143+ t.Run("invalidates caches", func(t *testing.T) {144+ p := fourCornerPuzzle()145+ // Trigger cache builds.146+ _ = p.BridgeCount(0)147+ _ = p.FindIslandAt(0, 0)148+ _ = p.CellContent(0, 0)149+150+ p.SetBridge(0, 1, 1)151+152+ if p.cellCache != nil {153+ t.Error("cellCache should be nil after SetBridge")154+ }155+ if p.posIndex != nil {156+ t.Error("posIndex should be nil after SetBridge")157+ }158+ if p.bridgeCounts != nil {159+ t.Error("bridgeCounts should be nil after SetBridge")160+ }161+ })162+}163+164+// --- BridgeCount (P0) ---165+166+func TestBridgeCount(t *testing.T) {167+ t.Run("single bridge", func(t *testing.T) {168+ p := fourCornerPuzzle()169+ p.SetBridge(0, 1, 1)170+171+ if got := p.BridgeCount(0); got != 1 {172+ t.Errorf("BridgeCount(0) = %d, want 1", got)173+ }174+ })175+176+ t.Run("double bridge", func(t *testing.T) {177+ p := fourCornerPuzzle()178+ p.SetBridge(0, 1, 2)179+180+ if got := p.BridgeCount(0); got != 2 {181+ t.Errorf("BridgeCount(0) = %d, want 2", got)182+ }183+ })184+185+ t.Run("multiple bridges", func(t *testing.T) {186+ p := fourCornerPuzzle()187+ p.SetBridge(0, 1, 1)188+ p.SetBridge(0, 2, 2)189+190+ if got := p.BridgeCount(0); got != 3 {191+ t.Errorf("BridgeCount(0) = %d, want 3", got)192+ }193+ })194+195+ t.Run("no bridges", func(t *testing.T) {196+ p := fourCornerPuzzle()197+198+ if got := p.BridgeCount(0); got != 0 {199+ t.Errorf("BridgeCount(0) = %d, want 0", got)200+ }201+ })202+203+ t.Run("invalid ID", func(t *testing.T) {204+ p := fourCornerPuzzle()205+206+ if got := p.BridgeCount(999); got != 0 {207+ t.Errorf("BridgeCount(999) = %d, want 0", got)208+ }209+ })210+211+ t.Run("counts both endpoints", func(t *testing.T) {212+ p := fourCornerPuzzle()213+ p.SetBridge(0, 1, 1)214+215+ if got := p.BridgeCount(1); got != 1 {216+ t.Errorf("BridgeCount(1) = %d, want 1", got)217+ }218+ })219+}220+221+// --- WouldCross (P0) ---222+223+func TestWouldCross(t *testing.T) {224+ t.Run("no crossing parallel horizontal", func(t *testing.T) {225+ // Two horizontal bridges on different rows can't cross.226+ // 0 --- 1227+ // 2 --- 3228+ p := fourCornerPuzzle()229+ p.SetBridge(0, 1, 1) // horizontal top230+ if p.WouldCross(2, 3) {231+ t.Error("parallel horizontal bridges should not cross")232+ }233+ })234+235+ t.Run("no crossing parallel vertical", func(t *testing.T) {236+ // Two vertical bridges on different columns.237+ p := fourCornerPuzzle()238+ p.SetBridge(0, 2, 1) // vertical left239+ if p.WouldCross(1, 3) {240+ t.Error("parallel vertical bridges should not cross")241+ }242+ })243+244+ t.Run("horizontal crosses vertical", func(t *testing.T) {245+ // Cross pattern: vertical bridge 0-2, then check horizontal 1-? crossing it.246+ // Need a puzzle with an island in the middle area.247+ p := &Puzzle{248+ Width: 5,249+ Height: 5,250+ Islands: []Island{251+ {ID: 0, X: 2, Y: 0, Required: 2},252+ {ID: 1, X: 0, Y: 2, Required: 2},253+ {ID: 2, X: 2, Y: 4, Required: 2},254+ {ID: 3, X: 4, Y: 2, Required: 2},255+ },256+ }257+ p.SetBridge(0, 2, 1) // vertical bridge through (2,1),(2,2),(2,3)258+ if !p.WouldCross(1, 3) {259+ t.Error("horizontal bridge should cross existing vertical bridge")260+ }261+ })262+263+ t.Run("vertical crosses horizontal", func(t *testing.T) {264+ p := &Puzzle{265+ Width: 5,266+ Height: 5,267+ Islands: []Island{268+ {ID: 0, X: 2, Y: 0, Required: 2},269+ {ID: 1, X: 0, Y: 2, Required: 2},270+ {ID: 2, X: 2, Y: 4, Required: 2},271+ {ID: 3, X: 4, Y: 2, Required: 2},272+ },273+ }274+ p.SetBridge(1, 3, 1) // horizontal bridge through (1,2),(2,2),(3,2)275+ if !p.WouldCross(0, 2) {276+ t.Error("vertical bridge should cross existing horizontal bridge")277+ }278+ })279+280+ t.Run("island in path", func(t *testing.T) {281+ // Island sitting between two endpoints blocks the bridge.282+ p := &Puzzle{283+ Width: 5,284+ Height: 1,285+ Islands: []Island{286+ {ID: 0, X: 0, Y: 0, Required: 2},287+ {ID: 1, X: 2, Y: 0, Required: 2},288+ {ID: 2, X: 4, Y: 0, Required: 2},289+ },290+ }291+ if !p.WouldCross(0, 2) {292+ t.Error("bridge should be blocked by island in the middle")293+ }294+ })295+296+ t.Run("endpoints dont count as crossing", func(t *testing.T) {297+ p := fourCornerPuzzle()298+ // No bridges set — the endpoints themselves are not obstacles.299+ if p.WouldCross(0, 1) {300+ t.Error("empty path between endpoints should not cross")301+ }302+ })303+304+ t.Run("non-aligned islands", func(t *testing.T) {305+ p := &Puzzle{306+ Width: 4,307+ Height: 4,308+ Islands: []Island{309+ {ID: 0, X: 0, Y: 0, Required: 1},310+ {ID: 1, X: 1, Y: 1, Required: 1},311+ },312+ }313+ if !p.WouldCross(0, 1) {314+ t.Error("diagonal (non-aligned) islands should return true")315+ }316+ })317+318+ t.Run("nil island", func(t *testing.T) {319+ p := fourCornerPuzzle()320+ if !p.WouldCross(0, 99) {321+ t.Error("bad island ID should return true")322+ }323+ })324+325+ t.Run("adjacent islands no obstacles", func(t *testing.T) {326+ p := &Puzzle{327+ Width: 3,328+ Height: 1,329+ Islands: []Island{330+ {ID: 0, X: 0, Y: 0, Required: 1},331+ {ID: 1, X: 1, Y: 0, Required: 1},332+ },333+ }334+ if p.WouldCross(0, 1) {335+ t.Error("adjacent islands with nothing between should not cross")336+ }337+ })338+}339+340+// --- IsConnected (P0) ---341+342+func TestIsConnected(t *testing.T) {343+ t.Run("all connected chain", func(t *testing.T) {344+ p := linePuzzle()345+ p.SetBridge(0, 1, 1)346+ p.SetBridge(1, 2, 1)347+ p.SetBridge(2, 3, 1)348+349+ if !p.IsConnected() {350+ t.Error("chain of bridges should be connected")351+ }352+ })353+354+ t.Run("disconnected pair", func(t *testing.T) {355+ p := fourCornerPuzzle()356+ p.SetBridge(0, 1, 1) // only top two connected357+358+ if p.IsConnected() {359+ t.Error("only two of four islands connected should not be connected")360+ }361+ })362+363+ t.Run("single island", func(t *testing.T) {364+ p := &Puzzle{365+ Width: 3,366+ Height: 3,367+ Islands: []Island{{ID: 0, X: 1, Y: 1, Required: 0}},368+ }369+ if !p.IsConnected() {370+ t.Error("single island should be connected")371+ }372+ })373+374+ t.Run("no bridges multiple islands", func(t *testing.T) {375+ p := fourCornerPuzzle()376+ if p.IsConnected() {377+ t.Error("multiple islands with no bridges should not be connected")378+ }379+ })380+381+ t.Run("multiple components", func(t *testing.T) {382+ p := fourCornerPuzzle()383+ p.SetBridge(0, 1, 1) // top pair384+ p.SetBridge(2, 3, 1) // bottom pair385+386+ if p.IsConnected() {387+ t.Error("two separate pairs should not be connected")388+ }389+ })390+391+ t.Run("star topology", func(t *testing.T) {392+ // Center island connected to 4 spokes.393+ p := &Puzzle{394+ Width: 5,395+ Height: 5,396+ Islands: []Island{397+ {ID: 0, X: 2, Y: 2, Required: 4},398+ {ID: 1, X: 2, Y: 0, Required: 1},399+ {ID: 2, X: 4, Y: 2, Required: 1},400+ {ID: 3, X: 2, Y: 4, Required: 1},401+ {ID: 4, X: 0, Y: 2, Required: 1},402+ },403+ }404+ p.SetBridge(0, 1, 1)405+ p.SetBridge(0, 2, 1)406+ p.SetBridge(0, 3, 1)407+ p.SetBridge(0, 4, 1)408+409+ if !p.IsConnected() {410+ t.Error("star topology should be connected")411+ }412+ })413+}414+415+// --- IsSolved (P0) ---416+417+func TestIsSolved(t *testing.T) {418+ t.Run("fully solved", func(t *testing.T) {419+ // Simple: two islands requiring 1 bridge each.420+ p := &Puzzle{421+ Width: 3,422+ Height: 1,423+ Islands: []Island{424+ {ID: 0, X: 0, Y: 0, Required: 1},425+ {ID: 1, X: 2, Y: 0, Required: 1},426+ },427+ }428+ p.SetBridge(0, 1, 1)429+430+ if !p.IsSolved() {431+ t.Error("puzzle should be solved")432+ }433+ })434+435+ t.Run("satisfied but disconnected", func(t *testing.T) {436+ // Four islands: each requires 1 bridge. Connect as two pairs.437+ p := &Puzzle{438+ Width: 5,439+ Height: 5,440+ Islands: []Island{441+ {ID: 0, X: 0, Y: 0, Required: 1},442+ {ID: 1, X: 2, Y: 0, Required: 1},443+ {ID: 2, X: 0, Y: 4, Required: 1},444+ {ID: 3, X: 2, Y: 4, Required: 1},445+ },446+ }447+ p.SetBridge(0, 1, 1)448+ p.SetBridge(2, 3, 1)449+450+ if p.IsSolved() {451+ t.Error("satisfied but disconnected should not be solved")452+ }453+ })454+455+ t.Run("connected but unsatisfied", func(t *testing.T) {456+ p := &Puzzle{457+ Width: 3,458+ Height: 1,459+ Islands: []Island{460+ {ID: 0, X: 0, Y: 0, Required: 2},461+ {ID: 1, X: 2, Y: 0, Required: 2},462+ },463+ }464+ p.SetBridge(0, 1, 1) // only 1, need 2465+466+ if p.IsSolved() {467+ t.Error("connected but unsatisfied should not be solved")468+ }469+ })470+471+ t.Run("over-satisfied", func(t *testing.T) {472+ p := &Puzzle{473+ Width: 3,474+ Height: 1,475+ Islands: []Island{476+ {ID: 0, X: 0, Y: 0, Required: 1},477+ {ID: 1, X: 2, Y: 0, Required: 1},478+ },479+ }480+ p.SetBridge(0, 1, 2)481+482+ if p.IsSolved() {483+ t.Error("over-satisfied should not be solved")484+ }485+ })486+487+ t.Run("empty puzzle no bridges", func(t *testing.T) {488+ p := fourCornerPuzzle()489+ if p.IsSolved() {490+ t.Error("no bridges placed should not be solved")491+ }492+ })493+}494+495+// --- FindIslandAt (P1) ---496+497+func TestFindIslandAt(t *testing.T) {498+ t.Run("island exists", func(t *testing.T) {499+ p := fourCornerPuzzle()500+ isl := p.FindIslandAt(3, 0)501+ if isl == nil {502+ t.Fatal("expected island at (3,0)")503+ }504+ if isl.ID != 1 {505+ t.Errorf("ID = %d, want 1", isl.ID)506+ }507+ })508+509+ t.Run("no island", func(t *testing.T) {510+ p := fourCornerPuzzle()511+ if isl := p.FindIslandAt(1, 1); isl != nil {512+ t.Errorf("expected nil at (1,1), got island %d", isl.ID)513+ }514+ })515+516+ t.Run("lazy index build", func(t *testing.T) {517+ p := fourCornerPuzzle()518+ if p.posIndex != nil {519+ t.Fatal("posIndex should be nil before first call")520+ }521+ _ = p.FindIslandAt(0, 0)522+ if p.posIndex == nil {523+ t.Error("posIndex should be built after first call")524+ }525+ // Subsequent call should use the same index.526+ _ = p.FindIslandAt(3, 3)527+ if p.posIndex == nil {528+ t.Error("posIndex should persist across calls")529+ }530+ })531+}532+533+// --- FindIslandByID (P1) ---534+535+func TestFindIslandByID(t *testing.T) {536+ t.Run("sequential IDs", func(t *testing.T) {537+ p := fourCornerPuzzle()538+ isl := p.FindIslandByID(2)539+ if isl == nil {540+ t.Fatal("expected island with ID=2")541+ }542+ if isl.X != 0 || isl.Y != 3 {543+ t.Errorf("island 2 at (%d,%d), want (0,3)", isl.X, isl.Y)544+ }545+ })546+547+ t.Run("invalid ID", func(t *testing.T) {548+ p := fourCornerPuzzle()549+ if isl := p.FindIslandByID(99); isl != nil {550+ t.Errorf("expected nil, got island %d", isl.ID)551+ }552+ })553+554+ t.Run("non-sequential fallback", func(t *testing.T) {555+ p := &Puzzle{556+ Width: 3,557+ Height: 3,558+ Islands: []Island{559+ {ID: 10, X: 0, Y: 0, Required: 1},560+ {ID: 20, X: 2, Y: 0, Required: 1},561+ {ID: 30, X: 0, Y: 2, Required: 1},562+ },563+ }564+ isl := p.FindIslandByID(20)565+ if isl == nil {566+ t.Fatal("expected island with ID=20")567+ }568+ if isl.X != 2 || isl.Y != 0 {569+ t.Errorf("island 20 at (%d,%d), want (2,0)", isl.X, isl.Y)570+ }571+ })572+}573+574+// --- FindAdjacentIsland (P1) ---575+576+func TestFindAdjacentIsland(t *testing.T) {577+ t.Run("finds island to the right", func(t *testing.T) {578+ p := fourCornerPuzzle()579+ adj := p.FindAdjacentIsland(0, 1, 0)580+ if adj == nil || adj.ID != 1 {581+ t.Errorf("expected island 1, got %v", adj)582+ }583+ })584+585+ t.Run("finds island below", func(t *testing.T) {586+ p := fourCornerPuzzle()587+ adj := p.FindAdjacentIsland(0, 0, 1)588+ if adj == nil || adj.ID != 2 {589+ t.Errorf("expected island 2, got %v", adj)590+ }591+ })592+593+ t.Run("no island in direction", func(t *testing.T) {594+ p := fourCornerPuzzle()595+ adj := p.FindAdjacentIsland(0, -1, 0) // left from leftmost596+ if adj != nil {597+ t.Errorf("expected nil, got island %d", adj.ID)598+ }599+ })600+601+ t.Run("stops at grid edge", func(t *testing.T) {602+ p := fourCornerPuzzle()603+ adj := p.FindAdjacentIsland(0, 0, -1) // up from top604+ if adj != nil {605+ t.Errorf("expected nil, got island %d", adj.ID)606+ }607+ })608+609+ t.Run("blocked by perpendicular bridge", func(t *testing.T) {610+ // Cross puzzle:611+ // . 0 .612+ // 1 . 2613+ // . 3 .614+ p := &Puzzle{615+ Width: 3,616+ Height: 3,617+ Islands: []Island{618+ {ID: 0, X: 1, Y: 0, Required: 1},619+ {ID: 1, X: 0, Y: 1, Required: 1},620+ {ID: 2, X: 2, Y: 1, Required: 1},621+ {ID: 3, X: 1, Y: 2, Required: 1},622+ },623+ }624+ p.SetBridge(1, 2, 1) // horizontal bridge through (1,1)625+626+ // Vertical ray from 0 downward should be blocked at (1,1).627+ adj := p.FindAdjacentIsland(0, 0, 1)628+ if adj != nil {629+ t.Errorf("expected nil (blocked by H bridge), got island %d", adj.ID)630+ }631+ })632+633+ t.Run("not blocked by parallel bridge", func(t *testing.T) {634+ // Two islands vertically aligned with a vertical bridge between another pair635+ // in the same column. The ray should pass through a parallel bridge.636+ p := &Puzzle{637+ Width: 3,638+ Height: 5,639+ Islands: []Island{640+ {ID: 0, X: 0, Y: 0, Required: 1},641+ {ID: 1, X: 0, Y: 2, Required: 2},642+ {ID: 2, X: 0, Y: 4, Required: 1},643+ },644+ }645+ // Bridge 1-2 creates vertical bridge segments at (0,3).646+ p.SetBridge(1, 2, 1)647+648+ // Ray from 0 downward should find island 1 (not blocked by vertical bridge beyond).649+ adj := p.FindAdjacentIsland(0, 0, 1)650+ if adj == nil || adj.ID != 1 {651+ t.Errorf("expected island 1, got %v", adj)652+ }653+ })654+}655+656+// --- CellContent (P1) ---657+658+func TestCellContent(t *testing.T) {659+ t.Run("island cell", func(t *testing.T) {660+ p := fourCornerPuzzle()661+ ci := p.CellContent(0, 0)662+ if ci.Kind != cellIsland {663+ t.Errorf("Kind = %d, want cellIsland(%d)", ci.Kind, cellIsland)664+ }665+ if ci.IslandID != 0 {666+ t.Errorf("IslandID = %d, want 0", ci.IslandID)667+ }668+ })669+670+ t.Run("horizontal bridge cell", func(t *testing.T) {671+ p := fourCornerPuzzle()672+ p.SetBridge(0, 1, 1)673+674+ ci := p.CellContent(1, 0)675+ if ci.Kind != cellBridgeH {676+ t.Errorf("Kind = %d, want cellBridgeH(%d)", ci.Kind, cellBridgeH)677+ }678+ })679+680+ t.Run("vertical bridge cell", func(t *testing.T) {681+ p := fourCornerPuzzle()682+ p.SetBridge(0, 2, 1)683+684+ ci := p.CellContent(0, 1)685+ if ci.Kind != cellBridgeV {686+ t.Errorf("Kind = %d, want cellBridgeV(%d)", ci.Kind, cellBridgeV)687+ }688+ })689+690+ t.Run("empty cell", func(t *testing.T) {691+ p := fourCornerPuzzle()692+ ci := p.CellContent(1, 1)693+ if ci.Kind != cellEmpty {694+ t.Errorf("Kind = %d, want cellEmpty(%d)", ci.Kind, cellEmpty)695+ }696+ })697+698+ t.Run("bridge count", func(t *testing.T) {699+ p := fourCornerPuzzle()700+ p.SetBridge(0, 1, 2)701+702+ ci := p.CellContent(1, 0) // bridge segment between (0,0) and (3,0)703+ if ci.BridgeCount != 2 {704+ t.Errorf("BridgeCount = %d, want 2", ci.BridgeCount)705+ }706+ })707+708+ t.Run("lazy rebuild after modification", func(t *testing.T) {709+ p := fourCornerPuzzle()710+ _ = p.CellContent(1, 0) // build cache (empty)711+712+ p.SetBridge(0, 1, 1) // invalidates cache713+714+ ci := p.CellContent(1, 0) // should rebuild715+ if ci.Kind != cellBridgeH {716+ t.Errorf("Kind = %d after rebuild, want cellBridgeH(%d)", ci.Kind, cellBridgeH)717+ }718+ })719+}720+721+// --- Save/Load round-trip (P1) ---722+723+func TestSaveLoadRoundTrip(t *testing.T) {724+ t.Run("full round trip", func(t *testing.T) {725+ original := &Model{726+ puzzle: Puzzle{727+ Width: 7,728+ Height: 7,729+ Islands: []Island{730+ {ID: 0, X: 0, Y: 0, Required: 2},731+ {ID: 1, X: 3, Y: 0, Required: 3},732+ {ID: 2, X: 6, Y: 0, Required: 1},733+ {ID: 3, X: 0, Y: 3, Required: 2},734+ },735+ Bridges: []Bridge{736+ {Island1: 0, Island2: 1, Count: 1},737+ {Island1: 1, Island2: 2, Count: 2},738+ {Island1: 0, Island2: 3, Count: 1},739+ },740+ },741+ cursorIsland: 1,742+ keys: DefaultKeyMap,743+ }744+745+ data, err := original.GetSave()746+ if err != nil {747+ t.Fatal(err)748+ }749+750+ got, err := ImportModel(data)751+ if err != nil {752+ t.Fatal(err)753+ }754+755+ // Dimensions.756+ if got.puzzle.Width != 7 || got.puzzle.Height != 7 {757+ t.Errorf("dimensions = %dx%d, want 7x7", got.puzzle.Width, got.puzzle.Height)758+ }759+760+ // Islands.761+ if len(got.puzzle.Islands) != 4 {762+ t.Fatalf("island count = %d, want 4", len(got.puzzle.Islands))763+ }764+ for i, isl := range original.puzzle.Islands {765+ g := got.puzzle.Islands[i]766+ if g.ID != isl.ID || g.X != isl.X || g.Y != isl.Y || g.Required != isl.Required {767+ t.Errorf("island[%d] = %+v, want %+v", i, g, isl)768+ }769+ }770+771+ // Bridges.772+ if len(got.puzzle.Bridges) != 3 {773+ t.Fatalf("bridge count = %d, want 3", len(got.puzzle.Bridges))774+ }775+ for i, b := range original.puzzle.Bridges {776+ g := got.puzzle.Bridges[i]777+ if g.Island1 != b.Island1 || g.Island2 != b.Island2 || g.Count != b.Count {778+ t.Errorf("bridge[%d] = %+v, want %+v", i, g, b)779+ }780+ }781+ })782+783+ t.Run("caches rebuilt on access", func(t *testing.T) {784+ m := &Model{785+ puzzle: Puzzle{786+ Width: 3,787+ Height: 1,788+ Islands: []Island{789+ {ID: 0, X: 0, Y: 0, Required: 1},790+ {ID: 1, X: 2, Y: 0, Required: 1},791+ },792+ Bridges: []Bridge{793+ {Island1: 0, Island2: 1, Count: 1},794+ },795+ },796+ keys: DefaultKeyMap,797+ }798+799+ data, err := m.GetSave()800+ if err != nil {801+ t.Fatal(err)802+ }803+804+ got, err := ImportModel(data)805+ if err != nil {806+ t.Fatal(err)807+ }808+809+ // Caches should be nil after import; accessing BridgeCount triggers rebuild.810+ if got.puzzle.bridgeCounts != nil {811+ t.Error("bridgeCounts should be nil immediately after import")812+ }813+ if cnt := got.puzzle.BridgeCount(0); cnt != 1 {814+ t.Errorf("BridgeCount(0) = %d, want 1", cnt)815+ }816+ })817+818+ t.Run("invalid JSON", func(t *testing.T) {819+ _, err := ImportModel([]byte("not json"))820+ if err == nil {821+ t.Fatal("expected error for invalid JSON")822+ }823+ })824+}825+826+// --- placeIslands (P2) ---827+828+func TestPlaceIslands(t *testing.T) {829+ t.Run("places correct count", func(t *testing.T) {830+ for range 10 {831+ islands := placeIslands(7, 7, 6)832+ if islands == nil {833+ continue // may fail on rare occasions834+ }835+ if len(islands) != 6 {836+ t.Errorf("len(islands) = %d, want 6", len(islands))837+ }838+ }839+ })840+841+ t.Run("no diagonal adjacency", func(t *testing.T) {842+ for range 20 {843+ islands := placeIslands(7, 7, 6)844+ if islands == nil {845+ continue846+ }847+ for i := 0; i < len(islands); i++ {848+ for j := i + 1; j < len(islands); j++ {849+ dx := abs(islands[i].X - islands[j].X)850+ dy := abs(islands[i].Y - islands[j].Y)851+ if dx <= 1 && dy <= 1 && dx > 0 && dy > 0 {852+ t.Errorf("islands %d(%d,%d) and %d(%d,%d) are diagonally adjacent",853+ islands[i].ID, islands[i].X, islands[i].Y,854+ islands[j].ID, islands[j].X, islands[j].Y)855+ }856+ }857+ }858+ }859+ })860+861+ t.Run("returns nil when impossible", func(t *testing.T) {862+ islands := placeIslands(3, 3, 100)863+ if islands != nil {864+ t.Errorf("expected nil for 100 islands on 3x3, got %d islands", len(islands))865+ }866+ })867+868+ t.Run("all within bounds", func(t *testing.T) {869+ for range 20 {870+ islands := placeIslands(7, 7, 6)871+ if islands == nil {872+ continue873+ }874+ for _, isl := range islands {875+ if isl.X < 0 || isl.X >= 7 || isl.Y < 0 || isl.Y >= 7 {876+ t.Errorf("island %d at (%d,%d) out of bounds", isl.ID, isl.X, isl.Y)877+ }878+ }879+ }880+ })881+}882+883+// --- isDirectlyConnectable (P2) ---884+885+func TestIsDirectlyConnectable(t *testing.T) {886+ t.Run("aligned no obstacle", func(t *testing.T) {887+ p := &Puzzle{888+ Width: 5,889+ Height: 1,890+ Islands: []Island{891+ {ID: 0, X: 0, Y: 0, Required: 1},892+ {ID: 1, X: 4, Y: 0, Required: 1},893+ },894+ }895+ if !isDirectlyConnectable(p, p.Islands[0], p.Islands[1]) {896+ t.Error("aligned islands with no obstacle should be connectable")897+ }898+ })899+900+ t.Run("aligned island between", func(t *testing.T) {901+ p := linePuzzle()902+ // Islands 0,1,2,3 at x=0,2,4,6. Check if 0 and 2 are directly connectable903+ // (island 1 at x=2 sits between them... actually 1 is at the endpoint).904+ // Instead: 0 at x=0 and 3 at x=6 — islands 1(x=2) and 2(x=4) are between.905+ if isDirectlyConnectable(p, p.Islands[0], p.Islands[3]) {906+ t.Error("island between should block direct connection")907+ }908+ })909+910+ t.Run("not aligned", func(t *testing.T) {911+ p := fourCornerPuzzle()912+ // Islands 0(0,0) and 3(3,3) are diagonal — not aligned.913+ if isDirectlyConnectable(p, p.Islands[0], p.Islands[3]) {914+ t.Error("diagonal pair should not be connectable")915+ }916+ })917+918+ t.Run("adjacent distance 1", func(t *testing.T) {919+ p := &Puzzle{920+ Width: 2,921+ Height: 1,922+ Islands: []Island{923+ {ID: 0, X: 0, Y: 0, Required: 1},924+ {ID: 1, X: 1, Y: 0, Required: 1},925+ },926+ }927+ if !isDirectlyConnectable(p, p.Islands[0], p.Islands[1]) {928+ t.Error("adjacent islands should be connectable")929+ }930+ })931+}932+933+// --- GeneratePuzzle (P2) ---934+935+func TestGeneratePuzzle(t *testing.T) {936+ mode := NewMode("Test", "test mode", 7, 7, 8, 10)937+938+ t.Run("produces valid puzzle", func(t *testing.T) {939+ puzzle := GeneratePuzzle(mode)940+ for _, isl := range puzzle.Islands {941+ if isl.Required < 1 {942+ t.Errorf("island %d has Required=%d, want >= 1", isl.ID, isl.Required)943+ }944+ }945+ })946+947+ t.Run("bridges cleared", func(t *testing.T) {948+ puzzle := GeneratePuzzle(mode)949+ if len(puzzle.Bridges) != 0 {950+ t.Errorf("len(Bridges) = %d, want 0", len(puzzle.Bridges))951+ }952+ })953+954+ t.Run("correct dimensions", func(t *testing.T) {955+ puzzle := GeneratePuzzle(mode)956+ if puzzle.Width != 7 || puzzle.Height != 7 {957+ t.Errorf("dimensions = %dx%d, want 7x7", puzzle.Width, puzzle.Height)958+ }959+ })960+}961+962+// --- Save JSON structure (P1) ---963+964+func TestSaveJSON(t *testing.T) {965+ m := Model{966+ puzzle: Puzzle{967+ Width: 4,968+ Height: 4,969+ Islands: []Island{970+ {ID: 0, X: 0, Y: 0, Required: 2},971+ },972+ Bridges: []Bridge{973+ {Island1: 0, Island2: 1, Count: 1},974+ },975+ },976+ keys: DefaultKeyMap,977+ }978+979+ data, err := m.GetSave()980+ if err != nil {981+ t.Fatal(err)982+ }983+984+ var raw map[string]json.RawMessage985+ if err := json.Unmarshal(data, &raw); err != nil {986+ t.Fatal(err)987+ }988+989+ for _, key := range []string{"width", "height", "islands", "bridges"} {990+ if _, ok := raw[key]; !ok {991+ t.Errorf("missing key %q in save JSON", key)992+ }993+ }994+}@@ -0,0 +1,582 @@
1+package nonogram2+3+import (4+ "reflect"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+)10+11+// --- generateTomography (P0) ---12+13+func TestGenerateTomography(t *testing.T) {14+ tests := []struct {15+ name string16+ grid grid17+ wantRows TomographyDefinition18+ wantCols TomographyDefinition19+ }{20+ {21+ name: "all filled row",22+ grid: grid{[]rune{filledTile, filledTile, filledTile}},23+ wantRows: TomographyDefinition{{3}},24+ wantCols: TomographyDefinition{{1}, {1}, {1}},25+ },26+ {27+ name: "all empty row",28+ grid: grid{[]rune{emptyTile, emptyTile, emptyTile}},29+ wantRows: TomographyDefinition{{0}},30+ wantCols: TomographyDefinition{{0}, {0}, {0}},31+ },32+ {33+ name: "single block",34+ grid: grid{[]rune{filledTile, filledTile, emptyTile, emptyTile}},35+ wantRows: TomographyDefinition{{2}},36+ wantCols: TomographyDefinition{{1}, {1}, {0}, {0}},37+ },38+ {39+ name: "two blocks",40+ grid: grid{[]rune{filledTile, filledTile, emptyTile, filledTile}},41+ wantRows: TomographyDefinition{{2, 1}},42+ wantCols: TomographyDefinition{{1}, {1}, {0}, {1}},43+ },44+ {45+ name: "alternating",46+ grid: grid{[]rune{filledTile, emptyTile, filledTile, emptyTile, filledTile}},47+ wantRows: TomographyDefinition{{1, 1, 1}},48+ wantCols: TomographyDefinition{{1}, {0}, {1}, {0}, {1}},49+ },50+ {51+ name: "column hints vertical pattern",52+ grid: grid{53+ []rune{filledTile, emptyTile},54+ []rune{filledTile, emptyTile},55+ []rune{emptyTile, filledTile},56+ },57+ wantRows: TomographyDefinition{{1}, {1}, {1}},58+ wantCols: TomographyDefinition{{2}, {1}},59+ },60+ {61+ name: "1x1 filled",62+ grid: grid{[]rune{filledTile}},63+ wantRows: TomographyDefinition{{1}},64+ wantCols: TomographyDefinition{{1}},65+ },66+ {67+ name: "1x1 empty",68+ grid: grid{[]rune{emptyTile}},69+ wantRows: TomographyDefinition{{0}},70+ wantCols: TomographyDefinition{{0}},71+ },72+ {73+ name: "empty grid",74+ grid: grid{},75+ wantRows: TomographyDefinition{},76+ wantCols: TomographyDefinition{},77+ },78+ {79+ name: "rectangular grid 3x5",80+ grid: grid{81+ []rune{filledTile, emptyTile, filledTile, emptyTile, filledTile},82+ []rune{emptyTile, emptyTile, emptyTile, emptyTile, emptyTile},83+ []rune{filledTile, filledTile, filledTile, filledTile, filledTile},84+ },85+ wantRows: TomographyDefinition{{1, 1, 1}, {0}, {5}},86+ wantCols: TomographyDefinition{{1, 1}, {1}, {1, 1}, {1}, {1, 1}},87+ },88+ }89+90+ for _, tt := range tests {91+ t.Run(tt.name, func(t *testing.T) {92+ hints := generateTomography(tt.grid)93+ if !reflect.DeepEqual([][]int(hints.rows), [][]int(tt.wantRows)) {94+ t.Errorf("rows = %v, want %v", hints.rows, tt.wantRows)95+ }96+ if !reflect.DeepEqual([][]int(hints.cols), [][]int(tt.wantCols)) {97+ t.Errorf("cols = %v, want %v", hints.cols, tt.wantCols)98+ }99+ })100+ }101+}102+103+// --- TomographyDefinition.equal (P0) ---104+105+func TestTomographyDefinitionEqual(t *testing.T) {106+ tests := []struct {107+ name string108+ a TomographyDefinition109+ b TomographyDefinition110+ want bool111+ }{112+ {113+ name: "identical single hints",114+ a: TomographyDefinition{{1, 2}, {3}},115+ b: TomographyDefinition{{1, 2}, {3}},116+ want: true,117+ },118+ {119+ name: "different values",120+ a: TomographyDefinition{{1, 2}},121+ b: TomographyDefinition{{1, 3}},122+ want: false,123+ },124+ {125+ name: "different inner lengths",126+ a: TomographyDefinition{{1, 2}},127+ b: TomographyDefinition{{1, 2, 3}},128+ want: false,129+ },130+ {131+ name: "different outer lengths",132+ a: TomographyDefinition{{1}, {2}},133+ b: TomographyDefinition{{1}},134+ want: false,135+ },136+ {137+ name: "both empty",138+ a: TomographyDefinition{},139+ b: TomographyDefinition{},140+ want: true,141+ },142+ {143+ name: "nil vs empty",144+ a: nil,145+ b: TomographyDefinition{},146+ want: true,147+ },148+ }149+150+ for _, tt := range tests {151+ t.Run(tt.name, func(t *testing.T) {152+ if got := tt.a.equal(tt.b); got != tt.want {153+ t.Errorf("equal() = %v, want %v", got, tt.want)154+ }155+ })156+ }157+}158+159+// --- TomographyDefinition.RequiredLen (P1) ---160+161+func TestTomographyDefinitionRequiredLen(t *testing.T) {162+ tests := []struct {163+ name string164+ td TomographyDefinition165+ want int166+ }{167+ {"varying lengths", TomographyDefinition{{1}, {2, 3}, {4}}, 2},168+ {"single element each", TomographyDefinition{{5}, {3}}, 1},169+ {"empty definition", TomographyDefinition{}, 0},170+ }171+172+ for _, tt := range tests {173+ t.Run(tt.name, func(t *testing.T) {174+ if got := tt.td.RequiredLen(); got != tt.want {175+ t.Errorf("RequiredLen() = %d, want %d", got, tt.want)176+ }177+ })178+ }179+}180+181+// --- Grid serialization round-trip (P1) ---182+183+func TestGridRoundTrip(t *testing.T) {184+ t.Run("5x5 round-trip", func(t *testing.T) {185+ g := grid{186+ []rune{filledTile, emptyTile, filledTile, emptyTile, filledTile},187+ []rune{emptyTile, filledTile, emptyTile, filledTile, emptyTile},188+ []rune{filledTile, filledTile, filledTile, filledTile, filledTile},189+ []rune{emptyTile, emptyTile, emptyTile, emptyTile, emptyTile},190+ []rune{filledTile, emptyTile, emptyTile, emptyTile, filledTile},191+ }192+ s := g.String()193+ g2 := newGrid(state(s))194+ s2 := g2.String()195+ if s != s2 {196+ t.Errorf("round-trip mismatch:\n got: %q\n want: %q", s2, s)197+ }198+ })199+200+ t.Run("preserves all tile types", func(t *testing.T) {201+ g := grid{202+ []rune{filledTile, markedTile, emptyTile},203+ }204+ s := g.String()205+ g2 := newGrid(state(s))206+ s2 := g2.String()207+ if s != s2 {208+ t.Errorf("tile types not preserved:\n got: %q\n want: %q", s2, s)209+ }210+ })211+212+ t.Run("single row", func(t *testing.T) {213+ g := grid{214+ []rune{filledTile, emptyTile, filledTile},215+ }216+ s := g.String()217+ g2 := newGrid(state(s))218+ if len(g2) != 1 {219+ t.Fatalf("expected 1 row, got %d", len(g2))220+ }221+ if g2.String() != s {222+ t.Error("single row round-trip mismatch")223+ }224+ })225+226+ t.Run("single column", func(t *testing.T) {227+ g := grid{228+ []rune{filledTile},229+ []rune{emptyTile},230+ []rune{filledTile},231+ }232+ s := g.String()233+ g2 := newGrid(state(s))234+ if len(g2) != 3 {235+ t.Fatalf("expected 3 rows, got %d", len(g2))236+ }237+ if g2.String() != s {238+ t.Error("single column round-trip mismatch")239+ }240+ })241+}242+243+// --- createEmptyState (P1) ---244+245+func TestCreateEmptyState(t *testing.T) {246+ t.Run("5x5", func(t *testing.T) {247+ s := createEmptyState(5, 5)248+ rows := strings.Split(string(s), "\n")249+ if len(rows) != 5 {250+ t.Fatalf("expected 5 rows, got %d", len(rows))251+ }252+ for i, row := range rows {253+ if len([]rune(row)) != 5 {254+ t.Errorf("row %d: expected 5 runes, got %d", i, len([]rune(row)))255+ }256+ for _, r := range row {257+ if r != emptyTile {258+ t.Errorf("row %d: expected emptyTile, got %q", i, r)259+ }260+ }261+ }262+ })263+264+ t.Run("1x1", func(t *testing.T) {265+ s := createEmptyState(1, 1)266+ if string(s) != string(emptyTile) {267+ t.Errorf("expected single emptyTile, got %q", s)268+ }269+ if strings.Contains(string(s), "\n") {270+ t.Error("1x1 should not contain newline")271+ }272+ })273+274+ t.Run("0 dimension", func(t *testing.T) {275+ if s := createEmptyState(0, 5); s != "" {276+ t.Errorf("expected empty string for h=0, got %q", s)277+ }278+ if s := createEmptyState(5, 0); s != "" {279+ t.Errorf("expected empty string for w=0, got %q", s)280+ }281+ })282+283+ t.Run("negative dimension", func(t *testing.T) {284+ if s := createEmptyState(-1, 5); s != "" {285+ t.Errorf("expected empty string for h=-1, got %q", s)286+ }287+ })288+}289+290+// --- generateRandomState (P2) ---291+292+func TestGenerateRandomState(t *testing.T) {293+ t.Run("density clamping low", func(t *testing.T) {294+ // Use a larger grid and multiple runs to avoid flakiness from per-row jitter.295+ totalFilled := 0296+ for range 10 {297+ s := generateRandomState(10, 10, 0.0)298+ g := newGrid(s)299+ for _, row := range g {300+ for _, r := range row {301+ if r == filledTile {302+ totalFilled++303+ }304+ }305+ }306+ }307+ if totalFilled == 0 {308+ t.Error("expected some filled cells with density clamped from 0.0")309+ }310+ })311+312+ t.Run("density clamping high", func(t *testing.T) {313+ totalEmpty := 0314+ for range 10 {315+ s := generateRandomState(10, 10, 1.0)316+ g := newGrid(s)317+ for _, row := range g {318+ for _, r := range row {319+ if r == emptyTile {320+ totalEmpty++321+ }322+ }323+ }324+ }325+ if totalEmpty == 0 {326+ t.Error("expected some empty cells with density clamped from 1.0")327+ }328+ })329+330+ t.Run("correct dimensions", func(t *testing.T) {331+ s := generateRandomState(5, 10, 0.5)332+ g := newGrid(s)333+ if len(g) != 5 {334+ t.Fatalf("expected 5 rows, got %d", len(g))335+ }336+ for i, row := range g {337+ if len(row) != 10 {338+ t.Errorf("row %d: expected 10 cols, got %d", i, len(row))339+ }340+ }341+ })342+343+ t.Run("0 dimensions", func(t *testing.T) {344+ if s := generateRandomState(0, 5, 0.5); s != "" {345+ t.Errorf("expected empty string for h=0, got %q", s)346+ }347+ if s := generateRandomState(5, 0, 0.5); s != "" {348+ t.Errorf("expected empty string for w=0, got %q", s)349+ }350+ })351+352+ t.Run("approximate density", func(t *testing.T) {353+ totalFilled := 0354+ totalCells := 0355+ runs := 20356+ for range runs {357+ s := generateRandomState(10, 10, 0.5)358+ g := newGrid(s)359+ for _, row := range g {360+ for _, r := range row {361+ totalCells++362+ if r == filledTile {363+ totalFilled++364+ }365+ }366+ }367+ }368+ ratio := float64(totalFilled) / float64(totalCells)369+ if ratio < 0.2 || ratio > 0.8 {370+ t.Errorf("average density = %.2f, expected roughly 0.5 (within 0.2–0.8)", ratio)371+ }372+ })373+}374+375+// --- isValidPuzzle (P0) ---376+377+func TestIsValidPuzzle(t *testing.T) {378+ tests := []struct {379+ name string380+ hints Hints381+ height int382+ width int383+ want bool384+ }{385+ {386+ name: "all-zero row",387+ hints: Hints{388+ rows: TomographyDefinition{{0}, {1, 2}},389+ cols: TomographyDefinition{{1}, {2}},390+ },391+ height: 2, width: 2,392+ want: false,393+ },394+ {395+ name: "full-width row",396+ hints: Hints{397+ rows: TomographyDefinition{{5}, {1, 2}},398+ cols: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},399+ },400+ height: 2, width: 5,401+ want: false,402+ },403+ {404+ name: "all-zero column",405+ hints: Hints{406+ rows: TomographyDefinition{{1}, {1}},407+ cols: TomographyDefinition{{0}, {2}},408+ },409+ height: 2, width: 2,410+ want: false,411+ },412+ {413+ name: "full-height column",414+ hints: Hints{415+ rows: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},416+ cols: TomographyDefinition{{5}, {1, 2}},417+ },418+ height: 5, width: 2,419+ want: false,420+ },421+ {422+ name: "valid mixed hints",423+ hints: Hints{424+ rows: TomographyDefinition{{1, 1}, {2}},425+ cols: TomographyDefinition{{1}, {1, 1}, {1}},426+ },427+ height: 2, width: 3,428+ want: true,429+ },430+ {431+ name: "multi-segment rows valid",432+ hints: Hints{433+ rows: TomographyDefinition{{2, 1}, {1, 1}},434+ cols: TomographyDefinition{{1}, {1, 1}, {1}, {1}, {1}},435+ },436+ height: 2, width: 5,437+ want: true,438+ },439+ }440+441+ for _, tt := range tests {442+ t.Run(tt.name, func(t *testing.T) {443+ if got := isValidPuzzle(tt.hints, tt.height, tt.width); got != tt.want {444+ t.Errorf("isValidPuzzle() = %v, want %v", got, tt.want)445+ }446+ })447+ }448+}449+450+// --- GenerateRandomTomography (P2) ---451+452+func TestGenerateRandomTomography(t *testing.T) {453+ mode := NewMode("Test", "test mode", 5, 5, 0.5)454+455+ t.Run("produces valid puzzle", func(t *testing.T) {456+ hints := GenerateRandomTomography(mode)457+ if !isValidPuzzle(hints, mode.Height, mode.Width) {458+ t.Error("GenerateRandomTomography produced invalid puzzle")459+ }460+ })461+462+ t.Run("hints match dimensions", func(t *testing.T) {463+ hints := GenerateRandomTomography(mode)464+ if len(hints.rows) != mode.Height {465+ t.Errorf("len(rows) = %d, want %d", len(hints.rows), mode.Height)466+ }467+ if len(hints.cols) != mode.Width {468+ t.Errorf("len(cols) = %d, want %d", len(hints.cols), mode.Width)469+ }470+ })471+}472+473+// --- Save/Load round-trip (P1) ---474+475+func TestSaveLoadRoundTrip(t *testing.T) {476+ t.Run("round-trip preserves state", func(t *testing.T) {477+ g := grid{478+ []rune{filledTile, emptyTile, markedTile, filledTile, emptyTile},479+ []rune{emptyTile, filledTile, filledTile, emptyTile, filledTile},480+ []rune{filledTile, filledTile, emptyTile, emptyTile, filledTile},481+ []rune{emptyTile, emptyTile, filledTile, filledTile, emptyTile},482+ []rune{filledTile, emptyTile, filledTile, emptyTile, filledTile},483+ }484+ hints := Hints{485+ rows: TomographyDefinition{{2, 1}, {1, 2}, {2, 1}, {2}, {1, 1, 1}},486+ cols: TomographyDefinition{{1, 1, 1}, {1, 1}, {1, 1, 1}, {1, 1}, {1, 1, 1}},487+ }488+ curr := generateTomography(g)489+490+ m := Model{491+ width: 5,492+ height: 5,493+ rowHints: hints.rows,494+ colHints: hints.cols,495+ grid: g,496+ keys: DefaultKeyMap,497+ currentHints: curr,498+ solved: curr.rows.equal(hints.rows) && curr.cols.equal(hints.cols),499+ }500+501+ data, err := m.GetSave()502+ if err != nil {503+ t.Fatal(err)504+ }505+506+ got, err := ImportModel(data)507+ if err != nil {508+ t.Fatal(err)509+ }510+511+ // Grid content.512+ if got.grid.String() != m.grid.String() {513+ t.Errorf("grid mismatch:\n got: %q\n want: %q", got.grid.String(), m.grid.String())514+ }515+516+ // Dimensions.517+ if got.width != m.width {518+ t.Errorf("width = %d, want %d", got.width, m.width)519+ }520+ if got.height != m.height {521+ t.Errorf("height = %d, want %d", got.height, m.height)522+ }523+524+ // Hints.525+ if !got.rowHints.equal(m.rowHints) {526+ t.Errorf("rowHints mismatch: got %v, want %v", got.rowHints, m.rowHints)527+ }528+ if !got.colHints.equal(m.colHints) {529+ t.Errorf("colHints mismatch: got %v, want %v", got.colHints, m.colHints)530+ }531+532+ // Solved state.533+ if got.solved != m.solved {534+ t.Errorf("solved = %v, want %v", got.solved, m.solved)535+ }536+ })537+538+ t.Run("preserves solved state", func(t *testing.T) {539+ // Create a grid that matches its own tomography (solved).540+ g := grid{541+ []rune{filledTile, emptyTile},542+ []rune{emptyTile, filledTile},543+ }544+ hints := generateTomography(g)545+546+ m := Model{547+ width: 2,548+ height: 2,549+ rowHints: hints.rows,550+ colHints: hints.cols,551+ grid: g,552+ keys: DefaultKeyMap,553+ currentHints: hints,554+ solved: true,555+ }556+557+ data, err := m.GetSave()558+ if err != nil {559+ t.Fatal(err)560+ }561+562+ got, err := ImportModel(data)563+ if err != nil {564+ t.Fatal(err)565+ }566+567+ if !got.solved {568+ t.Error("expected imported model to be solved")569+ }570+ })571+572+ t.Run("invalid JSON", func(t *testing.T) {573+ _, err := ImportModel([]byte("not json"))574+ if err == nil {575+ t.Fatal("expected error for invalid JSON")576+ }577+ })578+}579+580+// Ensure the game import is used (it's needed for the init() registration side effect581+// and the NewMode constructor uses game.NewBaseMode internally).582+var _ game.Gamer = Model{}@@ -0,0 +1,657 @@
1+package sudoku2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+)8+9+// validCompleteGrid returns a known valid 9x9 Sudoku solution.10+func validCompleteGrid() grid {11+ rows := [9][9]int{12+ {5, 3, 4, 6, 7, 8, 9, 1, 2},13+ {6, 7, 2, 1, 9, 5, 3, 4, 8},14+ {1, 9, 8, 3, 4, 2, 5, 6, 7},15+ {8, 5, 9, 7, 6, 1, 4, 2, 3},16+ {4, 2, 6, 8, 5, 3, 7, 9, 1},17+ {7, 1, 3, 9, 2, 4, 8, 5, 6},18+ {9, 6, 1, 5, 3, 7, 2, 8, 4},19+ {2, 8, 7, 4, 1, 9, 6, 3, 5},20+ {3, 4, 5, 2, 8, 6, 1, 7, 9},21+ }22+ var g grid23+ for y := range gridSize {24+ for x := range gridSize {25+ g[y][x] = cell{x: x, y: y, v: rows[y][x]}26+ }27+ }28+ return g29+}30+31+// emptyGrid returns a 9x9 grid with all cells set to 0 and correct x/y.32+func emptyGrid() grid {33+ return newGrid(nil)34+}35+36+// --- isValid (P0) ---37+38+func TestIsValid(t *testing.T) {39+ t.Run("valid placement on empty board", func(t *testing.T) {40+ g := emptyGrid()41+ if !isValid(&g, 5, 0, 0) {42+ t.Error("expected valid placement on empty grid")43+ }44+ })45+46+ t.Run("row conflict", func(t *testing.T) {47+ g := emptyGrid()48+ g[0][3].v = 5 // place 5 in row 0, col 349+ if isValid(&g, 5, 0, 0) {50+ t.Error("expected row conflict")51+ }52+ })53+54+ t.Run("column conflict", func(t *testing.T) {55+ g := emptyGrid()56+ g[3][0].v = 5 // place 5 in col 0, row 357+ if isValid(&g, 5, 0, 0) {58+ t.Error("expected column conflict")59+ }60+ })61+62+ t.Run("box conflict", func(t *testing.T) {63+ g := emptyGrid()64+ g[1][1].v = 5 // same box as (0,0)65+ if isValid(&g, 5, 0, 0) {66+ t.Error("expected box conflict")67+ }68+ })69+70+ t.Run("no self-conflict", func(t *testing.T) {71+ g := emptyGrid()72+ g[2][2].v = 573+ // Checking isValid for 5 at (2,2) should skip self.74+ if !isValid(&g, 5, 2, 2) {75+ t.Error("expected no self-conflict")76+ }77+ })78+79+ t.Run("different value same position", func(t *testing.T) {80+ g := emptyGrid()81+ g[0][0].v = 382+ if !isValid(&g, 5, 0, 0) {83+ t.Error("expected no conflict for different value")84+ }85+ })86+87+ t.Run("box boundary (2,2) vs (3,3) different boxes", func(t *testing.T) {88+ g := emptyGrid()89+ g[3][3].v = 5 // box (1,1), different from (0,0) box90+ if !isValid(&g, 5, 2, 2) {91+ t.Error("(2,2) and (3,3) are in different boxes, no conflict expected")92+ }93+ })94+95+ t.Run("box boundary (2,2) vs (0,0) same box", func(t *testing.T) {96+ g := emptyGrid()97+ g[0][0].v = 5 // same box as (2,2)98+ if isValid(&g, 5, 2, 2) {99+ t.Error("(0,0) and (2,2) are in the same box, conflict expected")100+ }101+ })102+103+ t.Run("all 9 boxes center placement", func(t *testing.T) {104+ // Place value 1 in center of each 3x3 box, then verify no cross-box conflict.105+ g := emptyGrid()106+ centers := [][2]int{107+ {1, 1}, {4, 1}, {7, 1},108+ {1, 4}, {4, 4}, {7, 4},109+ {1, 7}, {4, 7}, {7, 7},110+ }111+ for _, c := range centers {112+ g[c[1]][c[0]].v = 1113+ }114+ // Each center should be valid (1 appears once per box, once per row, once per column).115+ for _, c := range centers {116+ if !isValid(&g, 1, c[0], c[1]) {117+ t.Errorf("expected valid at center (%d,%d)", c[0], c[1])118+ }119+ }120+ })121+}122+123+// --- fillGrid (P1) ---124+125+func TestFillGrid(t *testing.T) {126+ t.Run("fills empty grid", func(t *testing.T) {127+ g := emptyGrid()128+ if !fillGrid(&g) {129+ t.Fatal("fillGrid returned false")130+ }131+ for y := range gridSize {132+ for x := range gridSize {133+ if g[y][x].v < 1 || g[y][x].v > 9 {134+ t.Errorf("cell (%d,%d) = %d, want 1-9", x, y, g[y][x].v)135+ }136+ }137+ }138+ })139+140+ t.Run("result is valid", func(t *testing.T) {141+ g := emptyGrid()142+ fillGrid(&g)143+ for y := range gridSize {144+ for x := range gridSize {145+ if !isValid(&g, g[y][x].v, x, y) {146+ t.Errorf("cell (%d,%d)=%d fails isValid", x, y, g[y][x].v)147+ }148+ }149+ }150+ })151+152+ t.Run("fills partial grid", func(t *testing.T) {153+ provided := []cell{154+ {x: 0, y: 0, v: 5},155+ {x: 1, y: 0, v: 3},156+ }157+ g := newGrid(provided)158+ if !fillGrid(&g) {159+ t.Fatal("fillGrid returned false on partial grid")160+ }161+ if g[0][0].v != 5 {162+ t.Errorf("provided cell (0,0) changed to %d", g[0][0].v)163+ }164+ if g[0][1].v != 3 {165+ t.Errorf("provided cell (1,0) changed to %d", g[0][1].v)166+ }167+ })168+169+ t.Run("produces different results", func(t *testing.T) {170+ var grids [5]grid171+ for i := range grids {172+ grids[i] = emptyGrid()173+ fillGrid(&grids[i])174+ }175+ allSame := true176+ for i := 1; i < len(grids); i++ {177+ for y := range gridSize {178+ for x := range gridSize {179+ if grids[i][y][x].v != grids[0][y][x].v {180+ allSame = false181+ break182+ }183+ }184+ if !allSame {185+ break186+ }187+ }188+ if !allSame {189+ break190+ }191+ }192+ if allSame {193+ t.Error("fillGrid produced identical grids across 5 runs")194+ }195+ })196+}197+198+// --- countSolutions (P0) ---199+200+func TestCountSolutions(t *testing.T) {201+ t.Run("complete grid", func(t *testing.T) {202+ g := validCompleteGrid()203+ if count := countSolutions(&g, 10); count != 1 {204+ t.Errorf("countSolutions = %d, want 1", count)205+ }206+ })207+208+ t.Run("one empty cell unique", func(t *testing.T) {209+ g := validCompleteGrid()210+ g[0][0].v = 0211+ if count := countSolutions(&g, 10); count != 1 {212+ t.Errorf("countSolutions = %d, want 1", count)213+ }214+ })215+216+ t.Run("impossible grid", func(t *testing.T) {217+ g := emptyGrid()218+ // Place two 1s in row 0 by forcing the grid state.219+ g[0][0].v = 1220+ g[0][1].v = 1221+ if count := countSolutions(&g, 10); count != 0 {222+ t.Errorf("countSolutions = %d, want 0", count)223+ }224+ })225+226+ t.Run("respects limit", func(t *testing.T) {227+ g := emptyGrid()228+ count := countSolutions(&g, 2)229+ if count < 2 {230+ t.Errorf("countSolutions = %d, want >= 2", count)231+ }232+ if count > 2 {233+ t.Errorf("countSolutions = %d, should not exceed limit 2", count)234+ }235+ })236+237+ t.Run("two empty cells ambiguous", func(t *testing.T) {238+ // Start with a valid complete grid, then clear two cells that create239+ // ambiguity: (0,0) and (0,1) where swapping their values still yields240+ // a valid grid.241+ g := validCompleteGrid()242+ // g[0][0]=5, g[0][1]=3. We need to check if swapping creates a valid grid.243+ // Clear both and count solutions.244+ v1, v2 := g[0][0].v, g[0][1].v245+ g[0][0].v = 0246+ g[0][1].v = 0247+ count := countSolutions(&g, 10)248+ // If swapping is valid, count >= 2; otherwise find a different pair.249+ // The valid grid has (0,0)=5,(0,1)=3 — swapping: is 3 valid at (0,0)250+ // and 5 valid at (0,1)?251+ g[0][0].v = v2252+ g[0][1].v = v1253+ swapValid := isValid(&g, v2, 0, 0) && isValid(&g, v1, 1, 0)254+ if swapValid && count < 2 {255+ t.Errorf("countSolutions = %d, expected >= 2 for ambiguous pair", count)256+ }257+ if !swapValid && count != 1 {258+ // The pair wasn't actually ambiguous; just verify we got 1.259+ t.Logf("pair (0,0)/(0,1) not ambiguous, count=%d", count)260+ }261+ })262+}263+264+// --- computeConflicts (P0) ---265+266+func TestComputeConflicts(t *testing.T) {267+ t.Run("no conflicts", func(t *testing.T) {268+ g := validCompleteGrid()269+ conflicts := computeConflicts(g)270+ for y := range gridSize {271+ for x := range gridSize {272+ if conflicts[y][x] {273+ t.Errorf("unexpected conflict at (%d,%d)", x, y)274+ }275+ }276+ }277+ })278+279+ t.Run("row conflict", func(t *testing.T) {280+ g := validCompleteGrid()281+ // Create a row conflict: set (0,1) to same value as (0,0).282+ g[0][1].v = g[0][0].v283+ conflicts := computeConflicts(g)284+ if !conflicts[0][0] {285+ t.Error("expected conflict at (0,0)")286+ }287+ if !conflicts[0][1] {288+ t.Error("expected conflict at (1,0)")289+ }290+ })291+292+ t.Run("column conflict", func(t *testing.T) {293+ g := validCompleteGrid()294+ // Create a column conflict: set (0,1) row to same as (0,0).295+ g[1][0].v = g[0][0].v296+ conflicts := computeConflicts(g)297+ if !conflicts[0][0] {298+ t.Error("expected conflict at (0,0)")299+ }300+ if !conflicts[1][0] {301+ t.Error("expected conflict at (0,1)")302+ }303+ })304+305+ t.Run("box conflict", func(t *testing.T) {306+ g := validCompleteGrid()307+ // Create a box conflict: set (1,1) to same value as (0,0).308+ g[1][1].v = g[0][0].v309+ conflicts := computeConflicts(g)310+ if !conflicts[0][0] {311+ t.Error("expected conflict at (0,0)")312+ }313+ if !conflicts[1][1] {314+ t.Error("expected conflict at (1,1)")315+ }316+ })317+318+ t.Run("empty cells no false positives", func(t *testing.T) {319+ g := emptyGrid()320+ // Place a single value.321+ g[0][0].v = 5322+ conflicts := computeConflicts(g)323+ for y := range gridSize {324+ for x := range gridSize {325+ if conflicts[y][x] {326+ t.Errorf("unexpected conflict at (%d,%d) on mostly empty grid", x, y)327+ }328+ }329+ }330+ })331+332+ t.Run("multiple conflicts", func(t *testing.T) {333+ g := validCompleteGrid()334+ // Row conflict in row 0.335+ g[0][1].v = g[0][0].v336+ // Column conflict in column 5.337+ g[3][5].v = g[0][5].v338+ conflicts := computeConflicts(g)339+ if !conflicts[0][0] || !conflicts[0][1] {340+ t.Error("expected row conflict at (0,0) and (1,0)")341+ }342+ if !conflicts[0][5] || !conflicts[3][5] {343+ t.Error("expected column conflict at (5,0) and (5,3)")344+ }345+ })346+}347+348+// --- isSolvedWith (P0) ---349+350+func TestIsSolvedWith(t *testing.T) {351+ t.Run("valid complete grid", func(t *testing.T) {352+ g := validCompleteGrid()353+ conflicts := computeConflicts(g)354+ if !isSolvedWith(g, conflicts) {355+ t.Error("expected solved")356+ }357+ })358+359+ t.Run("has empty cell", func(t *testing.T) {360+ g := validCompleteGrid()361+ g[4][4].v = 0362+ conflicts := computeConflicts(g)363+ if isSolvedWith(g, conflicts) {364+ t.Error("expected not solved with empty cell")365+ }366+ })367+368+ t.Run("has conflict", func(t *testing.T) {369+ g := validCompleteGrid()370+ g[0][1].v = g[0][0].v // duplicate in row371+ conflicts := computeConflicts(g)372+ if isSolvedWith(g, conflicts) {373+ t.Error("expected not solved with conflict")374+ }375+ })376+377+ t.Run("all zeros", func(t *testing.T) {378+ g := emptyGrid()379+ conflicts := computeConflicts(g)380+ if isSolvedWith(g, conflicts) {381+ t.Error("expected not solved for blank grid")382+ }383+ })384+}385+386+// --- newGrid (P1) ---387+388+func TestNewGrid(t *testing.T) {389+ t.Run("with provided cells", func(t *testing.T) {390+ provided := []cell{391+ {x: 0, y: 0, v: 5},392+ {x: 4, y: 3, v: 9},393+ {x: 8, y: 8, v: 1},394+ }395+ g := newGrid(provided)396+ for _, c := range provided {397+ if g[c.y][c.x].v != c.v {398+ t.Errorf("cell (%d,%d) = %d, want %d", c.x, c.y, g[c.y][c.x].v, c.v)399+ }400+ }401+ })402+403+ t.Run("without provided cells", func(t *testing.T) {404+ g := newGrid(nil)405+ for y := range gridSize {406+ for x := range gridSize {407+ if g[y][x].v != 0 {408+ t.Errorf("cell (%d,%d) = %d, want 0", x, y, g[y][x].v)409+ }410+ }411+ }412+ })413+414+ t.Run("all cells have correct positions", func(t *testing.T) {415+ g := newGrid(nil)416+ for y := range gridSize {417+ for x := range gridSize {418+ if g[y][x].x != x || g[y][x].y != y {419+ t.Errorf("cell position = (%d,%d), want (%d,%d)", g[y][x].x, g[y][x].y, x, y)420+ }421+ }422+ }423+ })424+}425+426+// --- gridToString (P1) ---427+428+func TestGridToString(t *testing.T) {429+ t.Run("round-trip", func(t *testing.T) {430+ g := validCompleteGrid()431+ s := gridToString(g)432+433+ // Parse it back.434+ g2 := emptyGrid()435+ rows := splitLines(s)436+ for y := range gridSize {437+ for x := range gridSize {438+ g2[y][x].v = int(rows[y][x] - '0')439+ }440+ }441+ for y := range gridSize {442+ for x := range gridSize {443+ if g2[y][x].v != g[y][x].v {444+ t.Errorf("round-trip mismatch at (%d,%d): got %d, want %d", x, y, g2[y][x].v, g[y][x].v)445+ }446+ }447+ }448+ })449+450+ t.Run("encodes zeros", func(t *testing.T) {451+ g := emptyGrid()452+ s := gridToString(g)453+ for _, ch := range s {454+ if ch != '0' && ch != '\n' {455+ t.Errorf("expected only '0' and '\\n', got %q", ch)456+ }457+ }458+ })459+460+ t.Run("encodes 1-9", func(t *testing.T) {461+ g := validCompleteGrid()462+ s := gridToString(g)463+ for _, ch := range s {464+ if ch == '\n' {465+ continue466+ }467+ if ch < '1' || ch > '9' {468+ t.Errorf("expected digit 1-9, got %q", ch)469+ }470+ }471+ })472+}473+474+// splitLines splits a string by newlines (helper for test).475+func splitLines(s string) []string {476+ var lines []string477+ start := 0478+ for i := range len(s) {479+ if s[i] == '\n' {480+ lines = append(lines, s[start:i])481+ start = i + 1482+ }483+ }484+ lines = append(lines, s[start:])485+ return lines486+}487+488+// --- GenerateProvidedCells (P2) ---489+490+func TestGenerateProvidedCells(t *testing.T) {491+ mode := NewMode("Test", "test", 30)492+493+ t.Run("produces solvable puzzle", func(t *testing.T) {494+ cells := GenerateProvidedCells(mode)495+ g := newGrid(cells)496+ count := countSolutions(&g, 2)497+ if count != 1 {498+ t.Errorf("countSolutions = %d, want 1", count)499+ }500+ })501+502+ t.Run("approximate count", func(t *testing.T) {503+ cells := GenerateProvidedCells(mode)504+ // May be slightly higher than target if removal would break uniqueness.505+ if len(cells) < mode.ProvidedCount {506+ t.Errorf("len(cells) = %d, want >= %d", len(cells), mode.ProvidedCount)507+ }508+ })509+510+ t.Run("all provided cells valid", func(t *testing.T) {511+ cells := GenerateProvidedCells(mode)512+ g := newGrid(cells)513+ for _, c := range cells {514+ if !isValid(&g, c.v, c.x, c.y) {515+ t.Errorf("provided cell (%d,%d)=%d fails isValid", c.x, c.y, c.v)516+ }517+ }518+ })519+520+ t.Run("cells have correct positions", func(t *testing.T) {521+ cells := GenerateProvidedCells(mode)522+ for _, c := range cells {523+ if c.x < 0 || c.x > 8 || c.y < 0 || c.y > 8 {524+ t.Errorf("cell position (%d,%d) out of bounds", c.x, c.y)525+ }526+ if c.v < 1 || c.v > 9 {527+ t.Errorf("cell value %d out of range at (%d,%d)", c.v, c.x, c.y)528+ }529+ }530+ })531+}532+533+// --- Save/Load round-trip (P1) ---534+535+func TestSaveLoadRoundTrip(t *testing.T) {536+ t.Run("preserves provided and user cells", func(t *testing.T) {537+ provided := []cell{538+ {x: 0, y: 0, v: 5},539+ {x: 1, y: 0, v: 3},540+ {x: 4, y: 4, v: 9},541+ }542+ g := newGrid(provided)543+ // Add user-entered values.544+ g[1][0].v = 6545+ g[2][2].v = 8546+547+ m := Model{548+ grid: g,549+ provided: provided,550+ providedGrid: buildProvidedGrid(provided),551+ keys: DefaultKeyMap,552+ cursor: game.Cursor{X: 2, Y: 3},553+ modeTitle: "Hard",554+ }555+556+ data, err := m.GetSave()557+ if err != nil {558+ t.Fatal(err)559+ }560+561+ got, err := ImportModel(data)562+ if err != nil {563+ t.Fatal(err)564+ }565+566+ // Provided cells preserved.567+ for _, c := range provided {568+ if got.grid[c.y][c.x].v != c.v {569+ t.Errorf("provided cell (%d,%d) = %d, want %d", c.x, c.y, got.grid[c.y][c.x].v, c.v)570+ }571+ if !got.providedGrid[c.y][c.x] {572+ t.Errorf("providedGrid[%d][%d] = false, want true", c.y, c.x)573+ }574+ }575+576+ // User-entered values preserved.577+ if got.grid[1][0].v != 6 {578+ t.Errorf("user cell (0,1) = %d, want 6", got.grid[1][0].v)579+ }580+ if got.grid[2][2].v != 8 {581+ t.Errorf("user cell (2,2) = %d, want 8", got.grid[2][2].v)582+ }583+584+ // Non-provided cells should not be in providedGrid.585+ if got.providedGrid[1][0] {586+ t.Error("user cell (0,1) should not be marked as provided")587+ }588+ })589+590+ t.Run("provided vs user distinction", func(t *testing.T) {591+ provided := []cell{592+ {x: 0, y: 0, v: 5},593+ }594+ m := Model{595+ grid: newGrid(provided),596+ provided: provided,597+ providedGrid: buildProvidedGrid(provided),598+ keys: DefaultKeyMap,599+ }600+601+ data, err := m.GetSave()602+ if err != nil {603+ t.Fatal(err)604+ }605+606+ got, err := ImportModel(data)607+ if err != nil {608+ t.Fatal(err)609+ }610+611+ if !got.providedGrid[0][0] {612+ t.Error("expected (0,0) to be marked as provided")613+ }614+ if got.providedGrid[0][1] {615+ t.Error("expected (1,0) to not be marked as provided")616+ }617+ })618+619+ t.Run("empty user input", func(t *testing.T) {620+ provided := []cell{621+ {x: 0, y: 0, v: 5},622+ {x: 8, y: 8, v: 1},623+ }624+ m := Model{625+ grid: newGrid(provided),626+ provided: provided,627+ providedGrid: buildProvidedGrid(provided),628+ keys: DefaultKeyMap,629+ }630+631+ data, err := m.GetSave()632+ if err != nil {633+ t.Fatal(err)634+ }635+636+ got, err := ImportModel(data)637+ if err != nil {638+ t.Fatal(err)639+ }640+641+ // All non-provided cells should be 0.642+ for y := range gridSize {643+ for x := range gridSize {644+ if !got.providedGrid[y][x] && got.grid[y][x].v != 0 {645+ t.Errorf("non-provided cell (%d,%d) = %d, want 0", x, y, got.grid[y][x].v)646+ }647+ }648+ }649+ })650+651+ t.Run("invalid JSON", func(t *testing.T) {652+ _, err := ImportModel([]byte("not json"))653+ if err == nil {654+ t.Fatal("expected error for invalid JSON")655+ }656+ })657+}@@ -36,7 +36,7 @@ var (
3636 gridBorderSolvedStyle = lipgloss.NewStyle().3737 Border(lipgloss.RoundedBorder()).3838 BorderForeground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"}).39- BorderBackground(backgroundColor)39+ BorderBackground(solvedBG)4040 4141 statusBarStyle = lipgloss.NewStyle().4242 Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).@@ -0,0 +1,918 @@
1+package takuzu2+3+import (4+ "encoding/json"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// --- Helpers ---11+12+func makeGrid6(rows ...string) grid {13+ if len(rows) != 6 {14+ panic("makeGrid6 requires exactly 6 rows")15+ }16+ g := make(grid, 6)17+ for i, row := range rows {18+ g[i] = []rune(row)19+ }20+ return g21+}22+23+func validGrid6() grid {24+ // A valid 6x6 Takuzu grid (3 zeros, 3 ones per line, no triples, unique lines)25+ return makeGrid6(26+ "001011",27+ "110100",28+ "010101",29+ "101010",30+ "100110",31+ "011001",32+ )33+}34+35+// --- canPlace (P0) ---36+37+func TestCanPlace_Horizontal_Triples(t *testing.T) {38+ tests := []struct {39+ name string40+ row string41+ x int42+ val rune43+ want bool44+ }{45+ {"00. placing 0 at x=2", "00....", 2, zeroCell, false},46+ {"00. placing 1 at x=2", "00....", 2, oneCell, true},47+ {".00 placing 0 at x=0", ".00...", 0, zeroCell, false},48+ {".00 placing 1 at x=0", ".00...", 0, oneCell, true},49+ {"0.0 placing 0 at x=1", "0.0...", 1, zeroCell, false},50+ {"0.0 placing 1 at x=1", "0.0...", 1, oneCell, true},51+ {"01. placing 0 at x=2", "01....", 2, zeroCell, true},52+ {"11. placing 1 at x=2", "11....", 2, oneCell, false},53+ {".11 placing 1 at x=0", ".11...", 0, oneCell, false},54+ {"1.1 placing 1 at x=1", "1.1...", 1, oneCell, false},55+ }56+57+ for _, tt := range tests {58+ t.Run(tt.name, func(t *testing.T) {59+ g := makeGrid6(tt.row, "......", "......", "......", "......", "......")60+ if got := canPlace(g, 6, tt.x, 0, tt.val); got != tt.want {61+ t.Errorf("canPlace = %v, want %v", got, tt.want)62+ }63+ })64+ }65+}66+67+func TestCanPlace_Vertical_Triples(t *testing.T) {68+ tests := []struct {69+ name string70+ col string // column 0 values by row index; '.' = leave empty71+ y int72+ val rune73+ want bool74+ }{75+ {"y-2,y-1 match: 00. placing 0 at y=2", "00....", 2, zeroCell, false},76+ {"y-2,y-1 match: 00. placing 1 at y=2", "00....", 2, oneCell, true},77+ {"y+1,y+2 match: .00 placing 0 at y=0", ".00...", 0, zeroCell, false},78+ {"y-1,y+1 match: 0.0 placing 0 at y=1", "0.0...", 1, zeroCell, false},79+ {"y-1,y+1 match: 0.0 placing 1 at y=1", "0.0...", 1, oneCell, true},80+ {"no triple: 01. placing 0 at y=2", "01....", 2, zeroCell, true},81+ {"y-2,y-1 match ones: 11. placing 1 at y=2", "11....", 2, oneCell, false},82+ {"y+1,y+2 match ones: .11 placing 1 at y=0", ".11...", 0, oneCell, false},83+ {"y-1,y+1 match ones: 1.1 placing 1 at y=1", "1.1...", 1, oneCell, false},84+ {"bottom edge: ....00 placing 0 at y=3", "....00", 3, zeroCell, false},85+ }86+87+ for _, tt := range tests {88+ t.Run(tt.name, func(t *testing.T) {89+ g := make(grid, 6)90+ for y := range 6 {91+ g[y] = []rune("......")92+ if y < len(tt.col) && tt.col[y] != '.' {93+ g[y][0] = rune(tt.col[y])94+ }95+ }96+ if got := canPlace(g, 6, 0, tt.y, tt.val); got != tt.want {97+ t.Errorf("canPlace = %v, want %v", got, tt.want)98+ }99+ })100+ }101+}102+103+func TestCanPlace_EqualCounts(t *testing.T) {104+ t.Run("row at limit for zeros", func(t *testing.T) {105+ g := makeGrid6("000...", "......", "......", "......", "......", "......")106+ // Row already has 3 zeros, can't place another.107+ if got := canPlace(g, 6, 3, 0, zeroCell); got != false {108+ t.Error("canPlace should reject when row has size/2 zeros")109+ }110+ })111+112+ t.Run("row at limit for ones", func(t *testing.T) {113+ g := makeGrid6("111...", "......", "......", "......", "......", "......")114+ if got := canPlace(g, 6, 3, 0, oneCell); got != false {115+ t.Error("canPlace should reject when row has size/2 ones")116+ }117+ })118+119+ t.Run("row has room for both", func(t *testing.T) {120+ g := makeGrid6("01....", "......", "......", "......", "......", "......")121+ // Only 1 zero and 1 one, room for both.122+ if got := canPlace(g, 6, 2, 0, zeroCell); got != true {123+ t.Error("canPlace should allow when row has room")124+ }125+ if got := canPlace(g, 6, 2, 0, oneCell); got != true {126+ t.Error("canPlace should allow when row has room")127+ }128+ })129+130+ t.Run("column at limit", func(t *testing.T) {131+ g := make(grid, 6)132+ for y := range 6 {133+ g[y] = []rune("......")134+ }135+ // Put 3 zeros in column 0.136+ g[0][0] = zeroCell137+ g[1][0] = zeroCell138+ g[2][0] = zeroCell139+140+ if got := canPlace(g, 6, 0, 3, zeroCell); got != false {141+ t.Error("canPlace should reject when column has size/2 zeros")142+ }143+ })144+}145+146+func TestCanPlace_UniqueLines(t *testing.T) {147+ t.Run("completing row creates duplicate", func(t *testing.T) {148+ g := makeGrid6(149+ "00101.", // Will complete to 001011150+ "001011", // Already exists151+ "......",152+ "......",153+ "......",154+ "......",155+ )156+ // Placing '1' at (5, 0) would make row 0 identical to row 1.157+ if got := canPlace(g, 6, 5, 0, oneCell); got != false {158+ t.Error("canPlace should reject when completing row creates duplicate")159+ }160+ })161+162+ t.Run("completing row is unique", func(t *testing.T) {163+ g := makeGrid6(164+ "01101.", // Will complete to 011010 (3 zeros, 3 ones)165+ "001011", // Different166+ "......",167+ "......",168+ "......",169+ "......",170+ )171+ // Placing '0' at (5, 0) makes row 0 unique and has valid counts.172+ if got := canPlace(g, 6, 5, 0, zeroCell); got != true {173+ t.Error("canPlace should allow unique row completion")174+ }175+ })176+177+ t.Run("completing column creates duplicate", func(t *testing.T) {178+ g := make(grid, 6)179+ for y := range 6 {180+ g[y] = []rune("......")181+ }182+ // Column 0: 00101. Column 1: 001011 (complete)183+ g[0][0], g[1][0], g[2][0], g[3][0], g[4][0] = '0', '0', '1', '0', '1'184+ g[0][1], g[1][1], g[2][1], g[3][1], g[4][1], g[5][1] = '0', '0', '1', '0', '1', '1'185+186+ // Placing '1' at (0, 5) would duplicate column 1.187+ if got := canPlace(g, 6, 0, 5, oneCell); got != false {188+ t.Error("canPlace should reject when completing column creates duplicate")189+ }190+ })191+192+ t.Run("row not yet complete no uniqueness check", func(t *testing.T) {193+ g := makeGrid6(194+ "0010..", // Not complete195+ "001011",196+ "......",197+ "......",198+ "......",199+ "......",200+ )201+ // Placing at (4, 0) doesn't trigger uniqueness check (row still incomplete after).202+ if got := canPlace(g, 6, 4, 0, oneCell); got != true {203+ t.Error("canPlace should allow when row not fully complete")204+ }205+ })206+}207+208+// --- rowEqual / colEqual (P0) ---209+210+func TestRowEqual(t *testing.T) {211+ tests := []struct {212+ name string213+ a, b []rune214+ want bool215+ }{216+ {"identical", []rune{'0', '1', '0', '1'}, []rune{'0', '1', '0', '1'}, true},217+ {"different", []rune{'0', '1', '0', '1'}, []rune{'0', '1', '1', '0'}, false},218+ {"different lengths", []rune{'0', '1'}, []rune{'0', '1', '0'}, false},219+ {"both empty", []rune{}, []rune{}, true},220+ }221+222+ for _, tt := range tests {223+ t.Run(tt.name, func(t *testing.T) {224+ if got := rowEqual(tt.a, tt.b); got != tt.want {225+ t.Errorf("rowEqual = %v, want %v", got, tt.want)226+ }227+ })228+ }229+}230+231+func TestColEqual(t *testing.T) {232+ g := makeGrid6(233+ "001011",234+ "010101",235+ "101010",236+ "110100",237+ "001011",238+ "010001",239+ )240+241+ t.Run("identical columns", func(t *testing.T) {242+ // Columns 2 and 4 are both [1,0,1,0,1,0].243+ if !colEqual(g, 6, 2, 4) {244+ t.Error("colEqual should return true for identical columns")245+ }246+ })247+248+ t.Run("different columns", func(t *testing.T) {249+ if colEqual(g, 6, 0, 1) {250+ t.Error("colEqual should return false for different columns")251+ }252+ })253+}254+255+// --- rowEqualWith / colEqualWith (P0) ---256+257+func TestRowEqualWith(t *testing.T) {258+ a := []rune{'0', '0', '1', '0', '1', '1'}259+ b := []rune{'0', '0', '1', '0', '1', '1'}260+261+ t.Run("override makes no difference when already equal", func(t *testing.T) {262+ // a[3]='0', override with '0', should still match b[3]='0'263+ if !rowEqualWith(a, b, 3, '0') {264+ t.Error("rowEqualWith should return true")265+ }266+ })267+268+ t.Run("override fixes difference", func(t *testing.T) {269+ b2 := []rune{'0', '0', '1', '1', '1', '1'} // Differs at index 3270+ if !rowEqualWith(a, b2, 3, '1') {271+ t.Error("rowEqualWith should return true when override fixes it")272+ }273+ })274+275+ t.Run("override doesn't help", func(t *testing.T) {276+ b2 := []rune{'1', '0', '1', '0', '1', '1'} // Differs at index 0277+ if rowEqualWith(a, b2, 3, '1') {278+ t.Error("rowEqualWith should return false when override doesn't match")279+ }280+ })281+282+ t.Run("different lengths", func(t *testing.T) {283+ b2 := []rune{'0', '0', '1'}284+ if rowEqualWith(a, b2, 0, '0') {285+ t.Error("rowEqualWith should return false for different lengths")286+ }287+ })288+}289+290+func TestColEqualWith(t *testing.T) {291+ g := makeGrid6(292+ "001011",293+ "001011",294+ "101010",295+ "110100",296+ "001011",297+ "001001",298+ )299+300+ t.Run("override fixes difference", func(t *testing.T) {301+ // Column 0 and 1 differ only at row 2 (g[2][0]='1', g[2][1]='0')302+ // If we treat g[2][0] as '0', they'd be equal.303+ if !colEqualWith(g, 6, 0, 1, 2, '0') {304+ t.Error("colEqualWith should return true when override matches")305+ }306+ })307+308+ t.Run("override doesn't help", func(t *testing.T) {309+ if colEqualWith(g, 6, 0, 2, 1, '0') {310+ t.Error("colEqualWith should return false when columns differ elsewhere")311+ }312+ })313+}314+315+// --- rowFilled / colFilled / rowFilledExcept / colFilledExcept (P1) ---316+317+func TestRowFilled(t *testing.T) {318+ g := makeGrid6(319+ "001011",320+ "01....",321+ "......",322+ "101010",323+ "......",324+ "......",325+ )326+327+ tests := []struct {328+ name string329+ y int330+ want bool331+ }{332+ {"all filled", 0, true},333+ {"has empty", 1, false},334+ {"all empty", 2, false},335+ {"all filled again", 3, true},336+ }337+338+ for _, tt := range tests {339+ t.Run(tt.name, func(t *testing.T) {340+ if got := rowFilled(g, tt.y, 6); got != tt.want {341+ t.Errorf("rowFilled(row %d) = %v, want %v", tt.y, got, tt.want)342+ }343+ })344+ }345+}346+347+func TestColFilled(t *testing.T) {348+ g := makeGrid6(349+ "0.....",350+ "0.....",351+ "1.....",352+ "0.....",353+ "1.....",354+ "1.....",355+ )356+357+ t.Run("all filled", func(t *testing.T) {358+ if !colFilled(g, 0, 6) {359+ t.Error("colFilled should return true for filled column")360+ }361+ })362+363+ t.Run("has empty", func(t *testing.T) {364+ if colFilled(g, 1, 6) {365+ t.Error("colFilled should return false for empty column")366+ }367+ })368+}369+370+func TestRowFilledExcept(t *testing.T) {371+ g := makeGrid6(372+ "00101.",373+ "0.1...",374+ "......",375+ "......",376+ "......",377+ "......",378+ )379+380+ t.Run("skip is only empty", func(t *testing.T) {381+ if !rowFilledExcept(g, 0, 5, 6) {382+ t.Error("rowFilledExcept should return true when skip is only empty cell")383+ }384+ })385+386+ t.Run("another empty exists", func(t *testing.T) {387+ if rowFilledExcept(g, 1, 1, 6) {388+ t.Error("rowFilledExcept should return false when other empties exist")389+ }390+ })391+392+ t.Run("all empty except skip", func(t *testing.T) {393+ if rowFilledExcept(g, 2, 0, 6) {394+ t.Error("rowFilledExcept should return false for all-empty row")395+ }396+ })397+}398+399+func TestColFilledExcept(t *testing.T) {400+ g := makeGrid6(401+ "0.....",402+ "0.....",403+ "......",404+ "1.....",405+ "0.....",406+ "1.....",407+ )408+409+ t.Run("skip is only empty", func(t *testing.T) {410+ if !colFilledExcept(g, 0, 2, 6) {411+ t.Error("colFilledExcept should return true when skip is only empty")412+ }413+ })414+415+ t.Run("another empty exists", func(t *testing.T) {416+ if colFilledExcept(g, 1, 0, 6) {417+ t.Error("colFilledExcept should return false when column has other empties")418+ }419+ })420+}421+422+// --- checkConstraints (P0) ---423+424+func TestCheckConstraints(t *testing.T) {425+ t.Run("valid grid", func(t *testing.T) {426+ g := validGrid6()427+ if !checkConstraints(g, 6) {428+ t.Error("checkConstraints should return true for valid grid")429+ }430+ })431+432+ t.Run("triple in row", func(t *testing.T) {433+ g := makeGrid6(434+ "000111", // Triple 0s and triple 1s435+ "010101",436+ "101010",437+ "110100",438+ "100110",439+ "011001",440+ )441+ if checkConstraints(g, 6) {442+ t.Error("checkConstraints should reject grid with triple in row")443+ }444+ })445+446+ t.Run("triple in column", func(t *testing.T) {447+ g := makeGrid6(448+ "001011",449+ "001011",450+ "001011", // Column 0 has triple 0s451+ "110100",452+ "100110",453+ "011001",454+ )455+ if checkConstraints(g, 6) {456+ t.Error("checkConstraints should reject grid with triple in column")457+ }458+ })459+460+ t.Run("unequal row count", func(t *testing.T) {461+ g := makeGrid6(462+ "000011", // Valid count463+ "010101",464+ "101010",465+ "111000", // Valid count466+ "100110",467+ "011001",468+ )469+ // Modify to break count: row 0 now has 4 zeros, 2 ones.470+ g[0] = []rune("000001")471+ if checkConstraints(g, 6) {472+ t.Error("checkConstraints should reject unequal row count")473+ }474+ })475+476+ t.Run("unequal column count", func(t *testing.T) {477+ g := validGrid6()478+ // Break column 0 count.479+ g[0][0] = oneCell480+ g[1][0] = oneCell481+ g[2][0] = oneCell482+ g[3][0] = oneCell // Now column has 4 ones483+ if checkConstraints(g, 6) {484+ t.Error("checkConstraints should reject unequal column count")485+ }486+ })487+}488+489+// --- hasUniqueLines (P0) ---490+491+func TestHasUniqueLines(t *testing.T) {492+ t.Run("all unique", func(t *testing.T) {493+ g := validGrid6()494+ if !hasUniqueLines(g, 6) {495+ t.Error("hasUniqueLines should return true for unique grid")496+ }497+ })498+499+ t.Run("duplicate rows", func(t *testing.T) {500+ g := makeGrid6(501+ "001011",502+ "010101",503+ "001011", // Duplicate of row 0504+ "110100",505+ "100110",506+ "011001",507+ )508+ if hasUniqueLines(g, 6) {509+ t.Error("hasUniqueLines should return false for duplicate rows")510+ }511+ })512+513+ t.Run("duplicate columns", func(t *testing.T) {514+ g := makeGrid6(515+ "001011",516+ "001101",517+ "101010",518+ "101100",519+ "001011",520+ "001001",521+ )522+ // Columns 0 and 3 are both [0,0,1,1,0,0].523+ if hasUniqueLines(g, 6) {524+ t.Error("hasUniqueLines should return false for duplicate columns")525+ }526+ })527+528+ t.Run("partial duplicate still unique", func(t *testing.T) {529+ g := makeGrid6(530+ "001011",531+ "001101", // Shares prefix with row 0 but differs at end532+ "101010",533+ "110100",534+ "100110",535+ "011001",536+ )537+ if !hasUniqueLines(g, 6) {538+ t.Error("hasUniqueLines should return true when rows only partially match")539+ }540+ })541+}542+543+// --- Grid serialization (P1) ---544+545+func TestGridSerialization(t *testing.T) {546+ t.Run("6x6 round-trip", func(t *testing.T) {547+ g := makeGrid6("001011", "010101", "101010", "110100", "100110", "011001")548+ s := g.String()549+ g2 := newGrid(state(s))550+551+ if len(g2) != len(g) {552+ t.Fatalf("row count = %d, want %d", len(g2), len(g))553+ }554+ for y := range g {555+ for x := range g[y] {556+ if g2[y][x] != g[y][x] {557+ t.Errorf("cell (%d,%d) = %c, want %c", x, y, g2[y][x], g[y][x])558+ }559+ }560+ }561+ })562+563+ t.Run("8x8 round-trip", func(t *testing.T) {564+ s := createEmptyState(8)565+ g := newGrid(s)566+ s2 := g.String()567+ if string(s) != s2 {568+ t.Error("8x8 round-trip failed")569+ }570+ })571+572+ t.Run("all empty", func(t *testing.T) {573+ s := createEmptyState(6)574+ g := newGrid(s)575+ for y := range g {576+ for x := range g[y] {577+ if g[y][x] != emptyCell {578+ t.Errorf("cell (%d,%d) = %c, want emptyCell", x, y, g[y][x])579+ }580+ }581+ }582+ })583+}584+585+func TestGridClone(t *testing.T) {586+ original := makeGrid6("001011", "010101", "101010", "110100", "100110", "011001")587+ cloned := original.clone()588+589+ t.Run("content match", func(t *testing.T) {590+ for y := range original {591+ for x := range original[y] {592+ if cloned[y][x] != original[y][x] {593+ t.Errorf("cloned[%d][%d] = %c, want %c", y, x, cloned[y][x], original[y][x])594+ }595+ }596+ }597+ })598+599+ t.Run("deep copy", func(t *testing.T) {600+ cloned[0][0] = 'X'601+ if original[0][0] == 'X' {602+ t.Error("clone modified original grid")603+ }604+ })605+}606+607+func TestCreateEmptyState(t *testing.T) {608+ t.Run("size 6", func(t *testing.T) {609+ s := createEmptyState(6)610+ g := newGrid(s)611+ if len(g) != 6 {612+ t.Fatalf("row count = %d, want 6", len(g))613+ }614+ for y := range g {615+ if len(g[y]) != 6 {616+ t.Fatalf("col count at row %d = %d, want 6", y, len(g[y]))617+ }618+ for x := range g[y] {619+ if g[y][x] != emptyCell {620+ t.Errorf("cell (%d,%d) = %c, want emptyCell", x, y, g[y][x])621+ }622+ }623+ }624+ })625+626+ t.Run("size 0", func(t *testing.T) {627+ s := createEmptyState(0)628+ if s != "" {629+ t.Errorf("createEmptyState(0) = %q, want empty", s)630+ }631+ })632+}633+634+// --- serializeProvided / deserializeProvided (P1) ---635+636+func TestProvidedSerialization(t *testing.T) {637+ t.Run("round-trip", func(t *testing.T) {638+ p := [][]bool{639+ {true, false, true, false, true, false},640+ {false, true, false, true, false, true},641+ {true, true, false, false, true, true},642+ {false, false, true, true, false, false},643+ {true, false, true, false, true, false},644+ {false, true, false, true, false, true},645+ }646+ s := serializeProvided(p)647+ p2 := deserializeProvided(s, 6)648+649+ if len(p2) != 6 {650+ t.Fatalf("row count = %d, want 6", len(p2))651+ }652+ for y := range p {653+ for x := range p[y] {654+ if p2[y][x] != p[y][x] {655+ t.Errorf("provided[%d][%d] = %v, want %v", y, x, p2[y][x], p[y][x])656+ }657+ }658+ }659+ })660+661+ t.Run("all provided", func(t *testing.T) {662+ p := make([][]bool, 3)663+ for y := range p {664+ p[y] = []bool{true, true, true}665+ }666+ s := serializeProvided(p)667+ if s != "###\n###\n###" {668+ t.Errorf("serializeProvided = %q, want ###\\n###\\n###", s)669+ }670+ })671+672+ t.Run("none provided", func(t *testing.T) {673+ p := make([][]bool, 3)674+ for y := range p {675+ p[y] = []bool{false, false, false}676+ }677+ s := serializeProvided(p)678+ if s != "...\n...\n..." {679+ t.Errorf("serializeProvided = %q, want ...\\n...\\n...", s)680+ }681+ })682+683+ t.Run("checkerboard", func(t *testing.T) {684+ p := [][]bool{685+ {true, false},686+ {false, true},687+ }688+ s := serializeProvided(p)689+ expected := "#.\n.#"690+ if s != expected {691+ t.Errorf("serializeProvided = %q, want %q", s, expected)692+ }693+ p2 := deserializeProvided(s, 2)694+ for y := range p {695+ for x := range p[y] {696+ if p2[y][x] != p[y][x] {697+ t.Errorf("deserialized[%d][%d] = %v, want %v", y, x, p2[y][x], p[y][x])698+ }699+ }700+ }701+ })702+}703+704+// --- fillGrid (P2) ---705+706+func TestFillGrid(t *testing.T) {707+ t.Run("fills 6x6", func(t *testing.T) {708+ g := newGrid(createEmptyState(6))709+ ok := fillGrid(g, 6)710+ if !ok {711+ t.Fatal("fillGrid returned false")712+ }713+714+ // Check no empty cells.715+ for y := range g {716+ for x := range g[y] {717+ if g[y][x] == emptyCell {718+ t.Errorf("cell (%d,%d) is still empty", x, y)719+ }720+ }721+ }722+ })723+724+ t.Run("result valid", func(t *testing.T) {725+ g := newGrid(createEmptyState(6))726+ fillGrid(g, 6)727+ if !checkConstraints(g, 6) {728+ t.Error("fillGrid produced grid that fails checkConstraints")729+ }730+ })731+732+ t.Run("result has unique lines", func(t *testing.T) {733+ g := newGrid(createEmptyState(6))734+ fillGrid(g, 6)735+ if !hasUniqueLines(g, 6) {736+ t.Error("fillGrid produced grid with duplicate lines")737+ }738+ })739+}740+741+// --- countSolutions (P2) ---742+743+func TestCountSolutions(t *testing.T) {744+ t.Run("complete valid grid", func(t *testing.T) {745+ g := validGrid6()746+ count := countSolutions(g, 6, 10)747+ if count != 1 {748+ t.Errorf("countSolutions = %d, want 1", count)749+ }750+ })751+752+ t.Run("one empty cell unique", func(t *testing.T) {753+ g := validGrid6()754+ // Remove one cell.755+ saved := g[0][0]756+ g[0][0] = emptyCell757+ count := countSolutions(g, 6, 10)758+ if count != 1 {759+ t.Errorf("countSolutions = %d, want 1", count)760+ }761+ g[0][0] = saved762+ })763+764+ t.Run("multiple solutions with limit", func(t *testing.T) {765+ g := newGrid(createEmptyState(6))766+ // Empty grid has many solutions, should hit limit.767+ count := countSolutions(g, 6, 2)768+ if count < 2 {769+ t.Errorf("countSolutions = %d, want >= 2", count)770+ }771+ })772+773+ t.Run("respects limit", func(t *testing.T) {774+ g := newGrid(createEmptyState(6))775+ count := countSolutions(g, 6, 3)776+ if count > 3 {777+ t.Errorf("countSolutions = %d, should not exceed limit 3", count)778+ }779+ })780+}781+782+// --- generatePuzzle (P2) ---783+784+func TestGeneratePuzzle(t *testing.T) {785+ complete := generateComplete(6)786+787+ t.Run("unique solution", func(t *testing.T) {788+ puzzle, _ := generatePuzzle(complete, 6, 0.5)789+ count := countSolutions(puzzle, 6, 2)790+ if count != 1 {791+ t.Errorf("generated puzzle has %d solutions, want 1", count)792+ }793+ })794+795+ t.Run("provided mask consistent with empties", func(t *testing.T) {796+ puzzle, provided := generatePuzzle(complete, 6, 0.5)797+ for y := range puzzle {798+ for x := range puzzle[y] {799+ isEmpty := puzzle[y][x] == emptyCell800+ isProvided := provided[y][x]801+ if isEmpty == isProvided {802+ t.Errorf("cell (%d,%d): isEmpty=%v but isProvided=%v", x, y, isEmpty, isProvided)803+ }804+ }805+ }806+ })807+808+ t.Run("approximate prefill", func(t *testing.T) {809+ _, provided := generatePuzzle(complete, 6, 0.5)810+ count := 0811+ for y := range provided {812+ for x := range provided[y] {813+ if provided[y][x] {814+ count++815+ }816+ }817+ }818+ target := 18 // 50% of 36819+ if count < target-5 || count > target+5 {820+ t.Errorf("provided cell count = %d, want ~%d (±5)", count, target)821+ }822+ })823+}824+825+// --- Save/Load round-trip (P1) ---826+827+func TestSaveLoadRoundTrip(t *testing.T) {828+ puzzle := makeGrid6("00101.", "01....", "......", "......", "......", "......")829+ provided := make([][]bool, 6)830+ for y := range 6 {831+ provided[y] = make([]bool, 6)832+ for x := range 6 {833+ provided[y][x] = puzzle[y][x] != emptyCell834+ }835+ }836+837+ m := Model{838+ size: 6,839+ grid: puzzle,840+ provided: provided,841+ cursor: game.Cursor{X: 2, Y: 3},842+ keys: DefaultKeyMap,843+ modeTitle: "Medium",844+ solved: false,845+ }846+847+ data, err := m.GetSave()848+ if err != nil {849+ t.Fatal(err)850+ }851+852+ got, err := ImportModel(data)853+ if err != nil {854+ t.Fatal(err)855+ }856+857+ // Size.858+ if got.size != 6 {859+ t.Errorf("size = %d, want 6", got.size)860+ }861+862+ // Grid content.863+ if got.grid.String() != m.grid.String() {864+ t.Error("grid mismatch")865+ }866+867+ // Provided matrix.868+ for y := range provided {869+ for x := range provided[y] {870+ if got.provided[y][x] != provided[y][x] {871+ t.Errorf("provided[%d][%d] = %v, want %v", y, x, got.provided[y][x], provided[y][x])872+ }873+ }874+ }875+876+ // Mode title.877+ if got.modeTitle != "Medium" {878+ t.Errorf("modeTitle = %q, want %q", got.modeTitle, "Medium")879+ }880+}881+882+func TestImportModel_InvalidJSON(t *testing.T) {883+ _, err := ImportModel([]byte("not json"))884+ if err == nil {885+ t.Fatal("expected error for invalid JSON")886+ }887+}888+889+func TestImportModel_InvalidSize(t *testing.T) {890+ data, _ := json.Marshal(Save{Size: 0})891+ _, err := ImportModel(data)892+ if err == nil {893+ t.Fatal("expected error for invalid size")894+ }895+}896+897+func TestImportModel_SolvedRecalculated(t *testing.T) {898+ g := validGrid6()899+ provided := make([][]bool, 6)900+ for y := range 6 {901+ provided[y] = make([]bool, 6)902+ }903+904+ data, _ := json.Marshal(Save{905+ Size: 6,906+ State: g.String(),907+ Provided: serializeProvided(provided),908+ })909+910+ got, err := ImportModel(data)911+ if err != nil {912+ t.Fatal(err)913+ }914+915+ if !got.solved {916+ t.Error("ImportModel should set solved=true for complete valid grid")917+ }918+}