All checks were successful
release / build-release (push) Successful in 1m38s
Björns tillägg 2026-10-24. Generatorn har nu ett betalt område per perrong (paidDepth) och toilets inside/outside/both: 005 Ueno och 008b Nippori har toalett bara innanför, 007b Ueno på båda sidor. Perrongceller görs till återvändsgränder (bara passagen mot starten behålls, labyrinten repareras) och det betalda området krymper om det skulle bli genomfart; regeln att varje perrong nås utan att passera ett annat betalt område testas i level och levelcheck. level.PathOutside används av testbotar och shots. Alla genererade banor utom 003 omgjorda, skärmdumpar omtagna. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
841 lines
30 KiB
Go
841 lines
30 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 _, tr := range l.Trains { // varje perrong ska nås utan att gå genom en annan perrongs betalda område
|
||
pt := l.Find(Tile(tr.Platform[0]))[0]
|
||
if l.PathOutside(l.SpawnPoint(), pt, l.CanEnter) == nil {
|
||
t.Errorf("perrong %s nås bara genom ett annat betalt område", tr.Platform)
|
||
}
|
||
}
|
||
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 TestPathOutsideAvoidsOtherPaidAreas(t *testing.T) {
|
||
// Två perronger med betalda områden; den raka vägen till Q går genom
|
||
// P:s område, PathOutside tar omvägen via korridoren ovanför.
|
||
src := `{"id":"po","name":{"sv":"x"},"difficulty":1,"destination":{"sv":"x"},"oneWayGates":true,
|
||
"grid":["###########","#S........#","#.#######.#","#G#######G#","#.G.P.G.Q.#","###########"],
|
||
"trains":[{"platform":"P","track":"1","line":{"en":"A"},"destination":{"en":"B"},"correct":true},
|
||
{"platform":"Q","track":"2","line":{"en":"A"},"destination":{"en":"C"}}]}`
|
||
l, err := Parse([]byte(src))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
q := l.Find(Platform2)[0]
|
||
direct := l.Path(l.SpawnPoint(), q, l.CanEnter)
|
||
outside := l.PathOutside(l.SpawnPoint(), q, l.CanEnter)
|
||
if direct == nil || outside == nil {
|
||
t.Fatal("vägar saknas")
|
||
}
|
||
p := l.Find(Platform)[0]
|
||
pRegion := map[Point]bool{{X: 3, Y: 4}: true, p: true, {X: 5, Y: 4}: true}
|
||
through := false
|
||
for _, pt := range direct {
|
||
through = through || pRegion[pt]
|
||
}
|
||
if !through {
|
||
t.Fatal("den raka vägen borde gå genom P:s område – annars testar vi inget")
|
||
}
|
||
for _, pt := range outside {
|
||
if pRegion[pt] {
|
||
t.Fatalf("PathOutside går genom P:s område vid %v", pt)
|
||
}
|
||
}
|
||
// Från P (inne) och ut till starten: det egna området får användas.
|
||
if back := l.PathOutside(p, l.SpawnPoint(), l.CanEnter); back == nil {
|
||
t.Fatal("vägen ut från P ska finnas")
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
|
||
// TestToiletSides: toaletterna ligger på den sida av spärrarna som banan
|
||
// är gjord för – bara innanför, bara utanför eller båda.
|
||
func TestToiletSides(t *testing.T) {
|
||
dir, err := res.Path("data", "levels")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := map[string][2]bool{ // id -> {innanför, utanför}
|
||
"005-ueno": {true, false},
|
||
"007-akihabara": {false, true},
|
||
"007b-ueno": {true, true},
|
||
"008-tokyo-hemresa": {false, true},
|
||
"008b-nippori": {true, false},
|
||
}
|
||
for id, w := range want {
|
||
l, err := LoadFile(filepath.Join(dir, id+".json"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
inside := l.InsideGates()
|
||
in, out := false, false
|
||
for _, p := range l.Find(Toilet) {
|
||
if inside[p] {
|
||
in = true
|
||
} else {
|
||
out = true
|
||
}
|
||
}
|
||
if in != w[0] || out != w[1] {
|
||
t.Errorf("%s: toaletter innanför=%v utanför=%v, vill ha %v/%v", id, in, out, w[0], w[1])
|
||
}
|
||
if in && !l.OneWayGates {
|
||
t.Errorf("%s: toaletter innanför är bara intressant med spärrar utan återvändo", id)
|
||
}
|
||
}
|
||
}
|