Today I think I’ll double-check that double-decrement issue. Then I want to see if we can combine those two parallel if nests.

My cunning plan is to copy the failing unit test, then revert the code, put in that test, and see if it fails. As we stand:

  def test_past_due_brie_increases_by_1
    gr = GildedRose.new([])
    brie = Item.new("Aged Brie", sell_in=0, quality=40)
    gr.update_item(brie)
    assert_equal(41, brie.quality)
  end

    1) Failure:
TestGildedRose#test_past_due_brie_increases_by_1 [/Users/ron/Dropbox/Ruby/gilded-rose-1/test-gilded-rose.rb:54]:
Expected: 41
  Actual: 42

Now to revert and test again. I’ve chosen this commit to revert to:

commit 01a059f82c22e52a3c4041dc9a30cace528ca437
Author: Ronald Jeffries <ronjeffries@acm.org>
Date:   Tue Apr 21 12:49:04 2020 -0400

    Added OriginalWrapper and used it

Check out, paste in the test, run it … and it fails! Perfect! We have duplicated the original behavior. Now to check out our latest version and get down to real work.

First, change the above test to expect 42, which is the correct value (obviously). Tests all run. Sweet.

Now I see the parallelism between the following two methods, and I’d like to remove it:

  def update
    return if sulfuras?
    if brie?
      update_brie
    elsif tickets?
      update_ticket
    else
      decrement_quality_down_to_zero
    end
    @item.sell_in = @item.sell_in - 1
    handle_expired_items
  end

  def handle_expired_items
    return if ! expired?
    if brie?
      increment_quality_up_to_50
    elsif tickets?
      set_quality_zero
    else
      decrement_quality_down_to_zero
    end
  end

