diff --git a/lib/ruby_units/cache.rb b/lib/ruby_units/cache.rb index 6446571..f5f1896 100644 --- a/lib/ruby_units/cache.rb +++ b/lib/ruby_units/cache.rb @@ -20,6 +20,8 @@ def get(key) # @return [void] def set(key, value) key = key.to_unit.units unless key.is_a?(String) + return if should_skip_caching?(key) + data[key] = value end @@ -32,5 +34,9 @@ def keys def clear @data = {} end + + def should_skip_caching?(key) + keys.include?(key) || key =~ RubyUnits::Unit.special_format_regex + end end end diff --git a/lib/ruby_units/unit.rb b/lib/ruby_units/unit.rb index b0e12db..264ca8a 100644 --- a/lib/ruby_units/unit.rb +++ b/lib/ruby_units/unit.rb @@ -54,54 +54,55 @@ class << self UNITY = "<1>" UNITY_ARRAY = [UNITY].freeze - SIGN_REGEX = /(?:[+-])?/.freeze # +, -, or nothing + SIGN_REGEX = /(?:[+-])?/ # +, -, or nothing # regex for matching an integer number but not a fraction - INTEGER_DIGITS_REGEX = %r{(?#{SIGN_REGEX}#{DECIMAL_REGEX})[ -])?(?#{SIGN_REGEX}#{DECIMAL_REGEX})/(?#{SIGN_REGEX}#{DECIMAL_REGEX})\)?}.freeze # 1 2/3, -1 2/3, 5/3, 1-2/3, (1/2) etc. + RATIONAL_NUMBER = %r{\(?(?:(?#{SIGN_REGEX}#{DECIMAL_REGEX})[ -])?(?#{SIGN_REGEX}#{DECIMAL_REGEX})/(?#{SIGN_REGEX}#{DECIMAL_REGEX})\)?} # 1 2/3, -1 2/3, 5/3, 1-2/3, (1/2) etc. # Scientific notation: 1, -1, +1, 1.2, +1.2, -1.2, 123.4E5, +123.4e5, # -123.4E+5, -123.4e-5, etc. - SCI_NUMBER = /([+-]?\d*[.]?\d+(?:[Ee][+-]?\d+(?![.]))?)/.freeze + SCI_NUMBER = /([+-]?\d*[.]?\d+(?:[Ee][+-]?\d+(?![.]))?)/ # ideally we would like to generate this regex from the alias for a 'feet' # and 'inches', but they aren't defined at the point in the code where we # need this regex. - FEET_INCH_UNITS_REGEX = /(?:'|ft|feet)\s*(?#{RATIONAL_NUMBER}|#{SCI_NUMBER})\s*(?:"|in|inch(?:es)?)/.freeze - FEET_INCH_REGEX = /(?#{INTEGER_REGEX})\s*#{FEET_INCH_UNITS_REGEX}/.freeze + FEET_INCH_UNITS_REGEX = /(?:'|ft|feet)\s*(?#{RATIONAL_NUMBER}|#{SCI_NUMBER})\s*(?:"|in|inch(?:es)?)/ + FEET_INCH_REGEX = /(?#{INTEGER_REGEX})\s*#{FEET_INCH_UNITS_REGEX}/ # ideally we would like to generate this regex from the alias for a 'pound' # and 'ounce', but they aren't defined at the point in the code where we # need this regex. - LBS_OZ_UNIT_REGEX = /(?:#|lbs?|pounds?|pound-mass)+[\s,]*(?#{RATIONAL_NUMBER}|#{UNSIGNED_INTEGER_REGEX})\s*(?:ozs?|ounces?)/.freeze - LBS_OZ_REGEX = /(?#{INTEGER_REGEX})\s*#{LBS_OZ_UNIT_REGEX}/.freeze + LBS_OZ_UNIT_REGEX = /(?:#|lbs?|pounds?|pound-mass)+[\s,]*(?#{RATIONAL_NUMBER}|#{UNSIGNED_INTEGER_REGEX})\s*(?:ozs?|ounces?)/ + LBS_OZ_REGEX = /(?#{INTEGER_REGEX})\s*#{LBS_OZ_UNIT_REGEX}/ # ideally we would like to generate this regex from the alias for a 'stone' # and 'pound', but they aren't defined at the point in the code where we # need this regex. also note that the plural of 'stone' is still 'stone', # but we accept 'stones' anyway. - STONE_LB_UNIT_REGEX = /(?:sts?|stones?)+[\s,]*(?#{RATIONAL_NUMBER}|#{UNSIGNED_INTEGER_REGEX})\s*(?:#|lbs?|pounds?|pound-mass)*/.freeze - STONE_LB_REGEX = /(?#{INTEGER_REGEX})\s*#{STONE_LB_UNIT_REGEX}/.freeze + STONE_LB_UNIT_REGEX = /(?:sts?|stones?)+[\s,]*(?#{RATIONAL_NUMBER}|#{UNSIGNED_INTEGER_REGEX})\s*(?:#|lbs?|pounds?|pound-mass)*/ + STONE_LB_REGEX = /(?#{INTEGER_REGEX})\s*#{STONE_LB_UNIT_REGEX}/ # Time formats: 12:34:56,78, (hh:mm:ss,msec) etc. - TIME_REGEX = /(?\d+):(?\d+):?(?:(?\d+))?(?:[.](?\d+))?/.freeze + TIME_REGEX = /(?\d+):(?\d+):?(?:(?\d+))?(?:[.](?\d+))?/ # Complex numbers: 1+2i, 1.0+2.0i, -1-1i, etc. - COMPLEX_NUMBER = /(?#{SCI_NUMBER})?(?#{SCI_NUMBER})i\b/.freeze + COMPLEX_NUMBER = /(?#{SCI_NUMBER})?(?#{SCI_NUMBER})i\b/ # Any Complex, Rational, or scientific number - ANY_NUMBER = /(#{COMPLEX_NUMBER}|#{RATIONAL_NUMBER}|#{SCI_NUMBER})/.freeze - ANY_NUMBER_REGEX = /(?:#{ANY_NUMBER})?\s?([^-\d.].*)?/.freeze - NUMBER_REGEX = /(?#{SCI_NUMBER}*)\s*(?.+)?/.freeze # a number followed by a unit - UNIT_STRING_REGEX = %r{#{SCI_NUMBER}*\s*([^/]*)/*(.+)*}.freeze - TOP_REGEX = /([^ *]+)(?:\^|\*\*)([\d-]+)/.freeze - BOTTOM_REGEX = /([^* ]+)(?:\^|\*\*)(\d+)/.freeze - NUMBER_UNIT_REGEX = /#{SCI_NUMBER}?(.*)/.freeze - COMPLEX_REGEX = /#{COMPLEX_NUMBER}\s?(?.+)?/.freeze - RATIONAL_REGEX = /#{RATIONAL_NUMBER}\s?(?.+)?/.freeze + ANY_NUMBER = /(#{COMPLEX_NUMBER}|#{RATIONAL_NUMBER}|#{SCI_NUMBER})/ + ANY_NUMBER_REGEX = /(?:#{ANY_NUMBER})?\s?([^-\d.].*)?/ + NUMBER_REGEX = /(?#{SCI_NUMBER}*)\s*(?.+)?/ # a number followed by a unit + UNIT_STRING_REGEX = %r{#{SCI_NUMBER}*\s*([^/]*)/*(.+)*} + TOP_REGEX = /([^ *]+)(?:\^|\*\*)([\d-]+)/ + BOTTOM_REGEX = /([^* ]+)(?:\^|\*\*)(\d+)/ + NUMBER_UNIT_REGEX = /#{SCI_NUMBER}?(.*)/ + COMPLEX_REGEX = /#{COMPLEX_NUMBER}\s?(?.+)?/ + RATIONAL_REGEX = /#{RATIONAL_NUMBER}\s?(?.+)?/ KELVIN = [""].freeze FAHRENHEIT = [""].freeze RANKINE = [""].freeze CELSIUS = [""].freeze @temp_regex = nil + @special_format_regex = nil SIGNATURE_VECTOR = %i[ length time @@ -214,9 +215,10 @@ def self.definition(unit_name) end # @param [RubyUnits::Unit::Definition, String] unit_definition - # @param [Proc] block # @return [RubyUnits::Unit::Definition] # @raise [ArgumentError] when passed a non-string if using the block form + # @yield [definition] Optional block to configure the unit definition (only used when unit_definition is a String) + # @yieldparam definition [RubyUnits::Unit::Definition] the definition being created # Unpack a unit definition and add it to the array of defined units # # @example Block form @@ -227,11 +229,11 @@ def self.definition(unit_name) # @example RubyUnits::Unit::Definition form # unit_definition = RubyUnits::Unit::Definition.new("foobar") {|foobar| foobar.definition = RubyUnits::Unit.new("1 baz")} # RubyUnits::Unit.define(unit_definition) - def self.define(unit_definition, &block) + def self.define(unit_definition, &) if block_given? raise ArgumentError, "When using the block form of RubyUnits::Unit.define, pass the name of the unit" unless unit_definition.is_a?(String) - unit_definition = RubyUnits::Unit::Definition.new(unit_definition, &block) + unit_definition = RubyUnits::Unit::Definition.new(unit_definition, &) end definitions[unit_definition.name] = unit_definition use_definition(unit_definition) @@ -241,12 +243,11 @@ def self.define(unit_definition, &block) # Get the definition for a unit and allow it to be redefined # # @param [String] name Name of unit to redefine - # @param [Proc] _block # @raise [ArgumentError] if a block is not given - # @yieldparam [RubyUnits::Unit::Definition] the definition of the unit being - # redefined + # @yield [definition] Block to modify the unit definition + # @yieldparam definition [RubyUnits::Unit::Definition] the definition of the unit being redefined # @return (see RubyUnits::Unit.define) - def self.redefine!(name, &_block) + def self.redefine!(name, &) raise ArgumentError, "A block is required to redefine a unit" unless block_given? unit_definition = definition(name) @@ -407,18 +408,36 @@ def self.temp_regex end end + # Generates (and memoizes) a regexp matching special format units that should not be cached. + # + # @return [Regexp] + def self.special_format_regex + @special_format_regex ||= Regexp.union( + %r{\D/[\d+.]+}, + temp_regex, + STONE_LB_UNIT_REGEX, + LBS_OZ_UNIT_REGEX, + FEET_INCH_UNITS_REGEX, + /%/, + TIME_REGEX, + /i\s?(.+)?/, + %r{±|\+/-} + ) + end + # inject a definition into the internal array and set it up for use # # @param definition [RubyUnits::Unit::Definition] def self.use_definition(definition) @unit_match_regex = nil # invalidate the unit match regex @temp_regex = nil # invalidate the temp regex + @special_format_regex = nil # invalidate the special format regex if definition.prefix? prefix_values[definition.name] = definition.scalar definition.aliases.each { prefix_map[_1] = definition.name } @prefix_regex = nil # invalidate the prefix regex else - unit_values[definition.name] = {} + unit_values[definition.name] = {} unit_values[definition.name][:scalar] = definition.scalar unit_values[definition.name][:numerator] = definition.numerator if definition.numerator unit_values[definition.name][:denominator] = definition.denominator if definition.denominator @@ -491,82 +510,9 @@ def copy(from) # @raise [ArgumentError] if no unit is specified # @raise [ArgumentError] if an invalid unit is specified def initialize(*options) - @scalar = nil - @base_scalar = nil - @unit_name = nil - @signature = nil - @output = {} - raise ArgumentError, "Invalid Unit Format" if options[0].nil? - - if options.size == 2 - # options[0] is the scalar - # options[1] is a unit string - cached = self.class.cached.get(options[1]) - if cached.nil? - initialize("#{options[0]} #{options[1]}") - else - copy(cached * options[0]) - end - return - end - if options.size == 3 - options[1] = options[1].join if options[1].is_a?(Array) - options[2] = options[2].join if options[2].is_a?(Array) - cached = self.class.cached.get("#{options[1]}/#{options[2]}") - if cached.nil? - initialize("#{options[0]} #{options[1]}/#{options[2]}") - else - copy(cached) * options[0] - end - return - end - - case options[0] - in Unit => unit - copy(unit) - return - in Hash => hash - @scalar = hash[:scalar] || 1 - @numerator = hash[:numerator] || UNITY_ARRAY - @denominator = hash[:denominator] || UNITY_ARRAY - @signature = hash[:signature] - in Array => array - initialize(*array) - return - in Numeric => num - @scalar = num - @numerator = @denominator = UNITY_ARRAY - in Time => time - @scalar = time.to_f - @numerator = [""] - @denominator = UNITY_ARRAY - in DateTime | Date => date - @scalar = date.ajd - @numerator = [""] - @denominator = UNITY_ARRAY - in /^\s*$/ => _empty - raise ArgumentError, "No Unit Specified" - in String => str - parse(str) - else - raise ArgumentError, "Invalid Unit Format" - end - update_base_scalar - raise ArgumentError, "Temperatures must not be less than absolute zero" if temperature? && base_scalar.negative? - - unary_unit = units || "" - if options.first.instance_of?(String) - _opt_scalar, opt_units = self.class.parse_into_numbers_and_units(options[0]) - if !(self.class.cached.keys.include?(opt_units) || - (opt_units =~ %r{\D/[\d+.]+}) || - (opt_units =~ %r{(#{self.class.temp_regex})|(#{STONE_LB_UNIT_REGEX})|(#{LBS_OZ_UNIT_REGEX})|(#{FEET_INCH_UNITS_REGEX})|%|(#{TIME_REGEX})|i\s?(.+)?|±|\+/-})) && opt_units && !opt_units.empty? - self.class.cached.set(opt_units, scalar == 1 ? self : opt_units.to_unit) - end - end - unless self.class.cached.keys.include?(unary_unit) || (unary_unit =~ self.class.temp_regex) - self.class.cached.set(unary_unit, scalar == 1 ? self : unary_unit.to_unit) - end - [@scalar, @numerator, @denominator, @base_scalar, @signature, @base].each(&:freeze) + initialize_instance_variables + parse_array(options) + finalize_initialization(options) super() end @@ -1614,10 +1560,229 @@ def unit_signature_vector # @param [Unit] other # @private def initialize_copy(other) - @numerator = other.numerator.dup + @numerator = other.numerator.dup @denominator = other.denominator.dup end + # Initialize instance variables to their default values + # @return [void] + def initialize_instance_variables + @scalar = nil + @base_scalar = nil + @unit_name = nil + @signature = nil + @output = {} + end + + # Parse options based on the number of arguments + # @param [Array] options + # @return [void] + def parse_array(options) + case options + in [first] if first + parse_single_arg(first) + in [first, String => second] if first + parse_two_args(first, second) + in [first, String | Array => second, String | Array => third] if first + parse_three_args(first, second, third) + else + raise ArgumentError, "Invalid Unit Format" + end + end + + # Parse a single argument + # @param [Unit,Hash,Array,Numeric,Time,Date,DateTime,String] arg + # @return [void] + def parse_single_arg(arg) + case arg + in Unit => unit + copy(unit) + in Hash => hash + parse_hash(hash) + in Array => array + parse_array(array) + in Numeric => number + parse_numeric(number) + in Time => time + parse_time(time) + in DateTime | Date => date + parse_date(date) + in String => str + parse_string_arg(str) + else + raise ArgumentError, "Invalid Unit Format" + end + end + + # Parse and validate a string argument + # @param [String] str + # @return [void] + # @raise [ArgumentError] if string is empty + def parse_string_arg(str) + raise ArgumentError, "No Unit Specified" if str.strip.empty? + + parse_string(str) + end + + # Parse two arguments (scalar and unit string) + # @param [Numeric] scalar + # @param [String] unit_string + # @return [void] + def parse_two_args(scalar, unit_string) + cached = self.class.cached.get(unit_string) + if cached + copy(cached * scalar) + else + parse_string("#{scalar} #{unit_string}") + end + end + + # Parse three arguments (scalar, numerator, denominator) + # @param [Numeric] scalar + # @param [String,Array] numerator + # @param [String,Array] denominator + # @return [void] + def parse_three_args(scalar, numerator, denominator) + unit_str = "#{Array(numerator).join}/#{Array(denominator).join}" + + cached = self.class.cached.get(unit_str) + if cached + copy(cached * scalar) + else + parse_string("#{scalar} #{unit_str}") + end + end + + # Parse a hash argument + # WARNING: if you pass a signature, it will be accepted without validation against the units + # @param [Hash] hash + # @return [void] + def parse_hash(hash) + @scalar = validate_scalar(hash.fetch(:scalar, 1)) + @numerator = validate_unit_array(hash.fetch(:numerator, UNITY_ARRAY), :numerator) + @denominator = validate_unit_array(hash.fetch(:denominator, UNITY_ARRAY), :denominator) + @signature = validate_signature(hash[:signature]) + end + + # Validate scalar parameter + # @param [Object] value + # @return [Numeric] + # @raise [ArgumentError] if value is not numeric + def validate_scalar(value) + raise ArgumentError, ":scalar must be numeric" unless value.is_a?(Numeric) + + value + end + + # Validate unit array parameter (numerator or denominator) + # @param [Object] value + # @param [Symbol] param_name + # @return [Array] + # @raise [ArgumentError] if value is not an array of strings + def validate_unit_array(value, param_name) + raise ArgumentError, ":#{param_name} must be an Array" unless value.is_a?(Array) && value.all?(String) + + value + end + + # Validate signature parameter + # @param [Object] value + # @return [Integer, nil] + # @raise [ArgumentError] if value is not an integer + def validate_signature(value) + raise ArgumentError, ":signature must be an Integer" if value && !value.is_a?(Integer) + + value + end + + # Parse a numeric argument + # @param [Numeric] num + # @return [void] + def parse_numeric(num) + @scalar = num + @numerator = @denominator = UNITY_ARRAY + end + + # Parse a Time argument + # @param [Time] time + # @return [void] + def parse_time(time) + @scalar = time.to_f + @numerator = [""] + @denominator = UNITY_ARRAY + end + + # Parse a Date or DateTime argument + # @param [Date,DateTime] date + # @return [void] + def parse_date(date) + @scalar = date.ajd + @numerator = [""] + @denominator = UNITY_ARRAY + end + + # Parse a string argument + # @param [String] str + # @return [void] + def parse_string(str) + parse(str) + end + + # Finalize initialization by updating base scalar, validating, caching, and freezing + # @param [Array] options original options passed to initialize + # @return [void] + def finalize_initialization(options) + update_base_scalar + validate_temperature + cache_unit_if_needed(options) + freeze_instance_variables + end + + # Validate that temperatures are not below absolute zero + # @return [void] + # @raise [ArgumentError] if temperature is below absolute zero + def validate_temperature + raise ArgumentError, "Temperatures must not be less than absolute zero" if temperature? && base_scalar.negative? + end + + # Cache the unit if it meets caching criteria + # @param [Array] options original options passed to initialize + # @return [void] + def cache_unit_if_needed(options) + unary_unit = units || "" + + # Cache units parsed from strings if they meet criteria + cache_parsed_string_unit(options[0]) if options.first.is_a?(String) + + # Cache unary units if not already cached and not temperature units + cache_unary_unit(unary_unit) + end + + # Cache a unit parsed from a string if it meets criteria + # @param [String] option_string + # @return [void] + def cache_parsed_string_unit(option_string) + _opt_scalar, opt_units = self.class.parse_into_numbers_and_units(option_string) + return unless opt_units && !opt_units.empty? + + self.class.cached.set(opt_units, scalar == 1 ? self : opt_units.to_unit) + end + + # Cache a unary unit if appropriate + # @param [String] unary_unit + # @return [void] + def cache_unary_unit(unary_unit) + return if unary_unit == "" + + self.class.cached.set(unary_unit, scalar == 1 ? self : unary_unit.to_unit) + end + + # Freeze all instance variables + # @return [void] + def freeze_instance_variables + [@scalar, @numerator, @denominator, @base_scalar, @signature, @base].each(&:freeze) + end + # calculates the unit signature id for use in comparing compatible units and simplification # the signature is based on a simple classification of units and is based on the following publication # diff --git a/spec/ruby_units/initialization_spec.rb b/spec/ruby_units/initialization_spec.rb new file mode 100644 index 0000000..04f2d88 --- /dev/null +++ b/spec/ruby_units/initialization_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +RSpec.describe "initialization" do + describe "Hash parameter validation" do + context "with invalid scalar" do + it "raises ArgumentError when scalar is not numeric" do + expect { RubyUnits::Unit.new(scalar: "invalid", numerator: [""], denominator: [""]) } + .to raise_error(ArgumentError, ":scalar must be numeric") + end + + it "raises ArgumentError when scalar is nil" do + expect { RubyUnits::Unit.new(scalar: nil, numerator: [""], denominator: [""]) } + .to raise_error(ArgumentError, ":scalar must be numeric") + end + + it "raises ArgumentError when scalar is an array" do + expect { RubyUnits::Unit.new(scalar: [1, 2], numerator: [""], denominator: [""]) } + .to raise_error(ArgumentError, ":scalar must be numeric") + end + end + + context "with invalid numerator" do + it "raises ArgumentError when numerator is not an array" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: "", denominator: [""]) } + .to raise_error(ArgumentError, ":numerator must be an Array") + end + + it "raises ArgumentError when numerator contains non-strings" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: ["", 123], denominator: [""]) } + .to raise_error(ArgumentError, ":numerator must be an Array") + end + + it "raises ArgumentError when numerator is nil" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: nil, denominator: [""]) } + .to raise_error(ArgumentError, ":numerator must be an Array") + end + end + + context "with invalid denominator" do + it "raises ArgumentError when denominator is not an array" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: [""], denominator: "") } + .to raise_error(ArgumentError, ":denominator must be an Array") + end + + it "raises ArgumentError when denominator contains non-strings" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: [""], denominator: [123, ""]) } + .to raise_error(ArgumentError, ":denominator must be an Array") + end + + it "raises ArgumentError when denominator is nil" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: [""], denominator: nil) } + .to raise_error(ArgumentError, ":denominator must be an Array") + end + end + + context "with invalid signature" do + it "raises ArgumentError when signature is not an integer" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: [""], denominator: [""], signature: "invalid") } + .to raise_error(ArgumentError, ":signature must be an Integer") + end + + it "raises ArgumentError when signature is a float" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: [""], denominator: [""], signature: 1.5) } + .to raise_error(ArgumentError, ":signature must be an Integer") + end + + it "accepts nil signature" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: [""], denominator: [""], signature: nil) } + .not_to raise_error + end + + it "accepts integer signature" do + expect { RubyUnits::Unit.new(scalar: 1, numerator: [""], denominator: [""], signature: 123) } + .not_to raise_error + end + end + + context "with valid hash parameters" do + it "creates unit with all parameters" do + unit = RubyUnits::Unit.new(scalar: 2, numerator: [""], denominator: [""], signature: nil) + expect(unit.scalar).to eq(2) + expect(unit.units).to eq("m/s") + end + + it "uses default scalar of 1 when not provided" do + unit = RubyUnits::Unit.new(numerator: [""], denominator: [""]) + expect(unit.scalar).to eq(1) + end + + it "uses UNITY_ARRAY for numerator when not provided" do + unit = RubyUnits::Unit.new(scalar: 5, denominator: [""]) + expect(unit.numerator).to eq(["<1>"]) + end + + it "uses UNITY_ARRAY for denominator when not provided" do + unit = RubyUnits::Unit.new(scalar: 5, numerator: [""]) + expect(unit.denominator).to eq(["<1>"]) + end + end + end + + describe "Array argument handling" do + context "with arrays containing different argument types" do + it "handles array with single Unit" do + original = RubyUnits::Unit.new("5 m") + unit = RubyUnits::Unit.new([original]) + expect(unit.scalar).to eq(5) + expect(unit.units).to eq("m") + end + + it "handles array with scalar and unit string" do + unit = RubyUnits::Unit.new([3, "m"]) + expect(unit.scalar).to eq(3) + expect(unit.units).to eq("m") + end + + it "handles array with scalar, numerator array, and denominator array" do + unit = RubyUnits::Unit.new([2, [""], [""]]) + expect(unit.scalar).to eq(2) + expect(unit.units).to eq("m/s") + end + + it "handles array with scalar, numerator string, and denominator string" do + unit = RubyUnits::Unit.new([2, "m^2", "s^2"]) + expect(unit.scalar).to eq(2) + expect(unit.units).to eq("m^2/s^2") + end + + it "raises error for array with nil first element" do + expect { RubyUnits::Unit.new([nil, "m"]) } + .to raise_error(ArgumentError, "Invalid Unit Format") + end + end + end + + describe "special_format_regex" do + it "matches temperature units" do + expect(RubyUnits::Unit.special_format_regex).to match("tempK") + expect(RubyUnits::Unit.special_format_regex).to match("tempF") + end + + it "matches stone-pound format" do + expect(RubyUnits::Unit.special_format_regex).to match("st 5 lbs") + end + + it "matches pound-ounce format" do + expect(RubyUnits::Unit.special_format_regex).to match("lbs 5 oz") + end + + it "matches feet-inch format" do + expect(RubyUnits::Unit.special_format_regex).to match("ft 5 in") + end + + it "matches percentage" do + expect(RubyUnits::Unit.special_format_regex).to match("%") + end + + it "matches time format" do + expect(RubyUnits::Unit.special_format_regex).to match("12:34:56") + end + + it "matches complex numbers with units" do + expect(RubyUnits::Unit.special_format_regex).to match("i m") + end + + it "matches plus-minus format" do + expect(RubyUnits::Unit.special_format_regex).to match("±") + expect(RubyUnits::Unit.special_format_regex).to match("+/-") + end + + it "matches division with number" do + expect(RubyUnits::Unit.special_format_regex).to match("m/5.5") + end + + it "does not match regular units" do + expect(RubyUnits::Unit.special_format_regex).not_to match("m") + expect(RubyUnits::Unit.special_format_regex).not_to match("kg") + expect(RubyUnits::Unit.special_format_regex).not_to match("m/s") + end + end + + describe "nil argument handling" do + it "raises ArgumentError for nil as single argument" do + expect { RubyUnits::Unit.new(nil) } + .to raise_error(ArgumentError, "Invalid Unit Format") + end + end + + describe "three-argument initialization with arrays" do + it "converts array numerator to string" do + unit = RubyUnits::Unit.new(2, ["", ""], [""]) + expect(unit.units).to eq("m^2/s") + end + + it "converts array denominator to string" do + unit = RubyUnits::Unit.new(2, [""], ["", ""]) + expect(unit.units).to eq("m/s^2") + end + + it "handles both numerator and denominator as arrays" do + unit = RubyUnits::Unit.new(1, ["", ""], ["", ""]) + expect(unit.units).to eq("kg*m/s^2") + end + end + + describe "caching behavior" do + before do + # Clear cache before each test + RubyUnits::Unit.clear_cache + end + + it "caches regular unit strings" do + RubyUnits::Unit.new("1 m") + expect(RubyUnits::Unit.cached.keys).to include("m") + end + + it "does not cache temperature units" do + RubyUnits::Unit.new("100 tempK") + expect(RubyUnits::Unit.cached.keys.any? { |k| k =~ /temp/ }).to be false + end + + it "does not cache special format units" do + RubyUnits::Unit.new("5%") + expect(RubyUnits::Unit.cached.keys).not_to include("%") + end + + it "caches unary units when scalar is 1" do + unit = RubyUnits::Unit.new("1 m/s") + expect(RubyUnits::Unit.cached.get("m/s")).to eq(unit) + end + + it "caches unit definition when scalar is not 1" do + RubyUnits::Unit.new("5 m/s") + cached = RubyUnits::Unit.cached.get("m/s") + expect(cached).not_to be_nil + expect(cached.scalar).to eq(1) + end + end + + describe "edge cases in initialization" do + it "handles nested array initialization" do + inner_array = [5, "m"] + unit = RubyUnits::Unit.new(inner_array) + expect(unit.scalar).to eq(5) + expect(unit.units).to eq("m") + end + + it "handles Unit copy through single argument" do + original = RubyUnits::Unit.new("10 kg") + copy = RubyUnits::Unit.new(original) + expect(copy.scalar).to eq(10) + expect(copy.units).to eq("kg") + expect(copy).not_to be(original) # Different object + end + + it "freezes instance variables after initialization" do + unit = RubyUnits::Unit.new("5 m") + expect(unit.scalar).to be_frozen + expect(unit.numerator).to be_frozen + expect(unit.denominator).to be_frozen + expect(unit.base_scalar).to be_frozen + end + end + + describe "two-argument initialization with caching" do + before do + RubyUnits::Unit.clear_cache + end + + it "uses cached unit when available" do + # Prime the cache + RubyUnits::Unit.new("m") + + # This should use the cached version + unit = RubyUnits::Unit.new(5, "m") + expect(unit.scalar).to eq(5) + expect(unit.units).to eq("m") + end + + it "parses when unit not in cache" do + # Clear cache to ensure it's not cached + RubyUnits::Unit.clear_cache + + unit = RubyUnits::Unit.new(3, "kg*m/s^2") + expect(unit.scalar).to eq(3) + expect(unit.units).to eq("kg*m/s^2") + end + end + + describe "three-argument initialization with caching" do + before do + RubyUnits::Unit.clear_cache + end + + it "uses cached unit when available" do + # Prime the cache + RubyUnits::Unit.new("m/s") + + # This should use the cached version + unit = RubyUnits::Unit.new(10, "m", "s") + expect(unit.scalar).to eq(10) + expect(unit.units).to eq("m/s") + end + + it "parses when unit not in cache" do + RubyUnits::Unit.clear_cache + + unit = RubyUnits::Unit.new(2, "m^2", "s^2") + expect(unit.scalar).to eq(2) + expect(unit.units).to eq("m^2/s^2") + end + end +end