All checks were successful
release / build-release (push) Successful in 2m7s
Björns tillägg 2026-10-24. Med egna kort och fler i sällskapet frågar kiosken vem som betalar (minigame.Payer: kontant, spelarens kort eller en följares kort; den som inte har råd nekas, Esc avbryter). Avgiften dras nu när man passerar spärren in i det betalda området (level.InsideGates), inte på spärrrutan. Banflaggan oneWayGates: incheckad släpps inte ut igen förrän man åkt; Enter vid spärren kallar på personal (10 s) som låser upp korten; ny inpassering kostar igen. På i 005, 007/007b och 008/008b. Skriptkommandon urge och push. Docs, wiki och skärmdumpar uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
761 lines
27 KiB
Go
761 lines
27 KiB
Go
package level
|
||
|
||
import (
|
||
"math"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
|
||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
|
||
"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{X: 1, Y: 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{X: 5, Y: 1}) {
|
||
t.Fatalf("Unreachable = %v, vill ha [(5,1)]", u)
|
||
}
|
||
}
|
||
|
||
func TestOutsideGridIsWall(t *testing.T) {
|
||
l := mustParse(t, sample)
|
||
if l.FloorAt(0, -1, 0).Passable() || l.FloorAt(0, 0, 99).Passable() || l.At(Point{Z: 5}).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) {
|
||
l, err := LoadFile(f) // kopplar nätbanor till network.json (rätt tåg räknas ut där)
|
||
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.Networked() && tr.Verdict == nav.Unjudged {
|
||
t.Errorf("tåg %d är inte bedömt av nätet", i)
|
||
}
|
||
}
|
||
if l.Networked() && len(l.Trains) > 1 {
|
||
trap := false
|
||
for _, tr := range l.Trains {
|
||
trap = trap || !tr.Correct
|
||
}
|
||
if !trap {
|
||
t.Error("nätbanor med flera tåg ska ha minst ett fel tåg att undvika")
|
||
}
|
||
}
|
||
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{X: 2, Y: 1}); sg == nil || sg.Text.Get("sv") != "Hej" {
|
||
t.Fatalf("SignAt = %v", sg)
|
||
}
|
||
if l.SignAt(Point{X: 1, Y: 1}) != nil {
|
||
t.Fatal("ingen skylt på startrutan")
|
||
}
|
||
}
|
||
|
||
func TestPath(t *testing.T) {
|
||
l := mustParse(t, sample)
|
||
path := l.Path(l.SpawnPoint(), Point{X: 5, Y: 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{X: 1, Y: 1}) || path[len(path)-1] != (Point{X: 5, Y: 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{X: 0, Y: 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)
|
||
}
|
||
}
|
||
|
||
// TestAnnotateWithNetwork: rätt/fel tåg räknas ut från linjenätet, inklusive
|
||
// fel håll och för långt bort, och orimliga kopplingar ger fel.
|
||
func TestAnnotateWithNetwork(t *testing.T) {
|
||
net, err := nav.Parse([]byte(`{"stations":[
|
||
{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}},{"id":"c","name":{"ja":"う"}},{"id":"d","name":{"ja":"え"}},{"id":"x","name":{"ja":"さ"}}],
|
||
"lines":[{"id":"l1","name":{"ja":"一線"},"stations":["a","b","c","d"]},{"id":"l2","name":{"ja":"二線"},"stations":["x","b"]}]}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
src := `{"id":"net","name":{"sv":"x"},"difficulty":1,"destination":{"ja":"う"},"station":"b","to":"c",
|
||
"grid":["#########","#S.GP.Gp#","#..GQ.Gq#","#########"],
|
||
"trains":[
|
||
{"platform":"P","track":"1","line":{"ja":"一線"},"destination":{"ja":"え"},"lineId":"l1","terminus":"d"},
|
||
{"platform":"p","track":"2","line":{"ja":"一線"},"destination":{"ja":"あ"},"lineId":"l1","terminus":"a"},
|
||
{"platform":"Q","track":"3","line":{"ja":"二線"},"destination":{"ja":"さ"},"lineId":"l2","terminus":"x"},
|
||
{"platform":"q","track":"4","line":{"ja":"一線"},"destination":{"ja":"え"},"lineId":"l1","terminus":"d"}]}`
|
||
l, err := Parse([]byte(src))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !l.Networked() || l.Net != nil {
|
||
t.Fatal("nätbana före Annotate")
|
||
}
|
||
if err := l.Annotate(net); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := []nav.Verdict{nav.Direct, nav.WrongDirection, nav.NoService, nav.Direct}
|
||
for i, tr := range l.Trains {
|
||
if tr.Verdict != want[i] || tr.Correct != (want[i] == nav.Direct) {
|
||
t.Errorf("tåg %d: verdict=%v correct=%v, vill ha %v", i, tr.Verdict, tr.Correct, want[i])
|
||
}
|
||
}
|
||
if l.Net != net || !l.Solvable() || l.CorrectPlatform() != (Point{X: 4, Y: 1}) {
|
||
t.Fatal("kopplad bana ska vara lösbar via P")
|
||
}
|
||
// Fel: okänd linje, fel linjenamn, resmål som inte matchar, inget rätt tåg, nät saknas.
|
||
cases := map[string]string{
|
||
"okänd linje": strings.Replace(src, `"lineId":"l1","terminus":"d"}`, `"lineId":"nope","terminus":"d"}`, 1),
|
||
"fel linjenamn": strings.Replace(src, `"line":{"ja":"二線"}`, `"line":{"ja":"一線"}`, 1),
|
||
"fel resmålstext": strings.Replace(src, `"destination":{"ja":"う"},"station"`, `"destination":{"ja":"え"},"station"`, 1),
|
||
"inget rätt tåg": strings.Replace(src, `"to":"c"`, `"to":"a"`, 1),
|
||
"obedömbart tåg": strings.Replace(src, `"lineId":"l1","terminus":"a"}`, `"lineId":"l1","terminus":"b"}`, 1),
|
||
}
|
||
for name, c := range cases {
|
||
l, err := Parse([]byte(c))
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", name, err)
|
||
}
|
||
if err := l.Annotate(net); err == nil {
|
||
t.Errorf("%s: skulle ha gett fel", name)
|
||
}
|
||
}
|
||
if err := l.Annotate(nil); err == nil {
|
||
t.Error("nätbana utan nät ska ge fel")
|
||
}
|
||
// Banor utan station/to påverkas inte.
|
||
plain, err := Parse([]byte(`{"id":"p","name":{"sv":"x"},"difficulty":1,"destination":{"ja":"う"},"grid":["#####","#S.P#","#####"],
|
||
"trains":[{"platform":"P","track":"1","line":{"ja":"一線"},"destination":{"ja":"う"},"correct":true}]}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := plain.Annotate(net); err != nil || plain.Net != nil || plain.Trains[0].Verdict != nav.Unjudged || !plain.Trains[0].Correct {
|
||
t.Fatal("okopplad bana ska lämnas i fred")
|
||
}
|
||
}
|
||
|
||
func TestPlatformTilesAndShippedVerdicts(t *testing.T) {
|
||
for _, tl := range PlatformTiles {
|
||
if !tl.IsPlatform() || !knownTiles[tl] {
|
||
t.Errorf("%c ska vara en känd perrong", tl)
|
||
}
|
||
}
|
||
if Gate.IsPlatform() || Floor.IsPlatform() {
|
||
t.Fatal("spärr/golv är inte perronger")
|
||
}
|
||
dir, err := res.Path("data", "levels")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
l, err := LoadFile(filepath.Join(dir, "005-ueno.json"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if tr := l.TrainAt(WrongPlatform); tr == nil || tr.Verdict != nav.WrongDirection {
|
||
t.Fatalf("005: Keihin-Tohoku mot Omiya ska vara fel håll, fick %v", tr)
|
||
}
|
||
l6, err := LoadFile(filepath.Join(dir, "006-ikebukuro.json"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := map[Tile]nav.Verdict{}
|
||
for _, tr := range l6.Trains {
|
||
got[Tile(tr.Platform[0])] = tr.Verdict
|
||
}
|
||
want := map[Tile]nav.Verdict{Platform: nav.Direct, Platform2: nav.Direct, WrongPlatform: nav.WrongDirection, Platform3: nav.TooFar}
|
||
for tl, v := range want {
|
||
if got[tl] != v {
|
||
t.Errorf("006 perrong %c: %v, vill ha %v", tl, got[tl], v)
|
||
}
|
||
}
|
||
all, err := LoadAll(dir)
|
||
if err != nil || len(all) < 7 {
|
||
t.Fatalf("LoadAll: %v (%d banor)", err, len(all))
|
||
}
|
||
}
|
||
|
||
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{X: 5, Y: 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{X: 5, Y: 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.FloorAt(0, 2, 1) != Toilet || l2.FloorAt(0, 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{X: 5, Y: 1}) // ordningen är ner, höger, vänster, upp
|
||
if tile != (Point{X: 5, Y: 2}) && tile != (Point{X: 6, Y: 1}) && tile != (Point{X: 5, Y: 0}) {
|
||
t.Fatalf("TrackTile = %v", tile)
|
||
}
|
||
// (5,2) är golv, (6,1) är vägg -> höger: vertikal räls
|
||
if tile == (Point{X: 6, Y: 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")
|
||
}
|
||
}
|
||
|
||
const floorsSample = `{
|
||
"id": "two-floors", "name": {"sv": "Två våningar"}, "difficulty": 2, "destination": {"sv": "x"},
|
||
"floors": [
|
||
{"name": "B1", "grid": [
|
||
"#######",
|
||
"#S..U.#",
|
||
"#.###.#",
|
||
"#.....#",
|
||
"#######"
|
||
]},
|
||
{"name": "1F", "grid": [
|
||
"#######",
|
||
"#...D.#",
|
||
"#.#####",
|
||
"#G..i.#",
|
||
"#P#####"
|
||
]}
|
||
],
|
||
"signs": [{"x": 4, "y": 3, "z": 1, "text": {"sv": "Upp", "en": "Up"}}],
|
||
"trains": [{"platform": "P", "track": "1", "line": {"en": "A"}, "destination": {"en": "B"}, "correct": true}]
|
||
}`
|
||
|
||
func TestFloorsStairsAndPaths(t *testing.T) {
|
||
l := mustParse(t, floorsSample)
|
||
if l.NumFloors() != 2 || l.FloorName(0) != "B1" || l.FloorName(1) != "1F" || l.FloorName(9) != "1F" {
|
||
t.Fatalf("våningar: %d %s %s", l.NumFloors(), l.FloorName(0), l.FloorName(1))
|
||
}
|
||
if l.Width() != 7 || l.Height() != 5 || len(l.Grid) != 5 {
|
||
t.Fatal("storlek/kompatibilitet")
|
||
}
|
||
if l.SpawnPoint() != (Point{X: 1, Y: 1, Z: 0}) || l.CorrectPlatform() != (Point{X: 1, Y: 4, Z: 1}) {
|
||
t.Fatalf("spawn=%v platform=%v", l.SpawnPoint(), l.CorrectPlatform())
|
||
}
|
||
if l.At(Point{X: 4, Y: 1}) != StairsUp || l.At(Point{X: 4, Y: 1, Z: 1}) != StairsDown || !StairsUp.IsStairs() || !StairsUp.Passable() {
|
||
t.Fatal("trappor")
|
||
}
|
||
up := Point{X: 4, Y: 1}
|
||
n := l.Neighbors(up)
|
||
if len(n) != 5 || n[4] != up.Up() {
|
||
t.Fatalf("grannar vid trappa: %v", n)
|
||
}
|
||
if !l.Solvable() {
|
||
t.Fatal("banan ska vara lösbar via trappan")
|
||
}
|
||
path := l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)
|
||
if path == nil || path[0].Z != 0 || path[len(path)-1].Z != 1 {
|
||
t.Fatalf("vägen ska byta våning: %v", path)
|
||
}
|
||
climbed := false
|
||
for i := 1; i < len(path); i++ {
|
||
if path[i].Z != path[i-1].Z {
|
||
climbed = true
|
||
if path[i].X != path[i-1].X || path[i].Y != path[i-1].Y || !l.At(path[i-1]).IsStairs() {
|
||
t.Fatalf("våningsbyte utan trappa: %v -> %v", path[i-1], path[i])
|
||
}
|
||
}
|
||
}
|
||
if !climbed {
|
||
t.Fatal("vägen använde ingen trappa")
|
||
}
|
||
if u := l.UngatedPlatforms(); len(u) != 0 {
|
||
t.Fatalf("perrongen ligger innanför spärren: %v", u)
|
||
}
|
||
if sg := l.SignAt(Point{X: 4, Y: 3, Z: 1}); sg == nil || l.SignAt(Point{X: 4, Y: 3}) != nil {
|
||
t.Fatal("skylten hör till våning 1")
|
||
}
|
||
if u := l.Unreachable(); len(u) != 0 {
|
||
t.Fatalf("onåbart: %v", u)
|
||
}
|
||
}
|
||
|
||
func TestFloorsValidation(t *testing.T) {
|
||
bad := map[string]string{
|
||
"trappa utan motpart": strings.Replace(floorsSample, `"#...D.#"`, `"#.....#"`, 1),
|
||
"olika storlek": strings.Replace(floorsSample, `"#P#####"`, `"#P######"`, 1),
|
||
"både grid och floors": strings.Replace(floorsSample, `"floors"`, `"grid": ["###","#S#","###"], "floors"`, 1),
|
||
"skylt på fel våning": strings.Replace(floorsSample, `"z": 1`, `"z": 0`, 1),
|
||
}
|
||
for name, src := range bad {
|
||
if _, err := Parse([]byte(src)); err == nil {
|
||
t.Errorf("%s: förväntade fel", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCarsWomenOnlyAndCrowdFields(t *testing.T) {
|
||
l := mustParse(t, sample)
|
||
tr := l.Trains[0]
|
||
if tr.Cars != DefaultCars || tr.WomenOnlyCar != 0 || tr.WomenOnlyActive(10) || l.Crowd != 0 {
|
||
t.Fatal("standardvärden")
|
||
}
|
||
src := strings.Replace(sample, `"correct": true`, `"correct": true, "cars": 4, "womenOnlyCar": 1, "womenOnlyFrom": 30, "womenOnlyTo": 90`, 1)
|
||
src = strings.Replace(src, `"difficulty": 1`, `"difficulty": 1, "crowd": 2, "clutter": 3, "noise": 1`, 1)
|
||
l2 := mustParse(t, src)
|
||
tr2 := l2.Trains[0]
|
||
if tr2.Cars != 4 || tr2.WomenOnlyCar != 1 || tr2.WomenOnlyActive(10) || !tr2.WomenOnlyActive(30) || !tr2.WomenOnlyActive(89.9) || tr2.WomenOnlyActive(90) {
|
||
t.Fatal("kvinnovagnens tidsfönster")
|
||
}
|
||
always := mustParse(t, strings.Replace(sample, `"correct": true`, `"correct": true, "womenOnlyCar": 2`, 1))
|
||
if !always.Trains[0].WomenOnlyActive(0) || !always.Trains[0].WomenOnlyActive(99999) {
|
||
t.Fatal("utan fönster gäller kvinnovagnen alltid")
|
||
}
|
||
if l2.Crowd != 2 || l2.Clutter != 3 || l2.Noise != 1 {
|
||
t.Fatal("crowd/clutter/noise")
|
||
}
|
||
for name, src := range map[string]string{
|
||
"för många vagnar": strings.Replace(sample, `"correct": true`, `"correct": true, "cars": 9`, 1),
|
||
"kvinnovagn > vagnar": strings.Replace(sample, `"correct": true`, `"correct": true, "cars": 2, "womenOnlyCar": 3`, 1),
|
||
"fönster baklänges": strings.Replace(sample, `"correct": true`, `"correct": true, "womenOnlyCar": 1, "womenOnlyFrom": 50, "womenOnlyTo": 10`, 1),
|
||
"crowd 4": strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "crowd": 4`, 1),
|
||
} {
|
||
if _, err := Parse([]byte(src)); err == nil {
|
||
t.Errorf("%s: förväntade fel", name)
|
||
}
|
||
}
|
||
lanes := mustParse(t, strings.Replace(sample, `"#S..#P#"`, `"#S<>#P#"`, 1))
|
||
if lanes.FloorAt(0, 2, 1) != LaneLeft || !LaneRight.IsLane() || !LaneLeft.Passable() || Floor.IsLane() {
|
||
t.Fatal("filer")
|
||
}
|
||
}
|
||
|
||
// TestStoryChains: resorna med byten hänger ihop, storyn börjar och slutar på
|
||
// flygplatsen, och felaktiga kedjor ger fel.
|
||
func TestStoryChains(t *testing.T) {
|
||
dir, err := res.Path("data", "levels")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
all, err := LoadAll(dir) // kör ValidateChains
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var first, last *Level
|
||
legs, chains := 0, 0
|
||
for _, l := range all {
|
||
switch {
|
||
case l.IsLeg():
|
||
legs++
|
||
if l.Order != 0 {
|
||
t.Errorf("%s: delbanor har ingen order", l.ID)
|
||
}
|
||
case !l.IsSide():
|
||
if first == nil || l.Order < first.Order {
|
||
first = l
|
||
}
|
||
if last == nil || l.Order > last.Order {
|
||
last = l
|
||
}
|
||
}
|
||
if l.Next != "" {
|
||
chains++
|
||
c := ChainOf(all, l.ID)
|
||
if len(c) < 2 || c[0] != l || FinalOf(all, l) != c[len(c)-1] {
|
||
t.Errorf("%s: ChainOf/FinalOf = %v", l.ID, c)
|
||
}
|
||
}
|
||
}
|
||
if legs == 0 || chains == 0 {
|
||
t.Fatal("storyn ska ha resor med byten")
|
||
}
|
||
if first == nil || first.Station != "narita-airport" {
|
||
t.Errorf("storyn ska börja på flygplatsen, första banan är %v", first)
|
||
}
|
||
if last == nil || FinalOf(all, last).To != "narita-airport" {
|
||
t.Errorf("storyn ska sluta på flygplatsen, sista banan slutar i %q", FinalOf(all, last).To)
|
||
}
|
||
|
||
mk := func(id, kind, next, station, to string) *Level {
|
||
return &Level{ID: id, Kind: kind, Next: next, Station: station, To: to}
|
||
}
|
||
bad := map[string][]*Level{
|
||
"saknad next": {mk("a", "main", "zzz", "", "")},
|
||
"next inte leg": {mk("a", "main", "b", "", ""), mk("b", "main", "", "", "")},
|
||
"oanvänd delbana": {mk("a", "main", "", "", ""), mk("b", "leg", "", "", "")},
|
||
"delbana två gånger": {mk("a", "main", "b", "", ""), mk("c", "main", "b", "", ""), mk("b", "leg", "", "", "")},
|
||
"cirkel": {mk("a", "main", "b", "", ""), mk("b", "leg", "c", "", ""), mk("c", "leg", "b", "", "")},
|
||
"byte hänger inte": {mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "z", "w")},
|
||
}
|
||
for name, lv := range bad {
|
||
if err := ValidateChains(lv); err == nil {
|
||
t.Errorf("%s: skulle ha gett fel", name)
|
||
}
|
||
}
|
||
if err := ValidateChains([]*Level{mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "y", "w")}); err != nil {
|
||
t.Errorf("giltig kedja: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestOneWayGatesAndInside(t *testing.T) {
|
||
src := `{"id":"ow","name":{"sv":"x"},"difficulty":1,"destination":{"sv":"x"},"oneWayGates":true,
|
||
"grid":["#########","#S.G.P.G#","#########"],
|
||
"trains":[{"platform":"P","track":"1","line":{"en":"A"},"destination":{"en":"B"},"correct":true}]}`
|
||
l, err := Parse([]byte(src))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !l.OneWayGates {
|
||
t.Fatal("flaggan ska läsas")
|
||
}
|
||
in := l.InsideGates()
|
||
want := map[Point]bool{{X: 4, Y: 1}: true, {X: 5, Y: 1}: true, {X: 6, Y: 1}: true}
|
||
if len(in) != len(want) {
|
||
t.Fatalf("innanför spärrarna: %v", in)
|
||
}
|
||
for p := range want {
|
||
if !in[p] {
|
||
t.Errorf("%v ska vara innanför", p)
|
||
}
|
||
}
|
||
if in[Point{X: 3, Y: 1}] || in[Point{X: 1, Y: 1}] {
|
||
t.Fatal("spärren och starten är inte innanför")
|
||
}
|
||
}
|