clone

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

hashi and hitori UX passes

Dami committed 20e6a1c
commit
20e6a1c35731f6f922eb3b49932c97e519018615
parent
8d753d50552902c40d3cfb803f82459a4a3068c3
author
Dami <dami.etoile@gmail.com>
6 files changed, +177 -17
Mhashiwokakero/Model.go7++
Mhashiwokakero/hashiwokakero_test.go95++++++++++++++++++++++++++++++
Mhashiwokakero/style.go2+-
Mhitori/hitori_test.go47+++++++++++++--
Mhitori/solver.go28++++++++-
Mhitori/style.go15+++--
M hashiwokakero/Model.go +7 -0
M hashiwokakero/hashiwokakero_test.go +95 -0
@@ -1001,3 +1001,98 @@ func TestSaveJSON(t *testing.T) {
10011001 }10021002 }10031003 }1004+1005+// --- Reset (P0) ---1006+1007+func TestReset(t *testing.T) {1008+ t.Run("invalidates caches", func(t *testing.T) {1009+ m := Model{1010+ puzzle: Puzzle{1011+ Width: 7,1012+ Height: 7,1013+ Islands: []Island{1014+ {ID: 0, X: 0, Y: 0, Required: 2},1015+ {ID: 1, X: 3, Y: 0, Required: 2},1016+ {ID: 2, X: 0, Y: 3, Required: 2},1017+ {ID: 3, X: 3, Y: 3, Required: 2},1018+ },1019+ },1020+ keys: DefaultKeyMap,1021+ }1022+ m.puzzle.SetBridge(0, 1, 1)1023+ m.puzzle.SetBridge(2, 3, 1)1024+1025+ // Populate caches.1026+ _ = m.puzzle.BridgeCount(0)1027+ _ = m.puzzle.CellContent(1, 0)1028+ _ = m.puzzle.FindIslandAt(0, 0)1029+1030+ got := m.Reset().(Model)1031+1032+ if got.puzzle.bridgeCounts != nil {1033+ t.Error("bridgeCounts should be nil after Reset")1034+ }1035+ if got.puzzle.cellCache != nil {1036+ t.Error("cellCache should be nil after Reset")1037+ }1038+ if got.puzzle.posIndex != nil {1039+ t.Error("posIndex should be nil after Reset")1040+ }1041+ })1042+1043+ t.Run("bridge counts reflect cleared state", func(t *testing.T) {1044+ m := Model{1045+ puzzle: Puzzle{1046+ Width: 7,1047+ Height: 7,1048+ Islands: []Island{1049+ {ID: 0, X: 0, Y: 0, Required: 2},1050+ {ID: 1, X: 3, Y: 0, Required: 2},1051+ {ID: 2, X: 0, Y: 3, Required: 2},1052+ {ID: 3, X: 3, Y: 3, Required: 2},1053+ },1054+ },1055+ keys: DefaultKeyMap,1056+ }1057+ m.puzzle.SetBridge(0, 1, 2)1058+1059+ // Populate the bridge count cache with stale data.1060+ if cnt := m.puzzle.BridgeCount(0); cnt != 2 {1061+ t.Fatalf("BridgeCount(0) before reset = %d, want 2", cnt)1062+ }1063+1064+ got := m.Reset().(Model)1065+1066+ if cnt := got.puzzle.BridgeCount(0); cnt != 0 {1067+ t.Errorf("BridgeCount(0) after reset = %d, want 0", cnt)1068+ }1069+ })1070+1071+ t.Run("cell cache reflects cleared state", func(t *testing.T) {1072+ m := Model{1073+ puzzle: Puzzle{1074+ Width: 7,1075+ Height: 7,1076+ Islands: []Island{1077+ {ID: 0, X: 0, Y: 0, Required: 2},1078+ {ID: 1, X: 3, Y: 0, Required: 2},1079+ },1080+ },1081+ keys: DefaultKeyMap,1082+ }1083+ m.puzzle.SetBridge(0, 1, 1)1084+1085+ // Populate cell cache with a bridge cell.1086+ ci := m.puzzle.CellContent(1, 0)1087+ if ci.Kind != cellBridgeH {1088+ t.Fatalf("CellContent(1,0) before reset = %d, want cellBridgeH(%d)", ci.Kind, cellBridgeH)1089+ }1090+1091+ got := m.Reset().(Model)1092+1093+ ci = got.puzzle.CellContent(1, 0)1094+ if ci.Kind != cellEmpty {1095+ t.Errorf("CellContent(1,0) after reset = %d, want cellEmpty(%d)", ci.Kind, cellEmpty)1096+ }1097+ })1098+}
M hashiwokakero/style.go +1 -1
M hitori/hitori_test.go +40 -7
@@ -315,10 +315,19 @@ func TestComputeConflicts_NoConflicts(t *testing.T) {
315315 316316 func TestComputeConflicts_RowDuplicate(t *testing.T) {317317 // Row 0 has two unshaded 1s at (0,0) and (1,0).318+ // Duplicates are only flagged when at least one cell is circled.318319 numbers := makeGrid("113", "231", "312")320+321+ // All unmarked: no duplicate conflicts shown.319322 marks := makeMarks("...", "...", "...")320323 conflicts := computeConflicts(numbers, marks, 3)324+ if conflicts[0][0] || conflicts[0][1] {325+ t.Error("unmarked duplicates should not be flagged")326+ }321327 328+ // Circle one of the duplicates: entire group is flagged.329+ marks = makeMarks("O..", "...", "...")330+ conflicts = computeConflicts(numbers, marks, 3)322331 if !conflicts[0][0] {323332 t.Error("expected conflict at (0,0)")324333 }
@@ -332,10 +341,19 @@ func TestComputeConflicts_RowDuplicate(t *testing.T) {
332341 333342 func TestComputeConflicts_ColDuplicate(t *testing.T) {334343 // Column 0 has two unshaded 1s at (0,0) and (0,2).344+ // Duplicates are only flagged when at least one cell is circled.335345 numbers := makeGrid("123", "231", "112")346+347+ // All unmarked: no duplicate conflicts shown.336348 marks := makeMarks("...", "...", "...")337349 conflicts := computeConflicts(numbers, marks, 3)350+ if conflicts[0][0] || conflicts[2][0] {351+ t.Error("unmarked duplicates should not be flagged")352+ }338353 354+ // Circle one of the duplicates: entire group is flagged.355+ marks = makeMarks("O..", "...", "...")356+ conflicts = computeConflicts(numbers, marks, 3)339357 if !conflicts[0][0] {340358 t.Error("expected conflict at (0,0)")341359 }
@@ -439,19 +457,34 @@ func TestComputeConflicts_MultipleViolations(t *testing.T) {
439457 440458 func TestComputeConflicts_EmptyBoard(t *testing.T) {441459 // No shaded cells -- the entire board is one connected white region.442- // There may be duplicate conflicts but no adjacency or connectivity issues.460+ // All cells are unmarked, so no duplicate conflicts are shown.443461 numbers := makeGrid("123", "123", "123")444462 marks := makeMarks("...", "...", "...")445463 conflicts := computeConflicts(numbers, marks, 3)446464 447- // Rows all have unique numbers, but columns have duplicates:448- // col 0: 1,1,1 -- all three conflict.449- // col 1: 2,2,2 -- all three conflict.450- // col 2: 3,3,3 -- all three conflict.451465 for y := range 3 {452466 for x := range 3 {453- if !conflicts[y][x] {454- t.Errorf("expected column duplicate conflict at (%d,%d)", x, y)467+ if conflicts[y][x] {468+ t.Errorf("unmarked duplicates should not conflict at (%d,%d)", x, y)469+ }470+ }471+ }472+473+ // Circle one cell: its entire column duplicate group is flagged.474+ marks = makeMarks("O..", "...", "...")475+ conflicts = computeConflicts(numbers, marks, 3)476+477+ // col 0: 1,1,1 -- all three should conflict (one is circled).478+ for y := range 3 {479+ if !conflicts[y][0] {480+ t.Errorf("expected column duplicate conflict at (0,%d)", y)481+ }482+ }483+ // col 1 and col 2 still have no circled cells, so no conflicts.484+ for y := range 3 {485+ for x := 1; x < 3; x++ {486+ if conflicts[y][x] {487+ t.Errorf("unexpected conflict at (%d,%d)", x, y)455488 }456489 }457490 }
M hitori/solver.go +25 -3
@@ -140,7 +140,9 @@ func computeConflicts(numbers grid, marks [][]cellMark, size int) [][]bool {
140140 }141141 142142 // markDuplicateConflicts flags unshaded cells that share a number with143-// another unshaded cell in the same row or column.143+// another unshaded cell in the same row or column. A duplicate group is144+// only flagged when at least one cell in the group is circled, so that145+// the initial board is not immediately flooded with conflict markers.144146 func markDuplicateConflicts(numbers grid, marks [][]cellMark, conflicts [][]bool, size int) {145147 // Check rows.146148 for y := range size {
@@ -153,7 +155,17 @@ func markDuplicateConflicts(numbers grid, marks [][]cellMark, conflicts [][]bool
153155 seen[num] = append(seen[num], x)154156 }155157 for _, positions := range seen {156- if len(positions) > 1 {158+ if len(positions) <= 1 {159+ continue160+ }161+ anyCircled := false162+ for _, x := range positions {163+ if marks[y][x] == circled {164+ anyCircled = true165+ break166+ }167+ }168+ if anyCircled {157169 for _, x := range positions {158170 conflicts[y][x] = true159171 }
@@ -172,7 +184,17 @@ func markDuplicateConflicts(numbers grid, marks [][]cellMark, conflicts [][]bool
172184 seen[num] = append(seen[num], y)173185 }174186 for _, positions := range seen {175- if len(positions) > 1 {187+ if len(positions) <= 1 {188+ continue189+ }190+ anyCircled := false191+ for _, y := range positions {192+ if marks[y][x] == circled {193+ anyCircled = true194+ break195+ }196+ }197+ if anyCircled {176198 for _, y := range positions {177199 conflicts[y][x] = true178200 }
M hitori/style.go +9 -6