All checks were successful
release / build-release (push) Successful in 1m46s
- internal/progress: katalog (skills.json 12 noder, items.json 25 items) med validering; XP/nivåer (150·n(n+1)/2) och skillpoäng; skillträd med krav och kostnad; items per plats med buffar, genvägar (JR Pass, kartor, powerbank) och färgton; banresultat och upplåsning (huvudbanor i ordning, sidospår efter föräldern); 33 %-regeln som test - internal/save: versionerad JSON med kunskap, plånbok, debuffar, progression och pågående bana + seed; atomisk skrivning; migrering; ~/.config/metro-puzzle-jp/save.json resp. %AppData% - sim: RunOptions får Extra/FogRadius/Landmarks/FreeGates/RevealAll/ InfiniteBattery; luck påverkar trängselzonen; Fog.RevealAll och extra landmärken - game: bankarta i spelbrädesstil ersätter menyn (⟳ återupptar med sparad seed), skillträd (T), garderob (I), butik i kiosken (B), XP/nivå/items/ upplåsningar på resultatet, spelaren tonas av skins; autospar vid banstart, efter banan och vid köp; flaggor -save/-nosave - level: kind/order/parent/rewards/shop; ny sidobana 002b Kabukicho som belönar skinnet Kimono; 001 belönar Salaryman-skinnet; butikssortiment på 002/003 - tools/shots: demo-sparfil (build/, aldrig den riktiga), bilder av karta, skills, garderob och butik; alla körningar med -nosave - docs/README/wiki uppdaterade Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
433 lines
15 KiB
Go
433 lines
15 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)")
|
||
}
|
||
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{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")
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|