Training plan validation #36 #37

master
mgifos 7 years ago
parent d757b72cd3
commit 4926b9b938
  1. 28
      src/main/scala/com.github.mgifos.workouts/Main.scala
  2. 10
      src/main/scala/com.github.mgifos.workouts/model/Step.scala
  3. 2
      src/main/scala/com.github.mgifos.workouts/model/Target.scala
  4. 12
      src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala
  5. 45
      src/main/scala/com.github.mgifos.workouts/model/Workout.scala
  6. 2
      src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala
  7. 78
      src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala

@ -43,9 +43,7 @@ object Main extends App {
case Some(config) =>
val worker = run(config).andThen {
case _ =>
shutdown()
log.info("Logged out. Connection is closed.")
case _ => shutdown()
}
Await.result(worker, 10.minutes)
log.info("Bye")
@ -104,22 +102,21 @@ object Main extends App {
def run(implicit config: Config): Future[Unit] = {
implicit val plan: WeeklyPlan = new WeeklyPlan(Files.readAllBytes(Paths.get(config.csv)))(config.system)
val console = System.console()
def proceedToGarmin() = {
val email = if (config.email.nonEmpty) config.email else {
print("Please enter your email address to login to Garmin Connect: ")
console.readLine()
}
val password = if (config.password.nonEmpty) config.password else {
print("Password: ")
new String(console.readPassword())
}
implicit val plan: WeeklyPlan = new WeeklyPlan(Files.readAllBytes(Paths.get(config.csv)))(config.system)
implicit val garmin: GarminConnect = new GarminConnect(email, password)
val workouts = plan.workouts.toIndexedSeq
garmin.login().flatMap {
@ -134,6 +131,7 @@ object Main extends App {
maybeDeleteMessage.foreach(msg => log.info(" " + msg))
maybeGarminWorkouts.foreach(workouts => log.info(s" ${workouts.length} imported"))
maybeScheduleMessage.foreach(msg => log.info(" " + msg))
log.info("Logging out and closing connection...")
}
case Left(loginFailureMessage) =>
log.error(loginFailureMessage)
@ -141,6 +139,18 @@ object Main extends App {
}
}
if (plan.invalid().isEmpty) proceedToGarmin()
else {
plan.invalid().foreach(i => log.warn(i.toString))
println("Your plan contains some invalid items.")
print("Do you want to proceed to Garmin by skipping these items? [Y/n]")
"" + console.readLine() match {
case "" | "y" | "Y" => proceedToGarmin()
case _ => Future.successful(())
}
}
}
/**
* Deletes existing workouts with the same names or not
*/
@ -163,12 +173,12 @@ object Main extends App {
if (config.mode.contains(Modes.schedule)) {
val start = (config.start, config.end) match {
case (_, end) if !end.isEqual(LocalDate.MIN) => end.minusDays(plan.get.length - 1)
case (_, end) if !end.isEqual(LocalDate.MIN) => end.minusDays(plan.get().length - 1)
case (from, _) => from
}
val woMap: Map[String, GarminWorkout] = Map(workouts.map(ga => ga.name -> ga): _*)
val spec = plan.get.zipWithIndex.collect {
val spec = plan.get().zipWithIndex.collect {
case (Some(ref), day) if !start.plusDays(day).isBefore(LocalDate.now()) => start.plusDays(day) -> woMap(ref.name)
}.to[Seq]
garmin.schedule(spec).map(c => Some(s"$c scheduled"))

@ -59,7 +59,7 @@ object Step {
def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Step = x match {
case StepRx(header, subSteps, _) if subSteps.nonEmpty => header match {
case StepHeader(name, params) =>
assert(name == "repeat", "must be 'repeat' if contains sub-steps")
if (name != "repeat") throw new IllegalArgumentException(s"'$name' cannot contain sub-steps, it must be 'repeat'")
RepeatStep(params.trim.toInt, subSteps.trim.lines.toList.map(parseDurationStep))
case _ => throw new IllegalArgumentException(s"Cannot parse repeat step $header")
}
@ -73,16 +73,16 @@ object Step {
case "run" | "bike" | "go" => IntervalStep.tupled(expect(params))
case "recover" => RecoverStep.tupled(expect(params))
case "cooldown" => CooldownStep.tupled(expect(params))
case _ => throw new IllegalArgumentException(s"Duration step type was expected, $name")
case _ => throw new IllegalArgumentException(s"'$name' is not a duration step type")
}
case _ => throw new IllegalArgumentException(s"Cannot parse step type $x")
case _ => throw new IllegalArgumentException(s"Cannot parse duration step: $x")
}
private def expect(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): (Duration, Option[Target]) = x match {
private def expect(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): (Duration, Option[Target]) = x.trim match {
case ParamsRx(duration, _, target) =>
val maybeTarget = Option(target).filter(_.trim.nonEmpty).map(Target.parse)
(Duration.parse(duration.trim), maybeTarget)
case _ => throw new IllegalArgumentException(s"Cannot parse step parameters $x")
case raw => throw new IllegalArgumentException(s"Cannot parse step parameters $raw")
}
}

@ -89,6 +89,6 @@ object Target {
case PaceRangeRx(from, to, uom) =>
val du = Option(uom).fold(msys.distance)(DistanceUnits.withPaceUOM)
PaceTarget(Pace(du, from), Pace(du, to))
case _ => throw new IllegalArgumentException(s"Unknown target specification: $x")
case raw => throw new IllegalArgumentException(s"'$raw' is not a valid target specification")
}
}

@ -13,9 +13,9 @@ class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.Measurement
def weekPlan(week: Week, previousWeeks: Seq[Option[Workout]]): Seq[Option[Workout]] = Seq.tabulate(7) { weekDayNo =>
week.lift(weekDayNo + 1).flatMap(text => Option(text.trim).filter(_.nonEmpty))
}.foldLeft(Seq.empty[Option[Workout]])((acc, maybeDayText) => acc :+ maybeDayText.map { dayText =>
Workout.parseDef(dayText) match {
case Right(definition) => definition
case Left(_) => onlyDefs(previousWeeks ++ acc).find(_.name == dayText).map(_.toRef).getOrElse(WorkoutNote(dayText))
Workout.parse(dayText) match {
case note: WorkoutNote => onlyDefs(previousWeeks ++ acc).find(_.name == dayText).map(_.toRef).getOrElse(note)
case w: Workout => w
}
})
@ -35,12 +35,16 @@ class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.Measurement
/**
* @return optional workout refs (defs included as refs)
*/
def get: Seq[Option[WorkoutRef]] = processed.map {
def get(): Seq[Option[WorkoutRef]] = processed.map {
case Some(x: WorkoutDef) => Some(x.toRef)
case Some(x: WorkoutRef) => Some(x)
case _ => None
}
def invalid(): Seq[Workout] = processed.collect {
case Some(x) if !x.valid() => x
}
private def isAValidWeek(w: Seq[String]) = w.headOption.exists(no => no.trim.nonEmpty && no.forall(_.isDigit))
private def onlyDefs(days: Seq[Option[Workout]]) = days.flatMap {

@ -4,13 +4,14 @@ import play.api.libs.json.{ JsValue, Json }
import Workout._
trait Workout {
def json: JsValue
def json(): JsValue = Json.obj()
def valid(): Boolean = true
}
case class WorkoutDef(sport: String, name: String, steps: Seq[Step] = Nil) extends Workout {
def toRef: WorkoutRef = WorkoutRef(name)
def withStep(step: Step): WorkoutDef = WorkoutDef(sport, name, steps :+ step)
def json: JsValue = Json.obj(
override def json(): JsValue = Json.obj(
"sportType" -> Json.obj(
"sportTypeId" -> sportId(sport),
"sportTypeKey" -> sportTypeKey(sport)),
@ -24,36 +25,44 @@ case class WorkoutDef(sport: String, name: String, steps: Seq[Step] = Nil) exten
"workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) })))
}
case class WorkoutRef(name: String) extends Workout {
def json: JsValue = Json.obj()
case class WorkoutDefFailure(`type`: String, original: String, cause: String) extends Workout {
override def toString = s"""Possible workout definition that can't be parsed: "$original"\nCause: "$cause"\n-------------------------------------"""
override def valid(): Boolean = false
}
case class WorkoutNote(note: String) extends Workout {
def json: JsValue = Json.obj()
case class WorkoutStepFailure(original: String, cause: String) extends Workout {
override def toString = s"""Workout steps that can't be parsed: "$original"\nCause: "$cause"\n-------------------------------------"""
override def valid(): Boolean = false
}
case class WorkoutRef(name: String) extends Workout
case class WorkoutNote(note: String) extends Workout
object Workout {
private val WorkoutHeader = """^(running|cycling|custom):\s([\u0020-\u007F]+)(([\r\n]+\s*\-\s[a-z]+:.*)*)$""".r
private val WorkoutType = "(running|cycling|custom)"
private val WorkoutHeader = raw"""^$WorkoutType:\s([\u0020-\u007F]+)(([\r\n]+\s*\-\s[a-z]+:.*)*)$$""".r
private val NextStepRx = """^((-\s\w*:\s.*)(([\r\n]+\s{1,}-\s.*)*))(([\s].*)*)$""".r
private val PossibleWorkoutHeader = raw"""^\s*$WorkoutType\s*:\s*.*(([\r\n]+\s*.*)*)$$""".r
def parseDef(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Either[String, WorkoutDef] = {
def loop(w: WorkoutDef, steps: String): Either[String, WorkoutDef] = steps match {
def parse(text: String)(implicit msys: MeasurementSystems.MeasurementSystem): Workout = {
def loop(w: WorkoutDef, steps: String): Workout = steps match {
case NextStepRx(next, _, _, _, rest, _) =>
try {
val newWorkout = w.withStep(Step.parse(next.trim))
if (rest.trim.isEmpty) Right(newWorkout)
if (rest.trim.isEmpty) newWorkout
else loop(newWorkout, rest.trim)
case _ => Left(s"Input string cannot be parsed to Workout: $steps")
} catch {
case ex: IllegalArgumentException => WorkoutStepFailure(text, ex.getMessage.trim)
}
x match {
case WorkoutHeader(sport, name, steps, _) => loop(WorkoutDef(sport, name), steps.trim)
case _ => Left(s"Input string cannot be parsed to Workout: $x")
case _ => WorkoutStepFailure(text, steps.trim)
}
text match {
case WorkoutHeader(sport, name, steps, _) => loop(WorkoutDef(sport, name), steps.trim)
case PossibleWorkoutHeader(t, _, cause) => WorkoutDefFailure(`type` = t, text, if (cause == null) "" else cause.trim)
case _ => WorkoutNote(text)
}
def parseRef(x: String): WorkoutRef = x match {
case WorkoutHeader(_, name, _, _) => WorkoutRef(name)
case _ => WorkoutRef(x.trim)
}
def sportId(sport: String) = sport match {

@ -10,7 +10,7 @@ class StepSpec extends FlatSpec with Matchers {
"Step" should "parse correctly" in {
a[IllegalArgumentException] should be thrownBy Step.parse("")
a[AssertionError] should be thrownBy Step.parse("- warmup: 5km\n - run: 10km\n - recover: 100m")
a[IllegalArgumentException] should be thrownBy Step.parse("- warmup: 5km\n - run: 10km\n - recover: 100m")
Step.parse("- warmup: 5km") should be(WarmupStep(DistanceDuration(5, km)))
Step.parse("- run: 2km @ 5:00-4:50") should be(IntervalStep(DistanceDuration(2, km), Some(PaceTarget(Pace(msys.distance, "5:00"), Pace(msys.distance, "4:50")))))

@ -1,10 +1,10 @@
package com.github.mgifos.workouts.model
import com.github.mgifos.workouts.model.DistanceUnits._
import org.scalatest.{ FlatSpec, Matchers }
import org.scalatest.{ Matchers, WordSpec }
import play.api.libs.json.Json
class WorkoutSpec extends FlatSpec with Matchers {
class WorkoutSpec extends WordSpec with Matchers {
implicit val msys = MeasurementSystems.metric
@ -18,47 +18,71 @@ class WorkoutSpec extends FlatSpec with Matchers {
*/
val testWO = "running: run-fast\n- warmup: 10:00\n- repeat: 2\n - run: 1500m @ 4:30-5:00\n - recover: 01:30 @ z2\n- cooldown: lap-button"
"Workout" should "parse correctly" in {
Workout.parseDef("") should be('left)
Workout.parseDef("running") should be('left)
Workout.parseDef("running:") should be('left)
Workout.parseDef("running !") should be('left)
Workout.parseDef("running run-fast") should be('left)
Workout.parseDef(" running: run-fast") should be('left)
"Workout parser should" should {
Workout.parseDef(testWO) should be(
Right(
WorkoutDef("running", "run-fast", Seq(
"parse notes correctly" in {
Workout.parse("") shouldBe a[WorkoutNote]
Workout.parse("running") shouldBe a[WorkoutNote]
Workout.parse("running !") shouldBe a[WorkoutNote]
Workout.parse("running run-fast") shouldBe a[WorkoutNote]
}
"parse a workout definition correctly" in {
Workout.parse(testWO) shouldBe WorkoutDef("running", "run-fast", Seq(
WarmupStep(TimeDuration(minutes = 10)),
RepeatStep(2, Seq(
IntervalStep(DistanceDuration(1500, m), Some(PaceTarget(Pace(msys.distance, "4:30"), Pace(msys.distance, "5:00")))),
RecoverStep(TimeDuration(1, 30), Some(HrZoneTarget(2))))),
CooldownStep(LapButtonPressed)))))
CooldownStep(LapButtonPressed)))
}
"Workout" should "parse various printable workout-names correctly" in {
"parse various printable workout-names correctly" in {
val testNames = Seq("abcz", "123 xyw", """abc!/+-@,?*;:_!\"#$%&/()=?*""")
testNames.foreach { testName =>
val x = Workout.parseDef(testWO.replace("run-fast", testName))
x.right.get.name should be(testName)
}
val x = Workout.parse(testWO.replace("run-fast", testName))
x shouldBe a[WorkoutDef]
x.asInstanceOf[WorkoutDef].name should be(testName)
}
"Workout" should "dump json correctly" in {
val is = getClass.getClassLoader.getResourceAsStream("run-fast.json")
val expectJson = Json.parse(is)
Workout.parseDef(testWO).map(_.json) should be(Right(expectJson))
}
"Workout" should "support cycling ws" in {
"parse cycling workouts" in {
val testBike = "cycling: cycle-test\r\n- warmup: 5:00\n- bike: 20km @ 20.0-100kph\r- cooldown: lap-button"
Workout.parseDef(testBike) should be(
Right(
WorkoutDef("cycling", "cycle-test", Seq(
Workout.parse(testBike) shouldBe WorkoutDef("cycling", "cycle-test", Seq(
WarmupStep(TimeDuration(minutes = 5)),
IntervalStep(DistanceDuration(20, km), Some(SpeedTarget(Speed(km, "20.0"), Speed(km, "100")))),
CooldownStep(LapButtonPressed)))))
CooldownStep(LapButtonPressed)))
}
"validate on a workout definition level" in {
Workout.parse("running:") shouldBe a[WorkoutDefFailure]
Workout.parse("running: run-fast\nhm") shouldBe a[WorkoutDefFailure]
Workout.parse("running: run\n- warmup: 10:00") shouldBe a[WorkoutDefFailure]
}
"validate on a step level" in {
Workout.parse("running: run\n- run: 5km 2+2") should matchPattern {
case WorkoutStepFailure(_, "Cannot parse step parameters 5km 2+2") =>
}
Workout.parse("running: run\n- run: 10:00\n - run: 1500m @ 4:30-5:00") should matchPattern {
case WorkoutStepFailure(_, "'run' cannot contain sub-steps, it must be 'repeat'") =>
}
Workout.parse("running: run\n- and: fail at this step") should matchPattern {
case WorkoutStepFailure(_, "'and' is not a duration step type") =>
}
Workout.parse("running: run\n- run: 10km @ 20x") should matchPattern {
case WorkoutStepFailure(_, "'20x' is not a valid target specification") =>
}
}
}
"Workout should" should {
"dump json correctly" in {
val is = getClass.getClassLoader.getResourceAsStream("run-fast.json")
val expectJson = Json.parse(is)
Workout.parse(testWO).json() should be(expectJson)
}
}
}

Loading…
Cancel
Save