Were it not for the adjustment to sell_in, we could just merge the ifs. It’s clear, I hope, that handle_expired_items` needs the date to be updated, so that the check for expired is valid. (That check also makes me a bit leery of the merge I have in mind, but I think if we can do it we’ll like the result.)

Could we move the adjustment of sell_in before the if nest? I guess the easiest thing is to try it and see what fails.

  def update
    return if sulfuras?
    @item.sell_in = @item.sell_in - 1
    if brie?
      update_brie
    elsif tickets?
      update_ticket
    else
      decrement_quality_down_to_zero
    end
    handle_expired_items
  end

Tests still run. If I were truly confident in the tests, I’d go ahead right now. Since I’m not (yes, I hear you, mutation-test supporters), I’m going to inspect the code in the update as well. In this case it’ll only take a moment, but in a real situation, we’d really want to be more confident in the tests.

Yes, this is troubling. Yes, it’s also a taste of reality.

Here’s the code of concern:

  def update_brie
    increment_quality_up_to_50
  end

  def update_ticket
    increment_quality_up_to_50
    if @item.sell_in < 11
      increment_quality_up_to_50
    end
    if @item.sell_in < 6
      increment_quality_up_to_50
    end
  end

Brie is obviously OK. the ticket update depends on sell_in and is concerning. I think we want to write a test. Here’s why. The way this code “works” is that ticket quality will be incremented once if sell_in is less than 11 and is 6 or more, that is, 10,9,8,7, or 6. So as originally written, a ticket should not increment on day 6 going in, but after this change, since we’ll have decremented the date, it should update. I’ll move the date decrement back and write a test.

Hmm. This test runs correctly, and I don’t think it should:

  def test_ticket_does_not_increase_on_day_6
    gr = GildedRose.new([])
    tick = Item.new("Backstage passes to a TAFKAL80ETC concert", sell_in=11, quality=40)
    gr.update_item(tick)
    assert_equal(41, tick.quality)
    gr.update_item(tick)
    assert_equal(43, tick.quality)
  end

It seems to me that on the first update, sell_in should be 10, which is less than 11, and therefore I should get a second increment to quality. Then, surely on the next day, I should get two more. But on the first update I get only one tick. Why?

Ah, I see. The update_ticket is called before the date is incremented. So the decision to bump quality is something like “after the day expires”, not “when the day starts”. This is a bit confusing to me. Maybe more than a bit.

Let’s look at the text test. The input is:

  Item.new(name="Backstage passes to a TAFKAL80ETC concert", sell_in=15, quality=20),
  Item.new(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=49),
  Item.new(name="Backstage passes to a TAFKAL80ETC concert", sell_in=5, quality=49),

Looking at those, I expect the second two tickets to get extra value. I expect the 10 day one go to from 49 to 51, and the 5 day on to go from 49 to 52.

But that’s not what the golden master says:

Backstage passes to a TAFKAL80ETC concert, 15, 20
Backstage passes to a TAFKAL80ETC concert, 10, 49
Backstage passes to a TAFKAL80ETC concert, 5, 49
...
Backstage passes to a TAFKAL80ETC concert, 14, 21
Backstage passes to a TAFKAL80ETC concert, 9, 50
Backstage passes to a TAFKAL80ETC concert, 4, 50

What’s up with that? The spec clearly says “10 days or less” and “5 days or less”.

Have I saved a bad golden master without noticing? Let’s look all the way back in history … and no, those values are the values in the very first run of the tests.

OH!! Those tests max at 50, so they don’t even test the special values. And we’ve already noticed that the way the text test works is to output the starting values and then the ending values for all the days it increments.

Whew. Our test will be the first real test for tickets. Let’s review it and be sure we’re good.

I paused right here to get a bowl of cereal. And, you know, I’m really kind of ticked off at this situation we find ourselves in. We have some questionable behavior that we’ve discovered, first the question of whether Brie increases its quality by 2 after expiry. The code says yes, the spec is unclear.

Now, we have this interesting behavior of the tickets, where they get their extra value after day ten and five, not on day ten or five. Here, I think the spec rather clearly has it the other way around. Let’s finish these tests and then I want to steam some more.

My first test has been elaborated thus:

  def test_ticket_does_not_increase_on_day_10_but_after_10
    gr = GildedRose.new([])
    tick = Item.new("Backstage passes to a TAFKAL80ETC concert", sell_in=11, quality=40)
    gr.update_item(tick)
    assert_equal(10, tick.sell_in)
    assert_equal(41, tick.quality)
    gr.update_item(tick)
    assert_equal( 9, tick.sell_in)
    assert_equal(43, tick.quality)
  end

This runs green. Now for another, testing day 5. Same pattern.

  def test_ticket_does_not_increase_on_day_5_but_after_5
    gr = GildedRose.new([])
    tick = Item.new("Backstage passes to a TAFKAL80ETC concert", sell_in=6, quality=40)
    gr.update_item(tick)
    assert_equal(5, tick.sell_in)
    assert_equal(42, tick.quality)
    gr.update_item(tick)
    assert_equal( 4, tick.sell_in)
    assert_equal(45, tick.quality)
  end

OK. This rather clearly documents what the code actually does, and we’ll have to check with Allison on whether it is what she wants.

Now I can try moving the date up and I’m sure it won’t work if that’s all I do.

Sure enough, both those tests fail as expected, ticking a day early.

My theory that I could combine those parallel ifs is in question, but it should be “obvious” that we can adjust the update_ticket code to make the tests run as they did before. (If we leave it this way it probably does what Allison said, but we can’t change behavior without her permission or request.)

If I do this:

  def update_ticket
    increment_quality_up_to_50
    if @item.sell_in < 10
      increment_quality_up_to_50
    end
    if @item.sell_in < 5
      increment_quality_up_to_50
    end
  end

I expect the tests to run with the date decrement moved up.

And they do. Now we can move the elements of the if block up if we wish. It would look like this:

  def update
    return if sulfuras?
    @item.sell_in = @item.sell_in - 1
    if brie?
      update_brie
      increment_quality_up_to_50 if expired?
    elsif tickets?
      update_ticket
      set_quality_zero if expired?
    else
      decrement_quality_down_to_zero
      decrement_quality_down_to_zero if expired?
    end
  end

The tests now run. All of them. We can delete the handle_expired_items method entirely.

Look at what we have accomplished. There are a few helper methods, but our update has gone from 48 very intricate lines to 14 straightforward ones. But we’re not done.

We now have a bit of oddness inside the ifs. We could expand update_brie to make that block be at a consistent level, but expanding update_ticket is more messy because of that date stuff. Instead, let’s fold our conditional lines inside those two methods:

  def update
    return if sulfuras?
    @item.sell_in = @item.sell_in - 1
    if brie?
      update_brie
    elsif tickets?
      update_ticket
    else
      decrement_quality_down_to_zero
      decrement_quality_down_to_zero if expired?
    end
  end

  def update_brie
    increment_quality_up_to_50
    increment_quality_up_to_50 if expired?
  end

  def update_ticket
    increment_quality_up_to_50
    if @item.sell_in < 10
      increment_quality_up_to_50
    end
    if @item.sell_in < 5
      increment_quality_up_to_50
    end
    set_quality_zero if expired?
  end

And yes, the tests all run. The update_ticket method is a bit odd, because it will increment quality and then clobber it if the ticket is expired. The update_brie seems nearly sensible.

For now, I’m going to leave things here. It’s a sunny day, the cat’s on deck, oops, no, she just came on off the deck, anyway, I’m calling the code done for today. Time to double check the tests and commit.

Summing up

This has been an interesting ride. We’ve refactored the original hideous method from 48 lines down to 12, with some mostly-sensible helper methods. The new structure handles the special cases explicitly and individually. I think I could maintain this version, even if I didn’t go to the trouble of creating the new classes for new products.

There are still questions about whether it’s doing what Allison wants, and there are still improvements that could be made to this code, some of which we might agree on and some of which not. But my pair partner, Kitty, when asked if she’s OK with the code, does not object.

The experiment with “just refactoring” the messy method went amazingly well, doing what “Read by Refactoring” meant to me. (Thanks again, Dave. I must remember to read what you write, and maybe look for a talk by Arlo.) It felt like taking small and obvious steps, over and over, each one making things a little better. I like where we’ve come to.

Testing has been a concern. What “should” I have done? Well, I’d have felt less uncertain in a few places had I written more tests. I have been assured that if I installed a mutation testing tool and used it, I’d discover leaks in my tests and be motivated to improve them.

Both of those would probably be good things to do. Adding a new tool is a big deal to me: I don’t like doing it unless I’m pairing with someone who already knows how to do it. And writing tests is SO boring. So, often, like this time, I do less than might be ideal.

Does that mean I “should” do more tests? I don’t know. Every day we make decisions about how to spend our limited time, and every now and then we do well to look back, then look forward to how we might want to change in the future.

Right now, I’m happy with the results. Had you been here, I’m sure we’d have done something different, probably something even better. But you weren’t here, and Kitty and I are satisfied.

I’m not sure if this is the end of the Gilded Rose series or not. If it isn’t, it’s pretty close.

Here’s the code:


# Gilded Rose

require "./wrappers"

class GildedRose

  def initialize(items)
    @items = items
  end

  def match_name(item,name)
    return item.name.downcase.include? name.downcase
  end

  def appropriate_wrapper_class(item)
    return ConjuredWrapper if match_name(item, "conjured")
    return OriginalWrapper
  end

  def update_item(item)
    wrapper_class = appropriate_wrapper_class(item)
    wrapped_item = wrapper_class.new(item)
    wrapped_item.update
  end

  def update_quality()
    @items.each do |item|
      update_item(item)
    end
  end
end # Gilded_Rose

class Item
  attr_accessor :name, :sell_in, :quality

  def initialize(name, sell_in, quality)
    @name = name
    @sell_in = sell_in
    @quality = quality
  end

  def to_s()
    "#{@name}, #{@sell_in}, #{@quality}"
  end
end

# wrapper classes

class ConjuredWrapper
  def initialize(item)
    @item = item
  end

  def conjured_degradation
    normal_degradation = 1
    if @item.sell_in < 0
      normal_degradation = 2*normal_degradation
    end
    conjured_degradation = 2*normal_degradation
    return conjured_degradation
  end

  def update
    @item.sell_in = @item.sell_in - 1
    @item.quality = [0,@item.quality - conjured_degradation].max
  end

end

class OriginalWrapper
  def initialize(item)
    @item = item
  end

  def update
    return if sulfuras?
    @item.sell_in = @item.sell_in - 1
    if brie?
      update_brie
    elsif tickets?
      update_ticket
    else
      decrement_quality_down_to_zero
      decrement_quality_down_to_zero if expired?
    end
  end

  def decrement_quality_down_to_zero
    @item.quality = @item.quality - 1 if @item.quality > 0
  end

  def sulfuras?
    @item.name == "Sulfuras, Hand of Ragnaros"
  end

  def update_brie
    increment_quality_up_to_50
    increment_quality_up_to_50 if expired?
  end

  def update_ticket
    increment_quality_up_to_50
    if @item.sell_in < 10
      increment_quality_up_to_50
    end
    if @item.sell_in < 5
      increment_quality_up_to_50
    end
    set_quality_zero if expired?
  end

  def tickets?
    @item.name == "Backstage passes to a TAFKAL80ETC concert"
  end

  def brie?
    @item.name == "Aged Brie"
  end

  def set_quality_zero
    @item.quality = 0
  end

  def expired?
    @item.sell_in < 0
  end

  def increment_quality_up_to_50
      @item.quality += 1 unless @item.quality >= 50
  end

end

#test-gided-rose.rb

require "./gilded-rose"
require "minitest/autorun"

class TestGildedRose<Minitest::Test

  def test_conjured
    gr = GildedRose.new([])
    item = Item.new("Conjured thing", sell_in=3, quality=10)
    gr.update_item(item)
    assert_equal(2, item.sell_in)
    assert_equal(8, item.quality)
  end

  def test_conjured_quality_non_negative
    gr = GildedRose.new([])
    item = Item.new("low-value conjured item", sell_in=2, quality=2)
    gr.update_item(item)
    assert_equal(1, item.sell_in)
    assert_equal(0, item.quality)
    gr.update_item(item)
    assert_equal(0, item.sell_in)
    assert_equal(0, item.quality)
  end

  def test_conjured_sell_in_negative
    gr = GildedRose.new([])
    item = Item.new("another conjured item", sell_in=0, quality=2)
    gr.update_item(item)
    assert_equal(-1, item.sell_in)
  end

  def test_conjured_double_dip_past_sell_in
    gr = GildedRose.new([])
    item = Item.new("double-dip conjured item", sell_in=0, quality=10)
    gr.update_item(item)
    assert_equal(6, item.quality)
  end

  def test_brie_quality_increases_to_50
    gr = GildedRose.new([])
    brie = Item.new("Aged Brie", sell_in=0, quality=49)
    gr.update_item(brie)
    assert_equal(50, brie.quality)
    gr.update_item(brie)
    assert_equal(50, brie.quality)
  end

  def test_past_due_brie_increases_by_1
    gr = GildedRose.new([])
    brie = Item.new("Aged Brie", sell_in=0, quality=40)
    gr.update_item(brie)
    assert_equal(42, brie.quality)
  end

  def test_ticket_does_not_increase_on_day_10_but_after_10
    gr = GildedRose.new([])
    tick = Item.new("Backstage passes to a TAFKAL80ETC concert", sell_in=11, quality=40)
    gr.update_item(tick)
    assert_equal(10, tick.sell_in)
    assert_equal(41, tick.quality)
    gr.update_item(tick)
    assert_equal( 9, tick.sell_in)
    assert_equal(43, tick.quality)
  end

  def test_ticket_does_not_increase_on_day_5_but_after_5
    gr = GildedRose.new([])
    tick = Item.new("Backstage passes to a TAFKAL80ETC concert", sell_in=6, quality=40)
    gr.update_item(tick)
    assert_equal(5, tick.sell_in)
    assert_equal(42, tick.quality)
    gr.update_item(tick)
    assert_equal( 4, tick.sell_in)
    assert_equal(45, tick.quality)
  end

  def test_text
    expected = "-------- day 0 --------
name, sellIn, quality
+5 Dexterity Vest, 10, 20
Aged Brie, 2, 0
Elixir of the Mongoose, 5, 7
Sulfuras, Hand of Ragnaros, 0, 80
Sulfuras, Hand of Ragnaros, -1, 80
Backstage passes to a TAFKAL80ETC concert, 15, 20
Backstage passes to a TAFKAL80ETC concert, 10, 49
Backstage passes to a TAFKAL80ETC concert, 5, 49
Conjured Mana Cake, 3, 6
Conjured Voodoo Doll, 0, 1
+1 Sword: Assbiter, 0, 20
conjured double dipper, 0, 10

-------- day 1 --------
name, sellIn, quality
+5 Dexterity Vest, 9, 19
Aged Brie, 1, 1
Elixir of the Mongoose, 4, 6
Sulfuras, Hand of Ragnaros, 0, 80
Sulfuras, Hand of Ragnaros, -1, 80
Backstage passes to a TAFKAL80ETC concert, 14, 21
Backstage passes to a TAFKAL80ETC concert, 9, 50
Backstage passes to a TAFKAL80ETC concert, 4, 50
Conjured Mana Cake, 2, 4
Conjured Voodoo Doll, -1, 0
+1 Sword: Assbiter, -1, 18
conjured double dipper, -1, 6

"

    items = [
      Item.new(name="+5 Dexterity Vest", sell_in=10, quality=20),
      Item.new(name="Aged Brie", sell_in=2, quality=0),
      Item.new(name="Elixir of the Mongoose", sell_in=5, quality=7),
      Item.new(name="Sulfuras, Hand of Ragnaros", sell_in=0, quality=80),
      Item.new(name="Sulfuras, Hand of Ragnaros", sell_in=-1, quality=80),
      Item.new(name="Backstage passes to a TAFKAL80ETC concert", sell_in=15, quality=20),
      Item.new(name="Backstage passes to a TAFKAL80ETC concert", sell_in=10, quality=49),
      Item.new(name="Backstage passes to a TAFKAL80ETC concert", sell_in=5, quality=49),
      # This Conjured item does not work properly yet
      Item.new(name="Conjured Mana Cake", sell_in=3, quality=6),
      Item.new(name="Conjured Voodoo Doll", sell_in=0, quality=1),
      Item.new(name="+1 Sword: Assbiter", sell_in=0, quality=20),
      Item.new(name="conjured double dipper", sell_in=0, quality = 10)
    ]

    days = 2
    # if ARGV.size > 0
    #   days = ARGV[0].to_i + 1
    # end

    actual = StringIO.new
    gilded_rose = GildedRose.new items
    (0...days).each do |day|
      actual.puts "-------- day #{day} --------"
      actual.puts "name, sellIn, quality"
      items.each do |item|
        actual.puts item
      end
      actual.puts ""
      gilded_rose.update_quality
    end

    assert_equal(expected, actual.string)
    # if actual.string != expected
    #   puts actual.string
    # end
  end

end