diff --git a/.travis.yml b/.travis.yml index dcfd6f1..af2941d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,9 @@ language: ruby rvm: - - 2.7 - 3.0 + - 3.1 + - 3.2 + - 3.3 - ruby-head branches: except: diff --git a/README.md b/README.md index 219c935..12bae33 100644 --- a/README.md +++ b/README.md @@ -318,12 +318,9 @@ Examples: If you need to validate if a specific text is fulfilling the pattern you can use the validate method. -If a string pattern supplied and no other parameters supplied the output will be an array with the errors detected. +When you supply a single pattern and do **not** supply `expected_errors` or `not_expected_errors`, the method returns an **array of error symbols**: an empty array `[]` when the text is valid, or one or more of `:min_length`, `:max_length`, `:length`, `:value`, `:string_set_not_allowed`, `:required_data`, `:excluded_data` when invalid. - -Possible output values, empty array (validation without errors detected) or one or more of: :min_length, :max_length, :length, :value, :string_set_not_allowed, :required_data, :excluded_data - -In case an array of patterns supplied it will return only true or false +When an array of patterns is supplied, the method returns only `true` or `false`. Examples: @@ -443,6 +440,69 @@ StringPattern.block_list_enabled = true "2-20:Tn".gen #>AAñ34Ef99éNOP ``` +#### StringPattern.analyze + +To inspect how a pattern is parsed without generating or validating: + +```ruby +p = StringPattern.analyze("10-20:LN/x/") +# => # +p.min_length # => 10 +p.max_length # => 20 +p.symbol_type # => "LN/x/" +``` + +Useful for debugging or building tools on top of the pattern DSL. Invalid patterns return the pattern string; use `silent: true` to avoid logging. + +#### Error handling and logging + +By default, when generation is impossible (e.g. invalid pattern or `dont_repeat` exhausted), `generate` returns an empty string `""` and a message is printed. You can: + +- Set `StringPattern.logger = Logger.new($stderr)` to send messages to a logger instead of `puts`. +- Set `StringPattern.raise_on_error = true` to raise `StringPattern::GenerationImpossibleError` or `StringPattern::InvalidPatternError` instead of returning `""`. + +#### Reproducible generation (seed) + +Pass `seed:` to get the same string for the same pattern in tests: + +```ruby +"10:N".gen(seed: 42) # => same result every time +``` + +#### Batch generation (sample) + +Generate up to `n` distinct strings without mutating the global dont_repeat cache: + +```ruby +StringPattern.sample("4:N", 10) # => array of 10 distinct 4-digit strings +``` + +#### Boolean validation (valid?) + +Check if text matches a pattern without building the full error list: + +```ruby +StringPattern.valid?(text: "user@domain.com", pattern: "14-40:@") # => true +``` + +#### UUID + +Generate a random UUID v4 or validate one: + +```ruby +StringPattern.uuid # => "550e8400-e29b-41d4-a716-446655440000" +StringPattern.valid_uuid?(some_str) # => true or false +``` + +#### block_list as Proc + +You can set `block_list` to a Proc for custom blocking: + +```ruby +StringPattern.block_list = ->(s) { s.include?("forbidden") } +StringPattern.block_list_enabled = true +``` + ## Contributing diff --git a/lib/string/pattern/add_to_ruby.rb b/lib/string/pattern/add_to_ruby.rb index 511b1c8..adcc06b 100644 --- a/lib/string/pattern/add_to_ruby.rb +++ b/lib/string/pattern/add_to_ruby.rb @@ -106,7 +106,7 @@ def to_sp elsif token == :literal and text.size == 2 text = text[1] else - puts "Report token not controlled: type: #{type}, token: #{token}, text: '#{text}' [#{ts}..#{te}]" + StringPattern.log_message("Report token not controlled: type: #{type}, token: #{token}, text: '#{text}' [#{ts}..#{te}]") end end @@ -165,7 +165,7 @@ def to_sp set_negate = false else pats += "]" - end + end end elsif type == :group @@ -190,7 +190,6 @@ def to_sp patg << pats pats = "" elsif patg.empty? - # for the case the first element was not added to patg and was on pata fex: (a+|b|c) patg << pata.pop end end @@ -299,11 +298,11 @@ def to_sp end if pats != "" if pata.empty? - if pats[0] == "[" and pats[-1] == "]" #fex: /[12ab]/ + if pats[0] == "[" and pats[-1] == "]" pata = ["1:#{pats}"] end else - pata[-1] += pats[1] #fex: /allo/ + pata[-1] += pats[1] end end if pata.size == 1 and pata[0].kind_of?(String) @@ -325,7 +324,7 @@ def generate(pattern, expected_errors: [], **synonyms) if pattern.is_a?(String) || pattern.is_a?(Array) || pattern.is_a?(Symbol) || pattern.is_a?(Regexp) StringPattern.generate(pattern, expected_errors: expected_errors, **synonyms) else - puts " Kernel generate method: class not recognized:#{pattern.class}" + StringPattern.log_message(" Kernel generate method: class not recognized:#{pattern.class}") end end diff --git a/lib/string/pattern/analyze.rb b/lib/string/pattern/analyze.rb index df65494..3873a9a 100644 --- a/lib/string/pattern/analyze.rb +++ b/lib/string/pattern/analyze.rb @@ -1,8 +1,8 @@ class StringPattern - ############################################### - # Analyze the pattern supplied and returns an object of Pattern structure including: - # min_length, max_length, symbol_type, required_data, excluded_data, data_provided, string_set, all_characters_set - ############################################### + # Analyzes a pattern string and returns a Pattern struct. + # @param pattern [String, Symbol] Pattern in format "length:type" or "min-max:type" (e.g. "10:N", "5-15:L") + # @param silent [Boolean] If true, invalid patterns do not log a message. + # @return [Struct, String] Pattern struct with min_length, max_length, symbol_type, required_data, excluded_data, data_provided, string_set, all_characters_set, unique; or the pattern string if invalid. def StringPattern.analyze(pattern, silent: false) #unless @cache[pattern.to_s].nil? # return Pattern.new(@cache[pattern.to_s].min_length.clone, @cache[pattern.to_s].max_length.clone, @@ -16,7 +16,7 @@ def StringPattern.analyze(pattern, silent: false) min_length, symbol_type = pattern.to_s.scan(/^!?(\d+):(.+)/)[0] max_length = min_length if min_length.nil? - puts "pattern argument not valid on StringPattern.generate: #{pattern.inspect}" unless silent + StringPattern.log_message("pattern argument not valid on StringPattern.generate: #{pattern.inspect}") unless silent return pattern.to_s end end diff --git a/lib/string/pattern/email.rb b/lib/string/pattern/email.rb new file mode 100644 index 0000000..a79fc34 --- /dev/null +++ b/lib/string/pattern/email.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class StringPattern + # Validates email format using the same rules as pattern type @: + # - Forbids consecutive/adjacent invalid sequences (.. __ -- etc.) + # - Local part: [a-z0-9]+([\+\._\-][a-z0-9])* + # - Domain part: [0-9a-z]+([\.-][a-z0-9])* + def self.valid_email?(string) + return false if string.nil? || !string.is_a?(String) + return false if string.index("@").to_i <= 0 + + wrong = %w(.. __ -- ._ _. .- -. _- -_ @. @_ @- .@ _@ -@ @@) + return false if Regexp.union(*wrong) === string + + local = string[0..(string.index("@") - 1)] + domain = string[(string.index("@") + 1)..-1] + local_ok = local.scan(/([a-z0-9]+([\+\._\-][a-z0-9]|)*)/i).join == local + domain_ok = domain.scan(/([0-9a-z]+([\.-][a-z0-9]|)*)/i).join == domain + local_ok && domain_ok + end +end diff --git a/lib/string/pattern/generate.rb b/lib/string/pattern/generate.rb index eb7b108..37ce1b6 100644 --- a/lib/string/pattern/generate.rb +++ b/lib/string/pattern/generate.rb @@ -64,6 +64,8 @@ class StringPattern # the generated string ############################################### def StringPattern.generate(pattern, expected_errors: [], **synonyms) + seed_given = synonyms.key?(:seed) + saved_rng = seed_given ? srand(synonyms[:seed]) : nil tries = 0 begin good_result = true @@ -95,7 +97,7 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) string << pat end else - puts "StringPattern.generate: it seems you supplied wrong array of patterns: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("StringPattern.generate: it seems you supplied wrong array of patterns: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return "" end } @@ -120,7 +122,7 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) } unless excluded_data.size == 0 if (required_chars.flatten & excluded_data.flatten).size > 0 - puts "pattern argument not valid on StringPattern.generate, a character cannot be required and excluded at the same time: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("pattern argument not valid on StringPattern.generate, a character cannot be required and excluded at the same time: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return "" end end @@ -130,7 +132,7 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) elsif pattern.kind_of?(Regexp) return generate(pattern.to_sp, expected_errors: expected_errors) else - puts "pattern argument not valid on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("pattern argument not valid on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return pattern.to_s end @@ -169,12 +171,12 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) unless deny_pattern if required_data.size == 0 and expected_errors_left.include?(:required_data) - puts "required data not supplied on pattern so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("required data not supplied on pattern so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return "" end if excluded_data.size == 0 and expected_errors_left.include?(:excluded_data) - puts "excluded data not supplied on pattern so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("excluded data not supplied on pattern so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return "" end @@ -182,7 +184,7 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) string_set_not_allowed = all_characters_set - string_set if string_set_not_allowed.size == 0 - puts "all characters are allowed so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("all characters are allowed so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return "" end end @@ -205,7 +207,7 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) expected_errors_left.delete(:length) expected_errors_left.delete(:min_length) else - puts "min_length is 0 so it won't be possible to generate a wrong string smaller than 0 characters. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("min_length is 0 so it won't be possible to generate a wrong string smaller than 0 characters. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return "" end elsif expected_errors_left.include?(:max_length) or expected_errors_left.include?(:length) @@ -264,7 +266,7 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) end if ((0...string.length).find_all { |i| string[i, 1] == rd_to_set }).size == 0 if positions_to_set.size == 0 - puts "pattern not valid on StringPattern.generate, not possible to generate a valid string: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("pattern not valid on StringPattern.generate, not possible to generate a valid string: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return "" else k = positions_to_set.sample @@ -289,7 +291,7 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) string_set_not_allowed = all_characters_set - string_set if string_set_not_allowed.size == 0 if string_set_not_allowed.size == 0 - puts "Not possible to generate a non valid string on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("Not possible to generate a non valid string on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return "" end (rand(string.size) + 1).times { @@ -502,24 +504,11 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) expected_errors_left.delete(:string_set_not_allowed) end - error_regular_expression = false + error_regular_expression = !StringPattern.valid_email?(string) if deny_pattern and expected_errors.include?(:length) good_result = true #it is already with wrong length else - # I'm doing this because many times the regular expression checking hangs with these characters - wrong = %w(.. __ -- ._ _. .- -. _- -_ @. @_ @- .@ _@ -@ @@) - if !(Regexp.union(*wrong) === string) #don't include any or the wrong strings - if string.index("@").to_i > 0 and - string[0..(string.index("@") - 1)].scan(/([a-z0-9]+([\+\._\-][a-z0-9]|)*)/i).join == string[0..(string.index("@") - 1)] and - string[(string.index("@") + 1)..-1].scan(/([0-9a-z]+([\.-][a-z0-9]|)*)/i).join == string[string[(string.index("@") + 1)..-1]] - error_regular_expression = false - else - error_regular_expression = true - end - else - error_regular_expression = true - end if expected_errors.size == 0 if error_regular_expression @@ -540,7 +529,9 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) end end until good_result or tries > 100 unless good_result - puts "Not possible to generate an email on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + msg = "Not possible to generate an email on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + raise StringPattern::GenerationImpossibleError, msg if @raise_on_error + StringPattern.log_message(msg) return "" end end @@ -569,7 +560,9 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) end end if @block_list_enabled - if @block_list.is_a?(Array) + if @block_list.respond_to?(:call) + good_result = false if @block_list.call(string) + elsif @block_list.is_a?(Array) @block_list.each do |bl| if string.match?(/#{bl}/i) good_result = false @@ -580,10 +573,14 @@ def StringPattern.generate(pattern, expected_errors: [], **synonyms) end end until good_result or tries > 10000 unless good_result - puts "Not possible to generate the string on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" - puts "Take in consideration if you are using StringPattern.dont_repeat=true that you don't try to generate more strings that are possible to be generated" + msg = "Not possible to generate the string on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + msg += "\nTake in consideration if you are using StringPattern.dont_repeat=true that you don't try to generate more strings that are possible to be generated" + raise StringPattern::GenerationImpossibleError, msg if @raise_on_error + StringPattern.log_message(msg) return "" end return string + ensure + srand(saved_rng) if saved_rng end end diff --git a/lib/string/pattern/validate.rb b/lib/string/pattern/validate.rb index 325d898..7b74eac 100644 --- a/lib/string/pattern/validate.rb +++ b/lib/string/pattern/validate.rb @@ -54,7 +54,7 @@ def StringPattern.validate(text: "", pattern: "", expected_errors: [], not_expec max_length = patt.max_length.clone symbol_type = patt.symbol_type.clone else - puts "String pattern class not supported (#{pat.class} for #{pat})" + StringPattern.log_message("String pattern class not supported (#{pat.class} for #{pat})") return false end @@ -133,7 +133,7 @@ def StringPattern.validate(text: "", pattern: "", expected_errors: [], not_expec required_chars << rd if rd.size == 1 } if (required_chars.flatten & excluded_data.flatten).size > 0 - puts "pattern argument not valid on StringPattern.validate, a character cannot be required and excluded at the same time: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}" + StringPattern.log_message("pattern argument not valid on StringPattern.validate, a character cannot be required and excluded at the same time: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}") return "" end end @@ -183,20 +183,7 @@ def StringPattern.validate(text: "", pattern: "", expected_errors: [], not_expec end } else #symbol_type=="@" - string = text_to_validate - wrong = %w(.. __ -- ._ _. .- -. _- -_ @. @_ @- .@ _@ -@ @@) - if !(Regexp.union(*wrong) === string) #don't include any or the wrong strings - if string.index("@").to_i > 0 and - string[0..(string.index("@") - 1)].scan(/([a-z0-9]+([\+\._\-][a-z0-9]|)*)/i).join == string[0..(string.index("@") - 1)] and - string[(string.index("@") + 1)..-1].scan(/([0-9a-z]+([\.-][a-z0-9]|)*)/i).join == string[string[(string.index("@") + 1)..-1]] - error_regular_expression = false - else - error_regular_expression = true - end - else - error_regular_expression = true - end - + error_regular_expression = !StringPattern.valid_email?(text_to_validate) if error_regular_expression detected_errors.push(:value) end diff --git a/lib/string_pattern.rb b/lib/string_pattern.rb index 188a9e7..45f4c71 100644 --- a/lib/string_pattern.rb +++ b/lib/string_pattern.rb @@ -1,6 +1,7 @@ SP_ADD_TO_RUBY = true if !defined?(SP_ADD_TO_RUBY) require_relative "string/pattern/add_to_ruby" if SP_ADD_TO_RUBY require_relative "string/pattern/analyze" +require_relative "string/pattern/email" require_relative "string/pattern/generate" require_relative "string/pattern/validate" @@ -29,8 +30,22 @@ # Array of words to be avoided from resultant strings. # block_list_enabled: (TrueFalse, default: false) # If true block_list will be take in consideration +# +# @example Generate a string +# StringPattern.generate("10:N") # => "3448910834" +# @example Validate and get errors +# StringPattern.validate(text: "ab", pattern: "6:N") # => [:min_length, :length] class StringPattern + # Raised when an invalid pattern is used and {raise_on_error} is true. + InvalidPatternError = Class.new(StandardError) + # Raised when generation is impossible (e.g. exhausted by dont_repeat) and {raise_on_error} is true. + GenerationImpossibleError = Class.new(StandardError) + class << self + # @return [String, nil] When set, warning messages are sent here instead of +puts+. + attr_accessor :logger + # @return [Boolean] When true, invalid patterns or impossible generation raise instead of returning "". + attr_accessor :raise_on_error attr_accessor :national_chars, :optimistic, :dont_repeat, :cache, :cache_values, :default_infinite, :word_separator, :block_list, :block_list_enabled end @national_chars = (("a".."z").to_a + ("A".."Z").to_a).join @@ -42,6 +57,8 @@ class << self @word_separator = "_" @block_list_enabled = false @block_list = [] + @logger = nil + @raise_on_error = false NUMBER_SET = ("0".."9").to_a SPECIAL_SET = [" ", "~", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "-", "_", "+", "=", "{", "}", "[", "]", "'", ";", ":", "?", ">", "<", "`", "|", "/", '"'] ALPHA_SET_LOWER = ("a".."z").to_a @@ -63,4 +80,60 @@ def self.national_chars=(par) @national_chars = par end + def self.log_message(message) + if @logger + @logger.warn(message) + else + puts message + end + end + + # Returns true if +text+ matches +pattern+; false otherwise. Uses validate under the hood. + # @param text [String] (synonyms: text_to_validate, validate) + # @param pattern [String, Symbol, Array] + # @return [Boolean] + def self.valid?(text: nil, pattern: nil, **synonyms) + text = synonyms[:text_to_validate] if text.nil? && synonyms.key?(:text_to_validate) + text = synonyms[:validate] if text.nil? && synonyms.key?(:validate) + return false if text.nil? || pattern.nil? + result = validate(text: text, pattern: pattern, **synonyms) + pattern.is_a?(Array) ? result == true : result.is_a?(Array) && result.empty? + end + + # Generates up to +n+ distinct strings for +pattern+. Uses a temporary dont_repeat state. + # @param pattern [String, Symbol, Array, Regexp] + # @param n [Integer] + # @return [Array] + def self.sample(pattern, n) + return [] if n <= 0 + old_dont = @dont_repeat + old_cache = @cache_values.dup + @dont_repeat = true + @cache_values = {} + results = [] + n.times do + s = generate(pattern) + break if s.nil? || s.empty? + results << s + end + results + ensure + @dont_repeat = old_dont + @cache_values = old_cache + end + + # Generates a random UUID v4 (e.g. "550e8400-e29b-41d4-a716-446655440000"). + # @return [String] + def self.uuid + require "securerandom" + SecureRandom.uuid + end + + # Returns true if +str+ is a valid UUID v4 format (8-4-4-4-12 hex with version and variant bits). + # @param str [String] + # @return [Boolean] + def self.valid_uuid?(str) + return false unless str.is_a?(String) + str.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i) + end end diff --git a/spec/string/pattern/analyze_spec.rb b/spec/string/pattern/analyze_spec.rb new file mode 100644 index 0000000..c495e50 --- /dev/null +++ b/spec/string/pattern/analyze_spec.rb @@ -0,0 +1,109 @@ +require "string_pattern" + +RSpec.describe StringPattern, ".analyze" do + describe "single pattern" do + it "returns a Pattern struct for simple length:symbol_type" do + p = StringPattern.analyze("10:N") + expect(p).to be_a(Struct) + expect(p.min_length).to eq(10) + expect(p.max_length).to eq(10) + expect(p.symbol_type).to eq("N") + expect(p.unique).to eq(false) + end + + it "returns pattern with range min-max" do + p = StringPattern.analyze("5-15:L") + expect(p.min_length).to eq(5) + expect(p.max_length).to eq(15) + expect(p.symbol_type).to include("L") + end + end + + describe "symbol types" do + it "parses N/n for numbers" do + p = StringPattern.analyze("3:N") + expect(p.symbol_type.downcase).to include("n") + expect(p.string_set).to include(*StringPattern::NUMBER_SET) + end + + it "parses L for letters" do + p = StringPattern.analyze("4:L") + expect(p.symbol_type).to include("L") + expect(p.string_set).to include("a", "A") + end + + it "parses x for lowercase" do + p = StringPattern.analyze("2:x") + expect(p.symbol_type).to include("x") + end + + it "parses X for uppercase" do + p = StringPattern.analyze("2:X") + expect(p.symbol_type).to include("X") + end + + it "parses @ for email" do + p = StringPattern.analyze("20:@") + expect(p.symbol_type).to eq("@") + end + end + + describe "required and excluded data" do + it "parses required characters /x/" do + p = StringPattern.analyze("6:XN/x/") + expect(p.required_data).not_to be_empty + end + + it "parses excluded characters [%0%]" do + p = StringPattern.analyze("10:N[%0%]") + expect(p.excluded_data).not_to be_empty + expect(p.excluded_data.flatten).to include("0") + end + + it "parses data_provided [abc]" do + p = StringPattern.analyze("5:[abc]") + expect(p.data_provided).to include("a", "b", "c") + end + end + + describe "unique flag" do + it "sets unique true when pattern ends with &" do + p = StringPattern.analyze("6-10:L&") + expect(p.unique).to eq(true) + expect(p.symbol_type).to eq("L") + end + + it "sets unique false when no &" do + p = StringPattern.analyze("6:L") + expect(p.unique).to eq(false) + end + end + + describe "deny pattern !" do + it "includes ! in symbol_type when pattern starts with !" do + p = StringPattern.analyze("!10:N") + expect(p.symbol_type).to start_with("!") + end + end + + describe "silent" do + it "returns pattern string for invalid pattern and does not put when silent: true" do + expect { StringPattern.analyze(":N", silent: true) }.not_to output.to_stdout + expect(StringPattern.analyze(":N", silent: true)).to eq(":N") + end + + it "prints message for invalid pattern when silent: false" do + expect { StringPattern.analyze(":N", silent: false) }.to output(/pattern argument not valid/).to_stdout + expect(StringPattern.analyze(":N", silent: false)).to eq(":N") + end + end + + describe "caching" do + it "returns cached result for same pattern" do + p1 = StringPattern.analyze("7:Tn") + p2 = StringPattern.analyze("7:Tn") + expect(p1.min_length).to eq(p2.min_length) + expect(p1.symbol_type).to eq(p2.symbol_type) + end + end +end diff --git a/spec/string/pattern/generate_spec.rb b/spec/string/pattern/generate_spec.rb index a7ec19e..51ca075 100644 --- a/spec/string/pattern/generate_spec.rb +++ b/spec/string/pattern/generate_spec.rb @@ -226,6 +226,11 @@ it 'returns wrong email when expected_errors :string_set_not_allowed' do expect("30:@".gen(errors: :string_set_not_allowed)).not_to match(/(\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z)/i) end + it "generated email passes validation (regression)" do + email = "25-35:@".gen + expect(email).not_to eq("") + expect("25-35:@".validate(email)).to eq([]) + end end describe "expected errors" do @@ -287,7 +292,7 @@ expect('0-2:N'.gen(errors: :min_length)).to eq '' expect { '0-2:N:*'.gen(errors: :min_length) }.to output(/min_length is 0 so it won't be possible to generate a wrong string smaller than 0 characters/).to_stdout end - + end describe "array of patterns" do @@ -491,12 +496,59 @@ it "doesn't return any of block list" do StringPattern.block_list_enabled = true StringPattern.block_list = ('a'..'x').to_a - 5.times do + 5.times do expect("2:L".gen).to match(/^[yz]{2}$/i) end end + it "supports block_list as Proc" do + StringPattern.block_list_enabled = true + StringPattern.block_list = ->(s) { s.include?("bad") } + 5.times do + val = "5:L".gen + expect(val).not_to include("bad") + end + StringPattern.block_list = [] + StringPattern.block_list_enabled = false + end + end + + describe "seed (reproducible generation)" do + it "produces the same string for the same seed" do + a = "10:N".gen(seed: 42) + b = "10:N".gen(seed: 42) + expect(a).to eq(b) + end + it "produces different strings for different seeds" do + a = "10:N".gen(seed: 1) + b = "10:N".gen(seed: 2) + expect(a).not_to eq(b) + end end + describe "sample" do + it "returns n distinct strings when possible" do + results = StringPattern.sample("2:N", 5) + expect(results.size).to eq(5) + expect(results.uniq.size).to eq(5) + end + it "returns array of strings matching pattern" do + results = StringPattern.sample("3:N", 3) + results.each { |s| expect(s).to match(/^[0-9]{3}$/) } + end + end + describe "uuid and valid_uuid?" do + it "generates a valid UUID v4" do + u = StringPattern.uuid + expect(u).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i) + end + it "valid_uuid? returns true for valid UUID" do + expect(StringPattern.valid_uuid?("550e8400-e29b-41d4-a716-446655440000")).to eq(true) + end + it "valid_uuid? returns false for invalid string" do + expect(StringPattern.valid_uuid?("not-a-uuid")).to eq(false) + expect(StringPattern.valid_uuid?("550e8400-e29b-41d4-a716-44665544000")).to eq(false) + end + end end end diff --git a/spec/string/pattern/validate_spec.rb b/spec/string/pattern/validate_spec.rb index c3f2a79..8d67cce 100644 --- a/spec/string/pattern/validate_spec.rb +++ b/spec/string/pattern/validate_spec.rb @@ -5,6 +5,9 @@ it "returns empty when good validation" do expect("6:N".validate("333444")).to eq([]) end + it "returns false when text is nil" do + expect(StringPattern.validate(text: nil, pattern: "6:N")).to eq(false) + end it "returns true when good validation for array of patterns" do expect(["3:n", "3:x"].validate("333xxx")).to eq(true) end @@ -16,6 +19,27 @@ expect { ['1:N',3].validate('3')}.to output(/String pattern class not supported/).to_stdout end end + + describe "valid?" do + it "returns true when text matches pattern" do + expect(StringPattern.valid?(text: "333444", pattern: "6:N")).to eq(true) + end + it "returns false when text does not match" do + expect(StringPattern.valid?(text: "33", pattern: "6:N")).to eq(false) + end + it "returns true for array pattern when valid" do + expect(StringPattern.valid?(text: "333xxx", pattern: ["3:n", "3:x"])).to eq(true) + end + end + + describe "edge cases" do + it "handles block_list_enabled without block_list" do + StringPattern.block_list_enabled = false + StringPattern.block_list = [] + expect("6:N".validate("333444")).to eq([]) + end + end + describe "length" do it "returns :min_length when wrong :min_length" do expect("6:N".validate("33344")).to include(:min_length) @@ -66,6 +90,22 @@ end end + describe "email validation (regression)" do + it "returns empty array for valid email within length" do + expect("14-40:@".validate("user@domain.com")).to eq([]) + expect("10-40:@".validate("test.user+tag@example.co.uk")).to eq([]) + end + it "returns :value for invalid email missing local part" do + expect("1-40:@".validate("@domain.com")).to include(:value) + end + it "returns :value for invalid email with consecutive dots" do + expect("1-40:@".validate("user@domain..com")).to include(:value) + end + it "returns :value for invalid email with double at-sign" do + expect("1-40:@".validate("user@@domain.com")).to include(:value) + end + end + describe "expected_errors" do it "admits alias :errors" do expect("6:N".validate("335344", errors: [:value])).to eq(false) diff --git a/string_pattern.gemspec b/string_pattern.gemspec index c11d6ce..6438eec 100644 --- a/string_pattern.gemspec +++ b/string_pattern.gemspec @@ -1,12 +1,12 @@ Gem::Specification.new do |s| s.name = 'string_pattern' - s.version = '2.3.0' + s.version = '2.4.0' s.summary = "Generate easily random strings following a simple pattern or regular expression. '10-20:Xn/x/'.generate #>qBstvc6JN8ra. Also generate words in English or Spanish. Perfect to be used in test data factories." s.description = "Easily generate strings supplying a very simple pattern. '10-20:Xn/x/'.generate #>qBstvc6JN8ra. Generate random strings using a regular expression (Regexp): /[a-z0-9]{2,5}\w+/.gen . Also generate words in English or Spanish. Perfect to be used in test data factories. Also, validate if a text fulfills a specific pattern or even generate a string following a pattern and returning the wrong length, value... for testing your applications." s.authors = ["Mario Ruiz"] s.email = 'marioruizs@gmail.com' s.files = ["lib/string_pattern.rb","lib/string/pattern/add_to_ruby.rb", "lib/string/pattern/analyze.rb", - "lib/string/pattern/generate.rb", "lib/string/pattern/validate.rb", + "lib/string/pattern/email.rb", "lib/string/pattern/generate.rb", "lib/string/pattern/validate.rb", "LICENSE","README.md",".yardopts", 'data/english/adjs.json', 'data/english/nouns.json', 'data/spanish/palabras0.json', 'data/spanish/palabras1.json','data/spanish/palabras2.json','data/spanish/palabras3.json', @@ -18,4 +18,3 @@ Gem::Specification.new do |s| s.license = 'MIT' s.add_runtime_dependency 'regexp_parser', '~> 2.5', '>= 2.5.0' end -