All checks were successful
release / build-release (push) Successful in 1m42s
- level.Schedule.Phase: away/approaching/arriving/dwelling/closing/leaving ur tidtabellen med progress; Boardable (öppna dörrar) och Present; validering av minsta uppehåll/borta-tid; TrackTile för rälsen - sim.Run: ombordstigning bara med öppna dörrar, TrainVisible via Present, PlatformPhase och NearTrack (närmaste perrong inom två rutor) - game/trains.go: rälsrutor längs kantraden (roterade för vertikala spår), tre vagnar som bromsar in och accelererar ut längs rälsen, strålkastare under inrullningen, blinkande lampor vid kanten för den som står nära, dammpartiklar längs perrongen; kameran får en rutas marginal så att kanträlsen syns - game/audio.go: ebiten/v2/audio med avgångsmelodi (approaching) och dörrsignal (closing), volym efter avstånd, -mute; tools/gensound syntetiserar placeholder-WAV:ar - HUD: fasberoende perrongtext och "tåg på väg in" nära rälsen; skriptkommandot waitphase; skärmdump av ankomsten - plan (Björn 2026-10-24): räls/väg-grafik och melodierna i M7b; folk/NPC:er, vänster-/högertrafik, bredare gångar, reklam- och ljudröra som nytt steg M9b – i spelbeskrivning, roadmap, testplan - docs/README/wiki uppdaterade Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
387 lines
13 KiB
Go
387 lines
13 KiB
Go
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)")
|
||
}
|
||
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")
|
||
}
|
||
}
|