clone

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

feat/app-decomposition
puzzletea / games / netwalk / netwalk_test.go
netwalk_test.go 9.0K
1package netwalk23import (4 "math"5 "math/rand/v2"6 "strings"7 "testing"89 "charm.land/bubbles/v2/key"10 tea "charm.land/bubbletea/v2"11 "github.com/FelineStateMachine/puzzletea/game"12 "github.com/charmbracelet/x/ansi"13)1415func TestRotateMask(t *testing.T) {16 mask := north | east17 if got := rotateMask(mask, 1); got != east|south {18 t.Fatalf("rotateMask(..., 1) = %v, want %v", got, east|south)19 }20 if got := rotateMask(mask, 2); got != south|west {21 t.Fatalf("rotateMask(..., 2) = %v, want %v", got, south|west)22 }23}2425func TestAnalyzePuzzleSolvedAndDangling(t *testing.T) {26 puzzle := newPuzzle(2)27 puzzle.Root = point{X: 0, Y: 0}28 puzzle.Tiles[0][0] = tile{BaseMask: east, Kind: serverCell}29 puzzle.Tiles[0][1] = tile{BaseMask: west, Kind: nodeCell}3031 state := analyzePuzzle(puzzle)32 if !state.solved {33 t.Fatal("expected simple 2-cell puzzle to be solved")34 }3536 puzzle.Tiles[0][1].Rotation = 137 state = analyzePuzzle(puzzle)38 if state.solved {39 t.Fatal("rotated puzzle should not be solved")40 }41 if state.dangling == 0 {42 t.Fatal("expected dangling connectors after bad rotation")43 }44}4546func TestSaveImportRoundTrip(t *testing.T) {47 puzzle := newPuzzle(3)48 puzzle.Root = point{X: 1, Y: 1}49 puzzle.Tiles[1][1] = tile{BaseMask: north | east | south, Rotation: 1, InitialRotation: 2, Kind: serverCell, Locked: true}50 puzzle.Tiles[0][1] = tile{BaseMask: south, Rotation: 3, InitialRotation: 3, Kind: nodeCell}51 puzzle.Tiles[1][2] = tile{BaseMask: west, Rotation: 0, InitialRotation: 1, Kind: nodeCell}52 puzzle.Tiles[2][1] = tile{BaseMask: north, Rotation: 2, InitialRotation: 2, Kind: nodeCell}5354 m := Model{puzzle: puzzle, keys: DefaultKeyMap, modeTitle: "Test"}55 m.recompute()56 save, err := m.GetSave()57 if err != nil {58 t.Fatalf("GetSave() error = %v", err)59 }6061 imported, err := ImportModel(save)62 if err != nil {63 t.Fatalf("ImportModel() error = %v", err)64 }6566 if imported.puzzle.Size != puzzle.Size {67 t.Fatalf("size = %d, want %d", imported.puzzle.Size, puzzle.Size)68 }69 if imported.puzzle.Root != puzzle.Root {70 t.Fatalf("root = %+v, want %+v", imported.puzzle.Root, puzzle.Root)71 }72 if !imported.puzzle.Tiles[1][1].Locked {73 t.Fatal("expected lock state to round-trip")74 }75 if imported.puzzle.Tiles[1][1].Rotation != 1 || imported.puzzle.Tiles[1][1].InitialRotation != 2 {76 t.Fatal("expected rotations to round-trip")77 }78}7980func TestGenerateSeededDeterministic(t *testing.T) {81 rngA := rand.New(rand.NewPCG(10, 20))82 rngB := rand.New(rand.NewPCG(10, 20))8384 a, err := GenerateSeeded(7, 14, rngA)85 if err != nil {86 t.Fatalf("GenerateSeeded() error = %v", err)87 }88 b, err := GenerateSeeded(7, 14, rngB)89 if err != nil {90 t.Fatalf("GenerateSeeded() error = %v", err)91 }9293 if got, want := encodeMaskRows(a.Tiles), encodeMaskRows(b.Tiles); got != want {94 t.Fatalf("mask encoding mismatch\n got %q\nwant %q", got, want)95 }96 if got, want := encodeRotationRows(a.Tiles, true), encodeRotationRows(b.Tiles, true); got != want {97 t.Fatalf("initial rotations mismatch\n got %q\nwant %q", got, want)98 }99}100101func TestDefaultKeyMapUsesEnterForLockAndSpaceForRotate(t *testing.T) {102 if !key.Matches(103 keyPress("space"),104 DefaultKeyMap.Rotate,105 ) {106 t.Fatal("space should match rotate binding")107 }108 if key.Matches(109 keyPress("enter"),110 DefaultKeyMap.Rotate,111 ) {112 t.Fatal("enter should not match rotate binding")113 }114 if !key.Matches(115 keyPress("enter"),116 DefaultKeyMap.Lock,117 ) {118 t.Fatal("enter should match lock binding")119 }120 if key.Matches(121 keyPress("l"),122 DefaultKeyMap.Lock,123 ) {124 t.Fatal("l should not match lock binding")125 }126}127128func TestFrontierWeightPrefersPackedCandidatesOnHardProfiles(t *testing.T) {129 active := map[point]struct{}{130 {X: 2, Y: 2}: {},131 {X: 1, Y: 1}: {},132 {X: 3, Y: 1}: {},133 }134 adjacency := map[point]directionMask{135 {X: 2, Y: 2}: 0,136 {X: 1, Y: 1}: 0,137 {X: 3, Y: 1}: 0,138 }139 bounds := activeBounds{minX: 0, maxX: 4, minY: 0, maxY: 4}140141 packed := frontierWeight(142 5,143 frontierEdge{from: point{X: 2, Y: 2}, to: point{X: 2, Y: 1}},144 active,145 adjacency,146 bounds,147 hardProfile,148 )149 isolated := frontierWeight(150 5,151 frontierEdge{from: point{X: 2, Y: 2}, to: point{X: 2, Y: 3}},152 active,153 adjacency,154 bounds,155 hardProfile,156 )157 if packed <= isolated {158 t.Fatalf("packed frontier weight = %d, want > isolated %d", packed, isolated)159 }160}161162func TestSpanGrowthScoreRewardsExpansionBeforeTarget(t *testing.T) {163 bounds := activeBounds{minX: 2, maxX: 4, minY: 2, maxY: 4}164165 growing := spanGrowthScore(9, point{X: 1, Y: 4}, bounds, mediumProfile)166 stable := spanGrowthScore(9, point{X: 3, Y: 3}, bounds, mediumProfile)167 if growing <= stable {168 t.Fatalf("expanding span score = %d, want > stable %d", growing, stable)169 }170}171172func TestNetwalkModeDensityProgression(t *testing.T) {173 modes := netwalkModesFromRegistry(t)174 fill := make([]float64, len(modes))175 junctions := make([]float64, len(modes))176177 for i, mode := range modes {178 fill[i], junctions[i] = sampleModeMetrics(t, mode, 12)179 }180181 for i := 1; i < len(fill); i++ {182 if fill[i] <= fill[i-1] {183 t.Fatalf("fill ratio[%d] = %.3f, want > %.3f", i, fill[i], fill[i-1])184 }185 }186 for i := 2; i < len(junctions); i++ {187 if junctions[i] <= junctions[i-1] {188 t.Fatalf("junction avg[%d] = %.3f, want > %.3f", i, junctions[i], junctions[i-1])189 }190 }191192 for i, mode := range modes {193 target := float64(targetActiveFromFillRatio(mode.Size, mode.FillRatio)) / float64(mode.Size*mode.Size)194 if math.Abs(fill[i]-target) > 1e-9 {195 t.Fatalf("%s fill ratio = %.3f, want %.3f", mode.Title(), fill[i], target)196 }197 }198}199200func TestCellRowsShowDirectionalRootsAndLeaves(t *testing.T) {201 m := Model{}202203 m.puzzle = newPuzzle(3)204 m.puzzle.Tiles[1][1] = tile{Kind: serverCell}205 m.puzzle.Tiles[1][2] = tile{Kind: nodeCell}206 m.puzzle.Tiles[0][1] = tile{Kind: nodeCell}207 m.state.rotatedMasks = make([][]directionMask, 3)208 for y := range 3 {209 m.state.rotatedMasks[y] = make([]directionMask, 3)210 }211 m.state.rotatedMasks[1][1] = south212 m.state.rotatedMasks[1][2] = north213 m.state.rotatedMasks[0][1] = north | east | south214215 if got := cellRows(m, 1, 1); got != [cellHeight]string{" ", " ◆ ", " │ "} {216 t.Fatalf("south root rows = %#v", got)217 }218 if got := cellRows(m, 2, 1); got != [cellHeight]string{" │ ", " ● ", " "} {219 t.Fatalf("north leaf rows = %#v", got)220 }221 if got := cellRows(m, 1, 0); got != [cellHeight]string{" │ ", " ├──", " │ "} {222 t.Fatalf("tee rows = %#v", got)223 }224}225226func TestGridViewUsesTallerFrameWithoutInteriorBoxes(t *testing.T) {227 m := Model{228 puzzle: newPuzzle(2),229 }230 m.recompute()231232 lines := strings.Split(ansi.Strip(gridView(m)), "\n")233 if len(lines) != 8 {234 t.Fatalf("rendered line count = %d, want 8", len(lines))235 }236 for _, idx := range []int{1, 2, 3, 4, 5, 6} {237 if got := strings.Count(lines[idx], "│"); got != 2 {238 t.Fatalf("line %d has %d vertical borders, want outer frame only", idx, got)239 }240 }241}242243func TestGridViewShowsCursorGlyphsOnBlankCells(t *testing.T) {244 m := Model{245 puzzle: newPuzzle(2),246 cursor: game.Cursor{X: 1, Y: 1},247 }248 m.recompute()249250 view := ansi.Strip(gridView(m))251 if !strings.Contains(view, "▸ ◂") {252 t.Fatalf("blank cursor markers missing from view:\n%s", view)253 }254}255256func TestRecomputeInvalidatesCachedGridOrigin(t *testing.T) {257 m := Model{258 puzzle: newPuzzle(2),259 modeTitle: "Origin Check",260 termWidth: 120,261 termHeight: 40,262 originX: 17,263 originY: 9,264 originValid: true,265 showFullHelp: true,266 }267268 m.recompute()269270 if m.originValid {271 t.Fatal("expected recompute to invalidate cached grid origin")272 }273}274275func netwalkModesFromRegistry(t *testing.T) []NetwalkMode {276 t.Helper()277278 modes := make([]NetwalkMode, 0, len(Modes))279 for i, mode := range Modes {280 netwalkMode, ok := mode.(NetwalkMode)281 if !ok {282 t.Fatalf("mode %d has type %T, want NetwalkMode", i, mode)283 }284 modes = append(modes, netwalkMode)285 }286 return modes287}288289func sampleModeMetrics(290 t *testing.T,291 mode NetwalkMode,292 samples int,293) (float64, float64) {294 t.Helper()295296 var totalFill float64297 var totalJunctions float64298 for i := range samples {299 rng := rand.New(rand.NewPCG(uint64(1000+i), uint64(7000+i)))300 puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, rng)301 if err != nil {302 t.Fatalf("GenerateSeededWithDensity(%q) error = %v", mode.Title(), err)303 }304 totalFill += puzzleFillRatio(puzzle)305 totalJunctions += float64(puzzleJunctionCount(puzzle))306 }307 return totalFill / float64(samples), totalJunctions / float64(samples)308}309310func puzzleFillRatio(p Puzzle) float64 {311 if p.Size <= 0 {312 return 0313 }314 active := 0315 for y := range p.Size {316 for x := range p.Size {317 if isActive(p.Tiles[y][x]) {318 active++319 }320 }321 }322 return float64(active) / float64(p.Size*p.Size)323}324325func puzzleJunctionCount(p Puzzle) int {326 count := 0327 for y := range p.Size {328 for x := range p.Size {329 if !isActive(p.Tiles[y][x]) {330 continue331 }332 if degree(p.Tiles[y][x].BaseMask) >= 3 {333 count++334 }335 }336 }337 return count338}339340func keyPress(value string) tea.KeyPressMsg {341 switch value {342 case "enter":343 return tea.KeyPressMsg{Code: tea.KeyEnter}344 case "space":345 return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}346 default:347 r := []rune(value)348 return tea.KeyPressMsg{Code: r[0], Text: value}349 }350}