I recently read Mike Jansen's 8th Light blog post on The Gilded Rose kata in Clojure and thought I'd give it a crack myself.
First was to get a set of tests setup. The kata is already setup to use the speclj testing library. I'd only used the very fine Midje library for testing in Clojure before, but I thought I'd stick with what was already in the project.
Both allow for very easy and straightforward testing, and both support automatic test running when files change. This makes for a really tight feedback loop when making any changes.
I tried as much as possible to just translate the requirements into a suite of tests without getting bogged down in implementation details. Here it is after the refactoring.
(ns gilded-rose.core-spec | |
(:require [speclj.core :refer :all] | |
[gilded-rose.core :as gr])) | |
(defn update-quality-n-days | |
[n items] | |
(reduce #(%2 %1) items (repeat n gr/update-quality))) | |
(defn items-have-key? | |
[items key] | |
(every? (comp not nil? key) items)) | |
(defn update-quality-decs-value? | |
[item key] | |
(= (dec (key item)) (key (first (gr/update-quality [item]))))) | |
(describe "gilded rose" | |
(it "should have a sell-in value for every inventory item" | |
(should (items-have-key? (gr/update-current-inventory) :sell-in))) | |
(it "should have a quality value for every inventory item" | |
(should (items-have-key? (gr/update-current-inventory) :quality))) | |
(it "should lower sell-in and quality for standard items" | |
(should (update-quality-decs-value? (first gr/inventory) :sell-in)) | |
(should (update-quality-decs-value? (first gr/inventory) :quality))) | |
(it "should degrade quality twice as fast once sell in has passed 0" | |
(let [sell-in-zero-items (update-quality-n-days 10 gr/inventory) | |
item (first sell-in-zero-items)] | |
(should (zero? (:sell-in item))) | |
(should= (-> item :quality (- 2)) (-> [item] gr/update-quality first :quality)))) | |
(it "never lowers quality below zero" | |
(should (every? (comp not neg? :quality) (update-quality-n-days 20 gr/inventory)))) | |
(it "increases aged brie quality with age" | |
(let [brie (second gr/inventory)] | |
(should= (-> brie :quality inc) (-> [brie] gr/update-quality first :quality)))) | |
(it "should never incease quality of increasing quality items over 50" | |
(let [non-legendary [(gr/inventory 2) (gr/inventory 4)]] | |
(should (every? (comp (partial >= 50) :quality) (update-quality-n-days 15 non-legendary))))) | |
(it "should not change the quality or sell-in of legendary items" | |
(should= (:quality (gr/inventory 3)) (-> gr/inventory gr/update-quality (nth 3) :quality)) | |
(should= (:sell-in (gr/inventory 3)) (-> gr/inventory gr/update-quality (nth 3) :sell-in))) | |
(it "improves the quality of concert tickets at double rate below 10 days" | |
(let [item (gr/with-fns (gr/item gr/back-passes 10 10) gr/standard-sell-in gr/concert-quality)] | |
(should= 12 (:quality (first (gr/update-quality [item])))))) | |
(it "improves the quality of concert tickets at triple rate below 5 days" | |
(let [item (gr/with-fns (gr/item gr/back-passes 5 10) gr/standard-sell-in gr/concert-quality)] | |
(should= 13 (:quality (first (gr/update-quality [item])))))) | |
(it "reduces the quality of concert tickets to 0 once over" | |
(let [item (gr/with-fns (gr/item gr/back-passes 0 10) gr/standard-sell-in gr/concert-quality)] | |
(should= 0 (:quality (first (gr/update-quality [item])))))) | |
(it "reduces quality of conjured items at double rate" | |
(let [item (last gr/inventory)] | |
(should= 8 (:quality (first (gr/update-quality [item])))))) | |
) | |
Not very much changed between initially writing the tests and refactoring the main code, just a few bits here and there to match changes made in the core file.
Refactoring the main code basically comprised of splitting each of the different behaviours spread around update-quality into their own functions which could be associated with different items in a declarative fashion. The update-quality function then just became a matter of mapping these functions over the items.
The restriction on being unable to change the item function made this approach a little more convoluted, but best not to enrage the goblin.
Here's the refactored core file.
(ns gilded-rose.core) | |
(def dex-vest "+5 Dexterity Vest") | |
(def aged-brie "Aged Brie") | |
(def elixir-mong "Elixir of the Mongoose") | |
(def sulfuras "Sulfuras, Hand of Ragnaros") | |
(def back-passes "Backstage passes to a TAFKAL80ETC concert") | |
(def conjured "Conjured frog") | |
(def minimum-quality 0) | |
(def maximum-quality 50) | |
(defn standard-sell-in | |
[item] | |
(assoc item :sell-in (dec (:sell-in item)))) | |
(defn inc-quality | |
[inc-by item] | |
(assoc item :quality (min maximum-quality (+ (:quality item) inc-by)))) | |
(defn dec-quality | |
[dec-by item] | |
(assoc item :quality (max minimum-quality (- (:quality item) dec-by)))) | |
(defn kill-quality | |
[item] | |
(assoc item :quality 0)) | |
(defn standard-quality | |
[item] | |
(if (< (:sell-in item) 0) | |
(dec-quality 2 item) | |
(dec-quality 1 item))) | |
(defn conjured-quality | |
[item] | |
(dec-quality 2 item)) | |
(defn maturing-quality | |
[item] | |
(inc-quality 1 item)) | |
(defn concert-quality | |
[item] | |
(let [sell-in (:sell-in item)] | |
(cond | |
(< sell-in 0) | |
(assoc item :quality 0) | |
(contains? #{0 1 2 3 4} sell-in) | |
(inc-quality 3 item) | |
(contains? #{5 6 7 8 9} sell-in) | |
(inc-quality 2 item) | |
:else | |
(inc-quality 1 item)))) | |
(defn update-quality | |
[items] | |
(map #((:quality-fn %) %) (map #((:sell-in-fn %) %) items))) | |
(defn item [item-name, sell-in, quality] | |
{:name item-name, :sell-in sell-in, :quality quality}) | |
(defn with-fns | |
[item sell-in-fn quality-fn] | |
(merge item {:sell-in-fn sell-in-fn :quality-fn quality-fn})) | |
(def inventory | |
[(with-fns (item dex-vest 10 20) standard-sell-in standard-quality) | |
(with-fns (item aged-brie 2 0) standard-sell-in maturing-quality) | |
(with-fns (item elixir-mong 5 7) standard-sell-in standard-quality) | |
(with-fns (item sulfuras 0 80) identity identity) | |
(with-fns (item back-passes 15 20) standard-sell-in concert-quality) | |
(with-fns (item conjured 4 10) standard-sell-in conjured-quality)]) | |
(defn update-current-inventory | |
[] | |
(update-quality inventory)) |
There's bits I'd like to tweak, but I also wanted to time box it within a reasonable range. One thing I would like to do would be to try core.match on some of the functions; particularly the one relating to concert ticket quality.