package level import ( "math" "os" "path/filepath" "sort" "strings" "testing" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" ) const sample = `{ "id": "test", "name": {"sv": "Test"}, "difficulty": 1, "destination": {"sv": "Mål"}, "grid": [ "#######", "#S..#P#", "#.#.#.#", "#.#...#", "#######" ], "trains": [{"platform": "P", "line": {"en": "A"}, "destination": {"en": "B"}, "correct": true}] }` func mustParse(t *testing.T, s string) *Level { t.Helper() l, err := Parse([]byte(s)) if err != nil { t.Fatal(err) } return l } func TestParseAndSolve(t *testing.T) { l := mustParse(t, sample) if l.Width() != 7 || l.Height() != 5 { t.Fatalf("storlek %dx%d", l.Width(), l.Height()) } if l.SpawnPoint() != (Point{1, 1}) { t.Fatalf("spawn %v", l.SpawnPoint()) } if !l.Solvable() { t.Fatal("banan ska vara lösbar") } if u := l.Unreachable(); len(u) != 0 { t.Fatalf("oväntat onåbara rutor: %v", u) } } func TestUnsolvableWhenWalledOff(t *testing.T) { l := mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.#.#"`, 1)) if l.Solvable() { t.Fatal("banan ska INTE vara lösbar när perrongen är inmurad") } if u := l.Unreachable(); len(u) != 1 || u[0] != (Point{5, 1}) { t.Fatalf("Unreachable = %v, vill ha [(5,1)]", u) } } func TestOutsideGridIsWall(t *testing.T) { l := mustParse(t, sample) if l.At(-1, 0).Passable() || l.At(0, 99).Passable() { t.Fatal("utanför kartan ska räknas som vägg") } } func TestValidationErrors(t *testing.T) { cases := map[string]string{ "ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1), "okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1), "två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1), "ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1), "ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1), "inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1), "tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1), "dwell >= interval": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 10, "dwellSec": 10}`, 1), "negativ offset": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"offsetSec": -1}`, 1), "skylt utanför i-ruta": strings.Replace(sample, `"trains"`, `"signs": [{"x": 1, "y": 1, "text": {"sv": "x"}}], "trains"`, 1), "i-ruta utan skylt": strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), "skylt utan text": strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), `"trains"`, `"signs": [{"x": 2, "y": 1, "text": {}}], "trains"`, 1), "saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1), "trasig json": "{", } for name, src := range cases { if _, err := Parse([]byte(src)); err == nil { t.Errorf("%s: förväntade fel", name) } } } func TestTextFallback(t *testing.T) { tx := Text{"en": "Exit", "ja": "出口"} if tx.Get("ja") != "出口" || tx.Get("sv") != "Exit" { t.Fatal("fel fallback") } if (Text{"ja": "x"}).Get("sv") != "x" { t.Fatal("fallback till godtyckligt språk saknas") } if (Text{}).Get("sv") != "" { t.Fatal("tom text ska ge tom sträng") } } // TestAllShippedLevels är ett datadrivet test: varje bana under // resources/data/levels måste parsa, validera och vara lösbar. // Lägger man till en bana som inte går att klara faller bygget här. func TestAllShippedLevels(t *testing.T) { dir, err := res.Path("data", "levels") if err != nil { t.Fatal(err) } files, err := filepath.Glob(filepath.Join(dir, "*.json")) if err != nil { t.Fatal(err) } if len(files) == 0 { t.Fatalf("inga banor i %s", dir) } for _, f := range files { t.Run(filepath.Base(f), func(t *testing.T) { b, err := os.ReadFile(f) if err != nil { t.Fatal(err) } l, err := Parse(b) if err != nil { t.Fatal(err) } if want := strings.TrimSuffix(filepath.Base(f), ".json"); l.ID != want { t.Errorf("id %q matchar inte filnamnet %q", l.ID, want) } if !l.Solvable() { t.Error("banan går inte att klara från start") } if u := l.Unreachable(); len(u) > 0 { t.Errorf("onåbara interaktiva rutor: %v", u) } if u := l.UngatedPlatforms(); len(u) > 0 { t.Errorf("perronger som nås utan att passera en spärr: %v – spärrarna måste vara flaskhalsar", u) } for i, tr := range l.Trains { if tr.Track == "" { t.Errorf("tåg %d saknar spårnummer", i) } } if l.Party > 1 && (len(l.Find(Toilet)) == 0 || len(l.Find(Kiosk)) == 0) { t.Errorf("banor med sällskap behöver minst en toalett (W) och en kiosk (K)") } if l.Kind == "main" && l.Order == 0 { t.Errorf("huvudbanor behöver order för bankartan") } for _, sg := range l.Signs { if sg.Text["sv"] == "" || sg.Text["en"] == "" { t.Errorf("skylt (%d,%d) saknar sv/en-text", sg.X, sg.Y) } } }) } } func TestListAndLoadFile(t *testing.T) { dir, err := res.Path("data", "levels") if err != nil { t.Fatal(err) } ids, err := List(dir) if err != nil || len(ids) == 0 { t.Fatalf("ids=%v err=%v", ids, err) } if !sort.StringsAreSorted(ids) { t.Fatalf("inte sorterade: %v", ids) } if ids[0] != "001-flygplatsen" { t.Fatalf("första banan ska vara 001-flygplatsen, fick %q", ids[0]) } l, err := LoadFile(filepath.Join(dir, ids[0]+".json")) if err != nil { t.Fatal(err) } if tr := l.TrainAt(Platform); tr == nil || !tr.Correct { t.Fatal("TrainAt(P) ska ge det rätta tåget") } if tr := l.TrainAt(WrongPlatform); tr == nil || tr.Correct { t.Fatal("TrainAt(p) ska ge ett fel tåg") } if l.TrainAt(Wall) != nil { t.Fatal("TrainAt(#) ska vara nil") } if _, err := LoadFile(filepath.Join(dir, "finns-inte.json")); err == nil { t.Fatal("saknad fil ska ge fel") } } func TestScheduleStatus(t *testing.T) { s := Schedule{OffsetSec: 5, IntervalSec: 30, DwellSec: 10} cases := []struct { t float64 at bool secs float64 }{ {0, false, 5}, {5, true, 10}, {14.9, true, 0.1}, {15, false, 20}, {34.9, false, 0.1}, {35, true, 10}, {70, true, 5}, {80, false, 15}, } for _, c := range cases { at, secs := s.Status(c.t) if at != c.at || math.Abs(secs-c.secs) > 1e-9 { t.Errorf("t=%v: at=%v secs=%.2f, vill ha at=%v secs=%.2f", c.t, at, secs, c.at, c.secs) } } } func TestScheduleAndFareDefaults(t *testing.T) { l := mustParse(t, sample) sc := l.Trains[0].Schedule if sc.IntervalSec != DefaultIntervalSec || sc.DwellSec != DefaultDwellSec || sc.OffsetSec != 0 { t.Fatalf("standardtidtabell saknas: %+v", sc) } if l.FareYen != DefaultFareYen { t.Fatalf("standardavgift saknas: %d", l.FareYen) } } func TestSignAt(t *testing.T) { src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), `"trains"`, `"signs": [{"x": 2, "y": 1, "text": {"sv": "Hej"}}], "trains"`, 1) l := mustParse(t, src) if sg := l.SignAt(Point{2, 1}); sg == nil || sg.Text.Get("sv") != "Hej" { t.Fatalf("SignAt = %v", sg) } if l.SignAt(Point{1, 1}) != nil { t.Fatal("ingen skylt på startrutan") } } func TestPath(t *testing.T) { l := mustParse(t, sample) path := l.Path(l.SpawnPoint(), Point{5, 1}, l.CanEnter) // S(1,1) -> (1,2) -> (1,3) -> (2,3) -> (3,3) -> (4,3) -> (5,3) -> (5,2) -> (5,1) if len(path) != 9 || path[0] != (Point{1, 1}) || path[len(path)-1] != (Point{5, 1}) { t.Fatalf("path = %v", path) } for i := 1; i < len(path); i++ { dx, dy := path[i].X-path[i-1].X, path[i].Y-path[i-1].Y if dx*dx+dy*dy != 1 { t.Fatalf("steg %d är inte en grannruta: %v -> %v", i, path[i-1], path[i]) } } if l.Path(l.SpawnPoint(), Point{0, 0}, l.CanEnter) != nil { t.Fatal("väg in i en vägg ska vara nil") } if p := l.Path(l.SpawnPoint(), l.SpawnPoint(), l.CanEnter); len(p) != 1 { t.Fatalf("väg till sig själv = %v", p) } } func TestUngatedPlatforms(t *testing.T) { // Perrong utan spärr framför -> rapporteras. l := mustParse(t, sample) if u := l.UngatedPlatforms(); len(u) != 1 || u[0] != (Point{5, 1}) { t.Fatalf("UngatedPlatforms = %v, vill ha [(5,1)]", u) } // Spärr i enda passagen -> inget rapporteras, men banan är fortfarande lösbar. l = mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.G.#"`, 1)) if u := l.UngatedPlatforms(); len(u) != 0 { t.Fatalf("UngatedPlatforms = %v, vill ha inga", u) } if !l.Solvable() { t.Fatal("banan ska vara lösbar med spärren passerbar") } } func TestPartyAndParDefaults(t *testing.T) { l := mustParse(t, sample) if l.Party != 1 || l.ParSec != 0 { t.Fatalf("party=%d par=%d", l.Party, l.ParSec) } if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "party": 9`, 1))); err == nil { t.Fatal("för stort sällskap ska ge fel") } if l.CorrectPlatform() != (Point{5, 1}) { t.Fatalf("CorrectPlatform = %v", l.CorrectPlatform()) } if got := l.Trains[0].BoardText(); got != "番線 A B方面" { t.Fatalf("BoardText = %q", got) } l2 := mustParse(t, strings.Replace(sample, `"#S..#P#"`, `"#SW.KP#"`, 1)) if l2.At(2, 1) != Toilet || l2.At(4, 1) != Kiosk || !l2.Solvable() { t.Fatal("W och K ska vara passerbara rutor") } } func TestTicketAndRideFields(t *testing.T) { l := mustParse(t, sample) if l.RideStops != DefaultRideStops || l.TicketRequired || l.Trains[0].Crowded { t.Fatalf("standard: stops=%d ticket=%v crowded=%v", l.RideStops, l.TicketRequired, l.Trains[0].Crowded) } if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "ticketRequired": true`, 1))); err == nil { t.Fatal("biljettkrav utan automat ska ge fel") } if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "rideStops": 1`, 1))); err == nil { t.Fatal("för få stopp ska ge fel") } ok := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#ST.#P#"`, 1), `"difficulty": 1`, `"difficulty": 1, "ticketRequired": true, "rideStops": 4`, 1) l2 := mustParse(t, ok) if !l2.TicketRequired || l2.RideStops != 4 { t.Fatal("fälten ska läsas") } } func TestDifficultySettings(t *testing.T) { if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "seatSelection": true`, 1))); err == nil { t.Fatal("sittplatsval utan biljettbana ska ge fel") } if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "maxMissedTrains": -1`, 1))); err == nil { t.Fatal("negativt maxantal ska ge fel") } src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#ST.#P#"`, 1), `"difficulty": 1`, `"difficulty": 3, "ticketRequired": true, "seatSelection": true, "individualCards": true, "maxMissedTrains": 2, "timeLimitSec": 300`, 1) l := mustParse(t, src) if !l.SeatSelection || !l.IndividualCards || l.MaxMissedTrains != 2 || l.TimeLimitSec != 300 { t.Fatal("inställningarna ska läsas") } } func TestSchedulePhases(t *testing.T) { s := Schedule{OffsetSec: 10, IntervalSec: 30, DwellSec: 8} cases := []struct { t float64 phase Phase }{ {0, Away}, {4.9, Away}, {5.1, Approaching}, {9.9, Approaching}, {10, Arriving}, {11.4, Arriving}, {11.6, Dwelling}, {14.9, Dwelling}, {15.1, Closing}, {17.9, Closing}, {18.1, Leaving}, {19.4, Leaving}, {19.6, Away}, {34.9, Away}, {35.1, Approaching}, {40.5, Arriving}, {42, Dwelling}, } for _, c := range cases { p, prog := s.Phase(c.t) if p != c.phase { t.Errorf("t=%.1f: fas %s, vill ha %s", c.t, p, c.phase) } if prog < 0 || prog > 1.0001 { t.Errorf("t=%.1f: progress %.2f utanför 0..1", c.t, prog) } } if !s.Boardable(13) || s.Boardable(10.5) || s.Boardable(18.5) || !s.Boardable(16) { t.Fatal("ombordstigning bara med öppna dörrar (dwelling/closing)") } if !s.Present(10.5) || !s.Present(19) || s.Present(19.6) || s.Present(9) { t.Fatal("tåget syns från inrullning till utrullning") } // Progress växer monotont inom en fas. p1, a := s.Phase(5.5) p2, b := s.Phase(9.5) if p1 != Approaching || p2 != Approaching || b <= a { t.Fatalf("approaching-progress %.2f -> %.2f", a, b) } // Status och Phase är konsekventa: "vid perrongen" = arriving/dwelling/closing. for tt := 0.0; tt < 60; tt += 0.25 { at, _ := s.Status(tt) p, _ := s.Phase(tt) if at != (p == Arriving || p == Dwelling || p == Closing) { t.Fatalf("t=%.2f: Status=%v men fas=%s", tt, at, p) } } } func TestScheduleValidationForPhases(t *testing.T) { if _, err := Parse([]byte(strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 30, "dwellSec": 4}`, 1))); err == nil { t.Fatal("för kort uppehåll för faserna ska ge fel") } if _, err := Parse([]byte(strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 12, "dwellSec": 6}`, 1))); err == nil { t.Fatal("för kort tid borta ska ge fel") } } func TestTrackTile(t *testing.T) { l := mustParse(t, sample) tile, horizontal := l.TrackTile(Point{5, 1}) // P uppe till höger: vägg ovanför? nej, (5,0) är vägg -> men ordningen är ner, höger, vänster, upp if tile != (Point{5, 2}) && tile != (Point{6, 1}) && tile != (Point{5, 0}) { t.Fatalf("TrackTile = %v", tile) } // (5,2) är golv, (6,1) är vägg -> höger: vertikal räls if tile == (Point{6, 1}) && horizontal { t.Fatal("räls till höger om perrongen är vertikal") } } func TestKindOrderParentAndLoadAll(t *testing.T) { l := mustParse(t, sample) if l.Kind != "main" || l.IsSide() { t.Fatal("standard är main") } if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "side"`, 1))); err == nil { t.Fatal("sidobana utan parent ska ge fel") } if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "parent": "x"`, 1))); err == nil { t.Fatal("huvudbana med parent ska ge fel") } if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "bus"`, 1))); err == nil { t.Fatal("okänd kind ska ge fel") } side := mustParse(t, strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "side", "parent": "001", "rewards": {"items": ["skin_kimono"], "skillPoints": 1}`, 1)) if !side.IsSide() || side.Parent != "001" || side.Rewards.SkillPoints != 1 || len(side.Rewards.Items) != 1 { t.Fatal("sidobanans fält ska läsas") } dir, _ := res.Path("data", "levels") all, err := LoadAll(dir) if err != nil || len(all) < 4 { t.Fatalf("LoadAll: %d banor, %v", len(all), err) } sides := 0 for _, l := range all { if l.IsSide() { sides++ found := false for _, m := range all { if m.ID == l.Parent && !m.IsSide() { found = true } } if !found { t.Errorf("%s: föräldern %q finns inte bland huvudbanorna", l.ID, l.Parent) } } } if sides == 0 { t.Fatal("det ska finnas minst en sidobana") } }