All checks were successful
release / build-release (push) Successful in 1m41s
- sim.Wallet: IC-kort, kontanter, pappersbiljett, telefon med batteri, spenderat; följer med mellan banor (Game.Wallet) - internal/minigame: gemensamt Game-gränssnitt (Update/Done/Success), all slump från del-seeds: TopUp (hitta チャージ, belopp, mata sedlar), Ticket (taxetavla med resmål på japanska), Crowd (tidsfönster, två försök), Ride (stationerna passerar, kliv av vid resmålet) - sim.Run: spärren kräver biljett på biljettbanor annars användbart kort; telefonen ger ×1,1 fart men 15 % seedad omskanning (frysning 1,5 s), dött batteri gör kortet oanvändbart; automaten startar påfyllning eller biljettköp; tåget kontrollerar biljetten; fulla tåg kräver trängsel; resan avgör vinst/förlust (fel station, åkte förbi, fel tåg); StepMini låter klockan och sällskapet ticka medan rörelsen pausar - level: ticketRequired, rideStops, Train.Crowded; 002 är biljettbana med fullt tåg och fyra stopp; ordboken har stationer (kind) + sju nya namn - game: vyer för minispelen, plånbok i HUD, P i menyn = kort i telefonen, förlustorsaker och spenderat på resultatet, flaggor -phone/-card, skriptkommandon hold/waitstop; inforuta/minikarta döljs under minispel - plan: M7b (Björn) – tågvind, ankomstindikator nära rälsen, in-/utkörningsanimation för tåg och bussar – i spelbeskrivning, roadmap, testplan - docs/README/wiki med tre nya bilder (automat, biljettavla, tågresa) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
309 lines
9.8 KiB
Go
309 lines
9.8 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")
|
||
}
|
||
}
|