Skip to content

Commit

Permalink
Add CanonicalDataParser (issue #331)
Browse files Browse the repository at this point in the history
  • Loading branch information
abo64 committed Mar 28, 2017
1 parent ae14d8e commit 9503d76
Show file tree
Hide file tree
Showing 13 changed files with 293 additions and 61 deletions.
4 changes: 0 additions & 4 deletions exercises/hello-world/HINTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
## Hints
For this exercise two Scala features come in handy:
- [Default Parameter Values](http://docs.scala-lang.org/tutorials/tour/default-parameter-values.html)
- [String Interpolation](http://docs.scala-lang.org/overviews/core/string-interpolation.html)

#### Common pitfalls that you should avoid
- `null` is usually not considered a valid value in Scala, and there are no `null` checks needed (if you don't have to interface with Java code, say). Instead there is the [Option](http://danielwestheide.com/blog/2012/12/19/the-neophytes-guide-to-scala-part-5-the-option-type.html) type if you want to express the possible absence of a value. But for this exercise just assume a normal non-`null` String parameter.
- Usually there is no need in Scala to use `return`. For a discussion see [here](http://stackoverflow.com/questions/24856106/return-in-a-scala-function-literal). Or as a quote from that discussion: *Don't use return, it makes Scala cry.*
3 changes: 1 addition & 2 deletions exercises/hello-world/example.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
object HelloWorld {
def hello() = "Hello, World!"

def hello(name: String) = s"Hello, $name!"
}

15 changes: 4 additions & 11 deletions exercises/hello-world/src/test/scala/HelloWorldTest.scala
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import org.scalatest.{Matchers, FunSuite}

/** @version 1.0.0 */
class HelloWorldTest extends FunSuite with Matchers {
test("Without name") {
HelloWorld.hello() should be ("Hello, World!")
}

test("with name") {
pending
HelloWorld.hello("Jane") should be ("Hello, Jane!")
}

test("with umlaut name") {
pending
HelloWorld.hello("Jürgen") should be ("Hello, Jürgen!")
test("Say Hi!") {
HelloWorld.hello() should be ("Hello, World!")
}
}

9 changes: 9 additions & 0 deletions testgen/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,13 @@ name := "ExcercismScalaTestGenerator"

scalaVersion := "2.11.8"

lazy val root = (project in file("."))
.enablePlugins(SbtTwirl)
.settings(
sourceDirectories in (Compile, TwirlKeys.compileTemplates) += (baseDirectory.value.getParentFile / "src" / "main" / "twirl"))

libraryDependencies += "com.typesafe.play" % "play-json_2.11" % "2.5.3"

libraryDependencies += "org.scala-lang.modules" %% "scala-parser-combinators" % "1.0.4"

libraryDependencies += "com.typesafe.play" %% "twirl-api" % "1.3.0"
2 changes: 2 additions & 0 deletions testgen/project/build.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
sbt.version=0.13.13

2 changes: 2 additions & 0 deletions testgen/project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.3.0")

67 changes: 23 additions & 44 deletions testgen/src/main/scala/BowlingTestGenerator.scala
Original file line number Diff line number Diff line change
@@ -1,49 +1,28 @@
import play.api.libs.json.Json

import scala.io.Source

class BowlingTestGenerator {
implicit val testCaseReader = Json.reads[BowlingTestCase]

private val filename = "bowling.json"
private val fileContents = Source.fromFile(filename).getLines.mkString
private val json = Json.parse(fileContents)

def write {
val testCases = (json \ "score" \ "cases").get.as[List[BowlingTestCase]]
val description = (json \ "score" \ "description").get.as[List[String]].mkString(" ")

implicit def testCaseToGen(tc: BowlingTestCase): TestCaseGen = {
val callSUT =
s"${tc.rolls}.foldLeft(Bowling())((acc, roll) => acc.roll(roll)).score()"
val expected = ""
val result = s"val score = $callSUT"
val (matchRight, matchLeft) =
if (tc.expected == -1)
("""fail("Unexpected score returned. Failure expected")""", "")
else
(s"assert(n == ${tc.expected})", s"""fail("${tc.description}")""")
val checkResult =
s"""score match {
case Right(n) => $matchRight
case Left(_) => $matchLeft
}"""

TestCaseGen(tc.description, callSUT, expected, result, checkResult)
}

val testBuilder = new TestBuilder("BowlingTest")
testBuilder.addTestCases(testCases, Some(description))
testBuilder.toFile
}
}

case class BowlingTestCase(description: String,
rolls: List[Int],
expected: Int)
import testgen._
import TestSuiteBuilder._
import java.io.File

object BowlingTestGenerator {
def main(args: Array[String]): Unit = {
new BowlingTestGenerator().write
val file = new File("src/main/resources/bowling.json")

def fromLabeledTest(argNames: String*): ToTestCaseData =
withLabeledTest { sut => labeledTest =>
val args = sutArgs(labeledTest.result, argNames: _*)
val isDefined =
labeledTest.expected.fold(Function.const(".isDefined"), Function.const(""))
val sutCall =
s"""val score = ${args}.foldLeft(Bowling())((acc, roll) => acc.roll(roll)).score()
score$isDefined"""
val expected =
labeledTest.expected.fold(Function.const("true"), x => s"Some($x)")

TestCaseData(labeledTest.description, sutCall, expected)
}

val code = TestSuiteBuilder.build(file, fromLabeledTest("previous_rolls"))
println(s"-------------")
println(code)
println(s"-------------")
}
}
14 changes: 14 additions & 0 deletions testgen/src/main/scala/HelloWorldTestGenerator.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import testgen._
import TestSuiteBuilder._
import java.io.File

object HelloWorldTestGenerator {
def main(args: Array[String]): Unit = {
val file = new File("src/main/resources/hello-world.json")

val code = TestSuiteBuilder.build(file, fromLabeledTest())
println(s"-------------")
println(code)
println(s"-------------")
}
}
15 changes: 15 additions & 0 deletions testgen/src/main/scala/NucleotideCountTestGenerator.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import testgen._
import TestSuiteBuilder._
import java.io.File

object NucleotideCountTestGenerator {

def main(args: Array[String]): Unit = {
val file = new File("src/main/resources/nucleotide-count.json")

val code = TestSuiteBuilder.build(file, fromLabeledTest("strand"))
println(s"-------------")
println(code)
println(s"-------------")
}
}
14 changes: 14 additions & 0 deletions testgen/src/main/scala/SumOfMultiplesTestGenerator.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import testgen._
import TestSuiteBuilder._
import java.io.File

object SumOfMultiplesTestGenerator {
def main(args: Array[String]): Unit = {
val file = new File("src/main/resources/sum-of-multiples.json")

val code = TestSuiteBuilder.build(file, fromLabeledTest("factors", "limit"))
println(s"-------------")
println(code)
println(s"-------------")
}
}
108 changes: 108 additions & 0 deletions testgen/src/main/scala/testgen/CanonicalDataParser.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package testgen

import scala.io.Source
import scala.util.parsing.json.JSON
import CanonicalDataParser._
import scala.util.Try
import scala.Left
import scala.Right
import java.io.File

object CanonicalDataParser {
type ParseResult = Map[String,Any]

type Description = String
type Comments = Seq[String]
type Cases = Seq[LabeledTestItem]
type Property = String
type Result = Any
type Error = String
type Expected = Either[Error, Result]
type Properties = Option[Map[String,Any]]

def getOptional[T](result: ParseResult, key: String): Option[T] =
result.get(key).asInstanceOf[Option[T]]
def getRequired[T](result: ParseResult, key: String): T =
getOptional(result, key) getOrElse (throw new Exception(s"missing: $key"))

def parse(file: File): Exercise = {
val fileContents = Source.fromFile(file).getLines.mkString
val rawParseResult =
JSON.parseFull(fileContents).get.asInstanceOf[ParseResult]
val parseResult = rawParseResult mapValues restoreInts
println(parseResult)
parseResult
}

private def restoreInts(any: Any): Any =
any match {
case double: Double if (double.toInt.toDouble == double) => double.toInt
case map: Map[_,_] => map mapValues restoreInts
case seq: Seq[_] => seq map restoreInts
case any => any
}

def main(args: Array[String]): Unit = {
val path = "src/main/resources"
// val name = "hello-world.json"
// val name = "sum-of-multiples.json"
val name = "bowling.json"
val result = parse(new File(s"$path/$name"))
println(result)
}
}

case class Exercise(name: String, version: String, cases: Cases,
comments: Option[Comments])
object Exercise {
implicit def fromParseResult(result: ParseResult): Exercise = {
val cases: Cases =
getRequired[Seq[ParseResult]](result, "cases") map LabeledTestItem.fromParseResult
Exercise(getRequired(result, "exercise"), getRequired(result, "version"),
flattenCases(cases), getOptional(result, "comments"))
}

// so far there are to few LabeledTestGroups to handle them separately
private def flattenCases(cases: Cases): Cases =
cases match {
case Seq() => Seq()
case (ltg: LabeledTestGroup) +: xs => ltg.cases ++ flattenCases(xs)
case (lt: LabeledTest) +: xs => lt +: flattenCases(xs)
}
}

sealed trait LabeledTestItem
object LabeledTestItem {
implicit def fromParseResult(result: ParseResult): LabeledTestItem =
if (result.contains("cases")) result: LabeledTestGroup
else result: LabeledTest
}

case class LabeledTest(description: Description, property: Property,
expected: Expected, result: ParseResult) extends LabeledTestItem
object LabeledTest {
implicit def fromParseResult(result: ParseResult): LabeledTest = {
val expected: Expected = {
val any = getRequired[Any](result, "expected")
val error = Try {
Left(any.asInstanceOf[Map[String,String]]("error"))
}
error.getOrElse(Right(any))
}
LabeledTest(getRequired(result, "description"), getRequired(result, "property"),
expected, result)
}
}

// only exercise in this format seems to be beer-song, so implemented it for now
case class LabeledTestGroup(description: Description, cases: Cases) extends LabeledTestItem
object LabeledTestGroup {
implicit def fromParseResult(result: ParseResult): LabeledTestGroup = {
val description = getRequired[String](result, "description")
val cases =
getRequired[Seq[ParseResult]](result, "cases") map LabeledTestItem.fromParseResult
LabeledTestGroup(description, cases)
}

// def toLabeledTest(result: ParseResult): LabeledTest = ???
}
89 changes: 89 additions & 0 deletions testgen/src/main/scala/testgen/TestSuiteBuilder.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package testgen

import java.io.FileWriter
import java.io.File
import play.twirl.api.Txt
import play.twirl.api.Template1
import TestSuiteBuilder._

object TestSuiteBuilder {

type TestSuiteTemplate = Template1[TestSuiteData, Txt]
type ToTestCaseData = String => LabeledTestItem => TestCaseData

private val DefaultTemplate: TestSuiteTemplate =
txt.funSuiteTemplate.asInstanceOf[Template1[TestSuiteData, Txt]]

def build(file: File, toTestCaseData: ToTestCaseData, imports: Seq[String] = Seq())(
implicit template: TestSuiteTemplate = DefaultTemplate): String =
{
val exercise @ Exercise(name, version, cases, comments) =
CanonicalDataParser.parse(file)
val tsName = testSuiteName(name)
val testCasesAllPending = cases map toTestCaseData(sutName(name))
val testCases =
testCasesAllPending updated(0, testCasesAllPending.head.copy(pending = false))
val testSuiteData =
TestSuiteData(tsName, version, imports, testCases)

template.render(testSuiteData).toString
}

def withLabeledTest(f: String => LabeledTest => TestCaseData): ToTestCaseData =
sut => item => f(sut)(item.asInstanceOf[LabeledTest])

def fromLabeledTest(argNames: String*): ToTestCaseData =
withLabeledTest { sut => labeledTest =>
val sutFunction = labeledTest.property
val args = sutArgs(labeledTest.result, argNames: _*)
val sutCall = s"$sut.$sutFunction(${args})"
val expected = toString(labeledTest.expected)

TestCaseData(labeledTest.description, sutCall, expected)
}

def main(args: Array[String]): Unit = {
val path = "src/main/resources"
val input: Map[String, ToTestCaseData] =
Map("hello-world" -> fromLabeledTest(),
"sum-of-multiples" -> fromLabeledTest("factors", "limit"),
"bowling" -> fromLabeledTest("previous_rolls"))

input foreach { case ((name, toTestCaseData)) =>
val file = new File(s"$path/$name.json")

val code = build(file, toTestCaseData)
println(s"------ $name -------")
println(code)
println(s"------ \\$name -------")
}
}

def sutName(exerciseName: String) =
(exerciseName split ("-") map (_.capitalize) mkString)
def testSuiteName(exerciseName: String): String =
sutName(exerciseName) + "Test"

def sutArgs(parseResult: CanonicalDataParser.ParseResult, argNames: String*): String =
argNames map (name => toString(parseResult(name))) mkString(", ")

private def toString(expected: CanonicalDataParser.Expected): String =
expected.fold(error => s"Left(${toString(error)})", toString)

private def toString(any: Any): String = any match {
case str: String => s""""$str""""
case _ => any.toString
}

def toFile(text: String, dest: File): Unit = {
val fileWriter = new FileWriter(dest)
try { fileWriter.write(text) } finally fileWriter.close
}
}

case class TestCaseData(description: String, sutCall: String, expected: String,
pending: Boolean = true)

case class TestSuiteData(name: String, version: String, imports: Seq[String],
testCases: Seq[TestCaseData])

12 changes: 12 additions & 0 deletions testgen/src/main/twirl/funSuiteTemplate.scala.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@(data: testgen.TestSuiteData)@for(imp <- data.imports) {import @imp}
import org.scalatest.{Matchers, FunSuite}
@import testgen.TestSuiteBuilder._

/** @@version @data.version */
class @data.name extends FunSuite with Matchers {
@for(testCase <- data.testCases) {
test("@testCase.description") { @if(testCase.pending) {
pending}
@testCase.sutCall should be (@testCase.expected)
}
}}

0 comments on commit 9503d76

Please sign in to comment.