- commit
- 20e6a1c35731f6f922eb3b49932c97e519018615
- parent
- 8d753d50552902c40d3cfb803f82459a4a3068c3
- author
- Dami <dami.etoile@gmail.com>
| M | hashiwokakero/ | 7 | ++ |
| M | hashiwokakero/ | 95 | ++++++++++++++++++++++++++++++ |
| M | hashiwokakero/ | 2 | +- |
| M | hitori/ | 47 | +++++++++++++-- |
| M | hitori/ | 28 | ++++++++- |
| M | hitori/ | 15 | +++-- |
@@ -132,6 +132,12 @@ func (m Model) View() string {
132132 info := infoView(&m.puzzle)133133 status := statusBarView(m.selectedIsland != nil, m.showFullHelp)134134 135+ // Pin info and status to the grid width so that variable-length text136+ // never widens the layout and causes centering shifts on re-render.137+ w := lipgloss.Width(grid)138+ info = lipgloss.NewStyle().Width(w).AlignHorizontal(lipgloss.Center).Render(info)139+ status = lipgloss.NewStyle().Width(w).AlignHorizontal(lipgloss.Center).Render(status)140+135141 return lipgloss.JoinVertical(lipgloss.Center, title, grid, info, status)136142 }137143 @@ -146,6 +152,7 @@ func (m Model) IsSolved() bool {
146152 147153 func (m Model) Reset() game.Gamer {148154 m.puzzle.Bridges = nil155+ m.puzzle.invalidateCache()149156 m.cursorIsland = 0150157 if len(m.puzzle.Islands) > 0 {151158 m.cursorIsland = m.puzzle.Islands[0].ID@@ -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+}@@ -283,7 +283,7 @@ func statusBarView(selected, showFullHelp bool) string {
283283 if showFullHelp {284284 return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select island ctrl+n: menu ctrl+r: reset ctrl+h: help")285285 }286- return game.StatusBarStyle.Render("enter/space: select island")286+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select island")287287 }288288 289289 func infoView(p *Puzzle) string {@@ -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 }@@ -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 }@@ -42,7 +42,7 @@ func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solve
4242 switch mark {4343 case shaded:4444 s = shadedStyle45- display = " ▒ "45+ display = " "4646 case circled:4747 s = circledStyle4848 display = fmt.Sprintf(" %c ", num)@@ -55,14 +55,17 @@ func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solve
5555 if isCursor && solved {5656 s = cursorSolvedStyle5757 } else if isCursor {58- s = s.Background(game.CursorWarmBG).59- Foreground(game.CursorFG).60- Bold(true)58+ s = s.Background(game.CursorWarmBG).Bold(true)59+ if mark != circled {60+ s = s.Foreground(game.CursorFG)61+ }6162 } else if solved {6263 s = s.Background(solvedBG)6364 } else if conflict {64- s = s.Background(game.ConflictBG).65- Foreground(game.ConflictFG)65+ s = s.Background(game.ConflictBG)66+ if mark != circled {67+ s = s.Foreground(game.ConflictFG)68+ }6669 } else if inCursorRow || inCursorCol {6770 s = s.Background(crosshairBG)6871 }