1package netwalk23import (4 "math"5 "math/rand/v2"6 "reflect"7 "strings"8 "testing"910 "charm.land/bubbles/v2/key"11 tea "charm.land/bubbletea/v2"12 "github.com/FelineStateMachine/puzzletea/difficulty"13 "github.com/FelineStateMachine/puzzletea/game"14 "github.com/charmbracelet/x/ansi"15)1617func TestRotateMask(t *testing.T) {18 mask := north | east19 if got := rotateMask(mask, 1); got != east|south {20 t.Fatalf("rotateMask(..., 1) = %v, want %v", got, east|south)21 }22 if got := rotateMask(mask, 2); got != south|west {23 t.Fatalf("rotateMask(..., 2) = %v, want %v", got, south|west)24 }25}2627func TestAnalyzePuzzleSolvedAndDangling(t *testing.T) {28 puzzle := newPuzzle(2)29 puzzle.Root = point{X: 0, Y: 0}30 puzzle.Tiles[0][0] = tile{BaseMask: east, Kind: serverCell}31 puzzle.Tiles[0][1] = tile{BaseMask: west, Kind: nodeCell}3233 state := analyzePuzzle(puzzle)34 if !state.solved {35 t.Fatal("expected simple 2-cell puzzle to be solved")36 }3738 puzzle.Tiles[0][1].Rotation = 139 state = analyzePuzzle(puzzle)40 if state.solved {41 t.Fatal("rotated puzzle should not be solved")42 }43 if state.dangling == 0 {44 t.Fatal("expected dangling connectors after bad rotation")45 }46}4748func TestSaveImportRoundTrip(t *testing.T) {49 puzzle := newPuzzle(3)50 puzzle.Root = point{X: 1, Y: 1}51 puzzle.Tiles[1][1] = tile{BaseMask: north | east | south, Rotation: 1, InitialRotation: 2, Kind: serverCell, Locked: true}52 puzzle.Tiles[0][1] = tile{BaseMask: south, Rotation: 3, InitialRotation: 3, Kind: nodeCell}53 puzzle.Tiles[1][2] = tile{BaseMask: west, Rotation: 0, InitialRotation: 1, Kind: nodeCell}54 puzzle.Tiles[2][1] = tile{BaseMask: north, Rotation: 2, InitialRotation: 2, Kind: nodeCell}5556 m := Model{puzzle: puzzle, keys: DefaultKeyMap, modeTitle: "Test"}57 m.recompute()58 save, err := m.GetSave()59 if err != nil {60 t.Fatalf("GetSave() error = %v", err)61 }6263 imported, err := ImportModel(save)64 if err != nil {65 t.Fatalf("ImportModel() error = %v", err)66 }6768 if imported.puzzle.Size != puzzle.Size {69 t.Fatalf("size = %d, want %d", imported.puzzle.Size, puzzle.Size)70 }71 if imported.puzzle.Root != puzzle.Root {72 t.Fatalf("root = %+v, want %+v", imported.puzzle.Root, puzzle.Root)73 }74 if !imported.puzzle.Tiles[1][1].Locked {75 t.Fatal("expected lock state to round-trip")76 }77 if imported.puzzle.Tiles[1][1].Rotation != 1 || imported.puzzle.Tiles[1][1].InitialRotation != 2 {78 t.Fatal("expected rotations to round-trip")79 }80}8182func TestGenerateSeededDeterministic(t *testing.T) {83 rngA := rand.New(rand.NewPCG(10, 20))84 rngB := rand.New(rand.NewPCG(10, 20))8586 a, err := GenerateSeeded(7, 14, rngA)87 if err != nil {88 t.Fatalf("GenerateSeeded() error = %v", err)89 }90 b, err := GenerateSeeded(7, 14, rngB)91 if err != nil {92 t.Fatalf("GenerateSeeded() error = %v", err)93 }9495 if got, want := encodeMaskRows(a.Tiles), encodeMaskRows(b.Tiles); got != want {96 t.Fatalf("mask encoding mismatch\n got %q\nwant %q", got, want)97 }98 if got, want := encodeRotationRows(a.Tiles, true), encodeRotationRows(b.Tiles, true); got != want {99 t.Fatalf("initial rotations mismatch\n got %q\nwant %q", got, want)100 }101}102103func TestSpawnEloRejectsInvalidElo(t *testing.T) {104 mode := NewMode("Test", "Test mode.", 7, 0.57, easyProfile)105106 gamer, report, err := mode.SpawnElo("seed", difficulty.SoftCapElo+1)107 if err == nil {108 t.Fatal("SpawnElo returned nil error for invalid Elo")109 }110 if gamer != nil {111 t.Fatalf("SpawnElo gamer = %#v, want nil", gamer)112 }113 if !reflect.DeepEqual(report, difficulty.Report{}) {114 t.Fatalf("SpawnElo report = %#v, want zero report", report)115 }116}117118func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {119 mode := NewMode("Test", "Test mode.", 7, 0.57, easyProfile)120121 gamerA, reportA, err := mode.SpawnElo("same-seed", 1500)122 if err != nil {123 t.Fatalf("first SpawnElo returned error: %v", err)124 }125 gamerB, reportB, err := mode.SpawnElo("same-seed", 1500)126 if err != nil {127 t.Fatalf("second SpawnElo returned error: %v", err)128 }129 if !reflect.DeepEqual(reportA, reportB) {130 t.Fatalf("reports differ for same seed and Elo:\n%#v\n%#v", reportA, reportB)131 }132133 saveA, err := gamerA.GetSave()134 if err != nil {135 t.Fatalf("first GetSave returned error: %v", err)136 }137 saveB, err := gamerB.GetSave()138 if err != nil {139 t.Fatalf("second GetSave returned error: %v", err)140 }141 if string(saveA) != string(saveB) {142 t.Fatalf("saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)143 }144}145146func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {147 mode := NewMode("Test", "Test mode.", 7, 0.57, easyProfile)148149 gamer, report, err := mode.SpawnElo("report-fields", 2200)150 if err != nil {151 t.Fatalf("SpawnElo returned error: %v", err)152 }153 if gamer == nil {154 t.Fatal("SpawnElo returned nil gamer")155 }156 if report.TargetElo != 2200 {157 t.Fatalf("TargetElo = %d, want 2200", report.TargetElo)158 }159 if report.ActualElo < difficulty.MinElo || report.ActualElo > difficulty.SoftCapElo {160 t.Fatalf("ActualElo = %d, want valid Elo", report.ActualElo)161 }162 if report.Confidence == "" {163 t.Fatal("Confidence is empty")164 }165166 required := []string{167 "size",168 "cells",169 "active_tiles",170 "density",171 "target_density",172 "leaf_tiles",173 "elbow_tiles",174 "tee_tiles",175 "rotation_options",176 "avg_rotation_options",177 "initially_rotated_tiles",178 }179 for _, key := range required {180 if _, ok := report.Metrics[key]; !ok {181 t.Fatalf("metric %q missing from report %#v", key, report.Metrics)182 }183 }184 if report.Metrics["active_tiles"] <= 0 {185 t.Fatalf("active_tiles = %.2f, want > 0", report.Metrics["active_tiles"])186 }187 if report.Metrics["density"] <= 0 || report.Metrics["density"] > 1 {188 t.Fatalf("density = %.2f, want 0 < density <= 1", report.Metrics["density"])189 }190 if report.Metrics["rotation_options"] < report.Metrics["active_tiles"] {191 t.Fatalf(192 "rotation_options = %.2f, want >= active_tiles %.2f",193 report.Metrics["rotation_options"],194 report.Metrics["active_tiles"],195 )196 }197}198199func TestDefaultKeyMapUsesEnterForLockAndSpaceForRotate(t *testing.T) {200 if !key.Matches(201 keyPress("space"),202 DefaultKeyMap.Rotate,203 ) {204 t.Fatal("space should match rotate binding")205 }206 if key.Matches(207 keyPress("enter"),208 DefaultKeyMap.Rotate,209 ) {210 t.Fatal("enter should not match rotate binding")211 }212 if !key.Matches(213 keyPress("enter"),214 DefaultKeyMap.Lock,215 ) {216 t.Fatal("enter should match lock binding")217 }218 if key.Matches(219 keyPress("l"),220 DefaultKeyMap.Lock,221 ) {222 t.Fatal("l should not match lock binding")223 }224}225226func TestFrontierWeightPrefersPackedCandidatesOnHardProfiles(t *testing.T) {227 active := map[point]struct{}{228 {X: 2, Y: 2}: {},229 {X: 1, Y: 1}: {},230 {X: 3, Y: 1}: {},231 }232 adjacency := map[point]directionMask{233 {X: 2, Y: 2}: 0,234 {X: 1, Y: 1}: 0,235 {X: 3, Y: 1}: 0,236 }237 bounds := activeBounds{minX: 0, maxX: 4, minY: 0, maxY: 4}238239 packed := frontierWeight(240 5,241 frontierEdge{from: point{X: 2, Y: 2}, to: point{X: 2, Y: 1}},242 active,243 adjacency,244 bounds,245 hardProfile,246 )247 isolated := frontierWeight(248 5,249 frontierEdge{from: point{X: 2, Y: 2}, to: point{X: 2, Y: 3}},250 active,251 adjacency,252 bounds,253 hardProfile,254 )255 if packed <= isolated {256 t.Fatalf("packed frontier weight = %d, want > isolated %d", packed, isolated)257 }258}259260func TestSpanGrowthScoreRewardsExpansionBeforeTarget(t *testing.T) {261 bounds := activeBounds{minX: 2, maxX: 4, minY: 2, maxY: 4}262263 growing := spanGrowthScore(9, point{X: 1, Y: 4}, bounds, mediumProfile)264 stable := spanGrowthScore(9, point{X: 3, Y: 3}, bounds, mediumProfile)265 if growing <= stable {266 t.Fatalf("expanding span score = %d, want > stable %d", growing, stable)267 }268}269270func TestNetwalkModeDensityProgression(t *testing.T) {271 modes := netwalkModesFromRegistry(t)272 fill := make([]float64, len(modes))273 junctions := make([]float64, len(modes))274275 for i, mode := range modes {276 fill[i], junctions[i] = sampleModeMetrics(t, mode, 12)277 }278279 for i := 1; i < len(fill); i++ {280 if fill[i] <= fill[i-1] {281 t.Fatalf("fill ratio[%d] = %.3f, want > %.3f", i, fill[i], fill[i-1])282 }283 }284 for i := 2; i < len(junctions); i++ {285 if junctions[i] <= junctions[i-1] {286 t.Fatalf("junction avg[%d] = %.3f, want > %.3f", i, junctions[i], junctions[i-1])287 }288 }289290 for i, mode := range modes {291 target := float64(targetActiveFromFillRatio(mode.Size, mode.FillRatio)) / float64(mode.Size*mode.Size)292 if math.Abs(fill[i]-target) > 1e-9 {293 t.Fatalf("%s fill ratio = %.3f, want %.3f", mode.Title(), fill[i], target)294 }295 }296}297298func TestCellRowsShowDirectionalRootsAndLeaves(t *testing.T) {299 m := Model{}300301 m.puzzle = newPuzzle(3)302 m.puzzle.Tiles[1][1] = tile{Kind: serverCell}303 m.puzzle.Tiles[1][2] = tile{Kind: nodeCell}304 m.puzzle.Tiles[0][1] = tile{Kind: nodeCell}305 m.state.rotatedMasks = make([][]directionMask, 3)306 for y := range 3 {307 m.state.rotatedMasks[y] = make([]directionMask, 3)308 }309 m.state.rotatedMasks[1][1] = south310 m.state.rotatedMasks[1][2] = north311 m.state.rotatedMasks[0][1] = north | east | south312313 if got := cellRows(m, 1, 1); got != [cellHeight]string{" ", " ◆ ", " │ "} {314 t.Fatalf("south root rows = %#v", got)315 }316 if got := cellRows(m, 2, 1); got != [cellHeight]string{" │ ", " ● ", " "} {317 t.Fatalf("north leaf rows = %#v", got)318 }319 if got := cellRows(m, 1, 0); got != [cellHeight]string{" │ ", " ├──", " │ "} {320 t.Fatalf("tee rows = %#v", got)321 }322}323324func TestGridViewUsesTallerFrameWithoutInteriorBoxes(t *testing.T) {325 m := Model{326 puzzle: newPuzzle(2),327 }328 m.recompute()329330 lines := strings.Split(ansi.Strip(gridView(m)), "\n")331 if len(lines) != 8 {332 t.Fatalf("rendered line count = %d, want 8", len(lines))333 }334 for _, idx := range []int{1, 2, 3, 4, 5, 6} {335 if got := strings.Count(lines[idx], "│"); got != 2 {336 t.Fatalf("line %d has %d vertical borders, want outer frame only", idx, got)337 }338 }339}340341func TestGridViewShowsCursorGlyphsOnBlankCells(t *testing.T) {342 m := Model{343 puzzle: newPuzzle(2),344 cursor: game.Cursor{X: 1, Y: 1},345 }346 m.recompute()347348 view := ansi.Strip(gridView(m))349 if !strings.Contains(view, "▸ ◂") {350 t.Fatalf("blank cursor markers missing from view:\n%s", view)351 }352}353354func TestRecomputeInvalidatesCachedGridOrigin(t *testing.T) {355 m := Model{356 puzzle: newPuzzle(2),357 modeTitle: "Origin Check",358 termWidth: 120,359 termHeight: 40,360 originX: 17,361 originY: 9,362 originValid: true,363 showFullHelp: true,364 }365366 m.recompute()367368 if m.originValid {369 t.Fatal("expected recompute to invalidate cached grid origin")370 }371}372373func netwalkModesFromRegistry(t *testing.T) []NetwalkMode {374 t.Helper()375376 modes := make([]NetwalkMode, 0, len(Modes))377 for i, mode := range Modes {378 netwalkMode, ok := mode.(NetwalkMode)379 if !ok {380 t.Fatalf("mode %d has type %T, want NetwalkMode", i, mode)381 }382 modes = append(modes, netwalkMode)383 }384 return modes385}386387func sampleModeMetrics(388 t *testing.T,389 mode NetwalkMode,390 samples int,391) (float64, float64) {392 t.Helper()393394 var totalFill float64395 var totalJunctions float64396 for i := range samples {397 rng := rand.New(rand.NewPCG(uint64(1000+i), uint64(7000+i)))398 puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, rng)399 if err != nil {400 t.Fatalf("GenerateSeededWithDensity(%q) error = %v", mode.Title(), err)401 }402 totalFill += puzzleFillRatio(puzzle)403 totalJunctions += float64(puzzleJunctionCount(puzzle))404 }405 return totalFill / float64(samples), totalJunctions / float64(samples)406}407408func puzzleFillRatio(p Puzzle) float64 {409 if p.Size <= 0 {410 return 0411 }412 active := 0413 for y := range p.Size {414 for x := range p.Size {415 if isActive(p.Tiles[y][x]) {416 active++417 }418 }419 }420 return float64(active) / float64(p.Size*p.Size)421}422423func puzzleJunctionCount(p Puzzle) int {424 count := 0425 for y := range p.Size {426 for x := range p.Size {427 if !isActive(p.Tiles[y][x]) {428 continue429 }430 if degree(p.Tiles[y][x].BaseMask) >= 3 {431 count++432 }433 }434 }435 return count436}437438func keyPress(value string) tea.KeyPressMsg {439 switch value {440 case "enter":441 return tea.KeyPressMsg{Code: tea.KeyEnter}442 case "space":443 return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}444 default:445 r := []rune(value)446 return tea.KeyPressMsg{Code: r[0], Text: value}447 }448}