clone

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

feat/elo-difficulty
puzzletea / games / hitori / hitori_test.go
hitori_test.go 36.0K
1package hitori23import (4 "encoding/json"5 "image"6 "image/color"7 "math/rand/v2"8 "strings"9 "testing"1011 tea "charm.land/bubbletea/v2"12 "github.com/FelineStateMachine/puzzletea/game"13 "github.com/FelineStateMachine/puzzletea/theme"14 "github.com/charmbracelet/x/ansi"15)1617// --- Helpers ---1819func makeGrid(rows ...string) grid {20 g := make(grid, len(rows))21 for i, row := range rows {22 g[i] = []rune(row)23 }24 return g25}2627func makeMarks(rows ...string) [][]cellMark {28 marks := make([][]cellMark, len(rows))29 for y, row := range rows {30 marks[y] = make([]cellMark, len(row))31 for x, r := range row {32 switch r {33 case 'X':34 marks[y][x] = shaded35 case 'O':36 marks[y][x] = circled37 default:38 marks[y][x] = unmarked39 }40 }41 }42 return marks43}4445func testMode(size int) HitoriMode {46 return NewMode("Test", "test mode", size, 0.25)47}4849func sameColor(left, right color.Color) bool {50 if left == nil || right == nil {51 return left == nil && right == nil52 }53 lr, lg, lb, la := left.RGBA()54 rr, rg, rb, ra := right.RGBA()55 return lr == rr && lg == rg && lb == rb && la == ra56}5758// --- Grid and serialization (P0) ---5960func TestNewGrid(t *testing.T) {61 g := newGrid("123\n456\n789")62 if len(g) != 3 {63 t.Fatalf("expected 3 rows, got %d", len(g))64 }65 if string(g[0]) != "123" {66 t.Errorf("row 0 = %q, want %q", string(g[0]), "123")67 }68 if string(g[2]) != "789" {69 t.Errorf("row 2 = %q, want %q", string(g[2]), "789")70 }71}7273func TestGridString(t *testing.T) {74 g := makeGrid("123", "456", "789")75 got := g.String()76 want := "123\n456\n789"77 if got != want {78 t.Errorf("grid.String() = %q, want %q", got, want)79 }80}8182func TestGridClone(t *testing.T) {83 g := makeGrid("123", "456")84 c := g.clone()85 c[0][0] = 'X'86 if g[0][0] == 'X' {87 t.Error("clone shares memory with original")88 }89}9091func TestSerializeDeserializeMarks(t *testing.T) {92 marks := makeMarks("..X", "O..", "XO.")93 serialized := serializeMarks(marks)94 deserialized := deserializeMarks(serialized, 3)9596 for y := range 3 {97 for x := range 3 {98 if deserialized[y][x] != marks[y][x] {99 t.Errorf("mark[%d][%d] = %d, want %d", y, x, deserialized[y][x], marks[y][x])100 }101 }102 }103}104105func TestCloneMarks(t *testing.T) {106 marks := makeMarks("X.", ".O")107 c := cloneMarks(marks)108 c[0][0] = unmarked109 if marks[0][0] != shaded {110 t.Error("cloneMarks shares memory with original")111 }112}113114// --- Rendering: cursor + mark visibility (P0) ---115116func TestCellView_CursorUnmarkedPreservesNumber(t *testing.T) {117 got := cellView('5', unmarked, true, false, false, false, false)118 if !strings.Contains(got, "5") {119 t.Fatalf("cursor unmarked cell should show number, got %q", got)120 }121 if strings.Contains(got, game.CursorLeft) || strings.Contains(got, game.CursorRight) {122 t.Fatalf("cursor unmarked cell should not use cursor brackets, got %q", got)123 }124}125126func TestCellView_CursorCircledPreservesNumberAndDiffersFromUnmarked(t *testing.T) {127 unmarkedCell := cellView('5', unmarked, true, false, false, false, false)128 circledCell := cellView('5', circled, true, false, false, false, false)129130 if !strings.Contains(circledCell, "5") {131 t.Fatalf("cursor circled cell should show number, got %q", circledCell)132 }133 if circledCell == unmarkedCell {134 t.Fatalf("cursor circled cell should differ from cursor unmarked cell, got %q", circledCell)135 }136}137138func TestCellView_CursorShadedShowsVisiblePayload(t *testing.T) {139 got := cellView('5', shaded, true, false, false, false, false)140 if !strings.Contains(got, "█") {141 t.Fatalf("cursor shaded cell should show visible shaded payload, got %q", got)142 }143}144145func TestCellView_CursorConflictVariantsDifferFromPlainCursor(t *testing.T) {146 plainCircled := cellView('5', circled, true, false, false, false, false)147 conflictCircled := cellView('5', circled, true, false, false, false, true)148 plainShaded := cellView('5', shaded, true, false, false, false, false)149 conflictShaded := cellView('5', shaded, true, false, false, false, true)150151 if conflictCircled == plainCircled {152 t.Fatalf("cursor circled conflict should differ from plain cursor circled, got %q", conflictCircled)153 }154 if conflictShaded == plainShaded {155 t.Fatalf("cursor shaded conflict should differ from plain cursor shaded, got %q", conflictShaded)156 }157}158159func TestCellView_CursorSolvedShadedPreservesShadedPayload(t *testing.T) {160 got := cellView('5', shaded, true, false, false, true, false)161 if !strings.Contains(got, "█") {162 t.Fatalf("cursor solved shaded cell should preserve shaded payload, got %q", got)163 }164}165166func TestGridViewUsesDynamicGridWithoutInteriorSeparators(t *testing.T) {167 numbers := makeGrid("123", "456", "789")168 marks := makeMarks("...", "...", "...")169 conflicts := computeConflicts(numbers, marks, 3)170171 lines := strings.Split(ansi.Strip(gridView(numbers, marks, game.Cursor{X: 1, Y: 1}, false, conflicts)), "\n")172 if got, want := len(lines), 7; got != want {173 t.Fatalf("line count = %d, want %d", got, want)174 }175 if !strings.HasPrefix(lines[0], "┌") || !strings.HasSuffix(lines[0], "┐") {176 t.Fatalf("top row = %q, want outer border corners", lines[0])177 }178 last := lines[len(lines)-1]179 if !strings.HasPrefix(last, "└") || !strings.HasSuffix(last, "┘") {180 t.Fatalf("bottom row = %q, want outer border corners", last)181 }182183 for _, idx := range []int{1, 2, 3, 4, 5} {184 line := lines[idx]185 if strings.Count(line, "│") != 2 {186 t.Fatalf("line %d = %q, want only outer vertical borders", idx, line)187 }188 }189 for _, idx := range []int{2, 4} {190 if strings.Contains(lines[idx], "─") {191 t.Fatalf("line %d = %q, want no interior horizontal separators", idx, lines[idx])192 }193 }194}195196func TestBridgeFillUsesVerticalCrosshairAcrossOpenInterior(t *testing.T) {197 numbers := makeGrid("12", "34")198 marks := makeMarks("..", "..")199 conflicts := computeConflicts(numbers, marks, 2)200201 got := bridgeFill(numbers, marks, game.Cursor{X: 0, Y: 1}, false, conflicts, game.DynamicGridBridge{202 Kind: game.DynamicGridBridgeVertical,203 X: 1,204 Y: 1,205 Count: 2,206 Uniform: true,207 Cells: [4]image.Point{208 {X: 0, Y: 1},209 {X: 1, Y: 1},210 },211 })212 if !sameColor(got, theme.Current().Surface) {213 t.Fatal("expected open vertical bridge on cursor row to use crosshair background")214 }215}216217func TestBridgeFillUsesHorizontalCrosshairAcrossOpenInterior(t *testing.T) {218 numbers := makeGrid("12", "34")219 marks := makeMarks("..", "..")220 conflicts := computeConflicts(numbers, marks, 2)221222 got := bridgeFill(numbers, marks, game.Cursor{X: 0, Y: 1}, false, conflicts, game.DynamicGridBridge{223 Kind: game.DynamicGridBridgeHorizontal,224 X: 0,225 Y: 1,226 Count: 2,227 Uniform: true,228 Cells: [4]image.Point{229 {X: 0, Y: 0},230 {X: 0, Y: 1},231 },232 })233 if !sameColor(got, theme.Current().Surface) {234 t.Fatal("expected open horizontal bridge on cursor column to use crosshair background")235 }236}237238func TestBridgeFillLeavesInteriorJunctionOpen(t *testing.T) {239 numbers := makeGrid("12", "34")240 marks := makeMarks("..", "..")241 conflicts := computeConflicts(numbers, marks, 2)242243 got := bridgeFill(numbers, marks, game.Cursor{X: 0, Y: 1}, false, conflicts, game.DynamicGridBridge{244 Kind: game.DynamicGridBridgeJunction,245 X: 1,246 Y: 1,247 Count: 4,248 Uniform: true,249 Cells: [4]image.Point{250 {X: 0, Y: 0},251 {X: 1, Y: 0},252 {X: 0, Y: 1},253 {X: 1, Y: 1},254 },255 })256 if got != nil {257 t.Fatal("expected open interior junction to remain unfilled")258 }259}260261func TestBridgeFillReturnsNilWhenSolved(t *testing.T) {262 numbers := makeGrid("12", "34")263 marks := makeMarks("..", "..")264 conflicts := computeConflicts(numbers, marks, 2)265266 got := bridgeFill(numbers, marks, game.Cursor{X: 0, Y: 0}, true, conflicts, game.DynamicGridBridge{267 Kind: game.DynamicGridBridgeVertical,268 X: 1,269 Y: 0,270 Count: 2,271 Uniform: true,272 Cells: [4]image.Point{273 {X: 0, Y: 0},274 {X: 1, Y: 0},275 },276 })277 if got != nil {278 t.Fatal("expected solved bridges to use dynamic grid default solved styling")279 }280}281282// --- Propagation: required moves (P0) ---283284func TestPropagateRequiredMarks_ShadedCellForcesOrthogonalWhite(t *testing.T) {285 numbers := makeGrid("123", "456", "789")286 userMarks := makeMarks("...", ".X.", "...")287288 got := propagateRequiredMarks(numbers, userMarks, 3)289 want := makeMarks(".O.", "OXO", ".O.")290291 for y := range 3 {292 for x := range 3 {293 if got[y][x] != want[y][x] {294 t.Fatalf("mark[%d][%d] = %v, want %v", y, x, got[y][x], want[y][x])295 }296 }297 }298}299300func TestPropagateRequiredMarks_CircledRowDuplicateForcesPeersBlack(t *testing.T) {301 numbers := makeGrid("121", "345", "678")302 userMarks := makeMarks("O..", "...", "...")303304 got := propagateRequiredMarks(numbers, userMarks, 3)305306 if got[0][0] != circled {307 t.Fatalf("mark[0][0] = %v, want circled", got[0][0])308 }309 if got[0][2] != shaded {310 t.Fatalf("mark[0][2] = %v, want shaded", got[0][2])311 }312}313314func TestPropagateRequiredMarks_CircledColumnDuplicateForcesPeersBlack(t *testing.T) {315 numbers := makeGrid("123", "456", "173")316 userMarks := makeMarks("O..", "...", "...")317318 got := propagateRequiredMarks(numbers, userMarks, 3)319320 if got[2][0] != shaded {321 t.Fatalf("mark[2][0] = %v, want shaded", got[2][0])322 }323}324325func TestPropagateRequiredMarks_OnlyRemainingDuplicateSurvivorBecomesWhite(t *testing.T) {326 numbers := makeGrid("111", "234", "567")327 userMarks := makeMarks("X.X", "...", "...")328329 got := propagateRequiredMarks(numbers, userMarks, 3)330331 if got[0][1] != circled {332 t.Fatalf("mark[0][1] = %v, want circled", got[0][1])333 }334}335336func TestPropagateRequiredMarks_DoesNotCascadeFromDerivedMarks(t *testing.T) {337 numbers := makeGrid("1223", "3412", "4321", "2143")338 userMarks := makeMarks("X...", "....", "....", "....")339340 got := propagateRequiredMarks(numbers, userMarks, 4)341342 if got[0][1] != circled {343 t.Fatalf("mark[0][1] = %v, want circled", got[0][1])344 }345 if got[0][2] != unmarked {346 t.Fatalf("mark[0][2] = %v, want unmarked", got[0][2])347 }348 if got[0][3] != unmarked {349 t.Fatalf("mark[0][3] = %v, want unmarked", got[0][3])350 }351 if got[1][2] != unmarked {352 t.Fatalf("mark[1][2] = %v, want unmarked", got[1][2])353 }354}355356func TestPropagateRequiredMarks_PreservesConflictingUserMarks(t *testing.T) {357 numbers := makeGrid("1223", "3412", "4321", "2143")358 userMarks := makeMarks("XX..", "....", "....", "....")359360 got := propagateRequiredMarks(numbers, userMarks, 4)361362 if got[0][0] != shaded {363 t.Fatalf("mark[0][0] = %v, want shaded", got[0][0])364 }365 if got[0][1] != shaded {366 t.Fatalf("mark[0][1] = %v, want shaded", got[0][1])367 }368}369370// --- Validation: hasNoDuplicatesInRows (P0) ---371372func TestHasNoDuplicatesInRows(t *testing.T) {373 tests := []struct {374 name string375 numbers grid376 marks [][]cellMark377 want bool378 }{379 {380 name: "no duplicates",381 numbers: makeGrid("123", "231", "312"),382 marks: makeMarks("...", "...", "..."),383 want: true,384 },385 {386 name: "duplicate in row but one is shaded",387 numbers: makeGrid("113", "231", "312"),388 marks: makeMarks("X..", "...", "..."),389 want: true,390 },391 {392 name: "duplicate in row neither shaded",393 numbers: makeGrid("113", "231", "312"),394 marks: makeMarks("...", "...", "..."),395 want: false,396 },397 }398399 for _, tt := range tests {400 t.Run(tt.name, func(t *testing.T) {401 got := hasNoDuplicatesInRows(tt.numbers, tt.marks, len(tt.numbers))402 if got != tt.want {403 t.Errorf("hasNoDuplicatesInRows = %v, want %v", got, tt.want)404 }405 })406 }407}408409// --- Validation: hasNoDuplicatesInCols (P0) ---410411func TestHasNoDuplicatesInCols(t *testing.T) {412 tests := []struct {413 name string414 numbers grid415 marks [][]cellMark416 want bool417 }{418 {419 name: "no duplicates in columns",420 numbers: makeGrid("123", "231", "312"),421 marks: makeMarks("...", "...", "..."),422 want: true,423 },424 {425 name: "duplicate in column but one shaded",426 numbers: makeGrid("123", "131", "312"),427 marks: makeMarks("...", "X..", "..."),428 want: true,429 },430 {431 name: "duplicate in column neither shaded",432 numbers: makeGrid("123", "131", "312"),433 marks: makeMarks("...", "...", "..."),434 want: false,435 },436 }437438 for _, tt := range tests {439 t.Run(tt.name, func(t *testing.T) {440 got := hasNoDuplicatesInCols(tt.numbers, tt.marks, len(tt.numbers))441 if got != tt.want {442 t.Errorf("hasNoDuplicatesInCols = %v, want %v", got, tt.want)443 }444 })445 }446}447448// --- Validation: hasNoAdjacentShaded (P0) ---449450func TestHasNoAdjacentShaded(t *testing.T) {451 tests := []struct {452 name string453 marks [][]cellMark454 want bool455 }{456 {457 name: "no shaded cells",458 marks: makeMarks("...", "...", "..."),459 want: true,460 },461 {462 name: "diagonal shaded OK",463 marks: makeMarks("X..", ".X.", "..X"),464 want: true,465 },466 {467 name: "horizontal adjacent",468 marks: makeMarks("XX.", "...", "..."),469 want: false,470 },471 {472 name: "vertical adjacent",473 marks: makeMarks("X..", "X..", "..."),474 want: false,475 },476 {477 name: "single shaded",478 marks: makeMarks("...", ".X.", "..."),479 want: true,480 },481 {482 name: "checkerboard pattern",483 marks: makeMarks("X.X.", ".X.X", "X.X.", ".X.X"),484 want: true,485 },486 }487488 for _, tt := range tests {489 t.Run(tt.name, func(t *testing.T) {490 got := hasNoAdjacentShaded(tt.marks, len(tt.marks))491 if got != tt.want {492 t.Errorf("hasNoAdjacentShaded = %v, want %v", got, tt.want)493 }494 })495 }496}497498// --- Validation: allWhiteConnected (P0) ---499500func TestAllWhiteConnected(t *testing.T) {501 tests := []struct {502 name string503 marks [][]cellMark504 want bool505 }{506 {507 name: "all white",508 marks: makeMarks("...", "...", "..."),509 want: true,510 },511 {512 name: "connected with some shaded",513 marks: makeMarks("X..", "...", "..X"),514 want: true,515 },516 {517 name: "disconnected by shaded column",518 marks: makeMarks(".X.", ".X.", ".X."),519 want: false,520 },521 {522 name: "single white cell surrounded",523 marks: makeMarks("XXX", "X.X", "XXX"),524 want: true,525 },526 }527528 for _, tt := range tests {529 t.Run(tt.name, func(t *testing.T) {530 got := allWhiteConnected(tt.marks, len(tt.marks))531 if got != tt.want {532 t.Errorf("allWhiteConnected = %v, want %v", got, tt.want)533 }534 })535 }536}537538// --- isValidSolution (P0) ---539540func TestIsValidSolution(t *testing.T) {541 // A valid 4x4 Hitori solution:542 // Numbers: 1 2 1 3 Marks: X . . .543 // 2 3 4 1 . . . .544 // 3 1 2 4 . . . .545 // 1 4 3 2 . . X .546 // After shading: row 0 has [2,1,3], row 3 has [1,4,2].547 // No dups in rows/cols, no adjacent shaded, white connected.548 numbers := makeGrid("1213", "2341", "3124", "1432")549 marks := makeMarks("X...", "....", "....", "..X.")550551 if !isValidSolution(numbers, marks, 4) {552 t.Error("expected valid solution")553 }554}555556func TestIsValidSolution_InvalidDuplicate(t *testing.T) {557 numbers := makeGrid("1213", "2341", "3124", "1432")558 marks := makeMarks("....", "....", "....", "....") // no shading, row 0 has dup 1559 if isValidSolution(numbers, marks, 4) {560 t.Error("expected invalid: duplicate 1 in row 0")561 }562}563564func TestIsValidSolution_InvalidAdjacent(t *testing.T) {565 numbers := makeGrid("1213", "2341", "3124", "1432")566 marks := makeMarks("XX..", "....", "....", "....") // adjacent shaded567 if isValidSolution(numbers, marks, 4) {568 t.Error("expected invalid: adjacent shaded cells")569 }570}571572// --- computeConflicts (P0) ---573574func TestComputeConflicts_NoConflicts(t *testing.T) {575 numbers := makeGrid("123", "231", "312")576 marks := makeMarks("...", "...", "...")577 conflicts := computeConflicts(numbers, marks, 3)578579 for y := range 3 {580 for x := range 3 {581 if conflicts[y][x] {582 t.Errorf("unexpected conflict at (%d,%d)", x, y)583 }584 }585 }586}587588func TestComputeConflicts_RowDuplicate(t *testing.T) {589 // Row 0 has two unshaded 1s at (0,0) and (1,0).590 // Duplicates are only flagged when at least one cell is circled.591 numbers := makeGrid("113", "231", "312")592593 // All unmarked: no duplicate conflicts shown.594 marks := makeMarks("...", "...", "...")595 conflicts := computeConflicts(numbers, marks, 3)596 if conflicts[0][0] || conflicts[0][1] {597 t.Error("unmarked duplicates should not be flagged")598 }599600 // Circle one of the duplicates: entire group is flagged.601 marks = makeMarks("O..", "...", "...")602 conflicts = computeConflicts(numbers, marks, 3)603 if !conflicts[0][0] {604 t.Error("expected conflict at (0,0)")605 }606 if !conflicts[0][1] {607 t.Error("expected conflict at (1,0)")608 }609 if conflicts[0][2] {610 t.Error("unexpected conflict at (2,0)")611 }612}613614func TestComputeConflicts_ColDuplicate(t *testing.T) {615 // Column 0 has two unshaded 1s at (0,0) and (0,2).616 // Duplicates are only flagged when at least one cell is circled.617 numbers := makeGrid("123", "231", "112")618619 // All unmarked: no duplicate conflicts shown.620 marks := makeMarks("...", "...", "...")621 conflicts := computeConflicts(numbers, marks, 3)622 if conflicts[0][0] || conflicts[2][0] {623 t.Error("unmarked duplicates should not be flagged")624 }625626 // Circle one of the duplicates: entire group is flagged.627 marks = makeMarks("O..", "...", "...")628 conflicts = computeConflicts(numbers, marks, 3)629 if !conflicts[0][0] {630 t.Error("expected conflict at (0,0)")631 }632 if !conflicts[2][0] {633 t.Error("expected conflict at (0,2)")634 }635}636637func TestComputeConflicts_ShadedRemovesDuplicate(t *testing.T) {638 // Row 0 has duplicate 1s but one is shaded.639 numbers := makeGrid("113", "231", "312")640 marks := makeMarks("X..", "...", "...")641 conflicts := computeConflicts(numbers, marks, 3)642643 if conflicts[0][0] {644 t.Error("shaded cell should not cause duplicate conflict")645 }646 if conflicts[0][2] {647 t.Error("no duplicate after shading, should not conflict")648 }649}650651func TestComputeConflicts_AdjacentShaded(t *testing.T) {652 numbers := makeGrid("123", "231", "312")653 marks := makeMarks("XX.", "...", "...")654 conflicts := computeConflicts(numbers, marks, 3)655656 if !conflicts[0][0] {657 t.Error("expected conflict at (0,0) for adjacent shaded")658 }659 if !conflicts[0][1] {660 t.Error("expected conflict at (1,0) for adjacent shaded")661 }662}663664func TestComputeConflicts_AdjacentShadedVertical(t *testing.T) {665 numbers := makeGrid("123", "231", "312")666 marks := makeMarks("X..", "X..", "...")667 conflicts := computeConflicts(numbers, marks, 3)668669 if !conflicts[0][0] {670 t.Error("expected conflict at (0,0) for vertical adjacent shaded")671 }672 if !conflicts[1][0] {673 t.Error("expected conflict at (0,1) for vertical adjacent shaded")674 }675}676677func TestComputeConflicts_DiagonalShadedOK(t *testing.T) {678 numbers := makeGrid("123", "231", "312")679 marks := makeMarks("X..", ".X.", "..X")680 conflicts := computeConflicts(numbers, marks, 3)681682 // Diagonal shaded cells are fine, but check connectivity.683 // The white cells form: (1,0),(2,0),(0,1),(2,1),(0,2),(1,2)684 // These are all connected, so no adjacency or connectivity conflicts.685 if conflicts[0][0] || conflicts[1][1] || conflicts[2][2] {686 t.Error("diagonal shaded cells should not conflict with each other")687 }688}689690func TestComputeConflicts_DisconnectedWhite(t *testing.T) {691 // Shaded column splits white cells into two groups.692 // Left side: (0,0),(0,1),(0,2) -- 3 cells693 // Right side: (2,0),(2,1),(2,2) -- 3 cells694 numbers := makeGrid("123", "231", "312")695 marks := makeMarks(".X.", ".X.", ".X.")696 conflicts := computeConflicts(numbers, marks, 3)697698 // One group of 3 is the largest, the other 3 are disconnected.699 // Both sides are size 3 so the first found (left) is the largest700 // by discovery order and the right side gets flagged (or vice versa).701 // At least one group of 3 should be flagged.702 disconnectedCount := 0703 for y := range 3 {704 for x := range 3 {705 if marks[y][x] != shaded && conflicts[y][x] {706 disconnectedCount++707 }708 }709 }710 if disconnectedCount != 3 {711 t.Errorf("expected 3 disconnected white cells flagged, got %d", disconnectedCount)712 }713}714715func TestComputeConflicts_MultipleViolations(t *testing.T) {716 // Adjacent shaded AND row duplicate at the same time.717 numbers := makeGrid("1123", "2341", "3124", "4213")718 marks := makeMarks("XX..", "....", "....", "....")719 conflicts := computeConflicts(numbers, marks, 4)720721 // (0,0) and (1,0) are adjacent shaded.722 if !conflicts[0][0] {723 t.Error("expected adjacent shaded conflict at (0,0)")724 }725 if !conflicts[0][1] {726 t.Error("expected adjacent shaded conflict at (1,0)")727 }728}729730func TestComputeConflicts_EmptyBoard(t *testing.T) {731 // No shaded cells -- the entire board is one connected white region.732 // All cells are unmarked, so no duplicate conflicts are shown.733 numbers := makeGrid("123", "123", "123")734 marks := makeMarks("...", "...", "...")735 conflicts := computeConflicts(numbers, marks, 3)736737 for y := range 3 {738 for x := range 3 {739 if conflicts[y][x] {740 t.Errorf("unmarked duplicates should not conflict at (%d,%d)", x, y)741 }742 }743 }744745 // Circle one cell: its entire column duplicate group is flagged.746 marks = makeMarks("O..", "...", "...")747 conflicts = computeConflicts(numbers, marks, 3)748749 // col 0: 1,1,1 -- all three should conflict (one is circled).750 for y := range 3 {751 if !conflicts[y][0] {752 t.Errorf("expected column duplicate conflict at (0,%d)", y)753 }754 }755 // col 1 and col 2 still have no circled cells, so no conflicts.756 for y := range 3 {757 for x := 1; x < 3; x++ {758 if conflicts[y][x] {759 t.Errorf("unexpected conflict at (%d,%d)", x, y)760 }761 }762 }763}764765// --- Latin square generation (P1) ---766767func TestGenerateLatinSquare(t *testing.T) {768 if testing.Short() {769 t.Skip("skipping generator test in short mode")770 }771772 sizes := []int{4, 5, 6, 8}773 for _, size := range sizes {774 t.Run("", func(t *testing.T) {775 g := generateLatinSquare(size)776777 // Check rows have all numbers.778 for y := range size {779 seen := map[rune]bool{}780 for x := range size {781 seen[g[y][x]] = true782 }783 if len(seen) != size {784 t.Errorf("row %d has %d unique numbers, want %d", y, len(seen), size)785 }786 }787788 // Check columns have all numbers.789 for x := range size {790 seen := map[rune]bool{}791 for y := range size {792 seen[g[y][x]] = true793 }794 if len(seen) != size {795 t.Errorf("col %d has %d unique numbers, want %d", x, len(seen), size)796 }797 }798 })799 }800}801802// --- Mask generation (P1) ---803804func TestGenerateValidMask(t *testing.T) {805 if testing.Short() {806 t.Skip("skipping generator test in short mode")807 }808809 size := 6810 mask := generateValidMask(size, 0.25)811812 // No adjacent black cells.813 for y := range size {814 for x := range size {815 if !mask[y][x] {816 continue817 }818 if x > 0 && mask[y][x-1] {819 t.Errorf("adjacent black cells at (%d,%d) and (%d,%d)", x, y, x-1, y)820 }821 if y > 0 && mask[y-1][x] {822 t.Errorf("adjacent black cells at (%d,%d) and (%d,%d)", x, y, x, y-1)823 }824 }825 }826827 // White cells connected.828 if !whiteCellsConnected(mask, size) {829 t.Error("white cells are not connected")830 }831}832833// --- Save/load round-trip (P1) ---834835func TestSaveLoadRoundTrip(t *testing.T) {836 numbers := makeGrid("1213", "2341", "3124", "1432")837 mode := testMode(4)838 gamer, err := New(mode, numbers)839 if err != nil {840 t.Fatalf("New: %v", err)841 }842843 data, err := gamer.GetSave()844 if err != nil {845 t.Fatalf("GetSave: %v", err)846 }847848 loaded, err := ImportModel(data)849 if err != nil {850 t.Fatalf("ImportModel: %v", err)851 }852853 // Verify grid preserved.854 if loaded.numbers.String() != numbers.String() {855 t.Error("numbers grid not preserved after round-trip")856 }857 if loaded.size != 4 {858 t.Errorf("size = %d, want 4", loaded.size)859 }860}861862func TestSaveLoadRoundTrip_WithMarks(t *testing.T) {863 numbers := makeGrid("1223", "3412", "4321", "2143")864 mode := testMode(4)865 gamer, err := New(mode, numbers)866 if err != nil {867 t.Fatalf("New: %v", err)868 }869870 // Apply some marks via the model.871 m := gamer.(Model)872 m.userMarks[0][0] = shaded873 m.userMarks[1][1] = circled874 m.recomputeState()875876 data, err := m.GetSave()877 if err != nil {878 t.Fatalf("GetSave: %v", err)879 }880881 loaded, err := ImportModel(data)882 if err != nil {883 t.Fatalf("ImportModel: %v", err)884 }885886 if loaded.userMarks[0][0] != shaded {887 t.Error("shaded user mark not preserved")888 }889 if loaded.userMarks[1][1] != circled {890 t.Error("circled user mark not preserved")891 }892 if loaded.marks[0][1] != circled {893 t.Error("derived white mark not recomputed on load")894 }895}896897func TestImportModel_InvalidJSON(t *testing.T) {898 _, err := ImportModel([]byte("not json"))899 if err == nil {900 t.Error("expected error for invalid JSON")901 }902}903904func TestImportModel_InvalidSize(t *testing.T) {905 data, _ := json.Marshal(Save{Size: 0, Numbers: "", Marks: ""})906 _, err := ImportModel(data)907 if err == nil {908 t.Error("expected error for zero size")909 }910}911912// --- Model construction (P0) ---913914func TestNew_ValidGrid(t *testing.T) {915 mode := testMode(3)916 numbers := makeGrid("123", "231", "312")917 g, err := New(mode, numbers)918 if err != nil {919 t.Fatalf("New: %v", err)920 }921 if g.IsSolved() {922 t.Error("new game should not be solved")923 }924}925926func TestNew_InvalidSize(t *testing.T) {927 mode := testMode(0)928 _, err := New(mode, makeGrid())929 if err == nil {930 t.Error("expected error for zero size")931 }932}933934func TestNew_RowMismatch(t *testing.T) {935 mode := testMode(3)936 _, err := New(mode, makeGrid("12", "23"))937 if err == nil {938 t.Error("expected error for row count mismatch")939 }940}941942func TestNew_ColumnMismatch(t *testing.T) {943 mode := testMode(3)944 _, err := New(mode, makeGrid("12", "23", "31"))945 if err == nil {946 t.Error("expected error for column width mismatch")947 }948}949950// --- Model methods (P0) ---951952func TestModel_Reset(t *testing.T) {953 mode := testMode(3)954 numbers := makeGrid("123", "231", "312")955 g, _ := New(mode, numbers)956957 m := g.(Model)958 m.userMarks[0][0] = shaded959 m.userMarks[1][1] = circled960 m.recomputeState()961962 reset := m.Reset().(Model)963 if reset.userMarks[0][0] != unmarked || reset.userMarks[1][1] != unmarked {964 t.Error("Reset did not clear user marks")965 }966 if reset.marks[0][0] != unmarked || reset.marks[1][1] != unmarked {967 t.Error("Reset did not clear marks")968 }969}970971func TestModel_Update_ShadeRecomputesRequiredMoves(t *testing.T) {972 mode := testMode(4)973 numbers := makeGrid("1223", "3412", "4321", "2143")974 g, err := New(mode, numbers)975 if err != nil {976 t.Fatalf("New: %v", err)977 }978979 next, _ := g.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})980 m := next.(Model)981982 if m.userMarks[0][0] != shaded {983 t.Fatalf("user mark at (0,0) = %v, want shaded", m.userMarks[0][0])984 }985 if m.marks[0][1] != circled {986 t.Fatalf("effective mark at (1,0) = %v, want circled", m.marks[0][1])987 }988 if m.marks[0][2] != unmarked {989 t.Fatalf("effective mark at (2,0) = %v, want unmarked", m.marks[0][2])990 }991 if m.solved {992 t.Fatal("single user move should not auto-solve the puzzle")993 }994 if m.marks[0][3] != unmarked {995 t.Fatalf("effective mark at (3,0) = %v, want unmarked", m.marks[0][3])996 }997}998999func TestModel_Update_ClearSourceRetractsDerivedMarks(t *testing.T) {1000 mode := testMode(4)1001 numbers := makeGrid("1223", "3412", "4321", "2143")1002 g, err := New(mode, numbers)1003 if err != nil {1004 t.Fatalf("New: %v", err)1005 }10061007 next, _ := g.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})1008 m := next.(Model)1009 m.userMarks[0][0] = unmarked1010 m.recomputeState()10111012 if m.userMarks[0][0] != unmarked {1013 t.Fatalf("user mark at (0,0) = %v, want unmarked", m.userMarks[0][0])1014 }1015 if m.marks[0][1] != unmarked {1016 t.Fatalf("effective mark at (1,0) = %v, want unmarked", m.marks[0][1])1017 }1018}10191020func TestModel_ContradictingUserMoveIsPreservedAndConflicts(t *testing.T) {1021 mode := testMode(4)1022 numbers := makeGrid("1223", "3412", "4321", "2143")1023 g, err := New(mode, numbers)1024 if err != nil {1025 t.Fatalf("New: %v", err)1026 }10271028 m := g.(Model)1029 m.userMarks = makeMarks("XX..", "....", "....", "....")1030 m.recomputeState()10311032 if m.marks[0][1] != shaded {1033 t.Fatalf("effective mark at (1,0) = %v, want shaded", m.marks[0][1])1034 }1035 if !m.conflicts[0][0] || !m.conflicts[0][1] {1036 t.Fatal("expected adjacent shaded conflict for contradicting user move")1037 }1038 if m.solved {1039 t.Fatal("contradicting user move should not solve the puzzle")1040 }1041}10421043func TestModel_SetTitle(t *testing.T) {1044 mode := testMode(3)1045 numbers := makeGrid("123", "231", "312")1046 g, _ := New(mode, numbers)10471048 updated := g.SetTitle("Custom Title").(Model)1049 if updated.modeTitle != "Custom Title" {1050 t.Errorf("modeTitle = %q, want %q", updated.modeTitle, "Custom Title")1051 }1052}10531054func TestModel_GetFullHelp(t *testing.T) {1055 mode := testMode(3)1056 numbers := makeGrid("123", "231", "312")1057 g, _ := New(mode, numbers)1058 help := g.GetFullHelp()1059 if len(help) != 2 {1060 t.Errorf("expected 2 help groups, got %d", len(help))1061 }1062}10631064func TestModel_GetDebugInfo(t *testing.T) {1065 mode := testMode(3)1066 numbers := makeGrid("123", "231", "312")1067 g, _ := New(mode, numbers)1068 info := g.GetDebugInfo()1069 if info == "" {1070 t.Error("expected non-empty debug info")1071 }1072}10731074func TestModel_View(t *testing.T) {1075 mode := testMode(3)1076 numbers := makeGrid("123", "231", "312")1077 g, _ := New(mode, numbers)1078 view := g.View()1079 if view == "" {1080 t.Error("expected non-empty view")1081 }1082}10831084func TestScreenToGridUsesDynamicGridCoordinates(t *testing.T) {1085 mode := testMode(3)1086 numbers := makeGrid("123", "456", "789")1087 g, _ := New(mode, numbers)1088 next, _ := g.Update(tea.WindowSizeMsg{Width: 120, Height: 40})1089 m := next.(Model)1090 x, y := m.cachedGridOrigin()10911092 if col, row, ok := m.screenToGrid(x, y); !ok || col != 0 || row != 0 {1093 t.Fatalf("screenToGrid(origin) = (%d,%d,%v), want (0,0,true)", col, row, ok)1094 }1095 if _, _, ok := m.screenToGrid(x+cellWidth, y); ok {1096 t.Fatal("screenToGrid(separator) = ok, want false")1097 }1098}10991100func TestMouseClickMovesCursor(t *testing.T) {1101 mode := testMode(3)1102 numbers := makeGrid("123", "456", "789")1103 g, _ := New(mode, numbers)1104 next, _ := g.Update(tea.WindowSizeMsg{Width: 120, Height: 40})1105 m := next.(Model)1106 x, y := hitoriCellScreenCoords(&m, 2, 1)11071108 next, _ = m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})1109 got := next.(Model)11101111 if got.cursor.X != 2 || got.cursor.Y != 1 {1112 t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)1113 }1114 if got.userMarks[1][2] != unmarked {1115 t.Fatalf("clicked destination mark = %v, want unchanged unmarked", got.userMarks[1][2])1116 }1117}11181119func TestMouseClickOnSeparatorDoesNotMoveCursor(t *testing.T) {1120 mode := testMode(3)1121 numbers := makeGrid("123", "456", "789")1122 g, _ := New(mode, numbers)1123 next, _ := g.Update(tea.WindowSizeMsg{Width: 120, Height: 40})1124 m := next.(Model)1125 m.cursor = game.Cursor{X: 1, Y: 1}1126 x, y := hitoriCellScreenCoords(&m, 1, 1)11271128 next, _ = m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})1129 got := next.(Model)11301131 if got.cursor.X != 1 || got.cursor.Y != 1 {1132 t.Fatalf("cursor = (%d,%d), want unchanged (1,1)", got.cursor.X, got.cursor.Y)1133 }1134}11351136func TestMouseClickSameCellDoesNotMutateMarks(t *testing.T) {1137 mode := testMode(3)1138 numbers := makeGrid("123", "456", "789")1139 g, _ := New(mode, numbers)1140 next, _ := g.Update(tea.WindowSizeMsg{Width: 120, Height: 40})1141 m := next.(Model)1142 m.cursor = game.Cursor{X: 1, Y: 1}1143 m.userMarks[1][1] = shaded1144 m.recomputeState()1145 x, y := hitoriCellScreenCoords(&m, 1, 1)11461147 next, _ = m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})1148 got := next.(Model)11491150 if got.userMarks[1][1] != shaded {1151 t.Fatalf("mark = %v, want unchanged shaded", got.userMarks[1][1])1152 }1153}11541155// --- Gamer interface compliance (P0) ---11561157func TestGamerInterface(t *testing.T) {1158 var _ game.Gamer = Model{}1159}11601161// --- Spawner/Mode interface compliance (P0) ---11621163func TestModeInterfaces(t *testing.T) {1164 var _ game.Mode = HitoriMode{}1165 var _ game.Spawner = HitoriMode{}1166}11671168func TestModeTitleDescription(t *testing.T) {1169 mode := NewMode("Easy", "6x6 grid", 6, 0.22)1170 if mode.Title() != "Easy" {1171 t.Errorf("Title = %q, want %q", mode.Title(), "Easy")1172 }1173 if mode.Description() != "6x6 grid" {1174 t.Errorf("Description = %q, want %q", mode.Description(), "6x6 grid")1175 }1176}11771178func hitoriCellScreenCoords(m *Model, col, row int) (x, y int) {1179 ox, oy := m.cachedGridOrigin()1180 return ox + col*(cellWidth+1), oy + row*21181}11821183// --- Puzzle generation (P2) ---11841185func TestGenerate_SmallPuzzle(t *testing.T) {1186 if testing.Short() {1187 t.Skip("skipping generator test in short mode")1188 }11891190 puzzle, err := Generate(5, 0.32)1191 if err != nil {1192 t.Fatalf("Generate: %v", err)1193 }1194 if len(puzzle) != 5 {1195 t.Errorf("puzzle has %d rows, want 5", len(puzzle))1196 }1197 for y, row := range puzzle {1198 if len(row) != 5 {1199 t.Errorf("row %d has %d cols, want 5", y, len(row))1200 }1201 for x, num := range row {1202 if num < '1' || num > '5' {1203 t.Errorf("puzzle[%d][%d] = %c, want 1-5", y, x, num)1204 }1205 }1206 }12071208 // Verify it has exactly one solution.1209 solutions := countPuzzleSolutions(puzzle, 5, 2)1210 if solutions != 1 {1211 t.Errorf("puzzle has %d solutions, want exactly 1", solutions)1212 }1213}12141215func TestSpawn_Mini(t *testing.T) {1216 if testing.Short() {1217 t.Skip("skipping generator test in short mode")1218 }12191220 mode := NewMode("Mini", "5x5 grid", 5, 0.32)1221 g, err := mode.Spawn()1222 if err != nil {1223 t.Fatalf("Spawn: %v", err)1224 }1225 if g == nil {1226 t.Fatal("Spawn returned nil game")1227 }1228 if g.IsSolved() {1229 t.Error("freshly spawned game should not be solved")1230 }1231}12321233// --- Solver validation (P1) ---12341235func TestCountPuzzleSolutions_KnownPuzzle(t *testing.T) {1236 if testing.Short() {1237 t.Skip("skipping solver test in short mode")1238 }12391240 // A simple 4x4 puzzle with known unique solution.1241 // Numbers: 1 2 1 31242 // 2 3 4 11243 // 3 1 2 41244 // 1 4 3 21245 // Solution: shade (0,0) and (2,3) -> unique1246 puzzle := makeGrid("1213", "2341", "3124", "1432")12471248 solutions := countPuzzleSolutions(puzzle, 4, 2)1249 if solutions < 1 {1250 t.Errorf("expected at least 1 solution, got %d", solutions)1251 }1252}12531254// --- Uniqueness and connectivity regression (P0) ---12551256func TestGenerateValidMaskSeeded_AllModes_NoAdjacencyAndConnected(t *testing.T) {1257 if testing.Short() {1258 t.Skip("skipping generator test in short mode")1259 }12601261 modes := []struct {1262 name string1263 size int1264 blackRatio float641265 seeds []uint641266 }{1267 {"Mini 5x5", 5, 0.32, []uint64{11, 17}},1268 {"Easy 6x6", 6, 0.32, []uint64{19, 23}},1269 {"Medium 8x8", 8, 0.30, []uint64{29, 31}},1270 {"Tricky 9x9", 9, 0.30, []uint64{37}},1271 {"Hard 10x10", 10, 0.30, []uint64{41}},1272 {"Expert 12x12", 12, 0.28, []uint64{43}},1273 }12741275 for _, mode := range modes {1276 t.Run(mode.name, func(t *testing.T) {1277 for _, seed := range mode.seeds {1278 rng := rand.New(rand.NewPCG(seed, seed*7+3))1279 mask := generateValidMaskSeeded(mode.size, mode.blackRatio, rng)12801281 for y := range mode.size {1282 for x := range mode.size {1283 if mask[y][x] && hasOrthogonalNeighbor(mask, mode.size, x, y) {1284 t.Fatalf("seed %d produced adjacent black cells at (%d,%d)", seed, x, y)1285 }1286 }1287 }12881289 if !whiteCellsConnected(mask, mode.size) {1290 t.Fatalf("seed %d produced disconnected white cells", seed)1291 }1292 }1293 })1294 }1295}12961297func TestGenerateSeeded_AllModes_UniqueAndConnected(t *testing.T) {1298 if testing.Short() {1299 t.Skip("skipping generator test in short mode")1300 }13011302 modes := []struct {1303 name string1304 size int1305 blackRatio float641306 seeds []uint641307 }{1308 {"Mini 5x5", 5, 0.32, []uint64{47, 53}},1309 {"Easy 6x6", 6, 0.32, []uint64{59, 61}},1310 {"Medium 8x8", 8, 0.30, []uint64{67, 71}},1311 {"Tricky 9x9", 9, 0.30, []uint64{73}},1312 {"Hard 10x10", 10, 0.30, []uint64{79}},1313 {"Expert 12x12", 12, 0.28, []uint64{83}},1314 }13151316 for _, mode := range modes {1317 t.Run(mode.name, func(t *testing.T) {1318 for _, seed := range mode.seeds {1319 rng := rand.New(rand.NewPCG(seed, seed*11+5))1320 puzzle, err := GenerateSeeded(mode.size, mode.blackRatio, rng)1321 if err != nil {1322 t.Fatalf("seed %d GenerateSeeded: %v", seed, err)1323 }13241325 if count := countPuzzleSolutions(puzzle, mode.size, 2); count != 1 {1326 t.Fatalf("seed %d puzzle has %d solutions, want 1", seed, count)1327 }13281329 marks, ok := findFirstSolutionMarks(puzzle, mode.size)1330 if !ok {1331 t.Fatalf("seed %d no valid solution marks found", seed)1332 }1333 if !isValidSolution(puzzle, marks, mode.size) {1334 t.Fatalf("seed %d solution marks violate invariants", seed)1335 }1336 }1337 })1338 }1339}13401341func findFirstSolutionMarks(puzzle grid, size int) ([][]cellMark, bool) {1342 st := make([][]solverState, size)1343 for y := range size {1344 st[y] = make([]solverState, size)1345 }13461347 var solution [][]cellMark1348 var search func(pos int) bool1349 search = func(pos int) bool {1350 if pos == size*size {1351 marks := stateToMarks(st, size)1352 if allWhiteConnected(marks, size) {1353 solution = marks1354 return true1355 }1356 return false1357 }13581359 x, y := pos%size, pos/size1360 if canBeWhite(puzzle, st, size, x, y) {1361 st[y][x] = white1362 if search(pos + 1) {1363 return true1364 }1365 }1366 if canBeBlack(st, size, x, y) {1367 st[y][x] = black1368 if search(pos + 1) {1369 return true1370 }1371 }13721373 st[y][x] = unknown1374 return false1375 }13761377 return solution, search(0)1378}13791380// --- Registration (P0) ---13811382func TestRegistration(t *testing.T) {1383 numbers := makeGrid("123", "231", "312")1384 mode := testMode(3)1385 g, _ := New(mode, numbers)1386 data, _ := g.GetSave()13871388 loaded, err := Entry.Import(data)1389 if err != nil {1390 t.Fatalf("definition import: %v", err)1391 }1392 if loaded == nil {1393 t.Fatal("definition import returned nil")1394 }1395}