- Go 1.27 + Ebitengine v2.10, intern upplösning 320x180 med heltalsskalning - internal/res (resursuppslag), internal/rng (seedad slump), internal/level (banformat, validering, BFS-lösbarhet) med tester; internal/archtest kontrollerar att bara presentationslagret beror på Ebitengine - tools/build: debug -> build/debug/, release -> build/release/<os>-<arch>/ med resources/ bredvid binären; Windows korsbyggs utan C-verktyg - VS Code tasks (build/test/release) och launch (dlv) - Första banan 001-flygplatsen, sv/en-texter, skyltordbok, placeholder-sprite - doc/: spelbeskrivning (renskriven), mål och tester, roadmap med teknikval (M0–M12), plan.md; README med bygg- och beroendeinformation Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
138 lines
3.6 KiB
Go
138 lines
3.6 KiB
Go
package level
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/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),
|
|
"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)
|
|
}
|
|
})
|
|
}
|
|
}
|