diff --git a/build.sbt b/build.sbt index 57822088..9b3df6c5 100644 --- a/build.sbt +++ b/build.sbt @@ -18,6 +18,7 @@ val Versions = new { val discipline = "0.7.3" val scalaCheck = "1.13.5" val scalaTest = "3.0.3" + val scalaMock = "3.6.0" val snakeYaml = "1.18" val previousCirceYaml = "0.6.1" } @@ -48,7 +49,8 @@ val root = project.in(file(".")) "io.circe" %% "circe-testing" % Versions.circe % "test", "org.typelevel" %% "discipline" % Versions.discipline % "test", "org.scalacheck" %% "scalacheck" % Versions.scalaCheck % "test", - "org.scalatest" %% "scalatest" % Versions.scalaTest % "test" + "org.scalatest" %% "scalatest" % Versions.scalaTest % "test", + "org.scalamock" %% "scalamock-scalatest-support" % Versions.scalaMock % "test" ), mimaPreviousArtifacts := Set("io.circe" %% "circe-yaml" % Versions.previousCirceYaml) ) diff --git a/src/main/scala/io/circe/yaml/parser/NodeAlg.scala b/src/main/scala/io/circe/yaml/parser/NodeAlg.scala new file mode 100644 index 00000000..0eb7c1be --- /dev/null +++ b/src/main/scala/io/circe/yaml/parser/NodeAlg.scala @@ -0,0 +1,164 @@ +package io.circe.yaml.parser + +import cats.data.ValidatedNel +import cats.instances.list._ +import cats.syntax.either._ +import cats.syntax.traverse._ +import io.circe.{Json, JsonNumber, JsonObject, ParsingFailure} +import org.yaml.snakeyaml.constructor.SafeConstructor +import org.yaml.snakeyaml.nodes._ +import scala.collection.JavaConverters._ +import scala.collection.immutable.Queue + +abstract class NodeAlg[T] { + def int(node: ScalarNode): T + def float(node: ScalarNode): T + def timestamp(node: ScalarNode): T + def bool(node: ScalarNode): T + def yNull(node: ScalarNode): T + def string(node: ScalarNode): T + def otherScalar(node: ScalarNode): T + + def sequence(node: SequenceNode): T = fromValues { + node.getValue.asScala.foldLeft(Queue.empty[T]) { + (accum, next) => accum enqueue any(next) + } + } + + def mapping(node: MappingNode): T = fromFields { + node.getValue.asScala.map { + nodeTuple => nodeTuple.getKeyNode match { + case keyNode: ScalarNode => keyNode.getValue -> any(nodeTuple.getValueNode) + case _ => throw ParsingFailure("Only string keys can be represented in JSON", null) + } + } + } + + def fromValues(ts: Iterable[T]): T + def fromFields(ts: Iterable[(String, T)]): T + + final def any(node: Node): T = node match { + case node: ScalarNode => node.getTag match { + case Tag.INT => int(node) + case Tag.FLOAT => float(node) + case Tag.TIMESTAMP => timestamp(node) + case Tag.BOOL => bool(node) + case Tag.NULL => yNull(node) + case Tag.STR => string(node) + case _ => otherScalar(node) + } + case node: SequenceNode => sequence(node) + case node: MappingNode => mapping(node) + } +} + +final class LiftedAlg[A](lifted: NodeAlg[A]) extends NodeAlg[Either[ParsingFailure, A]] { + private def wrap(what: String)(err: Throwable) = ParsingFailure(s"Failed to parse $what", err) + def int(node: ScalarNode): Either[ParsingFailure, A] = + Either.catchNonFatal(lifted.int(node)).leftMap(wrap("integer value")) + + def float(node: ScalarNode): Either[ParsingFailure, A] = + Either.catchNonFatal(lifted.float(node)).leftMap(wrap("float value")) + + def timestamp(node: ScalarNode): Either[ParsingFailure, A] = + Either.catchNonFatal(lifted.timestamp(node)).leftMap(wrap("timestamp value")) + + def bool(node: ScalarNode): Either[ParsingFailure, A] = + Either.catchNonFatal(lifted.bool(node)).leftMap(wrap("boolean value")) + + def yNull(node: ScalarNode): Either[ParsingFailure, A] = + Either.catchNonFatal(lifted.yNull(node)).leftMap(wrap("null value")) + + def string(node: ScalarNode): Either[ParsingFailure, A] = + Either.catchNonFatal(lifted.string(node)).leftMap(wrap("string value")) + + def otherScalar(node: ScalarNode): Either[ParsingFailure, A] = + Either.catchNonFatal(lifted.otherScalar(node)).leftMap(wrap("scalar value")) + + override def sequence(node: SequenceNode): Either[ParsingFailure, A] = + Either.catchNonFatal(lifted.sequence(node)).leftMap(wrap("sequence")) + + override def mapping(node: MappingNode): Either[ParsingFailure, A] = + Either.catchNonFatal(lifted.mapping(node)).leftMap(wrap("mapping")) + + def fromValues(ts: Iterable[Either[ParsingFailure, A]]): Either[ParsingFailure, A] = try { + Either.right { + lifted.fromValues { + ts.map(_.valueOr(throw _)) + } + } + } catch { + case f @ ParsingFailure(_, _) => Either.left(f) + } + + def fromFields(ts: Iterable[(String, Either[ParsingFailure, A])]): Either[ParsingFailure, A] = try { + Either.right { + lifted.fromFields { + ts.map { + case (key, value) => key -> value.valueOr(throw _) + } + } + } + } catch { + case f @ ParsingFailure(_, _) => Either.left(f) + } +} + +final class AccumulatingAlg[A](base: NodeAlg[A]) extends NodeAlg[ValidatedNel[ParsingFailure, A]] { + private val lifted = new LiftedAlg(base) + def int(node: ScalarNode): ValidatedNel[ParsingFailure, A] = lifted.int(node).toValidatedNel + def float(node: ScalarNode): ValidatedNel[ParsingFailure, A] = lifted.float(node).toValidatedNel + def timestamp(node: ScalarNode): ValidatedNel[ParsingFailure, A] = lifted.timestamp(node).toValidatedNel + def bool(node: ScalarNode): ValidatedNel[ParsingFailure, A] = lifted.bool(node).toValidatedNel + def yNull(node: ScalarNode): ValidatedNel[ParsingFailure, A] = lifted.yNull(node).toValidatedNel + def string(node: ScalarNode): ValidatedNel[ParsingFailure, A] = lifted.string(node).toValidatedNel + def otherScalar(node: ScalarNode): ValidatedNel[ParsingFailure, A] = lifted.otherScalar(node).toValidatedNel + + def fromFields(ts: Iterable[(String, ValidatedNel[ParsingFailure, A])]): ValidatedNel[ParsingFailure, A] = + ts.toList.traverseU { + case (key, value) => value.map(key -> _) + }.map(base.fromFields) + + def fromValues(ts: Iterable[ValidatedNel[ParsingFailure, A]]): ValidatedNel[ParsingFailure, A] = + ts.toList.sequenceU.map(base.fromValues) +} + +class DefaultAlg extends NodeAlg[Json] { + protected object Constructor extends SafeConstructor { + def flatten(node: MappingNode): Unit = flattenMapping(node) + } + + final protected def number(str: String): Json = JsonNumber.fromString(str).map(Json.fromJsonNumber).getOrElse { + throw new NumberFormatException(s"Invalid numeric string $str") + } + + def int(node: ScalarNode): Json = number(node.getValue) + def float(node: ScalarNode): Json = number(node.getValue) + def timestamp(node: ScalarNode): Json = Json.fromString(node.getValue) + def bool(node: ScalarNode): Json = Json.fromBoolean(node.getValue.toBoolean) + def yNull(node: ScalarNode): Json = Json.Null + def string(node: ScalarNode): Json = Json.fromString(node.getValue) + def otherScalar(node: ScalarNode): Json = if (!node.getTag.startsWith(Tag.PREFIX)) { + Json.fromJsonObject(JsonObject.singleton(node.getTag.getValue.stripPrefix("!"), Json.fromString(node.getValue))) + } else Json.fromString(node.getValue) + + def fromValues(ts: Iterable[Json]): Json = Json.fromValues(ts) + def fromFields(ts: Iterable[(String, Json)]): Json = Json.fromFields(ts) + + override def mapping(node: MappingNode): Json = { + Constructor.flatten(node) + super.mapping(node) + } +} + +case class ConfiguredAlg( + numericTimestamps: Boolean +) extends DefaultAlg { + final override def timestamp(node: ScalarNode): Json = if (!numericTimestamps) { + super.timestamp(node) + } else { + val constructor = new SafeConstructor.ConstructYamlTimestamp() + constructor.construct(node) + Json.fromLong(constructor.getCalendar.getTimeInMillis) + } +} diff --git a/src/main/scala/io/circe/yaml/parser/Parser.scala b/src/main/scala/io/circe/yaml/parser/Parser.scala new file mode 100644 index 00000000..46aa4735 --- /dev/null +++ b/src/main/scala/io/circe/yaml/parser/Parser.scala @@ -0,0 +1,90 @@ +package io.circe.yaml.parser + + +import cats.data.ValidatedNel +import cats.syntax.either._ +import io.circe._ +import java.io.{Reader, StringReader} +import org.yaml.snakeyaml.Yaml +import scala.collection.JavaConverters._ + +class Parser(algebra: NodeAlg[Json] = new DefaultAlg) { + + /** + * Configure the parser + * @param numericTimestamps if true, timestamps will be returned as epoch millisecond [[Long]]s + * @return A configured parser + */ + def configured( + numericTimestamps: Boolean = false + ): Parser = new Parser(ConfiguredAlg( + numericTimestamps = numericTimestamps + )) + + + /** + * Parse YAML from the given [[Reader]], returning either [[ParsingFailure]] or [[Json]] + */ + def parse(yaml: Reader): Either[ParsingFailure, Json] = for { + parsed <- parseSingle(yaml) + json <- Either.catchNonFatal(algebra.any(parsed)).leftMap { + case p @ ParsingFailure(_, _) => p + case err => ParsingFailure(err.getMessage, err) + } + } yield json + + /** + * Parse YAML from the given [[Reader]], accumulating errors and returning either a list of [[ParsingFailure]]s + * or a [[Json]] + */ + def parseAccumulating(yaml: Reader): ValidatedNel[ParsingFailure, Json] = parseSingle(yaml).toValidatedNel andThen { + parsed => new AccumulatingAlg(algebra).any(parsed) + } + + /** + * Parse YAML from the given string, returning either [[ParsingFailure]] or [[Json]] + */ + def parse(yaml: String): Either[ParsingFailure, Json] = parse(new StringReader(yaml)) + + /** + * Parse YAML from the given string, accumulating errors and returning either a list of [[ParsingFailure]]s + * or a [[Json]] + */ + def parseAccumulating(yaml: String): ValidatedNel[ParsingFailure, Json] = parseAccumulating(new StringReader(yaml)) + + /** + * Parse a succession of documents from the given [[Reader]], returning the result as a [[Stream]] of [[Either]] + */ + def parseDocuments(yaml: Reader): Stream[Either[ParsingFailure, Json]] = { + val alg = new LiftedAlg(algebra) + parseStream(yaml).map(alg.any) + } + + /** + * Parse a succession of documents from the given [[Reader]], accumulating errors within each document and + * returning the result as a [[Stream]] of [[ValidatedNel]] + */ + def parseDocumentsAccumulating(yaml: Reader): Stream[ValidatedNel[ParsingFailure, Json]] = { + val alg = new AccumulatingAlg(algebra) + parseStream(yaml).map(alg.any) + } + + /** + * Parse a succession of documents from the given string, returning the result as a [[Stream]] of [[Either]] + */ + def parseDocuments(yaml: String): Stream[Either[ParsingFailure, Json]] = parseDocuments(new StringReader(yaml)) + + /** + * Parse a succession of documents from the given string, accumulating errors within each document and + * returning the result as a [[Stream]] of [[ValidatedNel]] + */ + def parseDocumentsAccumulating(yaml: String): Stream[ValidatedNel[ParsingFailure, Json]] = + parseDocumentsAccumulating(new StringReader(yaml)) + + private[this] def parseSingle(reader: Reader) = + Either.catchNonFatal(new Yaml().compose(reader)).leftMap(err => ParsingFailure(err.getMessage, err)) + + private[this] def parseStream(reader: Reader) = + new Yaml().composeAll(reader).asScala.toStream + +} diff --git a/src/main/scala/io/circe/yaml/parser/package.scala b/src/main/scala/io/circe/yaml/parser/package.scala index a7e3d969..41ee2ca7 100644 --- a/src/main/scala/io/circe/yaml/parser/package.scala +++ b/src/main/scala/io/circe/yaml/parser/package.scala @@ -1,94 +1,3 @@ package io.circe.yaml -import cats.syntax.either._ -import io.circe._ -import java.io.{Reader, StringReader} -import org.yaml.snakeyaml.Yaml -import org.yaml.snakeyaml.constructor.SafeConstructor -import org.yaml.snakeyaml.nodes._ -import scala.collection.JavaConverters._ - -package object parser { - - - /** - * Parse YAML from the given [[Reader]], returning either [[ParsingFailure]] or [[Json]] - * @param yaml - * @return - */ - def parse(yaml: Reader): Either[ParsingFailure, Json] = for { - parsed <- parseSingle(yaml) - json <- yamlToJson(parsed) - } yield json - - def parse(yaml: String): Either[ParsingFailure, Json] = parse(new StringReader(yaml)) - - def parseDocuments(yaml: Reader): Stream[Either[ParsingFailure, Json]] = parseStream(yaml).map(yamlToJson) - def parseDocuments(yaml: String): Stream[Either[ParsingFailure, Json]] = parseDocuments(new StringReader(yaml)) - - private[this] def parseSingle(reader: Reader) = - Either.catchNonFatal(new Yaml().compose(reader)).leftMap(err => ParsingFailure(err.getMessage, err)) - - private[this] def parseStream(reader: Reader) = - new Yaml().composeAll(reader).asScala.toStream - - private[this] object CustomTag { - def unapply(tag: Tag): Option[String] = if (!tag.startsWith(Tag.PREFIX)) - Some(tag.getValue) - else - None - } - - private[this] class FlatteningConstructor extends SafeConstructor { - def flatten(node: MappingNode): MappingNode = { - flattenMapping(node) - node - } - } - - private[this] val flattener: FlatteningConstructor = new FlatteningConstructor - - private[this] def yamlToJson(node: Node): Either[ParsingFailure, Json] = { - - def convertScalarNode(node: ScalarNode) = Either.catchNonFatal(node.getTag match { - case Tag.INT | Tag.FLOAT => JsonNumber.fromString(node.getValue).map(Json.fromJsonNumber).getOrElse { - throw new NumberFormatException(s"Invalid numeric string ${node.getValue}") - } - case Tag.BOOL => Json.fromBoolean(node.getValue.toBoolean) - case Tag.NULL => Json.Null - case CustomTag(other) => - Json.fromJsonObject(JsonObject.singleton(other.stripPrefix("!"), Json.fromString(node.getValue))) - case other => Json.fromString(node.getValue) - }).leftMap { - err => - ParsingFailure(err.getMessage, err) - } - - def convertKeyNode(node: Node) = node match { - case scalar: ScalarNode => Right(scalar.getValue) - case _ => Left(ParsingFailure("Only string keys can be represented in JSON", null)) - } - - node match { - case mapping: MappingNode => - flattener.flatten(mapping).getValue.asScala.foldLeft( - Either.right[ParsingFailure, JsonObject](JsonObject.empty) - ) { - (objEither, tup) => for { - obj <- objEither - key <- convertKeyNode(tup.getKeyNode) - value <- yamlToJson(tup.getValueNode) - } yield obj.add(key, value) - }.map(Json.fromJsonObject) - case sequence: SequenceNode => - sequence.getValue.asScala.foldLeft(Either.right[ParsingFailure, List[Json]](List.empty[Json])) { - (arrEither, node) => for { - arr <- arrEither - value <- yamlToJson(node) - } yield value :: arr - }.map(arr => Json.fromValues(arr.reverse)) - case scalar: ScalarNode => convertScalarNode(scalar) - } - } - -} +package object parser extends Parser diff --git a/src/test/scala/io/circe/yaml/ConfiguredParserTests.scala b/src/test/scala/io/circe/yaml/ConfiguredParserTests.scala new file mode 100644 index 00000000..b7c94511 --- /dev/null +++ b/src/test/scala/io/circe/yaml/ConfiguredParserTests.scala @@ -0,0 +1,23 @@ +package io.circe.yaml + +import io.circe.Json +import java.text.SimpleDateFormat +import java.util.Calendar +import org.scalacheck.Gen +import org.scalatest.{FlatSpec, Matchers} +import org.scalatest.prop.GeneratorDrivenPropertyChecks + +class ConfiguredParserTests extends FlatSpec with Matchers with GeneratorDrivenPropertyChecks { + + "ConfiguredParser" should "parse timestamps as longs" in forAll(Gen.calendar) { cal => + val dateStr = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss.SX").format(cal.getTime) + whenever(cal.get(Calendar.YEAR) <= 9999 && cal.get(Calendar.YEAR) >= -9999 ) { + parser.configured(numericTimestamps = true).parse( + s""" + |timestamp: !!timestamp $dateStr + """.stripMargin + ) shouldEqual Right(Json.obj("timestamp" -> Json.fromLong(cal.getTimeInMillis))) + } + } + +} diff --git a/src/test/scala/io/circe/yaml/Generators.scala b/src/test/scala/io/circe/yaml/Generators.scala new file mode 100644 index 00000000..3ffd8d50 --- /dev/null +++ b/src/test/scala/io/circe/yaml/Generators.scala @@ -0,0 +1,103 @@ +package io.circe.yaml + +import java.text.SimpleDateFormat +import org.scalacheck.{Arbitrary, Gen}, Arbitrary.arbitrary +import org.yaml.snakeyaml.error.Mark +import org.yaml.snakeyaml.nodes._ +import scala.collection.JavaConverters._ + +object Generators { + + private def scalarNode(tag: Tag, value: String, style: Option[Character] = None) = new ScalarNode( + tag, + value, + new Mark("", 0, 0, 0, value, 0), + new Mark("", 0, 0, value.length, value, 0), + style.orNull + ) + + val intNode: Gen[ScalarNode] = for { + i <- arbitrary[Int] + } yield scalarNode(Tag.INT, i.toString) + + val floatNode: Gen[ScalarNode] = for { + d <- arbitrary[Double] + } yield scalarNode(Tag.FLOAT, d.toString) + + val boolNode: Gen[ScalarNode] = for { + b <- Gen.oneOf("y","Y","yes","Yes","YES","n","N","no","No","NO", + "true","True","TRUE","false","False","FALSE", + "on","On","ON","off","Off","OFF") + } yield scalarNode(Tag.BOOL, b) + + val nullNode: ScalarNode = scalarNode(Tag.NULL, "null") + + val timestampNode: Gen[ScalarNode] = for { + t <- Gen.calendar + fmtStr <- Gen.oneOf( + "yyyy-MM-dd hh:mm:ss.SX", + "yyyy-MM-dd't'hh:mm:ss.SX", + "yyyy-MM-dd'T'hh:mm:ss.SX" + ) + } yield scalarNode(Tag.TIMESTAMP, new SimpleDateFormat(fmtStr).format(t.getTime)) + + val strNode: Gen[ScalarNode] = for { + str <- arbitrary[String] + } yield scalarNode(Tag.STR, str) + + val otherScalar: Gen[ScalarNode] = for { + tag <- Gen.identifier + value <- arbitrary[String] + } yield scalarNode(new Tag(tag), value) + + val anyScalar: Gen[ScalarNode] = Gen.oneOf(intNode, floatNode, boolNode, timestampNode, strNode, Gen.const(nullNode)) + + lazy val anyNode: Gen[Node] = Gen.sized { size => + if (size > 1) { + Gen.frequency( + 5 -> anyScalar, + 1 -> Gen.resize(size >> 1, Gen.lzy(mappingNode)), + 1 -> Gen.resize(size >> 1, Gen.lzy(seqNode)) + ) + } else otherScalar + } + + val validTuple: Gen[NodeTuple] = for { + key <- strNode + value <- anyNode + } yield new NodeTuple(key, value) + + val validScalarTuple: Gen[NodeTuple] = for { + key <- strNode + value <- anyScalar + } yield new NodeTuple(key, value) + + val mappingNode: Gen[MappingNode] = for { + nodes <- Gen.nonEmptyListOf(validTuple) + tag <- Gen.oneOf(Tag.MAP, Tag.OMAP) + flow <- arbitrary[Boolean] + mapping = if (tag == Tag.OMAP) nodes.map(n => n.getKeyNode -> n).toMap.toList.map(_._2) else nodes + } yield new MappingNode(tag, mapping.asJava, flow) + + val flatMappingNode: Gen[MappingNode] = for { + nodes <- Gen.nonEmptyListOf(validScalarTuple) + tag <- Gen.oneOf(Tag.MAP, Tag.OMAP) + flow <- arbitrary[Boolean] + mapping = if (tag == Tag.OMAP) nodes.map(n => n.getKeyNode -> n).toMap.toList.map(_._2) else nodes + } yield new MappingNode(tag, mapping.asJava, flow) + + val seqNode: Gen[SequenceNode] = for { + nodes <- Gen.nonEmptyListOf(anyNode) + tag <- Gen.oneOf(Tag.SEQ, Tag.SET) + flow <- arbitrary[Boolean] + seq = if (tag == Tag.SET) nodes.toSet.toList else nodes + } yield new SequenceNode(tag, seq.asJava, flow) + + val flatSeqNode: Gen[SequenceNode] = for { + nodes <- Gen.nonEmptyListOf(anyScalar) + tag <- Gen.oneOf(Tag.SEQ, Tag.SET) + flow <- arbitrary[Boolean] + seq = if (tag == Tag.SET) nodes.toSet.toList else nodes + } yield new SequenceNode(tag, (seq: List[Node]).asJava, flow) + +} diff --git a/src/test/scala/io/circe/yaml/NodeAlgSpec.scala b/src/test/scala/io/circe/yaml/NodeAlgSpec.scala new file mode 100644 index 00000000..584f29c1 --- /dev/null +++ b/src/test/scala/io/circe/yaml/NodeAlgSpec.scala @@ -0,0 +1,97 @@ +package io.circe.yaml + +import io.circe.yaml.parser._ +import org.scalamock.scalatest.MockFactory +import org.scalatest.{FreeSpec, Matchers} +import org.scalatest.prop.GeneratorDrivenPropertyChecks +import org.yaml.snakeyaml.nodes.ScalarNode +import scala.collection.JavaConverters._ + +class NodeAlgSpec extends FreeSpec with Matchers with GeneratorDrivenPropertyChecks with MockFactory { + + import Generators._ + + "mocked" - { + val mockAlg = mock[NodeAlg[String]] + val subject = new NodeAlg[String] { + def int(node: ScalarNode): String = mockAlg.int(node) + def float(node: ScalarNode): String = mockAlg.float(node) + def timestamp(node: ScalarNode): String = mockAlg.timestamp(node) + def bool(node: ScalarNode): String = mockAlg.bool(node) + def yNull(node: ScalarNode): String = mockAlg.yNull(node) + def string(node: ScalarNode): String = mockAlg.string(node) + def otherScalar(node: ScalarNode): String = mockAlg.otherScalar(node) + def fromValues(ts: Iterable[String]): String = mockAlg.fromValues(ts) + def fromFields(ts: Iterable[(String, String)]): String = mockAlg.fromFields(ts) + } + + "plain" - { + "ints" in forAll(intNode) { node => + mockAlg.int _ expects node returning "" + subject.any(node) + } + "floats" in forAll(floatNode) { node => + mockAlg.float _ expects node returning "" + subject.any(node) + } + "timestamps" in forAll(timestampNode) { node => + mockAlg.timestamp _ expects node returning "" + subject.any(node) + } + "booleans" in forAll(boolNode) { node => + mockAlg.bool _ expects node returning "" + subject.any(node) + } + "nulls" in { + mockAlg.yNull _ expects nullNode returning "" + subject.any(nullNode) + } + "strings" in forAll(strNode) { node => + mockAlg.string _ expects node returning "" + subject.any(node) + } + "other scalars" in forAll(otherScalar) { node => + mockAlg.otherScalar _ expects node returning "" + subject.any(node) + } + "sequences" in forAll(flatSeqNode) { node => + inAnyOrder { + mockAlg.int _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.float _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.timestamp _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.bool _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.yNull _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.string _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.otherScalar _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + } + + mockAlg.fromValues _ expects where { + i: Iterable[String] => i.toList == node.getValue.asScala.toList.map(subject.any) + } returning "" + + subject.any(node) + } + + "maps" in forAll(flatMappingNode) { node => + inAnyOrder { + mockAlg.int _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.float _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.timestamp _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.bool _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.yNull _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.string _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + mockAlg.otherScalar _ expects * onCall { node: ScalarNode => node.getValue } anyNumberOfTimes() + } + + mockAlg.fromFields _ expects where { + i: Iterable[(String, String)] => i.toList == node.getValue.asScala.toList.map { + nodeTuple => nodeTuple.getKeyNode.asInstanceOf[ScalarNode].getValue -> subject.any(nodeTuple.getValueNode) + } + } returning "" + + subject.any(node) + } + } + } + +}