├── .travis.yml ├── .gitignore ├── README.md └── src └── test └── scala └── com └── example ├── laws ├── DowncastingPrismSpec.scala ├── NaivePrismSpec.scala ├── common │ ├── ArbitraryInstances.scala │ └── OpticsSpec.scala └── DurationOpticsSpec.scala ├── IsoExample.scala ├── CoproductPrismExample.scala ├── ClassicLensExample.scala ├── CirceExample.scala ├── OptionalExample.scala ├── NaivePrismExample.scala └── MaintainingInvariantsExample.scala /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | jdk: oraclejdk8 3 | scala: 4 | - 2.12.2 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /RUNNING_PID 2 | /logs/ 3 | /project/*-shim.sbt 4 | /project/project/ 5 | /project/target/ 6 | /target/ 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://api.travis-ci.org/note/monocle-example.svg)](https://travis-ci.org/note/monocle-example) 2 | 3 | This is accompanying repository to post Optics beyond Lenses: https://blog.scalac.io/optics-beyond-lenses-with-monocle.html 4 | 5 | ## How to navigate the repo 6 | 7 | Examples used in the article are in `com.example` package in `src/test` directory. The fact that snippets were 8 | transitioned to test cases made example code testable and (hopefully) readable thanks having expected result visible 9 | right away (instead of having to run some code). 10 | 11 | Tests verifying lawfulness of some Optics are located in `com.example.laws`. -------------------------------------------------------------------------------- /src/test/scala/com/example/laws/DowncastingPrismSpec.scala: -------------------------------------------------------------------------------- 1 | package com.example.laws 2 | 3 | import com.example.DowncastingPrisms 4 | import com.example.Percent._ 5 | import com.example.laws.common.OpticsSpec 6 | import monocle.law.discipline.PrismTests 7 | 8 | class DowncastingPrismSpec extends OpticsSpec { 9 | import scalaz.std.anyVal._ 10 | import scalaz.std.string._ 11 | 12 | val stringToIntRuleSet = PrismTests(DowncastingPrisms.stringToIntPrism) 13 | val intToPercentRuleSet = PrismTests(DowncastingPrisms.intToPercentPrism) 14 | checkLaws("stringToIntPrism", stringToIntRuleSet, 15) 15 | checkLaws("intToPercentPrism", intToPercentRuleSet) 16 | } 17 | -------------------------------------------------------------------------------- /src/test/scala/com/example/laws/NaivePrismSpec.scala: -------------------------------------------------------------------------------- 1 | package com.example.laws 2 | 3 | import com.example.NaivePrisms 4 | import com.example.laws.common.OpticsSpec 5 | import monocle.law.discipline.PrismTests 6 | 7 | class NaivePrismSpec extends OpticsSpec { 8 | import scalaz.std.anyVal._ 9 | import scalaz.std.string._ 10 | 11 | val stringToIntRuleSet = PrismTests(NaivePrisms.stringToIntPrism) 12 | val intToPercentRuleSet = PrismTests(NaivePrisms.intToPercentPrism) 13 | 14 | // Since those are NaivePrisms they're not lawful and would fail the tests 15 | // uncomment following lines to see them failing: 16 | // checkLaws("stringToIntPrism", stringToIntRuleSet, 15) 17 | // checkLaws("intToPercentPrism", intToPercentRuleSet) 18 | } 19 | -------------------------------------------------------------------------------- /src/test/scala/com/example/laws/common/ArbitraryInstances.scala: -------------------------------------------------------------------------------- 1 | package com.example.laws.common 2 | 3 | import com.example.Percent 4 | import org.scalacheck.{Arbitrary, Cogen, Gen} 5 | 6 | trait ArbitraryInstances { 7 | val numbers = ('0' to '9').map(n => Gen.freqTuple(8, n)) 8 | val letters = "@ !.xyz".toCharArray.map(n => Gen.freqTuple(1, n)) 9 | val freqTuples = (numbers ++ letters).toList 10 | 11 | val arbListChars = Gen.listOf(Gen.frequency(freqTuples:_*)) 12 | implicit val arbString = Arbitrary(arbListChars.map(_.mkString)) 13 | 14 | implicit val arbPercent = Arbitrary(Gen.chooseNum(0, 100).map(n => Percent(n))) 15 | implicit val arbPercentF = { 16 | implicit val percentCogen: Cogen[Percent] = implicitly[Cogen[Int]].contramap[Percent](_.value) 17 | Arbitrary.arbFunction1[Percent, Percent] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/scala/com/example/laws/common/OpticsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.example.laws.common 2 | 3 | import org.scalactic.anyvals.PosZInt 4 | import org.scalatest.FlatSpec 5 | import org.scalatest.prop.Checkers 6 | import org.typelevel.discipline.Laws 7 | 8 | class OpticsSpec extends FlatSpec with ArbitraryInstances { 9 | // taken from io.circe.tests.CirceSuite 10 | def checkLaws(name: String, ruleSet: Laws#RuleSet, maxSize: Int = 100): Unit = { 11 | val configParams = List(Checkers.MinSuccessful(200), Checkers.SizeRange(PosZInt.from(maxSize).get)) 12 | 13 | ruleSet.all.properties.zipWithIndex.foreach { 14 | case ((id, prop), 0) => name should s"obey $id" in Checkers.check(prop, configParams:_*) 15 | case ((id, prop), _) => it should s"obey $id" in Checkers.check(prop, configParams:_*) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/test/scala/com/example/laws/DurationOpticsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.example.laws 2 | 3 | import com.example.{Duration, DurationOptics} 4 | import com.example.laws.common.OpticsSpec 5 | import monocle.law.discipline.LensTests 6 | import org.scalacheck.{Arbitrary, Gen} 7 | 8 | /** 9 | * IMPORTANT: This spec crucial part is actually commented out 10 | * 11 | * This code remains in repo to have read-to-run code with which you can check 12 | * why DurationOptics is not lawful 13 | */ 14 | class DurationOpticsSpec extends OpticsSpec { 15 | import scalaz.std.anyVal._ 16 | import Duration._ 17 | 18 | val durationGen = for { 19 | hours <- Gen.chooseNum(0, 100) 20 | minutes <- Gen.chooseNum(0, 59) 21 | seconds <- Gen.chooseNum(0, 59) 22 | } yield Duration(hours, minutes, seconds) 23 | implicit val arbDuration = Arbitrary(durationGen) 24 | 25 | implicit val arbInt = Arbitrary(Gen.chooseNum(0, 120)) 26 | 27 | val hoursRules = LensTests(DurationOptics.hoursL) 28 | val minutesRules = LensTests(DurationOptics.minutesL) 29 | val secondsRules = LensTests(DurationOptics.secondsL) 30 | 31 | // checkLaws("hours Lens", hoursRules, maxSize = 3000) 32 | // checkLaws("minutes Lens", minutesRules, maxSize = 120) 33 | // checkLaws("seconds Lens", secondsRules, maxSize = 3000) 34 | } 35 | -------------------------------------------------------------------------------- /src/test/scala/com/example/IsoExample.scala: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import monocle._ 4 | import org.scalatest.{Matchers, WordSpec} 5 | 6 | // business entities 7 | case class Meter(whole: Int, fraction: Int) 8 | case class Centimeter(whole: Int) 9 | 10 | // Optics 11 | object PhysicalUnitsOptics { 12 | val centimeterToMeterIso = Iso[Centimeter, Meter] { cm => 13 | Meter(cm.whole / 100, cm.whole % 100) 14 | }{ m => 15 | Centimeter(m.whole * 100 + m.fraction) 16 | } 17 | 18 | val intCentimeter = Iso[Int, Centimeter](Centimeter.apply)(_.whole) 19 | val wholeMeterLens = Lens[Meter, Int](_.whole)(newWhole => prevMeter => prevMeter.copy(whole = newWhole)) 20 | val stringToWholeMeter: Optional[String, Int] = DowncastingPrisms.stringToIntPrism. 21 | composeIso(intCentimeter). 22 | composeIso(centimeterToMeterIso). 23 | composeLens(wholeMeterLens) 24 | } 25 | 26 | class IsoExample extends WordSpec with Matchers { 27 | import PhysicalUnitsOptics._ 28 | 29 | "centimeterToMeterIso" should { 30 | "work" in { 31 | centimeterToMeterIso.modify(m => m.copy(m.whole + 3))(Centimeter(155)) should equal(Centimeter(455)) 32 | centimeterToMeterIso.modify(meter => meter.copy(meter.whole + 3))(Centimeter(155)).toString 33 | } 34 | 35 | "be more readable with composed Optics" in { 36 | stringToWholeMeter.modify(_ + 3)("155") should equal("455") 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/test/scala/com/example/CoproductPrismExample.scala: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import monocle.Prism 4 | import org.scalatest.{Matchers, WordSpec} 5 | 6 | /** 7 | * Prism for Coproduct - in that case modelled with sealed trait hierarchy. 8 | * 9 | * Example idea taken from http://julien-truffaut.github.io/Monocle/optics/prism.html 10 | */ 11 | 12 | sealed trait Json 13 | case object JNull extends Json 14 | case class JStr(v: String) extends Json 15 | case class JNum(v: Double) extends Json 16 | case class JObj(v: Map[String, Json]) extends Json 17 | 18 | class CoproductPrismExample extends WordSpec with Matchers { 19 | val stringPrism = Prism.partial[Json, String]{case JStr(v) => v}(JStr) 20 | 21 | "stringPrism" should { 22 | "work for primitive operations" in { 23 | stringPrism.getOption(JStr("someString")) should equal(Some("someString")) 24 | stringPrism.reverseGet("someString") should equal(JStr("someString")) 25 | 26 | // prism `getOption` returns None if does not succeed 27 | stringPrism.getOption(JNull) should equal(None) 28 | } 29 | 30 | "work for modify" in { 31 | val someJson: Json = JStr("someString") 32 | 33 | // first let's try to capitalize JStr with pattern match 34 | val withPatternMatch = someJson match { 35 | case JStr(s) => JStr(s.toUpperCase) 36 | case anythingElse => anythingElse 37 | } 38 | 39 | // now the same thing with Prism 40 | val withPrism = stringPrism.modify(_.toUpperCase)(someJson) 41 | 42 | withPrism should equal(withPatternMatch) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/test/scala/com/example/ClassicLensExample.scala: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import monocle.Lens 4 | import org.scalatest.{FlatSpec, Matchers, WordSpec} 5 | 6 | // Business entities 7 | case class Person(fullName: String, address: Address) 8 | case class Address(city: String, street: Street) 9 | case class Street(name: String, number: Int) 10 | 11 | // Lens definitions 12 | object PersonLenses { 13 | val addressLens = Lens.apply[Person, Address](person => person.address)(newAddress => person => person.copy(address = newAddress)) 14 | val streetLens = Lens.apply[Address, Street](address => address.street)(newStreet => address => address.copy(street = newStreet)) 15 | val nameLens = Lens.apply[Street, String](street => street.name)(newName => street => street.copy(name = newName)) 16 | } 17 | 18 | object ClassicLensExample extends FlatSpec with Matchers { 19 | // to have access to lenses 20 | import PersonLenses._ 21 | 22 | "composed Lenses" should "works as nested copy" in { 23 | val bob = Person("Bob Dylan", Address("New York", Street("some", 67))) 24 | 25 | upperCaseWithCopy(bob) should equal(upperCaseWithLens(bob)) 26 | } 27 | 28 | // Let's capitalize street name without Lenses to have some reference point 29 | def upperCaseWithCopy(person: Person): Person = 30 | person.copy(address = person.address.copy( 31 | street = person.address.street.copy( 32 | name = person.address.street.name.toUpperCase 33 | ) 34 | )) 35 | 36 | // Same thing as above but with Lenses 37 | def upperCaseWithLens(person: Person): Person = { 38 | val streetName: Lens[Person, String] = addressLens.composeLens(streetLens).composeLens(nameLens) 39 | streetName.modify(_.toUpperCase)(person) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/scala/com/example/CirceExample.scala: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import io.circe.optics.JsonPath 4 | import org.scalatest.{Matchers, WordSpec} 5 | import io.circe.optics.JsonPath._ 6 | 7 | class CirceExample extends WordSpec with Matchers { 8 | "JsonPath" should { 9 | "should work as non-optics equivalent" in { 10 | val input = referenceJson("abc") 11 | val withOptics = modifyWithOptics(input) 12 | val withoutOptics = modifyWithoutOptics(input) 13 | 14 | withOptics should equal(withoutOptics) 15 | withOptics should equal(referenceJson("ABC")) 16 | } 17 | } 18 | 19 | // let's capitalize street name without optics 20 | def modifyWithoutOptics(json: io.circe.Json): io.circe.Json = 21 | json.hcursor. 22 | downField("order"). 23 | downField("address"). 24 | downField("street"). 25 | withFocus(_.mapString(_.toUpperCase)).top.get 26 | 27 | // now with optics: 28 | def modifyWithOptics(json: io.circe.Json): io.circe.Json = 29 | root.order.address.street.string.modify(_.toUpperCase)(json) 30 | 31 | def referenceJson(streetName: String) = 32 | io.circe.parser.parse(s""" 33 | |{ 34 | | "order": { 35 | | "address": { 36 | | "street": "$streetName", 37 | | "city": "someCity" 38 | | }, 39 | | "items": [ 40 | | { 41 | | "name": "OK Computer", 42 | | "amount": 1 43 | | }, 44 | | { 45 | | "name": "Kid A", 46 | | "amount": 3 47 | | } 48 | | ] 49 | | } 50 | |} 51 | """.stripMargin).right.get 52 | } 53 | -------------------------------------------------------------------------------- /src/test/scala/com/example/OptionalExample.scala: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import monocle._ 4 | import org.scalatest.{FlatSpec, Matchers} 5 | 6 | sealed trait Error 7 | 8 | case class ErrorA(message: String, details: DetailedErrorA) extends Error 9 | case object ErrorB extends Error 10 | 11 | case class DetailedErrorA(detailedMessage: String) 12 | 13 | object ErrorOptics { 14 | // That's straightforward approach, not recommended but shows the essence of Optional: 15 | val detailedErrorA = Optional[Error, String]{ 16 | case err: ErrorA => Some(err.details.detailedMessage) 17 | case _ => None 18 | }{ newDetailedMsg => from => 19 | from match { 20 | case err: ErrorA => err.copy(details = err.details.copy(newDetailedMsg)) 21 | case _ => from 22 | } 23 | } 24 | 25 | // Better approach is to get `Optional[OperationError, String]` by composing Prism and Lens: 26 | val errorA = Prism.partial[Error, ErrorA] { 27 | case err: ErrorA => err 28 | }(identity) 29 | 30 | val detailedError = 31 | Lens[ErrorA, DetailedErrorA](_.details)(newDetails => from => from.copy(details = newDetails)) 32 | 33 | val detailedErrorMsg = 34 | Lens[DetailedErrorA, String](_.detailedMessage)(newMsg => from => from.copy(detailedMessage = newMsg)) 35 | 36 | val composedDetailedErrorA: Optional[Error, String] = 37 | errorA.composeLens(detailedError.composeLens(detailedErrorMsg)) 38 | } 39 | 40 | class OptionalExample extends FlatSpec with Matchers { 41 | import ErrorOptics._ 42 | 43 | val examples = List( 44 | Example(ErrorA("msg", DetailedErrorA("detailedMessage")), Some(ErrorA("msg", DetailedErrorA("DETAILEDMESSAGE")))), 45 | Example(ErrorB, None) 46 | ) 47 | 48 | "detailedErrorA Optional" should "work as expected" in { 49 | val modify = detailedErrorA.modifyOption(_.toUpperCase) 50 | 51 | testExamples { example => 52 | modify(example.input) should equal(example.expectedOutput) 53 | }(examples) 54 | } 55 | 56 | "composedDetailedErrorA Optional" should "work as expected" in { 57 | val modify = detailedErrorA.modifyOption(_.toUpperCase) 58 | 59 | testExamples { example => 60 | modify(example.input) should equal(example.expectedOutput) 61 | }(examples) 62 | } 63 | 64 | def testExamples(operationToTest: Example => Unit)(examples: Seq[Example]) = { 65 | examples.foreach { example => 66 | operationToTest(example) 67 | } 68 | } 69 | 70 | case class Example(input: Error, expectedOutput: Option[Error]) 71 | } 72 | -------------------------------------------------------------------------------- /src/test/scala/com/example/NaivePrismExample.scala: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import monocle.Prism 4 | import org.scalatest.{Matchers, WordSpec} 5 | 6 | import scala.util.Try 7 | import scalaz.Equal 8 | 9 | case class Percent private (value: Int) { 10 | require(value >= 0) 11 | require(value <= 100) 12 | } 13 | 14 | object Percent { 15 | def fromInt(input: Int): Option[Percent] = 16 | if(input >= 0 && input <= 100) { 17 | Some(Percent(input)) 18 | } else { 19 | None 20 | } 21 | 22 | implicit val equalInstance: Equal[Percent] = (p1: Percent, p2: Percent) => p1 == p2 23 | } 24 | 25 | object NaivePrisms { 26 | // this is not a lawful Prism but in naive version we don't care about it 27 | // for lawful Prism take a look at DowncastingPrisms 28 | val stringToIntPrism = Prism[String, Int](str => Try(str.toInt).toOption)(_.toString) 29 | val intToPercentPrism = Prism[Int, Percent](i => Percent.fromInt(i))(_.value) 30 | } 31 | 32 | object DowncastingPrisms { 33 | val regex = "(-?[1-9][0-9]*)|0".r 34 | 35 | val stringToIntPrism = Prism[String, Int]{ str => 36 | if(regex.pattern.matcher(str).matches) { 37 | Try(str.toInt).toOption 38 | } else { 39 | None 40 | } 41 | }(_.toString) 42 | val intToPercentPrism = Prism[Int, Percent](i => Percent.fromInt(i))(_.value) 43 | } 44 | 45 | object NaivePrismExample extends WordSpec with Matchers { 46 | import NaivePrisms._ 47 | 48 | "stringToIntPrism" should { 49 | "work for inputs parsable to Double" in { 50 | stringToIntPrism.getOption("22") should equal(Some(22)) 51 | stringToIntPrism.set(40)("22") should equal("40") 52 | stringToIntPrism.modify(_ + 1)("22") should equal("23") 53 | } 54 | 55 | "return None when `getOption` called on unparsable input" in { 56 | stringToIntPrism.getOption("someString") should equal(None) 57 | } 58 | 59 | "return unmodified input when `set` called on unparsable input" in { 60 | stringToIntPrism.set(40)("someString") should equal("someString") 61 | } 62 | 63 | "return unmodified input when `modify` called on unparsable input" in { 64 | stringToIntPrism.modify(_ + 1)("someString") should equal("someString") 65 | } 66 | 67 | "return Option informing about success of `setOption`" in { 68 | stringToIntPrism.setOption(40)("22") should equal(Some("40")) 69 | stringToIntPrism.setOption(40)("someString") should equal(None) 70 | } 71 | 72 | "return Option informing about success of `modifyOption`" in { 73 | stringToIntPrism.modifyOption(_ + 1)("22") should equal(Some("23")) 74 | stringToIntPrism.modifyOption(_ + 1)("someString") should equal(None) 75 | } 76 | } 77 | 78 | "prism composition" should { 79 | "work as expected" in { 80 | val stringToPercent = stringToIntPrism.composePrism(intToPercentPrism) 81 | stringToPercent.getOption("someString") should equal(None) 82 | stringToPercent.getOption("188") should equal(None) 83 | stringToPercent.getOption("88.0") should equal(None) 84 | stringToPercent.getOption("88") should equal(Some(Percent(80))) 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/test/scala/com/example/MaintainingInvariantsExample.scala: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import monocle.Lens 4 | import org.scalatest.{FlatSpec, Matchers} 5 | 6 | import scalaz.Equal 7 | 8 | /** 9 | * Example inspired by Simon Payton Jones talk: https://skillsmatter.com/skillscasts/4556-simon-peyton-jones 10 | * Example with Duration starts around 42:30 11 | */ 12 | case class Duration(hours: Int, minutes: Int, seconds: Int) { 13 | private val secondsInHour = 60 * 60 14 | private val secondsInMinute = 60 15 | 16 | def asSeconds = hours * secondsInHour + minutes * secondsInMinute + seconds 17 | } 18 | 19 | object Duration { 20 | implicit val equalInstance: Equal[Duration] = (d1: Duration, d2: Duration) => { 21 | d1.asSeconds == d2.asSeconds 22 | } 23 | } 24 | 25 | /** 26 | * NOTE: those Lens are not lawful! 27 | * 28 | * DurationOpticsSpec proves that 29 | */ 30 | object DurationOptics { 31 | val hoursL = Lens[Duration, Int](_.hours)(newHours => duration => duration.copy(hours = newHours)) 32 | 33 | val minutesL: Lens[Duration, Int] = Lens[Duration, Int](_.minutes){ newMinutes =>duration => 34 | val minutesValidated = newMinutes % 60 35 | val newHours = (newMinutes / 60) + duration.hours 36 | val durationUpdated = hoursL.set(newHours)(duration) 37 | durationUpdated.copy(minutes = minutesValidated) 38 | } 39 | 40 | val secondsL = Lens[Duration, Int](_.seconds){ newSeconds => duration => 41 | val secondsValidated = newSeconds % 60 42 | val newMinutes = (newSeconds / 60) + duration.minutes 43 | val durationUpdated = minutesL.set(newMinutes)(duration) 44 | durationUpdated.copy(seconds = secondsValidated) 45 | } 46 | 47 | } 48 | 49 | class MaintainingInvariantsExample extends FlatSpec with Matchers { 50 | import DurationOptics._ 51 | 52 | "updating hours" should "maintain invariants" in { 53 | hoursL.modify(_ + 10)(Duration(3, 10, 50)) should equal (Duration(13, 10, 50)) 54 | } 55 | 56 | "updating minutes" should "maintain invariant" in { 57 | minutesL.modify(_ + 10)(Duration(3, 20, 50)) should equal (Duration(3, 30, 50)) 58 | minutesL.modify(_ + 10)(Duration(3, 55, 50)) should equal (Duration(4, 5, 50)) 59 | 60 | minutesL.modify(_ + 100)(Duration(3, 0, 50)) should equal (Duration(4, 40, 50)) 61 | minutesL.modify(_ + 239)(Duration(3, 0, 50)) should equal (Duration(6, 59, 50)) 62 | minutesL.modify(_ + 240)(Duration(3, 0, 50)) should equal (Duration(7, 0, 50)) 63 | 64 | 65 | minutesL.modify(_ => 0)(Duration(3, 2, 50)) should equal (Duration(3, 0, 50)) 66 | } 67 | 68 | "updating seconds" should "maintain invariant" in { 69 | secondsL.modify(_ + 9)(Duration(3, 20, 50)) should equal (Duration(3, 20, 59)) 70 | secondsL.modify(_ + 10)(Duration(3, 20, 50)) should equal (Duration(3, 21, 0)) 71 | secondsL.modify(_ + 11)(Duration(3, 20, 50)) should equal (Duration(3, 21, 1)) 72 | 73 | secondsL.modify(_ + 100)(Duration(3, 0, 0)) should equal (Duration(3, 1, 40)) 74 | secondsL.modify(_ + (7 * 60))(Duration(3, 0, 0)) should equal (Duration(3, 7, 0)) 75 | 76 | secondsL.modify(_ + (121 * 60))(Duration(3, 0, 0)) should equal (Duration(5, 1, 0)) 77 | secondsL.modify(_ + (121 * 60) + 17)(Duration(3, 0, 0)) should equal (Duration(5, 1, 17)) 78 | } 79 | 80 | "remove me" should "not work" in { 81 | 82 | } 83 | } 84 | --------------------------------------------------------------------------------