From cadf6716c0e7d234a423dd88268a7fb7c50bca26 Mon Sep 17 00:00:00 2001 From: Marcio Date: Sun, 11 Feb 2024 21:14:24 -0300 Subject: [PATCH] Feature/question class (#2) --- .vscode/settings.json | 3 + README.md | 52 ++++++ lib/mp_utils.rb | 2 + .../messages/question/bool_sufix.txt | 1 + .../messages/question/error/bool.txt | 3 + .../messages/question/error/float.txt | 2 + .../messages/question/error/index.txt | 1 + .../messages/question/error/integer.txt | 2 + .../messages/question/error/regex.txt | 2 + lib/resources/path_helper.rb | 2 +- lib/utils/array.rb | 22 +++ lib/utils/message.rb | 64 ++++--- lib/utils/question.rb | 115 +++++++++++++ lib/version.rb | 2 +- spec/utils/array_spec.rb | 52 ++++++ spec/utils/message_spec.rb | 7 +- spec/utils/question_spec.rb | 156 ++++++++++++++++++ 17 files changed, 457 insertions(+), 31 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 lib/resources/messages/question/bool_sufix.txt create mode 100644 lib/resources/messages/question/error/bool.txt create mode 100644 lib/resources/messages/question/error/float.txt create mode 100644 lib/resources/messages/question/error/index.txt create mode 100644 lib/resources/messages/question/error/integer.txt create mode 100644 lib/resources/messages/question/error/regex.txt create mode 100644 lib/utils/array.rb create mode 100644 lib/utils/question.rb create mode 100644 spec/utils/array_spec.rb create mode 100644 spec/utils/question_spec.rb diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2c62712 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "simplecov-vscode.enabled": true +} \ No newline at end of file diff --git a/README.md b/README.md index 4fef1d6..0b0edeb 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,58 @@ puts message.to_s # We hope you are well Alice! ``` +#### Question + +The Question class was created with the objective of asking questions to the user. +It receives a message that will be presented as a question to the user when a response method is called. +All messages received by the class are automatically placed in an instance of Message. +Here are some examples with the available answers: + +1. Boolean Answer + +```ruby +# Asking the user a yes/no question and processing the response. +question = Question.new("Do you like Ruby?") +puts question.bool_answer ? "You like Ruby!" : "You don't like Ruby?" +```` + +2. Float Answer + +```ruby +# Asking the user for a floating-point number, such as a version number. +question = Question.new("What is the value of pi?") +version = question.float_answer +puts "The value of pi is #{version}" +``` + +3. Integer Answer + +```ruby +# Prompting for an integer, for example, asking for a quantity. +question = Question.new("How many Ruby gems do you need?") +quantity = question.integer_answer +puts "You need #{quantity} gems." +``` + +4. Option Answer + +```ruby +# Allowing the user to choose from a list of options. +question = Question.new("Choose your preferred Ruby web framework:") +options = ["Rails", "Sinatra", "Hanami"] +framework = question.option_answer(options) +puts "You have chosen #{framework}." +``` + +5. String Answer + +```ruby +# Asking for a string input that matches a specific pattern, such as a name. +question = Question.new("What is your name?") +name = question.string_answer(regex: /^[A-Za-z ]+$/) +puts "Hello, #{name}!" +``` + ## Development To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). diff --git a/lib/mp_utils.rb b/lib/mp_utils.rb index e06643a..4f70f36 100644 --- a/lib/mp_utils.rb +++ b/lib/mp_utils.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true require 'utils/key' +require 'utils/array' require 'utils/message' +require 'utils/question' require 'resources/path_helper' diff --git a/lib/resources/messages/question/bool_sufix.txt b/lib/resources/messages/question/bool_sufix.txt new file mode 100644 index 0000000..60cf6f9 --- /dev/null +++ b/lib/resources/messages/question/bool_sufix.txt @@ -0,0 +1 @@ +(Yes/No/Y/N) \ No newline at end of file diff --git a/lib/resources/messages/question/error/bool.txt b/lib/resources/messages/question/error/bool.txt new file mode 100644 index 0000000..c14a1f4 --- /dev/null +++ b/lib/resources/messages/question/error/bool.txt @@ -0,0 +1,3 @@ +You need to answer with "Yes or No" only. +These are the valid response options: Yes, No, Y, N +Note: You can use both lowercase and uppercase letters for the answers. \ No newline at end of file diff --git a/lib/resources/messages/question/error/float.txt b/lib/resources/messages/question/error/float.txt new file mode 100644 index 0000000..97fdb7e --- /dev/null +++ b/lib/resources/messages/question/error/float.txt @@ -0,0 +1,2 @@ +You need to provide a numeric value with decimal places. +Example: 3143.14 \ No newline at end of file diff --git a/lib/resources/messages/question/error/index.txt b/lib/resources/messages/question/error/index.txt new file mode 100644 index 0000000..ebf276a --- /dev/null +++ b/lib/resources/messages/question/error/index.txt @@ -0,0 +1 @@ +The index value needs to be within the above listing. \ No newline at end of file diff --git a/lib/resources/messages/question/error/integer.txt b/lib/resources/messages/question/error/integer.txt new file mode 100644 index 0000000..2110f71 --- /dev/null +++ b/lib/resources/messages/question/error/integer.txt @@ -0,0 +1,2 @@ +You need to provide an integer numeric value. +Example: 123456 \ No newline at end of file diff --git a/lib/resources/messages/question/error/regex.txt b/lib/resources/messages/question/error/regex.txt new file mode 100644 index 0000000..18e8cf6 --- /dev/null +++ b/lib/resources/messages/question/error/regex.txt @@ -0,0 +1,2 @@ +Message does not meet the requested standard. +Please provide a message within the expected standard. \ No newline at end of file diff --git a/lib/resources/path_helper.rb b/lib/resources/path_helper.rb index 4d1e58e..6266e92 100644 --- a/lib/resources/path_helper.rb +++ b/lib/resources/path_helper.rb @@ -18,7 +18,7 @@ def self.library_path # {define} method. This method allows for easy retrieval of the custom path, facilitating flexible # resource management. # - # @return [String, nil] the custom resources path as defined by the environment variable, or nil if it hasn't been set. + # @return [String, nil] the custom path as defined by the environment variable, or nil if it hasn't been set. def self.custom_path ENV['SCRIPT_CUSTOM_RESOURCES'] end diff --git a/lib/utils/array.rb b/lib/utils/array.rb new file mode 100644 index 0000000..362078c --- /dev/null +++ b/lib/utils/array.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Extension to Ruby's Array class to enhance functionality. +class Array + # Lists all elements in the array with their index. + # + # This method outputs each element of the array to the console, prefixed by its index (1-based). + # The index is right-justified based on the length of the array, ensuring a tidy, column-aligned output. + # + # Example output for a 3-element array: + # |1| Element 1 + # |2| Element 2 + # |3| Element 3 + # + # @return [void] + def list_all_elements + index_size = count.to_s.length + each_index do |index| + puts "|#{(index + 1).to_s.rjust(index_size)}| #{self[index]}" + end + end +end diff --git a/lib/utils/message.rb b/lib/utils/message.rb index a21fc2c..1a4c4bd 100644 --- a/lib/utils/message.rb +++ b/lib/utils/message.rb @@ -20,10 +20,11 @@ class Message # Initializes a new instance of the Message class with a given message template. # # @param message [String] The message template to be used. - # @param replaces [String] The replaces Hash used to change key finded in @message by custom values. + # @param replaces [Hash] Used to replace all occurrences of a key with its value. def initialize(message, replaces: nil) raise 'Messsage replaces content need be a Hash' if !replaces.nil? && !replaces.is_a?(Hash) - @replaces = replaces + + @to_replace = replaces @message = message end @@ -42,29 +43,8 @@ def ==(other) # # @return [String] The formatted message with placeholders substituted with actual content. def to_s - new_message = String.new(@message) - keys = Key.find_keys_in(@message) - - if keys.count.positive? - keys.each do |key| - message = recover_message_with(key.value) - new_message.gsub!(key.to_s, message) if message != key.value - end - else - new_message = recover_message_with(new_message) - end - - if !@replaces.nil? - @replaces.each do |key, value| - if key.is_a?(Key) - new_message.gsub!(key.to_regexp, value.to_s) - else - new_message.gsub!(/#{Regexp.escape(key.to_s)}/, value.to_s) - end - end - end - - new_message + new_message = replace_message_keys(String.new(@message)) + replace_all_to_replace_elements(new_message) end end @@ -90,6 +70,40 @@ def library_path File.join(Resources.library_path, 'messages') end + # Replaces keys found in the original message with their corresponding values. + # + # @param message [String] The original message with keys to be replaced. + # @return [String] The message with keys replaced by their corresponding values. + def replace_message_keys(message) + keys = Key.find_keys_in(message) + return recover_message_with(message) if keys.empty? + + keys.each do |key| + key_message = recover_message_with(key.value) + message.gsub!(key.to_s, key_message) if key_message != key.value + end + + message + end + + # Replaces all placeholders in the message with their corresponding values from the @to_replace hash. + # + # @param message [String] The message with placeholders to replace. + # @return [String] The message with all placeholders replaced with actual content. + def replace_all_to_replace_elements(message) + return message if @to_replace.nil? + + @to_replace.each do |key, value| + if key.is_a?(Key) + message.gsub!(key.to_regexp, value.to_s) + else + message.gsub!(/#{Regexp.escape(key.to_s)}/, value.to_s) + end + end + + message + end + # Attempts to recover and return the content of a message file identified by the file_name parameter. # It first looks in the custom path (if defined) and then in the library path. # diff --git a/lib/utils/question.rb b/lib/utils/question.rb new file mode 100644 index 0000000..8bfd4f1 --- /dev/null +++ b/lib/utils/question.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require_relative 'message' +require_relative 'array' + +# The Question class facilitates the creation and management of interactive questions in the console. +# It provides methods to validate and return user input as various data types including boolean, +# float, integer, options (from a list), and string. +class Question + # Initializes a new instance of the Question class. + # + # @param message [String] The question message to be displayed to the user. + def initialize(message) + @message = Message.new(message) + end + + # Prompts the user with a boolean question and returns the answer as true or false. + # + # @param error_message [String, nil] Custom error message for invalid inputs. + # @return [Boolean] True if the user answers affirmatively, otherwise false. + def bool_answer(error_message: nil) + error = error_message || File.join('question', 'error', 'bool') + bool_sufix = Message.new(File.join('question', 'bool_sufix')) + result = read_input("#{@message} #{bool_sufix}", error_message: error) do |input| + input.match?(/^((Y|y)((E|e)(S|s))*)|((N|n)(O|o)*)$/) + end + + result.match?(/^(Y|y)((E|e)(S|s))*$/) + end + + # Prompts the user for a floating-point number and returns the value. + # + # @param error_message [String, nil] Custom error message for invalid inputs. + # @return [Float] The user's input converted to a float. + def float_answer(error_message: nil) + error = error_message || File.join('question', 'error', 'float') + string_answer(regex: /^\d+\.\d+$/, error_message: error).to_f + end + + # Prompts the user for an integer and returns the value. + # + # @param error_message [String, nil] Custom error message for invalid inputs. + # @return [Integer] The user's input converted to an integer. + def integer_answer(error_message: nil) + error = error_message || File.join('question', 'error', 'integer') + string_answer(regex: /^\d+$/, error_message: error).to_i + end + + # Prompts the user to select an option from a given list and returns the selected option. + # + # @param options [Array] The list of options for the user to choose from. + # @param error_message [String, nil] Custom error message for invalid inputs. + # @raise [RuntimeError] If options is not an Array or is empty. + # @return [Object] The selected option from the list. If options count is 1 return the first element. + def option_answer(options, error_message: nil) + raise 'Options should be an Array' unless options.is_a?(Array) + raise 'Options should not be empty' if options.empty? + return options.first if options.count == 1 + + options.list_all_elements + index = read_index_input(count: options.count, error_message: error_message) + options[index] + end + + # Prompts the user for a string that matches a given regular expression. + # + # @param regex [Regexp, nil] The regular expression the user's input must match. + # @param error_message [String, nil] Custom error message for invalid inputs. + # @return [String] The user's input if it matches the given regex. + def string_answer(regex: nil, error_message: nil) + error = error_message || File.join('question', 'error', 'regex') + read_input(error_message: error) do |input| + regex.nil? || input.match?(regex) + end + end + + private + + # Displays a message to the user and returns their input, if it satisfies the given condition. + # + # @param message [String] The message to display to the user. + # @param error_message [String] The error message to display for invalid inputs. + # @yieldparam input [String] The user's input to validate. + # @yieldreturn [Boolean] True if the input is valid, otherwise false. + # @return [String] The user's valid input. + def read_input(message = @message, error_message:) + loop do + puts message + input = $stdin.gets.chomp + return input if yield input + + puts Message.new(error_message) + end + end + + # Reads an index input from the user, ensuring it falls within a specified range. + # + # This method prompts the user to enter an index number. It validates the input to ensure + # it is an integer within the range of 0 to (count - 1). If the input is invalid, an error + # message is displayed, and the user is prompted again. + # + # @param count [Integer] The count of items, setting the upper limit of the valid index range. + # @param error_message [String, nil] Custom error message for invalid index inputs. + # Defaults to the path 'question/error/index' if nil. + # @return [Integer] The user's input adjusted to be zero-based (input - 1) and validated to be within the range. + def read_index_input(count:, error_message: nil) + loop do + index = integer_answer(error_message: error_message) - 1 + return index if index >= 0 && index < count + + error = error_message || File.join('question', 'error', 'index') + puts Message.new(error) + end + end +end diff --git a/lib/version.rb b/lib/version.rb index 488c253..c66be2b 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MPUtils - VERSION = '0.1.3' + VERSION = '0.2.1' end diff --git a/spec/utils/array_spec.rb b/spec/utils/array_spec.rb new file mode 100644 index 0000000..9ff62f3 --- /dev/null +++ b/spec/utils/array_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative '../../lib/utils/array' + +RSpec.describe Array do + describe '#list_all_elements' do + context 'when the array is empty' do + it 'prints nothing' do + expect { [].list_all_elements }.to output('').to_stdout + end + end + + context 'when the array has one element' do + it 'prints the element with its index' do + expect { ['Element'].list_all_elements }.to output("|1| Element\n").to_stdout + end + end + + context 'when the array has multiple elements' do + it 'prints all elements with their indices, aligned properly' do + array = %w[First Second Third] + expected_output = <<~OUTPUT + |1| First + |2| Second + |3| Third + OUTPUT + expect { array.list_all_elements }.to output(expected_output).to_stdout + end + end + + context 'with a larger set of elements affecting index alignment' do + it 'adjusts the index alignment based on the number of elements' do + array = (1..12).to_a + expected_output = <<~OUTPUT + | 1| 1 + | 2| 2 + | 3| 3 + | 4| 4 + | 5| 5 + | 6| 6 + | 7| 7 + | 8| 8 + | 9| 9 + |10| 10 + |11| 11 + |12| 12 + OUTPUT + expect { array.list_all_elements }.to output(expected_output).to_stdout + end + end + end +end diff --git a/spec/utils/message_spec.rb b/spec/utils/message_spec.rb index dfb51be..e0e5eac 100644 --- a/spec/utils/message_spec.rb +++ b/spec/utils/message_spec.rb @@ -5,11 +5,10 @@ RSpec.describe Message do describe '.new' do it 'should raise error if object is not a Hash' do - expect {Message.new('', replaces: [])}.to raise_error('Messsage replaces content need be a Hash') + expect { Message.new('', replaces: []) }.to raise_error('Messsage replaces content need be a Hash') end end - describe '.equals' do it 'instances with same message should be equal' do value = 'Minha mensagem para testes' @@ -44,7 +43,7 @@ end it 'should replace keys with given replaces values' do - replaces = { Key.new('username') => 'Alice', Key.new('code') => 'XYZ123'} + replaces = { Key.new('username') => 'Alice', Key.new('code') => 'XYZ123' } message = 'Welcome, <||username||>! Your code is <||code||>.' message += "\nWe hope you are well <||username||>!" @@ -55,7 +54,7 @@ it 'should replace valid keys with resource messages and replace keys with given replaces values' do message = "<||hellow_world||>\nWe hope you are well <||username||>!" - replaces = { '<||username||>' => 'Alice' } + replaces = { '<||username||>' => 'Alice' } result = "Hellow World from MPUtils!\nWe hope you are well Alice!" expect(Message.new(message, replaces: replaces).to_s).to eq(result) diff --git a/spec/utils/question_spec.rb b/spec/utils/question_spec.rb new file mode 100644 index 0000000..f797646 --- /dev/null +++ b/spec/utils/question_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require_relative '../../lib/utils/question' + +RSpec.describe Question do + response = 'This message should be replaced' + + describe '.string_answer' do + let(:input_response) { 'My name is Alice' } + let(:question) { Question.new(message) } + let(:message) { 'What is your name?' } + let(:regex) { /^(\w|\s)+$/ } + + it "should return the given user's input" do + expected_output = <<~OUTPUT + #{message} + OUTPUT + + allow($stdin).to receive(:gets).and_return("#{input_response} ;)") + expect { response = question.string_answer }.to output(expected_output).to_stdout + expect(response).to eq("#{input_response} ;)") + end + + it "should returns the user's input if it matches the given regex" do + expected_output = <<~OUTPUT + #{message} + Message does not meet the requested standard. + Please provide a message within the expected standard. + #{message} + OUTPUT + + allow($stdin).to receive(:gets).and_return("#{input_response} ;)", input_response) + expect { response = question.string_answer(regex: regex) }.to output(expected_output).to_stdout + expect(response).to eq(input_response) + end + end + + describe '.bool_answer' do + let(:question) { Question.new('Is Ruby fun to learn?') } + let(:expected_output) do + <<~OUTPUT + Is Ruby fun to learn? (Yes/No/Y/N) + You need to answer with "Yes or No" only. + These are the valid response options: Yes, No, Y, N + Note: You can use both lowercase and uppercase letters for the answers. + Is Ruby fun to learn? (Yes/No/Y/N) + OUTPUT + end + + it "should returns true for a 'yes' answer" do + allow($stdin).to receive(:gets).and_return('batata', 'yes') + expect { response = question.bool_answer }.to output(expected_output).to_stdout + expect(response).to eq(true) + end + + it "should returns false for a 'no' answer" do + response = true + + allow($stdin).to receive(:gets).and_return('o', 'no') + expect { response = question.bool_answer }.to output(expected_output).to_stdout + expect(response).to eq(false) + end + + it "should returns true for a 'Y' answer" do + allow($stdin).to receive(:gets).and_return('es', 'Y') + expect { response = question.bool_answer }.to output(expected_output).to_stdout + expect(response).to eq(true) + end + + it "should returns false for a 'N' answer" do + response = true + + allow($stdin).to receive(:gets).and_return('s', 'N') + expect { response = question.bool_answer }.to output(expected_output).to_stdout + expect(response).to eq(false) + end + end + + describe '.float_answer' do + let(:message) { 'Enter a floating-point number:' } + let(:question) { Question.new(message) } + + it 'returns the input converted to a float' do + expected_output = <<~OUTPUT + #{message} + You need to provide a numeric value with decimal places. + Example: 3143.14 + #{message} + You need to provide a numeric value with decimal places. + Example: 3143.14 + #{message} + OUTPUT + + allow($stdin).to receive(:gets).and_return('a', '3', '3.14') + expect { response = question.float_answer }.to output(expected_output).to_stdout + expect(response).to eq(3.14) + end + end + + describe '.integer_answer' do + let(:question) { Question.new(message) } + let(:message) { 'Enter an integer:' } + + it 'returns the input converted to an integer' do + expected_output = <<~OUTPUT + #{message} + You need to provide an integer numeric value. + Example: 123456 + #{message} + You need to provide an integer numeric value. + Example: 123456 + #{message} + OUTPUT + + allow($stdin).to receive(:gets).and_return('b', '3.14', '42') + expect { response = question.integer_answer }.to output(expected_output).to_stdout + expect(response).to eq(42) + end + end + + describe '.option_answer' do + options = %w[option1 option2 option3] + let(:question) { Question.new('Choose an option:') } + + it 'raises an error if options is not an array' do + expect { question.option_answer('not an array') }.to raise_error(RuntimeError, 'Options should be an Array') + end + + it 'raises an error if options array is empty' do + expect { question.option_answer([]) }.to raise_error(RuntimeError, 'Options should not be empty') + end + + it 'returns first option if array has only one element' do + option = 'Single Option' + expect(question.option_answer([option])).to eq(option) + end + + it 'returns the selected option from the list' do + expected_output = <<~OUTPUT + |1| option1 + |2| option2 + |3| option3 + Choose an option: + You need to provide an integer numeric value. + Example: 123456 + Choose an option: + The index value needs to be within the above listing. + Choose an option: + OUTPUT + + allow($stdin).to receive(:gets).and_return("A\n", "5\n", "2\n") + expect { response = question.option_answer(options) }.to output(expected_output).to_stdout + expect(response).to eq('option2') + end + end +end