Major unit conversion refactor + lots more specs

This commit is contained in:
Dan Elbert 2016-02-02 15:48:20 -06:00
parent ffb2c92f74
commit d5082f9c16
45 changed files with 1077 additions and 995 deletions

View File

@ -31,6 +31,8 @@ gem 'bcrypt', '~> 3.1.7'
group :development, :test do
gem 'guard', '~> 2.13.0'
gem 'guard-rspec', require: false
gem 'rspec-rails', '~> 3.4.0'
gem 'factory_girl_rails', '~> 4.5.0'
gem 'database_cleaner', '~> 1.5.1'

View File

@ -50,6 +50,7 @@ GEM
builder (3.2.2)
byebug (8.2.1)
cocoon (1.2.6)
coderay (1.1.0)
coffee-rails (4.1.1)
coffee-script (>= 2.2.0)
railties (>= 4.0.0, < 5.1.x)
@ -68,8 +69,24 @@ GEM
factory_girl_rails (4.5.0)
factory_girl (~> 4.5.0)
railties (>= 3.0.0)
ffi (1.9.10)
formatador (0.2.5)
globalid (0.3.6)
activesupport (>= 4.1.0)
guard (2.13.0)
formatador (>= 0.2.4)
listen (>= 2.7, <= 4.0)
lumberjack (~> 1.0)
nenv (~> 0.1)
notiffany (~> 0.0)
pry (>= 0.9.12)
shellany (~> 0.0)
thor (>= 0.18.1)
guard-compat (1.2.1)
guard-rspec (4.6.4)
guard (~> 2.1)
guard-compat (~> 1.1)
rspec (>= 2.99.0, < 4.0)
i18n (0.7.0)
jbuilder (2.4.0)
activesupport (>= 3.0.0, < 5.1)
@ -81,21 +98,34 @@ GEM
json (1.8.3)
libv8 (3.16.14.13)
liner (0.2.4)
listen (3.0.5)
rb-fsevent (>= 0.9.3)
rb-inotify (>= 0.9)
loofah (2.0.3)
nokogiri (>= 1.5.9)
lumberjack (1.0.10)
mail (2.6.3)
mime-types (>= 1.16, < 3)
memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1)
method_source (0.8.2)
mime-types (2.99)
mini_portile2 (2.0.0)
minitest (5.8.3)
multi_json (1.11.2)
mysql2 (0.3.20)
nenv (0.2.0)
nokogiri (1.6.7.1)
mini_portile2 (~> 2.0.0.rc2)
notiffany (0.0.8)
nenv (~> 0.1)
shellany (~> 0.0)
parslet (1.7.1)
blankslate (>= 2.0, <= 4.0)
pry (0.10.3)
coderay (~> 1.1.0)
method_source (~> 0.8.1)
slop (~> 3.4)
rack (1.6.4)
rack-test (0.6.3)
rack (>= 1.0)
@ -124,7 +154,14 @@ GEM
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
rake (10.5.0)
rb-fsevent (0.9.7)
rb-inotify (0.9.5)
ffi (>= 0.5.0)
ref (2.0.0)
rspec (3.4.0)
rspec-core (~> 3.4.0)
rspec-expectations (~> 3.4.0)
rspec-mocks (~> 3.4.0)
rspec-core (3.4.1)
rspec-support (~> 3.4.0)
rspec-expectations (3.4.0)
@ -149,7 +186,9 @@ GEM
sprockets (>= 2.8, < 4.0)
sprockets-rails (>= 2.0, < 4.0)
tilt (>= 1.1, < 3)
shellany (0.0.1)
signed_multiset (0.2.1)
slop (3.6.0)
sprockets (3.5.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
@ -192,6 +231,8 @@ DEPENDENCIES
cocoon (~> 1.2.6)
database_cleaner (~> 1.5.1)
factory_girl_rails (~> 4.5.0)
guard (~> 2.13.0)
guard-rspec
jbuilder (~> 2.0)
jquery-rails
mysql2 (~> 0.3.18)

70
Guardfile Normal file
View File

@ -0,0 +1,70 @@
# A sample Guardfile
# More info at https://github.com/guard/guard#readme
## Uncomment and set this to only include directories you want to watch
# directories %w(app lib config test spec features) \
# .select{|d| Dir.exists?(d) ? d : UI.warning("Directory #{d} does not exist")}
## Note: if you are using the `directories` clause above and you are not
## watching the project directory ('.'), then you will want to move
## the Guardfile to a watched dir and symlink it back, e.g.
#
# $ mkdir config
# $ mv Guardfile config/
# $ ln -s config/Guardfile .
#
# and, you'll have to watch "config/Guardfile" instead of "Guardfile"
# Note: The cmd option is now required due to the increasing number of ways
# rspec may be run, below are examples of the most common uses.
# * bundler: 'bundle exec rspec'
# * bundler binstubs: 'bin/rspec'
# * spring: 'bin/rspec' (This will use spring if running and you have
# installed the spring binstubs per the docs)
# * zeus: 'zeus rspec' (requires the server to be started separately)
# * 'just' rspec: 'rspec'
guard :rspec, cmd: "bundle exec rspec" do
require "guard/rspec/dsl"
dsl = Guard::RSpec::Dsl.new(self)
# Feel free to open issues for suggestions and improvements
# RSpec files
rspec = dsl.rspec
watch(rspec.spec_helper) { rspec.spec_dir }
watch(rspec.spec_support) { rspec.spec_dir }
watch(rspec.spec_files)
# Ruby files
ruby = dsl.ruby
dsl.watch_spec_files_for(ruby.lib_files)
# Rails files
rails = dsl.rails(view_extensions: %w(erb haml slim))
dsl.watch_spec_files_for(rails.app_files)
dsl.watch_spec_files_for(rails.views)
watch(rails.controllers) do |m|
[
rspec.spec.("routing/#{m[1]}_routing"),
rspec.spec.("controllers/#{m[1]}_controller"),
rspec.spec.("acceptance/#{m[1]}")
]
end
# Rails config changes
watch(rails.spec_helper) { rspec.spec_dir }
watch(rails.routes) { "#{rspec.spec_dir}/routing" }
watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
# Capybara features specs
watch(rails.view_dirs) { |m| rspec.spec.("features/#{m[1]}") }
watch(rails.layouts) { |m| rspec.spec.("features/#{m[1]}") }
# Turnip features and steps
watch(%r{^spec/acceptance/(.+)\.feature$})
watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
end
end

View File

@ -40,16 +40,11 @@
"/calculator/calculate",
{input: $input.val(), output_unit: $outputUnit.val()},
function(data) {
if (data.errors.input) {
$input.closest(".form-group").addClass("has-error");
if (data.errors.length) {
$("#errors_panel").show();
$("#errors_container").html(data.errors.join(" "));
} else {
$input.closest(".form-group").removeClass("has-error");
}
if (data.errors.output_unit) {
$outputUnit.closest(".form-group").addClass("has-error");
} else {
$outputUnit.closest(".form-group").removeClass("has-error");
$("#errors_panel").hide();
}
$output.val(data.output);

View File

@ -7,36 +7,20 @@ class CalculatorController < ApplicationController
def calculate
input = params[:input]
output_unit = params[:output_unit]
parsed_input = nil
original_number = nil
data = {errors: {}, output: ''}
data = {errors: [], output: ''}
if input.present?
begin
parsed_input = UnitConversion.parse(input)
original_number = parsed_input.value
rescue UnitConversion::UnparseableUnitError => e
data[:errors][:input] = [e.message]
begin
unit = UnitConversion.parse(input)
if output_unit.present?
unit = unit.convert(output_unit)
data[:output] = unit.to_s
else
data[:output] = unit.auto_unit.to_s
end
if parsed_input.present? && output_unit.present?
begin
parsed_input = parsed_input.convert_to output_unit
rescue Unitwise::ExpressionError => e
data[:errors][:output_unit] = [e.message]
end
end
if parsed_input
puts parsed_input.value
puts parsed_input.unit
data[:output] = UnitConversion.auto_unit(UnitConversion.quantity_format(parsed_input.value, original_number), parsed_input.unit.to_s)
end
else
data[:errors][:input] = ['Invalid input']
rescue UnitConversion::UnparseableUnitError => e
data[:errors] << e.message
end
render json: data

View File

@ -5,7 +5,7 @@ class DensityValidator < ActiveModel::EachValidator
begin
unit = UnitConversion::parse(value)
valid = UnitConversion::density? unit
valid = unit.density?
rescue UnitConversion::UnparseableUnitError => e
valid = false
msg = e.message

View File

@ -50,15 +50,13 @@ class Ingredient < ActiveRecord::Base
def calculate_density(grams, description)
return nil if grams.blank? || description.blank?
# replace 'fl oz' with 'floz'
description = description.gsub(/fl oz/i, 'floz')
begin
unit = UnitConversion.parse(description)
if UnitConversion.volume?(unit)
mass = Unitwise(grams, 'g')
density = (mass / unit).convert_to(UnitConversion.normalize_unit_names('oz/cup'))
return "#{density.value.round(4)} oz/cup"
value_unit = UnitConversion.parse(description)
if value_unit.volume?
density_value = grams.to_d / value_unit.raw_value
density_units = "g/#{value_unit.unit.unit}"
density = UnitConversion.parse(density_value, density_units)
return density.convert('oz/cup').to_s
else
return nil
end

View File

@ -21,11 +21,16 @@ class RecipeIngredient < ActiveRecord::Base
def scale(factor, auto_unit = false)
if factor.present? && self.quantity.present? && factor != '1'
self.quantity = UnitConversion.convert(self.quantity, factor, nil, nil)
value_unit = UnitConversion.parse(self.quantity, self.units)
value_unit = value_unit.scale(factor)
if auto_unit
self.quantity, self.units = UnitConversion.auto_unit(self.quantity, self.units)
value_unit = value_unit.auto_unit
end
self.quantity = value_unit.pretty_value
self.units = value_unit.unit.to_s
end
end

View File

@ -1,264 +0,0 @@
module UnitConversion
INTEGER_REGEX = /-?[0-9]+/
DECIMAL_REGEX = /(?:-?[0-9]+)?\.[0-9]+/
RATIONAL_REGEX = /-?(?:[0-9]+ )?[0-9]+\/[0-9]+/
UNIT_REGEX = /[\[\]a-zA-Z][\[\]a-zA-Z\/.\-()0-9]*/
UNIT_PARSING_REGEX = /^(?<value>#{INTEGER_REGEX}|#{DECIMAL_REGEX}|#{RATIONAL_REGEX})\s+(?<unit>#{UNIT_REGEX})$/
UNIT_ALIASES = {
'[cup_us]': %w(cup cups c),
'[tbs_us]': %w(tablespoon tablespoons tbsp tbs),
'[tsp_us]': %w(teaspoon teaspoons tsp),
'[oz_av]': %w(ounce ounces oz),
'[lb_av]': %w(pound pounds lb lbs),
'[pt_us]': %w(pint pints),
'[qt_us]': %w(quart quarts qt),
'[gal_us]': %w(gallon gallons ga),
'[foz_us]': %w(foz floz),
g: %w(gram grams),
kg: %w(kilograms kilogram),
ml: %w(milliliter milliliters),
l: %w(liter liters)
}
UNIT_ALIAS_MAP = Hash[UNIT_ALIASES.map { |unit, values| values.map { |v| [v, unit] } }.flatten(1)]
# map that gives comfortable ranges for a given set of units
UNIT_RANGES = {
'[tsp_us]': (0.125...3.0),
'[tbs_us]': (0.5...4.0),
'[cup_us]': (0.25...4.0),
'[qt_us]': (1.0...4.0),
'[gal_us]': (0.5..9999),
'[oz_av]': (0..16),
'[lb_av]': (1.0..9999),
g: (1.0..750),
kg: (0.5..9999),
ml: (1.0..750),
l: (0.5..9999)
}
UNIT_ORDERS = {
standard_volume: [
:'[tsp_us]',
:'[tbs_us]',
:'[cup_us]',
:'[qt_us]',
:'[gal_us]'
],
metric_volume: [
:ml,
:l
],
standard_mass: [
:'[oz_av]',
:'[lb_av]'
],
metric_mass: [
:g,
:kg
]
}
class UnparseableUnitError < StandardError
end
class UnknownUnitError < UnparseableUnitError
end
class << self
def parse(unit_string)
match = UNIT_PARSING_REGEX.match(unit_string.to_s.strip)
if match && match[:value].present? && match[:unit].present?
begin
value = get_value(match[:value])
Unitwise(value, normalize_unit_names(match[:unit]))
rescue Unitwise::ExpressionError => err
raise UnknownUnitError, err.message
end
else
raise UnparseableUnitError, "'#{unit_string}' does not appear to be a valid measurement of the form <value> <units> (ie '5 cup' or '223 gram/cup')"
end
end
def density?(unit)
unit.compatible_with? Unitwise(1, 'g/ml')
end
def volume?(unit)
unit.compatible_with? Unitwise(1, 'ml')
end
def mass?(unit)
unit.compatible_with? Unitwise(1, 'g')
end
# Returns a Unitwise representation of the density. Raises an exception if the value is anything other than a
# valid density
def get_density(str)
raise UnknownUnitError, 'No density provided' if str.blank?
begin
unit = parse(str)
rescue UnparseableUnitError => err
raise UnknownUnitError "Invalid density: #{err.message}"
end
raise UnknownUnitError, "Invalid density: #{str} is not a density" unless density?(unit)
unit
end
def normalize_unit_names(unit_description)
return unit_description.to_sym if UNIT_ALIASES.include?(unit_description.to_sym)
unit_description.downcase.gsub(/[a-z]+/) do |match|
UNIT_ALIAS_MAP[match] || match
end
end
def get_value(str)
if str =~ /^#{INTEGER_REGEX}$/
str.to_i
elsif str =~ /^#{RATIONAL_REGEX}$/
parts = str.split(' ')
if parts.length == 2
whole = parts.first.to_r
fractional = parts.last.to_r
(whole.abs + fractional) * (whole < 0 ? -1.to_r : 1.to_r)
else
str.to_r
end
elsif str =~ /^#{DECIMAL_REGEX}$/
str.to_d
else
raise UnparseableUnitError, "str (#{str}) is not a valid number"
end
end
def convert(quantity, factor, input_unit, output_unit, density = nil)
value = get_value(quantity)
factor = get_value(factor)
if value.is_a?(BigDecimal) && factor.is_a?(Rational)
factor = factor.to_d(10)
end
converted = value * factor
input_unit = normalize_unit_names(input_unit) unless input_unit.nil?
output_unit = normalize_unit_names(output_unit) unless output_unit.nil?
if input_unit.present? && output_unit.present? && input_unit != output_unit
in_unit = Unitwise(1, input_unit)
out_unit = Unitwise(1, output_unit)
unit = Unitwise(converted, input_unit)
if volume?(in_unit) && mass?(out_unit)
unit_density = get_density(density)
unit = unit * unit_density
elsif mass?(in_unit) && volume?(out_unit)
unit_density = get_density(density)
unit = unit / unit_density
end
converted = unit.convert_to(output_unit).value
end
quantity_format(converted, value)
end
def quantity_format(quantity, original_quantity)
if original_quantity.is_a?(Rational) || original_quantity.is_a?(Integer)
rational_val = rationalize(quantity)
if rational_val.denominator == 1
rational_val.to_i.to_s
elsif rational_val.denominator < rational_val.numerator.abs
whole = rational_val.floor
fraction = rational_val - whole
"#{whole} #{fraction}"
else
rational_val.to_s
end
else
"%g" % ("%.3f" % quantity)
end
end
def rationalize(value)
if value.is_a? Integer
return value
end
if value.floor == value
return value
end
useful_denominators = [2, 3, 4, 8, 16]
if value.is_a?(Rational) && useful_denominators.include?(value.denominator)
return value
end
whole = value.floor
fraction = value - whole
approximations = useful_denominators.map do |d|
best_n = (1...d).each.map { |n| {delta: (fraction - Rational(n, d)).abs, numerator: n} }.sort { |a, b| a[:delta] <=> b[:delta] }.first
{denominator: d, numerator: best_n[:numerator], delta: best_n[:delta]}
end
# Add 0 and 1
approximations << { denominator: 1, numerator: 0, delta: (fraction - Rational(0,1)).abs }
approximations << { denominator: 1, numerator: 1, delta: (fraction - Rational(1,1)).abs }
best = approximations.sort { |a, b| [a[:delta], a[:denominator]] <=> [b[:delta], b[:denominator]] }.first
Rational((whole * best[:denominator]) + best[:numerator], best[:denominator])
end
# Given an awkward measurement such as '18 9/10 oz' or '5/24 cup' or '0.09434 cup',
# it will round the rational and scale the unit to give a more reasonable, useful measurement, ie
# 19 oz, 3 Tbsp, 1 1/2 Tbsp
def auto_unit(quantity, units)
normalized_unit = normalize_unit_names(units)
value = initial_value = get_value(quantity)
type_key, unit_orders = UNIT_ORDERS.detect { |key, orders| orders.include?(normalized_unit.to_sym) }
new_unit = normalized_unit
if unit_orders && (unit_range = UNIT_RANGES[normalized_unit.to_sym])
if value < unit_range.first
unit_orders = unit_orders.reverse
end
idx = unit_orders.index(new_unit.to_sym)
while !unit_range.include?(value) && idx < (unit_orders.length - 1)
idx += 1
next_unit = unit_orders[idx]
value = Unitwise(value, new_unit).convert_to(next_unit).value.round(4)
new_unit = next_unit.to_s
unit_range = UNIT_RANGES[new_unit.to_sym]
end
end
if normalized_unit == new_unit
new_unit = units
else
new_unit = (UNIT_ALIASES[new_unit.to_sym] || []).first || new_unit
end
return quantity_format(value, initial_value), new_unit
end
end
end

View File

@ -22,5 +22,18 @@
</div>
<div class="row">
<div class="col-xs-12">
<div id="errors_panel" class="panel panel-danger" style="display: none;">
<div class="panel-heading">
<h3 class="panel-title">Error</h3>
</div>
<div id="errors_container" class="panel-body">
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,2 @@
require 'unit_conversion'

36
lib/unit_conversion.rb Normal file
View File

@ -0,0 +1,36 @@
require 'unit_conversion/constants'
require 'unit_conversion/errors'
require 'unit_conversion/formatters'
require 'unit_conversion/parsed_number'
require 'unit_conversion/parsed_unit'
require 'unit_conversion/conversions'
require 'unit_conversion/value_unit'
module UnitConversion
class << self
def parse(value_string, unit_string = nil)
ValueUnit.for(value_string, unit_string)
end
def convert(quantity, factor, input_unit, output_unit, density = nil)
unit_value = parse(quantity, input_unit).scale(factor)
if output_unit.present?
unit_value = unit_value.convert(output_unit, density)
end
unit_value.pretty_value
end
# Given an awkward measurement such as '18 9/10 oz' or '5/24 cup' or '0.09434 cup',
# it will round the rational and scale the unit to give a more reasonable, useful measurement, ie
# 19 oz, 3 Tbsp, 1 1/2 Tbsp
def auto_unit(quantity, units)
unit = parse(quantity, units).auto_unit
[unit.pretty_value, unit.unit.to_s]
end
end
end

View File

@ -0,0 +1,79 @@
module UnitConversion
INTEGER_REGEX = /-?[0-9]+/
DECIMAL_REGEX = /(?:-?[0-9]+)?\.[0-9]+/
RATIONAL_REGEX = /-?(?:[0-9]+ )?[0-9]+\/[0-9]+/
UNIT_REGEX = /[\[\]a-zA-Z][\[\]a-zA-Z\/.\-()0-9 ]*/
UNIT_PARSING_REGEX = /^(?<value>#{INTEGER_REGEX}|#{DECIMAL_REGEX}|#{RATIONAL_REGEX})(?:\s+(?<unit>#{UNIT_REGEX}))?$/
STANDARD_UNIT_ALIASES = {
'[cup_us]': %w(cup cups c),
'[tbs_us]': %w(tablespoon tablespoons tbsp tbs),
'[tsp_us]': %w(teaspoon teaspoons tsp),
'[oz_av]': %w(ounce ounces oz),
'[lb_av]': %w(pound pounds lb lbs),
'[pt_us]': %w(pint pints),
'[qt_us]': %w(quart quarts qt),
'[gal_us]': %w(gallon gallons ga),
'[foz_us]': %w(foz floz),
}
METRIC_UNIT_ALIASES = {
g: %w(gram grams),
kg: %w(kilograms kilogram),
ml: %w(milliliter milliliters),
cl: %w(centiliter centiliters),
dl: %w(deciliter deciliters),
l: %w(liter liters),
m: %w(meter meters),
cm: %w(centimeter centimeters)
}
UNIT_ALIASES = STANDARD_UNIT_ALIASES.merge(METRIC_UNIT_ALIASES)
UNIT_ALIAS_MAP = Hash[UNIT_ALIASES.map { |unit, values| values.map { |v| [v, unit] } }.flatten(1)]
# map that gives comfortable ranges for a given set of units
UNIT_RANGES = {
'[tsp_us]': (0.125...3.0),
'[tbs_us]': (0.5...4.0),
'[cup_us]': (0.25...4.0),
'[qt_us]': (1.0..3.9),
'[gal_us]': (0.5..9999),
'[oz_av]': (0..16),
'[lb_av]': (1.0..9999),
g: (1.0..750),
kg: (0.5..9999),
ml: (1.0..750),
l: (0.5..9999)
}
UNIT_ORDERS = {
standard_volume: [
:'[tsp_us]',
:'[tbs_us]',
:'[cup_us]',
:'[qt_us]',
:'[gal_us]'
],
metric_volume: [
:ml,
:l
],
standard_mass: [
:'[oz_av]',
:'[lb_av]'
],
metric_mass: [
:g,
:kg
]
}
end

View File

@ -0,0 +1,83 @@
module UnitConversion
class Conversion
end
class ScaleConversion < Conversion
def initialize(parsed_factor)
@factor = parsed_factor
end
def convert(value_unit)
value = @factor.value * value_unit.value.value
ValueUnit.for(value, value_unit.unit, value_unit.formatter)
end
end
class ConvertConversion < Conversion
def initialize(parsed_unit, density_unit_value = nil)
@target_unit = parsed_unit
raise UnknownUnitError, "#{density_unit_value} is not a density" if density_unit_value && !density_unit_value.density?
@density = density_unit_value
end
def convert(value_unit)
input = value_unit.unitwise
if value_unit.volume? && @target_unit.mass?
raise MissingDensityError, "Cannot convert #{value_unit.unit} to #{@target_unit} without density" unless @density
input = input * @density.unitwise
elsif value_unit.mass? && @target_unit.volume?
raise MissingDensityError, "Cannot convert #{value_unit.unit} to #{@target_unit} without density" unless @density
input = input / @density.unitwise
end
input = input.convert_to @target_unit.unit
formatter = @target_unit.metric? ? DecimalFormatter.new : value_unit.formatter
ValueUnit.for(input.value, @target_unit, formatter)
end
end
class AutoUnitConversion < Conversion
def convert(value_unit)
unless known_auto_unit?(value_unit.unit)
return value_unit
end
value = value_unit.raw_value
unit = value_unit.unit.unit
new_unit = unit
unit_orders = UNIT_ORDERS.values.detect { |orders| orders.include?(unit.to_sym) }
unit_range = UNIT_RANGES[unit.to_sym]
if value < unit_range.first
unit_orders = unit_orders.reverse
end
idx = unit_orders.index(new_unit.to_sym)
while !unit_range.include?(value) && idx < (unit_orders.length - 1)
idx += 1
next_unit = unit_orders[idx]
value = Unitwise(value, new_unit).convert_to(next_unit).value
new_unit = next_unit.to_s
unit_range = UNIT_RANGES[new_unit.to_sym]
end
ValueUnit.for(value, new_unit, value_unit.formatter)
end
def known_auto_unit?(unit)
unit && UNIT_ORDERS.values.flatten.include?(unit.unit.to_sym)
end
end
end

View File

@ -0,0 +1,10 @@
module UnitConversion
class UnparseableUnitError < StandardError
end
class UnknownUnitError < UnparseableUnitError
end
class MissingDensityError < UnparseableUnitError
end
end

View File

@ -0,0 +1,75 @@
module UnitConversion
class NumberFormatter
def self.for(parsed_value, parsed_unit)
case
when parsed_unit && parsed_unit.metric?
DecimalFormatter
when parsed_value.rational?
RationalFormatter
else
DecimalFormatter
end.new
end
def initialize
end
# Converts a Numeric into a cooking-friendly Rational
def rationalize(value)
# NOTE: this algorithm seems stupid.
if value.floor == value
return value
end
useful_denominators = [2, 3, 4, 8, 16]
if value.is_a?(Rational) && useful_denominators.include?(value.denominator)
return value
end
whole = value.floor
fraction = value - whole
approximations = useful_denominators.map do |d|
best_n = (1...d).each.map { |n| {delta: (fraction - Rational(n, d)).abs, numerator: n} }.sort { |a, b| a[:delta] <=> b[:delta] }.first
{denominator: d, numerator: best_n[:numerator], delta: best_n[:delta]}
end
# Add 0 and 1
approximations << { denominator: 1, numerator: 0, delta: (fraction - Rational(0,1)).abs }
approximations << { denominator: 1, numerator: 1, delta: (fraction - Rational(1,1)).abs }
best = approximations.sort { |a, b| [a[:delta], a[:denominator]] <=> [b[:delta], b[:denominator]] }.first
Rational((whole * best[:denominator]) + best[:numerator], best[:denominator])
end
end
class RationalFormatter < NumberFormatter
def format(value)
rational_val = rationalize(value)
if rational_val.denominator == 1
rational_val.to_i.to_s
elsif rational_val.denominator < rational_val.numerator.abs
whole = rational_val.floor
fraction = rational_val - whole
"#{whole} #{fraction}"
else
rational_val.to_s
end
end
end
class DecimalFormatter < NumberFormatter
def format(value)
"%g" % ("%.3f" % value)
end
end
end

View File

@ -0,0 +1,38 @@
module UnitConversion
class ParsedNumber
attr_reader :value
def initialize(str)
@original_value = str
@value = str.is_a?(Numeric) ? str : parse_value(str)
end
def to_s
@value.to_s
end
def rational?
value.is_a?(Integer) || value.is_a?(Rational)
end
def parse_value(str)
if str =~ /^#{INTEGER_REGEX}$/
str.to_i
elsif str =~ /^#{RATIONAL_REGEX}$/
parts = str.split(' ')
if parts.length == 2
whole = parts.first.to_r
fractional = parts.last.to_r
(whole.abs + fractional) * (whole < 0 ? -1.to_r : 1.to_r)
else
str.to_r
end
elsif str =~ /^#{DECIMAL_REGEX}$/
str.to_d
else
raise UnparseableUnitError, "str [#{str}] is not a valid number"
end
end
end
end

View File

@ -0,0 +1,64 @@
module UnitConversion
class ParsedUnit
attr_reader :original_unit, :unit
def initialize(str)
@original_unit = str
@unit = normalize_unit_names(str)
end
def to_s
unit.gsub(/[a-z_\[\]]+/) do |match|
if aliases = UNIT_ALIASES[match.to_sym]
aliases.first
else
match
end
end
end
def density?
compatible? 'g/ml'
end
def volume?
compatible? 'ml'
end
def mass?
compatible? 'g'
end
def metric?
METRIC_UNIT_ALIASES.keys.include? unit.to_sym
end
def unitwise(value = 1)
begin
Unitwise(value, unit)
rescue Unitwise::ExpressionError => err
raise UnknownUnitError, err.message
end
end
def compatible?(unit_str)
begin
unitwise.compatible_with? Unitwise(1, unit_str)
rescue UnknownUnitError
false
end
end
def normalize_unit_names(unit_description)
return unit_description.to_s if UNIT_ALIASES.include?(unit_description.to_sym)
# fluid ounce is the only known unit with a space; fix it up before the following replacement
unit_description = unit_description.gsub(/fl oz/i, 'floz')
unit_description.downcase.gsub(/[a-z_\[\]]+/) do |match|
UNIT_ALIAS_MAP[match] || match
end
end
end
end

View File

@ -0,0 +1,151 @@
module UnitConversion
class ValueUnit
def self.for(value_string, unit_string = nil, formatter = nil)
raise UnparseableUnitError, "value is empty" if value_string.blank?
if String === value_string && unit_string.nil?
value_string, unit_string = parse_single_string(value_string)
end
if value_string.is_a?(ParsedNumber)
value = value_string
else
value = ParsedNumber.new(value_string)
end
unit = case unit_string
when nil
nil
when ->(u) { u.blank? }
nil
when ParsedUnit
unit_string
else
ParsedUnit.new(unit_string)
end
if unit.nil?
ValueNoUnit.new(value, formatter)
else
ValueUnit.new(value, unit, formatter)
end
end
def self.parse_single_string(value_unit_string)
match = UNIT_PARSING_REGEX.match(value_unit_string.to_s.strip)
if match && match[:value].present?
return match[:value], match[:unit]
else
raise UnparseableUnitError, "'#{value_unit_string}' does not appear to be a valid measurement of the form <value> <units> (ie '5 cup' or '223 gram/cup')"
end
end
attr_reader :formatter
def initialize(value, unit, formatter = nil)
@value = value
@unit = unit
@formatter = formatter || NumberFormatter.for(value, unit)
end
def to_s
"#{pretty_value} #{unit}"
end
def unit
@unit
end
def value
@value
end
def raw_value
@value.value
end
def unitwise
unit.unitwise(value.value)
end
# Returns a new ValueUnit scaled by the given factor
def scale(factor)
if factor.present?
parsed_factor = ParsedNumber.new(factor)
ScaleConversion.new(parsed_factor).convert(self)
else
self
end
end
# Returns a new ValueUnit with the given new_parsed_unit. If converting between mass and volume,
# also requires a density UnitValue
def convert(new_unit, density = nil)
new_parsed_unit = ParsedUnit.new(new_unit)
parsed_density = density ? ValueUnit.for(density) : nil
if new_parsed_unit.unit != self.unit.unit
ConvertConversion.new(new_parsed_unit, parsed_density).convert(self)
else
self
end
end
def auto_unit
AutoUnitConversion.new.convert(self)
end
def density?
unit.density?
end
def volume?
unit.volume?
end
def mass?
unit.mass?
end
def pretty_value
formatter.format(value.value)
end
end
class ValueNoUnit < ValueUnit
def initialize(value, formatter = nil)
@value = value
@formatter = formatter || NumberFormatter.for(value, unit)
end
def to_s
"#{pretty_value}"
end
def unit
nil
end
def unitwise
raise UnknownUnitError, "No unit value provided"
end
def convert(new_parsed_unit, density_unit_value = nil)
raise UnknownUnitError, "No unit value provided"
end
def density?
false
end
def volume?
false
end
def mass?
false
end
end
end

View File

@ -1,159 +0,0 @@
require 'rails_helper'
# This spec was generated by rspec-rails when you ran the scaffold generator.
# It demonstrates how one might use RSpec to specify the controller code that
# was generated by Rails when you ran the scaffold generator.
#
# It assumes that the implementation code is generated by the rails scaffold
# generator. If you are using any extension libraries to generate different
# controller code, this generated spec may or may not pass.
#
# It only uses APIs available in rails and/or rspec-rails. There are a number
# of tools you can use to make these specs even more expressive, but we're
# sticking to rails and rspec-rails APIs to keep things simple and stable.
#
# Compared to earlier versions of this generator, there is very limited use of
# stubs and message expectations in this spec. Stubs are only used when there
# is no simpler way to get a handle on the object needed for the example.
# Message expectations are only used when there is no simpler way to specify
# that an instance is receiving a specific message.
RSpec.describe IngredientsController, type: :controller do
# This should return the minimal set of attributes required to create a valid
# Ingredient. As you add validations to Ingredient, be sure to
# adjust the attributes here as well.
let(:valid_attributes) {
skip("Add a hash of attributes valid for your model")
}
let(:invalid_attributes) {
skip("Add a hash of attributes invalid for your model")
}
# This should return the minimal set of values that should be in the session
# in order to pass any filters (e.g. authentication) defined in
# IngredientsController. Be sure to keep this updated too.
let(:valid_session) { {} }
describe "GET #index" do
it "assigns all ingredients as @ingredients" do
ingredient = Ingredient.create! valid_attributes
get :index, {}, valid_session
expect(assigns(:ingredients)).to eq([ingredient])
end
end
describe "GET #show" do
it "assigns the requested ingredient as @ingredient" do
ingredient = Ingredient.create! valid_attributes
get :show, {:id => ingredient.to_param}, valid_session
expect(assigns(:ingredient)).to eq(ingredient)
end
end
describe "GET #new" do
it "assigns a new ingredient as @ingredient" do
get :new, {}, valid_session
expect(assigns(:ingredient)).to be_a_new(Ingredient)
end
end
describe "GET #edit" do
it "assigns the requested ingredient as @ingredient" do
ingredient = Ingredient.create! valid_attributes
get :edit, {:id => ingredient.to_param}, valid_session
expect(assigns(:ingredient)).to eq(ingredient)
end
end
describe "POST #create" do
context "with valid params" do
it "creates a new Ingredient" do
expect {
post :create, {:ingredient => valid_attributes}, valid_session
}.to change(Ingredient, :count).by(1)
end
it "assigns a newly created ingredient as @ingredient" do
post :create, {:ingredient => valid_attributes}, valid_session
expect(assigns(:ingredient)).to be_a(Ingredient)
expect(assigns(:ingredient)).to be_persisted
end
it "redirects to the created ingredient" do
post :create, {:ingredient => valid_attributes}, valid_session
expect(response).to redirect_to(Ingredient.last)
end
end
context "with invalid params" do
it "assigns a newly created but unsaved ingredient as @ingredient" do
post :create, {:ingredient => invalid_attributes}, valid_session
expect(assigns(:ingredient)).to be_a_new(Ingredient)
end
it "re-renders the 'new' template" do
post :create, {:ingredient => invalid_attributes}, valid_session
expect(response).to render_template("new")
end
end
end
describe "PUT #update" do
context "with valid params" do
let(:new_attributes) {
skip("Add a hash of attributes valid for your model")
}
it "updates the requested ingredient" do
ingredient = Ingredient.create! valid_attributes
put :update, {:id => ingredient.to_param, :ingredient => new_attributes}, valid_session
ingredient.reload
skip("Add assertions for updated state")
end
it "assigns the requested ingredient as @ingredient" do
ingredient = Ingredient.create! valid_attributes
put :update, {:id => ingredient.to_param, :ingredient => valid_attributes}, valid_session
expect(assigns(:ingredient)).to eq(ingredient)
end
it "redirects to the ingredient" do
ingredient = Ingredient.create! valid_attributes
put :update, {:id => ingredient.to_param, :ingredient => valid_attributes}, valid_session
expect(response).to redirect_to(ingredient)
end
end
context "with invalid params" do
it "assigns the ingredient as @ingredient" do
ingredient = Ingredient.create! valid_attributes
put :update, {:id => ingredient.to_param, :ingredient => invalid_attributes}, valid_session
expect(assigns(:ingredient)).to eq(ingredient)
end
it "re-renders the 'edit' template" do
ingredient = Ingredient.create! valid_attributes
put :update, {:id => ingredient.to_param, :ingredient => invalid_attributes}, valid_session
expect(response).to render_template("edit")
end
end
end
describe "DELETE #destroy" do
it "destroys the requested ingredient" do
ingredient = Ingredient.create! valid_attributes
expect {
delete :destroy, {:id => ingredient.to_param}, valid_session
}.to change(Ingredient, :count).by(-1)
end
it "redirects to the ingredients list" do
ingredient = Ingredient.create! valid_attributes
delete :destroy, {:id => ingredient.to_param}, valid_session
expect(response).to redirect_to(ingredients_url)
end
end
end

View File

@ -1,159 +0,0 @@
require 'rails_helper'
# This spec was generated by rspec-rails when you ran the scaffold generator.
# It demonstrates how one might use RSpec to specify the controller code that
# was generated by Rails when you ran the scaffold generator.
#
# It assumes that the implementation code is generated by the rails scaffold
# generator. If you are using any extension libraries to generate different
# controller code, this generated spec may or may not pass.
#
# It only uses APIs available in rails and/or rspec-rails. There are a number
# of tools you can use to make these specs even more expressive, but we're
# sticking to rails and rspec-rails APIs to keep things simple and stable.
#
# Compared to earlier versions of this generator, there is very limited use of
# stubs and message expectations in this spec. Stubs are only used when there
# is no simpler way to get a handle on the object needed for the example.
# Message expectations are only used when there is no simpler way to specify
# that an instance is receiving a specific message.
RSpec.describe RecipesController, type: :controller do
# This should return the minimal set of attributes required to create a valid
# Recipe. As you add validations to Recipe, be sure to
# adjust the attributes here as well.
let(:valid_attributes) {
skip("Add a hash of attributes valid for your model")
}
let(:invalid_attributes) {
skip("Add a hash of attributes invalid for your model")
}
# This should return the minimal set of values that should be in the session
# in order to pass any filters (e.g. authentication) defined in
# RecipesController. Be sure to keep this updated too.
let(:valid_session) { {} }
describe "GET #index" do
it "assigns all recipes as @recipes" do
recipe = Recipe.create! valid_attributes
get :index, {}, valid_session
expect(assigns(:recipes)).to eq([recipe])
end
end
describe "GET #show" do
it "assigns the requested recipe as @recipe" do
recipe = Recipe.create! valid_attributes
get :show, {:id => recipe.to_param}, valid_session
expect(assigns(:recipe)).to eq(recipe)
end
end
describe "GET #new" do
it "assigns a new recipe as @recipe" do
get :new, {}, valid_session
expect(assigns(:recipe)).to be_a_new(Recipe)
end
end
describe "GET #edit" do
it "assigns the requested recipe as @recipe" do
recipe = Recipe.create! valid_attributes
get :edit, {:id => recipe.to_param}, valid_session
expect(assigns(:recipe)).to eq(recipe)
end
end
describe "POST #create" do
context "with valid params" do
it "creates a new Recipe" do
expect {
post :create, {:recipe => valid_attributes}, valid_session
}.to change(Recipe, :count).by(1)
end
it "assigns a newly created recipe as @recipe" do
post :create, {:recipe => valid_attributes}, valid_session
expect(assigns(:recipe)).to be_a(Recipe)
expect(assigns(:recipe)).to be_persisted
end
it "redirects to the created recipe" do
post :create, {:recipe => valid_attributes}, valid_session
expect(response).to redirect_to(Recipe.last)
end
end
context "with invalid params" do
it "assigns a newly created but unsaved recipe as @recipe" do
post :create, {:recipe => invalid_attributes}, valid_session
expect(assigns(:recipe)).to be_a_new(Recipe)
end
it "re-renders the 'new' template" do
post :create, {:recipe => invalid_attributes}, valid_session
expect(response).to render_template("new")
end
end
end
describe "PUT #update" do
context "with valid params" do
let(:new_attributes) {
skip("Add a hash of attributes valid for your model")
}
it "updates the requested recipe" do
recipe = Recipe.create! valid_attributes
put :update, {:id => recipe.to_param, :recipe => new_attributes}, valid_session
recipe.reload
skip("Add assertions for updated state")
end
it "assigns the requested recipe as @recipe" do
recipe = Recipe.create! valid_attributes
put :update, {:id => recipe.to_param, :recipe => valid_attributes}, valid_session
expect(assigns(:recipe)).to eq(recipe)
end
it "redirects to the recipe" do
recipe = Recipe.create! valid_attributes
put :update, {:id => recipe.to_param, :recipe => valid_attributes}, valid_session
expect(response).to redirect_to(recipe)
end
end
context "with invalid params" do
it "assigns the recipe as @recipe" do
recipe = Recipe.create! valid_attributes
put :update, {:id => recipe.to_param, :recipe => invalid_attributes}, valid_session
expect(assigns(:recipe)).to eq(recipe)
end
it "re-renders the 'edit' template" do
recipe = Recipe.create! valid_attributes
put :update, {:id => recipe.to_param, :recipe => invalid_attributes}, valid_session
expect(response).to render_template("edit")
end
end
end
describe "DELETE #destroy" do
it "destroys the requested recipe" do
recipe = Recipe.create! valid_attributes
expect {
delete :destroy, {:id => recipe.to_param}, valid_session
}.to change(Recipe, :count).by(-1)
end
it "redirects to the recipes list" do
recipe = Recipe.create! valid_attributes
delete :destroy, {:id => recipe.to_param}, valid_session
expect(response).to redirect_to(recipes_url)
end
end
end

View File

@ -7,7 +7,7 @@ FactoryGirl.define do
factory :usda_food do
long_description 'Food'
short_description 'Food'
ndbn '01234'
ndbn { generate :unique_ndbn }
water 1.0
kcal 101
protein 1.2
@ -23,4 +23,22 @@ FactoryGirl.define do
refuse_percent 3
end
factory :salted_butter, parent: :usda_food do
long_description 'Butter, salted'
short_description 'BUTTER,WITH SALT'
water 15.87
kcal 717
protein 0.85
lipid 81.11
ash 2.11
carbohydrates 0.06
fiber 0
sugar 0.06
gram_weight_1 5.0
gram_weight_2 14.2
gram_weight_desc_1 '1 pat, (1\" sq, 1/3\" high)'
gram_weight_desc_2 '1 tbsp'
refuse_percent 0
end
end

View File

@ -1,15 +0,0 @@
require 'rails_helper'
# Specs in this file have access to a helper object that includes
# the IngredientsHelper. For example:
#
# describe IngredientsHelper do
# describe "string concat" do
# it "concats two strings with spaces" do
# expect(helper.concat_strings("this","that")).to eq("this that")
# end
# end
# end
RSpec.describe IngredientsHelper, type: :helper do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -1,15 +0,0 @@
require 'rails_helper'
# Specs in this file have access to a helper object that includes
# the RecipesHelper. For example:
#
# describe RecipesHelper do
# describe "string concat" do
# it "concats two strings with spaces" do
# expect(helper.concat_strings("this","that")).to eq("this that")
# end
# end
# end
RSpec.describe RecipesHelper, type: :helper do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -0,0 +1,78 @@
require 'rails_helper'
RSpec.describe UnitConversion::Conversion do
def get_value_unit(value, units)
UnitConversion.parse(value, units)
end
def get_number(number)
UnitConversion::ParsedNumber.new(number)
end
def get_unit(unit)
UnitConversion::ParsedUnit.new(unit)
end
describe UnitConversion::ScaleConversion do
it 'scales' do
expect(UnitConversion::ScaleConversion.new(get_number(2)).convert(get_value_unit(4, 'cups')).raw_value).to eq 8
expect(UnitConversion::ScaleConversion.new(get_number('1/2')).convert(get_value_unit(4, 'cups')).raw_value).to eq 2
end
it 'leaves the unit alone' do
expect(UnitConversion::ScaleConversion.new(get_number(2)).convert(get_value_unit(4, 'cups')).unit.original_unit).to eq 'cups'
expect(UnitConversion::ScaleConversion.new(get_number(2)).convert(get_value_unit(4, 'cats')).unit.original_unit).to eq 'cats'
end
end
describe UnitConversion::ConvertConversion do
it 'converts standard units' do
expect(UnitConversion::ConvertConversion.new(get_unit('tbsp')).convert(get_value_unit(1, 'cups')).raw_value).to eq 16
expect(UnitConversion::ConvertConversion.new(get_unit('cups')).convert(get_value_unit(8, 'tbsp')).raw_value).to eq 0.5
end
it 'takes on the new unit' do
expect(UnitConversion::ConvertConversion.new(get_unit('tbsp')).convert(get_value_unit(1, 'cups')).unit.original_unit).to eq 'tbsp'
expect(UnitConversion::ConvertConversion.new(get_unit('gallons')).convert(get_value_unit(1, 'cups')).unit.original_unit).to eq 'gallons'
end
it 'converts from mass to volume' do
expect(UnitConversion::ConvertConversion.new(get_unit('oz'), get_value_unit(5, 'oz/c')).convert(get_value_unit(2, 'cups')).raw_value).to be_within(0.01).of(10)
end
it 'converts from volume to mass' do
expect(UnitConversion::ConvertConversion.new(get_unit('c'), get_value_unit(5, 'oz/c')).convert(get_value_unit(10, 'oz')).raw_value).to be_within(0.01).of(2)
end
it 'raises an error when attempting a mass/volume conversion without density' do
expect { UnitConversion::ConvertConversion.new(get_unit('c')).convert(get_value_unit(10, 'oz')) }.to raise_error(UnitConversion::MissingDensityError)
expect { UnitConversion::ConvertConversion.new(get_unit('oz')).convert(get_value_unit(10, 'c')) }.to raise_error(UnitConversion::MissingDensityError)
end
it 'raises an error when density param is not a density' do
expect { UnitConversion::ConvertConversion.new(get_unit('oz'), get_value_unit(2, 'g/oz')) }.to raise_error(UnitConversion::UnknownUnitError)
expect { UnitConversion::ConvertConversion.new(get_unit('oz'), get_value_unit(2, 'cats')) }.to raise_error(UnitConversion::UnknownUnitError)
expect { UnitConversion::ConvertConversion.new(get_unit('oz'), get_value_unit(2, 'g')) }.to raise_error(UnitConversion::UnknownUnitError)
end
it 'preserves the formatter for standard units' do
original = get_value_unit(10, 'tbsp')
converted = UnitConversion::ConvertConversion.new(get_unit('c')).convert(original)
expect(original.formatter).to be converted.formatter
original = get_value_unit("10.0", 'tbsp')
converted = UnitConversion::ConvertConversion.new(get_unit('c')).convert(original)
expect(original.formatter).to be converted.formatter
end
it 'always sets the formatter to a DecimalFormatter for metric units' do
original = get_value_unit(10, 'tbsp')
expect(original.formatter).to be_a UnitConversion::RationalFormatter
converted = UnitConversion::ConvertConversion.new(get_unit('ml')).convert(original)
expect(converted.formatter).to be_a UnitConversion::DecimalFormatter
end
end
end

View File

@ -0,0 +1,82 @@
require 'rails_helper'
RSpec.describe UnitConversion::NumberFormatter do
describe UnitConversion::RationalFormatter do
it 'formats integers' do
expect(UnitConversion::RationalFormatter.new.format(1)).to eq '1'
expect(UnitConversion::RationalFormatter.new.format(-1)).to eq '-1'
expect(UnitConversion::RationalFormatter.new.format(1.0)).to eq '1'
end
it 'formats rationals into rationals' do
expect(UnitConversion::RationalFormatter.new.format(Rational(1,2))).to eq '1/2'
expect(UnitConversion::RationalFormatter.new.format(Rational(5,16))).to eq '5/16'
expect(UnitConversion::RationalFormatter.new.format(Rational(5, 4))).to eq '1 1/4'
end
it 'rounds rationals into better rationals' do
expect(UnitConversion::RationalFormatter.new.format(Rational(3,7))).to eq '7/16'
end
it 'formats decimals into rationals' do
expect(UnitConversion::RationalFormatter.new.format(1.5)).to eq '1 1/2'
expect(UnitConversion::RationalFormatter.new.format(1.125)).to eq '1 1/8'
expect(UnitConversion::RationalFormatter.new.format(24.38)).to eq '24 3/8'
end
end
describe UnitConversion::DecimalFormatter do
it 'formats everything to a decimal' do
expect(UnitConversion::DecimalFormatter.new.format(1)).to eq '1'
expect(UnitConversion::DecimalFormatter.new.format(-1)).to eq '-1'
expect(UnitConversion::DecimalFormatter.new.format(1.0)).to eq '1'
expect(UnitConversion::DecimalFormatter.new.format(Rational(1,4))).to eq '0.25'
expect(UnitConversion::DecimalFormatter.new.format("4.2899999999".to_d)).to eq '4.29'
end
end
describe '.rationalize' do
it 'leaves integers alone' do
expect(UnitConversion::NumberFormatter.new.rationalize(1)).to eq 1
expect(UnitConversion::NumberFormatter.new.rationalize(15)).to eq 15
expect(UnitConversion::NumberFormatter.new.rationalize(-1)).to eq -1
expect(UnitConversion::NumberFormatter.new.rationalize(0)).to eq 0
end
it 'leaves non-fractional numbers alone' do
expect(UnitConversion::NumberFormatter.new.rationalize(1.0)).to eq 1.0
expect(UnitConversion::NumberFormatter.new.rationalize(-1.0)).to eq -1.0
expect(UnitConversion::NumberFormatter.new.rationalize(0.0)).to eq 0.0
expect(UnitConversion::NumberFormatter.new.rationalize(35.0)).to eq 35.0
end
it 'leaves already nice rationals alone' do
expect(UnitConversion::NumberFormatter.new.rationalize(Rational(1,2))).to eq Rational(1,2)
expect(UnitConversion::NumberFormatter.new.rationalize(Rational(5,2))).to eq Rational(5,2)
expect(UnitConversion::NumberFormatter.new.rationalize(Rational(3,16))).to eq Rational(3,16)
expect(UnitConversion::NumberFormatter.new.rationalize(Rational(3,4))).to eq Rational(3,4)
end
it 'converts neat decimals to rationals' do
expect(UnitConversion::NumberFormatter.new.rationalize(1.5)).to eq Rational(3,2)
expect(UnitConversion::NumberFormatter.new.rationalize(0.125)).to eq Rational(1,8)
expect(UnitConversion::NumberFormatter.new.rationalize(5.0625)).to eq Rational(81, 16)
expect(UnitConversion::NumberFormatter.new.rationalize(0.75)).to eq Rational(3,4)
end
it 'rounds weird rationals to nice rationals' do
expect(UnitConversion::NumberFormatter.new.rationalize(Rational(3,7))).to eq Rational(7,16)
expect(UnitConversion::NumberFormatter.new.rationalize(Rational(2,5))).to eq Rational(3,8)
expect(UnitConversion::NumberFormatter.new.rationalize(Rational(2,5))).to eq Rational(3,8)
end
it 'rounds weird decimals to nice rationals' do
expect(UnitConversion::NumberFormatter.new.rationalize(0.24)).to eq Rational(1,4)
expect(UnitConversion::NumberFormatter.new.rationalize(1.24)).to eq Rational(5,4)
expect(UnitConversion::NumberFormatter.new.rationalize(1.13)).to eq Rational(9,8)
end
end
end

View File

@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe UnitConversion::ParsedNumber do
it 'converts integers' do
expect(UnitConversion::ParsedNumber.new('1').value).to eq 1
expect(UnitConversion::ParsedNumber.new('-1').value).to eq -1
expect(UnitConversion::ParsedNumber.new('0').value).to eq 0
expect(UnitConversion::ParsedNumber.new('20').value).to eq 20
expect(UnitConversion::ParsedNumber.new('103').value).to eq 103
end
it 'converts decimal numbers' do
expect(UnitConversion::ParsedNumber.new('1.0').value).to eq BigDecimal.new("1")
expect(UnitConversion::ParsedNumber.new('-1.0').value).to eq BigDecimal.new("-1")
expect(UnitConversion::ParsedNumber.new('54.33').value).to eq BigDecimal.new("54.33")
expect(UnitConversion::ParsedNumber.new('-54.33').value).to eq BigDecimal.new("-54.33")
expect(UnitConversion::ParsedNumber.new('.33').value).to eq BigDecimal.new("0.33")
end
it 'converts simple fractions' do
expect(UnitConversion::ParsedNumber.new('1/2').value).to eq Rational(1, 2)
expect(UnitConversion::ParsedNumber.new('-1/2').value).to eq Rational(-1, 2)
expect(UnitConversion::ParsedNumber.new('3/16').value).to eq Rational(3, 16)
end
it 'converts fractions with whole numbers' do
expect(UnitConversion::ParsedNumber.new('1 1/2').value).to eq Rational(3, 2)
expect(UnitConversion::ParsedNumber.new('-1 1/2').value).to eq Rational(-3, 2)
expect(UnitConversion::ParsedNumber.new('4 3/4').value).to eq Rational(19, 4)
expect(UnitConversion::ParsedNumber.new('18 9/10').value).to eq Rational(189, 10)
end
end

View File

@ -0,0 +1,99 @@
require 'rails_helper'
RSpec.describe UnitConversion::ParsedUnit do
it 'converts simple units' do
data = {
'c' => '[cup_us]',
'cups' => '[cup_us]',
'pints' => '[pt_us]',
'gram' => 'g',
'grams' => 'g',
'Grams' => 'g',
'Tbsp' => '[tbs_us]',
'[tbs_us]' => '[tbs_us]',
'[oz_av]' => '[oz_av]'
}
data.each do |input, output|
expect(UnitConversion::ParsedUnit.new(input).unit).to eq output
end
end
it 'converts mixed units' do
data = {
'oz/c' => '[oz_av]/[cup_us]',
'kilograms/cups' => 'kg/[cup_us]',
'pints/junk' => '[pt_us]/junk',
'gram/[tbs_us]' => 'g/[tbs_us]'
}
data.each do |input, output|
expect(UnitConversion::ParsedUnit.new(input).unit).to eq output
end
end
describe '.to_s' do
it 'renders friendly simple units' do
expect(UnitConversion::ParsedUnit.new('m').to_s).to eq 'meter'
expect(UnitConversion::ParsedUnit.new('tbsp').to_s).to eq 'tablespoon'
expect(UnitConversion::ParsedUnit.new('gallons').to_s).to eq 'gallon'
expect(UnitConversion::ParsedUnit.new('[cup_us]').to_s).to eq 'cup'
expect(UnitConversion::ParsedUnit.new('junk').to_s).to eq 'junk'
end
it 'renders friendly compound units' do
expect(UnitConversion::ParsedUnit.new('m/c').to_s).to eq 'meter/cup'
expect(UnitConversion::ParsedUnit.new('[oz_av]/[cup_us]').to_s).to eq 'ounce/cup'
end
end
describe '.metric?' do
it 'returns true for metric units' do
expect(UnitConversion::ParsedUnit.new('m').metric?).to be_truthy
expect(UnitConversion::ParsedUnit.new('g').metric?).to be_truthy
expect(UnitConversion::ParsedUnit.new('meter').metric?).to be_truthy
expect(UnitConversion::ParsedUnit.new('centiliter').metric?).to be_truthy
end
it 'returns false for standard units' do
expect(UnitConversion::ParsedUnit.new('c').metric?).to be_falsey
expect(UnitConversion::ParsedUnit.new('tbsp').metric?).to be_falsey
expect(UnitConversion::ParsedUnit.new('oz').metric?).to be_falsey
end
it 'returns false for unknown units' do
expect(UnitConversion::ParsedUnit.new('cats').metric?).to be_falsey
expect(UnitConversion::ParsedUnit.new('dogs').metric?).to be_falsey
end
end
describe '.density?' do
it 'returns true for any mass over volume unit' do
data = [
'gram/cup',
'pound/gallon',
'ounce/tablespoon',
'ounce/centimeter3'
]
data.each do |input|
expect(UnitConversion::ParsedUnit.new(input).density?).to be_truthy
end
end
it 'returns false for any non density unit' do
data = [
'cup',
'gram',
'gram/hour',
'centimeter3/ounce'
]
data.each do |input|
expect(UnitConversion::ParsedUnit.new(input).density?).to be_falsey
end
end
end
end

View File

@ -0,0 +1,49 @@
require 'rails_helper'
RSpec.describe UnitConversion::ValueUnit do
def check_vu(vu, value, unit)
expect(vu.value).to be_a UnitConversion::ParsedNumber
expect(vu.raw_value).to eq value
if unit.present?
expect(vu.unit).to be_a UnitConversion::ParsedUnit
expect(vu.unit.to_s).to eq unit
else
expect(vu.unit).to be_nil
expect(vu).to be_a UnitConversion::ValueNoUnit
end
end
describe '.for' do
it 'Converts single strings' do
check_vu(UnitConversion::ValueUnit.for('5 cups'), 5, 'cup')
check_vu(UnitConversion::ValueUnit.for('5'), 5, nil)
check_vu(UnitConversion::ValueUnit.for('1/3 fl oz'), Rational(1,3), 'foz')
end
it 'Converts a pair of strings' do
check_vu(UnitConversion::ValueUnit.for('5', 'cups'), 5, 'cup')
check_vu(UnitConversion::ValueUnit.for('5', ''), 5, nil)
check_vu(UnitConversion::ValueUnit.for('1/3', 'fl oz'), Rational(1,3), 'foz')
end
it 'Converts a bare Numeric' do
check_vu(UnitConversion::ValueUnit.for(5, 'cups'), 5, 'cup')
check_vu(UnitConversion::ValueUnit.for(5, ''), 5, nil)
check_vu(UnitConversion::ValueUnit.for(Rational(1,3), 'fl oz'), Rational(1,3), 'foz')
check_vu(UnitConversion::ValueUnit.for(2.5, 'tsp'), 2.5, 'teaspoon')
end
it 'Converts ParsedNumber and a string' do
check_vu(UnitConversion::ValueUnit.for(UnitConversion::ParsedNumber.new('5'), 'cups'), 5, 'cup')
check_vu(UnitConversion::ValueUnit.for(UnitConversion::ParsedNumber.new('5'), nil), 5, nil)
end
it 'Converts parsed objects' do
check_vu(UnitConversion::ValueUnit.for(UnitConversion::ParsedNumber.new('5'), UnitConversion::ParsedUnit.new('cups')), 5, 'cup')
end
end
end

View File

@ -4,9 +4,9 @@ RSpec.describe UnitConversion do
describe '.auto_unit' do
it 'leaves units alone if reasonable' do
expect(UnitConversion.auto_unit('1/2', 'tbsp')).to eq ['1/2', 'tbsp']
expect(UnitConversion.auto_unit('2', 'cups')).to eq ['2', 'cups']
expect(UnitConversion.auto_unit('1', 'c')).to eq ['1', 'c']
expect(UnitConversion.auto_unit('1/2', 'tbsp')).to eq ['1/2', 'tablespoon']
expect(UnitConversion.auto_unit('2', 'cups')).to eq ['2', 'cup']
expect(UnitConversion.auto_unit('1', 'c')).to eq ['1', 'cup']
end
it 'leaves units alone if unknown' do
@ -32,22 +32,6 @@ RSpec.describe UnitConversion do
end
end
describe '.get_value' do
it 'returns rationals' do
expect(UnitConversion.get_value('1/2')).to eq Rational(1, 2)
expect(UnitConversion.get_value('1 1/2')).to eq Rational(3, 2)
expect(UnitConversion.get_value('-1/2')).to eq Rational(-1, 2)
expect(UnitConversion.get_value('-1 1/2')).to eq Rational(-3, 2)
expect(UnitConversion.get_value('18 9/10')).to eq Rational(189, 10)
end
it 'returns decimals' do
expect(UnitConversion.get_value('-1')).to eq -1.to_d
expect(UnitConversion.get_value('1.0')).to eq 1.to_d
expect(UnitConversion.get_value('5.56')).to eq "5.56".to_d
end
end
describe '.convert' do
it 'scales decimal numbers' do
expect(UnitConversion.convert('1', '2', 'cup', 'cup')).to eq '2'
@ -77,9 +61,7 @@ RSpec.describe UnitConversion do
expect(UnitConversion.convert('1/2', '2', 'slices', 'slices')).to eq '1'
expect(UnitConversion.convert('4', '1/8', nil, nil)).to eq '1/2'
expect(UnitConversion.convert('4', '1/8', 'slices', nil)).to eq '1/2'
expect(UnitConversion.convert('4', '1/8', nil, 'slices')).to eq '1/2'
expect(UnitConversion.convert('4', '1/8', 'slices', '')).to eq '1/2'
expect(UnitConversion.convert('4', '1/8', '', 'slices')).to eq '1/2'
end
it 'converts and scales' do
@ -111,7 +93,7 @@ RSpec.describe UnitConversion do
}
data.each do |input, output|
expect(UnitConversion.parse(input)).to eq output
expect(UnitConversion.parse(input).unitwise).to eq output
end
end
@ -124,16 +106,12 @@ RSpec.describe UnitConversion do
]
data.each do |input|
expect { UnitConversion.parse(input) }.to raise_error(UnitConversion::UnknownUnitError), "'#{input}' didn't raise"
expect { UnitConversion.parse(input).unitwise }.to raise_error(UnitConversion::UnknownUnitError), "'#{input}' didn't raise"
end
end
it 'raises UnparseableUnitError on malformed string' do
data = [
'55',
'55.5',
'-55',
'-55.55',
'5.5/2 cups',
'2/3.0 cups',
'ounce',
@ -148,106 +126,6 @@ RSpec.describe UnitConversion do
end
end
describe '.density?' do
it 'returns true for any mass over volume unit' do
data = [
Unitwise(1, 'gram/cup'),
Unitwise(1, 'pound/gallon'),
Unitwise(1, 'ounce/tablespoon'),
Unitwise(1, 'ounce/centimeter3')
]
data.each do |input|
expect(UnitConversion.density?(input)).to be_truthy
end
end
it 'returns false for any non density unit' do
data = [
Unitwise(1, 'cup'),
Unitwise(1, 'gram'),
Unitwise(1, 'gram/hour'),
Unitwise(1, 'centimeter3/ounce')
]
data.each do |input|
expect(UnitConversion.density?(input)).to be_falsey
end
end
end
describe '.rationalize' do
it 'leaves integers alone' do
expect(UnitConversion.rationalize(1)).to eq 1
expect(UnitConversion.rationalize(15)).to eq 15
expect(UnitConversion.rationalize(-1)).to eq -1
expect(UnitConversion.rationalize(0)).to eq 0
end
it 'leaves non-fractional numbers alone' do
expect(UnitConversion.rationalize(1.0)).to eq 1.0
expect(UnitConversion.rationalize(-1.0)).to eq -1.0
expect(UnitConversion.rationalize(0.0)).to eq 0.0
expect(UnitConversion.rationalize(35.0)).to eq 35.0
end
it 'leaves already nice rationals alone' do
expect(UnitConversion.rationalize(Rational(1,2))).to eq Rational(1,2)
expect(UnitConversion.rationalize(Rational(5,2))).to eq Rational(5,2)
expect(UnitConversion.rationalize(Rational(3,16))).to eq Rational(3,16)
expect(UnitConversion.rationalize(Rational(3,4))).to eq Rational(3,4)
end
it 'converts neat decimals to rationals' do
expect(UnitConversion.rationalize(1.5)).to eq Rational(3,2)
expect(UnitConversion.rationalize(0.125)).to eq Rational(1,8)
expect(UnitConversion.rationalize(5.0625)).to eq Rational(81, 16)
expect(UnitConversion.rationalize(0.75)).to eq Rational(3,4)
end
it 'rounds weird rationals to nice rationals' do
expect(UnitConversion.rationalize(Rational(3,7))).to eq Rational(7,16)
expect(UnitConversion.rationalize(Rational(2,5))).to eq Rational(3,8)
expect(UnitConversion.rationalize(Rational(2,5))).to eq Rational(3,8)
end
it 'rounds weird decimals to nice rationals' do
expect(UnitConversion.rationalize(0.24)).to eq Rational(1,4)
expect(UnitConversion.rationalize(1.24)).to eq Rational(5,4)
expect(UnitConversion.rationalize(1.13)).to eq Rational(9,8)
end
end
describe '.normalize_unit_name' do
it 'converts simple units' do
data = {
'c' => '[cup_us]',
'cups' => '[cup_us]',
'pints' => '[pt_us]',
'gram' => 'g',
'grams' => 'g',
'Grams' => 'g',
'Tbsp' => '[tbs_us]'
}
data.each do |input, output|
expect(UnitConversion.normalize_unit_names(input)).to eq output
end
end
it 'converts mixed units' do
data = {
'oz/c' => '[oz_av]/[cup_us]',
'kilograms/cups' => 'kg/[cup_us]',
'pints/junk' => '[pt_us]/junk'
}
data.each do |input, output|
expect(UnitConversion.normalize_unit_names(input)).to eq output
end
end
end
end

View File

@ -23,4 +23,15 @@ RSpec.describe Ingredient, type: :model do
end
describe 'set_usda_food' do
it 'sets the density' do
i = build(:ingredient)
f = create(:salted_butter)
i.set_usda_food(f)
expect(i.density).not_to be_nil
end
end
end

View File

@ -1,5 +1,5 @@
require 'rails_helper'
RSpec.describe RecipeIngredient, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -33,12 +33,15 @@ RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
DatabaseCleaner.cleaning do
FactoryGirl.lint
unless ENV['FAST'] == 'true'
DatabaseCleaner.clean_with(:truncation)
DatabaseCleaner.cleaning do
FactoryGirl.lint
end
end
end
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do

View File

@ -1,10 +0,0 @@
require 'rails_helper'
RSpec.describe "Ingredients", type: :request do
describe "GET /ingredients" do
it "works! (now write some real specs)" do
get ingredients_path
expect(response).to have_http_status(200)
end
end
end

View File

@ -1,10 +0,0 @@
require 'rails_helper'
RSpec.describe "Recipes", type: :request do
describe "GET /recipes" do
it "works! (now write some real specs)" do
get recipes_path
expect(response).to have_http_status(200)
end
end
end

View File

@ -1,39 +0,0 @@
require "rails_helper"
RSpec.describe IngredientsController, type: :routing do
describe "routing" do
it "routes to #index" do
expect(:get => "/ingredients").to route_to("ingredients#index")
end
it "routes to #new" do
expect(:get => "/ingredients/new").to route_to("ingredients#new")
end
it "routes to #show" do
expect(:get => "/ingredients/1").to route_to("ingredients#show", :id => "1")
end
it "routes to #edit" do
expect(:get => "/ingredients/1/edit").to route_to("ingredients#edit", :id => "1")
end
it "routes to #create" do
expect(:post => "/ingredients").to route_to("ingredients#create")
end
it "routes to #update via PUT" do
expect(:put => "/ingredients/1").to route_to("ingredients#update", :id => "1")
end
it "routes to #update via PATCH" do
expect(:patch => "/ingredients/1").to route_to("ingredients#update", :id => "1")
end
it "routes to #destroy" do
expect(:delete => "/ingredients/1").to route_to("ingredients#destroy", :id => "1")
end
end
end

View File

@ -1,39 +0,0 @@
require "rails_helper"
RSpec.describe RecipesController, type: :routing do
describe "routing" do
it "routes to #index" do
expect(:get => "/recipes").to route_to("recipes#index")
end
it "routes to #new" do
expect(:get => "/recipes/new").to route_to("recipes#new")
end
it "routes to #show" do
expect(:get => "/recipes/1").to route_to("recipes#show", :id => "1")
end
it "routes to #edit" do
expect(:get => "/recipes/1/edit").to route_to("recipes#edit", :id => "1")
end
it "routes to #create" do
expect(:post => "/recipes").to route_to("recipes#create")
end
it "routes to #update via PUT" do
expect(:put => "/recipes/1").to route_to("recipes#update", :id => "1")
end
it "routes to #update via PATCH" do
expect(:patch => "/recipes/1").to route_to("recipes#update", :id => "1")
end
it "routes to #destroy" do
expect(:delete => "/recipes/1").to route_to("recipes#destroy", :id => "1")
end
end
end

View File

@ -1,14 +0,0 @@
require 'rails_helper'
RSpec.describe "ingredients/edit", type: :view do
before(:each) do
@ingredient = assign(:ingredient, Ingredient.create!())
end
it "renders the edit ingredient form" do
render
assert_select "form[action=?][method=?]", ingredient_path(@ingredient), "post" do
end
end
end

View File

@ -1,14 +0,0 @@
require 'rails_helper'
RSpec.describe "ingredients/index", type: :view do
before(:each) do
assign(:ingredients, [
Ingredient.create!(),
Ingredient.create!()
])
end
it "renders a list of ingredients" do
render
end
end

View File

@ -1,14 +0,0 @@
require 'rails_helper'
RSpec.describe "ingredients/new", type: :view do
before(:each) do
assign(:ingredient, Ingredient.new())
end
it "renders new ingredient form" do
render
assert_select "form[action=?][method=?]", ingredients_path, "post" do
end
end
end

View File

@ -1,11 +0,0 @@
require 'rails_helper'
RSpec.describe "ingredients/show", type: :view do
before(:each) do
@ingredient = assign(:ingredient, Ingredient.create!())
end
it "renders attributes in <p>" do
render
end
end

View File

@ -1,14 +0,0 @@
require 'rails_helper'
RSpec.describe "recipes/edit", type: :view do
before(:each) do
@recipe = assign(:recipe, Recipe.create!())
end
it "renders the edit recipe form" do
render
assert_select "form[action=?][method=?]", recipe_path(@recipe), "post" do
end
end
end

View File

@ -1,14 +0,0 @@
require 'rails_helper'
RSpec.describe "recipes/index", type: :view do
before(:each) do
assign(:recipes, [
Recipe.create!(),
Recipe.create!()
])
end
it "renders a list of recipes" do
render
end
end

View File

@ -1,14 +0,0 @@
require 'rails_helper'
RSpec.describe "recipes/new", type: :view do
before(:each) do
assign(:recipe, Recipe.new())
end
it "renders new recipe form" do
render
assert_select "form[action=?][method=?]", recipes_path, "post" do
end
end
end

View File

@ -1,11 +0,0 @@
require 'rails_helper'
RSpec.describe "recipes/show", type: :view do
before(:each) do
@recipe = assign(:recipe, Recipe.create!())
end
it "renders attributes in <p>" do
render
end
end