From 1f9aa1548630d7cd74b3af4af0ecb380e731ea48 Mon Sep 17 00:00:00 2001 From: Kevin Olbrich Date: Tue, 3 Oct 2023 21:27:20 -0400 Subject: [PATCH] Fix parsing of special unit formats (#277) * 5' 3/4" doesn't parse correctly Fixes #276 - update the regex used for feet-inch notation to handle signed ints, and rational parts - handle negative lbs/oz and stone-lbs forms with fractional secondary units - Handle a bunch of parsing corner cases. WARNING: the time format HH::mm:ss,nsec is gone now in favor of HH::mm:ss.SSS which better lines up with how times are parsed by Time class. * tweak special formats so we generate improper fractions for inches and ounces, etc.. This is more in line with how these formats are used in practice. WARNING: If you want precision, don't use these formats. --- lib/ruby_units/unit.rb | 153 +++++++++++++++++--------- spec/ruby_units/configuration_spec.rb | 12 +- spec/ruby_units/parsing_spec.rb | 92 ++++++++++++++++ spec/ruby_units/unit_spec.rb | 123 ++++++++++----------- spec/spec_helper.rb | 2 +- 5 files changed, 258 insertions(+), 124 deletions(-) create mode 100644 spec/ruby_units/parsing_spec.rb diff --git a/lib/ruby_units/unit.rb b/lib/ruby_units/unit.rb index 9391e979..77a8c53c 100644 --- a/lib/ruby_units/unit.rb +++ b/lib/ruby_units/unit.rb @@ -51,41 +51,50 @@ class << self @unit_match_regex = nil UNITY = '<1>'.freeze UNITY_ARRAY = [UNITY].freeze + + SIGN_REGEX = /(?:[+-])?/.freeze # +, -, 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})\)?} # 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+(?![.]))?)/ # 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*(\d+)\s*(?:"|in|inch(?:es)?)/.freeze - FEET_INCH_REGEX = /(\d+)\s*#{FEET_INCH_UNITS_REGEX}/.freeze + 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 # 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,]*(\d+)\s*(?:ozs?|ounces?)/.freeze - LBS_OZ_REGEX = /(\d+)\s*#{LBS_OZ_UNIT_REGEX}/.freeze + 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 # 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,]*(\d+)\s*(?:#|lbs?|pounds?|pound-mass)*/.freeze - STONE_LB_REGEX = /(\d+)\s*#{STONE_LB_UNIT_REGEX}/.freeze + 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 # Time formats: 12:34:56,78, (hh:mm:ss,msec) etc. - TIME_REGEX = /(?\d+):(?\d+):?(?:(?\d+))?(?:,(?\d+))?/.freeze - # 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 - # Rational number, including improper fractions: 1 2/3, -1 2/3, 5/3, etc. - RATIONAL_NUMBER = %r{\(?([+-])?(\d+[ -])?(\d+)/(\d+)\)?}.freeze + TIME_REGEX = /(?\d+):(?\d+):?(?:(?\d+))?(?:[.](?\d+))?/.freeze # 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/.freeze # 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 + 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 + COMPLEX_REGEX = /#{COMPLEX_NUMBER}\s?(?.+)?/.freeze + RATIONAL_REGEX = /#{RATIONAL_NUMBER}\s?(?.+)?/.freeze KELVIN = [''].freeze FAHRENHEIT = [''].freeze RANKINE = [''].freeze @@ -646,22 +655,29 @@ def to_base # # @note Rational scalars that are equal to an integer will be represented as integers (i.e, 6/1 => 6, 4/2 => 2, etc..) # @param [Symbol] target_units + # @param [Float] precision - the precision to use when converting to a rational # @return [String] - def to_s(target_units = nil) + def to_s(target_units = nil, precision: 0.0001) out = @output[target_units] return out if out separator = RubyUnits.configuration.separator case target_units when :ft - inches = convert_to('in').scalar.to_int - out = "#{(inches / 12).truncate}'#{(inches % 12).round}\"" + feet, inches = convert_to('in').scalar.abs.divmod(12) + improper, frac = inches.divmod(1) + frac = frac.zero? ? '' : "-#{frac.rationalize(precision)}" + out = "#{negative? ? '-' : nil}#{feet}'#{improper}#{frac}\"" when :lbs - ounces = convert_to('oz').scalar.to_int - out = "#{(ounces / 16).truncate}#{separator}lbs, #{(ounces % 16).round}#{separator}oz" + pounds, ounces = convert_to('oz').scalar.abs.divmod(16) + improper, frac = ounces.divmod(1) + frac = frac.zero? ? '' : "-#{frac.rationalize(precision)}" + out = "#{negative? ? '-' : nil}#{pounds}#{separator}lbs #{improper}#{frac}#{separator}oz" when :stone - pounds = convert_to('lbs').scalar.to_int - out = "#{(pounds / 14).truncate}#{separator}stone, #{(pounds % 14).round}#{separator}lb" + stone, pounds = convert_to('lbs').scalar.abs.divmod(14) + improper, frac = pounds.divmod(1) + frac = frac.zero? ? '' : "-#{frac.rationalize(precision)}" + out = "#{negative? ? '-' : nil}#{stone}#{separator}stone #{improper}#{frac}#{separator}lbs" when String out = case target_units.strip when /\A\s*\Z/ # whitespace only @@ -1565,27 +1581,43 @@ def parse(passed_unit_string = '0') unit_string = "#{Regexp.last_match(1)} USD" if unit_string =~ /\$\s*(#{NUMBER_REGEX})/ unit_string.gsub!("\u00b0".force_encoding('utf-8'), 'deg') if unit_string.encoding == Encoding::UTF_8 - unit_string.gsub!(/[%'"#]/, '%' => 'percent', "'" => 'feet', '"' => 'inch', '#' => 'pound') - - if defined?(Complex) && unit_string =~ COMPLEX_NUMBER - real, imaginary, unit_s = unit_string.scan(COMPLEX_REGEX)[0] - result = self.class.new(unit_s || '1') * Complex(real.to_f, imaginary.to_f) + unit_string.gsub!(/[%'"#_,]/, '%' => 'percent', "'" => 'feet', '"' => 'inch', '#' => 'pound', '_' => '', ',' => '') + if unit_string.start_with?(COMPLEX_NUMBER) + match = unit_string.match(COMPLEX_REGEX) + real = Float(match[:real]) if match[:real] + imaginary = Float(match[:imaginary]) + unit_s = match[:unit] + real = real.to_i if real.to_i == real + imaginary = imaginary.to_i if imaginary.to_i == imaginary + complex = Complex(real || 0, imaginary) + complex = complex.to_i if complex.imaginary.zero? && complex.real == complex.real.to_i + result = self.class.new(unit_s || 1) * complex copy(result) return end - if defined?(Rational) && unit_string =~ RATIONAL_NUMBER - sign, proper, numerator, denominator, unit_s = unit_string.scan(RATIONAL_REGEX)[0] - sign = sign == '-' ? -1 : 1 - rational = sign * (proper.to_i + Rational(numerator.to_i, denominator.to_i)) - result = self.class.new(unit_s || '1') * rational + if unit_string.start_with?(RATIONAL_NUMBER) + match = unit_string.match(RATIONAL_REGEX) + numerator = Integer(match[:numerator]) + denominator = Integer(match[:denominator]) + raise ArgumentError, 'Improper fractions must have a whole number part' if !match[:proper].nil? && !match[:proper].match?(/^#{INTEGER_REGEX}$/) + + proper = match[:proper].to_i + unit_s = match[:unit] + rational = if proper.negative? + (proper - Rational(numerator, denominator)) + else + (proper + Rational(numerator, denominator)) + end + rational = rational.to_int if rational.to_int == rational + result = self.class.new(unit_s || 1) * rational copy(result) return end - unit_string =~ NUMBER_REGEX - unit = self.class.cached.get(Regexp.last_match(2)) - mult = Regexp.last_match(1).nil? ? 1.0 : Regexp.last_match(1).to_f + match = unit_string.match(NUMBER_REGEX) + unit = self.class.cached.get(match[:unit]) + mult = match[:scalar] == '' ? 1.0 : match[:scalar].to_f mult = mult.to_int if mult.to_int == mult if unit @@ -1600,46 +1632,65 @@ def parse(passed_unit_string = '0') end # ... and then strip the remaining brackets for x*y*z unit_string.gsub!(/[<>]/, '') - if unit_string =~ TIME_REGEX - hours, minutes, seconds, microseconds = unit_string.scan(TIME_REGEX)[0] - raise ArgumentError, 'Invalid Duration' if [hours, minutes, seconds, microseconds].all?(&:nil?) - result = self.class.new("#{hours || 0} h") + + if (match = unit_string.match(TIME_REGEX)) + hours = match[:hour] + minutes = match[:min] + seconds = match[:sec] + milliseconds = match[:msec] + raise ArgumentError, 'Invalid Duration' if [hours, minutes, seconds, milliseconds].all?(&:nil?) + + result = self.class.new("#{hours || 0} hours") + self.class.new("#{minutes || 0} minutes") + self.class.new("#{seconds || 0} seconds") + - self.class.new("#{microseconds || 0} usec") + self.class.new("#{milliseconds || 0} milliseconds") copy(result) return end # Special processing for unusual unit strings # feet -- 6'5" - feet, inches = unit_string.scan(FEET_INCH_REGEX)[0] - if feet && inches - result = self.class.new("#{feet} ft") + self.class.new("#{inches} inches") + if (match = unit_string.match(FEET_INCH_REGEX)) + feet = Integer(match[:feet]) + inches = match[:inches] + result = if feet.negative? + self.class.new("#{feet} ft") - self.class.new("#{inches} inches") + else + self.class.new("#{feet} ft") + self.class.new("#{inches} inches") + end copy(result) return end # weight -- 8 lbs 12 oz - pounds, oz = unit_string.scan(LBS_OZ_REGEX)[0] - if pounds && oz - result = self.class.new("#{pounds} lbs") + self.class.new("#{oz} oz") + if (match = unit_string.match(LBS_OZ_REGEX)) + pounds = Integer(match[:pounds]) + oz = match[:oz] + result = if pounds.negative? + self.class.new("#{pounds} lbs") - self.class.new("#{oz} oz") + else + self.class.new("#{pounds} lbs") + self.class.new("#{oz} oz") + end copy(result) return end # stone -- 3 stone 5, 2 stone, 14 stone 3 pounds, etc. - stone, pounds = unit_string.scan(STONE_LB_REGEX)[0] - if stone && pounds - result = self.class.new("#{stone} stone") + self.class.new("#{pounds} lbs") + if (match = unit_string.match(STONE_LB_REGEX)) + stone = Integer(match[:stone]) + pounds = match[:pounds] + result = if stone.negative? + self.class.new("#{stone} stone") - self.class.new("#{pounds} lbs") + else + self.class.new("#{stone} stone") + self.class.new("#{pounds} lbs") + end copy(result) return end # more than one per. I.e., "1 m/s/s" raise(ArgumentError, "'#{passed_unit_string}' Unit not recognized") if unit_string.count('/') > 1 - raise(ArgumentError, "'#{passed_unit_string}' Unit not recognized") if unit_string =~ /\s[02-9]/ + raise(ArgumentError, "'#{passed_unit_string}' Unit not recognized #{unit_string}") if unit_string =~ /\s[02-9]/ @scalar, top, bottom = unit_string.scan(UNIT_STRING_REGEX)[0] # parse the string into parts top.scan(TOP_REGEX).each do |item| diff --git a/spec/ruby_units/configuration_spec.rb b/spec/ruby_units/configuration_spec.rb index f32fb88e..93bc108c 100644 --- a/spec/ruby_units/configuration_spec.rb +++ b/spec/ruby_units/configuration_spec.rb @@ -1,14 +1,14 @@ require 'spec_helper' describe RubyUnits::Configuration do - context '.separator is true' do + describe '.separator is true' do it 'has a space between the scalar and the unit' do expect(RubyUnits::Unit.new('1 m').to_s).to eq '1 m' end end - context '.separator is false' do - around(:each) do |example| + describe '.separator is false' do + around do |example| RubyUnits.configure do |config| config.separator = false end @@ -18,9 +18,9 @@ it 'does not have a space between the scalar and the unit' do expect(RubyUnits::Unit.new('1 m').to_s).to eq '1m' - expect(RubyUnits::Unit.new('14.5 lbs').to_s(:lbs)).to eq '14lbs, 8oz' - expect(RubyUnits::Unit.new('220 lbs').to_s(:stone)).to eq '15stone, 10lb' - expect(RubyUnits::Unit.new('14.2 ft').to_s(:ft)).to eq %(14'2") + expect(RubyUnits::Unit.new('14.5 lbs').to_s(:lbs)).to eq '14lbs 8oz' + expect(RubyUnits::Unit.new('220 lbs').to_s(:stone)).to eq '15stone 10lbs' + expect(RubyUnits::Unit.new('14.2 ft').to_s(:ft)).to eq %(14'2-2/5") expect(RubyUnits::Unit.new('1/2 cup').to_s).to eq '1/2cu' expect(RubyUnits::Unit.new('123.55 lbs').to_s('%0.2f')).to eq '123.55lbs' end diff --git a/spec/ruby_units/parsing_spec.rb b/spec/ruby_units/parsing_spec.rb new file mode 100644 index 00000000..35c40c43 --- /dev/null +++ b/spec/ruby_units/parsing_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +RSpec.describe 'Number parsing' do + context 'with Integers' do + it { expect(RubyUnits::Unit.new('1')).to have_attributes(scalar: 1) } + it { expect(RubyUnits::Unit.new('-1')).to have_attributes(scalar: -1) } + it { expect(RubyUnits::Unit.new('+1')).to have_attributes(scalar: 1) } + it { expect(RubyUnits::Unit.new('01')).to have_attributes(scalar: 1) } + it { expect(RubyUnits::Unit.new('1,000')).to have_attributes(scalar: 1000) } + it { expect(RubyUnits::Unit.new('1_000')).to have_attributes(scalar: 1000) } + end + + context 'with Decimals' do + # NOTE: that since this float is the same as an integer, the integer is returned + it { expect(RubyUnits::Unit.new('1.0').scalar).to be(1) } + it { expect(RubyUnits::Unit.new('-1.0').scalar).to be(-1) } + + it { expect(RubyUnits::Unit.new('1.1').scalar).to be(1.1) } + it { expect(RubyUnits::Unit.new('-1.1').scalar).to be(-1.1) } + it { expect(RubyUnits::Unit.new('+1.1').scalar).to be(1.1) } + it { expect(RubyUnits::Unit.new('0.1').scalar).to be(0.1) } + it { expect(RubyUnits::Unit.new('-0.1').scalar).to be(-0.1) } + it { expect(RubyUnits::Unit.new('+0.1').scalar).to be(0.1) } + it { expect(RubyUnits::Unit.new('.1').scalar).to be(0.1) } + it { expect(RubyUnits::Unit.new('-.1').scalar).to be(-0.1) } + it { expect(RubyUnits::Unit.new('+.1').scalar).to be(0.1) } + + it { expect { RubyUnits::Unit.new('0.1.') }.to raise_error(ArgumentError) } + it { expect { RubyUnits::Unit.new('-0.1.') }.to raise_error(ArgumentError) } + it { expect { RubyUnits::Unit.new('+0.1.') }.to raise_error(ArgumentError) } + end + + context 'with Fractions' do + it { expect(RubyUnits::Unit.new('1/1').scalar).to be(1) } + it { expect(RubyUnits::Unit.new('-1/1').scalar).to be(-1) } + it { expect(RubyUnits::Unit.new('+1/1').scalar).to be(1) } + + # NOTE: eql? is used here because two equivalent Rational objects are not the same object, unlike Integers + it { expect(RubyUnits::Unit.new('1/2').scalar).to eql(1/2r) } + it { expect(RubyUnits::Unit.new('-1/2').scalar).to eql(-1/2r) } + it { expect(RubyUnits::Unit.new('+1/2').scalar).to eql(1/2r) } + it { expect(RubyUnits::Unit.new('(1/2)').scalar).to eql(1/2r) } + it { expect(RubyUnits::Unit.new('(-1/2)').scalar).to eql(-1/2r) } + it { expect(RubyUnits::Unit.new('(+1/2)').scalar).to eql(1/2r) } + + # improper fractions + it { expect(RubyUnits::Unit.new('1 1/2').scalar).to eql(3/2r) } + it { expect(RubyUnits::Unit.new('-1 1/2').scalar).to eql(-3/2r) } + it { expect(RubyUnits::Unit.new('+1 1/2').scalar).to eql(3/2r) } + it { expect(RubyUnits::Unit.new('1-1/2').scalar).to eql(3/2r) } + it { expect(RubyUnits::Unit.new('-1-1/2').scalar).to eql(-3/2r) } + it { expect(RubyUnits::Unit.new('+1-1/2').scalar).to eql(3/2r) } + it { expect(RubyUnits::Unit.new('1 2/2').scalar).to be(2) } # weird, but not wrong + it { expect(RubyUnits::Unit.new('1 3/2').scalar).to eql(5/2r) } # weird, but not wrong + it { expect { RubyUnits::Unit.new('1.5 1/2') }.to raise_error(ArgumentError, 'Improper fractions must have a whole number part') } + it { expect { RubyUnits::Unit.new('1.5/2') }.to raise_error(ArgumentError, 'invalid value for Integer(): "1.5"') } + it { expect { RubyUnits::Unit.new('1/2.5') }.to raise_error(ArgumentError, 'invalid value for Integer(): "2.5"') } + end + + context 'with Scientific Notation' do + it { expect(RubyUnits::Unit.new('1e0').scalar).to be(1) } + it { expect(RubyUnits::Unit.new('-1e0').scalar).to be(-1) } + it { expect(RubyUnits::Unit.new('+1e0').scalar).to be(1) } + it { expect(RubyUnits::Unit.new('1e1').scalar).to be(10) } + it { expect(RubyUnits::Unit.new('-1e1').scalar).to be(-10) } + it { expect(RubyUnits::Unit.new('+1e1').scalar).to be(10) } + it { expect(RubyUnits::Unit.new('1e-1').scalar).to be(0.1) } + it { expect(RubyUnits::Unit.new('-1e-1').scalar).to be(-0.1) } + it { expect(RubyUnits::Unit.new('+1e-1').scalar).to be(0.1) } + it { expect(RubyUnits::Unit.new('1E+1').scalar).to be(10) } + it { expect(RubyUnits::Unit.new('-1E+1').scalar).to be(-10) } + it { expect(RubyUnits::Unit.new('+1E+1').scalar).to be(10) } + it { expect(RubyUnits::Unit.new('1E-1').scalar).to be(0.1) } + it { expect(RubyUnits::Unit.new('-1E-1').scalar).to be(-0.1) } + it { expect(RubyUnits::Unit.new('+1E-1').scalar).to be(0.1) } + it { expect(RubyUnits::Unit.new('1.0e2').scalar).to be(100) } + it { expect(RubyUnits::Unit.new('.1e2').scalar).to be(10) } + it { expect(RubyUnits::Unit.new('0.1e2').scalar).to be(10) } + it { expect { RubyUnits::Unit.new('0.1e2.5') }.to raise_error(ArgumentError) } + end + + context 'with Complex numbers' do + it { expect(RubyUnits::Unit.new('1+1i').scalar).to eql(Complex(1, 1)) } + it { expect(RubyUnits::Unit.new('1i').scalar).to eql(Complex(0, 1)) } + it { expect(RubyUnits::Unit.new('-1i').scalar).to eql(Complex(0, -1)) } + it { expect(RubyUnits::Unit.new('-1+1i').scalar).to eql(Complex(-1, 1)) } + it { expect(RubyUnits::Unit.new('+1+1i').scalar).to eql(Complex(1, 1)) } + it { expect(RubyUnits::Unit.new('1-1i').scalar).to eql(Complex(1, -1)) } + it { expect(RubyUnits::Unit.new('-1.23-4.5i').scalar).to eql(Complex(-1.23, -4.5)) } + it { expect(RubyUnits::Unit.new('1+0i').scalar).to be(1) } + end +end diff --git a/spec/ruby_units/unit_spec.rb b/spec/ruby_units/unit_spec.rb index 9f01b4fb..80b821b0 100644 --- a/spec/ruby_units/unit_spec.rb +++ b/spec/ruby_units/unit_spec.rb @@ -453,69 +453,55 @@ end # feet/in form - ['5 feet 6 inches', '5 feet 6 inch', '5ft 6in', '5 ft 6 in', %(5'6"), %(5' 6")].each do |unit| - describe unit do - subject { RubyUnits::Unit.new(unit) } + ['5 feet 6 inches', '5 feet 6 inch', '5ft 6in', '5 ft 6 in', %(5'6"), %(5' 6")].each do |unit_string| + describe unit_string do + subject(:unit) { RubyUnits::Unit.new(unit_string) } it { is_expected.to be_an_instance_of Unit } - - describe '#scalar' do - subject { super().scalar } - it { is_expected.to eq(5.5) } - end - - describe '#units' do - subject { super().units } - it { is_expected.to eq('ft') } - end - - describe '#kind' do - subject { super().kind } - it { is_expected.to eq(:length) } - end + it { is_expected.to have_attributes(scalar: 5.5, units: 'ft', kind: :length) } it { is_expected.not_to be_temperature } it { is_expected.not_to be_degree } it { is_expected.not_to be_base } it { is_expected.not_to be_unitless } it { is_expected.not_to be_zero } - - describe '#base' do - subject { super().base } - it { is_expected.to be_within(RubyUnits::Unit.new('0.01 m')).of RubyUnits::Unit.new('1.6764 m') } - end - specify { expect(subject.to_s(:ft)).to eq(%(5'6")) } + it { expect(unit.base).to be_within(RubyUnits::Unit.new('0.01 m')).of RubyUnits::Unit.new('1.6764 m') } + it { expect(unit.to_s(:ft)).to eq(%(5'6")) } end end - # pound/ounces form - describe RubyUnits::Unit.new('6lbs 5oz') do + describe RubyUnits::Unit.new(%(-5' 3/4")) do it { is_expected.to be_an_instance_of Unit } + it { is_expected.to have_attributes(scalar: -5.0625, units: 'ft', kind: :length) } + end - describe '#scalar' do - subject { super().scalar } - it { is_expected.to be_within(0.001).of 6.312 } - end + describe RubyUnits::Unit.new(%(-5' 1 3/4")) do + it { is_expected.to be_an_instance_of Unit } + it { is_expected.to have_attributes(scalar: (-247/48r), units: 'ft', kind: :length) } + end - describe '#units' do - subject { super().units } - it { is_expected.to eq('lbs') } - end + # pound/ounces form + describe '6lbs 5oz' do + subject(:unit) { RubyUnits::Unit.new('6lbs 5oz') } - describe '#kind' do - subject { super().kind } - it { is_expected.to eq(:mass) } - end + it { is_expected.to be_an_instance_of Unit } + it { is_expected.to have_attributes(scalar: 6.3125, units: 'lbs', kind: :mass) } it { is_expected.not_to be_temperature } it { is_expected.not_to be_degree } it { is_expected.not_to be_base } it { is_expected.not_to be_unitless } it { is_expected.not_to be_zero } + it { expect(unit.base).to be_within(RubyUnits::Unit.new('0.01 kg')).of RubyUnits::Unit.new('2.8633 kg') } + it { expect(unit.to_s(:lbs)).to eq('6 lbs 5 oz') } + end - describe '#base' do - subject { super().base } - it { is_expected.to be_within(RubyUnits::Unit.new('0.01 kg')).of RubyUnits::Unit.new('2.8633 kg') } - end - specify { expect(subject.to_s(:lbs)).to eq('6 lbs, 5 oz') } + describe RubyUnits::Unit.new('-6lbs 5oz') do + it { is_expected.to be_an_instance_of Unit } + it { is_expected.to have_attributes(scalar: -6.3125, units: 'lbs', kind: :mass) } + end + + describe RubyUnits::Unit.new('-6lbs 5 3/4oz') do + it { is_expected.to be_an_instance_of Unit } + it { is_expected.to have_attributes(scalar: -6.359375, units: 'lbs', kind: :mass) } end # temperature @@ -611,14 +597,14 @@ end end - describe RubyUnits::Unit.new("1:23:45,67") do + describe RubyUnits::Unit.new("1:23:45.67") do it { is_expected.to be_an_instance_of Unit } describe '#scalar' do subject { super().scalar } it { is_expected.to be_a(Numeric) } - it { is_expected.to be === 5025000067/3600000000r } + it { is_expected.to be === 5025067/3600000r } end describe '#units' do @@ -947,9 +933,9 @@ end # time string - describe RubyUnits::Unit.new('1:23:45,200') do + describe RubyUnits::Unit.new('1:23:45.200') do it { is_expected.to be_an_instance_of Unit } - it { is_expected.to eq(RubyUnits::Unit.new('1 h') + RubyUnits::Unit.new('23 min') + RubyUnits::Unit.new('45 seconds') + RubyUnits::Unit.new('200 usec')) } + it { is_expected.to eq(RubyUnits::Unit.new('1 h') + RubyUnits::Unit.new('23 min') + RubyUnits::Unit.new('45 seconds') + RubyUnits::Unit.new('200 ms')) } describe '#scalar' do subject { super().scalar } @@ -1795,6 +1781,7 @@ describe 'Foot-inch conversions' do [ ['76 in', %(6'4")], + ['-153/2 in', %(-6'4-1/2")], ['77 in', %(6'5")], ['78 in', %(6'6")], ['79 in', %(6'7")], @@ -1805,21 +1792,22 @@ ['66 in', %(5'6")], ['66in', %(5'6")] ].each do |inches, feet| - specify { expect(RubyUnits::Unit.new(inches).convert_to('ft')).to eq(RubyUnits::Unit.new(feet)) } + specify { expect(RubyUnits::Unit.new(inches)).to eq(RubyUnits::Unit.new(feet)) } specify { expect(RubyUnits::Unit.new(inches).to_s(:ft)).to eq(feet) } end end describe 'pound-ounce conversions' do [ - ['76 oz', '4 lbs, 12 oz'], - ['77 oz', '4 lbs, 13 oz'], - ['78 oz', '4 lbs, 14 oz'], - ['79 oz', '4 lbs, 15 oz'], - ['80 oz', '5 lbs, 0 oz'], - ['87 oz', '5 lbs, 7 oz'], - ['88 oz', '5 lbs, 8 oz'], - ['89 oz', '5 lbs, 9 oz'] + ['76 oz', '4 lbs 12 oz'], + ['-76.5 oz', '-4 lbs 12-1/2 oz'], + ['77 oz', '4 lbs 13 oz'], + ['78 oz', '4 lbs 14 oz'], + ['79 oz', '4 lbs 15 oz'], + ['80 oz', '5 lbs 0 oz'], + ['87 oz', '5 lbs 7 oz'], + ['88 oz', '5 lbs 8 oz'], + ['89 oz', '5 lbs 9 oz'] ].each do |ounces, pounds| specify { expect(RubyUnits::Unit.new(ounces).convert_to('lbs')).to eq(RubyUnits::Unit.new(pounds)) } specify { expect(RubyUnits::Unit.new(ounces).to_s(:lbs)).to eq(pounds) } @@ -1831,11 +1819,14 @@ ['14 stone 4', '200 lbs'], ['14 st 4', '200 lbs'], ['14 stone, 4 pounds', '200 lbs'], + ['-14 stone, 4 pounds', '-200 lbs'], ['14 st, 4 lbs', '200 lbs'] ].each do |stone, pounds| specify { expect(RubyUnits::Unit.new(stone).convert_to('lbs')).to eq(RubyUnits::Unit.new(pounds)) } end - specify { expect(RubyUnits::Unit.new('200 lbs').to_s(:stone)).to eq '14 stone, 4 lb' } + it { expect(RubyUnits::Unit.new('200 lbs').to_s(:stone)).to eq '14 stone 4 lbs' } + it { expect(RubyUnits::Unit.new('-200 lbs').to_s(:stone)).to eq '-14 stone 4 lbs' } + it { expect(RubyUnits::Unit.new('-14 stone, 4 1/2 lbs')).to eq RubyUnits::Unit.new('-200.5 lbs') } end end @@ -2093,18 +2084,18 @@ specify { expect { RubyUnits::Unit.new('100 tempK').inverse }.to raise_error(ArgumentError, 'Cannot divide with temperatures') } end - context 'convert to scalars' do - specify { expect(RubyUnits::Unit.new('10').to_i).to be_kind_of(Integer) } - specify { expect { RubyUnits::Unit.new('10 m').to_i }.to raise_error(RuntimeError, "Cannot convert '10 m' to Integer unless unitless. Use Unit#scalar") } + context 'when converting to scalars' do + it { expect(RubyUnits::Unit.new('10').to_i).to be_a(Integer) } + it { expect { RubyUnits::Unit.new('10 m').to_i }.to raise_error(RuntimeError, "Cannot convert '10 m' to Integer unless unitless. Use Unit#scalar") } - specify { expect(RubyUnits::Unit.new('10.0').to_f).to be_kind_of(Float) } - specify { expect { RubyUnits::Unit.new('10.0 m').to_f }.to raise_error(RuntimeError, "Cannot convert '10 m' to Float unless unitless. Use Unit#scalar") } + it { expect(RubyUnits::Unit.new('10.0').to_f).to be_a(Float) } + it { expect { RubyUnits::Unit.new('10.0 m').to_f }.to raise_error(RuntimeError, "Cannot convert '10 m' to Float unless unitless. Use Unit#scalar") } - specify { expect(RubyUnits::Unit.new('1+1i').to_c).to be_kind_of(Complex) } - specify { expect { RubyUnits::Unit.new('1+1i m').to_c }.to raise_error(RuntimeError, "Cannot convert '1.0+1.0i m' to Complex unless unitless. Use Unit#scalar") } + it { expect(RubyUnits::Unit.new('1+1i').to_c).to be_a(Complex) } + it { expect { RubyUnits::Unit.new('1+1i m').to_c }.to raise_error(RuntimeError, "Cannot convert '1+1i m' to Complex unless unitless. Use Unit#scalar") } - specify { expect(RubyUnits::Unit.new('3/7').to_r).to be_kind_of(Rational) } - specify { expect { RubyUnits::Unit.new('3/7 m').to_r }.to raise_error(RuntimeError, "Cannot convert '3/7 m' to Rational unless unitless. Use Unit#scalar") } + it { expect(RubyUnits::Unit.new('3/7').to_r).to be_a(Rational) } + it { expect { RubyUnits::Unit.new('3/7 m').to_r }.to raise_error(RuntimeError, "Cannot convert '3/7 m' to Rational unless unitless. Use Unit#scalar") } end context 'absolute value (#abs)' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7c94c015..52404c7c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -15,4 +15,4 @@ config.run_all_when_everything_filtered = true end -require File.dirname(__FILE__) + '/../lib/ruby-units' +require_relative '../lib/ruby-units'