Reputation: 5567
I want to write a clojure spec for a hash-map wherein the value of one of the keys is constrained to be equal to the sum of the values of two other keys. I know one way to write a test generator for such a spec by hand:
(ns my-domain)
(require '[clojure.test :refer :all ]
'[clojure.spec.alpha :as s ]
'[clojure.spec.gen.alpha :as gen ]
'[clojure.pprint :refer (pprint) ])
(s/def ::station-id string?)
(s/def ::sim-time (s/double-in :infinite? true, :NaN? false))
(s/def ::reserved-counts (s/and int? #(not (neg? %))))
(s/def ::free-counts (s/and int? #(not (neg? %))))
(def counts-preimage (s/gen (s/keys :req [::station-id
::sim-time
::reserved-counts
::free-counts])))
(pprint (gen/generate
(gen/bind
counts-preimage
#(gen/return
(into % {::total-counts
(+ (::reserved-counts %)
(::free-counts %))})))))
#:my-domain{:station-id "sHN8Ce0tKWSdXmRd4e46fB", :sim-time -3.4619293212890625, :reserved-counts 58, :free-counts 194, :total-counts 252}
But I haven't figured out how to write a spec for it, let alone a spec that
produces a similar generator. The gist of the problem is that I lack, in the space of specs, a way to
get hold of the "preimage" in the spec, that is, I lack an analogue to bind
from the space of generators. Here is a failed attempt:
(s/def ::counts-partial-hash-map
(s/keys :req [::station-id
::sim-time
::reserved-counts
::free-counts]))
(s/def ::counts-attempted-hash-map
(s/and ::counts-partial-hash-map
#(into % {::total-counts (+ (::reserved-counts %)
(::free-counts %))})))
(pprint (gen/generate (s/gen ::counts-attempted-hash-map)))
#:my-domain{:station-id "ls5qBUoF", :sim-time ##Inf, :reserved-counts 56797960, :free-counts 17}
The generated sample conforms to the spec because #(into % {...})
is truthy,
but the result doesn't contain the new attribute with the key ::total-counts
.
I'd be grateful for any guidance.
EDIT: Today I Learned about s/with-gen
, which will allow me to attach
my (working) test generator to my "preimage" or "partial" spec. Perhaps
that's the best way forward?
Upvotes: 2
Views: 538
Reputation: 16194
You could use the nat-int?
predicate (for which there's a built-in spec, thanks @glts) for the count keys, and add a ::total-counts
spec too:
(s/def ::reserved-counts nat-int?)
(s/def ::free-counts nat-int?)
(s/def ::total-counts nat-int?)
(s/def ::counts-partial-hash-map
(s/keys :req [::station-id
::sim-time
::reserved-counts
::free-counts]))
spec for a hash-map wherein the value of one of the keys is constrained to be equal to the sum of the values of two other keys
To add this assertion you can s/and
a predicate function with the keys
spec (or in this example the merge
spec that merges the partial map spec with a ::total-count
keys spec):
(s/def ::counts-attempted-hash-map
(s/with-gen
;; keys spec + sum-check predicate
(s/and
(s/merge ::counts-partial-hash-map (s/keys :req [::total-counts]))
#(= (::total-counts %) (+ (::reserved-counts %) (::free-counts %))))
;; custom generator
#(gen/fmap
(fn [m]
(assoc m ::total-counts (+ (::reserved-counts m) (::free-counts m))))
(s/gen ::counts-partial-hash-map))))
This also uses with-gen
to associate a custom generator with the spec that sets ::total-count
to the sum of the other count keys.
(gen/sample (s/gen ::counts-attempted-hash-map) 1)
=> (#:user{:station-id "", :sim-time 0.5, :reserved-counts 1, :free-counts 1, :total-counts 2})
The generated sample conforms to the spec because
#(into % {...})
is truthy, but the result doesn't contain the new attribute with the key::total-counts
.
I'd recommend against using specs to calculate/add ::total-counts
to the map. Specs generally shouldn't be used for data transformation.
Upvotes: 3