I got this refactor challenge for a simple ruby script that has 3 objects and produces a statement. In effect we have a Movie object, a Rental object and a Customer object. The purpose of the script is to print out a statement with the customer’s name, order, amount due and frequent renter points.
The code that I received for refactoring has syntax and other errors, which after a quick email exchange I found out where put there intentionally. So it’s more of a fix the code challenge rather than a refactor job, but I did both. Underneath you’ll see the snippets of the code I got:
class Movie
CHILDRENS_MOVIE = 2
REGULAR = 0
NEW_RELEASE = 1
attr_reader :title_for_movie
attr_accessor :price_code
def initialize(title_for_movie, price_code)
@title_for_movie, @price_code = title_for_movie, price_code
end
end
class Rental
attr_reader :movie, :days_rented
def initialize(movie, days_rented)
@movie, @days_rented = movie, days_rented
end
end
And the problem object
class CustomerClass
attr_reader :customer_name
def initialize(customer_name)
@customer_name = customer_name
@rentals ||= []
end
def add_rental_object_to_list(arg)
x = 7
@rentals << arg
end
def statement
total_amount, freqent_renter_points = 0, 0
result = "Rental Record for #{@customer_name}\n"
@rentals.each do |_element|
this_amount = 0
# determine amounts for each line
case _element.movie.price_code
when Movie::REGULAR
this_amount += 2; this_amount += (_element.days_rented - 2) * 1.5 if _element.days_rented > 2
when Movie::NEW_RELEASE
this_amount += _element.days_rented * 3
when Movie::CHILDRENS_MOVIE
this_amount += 1.5; this_amount += (_element.days_rented - 3) * 1.5 if _element.days_rented > 3
end
# add frequent renter points
freqent_renter_points += 1
# add bonus for a two day new release rental
freqent_renter_points += 1 if _element.movie.price_code == Movie.NEW_RELEASE && _element.days_rented > 1
# show figures for this rental
result += "\t" + _element.movie.title_for_movie + "\t" + this_amount.to_s + "\n"
total_amount += this_amount
end
# add footer lines
result += "Amount owed is #{total_amount.to_s}\n"
result += "You earned #{freqent_renter_points.to_s} frequent renter points"
result
end
end
At first glance we can see that we are dealing with a renting operation in which a customer chooses one or more movies to rent and gets billed accordingly.
We can see from a glance that the customer class is pretty much a mess. We have an unnecessary variable (x = 7) , we have bad variable and method names “add_rental_object_to_list(arg)” , an improper constant call “Movie.NEW_RELEASE” , a couple of typos and worst still:
# We have code comments # Now for those of you that are new to my blog # or are simply curious as to why I am so opposed to comments # the reason is twofold and boils down to this: # # The existence of code comments is proof of hard to understand code # and hard to understand code logic. # If you need to add comments to your code # to make it readable, chances are you need to refactor more. # # And secondly, I have yet to meet a developer that once tasked with # making changes to commented code will take the time to # improve and change the comments so that they now reflect the new # code reality. So what happens is that by the time the third developer # comes up to the code, the comments say one story because they are outdated # and the code tells a whole other story.
Now, let’s move on to how I made the code, and look under the hood a bit
class Movie
CHILDRENS_MOVIE = 2
REGULAR = 0
NEW_RELEASE = 1
attr_reader :title_for_movie
attr_accessor :price_code
def initialize(title_for_movie: title_for_movie, price_code: price_code)
@title_for_movie = title_for_movie
@price_code = price_code
end
end
class Rental
attr_accessor :movie, :days_rented
def initialize(movie: movie, days_rented: days_rented)
@movie = movie
@days_rented = days_rented
end
end
class Customer
attr_accessor :customer_name
def initialize(customer_name: customer_name)
@customer_name = customer_name
end
end
Now you must be wondering ‘what happened to all the code logic from the customer class?’
Well I extracted out all that business logic and placed it all inside an Interactor. I did this because all business logic should be extracted and separated from ruby/rails logic. I like to keep my public objects as plain and as dumb as humanly possible. I guess you have heard of the paradigm, ‘slim model fat controller’ or others put it as ‘slim controller fat model’. I for one prefer to keep all my main public objects as slim and as dumb as possible.
I know what you’re thinking, ‘model? controller? what does rails logic have to do with a ruby script?’ and you’d be right. But I am putting out an idea. and as I’ve said it before in a previous post the business logic of your app should be extracted and tested separately. This has many advantages including: a more modular design, easier to read code, easier to extend and maintain, easier to refactor etc.
So first I’ll put the tests
require 'spec_helper'
require_relative '../interactors/set_price_and_bonus_points'
require_relative '../bin/rental'
require_relative '../bin/movie'
require_relative '../bin/customer'
RSpec.describe Interactors::SetPriceAndBonusPoints do
let(:new_movie) {Movie.new(title_for_movie: "Jaws", price_code: Movie::NEW_RELEASE) }
let(:kids_movie) {Movie.new(title_for_movie: "Incredibles 2", price_code: Movie::CHILDRENS_MOVIE)}
let(:regular_movie) {Movie.new(title_for_movie: "Avengers", price_code: Movie::REGULAR)}
let(:new_rental) {Rental.new(movie: new_movie, days_rented: 2)}
let(:kids_rental) {Rental.new(movie: kids_movie, days_rented: 4)}
let(:regular_rental) {Rental.new(movie: regular_movie, days_rented: 2)}
let(:customer) {Customer.new(customer_name: "Robert")}
describe 'calculates and prints out a statement' do
it 'returns 0 amount and 0 bonus points if no movie is rented' do
expect(Interactors::SetPriceAndBonusPoints.call([], customer)).to eq "Rental record for Robert\nAmount owed is $0\nYou earned 0 frequent renter points"
end
it 'can calculate a price and bonus points awarded for a new release movie rented for 2 days' do
expect(Interactors::SetPriceAndBonusPoints.call([new_rental], customer)).to eq "Rental record for Robert\nAmount owed is $6\nYou earned 2 frequent renter points"
end
it 'can calculate a price and bonus points awarded for a kids movie rented for 4 days' do
expect(Interactors::SetPriceAndBonusPoints.call([kids_rental], customer)).to eq "Rental record for Robert\nAmount owed is $1.5\nYou earned 1 frequent renter points"
end
it 'can calculate a price and bonus points awarded for a kids movie rented for 2 days, and it should cost just as much as for 4 days' do
new_kids_rental = Rental.new(movie: kids_movie, days_rented: 1)
expect(Interactors::SetPriceAndBonusPoints.call([new_kids_rental], customer)).to eq "Rental record for Robert\nAmount owed is $1.5\nYou earned 1 frequent renter points"
end
it 'can calculate a price and bonus points awarded for a regular movie rented for 2 days' do
expect(Interactors::SetPriceAndBonusPoints.call([regular_rental], customer)).to eq "Rental record for Robert\nAmount owed is $2\nYou earned 1 frequent renter points"
end
it 'can calculate multiple rental bonus points and a prices added up' do
expect(Interactors::SetPriceAndBonusPoints.call([new_rental, kids_rental, regular_rental], customer)).to eq "Rental record for Robert\nAmount owed is $9.5\nYou earned 4 frequent renter points"
end
end
end
And here is the actual code
module Interactors
SetPriceAndBonusPoints = lambda { |rentals, customer|
amount_owed = 0
frequent_renter_points = 0
result = "Rental record for #{customer.customer_name}\n"
rentals.each do |rental|
individual_film_amount = 0
if rental.movie.price_code == Movie::NEW_RELEASE
individual_film_amount += rental.days_rented * 3
rental.days_rented > 1 ? frequent_renter_points += 2 : frequent_renter_points += 1
elsif rental.movie.price_code == Movie::REGULAR
rental.days_rented > 2 ? individual_film_amount += (rental.days_rented - 2) * 1.5 : individual_film_amount += 2
frequent_renter_points += 1
else rental.movie.price_code == Movie::CHILDRENS_MOVIE
rental.days_rented > 3 ? individual_film_amount += (rental.days_rented - 3) * 1.5 : individual_film_amount += 1.5
frequent_renter_points += 1
end
result += "\t" + rental.movie.title_for_movie + "\t" + "$" + individual_film_amount.to_s + "\n"
amount_owed += individual_film_amount
end
result += "Amount owed is $#{amount_owed}\n"
result += "You earned #{frequent_renter_points} frequent renter points"
result
}
end
I extracted all the logic in this lamba function that you can conveniently call as such:
Interactors::SetPriceAndBonusPoints.call([new_rental, kids_rental], customer)
and it will print out the following:
Rental record for Robert
Jaws $9
Incredibles 2 $1.5
Avengers $1.5
Amount owed is $12.0
You earned 4 frequent renter points
You can find the code repo here and also other code snippets and scripts.
Have a splendid week!
