diff --git a/.gitignore b/.gitignore index 5e1422c9c..250b680c9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /test/tmp/ /test/version_tmp/ /tmp/ +design.md # Used by dotenv library to load environment variables. # .env diff --git a/Rakefile b/Rakefile new file mode 100644 index 000000000..902fe3b61 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require 'rake/testtask' + +Rake::TestTask.new do |t| + t.libs = ['lib'] + t.warning = true + t.test_files = FileList['./specs/*_spec.rb'] +end + +task default: :test diff --git a/design-activity.md b/design-activity.md new file mode 100644 index 000000000..db74ece3e --- /dev/null +++ b/design-activity.md @@ -0,0 +1,99 @@ +### Activity +##### What classes does each implementation include? Are the lists the same? + +Yes, they are the same. Both implementations have: + * CartEntry + * ShoppingCart + * Order + + +##### Write down a sentence to describe each class. + +Implementation A: +* CartEntry + * Contains basic pricing info entry +* ShoppingCart + * Contains CartEntry objects +* Order + * Contains and totals a ShoppingCart object + +Implementation B: +* CartEntry + * Manages all pricing info of a single entry +* ShoppingCart + * Contains and totals CartEntry objects +* Order + * Contains and totals ShoppingCart, accounting for sales tax + +##### How do the classes relate to each other? It might be helpful to draw a diagram on a whiteboard or piece of paper. +* Order _has a_ ShoppingCart +* ShoppingCart _has many_ CartEntries + +##### What data does each class store? How (if at all) does this differ between the two implementations? +Implementation A: +* CartEntry + * Unit price + * Unit quantity +* ShoppingCart + * List of CartEntries +* Order + * ShoppingCart + * Total price of all CartEntries in ShoppingCart + +Implementation B: +* CartEntry + * Unit price + * Unit quantity + * Total entry price +* ShoppingCart + * List of CartEntries + * Total of all CartEntry prices +* Order + * ShoppingCart + * Total price of ShoppingCart with tax + +##### What methods does each class have? How (if at all) does this differ between the two implementations? +Implementation A: +* CartEntry + * #unit_price + * #quantity +* ShoppingCart + * #entries +* Order + * #cart + * #total_price + +Implementation B: +* CartEntry + * #price +* ShoppingCart + * #price +* Order + * #total_price + +Implementation A uses attr_accessor methods to make all of the data contained within each class publicly accessible. Implementation B keeps this data private and uses a #price method on CartEntry and ShoppingCart classes to return only the necessary information (in this case, price). + +##### Consider the Order#totalprice method. In each implementation: + +##### Is logic to compute the price delegated to "lower level" classes like ShoppingCart and CartEntry, or is it retained in Order? + +Implementation A requires the Order#total_price method to dig through the ShoppingCart class and into the CartEntry class to directly access its variables, while Implementation B takes the returned value of ShoppingCart#price and performs its logic around that. + +##### Does totalprice directly manipulate the instance variables of other classes? + +Implementation A directly reads the instance variables of the ShoppingCart and CartEntry classes, though it does not modify them. Implementation B does not directly read or modify any other class' instance variables. + +##### If we decide items are cheaper if bought in bulk, how would this change the code? Which implementation is easier to modify? + +Implementation B would be easier to modify, as this change would only affect the CartEntry class, which could be updated to modify price based on quantity. In Implementation A, this would require the Order#total_price method to perform this additional logic within the same method where it is already calculating the total _and_ applying sales tax. + +##### Which implementation better adheres to the single responsibility principle? + +Implementation B better adheres to this principle, as each class manages and encapsulates its relevant information. + +##### Bonus question once you've read Metz ch. 3: Which implementation is more loosely coupled? + +### Revisiting Hotel +Overall, I feel that I did a pretty good job of adhering to the single responsibility principle. The only thing that bothers me about my design is that the price of each room comes from a constant variable defined within the Hotel class, but since the current spec of the project is for all of the hotel's rooms to have a price of 200, I still think it makes the most sense for the time being. How I would refactor the code to account for varying room types would vary depending on how the rooms' prices were being determined, so there's nothing to change at the moment. + +The change I _did_ make was to remove the #validate method from the DateRange class. My thought process behind the change was that the method (which converts valid input into a Date object if it isn't already one) only interacts with one date, and therefore should be part of the Date class, not DateRange. diff --git a/lib/block.rb b/lib/block.rb new file mode 100644 index 000000000..dd1e2caf9 --- /dev/null +++ b/lib/block.rb @@ -0,0 +1,28 @@ +module Hotel + class Block + attr_reader :rooms, :id, :start_date, :end_date, :dates, :discount_rate + + def initialize(start_date, end_date, rooms, discount_rate = 0) + @start_date = Date.parse(start_date) + @end_date = Date.parse(end_date) + @dates = DateRange.range_to(@start_date, @end_date) + @rooms = rooms + @discount_rate = get_discount_rate(discount_rate) + @id = create_id + end + + def create_id + format('B%.2d%.2d%.4d', @start_date.month, @start_date.day, rand(9999)) + end + + def includes_dates?(checkfirst, checklast) + DateRange.include_all?(checkfirst, checklast, @start_date, @end_date) + end + + def get_discount_rate(input) + raise(ArgumentError, "Discount must be number: was #{input.class}") unless (input.class == Integer) || (input.class == Float) + raise(DiscountError, "Discount must be between 0-100%: was #{input}") unless 0 <= input && input <= 100 + return (100.0 - input) / 100 + end + end +end diff --git a/lib/date_range.rb b/lib/date_range.rb new file mode 100644 index 000000000..98f2c674f --- /dev/null +++ b/lib/date_range.rb @@ -0,0 +1,68 @@ +module DateRange + require 'date' + + def self.range_to(start_date, end_date) + start_date = Date.validate(start_date) + end_date = Date.validate(end_date) + validate_order(start_date, end_date) + dates = [] + while start_date < end_date + dates << start_date + start_date += 1 + end + dates + end + + def self.overlap?(first_start, first_end, second_start, second_end) + first_start = Date.validate(first_start) + first_end = Date.validate(first_end) + second_start = Date.validate(second_start) + second_end = Date.validate(second_end) + + first_dates = DateRange.range_to(first_start, first_end) + second_dates = DateRange.range_to(second_start, second_end) + + first_dates.each do |first_date| + second_dates.each do |second_date| + return true if first_date.strftime == second_date.strftime + end + end + false + end + + def self.include_all?(search_start, search_end, contain_start, contain_end) + search_start = Date.validate(search_start) + search_end = Date.validate(search_end) + contain_start = Date.validate(contain_start) + contain_end = Date.validate(contain_end) + + search_dates = DateRange.range_to(search_start, search_end) + contain_dates = DateRange.range_to(contain_start, contain_end) + + search_dates.each do |search_date| + return false unless contain_dates.include? search_date + end + true + end + + def self.validate_order(first, second) + first = Date.validate(first) + second = Date.validate(second) + unless first < second + raise(DatesError, 'Start date must be at least 1 day before end date') + end + true + end +end + +class Date + def self.validate(input) + if input.class == Date + return input + elsif input.class == String + return Date.parse(input) + else + raise(ArgumentError, "Input #{input.class} cannot be converted into Date") + end + end +end diff --git a/lib/exceptions.rb b/lib/exceptions.rb new file mode 100644 index 000000000..ee83bb621 --- /dev/null +++ b/lib/exceptions.rb @@ -0,0 +1,16 @@ +class NoRoomError < StandardError + # Raised when there are not enough available rooms for request +end + +class DatesError < StandardError + # Raised when dates are in wrong order, out of range, etc + # NOT raised for invalid date input (use ArgumentError) +end + +class NoBlockError < StandardError + # Raised when provided block does not exist/cannot be found +end + +class DiscountError < StandardError + # Raised when discount is not number from 0-100 +end diff --git a/lib/hotel.rb b/lib/hotel.rb new file mode 100644 index 000000000..8cfe30c9f --- /dev/null +++ b/lib/hotel.rb @@ -0,0 +1,104 @@ +require 'pry' + +module Hotel + require_relative 'room' + require_relative 'reservation' + require_relative 'room' + require_relative 'block' + require_relative 'exceptions' + + class Hotel + attr_reader :rooms, :reservations, :blocks + ROOM_COST = 200 + + def initialize(num_rooms) + @rooms = [] + + num_rooms.times do |i| + @rooms << Room.new(i + 1, ROOM_COST) + end + @reservations = [] + @blocks = [] + end + + def make_reservation(checkin, checkout, block = false) + room_num = find_available_rooms(checkin, checkout, block).first + if room_num.nil? + raise(NoRoomError, "No available rooms for dates #{checkin} - #{checkout}") + end + reservation = Reservation.new(room_num, checkin, checkout, self, block) + @reservations << reservation + reservation + end + + def make_block(start_date, end_date, num_rooms, discount = 0) + raise(ArgumentError,"Number of rooms must be Integer: is #{num_rooms.class}") unless num_rooms.class == Integer + + rooms = find_available_rooms(start_date, end_date)[0...num_rooms] + + raise(NoRoomError, "Not enough available rooms for amount #{num_rooms}") if rooms.length < num_rooms + + block = Block.new(start_date, end_date, rooms, discount) + @blocks << block + block + end + + def view_reservations(date) + date = Date.validate(date) + reservations = [] + @reservations.each do |reservation| + reservations << reservation if reservation.dates.include?(date) + end + reservations + end + + def find_available_rooms(checkin, checkout, block_id = false) + DateRange.validate_order(checkin, checkout) + if block_id + unless block_exists?(block_id) + raise(NoBlockError, "No such block: #{block_id}") + end + unless block(block_id).includes_dates?(checkin, checkout) + raise(DatesError, "Dates (#{checkin}, #{checkout}) do not fall within provided block #{block(block_id).id}") + end + rooms = block(block_id).rooms + else + rooms = find_rooms_not_in_blocks(checkin, checkout) + end + + @reservations.each do |reservation| + rooms.delete(reservation.room) if (rooms.include? reservation.room) && reservation.includes_dates?(checkin, checkout) + end + rooms + end + + def block_availability?(checkin, checkout, block_id) + find_available_rooms(checkin, checkout, block_id) != [] + end + + def room(num) + @rooms.each { |room| return room if room.number == num } + nil + end + + def block(id) + @blocks.each { |block| return block if block.id == id } + nil + end + + # private + def find_rooms_not_in_blocks(start_date, end_date) + rooms = @rooms.dup + @blocks.each do |block| + if block.includes_dates?(start_date, end_date) + rooms.delete_if { |room| block.rooms.include? room } + end + end + rooms + end + + def block_exists?(block_id) + block(block_id) != nil + end + end +end diff --git a/lib/reservation.rb b/lib/reservation.rb new file mode 100644 index 000000000..ab5c32291 --- /dev/null +++ b/lib/reservation.rb @@ -0,0 +1,32 @@ +require 'pry' + +module Hotel + class Reservation + require_relative 'date_range' + attr_reader :total_cost, :dates, :checkin, :checkout, :id, :hotel, :room, :block + + def initialize(room, checkin, checkout, hotel, block = false) + @room = room + @checkin = Date.validate(checkin) + @checkout = Date.validate(checkout) + @dates = DateRange.range_to(@checkin, @checkout) + if block + @block = hotel.block(block) + raise(DatesError) unless @block.includes_dates?(checkin, checkout) + else + @block = false + end + get_total + end + + def get_total + num_nights = @dates.length + @total_cost = @room.rate * num_nights + @total_cost *= @block.discount_rate if @block + end + + def includes_dates?(checkfirst, checklast) + DateRange.overlap?(checkfirst, checklast, @checkin, @checkout) + end + end +end diff --git a/lib/room.rb b/lib/room.rb new file mode 100644 index 000000000..42ec1eb81 --- /dev/null +++ b/lib/room.rb @@ -0,0 +1,10 @@ +module Hotel + class Room + attr_reader :number, :rate + + def initialize(number, rate) + @number = number + @rate = rate + end + end +end diff --git a/specs/block_spec.rb b/specs/block_spec.rb new file mode 100644 index 000000000..5e1bdc99a --- /dev/null +++ b/specs/block_spec.rb @@ -0,0 +1,126 @@ +require_relative 'spec_helper' + +describe 'Block' do + before do + # - To create a block you need a date range, collection of rooms and a discounted room rate + @hotel = Hotel::Hotel.new(20) + @block = @hotel.make_block('2017-08-03', '2017-08-07', 5, 20) + end + + describe 'initialize' do + it 'can be instantiated' do + @block.must_be_kind_of Hotel::Block + end + + it 'has 9-character @id value' do + @block.id.must_be_kind_of String + @block.id.length.must_equal 9r + @block.id[0..4].must_equal 'B0803' + end + + it 'has array of Room objects' do + @block.rooms.must_be_kind_of Array + @block.rooms.each do |room| + room.must_be_kind_of Hotel::Room + end + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + Hotel::Block.new('sea','HAWKS', 5, 20) + }.must_raise ArgumentError + end + + it 'raises DatesError if dates are out of order' do + proc { + Hotel::Block.new('2017-08-08', '2017-08-03', 5, 20) + }.must_raise DatesError + end + + it 'raises DatesError if dates do not span at least 1 night' do + proc { + Hotel::Block.new('2017-08-06', '2017-08-06', 5, 20) + }.must_raise DatesError + end + + it 'has 0 >= n >= 1 discount_rate value' do + @block.discount_rate.must_equal 0.8 + end + end + + describe 'includes_dates?' do + it 'returns true if block includes provided dates' do + includes_dates = @block.includes_dates?('2017-08-03', '2017-08-07') + + includes_dates.must_equal true + end + + it 'returns false if block does not include provided dates' do + includes_dates = @block.includes_dates?('2017-10-14', '2017-10-18') + + includes_dates.must_equal false + end + + it 'returns true for partial overlap' do + includes_dates = @block.includes_dates?('2017-08-03', '2017-08-10') + + includes_dates.must_equal false + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + @block.includes_dates?('sea','HAWKS') + }.must_raise ArgumentError + end + + it 'raises DatesError if dates are out of order' do + proc { + @block.includes_dates?('2017-08-08', '2017-08-03') + }.must_raise DatesError + end + + it 'raises DatesError if dates do not span at least 1 night' do + proc { + @block.includes_dates?('2017-08-06', '2017-08-06') + }.must_raise DatesError + end + end + + describe 'get_discount_rate' do + it 'converts percentage number into equivalent Float' do + @block.get_discount_rate(80).must_equal 0.2 + @block.get_discount_rate(75).must_equal 0.25 + @block.get_discount_rate(10).must_equal 0.9 + @block.get_discount_rate(12.5).must_equal 0.875r + end + + it 'can convert 0 or 100' do + @block.get_discount_rate(0).must_equal 1 + @block.get_discount_rate(100).must_equal 0 + end + + it 'raises DiscountError if given discount that is not 0-100' do + proc { + @block.get_discount_rate(125) + }.must_raise DiscountError + + proc { + @block.get_discount_rate(-25) + }.must_raise DiscountError + end + + it 'raises ArgumentError if given discount that is not a number' do + proc { + @block.get_discount_rate('hello') + }.must_raise ArgumentError + + proc { + @block.get_discount_rate(true) + }.must_raise ArgumentError + + proc { + @block.get_discount_rate(nil) + }.must_raise ArgumentError + end + end +end diff --git a/specs/date_range_spec.rb b/specs/date_range_spec.rb new file mode 100644 index 000000000..1f70aa9a2 --- /dev/null +++ b/specs/date_range_spec.rb @@ -0,0 +1,191 @@ +require_relative 'spec_helper' + +describe 'DateRange' do + before do + @before = Date.new(2017, 10, 1) + @after = Date.new(2017, 10, 14) + end + + describe 'self.range_to' do + it 'returns an array of Dates' do + dates = DateRange.range_to(@before, @after) + + dates.must_be_kind_of Array + + dates.each do |date| + date.must_be_kind_of Date + end + end + + it 'includes all dates up to, but not including, the end date' do + dates = DateRange.range_to(@before, @after) + + dates.first.strftime.must_equal '2017-10-01' + dates.last.strftime.must_equal '2017-10-13' + end + + it 'raises DatesError if start date is after end date' do + proc { + DateRange.range_to(@after, @before) + }.must_raise DatesError + end + + it 'raises DatesError if dates do not span at least 1 night' do + proc { + DateRange.range_to(@before, @before) + }.must_raise DatesError + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + DateRange.range_to('sea', 'HAWKS') + }.must_raise ArgumentError + end + end + + describe 'self.overlap?' do + it 'returns true if provided date range overlaps' do + overlap = DateRange.overlap?('2017-09-06', '2017-09-07', '2017-09-06', '2017-09-07') + overlap.must_equal true + end + + it 'returns false if provided date range does not overlap' do + overlap = DateRange.overlap?('2017-10-14', '2017-10-15', '2017-09-06', '2017-09-07') + overlap.must_equal false + end + + it 'returns true for partial overlap' do + overlap = DateRange.overlap?('2017-09-06', '2017-09-10', '2017-09-8', '2017-09-20') + overlap.must_equal true + end + + it 'allows one range to end the day the next starts' do + overlap = DateRange.overlap?('2017-09-06', '2017-09-10', '2017-09-10', '2017-09-20') + overlap.must_equal false + end + + it 'raises DatesError if either start date is after end date' do + proc { + DateRange.overlap?(@after, @before, @before, @after) + }.must_raise DatesError + + proc { + DateRange.overlap?(@before, @after, @after, @before) + }.must_raise DatesError + end + + it 'raises DatesError if either dates do not span at least 1 night' do + proc { + DateRange.overlap?(@before, @before, @before, @after) + }.must_raise DatesError + + proc { + DateRange.overlap?(@before, @after, @before, @before) + }.must_raise DatesError + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + DateRange.overlap?(@before, @after, 'before', 'after') + }.must_raise ArgumentError + end + end + + describe 'self.include_all?' do + it 'returns true if all searched dates fall within container range' do + include_all = DateRange.include_all?('2017-10-10', '2017-10-14', @before, @after) + + include_all.must_equal true + end + + it 'returns false if no searched dates fall within container range' do + include_all = DateRange.include_all?('2017-11-10', '2017-11-14', @before, @after) + + include_all.must_equal false + end + + it 'returns false for partial overlap' do + include_all = DateRange.include_all?('2017-10-10', '2017-11-14', @before, @after) + + include_all.must_equal false + end + + it 'raises DatesError if either start date is after end date' do + proc { + DateRange.include_all?(@after, @before, @before, @after) + }.must_raise DatesError + + proc { + DateRange.include_all?(@before, @after, @after, @before) + }.must_raise DatesError + end + + it 'raises DatesError if either dates do not span at least 1 night' do + proc { + DateRange.include_all?(@before, @before, @before, @after) + }.must_raise DatesError + + proc { + DateRange.include_all?(@before, @after, @before, @before) + }.must_raise DatesError + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + DateRange.include_all?(@before, @after, 'before', 'after') + }.must_raise ArgumentError + end + end + + describe 'self.validate' do + it 'returns input unchanged if input is a Date object' do + Date.validate(@before).must_equal @before + end + + it 'returns a Date object if input is a String' do + date = Date.validate('2017-10-14') + date.must_be_kind_of Date + date.month.must_equal 10 + end + + it 'raises ArgumentError if input is neither Date or String' do + proc { + Date.validate(2017_10_14) + }.must_raise ArgumentError + + proc { + Date.validate(true) + }.must_raise ArgumentError + end + + it 'raises ArgumentError if passed invalid String format' do + proc { + Date.validate('my birthday') + }.must_raise ArgumentError + end + end + + describe 'self.validate_order' do + it 'returns true if it is two dates in correct order' do + DateRange.validate_order(@before, @after).must_equal true + end + + it 'raises DatesError if input is two dates in incorrect order' do + proc { + DateRange.validate_order(@after, @before) + }.must_raise DatesError + end + + it 'raises DatesError if input dates do not span at least 1 night' do + proc { + DateRange.validate_order(@after, @after) + }.must_raise DatesError + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + DateRange.validate_order('sea', 'HAWKS') + }.must_raise ArgumentError + end + end +end diff --git a/specs/design.md b/specs/design.md new file mode 100644 index 000000000..1c989d0a9 --- /dev/null +++ b/specs/design.md @@ -0,0 +1,49 @@ +WAVE 1 +As an administrator, I can access the list of all of the rooms in the hotel +As an administrator, I can reserve a room for a given date range +As an administrator, I can access the list of reservations for a specific date +As an administrator, I can get the total cost for a given reservation + +The hotel has 20 rooms, and they are numbered 1 through 20 +Every room is identical, and a room always costs $200/night +The last day of a reservation is the checkout day, so the guest should not be charged for that night +For this wave, any room can be reserved at any time, and you don't need to check whether reservations conflict with each other (this will come in wave 2!) + +WAVE 2 +As an administrator, I can view a list of rooms that are not reserved for a given date range +As an administrator, I can reserve an available room for a given date range + +A reservation is allowed start on the same day that another reservation for the same room ends +Your code should raise an exception when asked to reserve a room that is not available + +WAVE 3 +As an administrator, I can create a block of rooms +To create a block you need a date range, collection of rooms and a discounted room rate +The collection of rooms should only include rooms that are available for the given date range +If a room is set aside in a block, it is not available for reservation by the general public, nor can it be included in another block +As an administrator, I can check whether a given block has any rooms available +As an administrator, I can reserve a room from within a block of rooms +Constraints + +A block can contain a maximum of 5 rooms +When a room is reserved from a block of rooms, the reservation dates will always match the date range of the block +All of the availability checking logic from Wave 2 should now respect room blocks as well as individual reservations + +**Nouns:** +* administrator +* room(s) +* hotel +* date +* date range +* reservation(s) +* list (of rooms) +* list (of reservations) +* cost +* room number +* guest + + +**Verbs:** +* access (lists) +* reserve (room) +* get (cost) diff --git a/specs/hotel_spec.rb b/specs/hotel_spec.rb new file mode 100644 index 000000000..23f5c5ae7 --- /dev/null +++ b/specs/hotel_spec.rb @@ -0,0 +1,560 @@ +require_relative 'spec_helper' + +describe 'Hotel' do + before do + @hotel = Hotel::Hotel.new(20) + end + + describe '#initialize' do + it 'Can be instantiated' do + @hotel.must_be_kind_of Hotel::Hotel + @hotel.reservations.must_equal [] + @hotel.blocks.must_equal [] + end + + it 'Creates an array of rooms' do + @hotel.rooms.must_be_kind_of Array + @hotel.rooms.length.must_equal 20 + @hotel.rooms.each do |room| + room.must_be_kind_of Hotel::Room + end + end + + it 'Can be initialized with 0 rooms' do + hotel = Hotel::Hotel.new(0) + hotel.rooms.must_equal [] + end + + it 'Can be initialized with 1 room' do + hotel = Hotel::Hotel.new(1) + hotel.rooms.length.must_equal 1 + hotel.rooms[0].must_be_kind_of Hotel::Room + end + + it 'raises NoMethodError if passed anything but an Integer' do + proc { + Hotel::Hotel.new('hi') + }.must_raise NoMethodError + + proc { + Hotel::Hotel.new(nil) + }.must_raise NoMethodError + + proc { + Hotel::Hotel.new(4.0) + }.must_raise NoMethodError + end + end + + describe 'rooms' do + describe '@rooms' do + # As an administrator, I can access the list of all of the rooms in the hotel + it 'Returns an array of all room numbers' do + all_rooms = @hotel.rooms + all_rooms.must_be_kind_of Array + all_rooms.each do |room| + room.must_be_kind_of Hotel::Room + end + end + + it 'returns an empty array if there are no rooms' do + all_rooms = Hotel::Hotel.new(0).rooms + all_rooms.must_equal [] + end + + it 'returns a single-element array if there is 1 room' do + all_rooms = Hotel::Hotel.new(1).rooms + all_rooms.length.must_equal 1 + all_rooms[0].must_be_kind_of Hotel::Room + end + end + + describe '#room' do + it 'returns the Room with corresponding number' do + room = @hotel.room(4) + room.must_be_kind_of Hotel::Room + room.number.must_equal 4 + end + + it 'returns nil if room is not found' do + assert_nil @hotel.room(1000) + assert_nil @hotel.room('hi') + assert_nil @hotel.room(5.5) + assert_nil @hotel.room(nil) + assert_nil @hotel.room(true) + assert_nil @hotel.room([4,0]) + end + end + + describe '#find_available_rooms' do + # As an administrator, I can view a list of rooms that are not reserved for a given date range + it 'returns an array of rooms' do + rooms = @hotel.find_available_rooms('2017-09-05', '2017-09-09') + rooms.must_be_kind_of Array + rooms.each do |room| + room.must_be_kind_of Hotel::Room + end + end + + it 'returns only available rooms' do + @hotel.make_reservation('2017-10-14', '2017-10-18') + @hotel.make_reservation('2017-10-14', '2017-10-18') + @hotel.make_reservation('2018-10-14', '2018-10-18') + rooms = @hotel.find_available_rooms('2017-10-14', '2017-10-18') + + rooms.length.must_equal 18 + end + + it 'returns an empty array if no rooms are available' do + 20.times {@hotel.make_reservation('2017-10-14', '2017-10-18')} + rooms = @hotel.find_available_rooms('2017-10-14', '2017-10-18') + rooms.must_equal [] + end + + it 'will not return rooms in a block' do + @hotel.make_reservation('2017-10-14', '2017-10-18') + @hotel.make_block('2017-10-10', '2017-10-16', 5, 20) + @hotel.make_block('2017-11-10', '2017-11-16', 5, 20) + rooms = @hotel.find_available_rooms('2017-10-14','2017-10-15') + + rooms.length.must_equal 14 + end + + it 'returns an empty array if all rooms are in a block' do + @hotel.make_block('2017-10-10', '2017-10-16', 20, 20) + rooms = @hotel.find_available_rooms('2017-10-14','2017-10-15') + + rooms.must_equal [] + end + + it 'will return rooms in a block when provided block ID' do + block = @hotel.make_block('2017-10-10', '2017-10-16', 5, 20) + rooms = @hotel.find_available_rooms('2017-10-14','2017-10-15', block.id) + + rooms.length.must_equal 5 + end + + it 'raises an error if passed invalid dates' do + proc { + @hotel.find_available_rooms(1,2) + }.must_raise ArgumentError + + proc { + @hotel.find_available_rooms('tomorrow','next friday') + }.must_raise ArgumentError + + proc { + @hotel.find_available_rooms('2017-02-30','2017-04-98') + }.must_raise ArgumentError + end + + it 'raises an error if block is not found' do + proc { + @hotel.find_available_rooms('2017-10-14','2017-10-15', 'B10141991') + }.must_raise NoBlockError + + end + + it 'raises an error if dates do not fall within given block' do + block = @hotel.make_block('2017-08-03', '2017-08-08', 5, 20) + + proc { + @hotel.find_available_rooms('2017-10-14', '2017-10-15', block.id) + }.must_raise DatesError + end + + it 'raises an error if dates are out of order' do + proc { + @hotel.find_available_rooms('2017-08-23', '2017-08-08') + }.must_raise DatesError + end + + it 'raises an error if dates do not span at least 1 night' do + proc { + @hotel.find_available_rooms('2017-08-08', '2017-08-08') + }.must_raise DatesError + end + end + end + + describe 'reservations' do + describe '#make_reservation' do + before do + @reservation = @hotel.make_reservation('2017-09-05', '2017-09-08') + end + + it 'creates a reservation and adds it to the @reservations array' do + # As an administrator, I can reserve a room for a given date range + @reservation.must_be_kind_of Hotel::Reservation + @reservation.checkout.strftime.must_equal '2017-09-08' + end + + it 'will reserve the first available room' do + # As an administrator, I can reserve an available room for a given date range + reservation2 = @hotel.make_reservation('2017-09-05', '2017-09-08') + @reservation.room.wont_equal reservation2.room + end + + it 'will book a room again after a reservation ends' do + reservation2 = @hotel.make_reservation('2018-09-05', '2018-09-08') + + @reservation.room.must_equal reservation2.room + end + + it 'can book two consecutive reservations to the same room' do + # A reservation is allowed start on the same day that another reservation for the same room ends + reservation2 = @hotel.make_reservation('2017-09-08', '2017-09-11') + + @reservation.room.must_equal reservation2.room + end + + it 'will not book a room that is part of a block' do + # - If a room is set aside in a block, it is not available for reservation by the general public + block = @hotel.make_block('2017-08-03', '2017-08-07', 10, 20) + reservation = @hotel.make_reservation('2017-08-04', '2017-08-05') + + block.rooms.wont_include reservation.room + end + + it 'will book room in block if provided a block ID' do + # - As an administrator, I can reserve a room from within a block of rooms + block = @hotel.make_block('2017-08-03', '2017-08-07', 10, 20) + reservation = @hotel.make_reservation('2017-08-04', '2017-08-05', block.id) + + block.rooms.must_include reservation.room + end + + it 'raises DatesError if reservation does not fall fully within block' do + block = @hotel.make_block('2017-08-03', '2017-08-08', 5, 10) + proc { + @hotel.make_reservation('2017-08-06', '2017-08-10', block.id) + }.must_raise DatesError + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + @hotel.make_reservation('cat','bug') + }.must_raise ArgumentError + + proc { + @hotel.make_reservation(1,2) + }.must_raise ArgumentError + + proc { + @hotel.make_reservation('2019-02-40','2019-02-45') + }.must_raise ArgumentError + end + + it 'raises DatesError if dates are out of order' do + proc { + reservation = @hotel.make_reservation('2017-09-08', '2017-09-05') + }.must_raise DatesError + end + + it 'raises DatesError if reservation does not span at least 1 night' do + proc { + @hotel.make_reservation('2017-10-14','2017-10-14') + }.must_raise DatesError + end + + it 'raises NoBlockError if passed nonexistent block' do + proc { + @hotel.make_reservation('2017-09-05', '2017-09-08', 'catbug') + }.must_raise NoBlockError + end + + it 'raises NoRoomError if no rooms are available' do + # Your code should raise an exception when asked to reserve a room that is not available + proc { + 21.times { @hotel.make_reservation('2017-09-05', '2017-09-08') } + }.must_raise NoRoomError + end + + it 'raises NoRoomError if all rooms are in a block' do + @hotel.make_block('2017-09-05', '2017-09-08', 19, 20) + + proc { + @hotel.make_reservation('2017-09-05', '2017-09-08') + }.must_raise NoRoomError + end + end + + describe '#view_reservations' do + # As an administrator, I can access the list of reservations for a specific date + it 'returns an array of Reservations' do + @hotel.make_reservation('2017-10-14', '2017-10-18') + reservations = @hotel.view_reservations('2017-10-14') + + reservations.must_be_kind_of Array + reservations.length.must_equal 1 + reservations.each do |reservation| + reservation.must_be_kind_of Hotel::Reservation + end + end + + it 'returns all Reservations' do + 10.times { @hotel.make_reservation('2017-10-14', '2017-10-18') } + reservations = @hotel.view_reservations('2017-10-14') + + reservations.length.must_equal 10 + reservations.each do |reservation| + reservation.must_be_kind_of Hotel::Reservation + end + end + + it 'returns Reservations inside and outside of blocks' do + block = @hotel.make_block('2017-08-03', '2017-08-10', 10, 25) + @hotel.make_reservation('2017-08-03', '2017-08-10', block.id) + @hotel.make_reservation('2017-08-03', '2017-08-10') + reservations = @hotel.view_reservations('2017-08-06') + + reservations.length.must_equal 2 + end + + it 'returns an empty array when there are no reservations' do + @hotel.view_reservations('2000-01-01').must_equal [] + end + + it 'wont display rooms in blocks that are not reserved' do + @block = @hotel.make_block('2017-08-03', '2017-08-07', 10, 20) + + @hotel.view_reservations('2017-08-06').must_equal [] + end + + it 'raises an ArgumentError when given invalid dates' do + proc { + @hotel.view_reservations('coffee') + }.must_raise ArgumentError + + proc { + @hotel.view_reservations('9999-99-99') + }.must_raise ArgumentError + end + end + end + + describe 'blocks' do + before do + @block = @hotel.make_block('2017-08-03', '2017-08-07', 10, 20) + end + + describe '#make_block' do + # - As an administrator, I can create a block of rooms + it 'returns a new Block object' do + @block.must_be_kind_of Hotel::Block + end + + it 'can make a block for 1 night' do + block = @hotel.make_block('2017-08-03', '2017-08-04', 10, 20) + + block.dates.length.must_equal 1 + end + + it 'adds to @blocks array' do + @hotel.blocks.length.must_equal 1 + end + + it 'fills block with available rooms only' do + # - The collection of rooms should only include rooms that are available for the given date range + 3.times { @hotel.make_reservation('2017-10-05', '2017-10-07') } + new_block = @hotel.make_block('2017-10-03', '2017-10-07', 10, 20) + reserved = @hotel.view_reservations('2017-10-06') + + reserved.each do |reservation| + new_block.rooms.wont_include reservation.room + end + end + + it 'will not overlap multiple blocks' do + # - If a room is set aside in a block, it cannot be included in another block + block2 = @hotel.make_block('2017-08-03', '2017-08-07', 5, 20) + + @block.rooms.each do |room| + block2.rooms.wont_include room + end + end + + it 'can begin a block the day another block ends' do + block2 = @hotel.make_block('2017-08-07', '2017-08-10', 10, 20) + + @block.rooms.must_equal block2.rooms + end + + it 'raises NoRoomError when there are not enough rooms to fill a block' do + proc { + @hotel.make_block('2017-08-03', '2017-08-07', 11, 20) + }.must_raise NoRoomError + end + + it 'raises ArgumentError error when passed invalid dates' do + proc { + @hotel.make_block('Augustish', 'Later', 5, 25) + }.must_raise ArgumentError + + proc { + @hotel.make_block('2017-14-14', '2017-15-15', 5, 25) + }.must_raise ArgumentError + end + + it 'raises DatesError when dates are out of order' do + proc { + @hotel.make_block('2017-09-07', '2017-09-05', 5, 25) + }.must_raise DatesError + end + + it 'raises DatesError when dates do not span at least 1 night' do + proc { + @hotel.make_block('2017-08-03', '2017-08-03', 10, 20) + }.must_raise DatesError + end + + it 'raises DiscountError when passed discount greater than 100%' do + proc { + @hotel.make_block('2017-09-07', '2017-09-10', 5, 125) + }.must_raise DiscountError + end + + it 'raises DiscountError when passed discount below 0%' do + proc { + @hotel.make_block('2017-09-07', '2017-09-10', 5, -25) + }.must_raise DiscountError + end + + it 'raises ArgumentError when passed invalid discount' do + proc { + @hotel.make_block('2017-09-07', '2017-09-10', 5, 'free') + }.must_raise ArgumentError + end + + it 'raises error when passed invalid rooms number' do + proc { + @hotel.make_block('2017-09-07', '2017-09-10', true, 25) + }.must_raise ArgumentError + + proc { + @hotel.make_block('2017-09-07', '2017-09-10', 0.5, 25) + }.must_raise ArgumentError + + proc { + @hotel.make_block('2017-09-07', '2017-09-10', 'hello', 25) + }.must_raise ArgumentError + end + end + + describe '#find_rooms_not_in_blocks' do + it 'returns array of rooms' do + rooms = @hotel.find_rooms_not_in_blocks('2017-10-14', '2017-10-18') + rooms.must_be_kind_of Array + end + + it 'returns rooms that are not in block' do + rooms = @hotel.find_rooms_not_in_blocks('2017-08-05', '2017-08-07') + + rooms.each do |room| + @block.rooms.wont_include room + end + + rooms.length.must_equal 10 + end + + it 'returns rooms that are reserved, but not in blocks' do + @hotel.make_reservation('2017-08-03', '2017-08-07') + + rooms = @hotel.find_rooms_not_in_blocks('2017-08-05', '2017-08-07') + + rooms.length.must_equal 10 + end + + it 'returns empty array if all rooms are in block' do + @hotel.make_block('2017-08-03', '2017-08-07', 10, 20) + + rooms = @hotel.find_rooms_not_in_blocks('2017-08-03', '2017-08-07') + + rooms.must_equal [] + end + + it 'raises DatesError if dates are out of order' do + proc { + @hotel.find_rooms_not_in_blocks('2017-09-15', '2017-09-05') + }.must_raise DatesError + end + + it 'raises ArgumentError if passed invalid date' do + proc { + @hotel.find_rooms_not_in_blocks('sea','HAWKS') + }.must_raise ArgumentError + + proc { + @hotel.find_rooms_not_in_blocks('2017-08-06','HAWKS') + }.must_raise ArgumentError + + proc { + @hotel.find_rooms_not_in_blocks('2017-08-06','HAWKS') + }.must_raise ArgumentError + end + + it 'raises DatesError if dates do not span at least 1 night' do + proc { + @hotel.find_rooms_not_in_blocks('2017-08-06','2017-08-06') + }.must_raise DatesError + end + end + + describe '#block_availability?' do + # - As an administrator, I can check whether a given block has any rooms available + it 'returns true if there are unbooked rooms within block for given dates' do + check = @hotel.block_availability?('2017-08-05', '2017-08-07', @block.id) + check.must_equal true + end + + it 'returns false if there are no unbooked rooms within block for given dates' do + 10.times { @hotel.make_reservation('2017-08-03', '2017-08-07', @block.id) } + check = @hotel.block_availability?('2017-08-05', '2017-08-07', @block.id) + check.must_equal false + end + + it 'raises NoBlockError if block cannot be found' do + proc { + @hotel.block_availability?('2017-10-14', '2017-10-15', 'bad block') + }.must_raise NoBlockError + end + + it 'raises DatesError if the dates do not fall within the block' do + proc { + @hotel.block_availability?('2017-10-14','2017-10-15', @block.id) + }.must_raise DatesError + end + + it 'raises DatesError if the dates are out of order' do + proc { + @hotel.block_availability?('2017-11-14','2017-10-15', @block.id) + }.must_raise DatesError + end + + it 'raises DatesError if the dates do not span at least 1 night' do + proc { + @hotel.block_availability?('2017-10-14','2017-10-14', @block.id) + }.must_raise DatesError + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + @hotel.block_availability?('sea','HAWKS') + }.must_raise ArgumentError + end + end + + describe '#block_exists?' do + it 'returns true if block w/ given ID exists' do + @hotel.block_exists?(@block.id).must_equal true + end + + it 'returns false if block w/ given ID does not exist' do + @hotel.block_exists?('B09051234').must_equal false + @hotel.block_exists?(2).must_equal false + @hotel.block_exists?(nil).must_equal false + @hotel.block_exists?('hello').must_equal false + end + end + end +end diff --git a/specs/reservation_spec.rb b/specs/reservation_spec.rb new file mode 100644 index 000000000..3359a097c --- /dev/null +++ b/specs/reservation_spec.rb @@ -0,0 +1,126 @@ +require_relative 'spec_helper' + +describe 'Reservation' do + before do + @hotel = Hotel::Hotel.new(20) + @room = @hotel.rooms[0] + @reservation = Hotel::Reservation.new(@room, '2017-09-05', '2017-09-07', @hotel) + end + + describe '#initialize' do + it 'can be instantiated' do + @reservation.must_be_kind_of Hotel::Reservation + end + + it 'has @dates value, which is array of dates' do + @reservation.dates.must_be_kind_of Array + @reservation.dates.first.must_be_kind_of Date + end + + it 'has @total_cost value, which is rate * num of nights' do + # As an administrator, I can get the total cost for a given reservation + @reservation.total_cost.must_equal 400 + (@reservation.total_cost % @reservation.dates.length).must_equal 0 + end + + it 'can be initialized with various date formats (Date or String)' do + date1 = Date.new(2017, 9, 5) + date2 = Date.new(2017, 9, 10) + Hotel::Reservation.new(@room, date1, date2, @hotel) + Hotel::Reservation.new(@room, '2017-09-05', '2017-09-10', @hotel) + Hotel::Reservation.new(@room, 'September 5th, 2017', 'September 10th, 2017', @hotel) + Hotel::Reservation.new(@room, '5/9/17', '10/9/17, 2017', @hotel) + end + + it 'raises DatesError if dates are out of order' do + proc { + Hotel::Reservation.new(@room, '2017-09-15', '2017-09-07', @hotel) + }.must_raise DatesError + end + + it 'raises DatesError if dates do not span at least 1 night' do + proc { + Hotel::Reservation.new(@room, '2017-09-05', '2017-09-05', @hotel) + }.must_raise DatesError + + + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + Hotel::Reservation.new(@room, 'sea', 'HAWKS', @hotel) + }.must_raise ArgumentError + end + end + + describe 'get_total' do + it 'factors block discount into total if part of block' do + block = @hotel.make_block('2017-08-03', '2017-08-08', 10, 25) + reservation = @hotel.make_reservation('2017-08-03', '2017-08-08', block.id) + reservation.total_cost.must_equal 750 + end + + it 'returns total without discount if not part of block' do + @reservation.total_cost.must_equal 400 + end + + it 'can have 0% (no) discount in block' do + block = @hotel.make_block('2017-08-03', '2017-08-08', 10, 0) + reservation = @hotel.make_reservation('2017-08-03', '2017-08-08', block.id) + reservation.total_cost.must_equal 1000 + end + + it 'can have 100% (gratis) discount in block' do + block = @hotel.make_block('2017-08-03', '2017-08-08', 10, 100) + reservation = @hotel.make_reservation('2017-08-03', '2017-08-08', block.id) + reservation.total_cost.must_equal 0 + end + + it 'can have float as discount in block' do + block = @hotel.make_block('2017-08-03', '2017-08-08', 10, 12.5) + reservation = @hotel.make_reservation('2017-08-03', '2017-08-08', block.id) + reservation.total_cost.must_equal 875 + end + + it 'defaults to 0% (no) discount' do + block = @hotel.make_block('2017-08-03', '2017-08-08', 10) + reservation = @hotel.make_reservation('2017-08-03', '2017-08-08', block.id) + reservation.total_cost.must_equal 1000 + end + end + + describe '#includes_dates?' do + it 'returns true if provided date range overlaps' do + overlap = @reservation.includes_dates?('2017-09-06', '2017-09-07') + overlap.must_equal true + end + + it 'returns false if provided date range does not overlap' do + overlap = @reservation.includes_dates?('2017-10-14', '2017-10-15') + overlap.must_equal false + end + + it 'returns true for partial overlap' do + overlap = @reservation.includes_dates?('2017-09-06', '2017-09-20') + overlap.must_equal true + end + + it 'raises DatesError if dates are out of order' do + proc { + @reservation.includes_dates?('2017-09-15', '2017-09-07') + }.must_raise DatesError + end + + it 'raises DatesError if dates do not span at least 1 night' do + proc { + @reservation.includes_dates?('2017-09-05', '2017-09-05') + }.must_raise DatesError + end + + it 'raises ArgumentError if passed invalid dates' do + proc { + @reservation.includes_dates?('sea', 'HAWKS') + }.must_raise ArgumentError + end + end +end diff --git a/specs/room_spec.rb b/specs/room_spec.rb new file mode 100644 index 000000000..d278cf4cc --- /dev/null +++ b/specs/room_spec.rb @@ -0,0 +1,20 @@ +require_relative 'spec_helper' + +describe 'Room' do + before do + @room = Hotel::Room.new(5, 200) + end + describe 'initialize' do + it 'can be instantiated' do + @room.must_be_kind_of Hotel::Room + end + + it 'has a room number' do + @room.number.must_equal 5 + end + + it 'has a cost' do + @room.rate.must_equal 200 + end + end +end diff --git a/specs/spec_helper.rb b/specs/spec_helper.rb new file mode 100644 index 000000000..c9d0edfe5 --- /dev/null +++ b/specs/spec_helper.rb @@ -0,0 +1,15 @@ +require 'simplecov' +SimpleCov.start + +gem 'minitest', '>= 5.0.0' +require 'minitest/autorun' +require 'minitest/reporters' +require 'minitest/skip_dsl' +require 'minitest/pride' +require_relative '../lib/hotel.rb' +require_relative '../lib/block.rb' +# require_relative '../lib/date_range.rb' +# require_relative '../lib/reservation.rb' +# require_relative '../lib/room.rb' + +Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new