From 3cda9c83f6542e2ea0f46f7b518e00ce267f0795 Mon Sep 17 00:00:00 2001 From: Cam Saul Date: Mon, 20 Apr 2020 15:28:44 -0700 Subject: [PATCH] Polish :shower: --- .circleci/config.yml | 7 --- README.md | 92 ++++++++++++++++++++++++++++++- project.clj | 23 +++----- src/second_date/common.clj | 2 - src/second_date/core.clj | 66 +++++++++++++++------- src/second_date/parse.clj | 26 +++++++-- src/second_date/parse/builder.clj | 6 +- test/second_date/core_test.clj | 11 ++-- test/second_date/parse_test.clj | 63 +++++++++------------ 9 files changed, 204 insertions(+), 92 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ee9c06c..f75928b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -132,12 +132,6 @@ workflows: - deps lein-command: bikeshed - - lein: - name: yagni - requires: - - deps - lein-command: yagni - - lein: name: deploy requires: @@ -145,7 +139,6 @@ workflows: - docstring-checker - eastwood - namespace-decls - - yagni - tests - tests-java-11 lein-command: deploy clojars diff --git a/README.md b/README.md index d8ee07c..fb91b83 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,94 @@ [![Clojars Project](https://clojars.org/metabase/second-date/latest-version.svg)](http://clojars.org/metabase/second-date) -# Cam's Clojure Library template +# Second Date -Run `./init.sh` and follow the prompts. That's it! +Second Date provides a handful of utility functions for working with `java.time`. The most important are: + +###### `parse` + +Can parse almost any temporal literal String to the correct `java.time` class. + +```clj +(require '[second-date.core :as second-date]) + +(second-date/parse "2020-04") +;; -> #object[java.time.LocalDate 0x1998e54f "2020-04-01"] + +(second-date/parse "2020-04-01") +;; -> #object[java.time.LocalDate 0x1998e54f "2020-04-01"] + +(second-date/parse "2020-04-01T15:01") +;; -> #object[java.time.LocalDateTime 0x121829b7 "2020-04-01T15:01"] + +(second-date/parse "2020-04-01T15:01-07:00") +;; -> #object[java.time.OffsetDateTime 0x7dc126b0 "2020-04-01T15:01-07:00"] + +(second-date/parse "2020-04-01T15:01-07:00[US/Pacific]") +;; -> #object[java.time.ZonedDateTime 0x351fb7c8 "2020-04-01T15:01-07:00[US/Pacific]"] +``` + +`parse` handles ISO-8601 strings as well as SQL literals (e.g. `2020-04-01 15:01:00`). + +###### `format` + +Formats any of the main `java.time` temporal instant classes as a String. Uses ISO-8601 by default, but can use any +`java.time.format.DateTimeFormatter` or keywords naming static formatters as understood by +[`clojure.java-time`](https://github.com/dm3/clojure.java-time). + +```clj +(require '[java-time :as t] + '[second-date.core :as second-date]) + +(second-date/format (t/zoned-date-time "2020-04-01T15:01-07:00[US/Pacific]")) +;; -> "2020-04-01T16:01:00-07:00" + +(second-date/format (t/offset-date-time "2020-04-01T15:01-07:00")) +;; -> "2020-04-01T16:01:00-07:00" + +(second-date/format (t/local-date-time "2020-04-01T15:01")) +;; -> "2020-04-01T15:01:00" + +;; with a different formatter +(second-date/format :basic-iso-date (t/local-date-time "2020-04-01T15:01")) +;; -> "20200401" + +;; it even handles Instants +(second-date/format :iso-week-date (t/instant "2020-04-01T15:01:00-07:00")) +;; "2020-W14-3Z" +``` + +Check the value of `java-time.format/predefined-formatters` for all supported predefined formatters. + +###### `second-date.parse.builder/formatter` + +`second-date.parse.builder/formatter` is a Clojure interface for `java.time.format.DateTimeFormatterBuilder`, used to +create `DateTimeFormatter`s. + +```clj +(require '[java-time :as t] + '[second-date :as second-date] + '[second-date.parse.builder :as b]) + +(def my-time-formatter + (b/formatter + (b/case-insensitive + (b/value :hour-of-day 2) + (b/optional + ":" + (b/value :minute-of-hour 2) + (b/optional + ":" + (b/value :second-of-minute)))))) + +;; -> #object[java.time.format.DateTimeFormatter "ParseCaseSensitive(false)Value(HourOfDay,2)[':'Value(MinuteOfHour,2)[':'Value(SecondOfMinute)]]"] + +;; you can now use the formatter to format and parse +(second-date/format my-time-formatter (t/zoned-date-time "2020-04-01T15:23:52.878132-07:00[America/Los_Angeles]")) +;; -> "15:23:52" + +(second-date/parse my-time-formatter "15:23:52") +;; -> #object[java.time.LocalTime 0x514c293c "15:23:52"] +``` + +Some example formatters can be found in `second-date.parse`. diff --git a/project.clj b/project.clj index ad5dc1f..544a81f 100644 --- a/project.clj +++ b/project.clj @@ -7,14 +7,13 @@ :aliases {"test" ["with-profile" "+test" "test"] - "bikeshed" ["with-profile" "+bikeshed" "bikeshed" "--max-line-length" "120"] + "bikeshed" ["with-profile" "+bikeshed" "bikeshed" "--max-line-length" "200"] "check-namespace-decls" ["with-profile" "+check-namespace-decls" "check-namespace-decls"] "eastwood" ["with-profile" "+eastwood" "eastwood"] "check-reflection-warnings" ["with-profile" "+reflection-warnings" "check"] "docstring-checker" ["with-profile" "+docstring-checker" "docstring-checker"] - "yagni" ["with-profile" "+yagni" "yagni"] ;; `lein lint` will run all linters - "lint" ["do" ["eastwood"] ["bikeshed"] ["yagni"] ["check-namespace-decls"] ["docstring-checker"]]} + "lint" ["do" ["eastwood"] ["bikeshed"] ["check-namespace-decls"] ["docstring-checker"]]} :dependencies [[clojure.java-time "0.3.2"] @@ -33,14 +32,14 @@ {:plugins [[jonase/eastwood "0.3.11" :exclusions [org.clojure/clojure]]] - :add-linters - [:unused-private-vars - :unused-namespaces - :unused-fn-args - :unused-locals] + :eastwood + {:add-linters + [:unused-private-vars + :unused-fn-args + :unused-locals] - :exclude-linters - [:deprecations]} + :exclude-linters + [:implicit-dependencies]}} :docstring-checker {:plugins @@ -53,10 +52,6 @@ {:plugins [[lein-bikeshed "0.5.2"]]} - :yagni - {:plugins - [[venantius/yagni "0.1.7"]]} - :check-namespace-decls {:plugins [[lein-check-namespace-decls "1.0.2"]] :source-paths ["test"] diff --git a/src/second_date/common.clj b/src/second_date/common.clj index 0cf02c8..872f088 100644 --- a/src/second_date/common.clj +++ b/src/second_date/common.clj @@ -2,8 +2,6 @@ (:require [clojure.string :as str]) (:import [java.time.temporal ChronoField IsoFields TemporalField WeekFields])) -;; TODO - not sure this belongs here, it seems to be a bit more general than just `date-2`. - (defn static-instances "Utility function to get the static members of a class. Returns map of `lisp-case` keyword names of members -> value." ([^Class klass] diff --git a/src/second_date/core.clj b/src/second_date/core.clj index c844bc6..289f0ae 100644 --- a/src/second_date/core.clj +++ b/src/second_date/core.clj @@ -27,26 +27,54 @@ OffsetDateTime :iso-offset-date-time ZonedDateTime :iso-offset-date-time)) -(defn- format* [formatter t] - (when t - (if (t/instant? t) - (recur formatter (t/zoned-date-time t (t/zone-id "UTC"))) - (t/format formatter t)))) - (defn format - "Format temporal value `t` as a ISO-8601 date/time/datetime string." - ^String [t] - (when t - (format* (temporal->iso-8601-formatter t) t))) + "Format any of the main `java.time` temporal instant classes as a String. Uses ISO-8601 by default, but `formatter` + can be a `java.time.format.DateTimeFormatter` or keywords naming static formatters as understood by + [`clojure.java-time`](https://github.com/dm3/clojure.java-time). Check the value of + `java-time.format/predefined-formatters` for all supported predefined formatters. + + (second-date/format (t/zoned-date-time \"2020-04-01T15:01-07:00[US/Pacific]\")) + ;; -> \"2020-04-01T16:01:00-07:00\" + + (second-date/format (t/offset-date-time \"2020-04-01T15:01-07:00\")) + ;; -> \"2020-04-01T16:01:00-07:00\" + + (second-date/format (t/local-date-time \"2020-04-01T15:01\")) + ;; -> \"2020-04-01T15:01:00\" + + ;; with a different formatter + (second-date/format :basic-iso-date (t/local-date-time \"2020-04-01T15:01\")) + ;; -> \"20200401\" + + ;; it even handles Instants + (second-date/format :iso-week-date (t/instant \"2020-04-01T15:01:00-07:00\")) + ;; \"2020-W14-3Z\"" + (^String [t] + (when t + (format (temporal->iso-8601-formatter t) t))) + + (^String [formatter t] + (when t + (if (t/instant? t) + (recur formatter (t/zoned-date-time t (t/zone-id "UTC"))) + (t/format formatter t))))) + +;; replace the `T` with a space. Easy! +(defn- replace-T-with-space [s] + (when s + (str/replace-first s #"(\d{2})T(\d{2})" "$1 $2"))) (defn format-sql "Format a temporal value `t` as a SQL-style literal string (for most SQL databases). This is the same as ISO-8601 but uses a space rather than of a `T` to separate the date and time components." - ^String [t] - ;; replace the `T` with a space. Easy! - (str/replace-first (format t) #"(\d{2})T(\d{2})" "$1 $2")) + (^String [t] + (replace-T-with-space (format t))) + + (^String [formatter t] + (replace-T-with-space (format formatter t)))) -(def ^:private add-units +(def add-units + "Units supported by the `add` function." #{:millisecond :second :minute :hour :day :week :month :quarter :year}) (defn add @@ -76,7 +104,7 @@ ;; TIMEZONE FIXME - we should add `:millisecond-of-second` (or `:fraction-of-second`?) and `:second-of-minute` as ;; well. Not sure where we'd use these, but we should have them for consistency (def extract-units - "Units which return a (numerical, periodic) component of a date" + "Units which return a (numerical, periodic) component of a date." #{:minute-of-hour :hour-of-day :day-of-week @@ -87,8 +115,8 @@ :iso-week-of-year :month-of-year :quarter-of-year - ;; TODO - in this namespace `:year` is something you can both extract and truncate to. In MBQL `:year` is a truncation - ;; operation. Maybe we should rename this unit to clear up the potential confusion (?) + ;; TODO - in this namespace `:year` is something you can both extract and truncate to. In MBQL `:year` is a + ;; truncation operation. Maybe we should rename this unit to clear up the potential confusion (?) :year}) (def ^:private week-fields* @@ -134,7 +162,7 @@ (defmethod adjuster :default [k] - (throw (ex-info (format "No temporal adjuster named %s" k) {}))) + (throw (ex-info (clojure.core/format "No temporal adjuster named %s" k) {}))) (defmethod adjuster :first-day-of-week [_] @@ -221,7 +249,7 @@ (= unit :default) t (extract-units unit) (extract t unit) (truncate-units unit) (truncate t unit) - :else (throw (ex-info (format "Invalid unit: %s" unit) {:unit unit}))))) + :else (throw (ex-info (clojure.core/format "Invalid unit: %s" unit) {:unit unit}))))) (defn range "Get a start (by default, inclusive) and end (by default, exclusive) pair of instants for a `unit` span of time diff --git a/src/second_date/parse.clj b/src/second_date/parse.clj index 3b6161e..f85ab8a 100644 --- a/src/second_date/parse.clj +++ b/src/second_date/parse.clj @@ -108,7 +108,7 @@ (b/optional (b/zone-id)))) -(def ^:private ^DateTimeFormatter formatter +(def ^:private ^DateTimeFormatter default-formatter (b/formatter (b/case-insensitive (b/optional @@ -121,6 +121,24 @@ offset-formatter*)))) (defn parse - "Parse a string into a `java.time` object." - [^String s] - (parse-with-formatter formatter s)) + "Parse almost any temporal literal String to a `java.time` object. + + (second-date/parse \"2020-04\") + ;; -> #object[java.time.LocalDate 0x1998e54f \"2020-04-01\"] + + (second-date/parse \"2020-04-01\") + ;; -> #object[java.time.LocalDate 0x1998e54f \"2020-04-01\"] + + (second-date/parse \"2020-04-01T15:01\") + ;; -> #object[java.time.LocalDateTime 0x121829b7 \"2020-04-01T15:01\"] + + (second-date/parse \"2020-04-01T15:01-07:00\") + ;; -> #object[java.time.OffsetDateTime 0x7dc126b0 \"2020-04-01T15:01-07:00\"] + + (second-date/parse \"2020-04-01T15:01-07:00[US/Pacific]\") + ;; -> #object[java.time.ZonedDateTime 0x351fb7c8 \"2020-04-01T15:01-07:00[US/Pacific]\"]" + (^Temporal [s] + (parse-with-formatter default-formatter s)) + + (^Temporal [formatter s] + (parse-with-formatter formatter s))) diff --git a/src/second_date/parse/builder.clj b/src/second_date/parse/builder.clj index abe4ab7..ac6102f 100644 --- a/src/second_date/parse/builder.clj +++ b/src/second_date/parse/builder.clj @@ -99,7 +99,7 @@ (defn value "Define a section for a specific field such as `:hour-of-day` or `:minute-of-hour`. Refer to - `metabase.util.date-2.common/temporal-field` for all possible temporal fields names." + `second-date.common/temporal-field` for all possible temporal fields names." ([temporal-field-name] (fn [^DateTimeFormatterBuilder builder] (.appendValue builder (temporal-field temporal-field-name)))) @@ -122,7 +122,7 @@ "Define a section for a fractional value, e.g. milliseconds or nanoseconds." [temporal-field-name min-val-width max-val-width & {:keys [decimal-point?]}] (fn [^DateTimeFormatterBuilder builder] - (.appendFraction builder (temporal-field temporal-field-name) 0 9 (boolean decimal-point?)))) + (.appendFraction builder (temporal-field temporal-field-name) min-val-width max-val-width (boolean decimal-point?)))) (defn zone-offset "Define a section for a timezone offset. e.g. `-08:00`." @@ -142,7 +142,7 @@ (optional "]")))) (defn formatter - "Return a new `DateTimeFormatter` from `sections`. See examples in `metabase.util.date-2.parse` for more details. + "Return a new `DateTimeFormatter` from `sections`. See examples in `second-date.parse` for more details. (formatter (case-insensitive diff --git a/test/second_date/core_test.clj b/test/second_date/core_test.clj index daf2886..81e184b 100644 --- a/test/second_date/core_test.clj +++ b/test/second_date/core_test.clj @@ -72,7 +72,7 @@ (format "Extract %s from %s %s should be %s" unit (class t) t expected))))) (testing "second-date/extract with 1 arg (extract from now)" (is (= 2 - (t/with-clock (t/mock-clock (t/instant "2019-11-18T22:31:00Z")) + (t/with-clock (t/mock-clock (t/instant "2019-11-18T22:31:00Z") (t/zone-id "UTC")) (second-date/extract :day-of-week)))))) (deftest truncate-test @@ -127,14 +127,15 @@ (format "Truncate %s %s to %s should be %s" (class t) t unit expected))))) (testing "second-date/truncate with 1 arg (truncate now)" (is (= (t/zoned-date-time "2019-11-18T00:00Z[UTC]") - (t/with-clock (t/mock-clock (t/instant "2019-11-18T22:31:00Z")) + (t/with-clock (t/mock-clock (t/instant "2019-11-18T22:31:00Z") (t/zone-id "UTC")) (second-date/truncate :day)))))) (deftest add-test (testing "with 2 args (datetime relative to now)" (is (= (t/zoned-date-time "2019-11-20T22:31Z[UTC]") - (t/with-clock (t/mock-clock (t/instant "2019-11-18T22:31:00Z")) + (t/with-clock (t/mock-clock (t/instant "2019-11-18T22:31:00Z") (t/zone-id "UTC")) (second-date/add :day 2))))) + (testing "with 3 args" (let [t (t/zoned-date-time "2019-06-14T00:00:00.000Z[UTC]")] (doseq [[unit n expected] [[:second 5 "2019-06-14T00:00:05Z[UTC]"] @@ -153,12 +154,14 @@ (testing "with 1 arg (range relative to now)" (is (= {:start (t/zoned-date-time "2019-11-17T00:00Z[UTC]") :end (t/zoned-date-time "2019-11-24T00:00Z[UTC]")} - (t/with-clock (t/mock-clock (t/instant "2019-11-18T22:31:00Z")) + (t/with-clock (t/mock-clock (t/instant "2019-11-18T22:31:00Z") (t/zone-id "UTC")) (second-date/range :week))))) + (testing "with 2 args" (is (= {:start (t/zoned-date-time "2019-10-27T00:00Z[UTC]") :end (t/zoned-date-time "2019-11-03T00:00Z[UTC]")} (second-date/range (t/zoned-date-time "2019-11-01T15:29:00Z[UTC]") :week)))) + (testing "with 3 args (start/end inclusitivity options)" (testing "exclusive start" (is (= {:start (t/local-date-time "2019-10-31T23:59:59.999"), :end (t/local-date-time "2019-12-01T00:00")} diff --git a/test/second_date/parse_test.clj b/test/second_date/parse_test.clj index 7cd67eb..634a65e 100644 --- a/test/second_date/parse_test.clj +++ b/test/second_date/parse_test.clj @@ -9,28 +9,29 @@ ;; system timezone should not affect the way strings are parsed (doseq [system-timezone-id ["UTC" "US/Pacific"]] (tu/with-system-timezone-id system-timezone-id - (letfn [(message [expected s default-timezone-id] - (if default-timezone-id - (format "parsing '%s' with default timezone id '%s' should give you %s" s default-timezone-id (pr-str expected)) - (format "parsing '%s' should give you %s" s (pr-str expected)))) - (is-parsed? [expected s default-timezone-id] + (letfn [(message [expected s] + (format "parsing '%s' should give you %s" s (pr-str expected))) + + (is-parsed [expected s] {:pre [(string? s)]} (testing "ISO-8601-style literal" (is (= expected - (parse/parse s default-timezone-id)) - (message expected s default-timezone-id))) + (parse/parse s)) + (message expected s))) + (when (str/includes? s "T") (testing "SQL-style literal" (let [s (str/replace s #"T" " ")] (is (= expected - (parse/parse s default-timezone-id)) - (message expected s default-timezone-id)) + (parse/parse s)) + (message expected s)) + (when-let [[_ before-offset offset] (re-find #"(.*)((?:(?:[+-]\d{2}:\d{2})|Z).*$)" s)] (let [s (format "%s %s" before-offset offset)] (testing "w/ space before offset" (is (= expected - (parse/parse s default-timezone-id)) - (message expected s default-timezone-id)))))))))] + (parse/parse s)) + (message expected s)))))))))] (testing "literals without timezone" (doseq [[s expected] {"2019" (t/local-date 2019 1 1) @@ -43,23 +44,8 @@ "13:30" (t/local-time 13 30) "13:30:20" (t/local-time 13 30 20) "13:30:20.555" (t/local-time 13 30 20 (* 555 1000 1000))}] - (is-parsed? expected s nil))) - (testing "literals without timezone, but default timezone provided" - (doseq [[s expected] - {"2019" (t/zoned-date-time 2019 1 1 0 0 0 0 (t/zone-id "America/Los_Angeles")) - "2019-10" (t/zoned-date-time 2019 10 1 0 0 0 0 (t/zone-id "America/Los_Angeles")) - "2019-10-28" (t/zoned-date-time 2019 10 28 0 0 0 0 (t/zone-id "America/Los_Angeles")) - "2019-10-28T13" (t/zoned-date-time 2019 10 28 13 0 0 0 (t/zone-id "America/Los_Angeles")) - "2019-10-28T13:14" (t/zoned-date-time 2019 10 28 13 14 0 0 (t/zone-id "America/Los_Angeles")) - "2019-10-28T13:14:15" (t/zoned-date-time 2019 10 28 13 14 15 0 (t/zone-id "America/Los_Angeles")) - "2019-10-28T13:14:15.555" (t/zoned-date-time 2019 10 28 13 14 15 (* 555 1000000) (t/zone-id "America/Los_Angeles")) - ;; Times without timezone info should always be parsed as `LocalTime` regardless of whether a default - ;; timezone if provided. That's because we can't convert the zone to an offset because the offset is up - ;; in the air because of daylight savings. - "13:30" (t/local-time 13 30 0 0) - "13:30:20" (t/local-time 13 30 20 0) - "13:30:20.555" (t/local-time 13 30 20 (* 555 1000000))}] - (is-parsed? expected s "America/Los_Angeles"))) + (is-parsed expected s))) + (testing "literals with a timezone offset" (doseq [[s expected] {"2019-10-28-07:00" (t/offset-date-time 2019 10 28 0 0 0 0 (t/zone-offset -7)) @@ -70,8 +56,8 @@ "13:30-07:00" (t/offset-time 13 30 0 0 (t/zone-offset -7)) "13:30:20-07:00" (t/offset-time 13 30 20 0 (t/zone-offset -7)) "13:30:20.555-07:00" (t/offset-time 13 30 20 (* 555 1000000) (t/zone-offset -7))}] - ;; The 'UTC' default timezone ID should be ignored entirely since all these literals specify their offset - (is-parsed? expected s "UTC"))) + (is-parsed expected s))) + (testing "literals with a timezone id" (doseq [[s expected] {"2019-12-13T16:31:00-08:00[US/Pacific]" (t/zoned-date-time 2019 12 13 16 31 0 0 (t/zone-id "US/Pacific")) "2019-10-28-07:00[America/Los_Angeles]" (t/zoned-date-time 2019 10 28 0 0 0 0 (t/zone-id "America/Los_Angeles")) @@ -82,8 +68,8 @@ "13:30-07:00[America/Los_Angeles]" (t/offset-time 13 30 0 0 (t/zone-offset -7)) "13:30:20-07:00[America/Los_Angeles]" (t/offset-time 13 30 20 0 (t/zone-offset -7)) "13:30:20.555-07:00[America/Los_Angeles]" (t/offset-time 13 30 20 (* 555 1000000) (t/zone-offset -7))}] - ;; The 'UTC' default timezone ID should be ignored entirely since all these literals specify their zone ID - (is-parsed? expected s "UTC"))) + (is-parsed expected s))) + (testing "literals with UTC offset 'Z'" (doseq [[s expected] {"2019Z" (t/zoned-date-time 2019 1 1 0 0 0 0 (t/zone-id "UTC")) "2019-10Z" (t/zoned-date-time 2019 10 1 0 0 0 0 (t/zone-id "UTC")) @@ -95,9 +81,8 @@ "13:30Z" (t/offset-time 13 30 0 0 (t/zone-offset 0)) "13:30:20Z" (t/offset-time 13 30 20 0 (t/zone-offset 0)) "13:30:20.555Z" (t/offset-time 13 30 20 (* 555 1000000) (t/zone-offset 0))}] - ;; default timezone ID should be ignored; because `Z` means UTC we should return ZonedDateTimes instead of - ;; OffsetDateTime - (is-parsed? expected s "US/Pacific")))) + (is-parsed expected s)))) + (testing "Weird formats" (testing "Should be able to parse SQL-style literals where Zone offset is separated by a space, with no colons between hour and minute" (is (= (t/offset-date-time "2014-08-01T10:00-07:00") @@ -106,11 +91,13 @@ (parse/parse "2014-08-01 10:00:00 -0700"))) (is (= (t/offset-date-time "2014-08-01T10:00-07:00") (parse/parse "2014-08-01 10:00 -0700")))) + (testing "Should be able to parse SQL-style literals where Zone ID is separated by a space, without brackets" (is (= (t/zoned-date-time "2014-08-01T10:00Z[UTC]") (parse/parse "2014-08-01 10:00:00.000 UTC"))) (is (= (t/zoned-date-time "2014-08-02T00:00+08:00[Asia/Hong_Kong]") (parse/parse "2014-08-02 00:00:00.000 Asia/Hong_Kong")))) + (testing "Should be able to parse strings with hour-only offsets e.g. '+00'" (is (= (t/offset-time "07:23:18.331Z") (parse/parse "07:23:18.331-00"))) @@ -124,12 +111,14 @@ (parse/parse "07:23:18-08"))) (is (= (t/offset-time "07:23:00.000-08:00") (parse/parse "07:23-08"))))) + (testing "nil" (is (= nil (parse/parse nil)) "Passing `nil` should return `nil`")) + (testing "blank strings" (is (= nil - (parse/parse "")) - (= nil + (parse/parse ""))) + (is (= nil (parse/parse " ")))))))