mirror of
https://github.com/IBM/fp-go.git
synced 2025-11-23 22:14:53 +02:00
482 lines
13 KiB
Go
482 lines
13 KiB
Go
|
|
// Copyright (c) 2023 - 2025 IBM Corp.
|
||
|
|
// All rights reserved.
|
||
|
|
//
|
||
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
// you may not use this file except in compliance with the License.
|
||
|
|
// You may obtain a copy of the License at
|
||
|
|
//
|
||
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
//
|
||
|
|
// Unless required by applicable law or agreed to in writing, software
|
||
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
// See the License for the specific language governing permissions and
|
||
|
|
// limitations under the License.
|
||
|
|
|
||
|
|
package option
|
||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
EQT "github.com/IBM/fp-go/v2/eq/testing"
|
||
|
|
F "github.com/IBM/fp-go/v2/function"
|
||
|
|
ISO "github.com/IBM/fp-go/v2/optics/iso"
|
||
|
|
L "github.com/IBM/fp-go/v2/optics/lens"
|
||
|
|
LT "github.com/IBM/fp-go/v2/optics/lens/testing"
|
||
|
|
O "github.com/IBM/fp-go/v2/option"
|
||
|
|
"github.com/stretchr/testify/assert"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Test types
|
||
|
|
type Config struct {
|
||
|
|
timeout int
|
||
|
|
retries int
|
||
|
|
}
|
||
|
|
|
||
|
|
type Settings struct {
|
||
|
|
maxConnections int
|
||
|
|
bufferSize int
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFromIsoBasic tests basic functionality of FromIso
|
||
|
|
func TestFromIsoBasic(t *testing.T) {
|
||
|
|
// Create an isomorphism that treats 0 as None
|
||
|
|
zeroAsNone := ISO.MakeIso(
|
||
|
|
func(t int) O.Option[int] {
|
||
|
|
if t == 0 {
|
||
|
|
return O.None[int]()
|
||
|
|
}
|
||
|
|
return O.Some(t)
|
||
|
|
},
|
||
|
|
func(opt O.Option[int]) int {
|
||
|
|
return O.GetOrElse(F.Constant(0))(opt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
// Create a lens to the timeout field
|
||
|
|
timeoutLens := L.MakeLens(
|
||
|
|
func(c Config) int { return c.timeout },
|
||
|
|
func(c Config, t int) Config { c.timeout = t; return c },
|
||
|
|
)
|
||
|
|
|
||
|
|
// Convert to optional lens using FromIso
|
||
|
|
optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens)
|
||
|
|
|
||
|
|
t.Run("GetNone", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 0, retries: 3}
|
||
|
|
result := optTimeoutLens.Get(config)
|
||
|
|
assert.True(t, O.IsNone(result))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("GetSome", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 30, retries: 3}
|
||
|
|
result := optTimeoutLens.Get(config)
|
||
|
|
assert.True(t, O.IsSome(result))
|
||
|
|
assert.Equal(t, 30, O.GetOrElse(F.Constant(0))(result))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SetNone", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 30, retries: 3}
|
||
|
|
updated := optTimeoutLens.Set(O.None[int]())(config)
|
||
|
|
assert.Equal(t, 0, updated.timeout)
|
||
|
|
assert.Equal(t, 3, updated.retries) // Other fields unchanged
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SetSome", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 0, retries: 3}
|
||
|
|
updated := optTimeoutLens.Set(O.Some(60))(config)
|
||
|
|
assert.Equal(t, 60, updated.timeout)
|
||
|
|
assert.Equal(t, 3, updated.retries) // Other fields unchanged
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SetPreservesOriginal", func(t *testing.T) {
|
||
|
|
original := Config{timeout: 30, retries: 3}
|
||
|
|
_ = optTimeoutLens.Set(O.Some(60))(original)
|
||
|
|
// Original should be unchanged
|
||
|
|
assert.Equal(t, 30, original.timeout)
|
||
|
|
assert.Equal(t, 3, original.retries)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFromIsoWithNegativeSentinel tests using -1 as a sentinel value
|
||
|
|
func TestFromIsoWithNegativeSentinel(t *testing.T) {
|
||
|
|
// Create an isomorphism that treats -1 as None
|
||
|
|
negativeOneAsNone := ISO.MakeIso(
|
||
|
|
func(n int) O.Option[int] {
|
||
|
|
if n == -1 {
|
||
|
|
return O.None[int]()
|
||
|
|
}
|
||
|
|
return O.Some(n)
|
||
|
|
},
|
||
|
|
func(opt O.Option[int]) int {
|
||
|
|
return O.GetOrElse(F.Constant(-1))(opt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
retriesLens := L.MakeLens(
|
||
|
|
func(c Config) int { return c.retries },
|
||
|
|
func(c Config, r int) Config { c.retries = r; return c },
|
||
|
|
)
|
||
|
|
|
||
|
|
optRetriesLens := FromIso[Config, int](negativeOneAsNone)(retriesLens)
|
||
|
|
|
||
|
|
t.Run("GetNoneForNegativeOne", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 30, retries: -1}
|
||
|
|
result := optRetriesLens.Get(config)
|
||
|
|
assert.True(t, O.IsNone(result))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("GetSomeForZero", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 30, retries: 0}
|
||
|
|
result := optRetriesLens.Get(config)
|
||
|
|
assert.True(t, O.IsSome(result))
|
||
|
|
assert.Equal(t, 0, O.GetOrElse(F.Constant(-1))(result))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SetNoneToNegativeOne", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 30, retries: 5}
|
||
|
|
updated := optRetriesLens.Set(O.None[int]())(config)
|
||
|
|
assert.Equal(t, -1, updated.retries)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFromIsoLaws verifies that FromIso satisfies lens laws
|
||
|
|
func TestFromIsoLaws(t *testing.T) {
|
||
|
|
// Create an isomorphism
|
||
|
|
zeroAsNone := ISO.MakeIso(
|
||
|
|
func(t int) O.Option[int] {
|
||
|
|
if t == 0 {
|
||
|
|
return O.None[int]()
|
||
|
|
}
|
||
|
|
return O.Some(t)
|
||
|
|
},
|
||
|
|
func(opt O.Option[int]) int {
|
||
|
|
return O.GetOrElse(F.Constant(0))(opt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
timeoutLens := L.MakeLens(
|
||
|
|
func(c Config) int { return c.timeout },
|
||
|
|
func(c Config, t int) Config { c.timeout = t; return c },
|
||
|
|
)
|
||
|
|
|
||
|
|
optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens)
|
||
|
|
|
||
|
|
eqOptInt := O.Eq(EQT.Eq[int]())
|
||
|
|
eqConfig := EQT.Eq[Config]()
|
||
|
|
|
||
|
|
config := Config{timeout: 30, retries: 3}
|
||
|
|
newValue := O.Some(60)
|
||
|
|
|
||
|
|
// Law 1: GetSet - lens.Set(lens.Get(s))(s) == s
|
||
|
|
t.Run("GetSetLaw", func(t *testing.T) {
|
||
|
|
result := optTimeoutLens.Set(optTimeoutLens.Get(config))(config)
|
||
|
|
assert.True(t, eqConfig.Equals(config, result))
|
||
|
|
})
|
||
|
|
|
||
|
|
// Law 2: SetGet - lens.Get(lens.Set(a)(s)) == a
|
||
|
|
t.Run("SetGetLaw", func(t *testing.T) {
|
||
|
|
result := optTimeoutLens.Get(optTimeoutLens.Set(newValue)(config))
|
||
|
|
assert.True(t, eqOptInt.Equals(newValue, result))
|
||
|
|
})
|
||
|
|
|
||
|
|
// Law 3: SetSet - lens.Set(a2)(lens.Set(a1)(s)) == lens.Set(a2)(s)
|
||
|
|
t.Run("SetSetLaw", func(t *testing.T) {
|
||
|
|
a1 := O.Some(60)
|
||
|
|
a2 := O.None[int]()
|
||
|
|
result1 := optTimeoutLens.Set(a2)(optTimeoutLens.Set(a1)(config))
|
||
|
|
result2 := optTimeoutLens.Set(a2)(config)
|
||
|
|
assert.True(t, eqConfig.Equals(result1, result2))
|
||
|
|
})
|
||
|
|
|
||
|
|
// Use the testing helper to verify all laws
|
||
|
|
t.Run("AllLaws", func(t *testing.T) {
|
||
|
|
laws := LT.AssertLaws(t, eqOptInt, eqConfig)(optTimeoutLens)
|
||
|
|
assert.True(t, laws(config, O.Some(100)))
|
||
|
|
assert.True(t, laws(Config{timeout: 0, retries: 5}, O.None[int]()))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFromIsoComposition tests composing FromIso with other lenses
|
||
|
|
func TestFromIsoComposition(t *testing.T) {
|
||
|
|
type Application struct {
|
||
|
|
config Config
|
||
|
|
}
|
||
|
|
|
||
|
|
// Isomorphism for zero as none
|
||
|
|
zeroAsNone := ISO.MakeIso(
|
||
|
|
func(t int) O.Option[int] {
|
||
|
|
if t == 0 {
|
||
|
|
return O.None[int]()
|
||
|
|
}
|
||
|
|
return O.Some(t)
|
||
|
|
},
|
||
|
|
func(opt O.Option[int]) int {
|
||
|
|
return O.GetOrElse(F.Constant(0))(opt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
// Lens to config field
|
||
|
|
configLens := L.MakeLens(
|
||
|
|
func(a Application) Config { return a.config },
|
||
|
|
func(a Application, c Config) Application { a.config = c; return a },
|
||
|
|
)
|
||
|
|
|
||
|
|
// Lens to timeout field
|
||
|
|
timeoutLens := L.MakeLens(
|
||
|
|
func(c Config) int { return c.timeout },
|
||
|
|
func(c Config, t int) Config { c.timeout = t; return c },
|
||
|
|
)
|
||
|
|
|
||
|
|
// Compose: Application -> Config -> timeout (as Option)
|
||
|
|
optTimeoutFromConfig := FromIso[Config, int](zeroAsNone)(timeoutLens)
|
||
|
|
optTimeoutFromApp := F.Pipe1(
|
||
|
|
configLens,
|
||
|
|
L.Compose[Application](optTimeoutFromConfig),
|
||
|
|
)
|
||
|
|
|
||
|
|
app := Application{config: Config{timeout: 0, retries: 3}}
|
||
|
|
|
||
|
|
t.Run("ComposedGet", func(t *testing.T) {
|
||
|
|
result := optTimeoutFromApp.Get(app)
|
||
|
|
assert.True(t, O.IsNone(result))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("ComposedSet", func(t *testing.T) {
|
||
|
|
updated := optTimeoutFromApp.Set(O.Some(45))(app)
|
||
|
|
assert.Equal(t, 45, updated.config.timeout)
|
||
|
|
assert.Equal(t, 3, updated.config.retries)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFromIsoModify tests using Modify with FromIso-based lenses
|
||
|
|
func TestFromIsoModify(t *testing.T) {
|
||
|
|
zeroAsNone := ISO.MakeIso(
|
||
|
|
func(t int) O.Option[int] {
|
||
|
|
if t == 0 {
|
||
|
|
return O.None[int]()
|
||
|
|
}
|
||
|
|
return O.Some(t)
|
||
|
|
},
|
||
|
|
func(opt O.Option[int]) int {
|
||
|
|
return O.GetOrElse(F.Constant(0))(opt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
timeoutLens := L.MakeLens(
|
||
|
|
func(c Config) int { return c.timeout },
|
||
|
|
func(c Config, t int) Config { c.timeout = t; return c },
|
||
|
|
)
|
||
|
|
|
||
|
|
optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens)
|
||
|
|
|
||
|
|
t.Run("ModifyNoneToSome", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 0, retries: 3}
|
||
|
|
// Map None to Some(10)
|
||
|
|
modified := L.Modify[Config](O.Map(func(x int) int { return x + 10 }))(optTimeoutLens)(config)
|
||
|
|
// Since it was None, Map doesn't apply, stays None (0)
|
||
|
|
assert.Equal(t, 0, modified.timeout)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("ModifySomeValue", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 30, retries: 3}
|
||
|
|
// Double the timeout value
|
||
|
|
modified := L.Modify[Config](O.Map(func(x int) int { return x * 2 }))(optTimeoutLens)(config)
|
||
|
|
assert.Equal(t, 60, modified.timeout)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("ModifyWithAlt", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 0, retries: 3}
|
||
|
|
// Use Alt to provide a default
|
||
|
|
modified := L.Modify[Config](func(opt O.Option[int]) O.Option[int] {
|
||
|
|
return O.Alt(F.Constant(O.Some(10)))(opt)
|
||
|
|
})(optTimeoutLens)(config)
|
||
|
|
assert.Equal(t, 10, modified.timeout)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFromIsoWithStringEmpty tests using empty string as None
|
||
|
|
func TestFromIsoWithStringEmpty(t *testing.T) {
|
||
|
|
type User struct {
|
||
|
|
name string
|
||
|
|
email string
|
||
|
|
}
|
||
|
|
|
||
|
|
// Isomorphism that treats empty string as None
|
||
|
|
emptyAsNone := ISO.MakeIso(
|
||
|
|
func(s string) O.Option[string] {
|
||
|
|
if s == "" {
|
||
|
|
return O.None[string]()
|
||
|
|
}
|
||
|
|
return O.Some(s)
|
||
|
|
},
|
||
|
|
func(opt O.Option[string]) string {
|
||
|
|
return O.GetOrElse(F.Constant(""))(opt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
emailLens := L.MakeLens(
|
||
|
|
func(u User) string { return u.email },
|
||
|
|
func(u User, e string) User { u.email = e; return u },
|
||
|
|
)
|
||
|
|
|
||
|
|
optEmailLens := FromIso[User, string](emptyAsNone)(emailLens)
|
||
|
|
|
||
|
|
t.Run("EmptyStringAsNone", func(t *testing.T) {
|
||
|
|
user := User{name: "Alice", email: ""}
|
||
|
|
result := optEmailLens.Get(user)
|
||
|
|
assert.True(t, O.IsNone(result))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("NonEmptyStringAsSome", func(t *testing.T) {
|
||
|
|
user := User{name: "Alice", email: "alice@example.com"}
|
||
|
|
result := optEmailLens.Get(user)
|
||
|
|
assert.True(t, O.IsSome(result))
|
||
|
|
assert.Equal(t, "alice@example.com", O.GetOrElse(F.Constant(""))(result))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SetNoneToEmpty", func(t *testing.T) {
|
||
|
|
user := User{name: "Alice", email: "alice@example.com"}
|
||
|
|
updated := optEmailLens.Set(O.None[string]())(user)
|
||
|
|
assert.Equal(t, "", updated.email)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFromIsoRoundTrip tests round-trip conversions
|
||
|
|
func TestFromIsoRoundTrip(t *testing.T) {
|
||
|
|
zeroAsNone := ISO.MakeIso(
|
||
|
|
func(t int) O.Option[int] {
|
||
|
|
if t == 0 {
|
||
|
|
return O.None[int]()
|
||
|
|
}
|
||
|
|
return O.Some(t)
|
||
|
|
},
|
||
|
|
func(opt O.Option[int]) int {
|
||
|
|
return O.GetOrElse(F.Constant(0))(opt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
maxConnectionsLens := L.MakeLens(
|
||
|
|
func(s Settings) int { return s.maxConnections },
|
||
|
|
func(s Settings, m int) Settings { s.maxConnections = m; return s },
|
||
|
|
)
|
||
|
|
|
||
|
|
optMaxConnectionsLens := FromIso[Settings, int](zeroAsNone)(maxConnectionsLens)
|
||
|
|
|
||
|
|
t.Run("RoundTripThroughGet", func(t *testing.T) {
|
||
|
|
settings := Settings{maxConnections: 100, bufferSize: 1024}
|
||
|
|
// Get the value, then Set it back
|
||
|
|
opt := optMaxConnectionsLens.Get(settings)
|
||
|
|
restored := optMaxConnectionsLens.Set(opt)(settings)
|
||
|
|
assert.Equal(t, settings, restored)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("RoundTripThroughSet", func(t *testing.T) {
|
||
|
|
settings := Settings{maxConnections: 0, bufferSize: 1024}
|
||
|
|
// Set a new value, then Get it
|
||
|
|
newOpt := O.Some(200)
|
||
|
|
updated := optMaxConnectionsLens.Set(newOpt)(settings)
|
||
|
|
retrieved := optMaxConnectionsLens.Get(updated)
|
||
|
|
assert.True(t, O.Eq(EQT.Eq[int]()).Equals(newOpt, retrieved))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("RoundTripWithNone", func(t *testing.T) {
|
||
|
|
settings := Settings{maxConnections: 100, bufferSize: 1024}
|
||
|
|
// Set None, then get it back
|
||
|
|
updated := optMaxConnectionsLens.Set(O.None[int]())(settings)
|
||
|
|
retrieved := optMaxConnectionsLens.Get(updated)
|
||
|
|
assert.True(t, O.IsNone(retrieved))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFromIsoChaining tests chaining multiple FromIso transformations
|
||
|
|
func TestFromIsoChaining(t *testing.T) {
|
||
|
|
// Create two different isomorphisms
|
||
|
|
zeroAsNone := ISO.MakeIso(
|
||
|
|
func(t int) O.Option[int] {
|
||
|
|
if t == 0 {
|
||
|
|
return O.None[int]()
|
||
|
|
}
|
||
|
|
return O.Some(t)
|
||
|
|
},
|
||
|
|
func(opt O.Option[int]) int {
|
||
|
|
return O.GetOrElse(F.Constant(0))(opt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
timeoutLens := L.MakeLens(
|
||
|
|
func(c Config) int { return c.timeout },
|
||
|
|
func(c Config, t int) Config { c.timeout = t; return c },
|
||
|
|
)
|
||
|
|
|
||
|
|
optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens)
|
||
|
|
|
||
|
|
config := Config{timeout: 30, retries: 3}
|
||
|
|
|
||
|
|
t.Run("ChainedOperations", func(t *testing.T) {
|
||
|
|
// Chain multiple operations
|
||
|
|
result := F.Pipe2(
|
||
|
|
config,
|
||
|
|
optTimeoutLens.Set(O.Some(60)),
|
||
|
|
optTimeoutLens.Set(O.None[int]()),
|
||
|
|
)
|
||
|
|
assert.Equal(t, 0, result.timeout)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFromIsoMultipleFields tests using FromIso on multiple fields
|
||
|
|
func TestFromIsoMultipleFields(t *testing.T) {
|
||
|
|
zeroAsNone := ISO.MakeIso(
|
||
|
|
func(t int) O.Option[int] {
|
||
|
|
if t == 0 {
|
||
|
|
return O.None[int]()
|
||
|
|
}
|
||
|
|
return O.Some(t)
|
||
|
|
},
|
||
|
|
func(opt O.Option[int]) int {
|
||
|
|
return O.GetOrElse(F.Constant(0))(opt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
timeoutLens := L.MakeLens(
|
||
|
|
func(c Config) int { return c.timeout },
|
||
|
|
func(c Config, t int) Config { c.timeout = t; return c },
|
||
|
|
)
|
||
|
|
|
||
|
|
retriesLens := L.MakeLens(
|
||
|
|
func(c Config) int { return c.retries },
|
||
|
|
func(c Config, r int) Config { c.retries = r; return c },
|
||
|
|
)
|
||
|
|
|
||
|
|
optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens)
|
||
|
|
optRetriesLens := FromIso[Config, int](zeroAsNone)(retriesLens)
|
||
|
|
|
||
|
|
t.Run("IndependentFields", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 0, retries: 5}
|
||
|
|
|
||
|
|
// Get both fields
|
||
|
|
timeoutOpt := optTimeoutLens.Get(config)
|
||
|
|
retriesOpt := optRetriesLens.Get(config)
|
||
|
|
|
||
|
|
assert.True(t, O.IsNone(timeoutOpt))
|
||
|
|
assert.True(t, O.IsSome(retriesOpt))
|
||
|
|
assert.Equal(t, 5, O.GetOrElse(F.Constant(0))(retriesOpt))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SetBothFields", func(t *testing.T) {
|
||
|
|
config := Config{timeout: 0, retries: 0}
|
||
|
|
|
||
|
|
// Set both fields
|
||
|
|
updated := F.Pipe2(
|
||
|
|
config,
|
||
|
|
optTimeoutLens.Set(O.Some(30)),
|
||
|
|
optRetriesLens.Set(O.Some(3)),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert.Equal(t, 30, updated.timeout)
|
||
|
|
assert.Equal(t, 3, updated.retries)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Made with Bob
|