beerxml 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +19 -0
- data/Rakefile +2 -1
- data/beerxml.gemspec +3 -2
- data/lib/beerxml.rb +9 -0
- data/lib/beerxml/fermentable.rb +34 -0
- data/lib/beerxml/hop.rb +16 -2
- data/lib/beerxml/model.rb +52 -8
- data/lib/beerxml/properties.rb +93 -0
- data/lib/beerxml/recipe.rb +49 -0
- data/lib/beerxml/unit.rb +168 -0
- data/lib/beerxml/version.rb +1 -1
- data/spec/hops_spec.rb +27 -0
- data/spec/parsing_spec.rb +124 -5
- data/spec/recipes_spec.rb +15 -0
- data/spec/spec_helper.rb +10 -4
- data/spec/units_spec.rb +123 -0
- metadata +34 -10
data/LICENSE
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (C) 2011 by Brian Palmer
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
11
|
+
all copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
|
+
THE SOFTWARE.
|
data/Rakefile
CHANGED
@@ -10,10 +10,11 @@ end
|
|
10
10
|
RSpec::Core::RakeTask.new(:rcov) do |t|
|
11
11
|
t.rspec_opts = "-c -f d"
|
12
12
|
t.rcov = true
|
13
|
+
t.rcov_opts = ["--exclude", "spec,gems/,rubygems/"]
|
13
14
|
end
|
14
15
|
|
15
16
|
require 'yard'
|
16
17
|
YARD::Rake::YardocTask.new(:doc) do |t|
|
17
18
|
version = Beerxml::VERSION
|
18
|
-
t.options = ["--title", "beerxml #{version}"]
|
19
|
+
t.options = ["--title", "beerxml #{version}", "--files", "LICENSE"]
|
19
20
|
end
|
data/beerxml.gemspec
CHANGED
@@ -22,8 +22,9 @@ Gem::Specification.new do |s|
|
|
22
22
|
s.add_dependency "dm-validations", "~> 1.0"
|
23
23
|
s.add_dependency "dm-types", "~> 1.0"
|
24
24
|
|
25
|
-
s.add_development_dependency "rspec", "~> 2.
|
26
|
-
s.add_development_dependency "
|
25
|
+
s.add_development_dependency "rspec", "~> 2.5"
|
26
|
+
s.add_development_dependency "autotest-standalone"
|
27
|
+
s.add_development_dependency "autotest-growl"
|
27
28
|
s.add_development_dependency "rcov"
|
28
29
|
s.add_development_dependency "yard"
|
29
30
|
end
|
data/lib/beerxml.rb
CHANGED
@@ -6,6 +6,15 @@ require 'nokogiri'
|
|
6
6
|
module Beerxml
|
7
7
|
# This'll have to go eventually, but for now it's nice
|
8
8
|
DataMapper.setup(:default, "abstract::")
|
9
|
+
|
10
|
+
def self.parse(string_or_io)
|
11
|
+
Beerxml::Model.from_xml(Nokogiri::XML(string_or_io).root)
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.round(float, to = 0)
|
15
|
+
exp = 10 ** to
|
16
|
+
(float * exp).round.to_f / exp
|
17
|
+
end
|
9
18
|
end
|
10
19
|
|
11
20
|
require 'beerxml/model'
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class Beerxml::Fermentable < Beerxml::Model
|
2
|
+
property :name, String, :required => true
|
3
|
+
property :type, Enum['Grain', 'Sugar', 'Extract', 'Dry Extract', 'Adjunct'], :required => true
|
4
|
+
property :amount, Weight, :required => true
|
5
|
+
property :yield, Float, :required => true
|
6
|
+
property :color, Float, :required => true
|
7
|
+
|
8
|
+
property :add_after_boil, Boolean, :default => false
|
9
|
+
property :origin, String, :length => 512
|
10
|
+
property :supplier, String, :length => 512
|
11
|
+
property :notes, String, :length => 65535
|
12
|
+
property :coarse_fine_diff, Float
|
13
|
+
property :moisture, Float
|
14
|
+
property :diastatic_power, Float
|
15
|
+
property :protein, Float
|
16
|
+
property :max_in_batch, Float
|
17
|
+
property :recommend_mash, Boolean
|
18
|
+
property :ibu_gal_per_lb, Float
|
19
|
+
|
20
|
+
# these are not used in the xml
|
21
|
+
property :id, Serial
|
22
|
+
belongs_to :recipe, :required => false
|
23
|
+
|
24
|
+
def ppg
|
25
|
+
# potential is (yield * 0.001 + 1)
|
26
|
+
(self.yield * 0.01) * 46.214
|
27
|
+
end
|
28
|
+
|
29
|
+
def total_ppg(efficiency = nil)
|
30
|
+
total = ppg * amount.in_pounds.to_f
|
31
|
+
total *= (efficiency * 0.01) unless efficiency.nil? || type != 'Grain'
|
32
|
+
total
|
33
|
+
end
|
34
|
+
end
|
data/lib/beerxml/hop.rb
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
class Beerxml::Hop < Beerxml::Model
|
2
2
|
property :name, String, :required => true
|
3
3
|
property :alpha, Float, :required => true
|
4
|
-
property :amount,
|
4
|
+
property :amount, Weight, :required => true
|
5
5
|
property :use, Enum['Boil', 'Dry Hop', 'Mash', 'First Wort', 'Aroma'], :required => true
|
6
|
-
property :time,
|
6
|
+
property :time, Time, :required => true
|
7
|
+
|
7
8
|
property :notes, String, :length => 65535
|
8
9
|
property :type, Enum[nil, 'Bittering', 'Aroma', 'Both']
|
9
10
|
property :form, Enum[nil, 'Pellet', 'Plug', 'Leaf']
|
@@ -19,4 +20,17 @@ class Beerxml::Hop < Beerxml::Model
|
|
19
20
|
# these are not used in the xml
|
20
21
|
property :id, Serial
|
21
22
|
belongs_to :recipe, :required => false
|
23
|
+
|
24
|
+
def tinseth(post_boil_og, batch_size) # batch size is gallons or Unit
|
25
|
+
bigness = 1.65 * 0.000125**(post_boil_og - 1)
|
26
|
+
boil_factor = (1 - 2.72 ** (-0.04 * time.in_minutes.to_f)) / 4.15
|
27
|
+
utilization = bigness * boil_factor
|
28
|
+
ibus = utilization * (aau * 0.01 * 7490) / U(batch_size, 'gallons').to_f
|
29
|
+
Beerxml.round(ibus, 1)
|
30
|
+
end
|
31
|
+
alias_method :ibus, :tinseth
|
32
|
+
|
33
|
+
def aau
|
34
|
+
alpha * amount.in('ounces').to_f
|
35
|
+
end
|
22
36
|
end
|
data/lib/beerxml/model.rb
CHANGED
@@ -1,10 +1,43 @@
|
|
1
1
|
class Beerxml::Model
|
2
2
|
include DataMapper::Resource
|
3
|
+
require 'beerxml/properties'
|
4
|
+
include Beerxml::Properties
|
5
|
+
|
6
|
+
##########################
|
7
|
+
|
8
|
+
@models = {}
|
9
|
+
@plurals = {}
|
3
10
|
|
4
11
|
def self.beerxml_name
|
5
12
|
name.split('::').last.upcase
|
6
13
|
end
|
7
14
|
|
15
|
+
def self.beerxml_plural_name
|
16
|
+
"#{beerxml_name}S"
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.inherited(klass)
|
20
|
+
super
|
21
|
+
@models[klass.beerxml_name] = klass
|
22
|
+
@plurals["#{klass.beerxml_name}S"] = klass
|
23
|
+
end
|
24
|
+
|
25
|
+
##########################
|
26
|
+
|
27
|
+
# Takes a Nokogiri node, figures out what sort of class it is, and parses it.
|
28
|
+
# Raises if it's not a Beerxml::Model class (or collection).
|
29
|
+
def self.from_xml(node)
|
30
|
+
if model = @models[node.name]
|
31
|
+
model.new.from_xml(node)
|
32
|
+
elsif model = @plurals[node.name]
|
33
|
+
collection_from_xml(node)
|
34
|
+
else
|
35
|
+
raise "Unknown BeerXML node type: #{node.name}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Parse a Nokogiri node, reading in the properties defined on this model.
|
40
|
+
# Assumes the node is of the correct type.
|
8
41
|
def from_xml(node)
|
9
42
|
properties.each do |property|
|
10
43
|
read_xml_field(node, property.name.to_s)
|
@@ -14,17 +47,26 @@ class Beerxml::Model
|
|
14
47
|
child_model = rel.child_model
|
15
48
|
next unless child_model.ancestors.include?(Beerxml::Model)
|
16
49
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
self.send(rel.name) << child_model.new.from_xml(child_node)
|
50
|
+
# look for the plural element in the children of this node
|
51
|
+
# e.g., for Hop, see if there's any HOPS element.
|
52
|
+
(node>child_model.beerxml_plural_name).each do |child_wrapper_node|
|
53
|
+
collection = Beerxml::Model.collection_from_xml(child_wrapper_node)
|
54
|
+
self.send(rel.name).concat(collection)
|
23
55
|
end
|
24
56
|
end
|
25
57
|
self
|
26
58
|
end
|
27
59
|
|
60
|
+
# takes a collection root xml node, like <HOPS>, and returns an array of the
|
61
|
+
# child model objects inside the node.
|
62
|
+
def self.collection_from_xml(collection_node)
|
63
|
+
model = @plurals[collection_node.name]
|
64
|
+
raise("Unknown model: #{collection_node.name}") unless model
|
65
|
+
(collection_node>model.beerxml_name).map do |child_node|
|
66
|
+
model.new.from_xml(child_node)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
28
70
|
def read_xml_field(node, attr_name, node_name = attr_name.upcase)
|
29
71
|
child = (node>node_name).first
|
30
72
|
return unless child
|
@@ -33,6 +75,8 @@ class Beerxml::Model
|
|
33
75
|
self.send("#{attr_name}=", child)
|
34
76
|
end
|
35
77
|
|
78
|
+
##########################
|
79
|
+
|
36
80
|
def to_beerxml(parent = Nokogiri::XML::Document.new)
|
37
81
|
# TODO: do we raise an error if not valid?
|
38
82
|
node = Nokogiri::XML::Node.new(self.class.beerxml_name, parent)
|
@@ -55,5 +99,5 @@ class Beerxml::Model
|
|
55
99
|
end
|
56
100
|
end
|
57
101
|
|
58
|
-
|
59
|
-
require
|
102
|
+
%w(hop recipe fermentable).
|
103
|
+
each { |f| require "beerxml/#{f}" }
|
@@ -0,0 +1,93 @@
|
|
1
|
+
require 'beerxml/unit'
|
2
|
+
|
3
|
+
# Custom DM property types for the various BeerXML data types.
|
4
|
+
module Beerxml::Properties
|
5
|
+
|
6
|
+
class Property < DataMapper::Property::Float
|
7
|
+
def custom?
|
8
|
+
true
|
9
|
+
end
|
10
|
+
|
11
|
+
def load(value)
|
12
|
+
return if value.nil?
|
13
|
+
if value.is_a?(Beerxml::Unit)
|
14
|
+
raise(ArgumentError, 'Weight required') unless value.type == unit_type
|
15
|
+
value
|
16
|
+
else
|
17
|
+
U(value, base_unit)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def dump(value)
|
22
|
+
return if value.nil?
|
23
|
+
value.in(base_unit).to_f
|
24
|
+
end
|
25
|
+
|
26
|
+
def typecast_to_primitive(value)
|
27
|
+
load(value)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Represents a weight. Default scale is kilograms, since that's what
|
32
|
+
# BeerXML uses. But can handle conversion/display of other units.
|
33
|
+
class Weight < Property
|
34
|
+
def unit_type
|
35
|
+
'weight'
|
36
|
+
end
|
37
|
+
def base_unit
|
38
|
+
'kilograms'
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# A volume, in liters.
|
43
|
+
class Volume < Property
|
44
|
+
def unit_type
|
45
|
+
'volume'
|
46
|
+
end
|
47
|
+
def base_unit
|
48
|
+
'liters'
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# A temperature, in deg C.
|
53
|
+
class Temperature < Property
|
54
|
+
def unit_type
|
55
|
+
'temperature'
|
56
|
+
end
|
57
|
+
def base_unit
|
58
|
+
'C'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# A time, in minutes.
|
63
|
+
class Time < Property
|
64
|
+
def unit_type
|
65
|
+
'time'
|
66
|
+
end
|
67
|
+
def base_unit
|
68
|
+
'minutes'
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Time, but in days.
|
73
|
+
class TimeInDays < Time
|
74
|
+
def base_unit
|
75
|
+
'days'
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Appendix A: http://www.beerxml.com/beerxml.htm
|
80
|
+
# "all fields that are defined for display only may also use a unit
|
81
|
+
# tag after them". For example “3.45 gal” is an acceptable value.
|
82
|
+
class DisplayWeight < Weight
|
83
|
+
end
|
84
|
+
|
85
|
+
class DisplayVolume < Volume
|
86
|
+
end
|
87
|
+
|
88
|
+
class DisplayTemperature < Temperature
|
89
|
+
end
|
90
|
+
|
91
|
+
class DisplayTime < Time
|
92
|
+
end
|
93
|
+
end
|
data/lib/beerxml/recipe.rb
CHANGED
@@ -2,9 +2,58 @@ class Beerxml::Recipe < Beerxml::Model
|
|
2
2
|
include DataMapper::Resource
|
3
3
|
|
4
4
|
property :name, String, :required => true
|
5
|
+
property :type, Enum['Extract', 'Partial Mash', 'All Grain'], :required => true
|
6
|
+
# has 1, :style, :required => true
|
7
|
+
property :brewer, String, :required => true
|
8
|
+
property :batch_size, Volume, :required => true
|
9
|
+
property :boil_size, Volume, :required => true
|
10
|
+
property :boil_time, Time, :required => true
|
11
|
+
property :efficiency, Float
|
12
|
+
validates_presence_of :efficiency, :if => proc { |t| t.type != 'Extract' }
|
5
13
|
|
6
14
|
has n, :hops
|
15
|
+
has n, :fermentables
|
16
|
+
# has n, :miscs
|
17
|
+
# has n, :yeasts
|
18
|
+
# has n, :waters
|
19
|
+
|
20
|
+
property :asst_brewer, String
|
21
|
+
property :notes, String, :length => 65535
|
22
|
+
property :taste_notes, String, :length => 65535
|
23
|
+
property :taste_rating, Float
|
24
|
+
property :og, Float
|
25
|
+
property :fg, Float
|
26
|
+
property :fermentation_stages, Integer
|
27
|
+
property :primary_age, TimeInDays
|
28
|
+
property :primary_temp, Temperature
|
29
|
+
property :secondary_age, TimeInDays
|
30
|
+
property :secondary_temp, Temperature
|
31
|
+
property :tertiary_age, TimeInDays
|
32
|
+
property :tertiary_temp, Temperature
|
33
|
+
property :age, TimeInDays
|
34
|
+
property :age_temp, Temperature
|
35
|
+
property :date, String
|
36
|
+
property :carbonation, Float
|
37
|
+
property :forced_carbonation, Boolean
|
38
|
+
property :priming_sugar_name, String
|
39
|
+
property :carbonation_temp, Temperature
|
40
|
+
property :priming_sugar_equiv, Float
|
41
|
+
property :keg_priming_factor, Float
|
42
|
+
# has 1, :equipment
|
43
|
+
# has 1, :mash
|
44
|
+
# validates_presence_of :mash, :if => proc { |t| t.type != 'Extract' }
|
7
45
|
|
8
46
|
# these are not used in the xml
|
9
47
|
property :id, Serial
|
48
|
+
|
49
|
+
def tinseth
|
50
|
+
Beerxml.round(hops.select { |h| h.use == 'Boil' }.inject(0) { |v, h| v + h.tinseth(og, batch_size) }, 1)
|
51
|
+
end
|
52
|
+
alias_method :ibus, :tinseth
|
53
|
+
|
54
|
+
def calculate_og
|
55
|
+
total_ppg = fermentables.inject(0) { |v, f| v + f.total_ppg(efficiency) }
|
56
|
+
og = 1 + ((total_ppg / batch_size.in_gallons.to_f) * 0.001)
|
57
|
+
Beerxml.round(og, 3)
|
58
|
+
end
|
10
59
|
end
|
data/lib/beerxml/unit.rb
ADDED
@@ -0,0 +1,168 @@
|
|
1
|
+
module Beerxml
|
2
|
+
# Unit system with conversions between units of the same type. Very much
|
3
|
+
# a work in progress.
|
4
|
+
class Unit
|
5
|
+
attr_reader :type, :unit
|
6
|
+
|
7
|
+
Units = {}
|
8
|
+
BaseUnits = {}
|
9
|
+
UnitToType = {}
|
10
|
+
|
11
|
+
def in(unit, *args)
|
12
|
+
self.clone.in!(unit, *args)
|
13
|
+
end
|
14
|
+
alias_method :to, :in
|
15
|
+
|
16
|
+
def in!(new_unit, *args)
|
17
|
+
new_unit = new_unit.to_s
|
18
|
+
new_unit_type = UnitToType[new_unit]
|
19
|
+
raise(ArgumentError, "Unknown unit: #{new_unit}") if new_unit_type.nil?
|
20
|
+
if new_unit_type != type
|
21
|
+
raise(ArgumentError, "New unit: #{new_unit} not compatible with current unit: #{unit}")
|
22
|
+
end
|
23
|
+
@unit = new_unit
|
24
|
+
self
|
25
|
+
end
|
26
|
+
alias_method :to!, :in!
|
27
|
+
alias_method :unit=, :in!
|
28
|
+
|
29
|
+
def base_unit
|
30
|
+
BaseUnits[type]
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_f
|
34
|
+
@value * Units[type][unit]
|
35
|
+
end
|
36
|
+
|
37
|
+
def initialize(amount, unit = nil)
|
38
|
+
if amount.is_a?(Unit)
|
39
|
+
if unit
|
40
|
+
amount = amount.to(unit).to_f
|
41
|
+
else
|
42
|
+
amount, unit = amount.to_f, amount.unit
|
43
|
+
end
|
44
|
+
elsif !unit
|
45
|
+
amount, unit = amount.to_s.split(/\s+/, 2)
|
46
|
+
end
|
47
|
+
unit = unit.to_s
|
48
|
+
|
49
|
+
@type = UnitToType[unit]
|
50
|
+
self.unit = unit
|
51
|
+
@value = Float(amount) / Units[type][unit]
|
52
|
+
end
|
53
|
+
|
54
|
+
def is?(unit)
|
55
|
+
self.unit == unit ||
|
56
|
+
(UnitToType[self.unit] == UnitToType[unit] &&
|
57
|
+
Units[type][self.unit] == Units[type][unit])
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.add_type(type, base_unit_name, *aliases)
|
61
|
+
Units[type] = {}
|
62
|
+
Units[type][base_unit_name] = 1.0
|
63
|
+
UnitToType[base_unit_name] = type
|
64
|
+
BaseUnits[type] = base_unit_name
|
65
|
+
aliases.each { |a| add_alias(type, base_unit_name, a) }
|
66
|
+
end
|
67
|
+
def self.add(type, factor, name, *aliases)
|
68
|
+
Units[type][name] = factor.to_f
|
69
|
+
aliases.each { |a| add_alias(type, name, a) }
|
70
|
+
UnitToType[name] = type
|
71
|
+
end
|
72
|
+
def self.add_alias(type, name, new_alias)
|
73
|
+
base = Units[type][name]
|
74
|
+
raise(ArgumentError, "Unknown base: #{name}") unless base
|
75
|
+
Units[type][new_alias] = base
|
76
|
+
UnitToType[new_alias] = type
|
77
|
+
end
|
78
|
+
|
79
|
+
def ==(rhs)
|
80
|
+
# TODO: hard-coding this precision stinks...
|
81
|
+
if rhs.is_a?(self.class)
|
82
|
+
rhs = rhs.in(base_unit)
|
83
|
+
@value.between?(rhs.to_f - 0.01, rhs.to_f + 0.01)
|
84
|
+
elsif rhs.is_a?(Numeric)
|
85
|
+
to_f.between?(rhs.to_f - 0.01, rhs.to_f + 0.01)
|
86
|
+
else
|
87
|
+
raise ArgumentError, "#{rhs.inspect} is not a #{self.class.name}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def method_missing(meth, *a, &b)
|
92
|
+
if meth.to_s =~ %r{\A(to|in)_([^!]+)(!)?\z}
|
93
|
+
send($3 ? :in! : :in, $2)
|
94
|
+
else
|
95
|
+
super
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# todo: remove this, to_beerxml is using it (and incorrectly at that)
|
100
|
+
def to_s
|
101
|
+
to_f.to_s
|
102
|
+
end
|
103
|
+
|
104
|
+
def inspect
|
105
|
+
"[#{to_f} #{unit}]"
|
106
|
+
end
|
107
|
+
|
108
|
+
def *(rhs)
|
109
|
+
if rhs.is_a?(Numeric)
|
110
|
+
ret = Unit.new(self.to_f * rhs, self.unit)
|
111
|
+
elsif rhs.is_a?(Unit) && rhs.type == type
|
112
|
+
ret = Unit.new(self.to_f * rhs.to(self.unit).to_f, self.unit)
|
113
|
+
else
|
114
|
+
super
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Assumes earth gravity ;)
|
119
|
+
module Weight
|
120
|
+
def self.included(k)
|
121
|
+
k.add_type('weight', 'kg', 'kilogram', 'kilograms')
|
122
|
+
k.add('weight', 1000, 'g', 'gram', 'grams')
|
123
|
+
k.add('weight', 2.204622, 'lb', 'pound', 'pounds', 'lbs')
|
124
|
+
k.add('weight', 35.273961, 'oz', 'ounce', 'ounces')
|
125
|
+
end
|
126
|
+
end
|
127
|
+
include Weight
|
128
|
+
|
129
|
+
module Volume
|
130
|
+
def self.included(k)
|
131
|
+
k.add_type('volume', 'liters', 'l', 'liter')
|
132
|
+
k.add('volume', 0.26417, 'gallons', 'gallon')
|
133
|
+
end
|
134
|
+
end
|
135
|
+
include Volume
|
136
|
+
|
137
|
+
module Time
|
138
|
+
def self.included(k)
|
139
|
+
k.add_type('time', 'day', 'days')
|
140
|
+
k.add('time', 24, 'hour', 'hours')
|
141
|
+
k.add('time', 1440, 'minute', 'minutes')
|
142
|
+
end
|
143
|
+
end
|
144
|
+
include Time
|
145
|
+
|
146
|
+
module Temperature
|
147
|
+
def self.included(k)
|
148
|
+
k.add_type('temperature', 'c', 'C', 'celsius')
|
149
|
+
# k.add('temperature', proc {}, 'f', 'F', 'fahrenheit')
|
150
|
+
end
|
151
|
+
end
|
152
|
+
include Temperature
|
153
|
+
|
154
|
+
def self.apply_to_numeric!
|
155
|
+
Beerxml::Unit::UnitToType.each do |unit, v|
|
156
|
+
Numeric.class_eval(<<-METHOD, __FILE__, __LINE__+1)
|
157
|
+
def #{unit}
|
158
|
+
Beerxml::Unit.new(self, #{unit.inspect})
|
159
|
+
end
|
160
|
+
METHOD
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def U(*a)
|
167
|
+
Beerxml::Unit.new(*a)
|
168
|
+
end
|
data/lib/beerxml/version.rb
CHANGED
data/spec/hops_spec.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
require "#{File.dirname(__FILE__)}/spec_helper"
|
2
|
+
|
3
|
+
require 'beerxml/unit'
|
4
|
+
|
5
|
+
describe Beerxml::Hop do
|
6
|
+
describe "IBUs" do
|
7
|
+
it "should calculate IBUs using the Tinseth formula" do
|
8
|
+
hop = Beerxml::Hop.new(:name => "Cascade",
|
9
|
+
:alpha => 6,
|
10
|
+
:amount => U('0.5 oz'),
|
11
|
+
:use => 'Boil',
|
12
|
+
:time => 20)
|
13
|
+
hop.should be_valid
|
14
|
+
hop.tinseth(1.065, 5.5).should == 5
|
15
|
+
# tinseth by default
|
16
|
+
hop.ibus(1.065, 5.5).should == 5
|
17
|
+
|
18
|
+
hop2 = Beerxml::Hop.new(:name => 'Goldings',
|
19
|
+
:alpha => 5,
|
20
|
+
:amount => U(4.5, 'oz'),
|
21
|
+
:use => 'Boil',
|
22
|
+
:time => 60)
|
23
|
+
hop2.should be_valid
|
24
|
+
hop2.ibus(1.081, 11).should == 26.7
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/spec/parsing_spec.rb
CHANGED
@@ -1,10 +1,6 @@
|
|
1
|
-
require
|
1
|
+
require "#{File.dirname(__FILE__)}/spec_helper"
|
2
2
|
|
3
3
|
describe "beerxml.com examples" do
|
4
|
-
def read_xml(example)
|
5
|
-
Nokogiri::XML(File.read("examples/beerxml.com/#{example}.xml"))
|
6
|
-
end
|
7
|
-
|
8
4
|
it "should parse the first recipe and its hops" do
|
9
5
|
recipe = Beerxml::Recipe.new.from_xml(read_xml("recipes").root.children[1])
|
10
6
|
|
@@ -29,4 +25,127 @@ describe "beerxml.com examples" do
|
|
29
25
|
hop2.should be_valid
|
30
26
|
hop2.attributes.should == hop.attributes
|
31
27
|
end
|
28
|
+
|
29
|
+
def assert_hops(hops)
|
30
|
+
hops.size.should == 5
|
31
|
+
hops.each { |h| h.should(be_a(Beerxml::Hop)) && h.should(be_valid) }
|
32
|
+
hops.map(&:name).should == ["Cascade", "Galena", "Goldings, B.C.", "Northern Brewer", "Tettnang"]
|
33
|
+
end
|
34
|
+
|
35
|
+
describe "auto-discovery of root node types" do
|
36
|
+
it "should discover singular nodes" do
|
37
|
+
hop = Beerxml::Model.from_xml(read_xml("hops").root.children[1])
|
38
|
+
hop.should be_a(Beerxml::Hop)
|
39
|
+
hop.should be_valid
|
40
|
+
hop.name.should == "Cascade"
|
41
|
+
end
|
42
|
+
|
43
|
+
it "should discover plural nodes" do
|
44
|
+
hops = Beerxml::Model.from_xml(read_xml("hops").root)
|
45
|
+
assert_hops(hops)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
it "should parse given a string or IO" do
|
50
|
+
hops = Beerxml.parse(File.read(filename("hops")))
|
51
|
+
assert_hops(hops)
|
52
|
+
hops = Beerxml.parse(File.open(filename("hops"), "rb"))
|
53
|
+
assert_hops(hops)
|
54
|
+
end
|
55
|
+
|
56
|
+
describe "sanity checks" do
|
57
|
+
def check_parse(klass, file, node, attrs)
|
58
|
+
model = klass.new.from_xml(read_xml(file).root.children[node])
|
59
|
+
model.should be_valid
|
60
|
+
model.attributes.reject { |k,v| v.nil? }.should == attrs
|
61
|
+
model
|
62
|
+
end
|
63
|
+
it "should parse fermentables" do
|
64
|
+
check_parse(Beerxml::Fermentable, "grain", 5, {
|
65
|
+
:name => 'Munich Malt',
|
66
|
+
:type => 'Grain',
|
67
|
+
:amount => 0.0,
|
68
|
+
:yield => 80.0,
|
69
|
+
:color => 9.0,
|
70
|
+
:add_after_boil => false,
|
71
|
+
:origin => 'Germany',
|
72
|
+
:notes => "Malty-sweet flavor characteristic and adds a reddish amber color to the beer.
|
73
|
+
Does not contribute signficantly to body or head retention.
|
74
|
+
Use for: Bock, Porter, Marzen, Oktoberfest beers",
|
75
|
+
:coarse_fine_diff => 1.3,
|
76
|
+
:moisture => 5.0,
|
77
|
+
:diastatic_power => 72.0,
|
78
|
+
:protein => 11.5,
|
79
|
+
:max_in_batch => 80.0,
|
80
|
+
:recommend_mash => true,
|
81
|
+
:ibu_gal_per_lb => 0.0,
|
82
|
+
:supplier => '',
|
83
|
+
})
|
84
|
+
end
|
85
|
+
it "should parse hops" do
|
86
|
+
check_parse(Beerxml::Hop, "hops", 7, {
|
87
|
+
:name => 'Northern Brewer',
|
88
|
+
:origin => 'Germany',
|
89
|
+
:alpha => 8.5,
|
90
|
+
:amount => 0.0,
|
91
|
+
:use => 'Boil',
|
92
|
+
:time => 0.0,
|
93
|
+
:notes => %{Also called Hallertauer Northern Brewers
|
94
|
+
Use for: Bittering and finishing both ales and lagers of all kinds
|
95
|
+
Aroma: Fine, dry, clean bittering hop. Unique flavor.
|
96
|
+
Substitute: Hallertauer Mittelfrueh, Hallertauer
|
97
|
+
Examples: Anchor Steam, Old Peculiar, },
|
98
|
+
:type => 'Both',
|
99
|
+
:form => 'Pellet',
|
100
|
+
:beta => 4.0,
|
101
|
+
:hsi => 35.0,
|
102
|
+
})
|
103
|
+
end
|
104
|
+
it "should parse recipes" do
|
105
|
+
recipe = check_parse(Beerxml::Recipe, "recipes", 3, {
|
106
|
+
:name => 'Dry Stout',
|
107
|
+
:type => 'All Grain',
|
108
|
+
:brewer => 'Brad Smith',
|
109
|
+
:asst_brewer => '',
|
110
|
+
:batch_size => 18.92716800,
|
111
|
+
:boil_size => 20.81988500,
|
112
|
+
:boil_time => 60,
|
113
|
+
:efficiency => 72.0,
|
114
|
+
:notes => %{A very simple all grain beer that produces a great Guiness-style taste every time. So light in body that I have even made black and tans with it using a full body pale ale in the bottom of the glass.},
|
115
|
+
:taste_notes => %{One of my favorite stock beers - I always keep a keg on hand. Rich flavored dry Irish Stout that is very simple to make. Perfect every time!},
|
116
|
+
:taste_rating => 44.0,
|
117
|
+
:og => 1.038,
|
118
|
+
:fg => 1.012,
|
119
|
+
:carbonation => 2.3,
|
120
|
+
:fermentation_stages => 2,
|
121
|
+
:primary_age => 4,
|
122
|
+
:primary_temp => 20.000,
|
123
|
+
:secondary_age => 7,
|
124
|
+
:secondary_temp => 20.000,
|
125
|
+
:tertiary_age => 0,
|
126
|
+
:age => 7,
|
127
|
+
:age_temp => 5.000,
|
128
|
+
:date => '4/1/2003',
|
129
|
+
})
|
130
|
+
recipe.hops.map(&:attributes).should == [
|
131
|
+
:name => 'Goldings, East Kent',
|
132
|
+
:origin => 'United Kingdom',
|
133
|
+
:alpha => 5.0,
|
134
|
+
:amount => 0.0637860,
|
135
|
+
:use => 'Boil',
|
136
|
+
:time => 60,
|
137
|
+
:notes => %{Used For: General purpose hops for bittering/finishing all British Ales
|
138
|
+
Aroma: Floral, aromatic, earthy, slightly sweet spicy flavor
|
139
|
+
Substitutes: Fuggles, BC Goldings
|
140
|
+
Examples: Bass Pale Ale, Fullers ESB, Samual Smith's Pale Ale
|
141
|
+
},
|
142
|
+
:type => 'Aroma',
|
143
|
+
:form => 'Pellet',
|
144
|
+
:beta => 3.5,
|
145
|
+
:hsi => 35.0,
|
146
|
+
:recipe_id => nil,
|
147
|
+
]
|
148
|
+
recipe.fermentables.map(&:name).should == ['Pale Malt (2 Row) UK', 'Barley, Flaked', 'Black Barley (Stout)']
|
149
|
+
end
|
150
|
+
end
|
32
151
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require "#{File.dirname(__FILE__)}/spec_helper"
|
2
|
+
|
3
|
+
describe Beerxml::Recipe do
|
4
|
+
it "should calculate IBUs using the tinseth method" do
|
5
|
+
recipe = Beerxml::Recipe.new.from_xml(read_xml("recipes").root.children[1])
|
6
|
+
recipe.should be_valid
|
7
|
+
recipe.ibus.round.should == 32
|
8
|
+
end
|
9
|
+
|
10
|
+
it "should calculate the OG for an all grain batch" do
|
11
|
+
recipe = Beerxml::Recipe.new.from_xml(read_xml("recipes").root.children[1])
|
12
|
+
recipe.should be_valid
|
13
|
+
recipe.calculate_og.should == 1.056
|
14
|
+
end
|
15
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,9 +1,15 @@
|
|
1
|
-
require 'bundler'
|
2
|
-
Bundler.require(:default, :development)
|
3
|
-
|
4
1
|
require 'beerxml'
|
5
2
|
|
6
|
-
require '
|
3
|
+
require 'rspec'
|
7
4
|
|
8
5
|
RSpec.configure do |c|
|
6
|
+
def filename(example)
|
7
|
+
"examples/beerxml.com/#{example}.xml"
|
8
|
+
end
|
9
|
+
def read_file(example)
|
10
|
+
File.read(filename(example))
|
11
|
+
end
|
12
|
+
def read_xml(example)
|
13
|
+
Nokogiri::XML(read_file(example))
|
14
|
+
end
|
9
15
|
end
|
data/spec/units_spec.rb
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
require "#{File.dirname(__FILE__)}/spec_helper"
|
2
|
+
|
3
|
+
class Float
|
4
|
+
def near?(rhs, prec = 0.01)
|
5
|
+
self.between?(rhs - prec, rhs + prec)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
describe Beerxml::Unit do
|
10
|
+
it "should allow constructing with base units" do
|
11
|
+
w = U('153.27 kg')
|
12
|
+
w.unit.should == 'kg'
|
13
|
+
w.is?('kg').should be_true
|
14
|
+
w.is?('kilograms').should be_true
|
15
|
+
w.is?('g').should be_false
|
16
|
+
w.to_f.should == 153.27
|
17
|
+
w.should == 153.27
|
18
|
+
w.should == U(153.27, 'kg')
|
19
|
+
w.should == U('153.27', 'kg')
|
20
|
+
end
|
21
|
+
|
22
|
+
it "should reject unknown units" do
|
23
|
+
proc { U('153') }.should raise_error(ArgumentError)
|
24
|
+
proc { U('153 glorbs') }.should raise_error(ArgumentError)
|
25
|
+
proc { U(153, 'glorbs') }.should raise_error(ArgumentError)
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should require an amount" do
|
29
|
+
proc { U('kg') }.should raise_error(ArgumentError)
|
30
|
+
proc { U('hai kg') }.should raise_error(ArgumentError)
|
31
|
+
proc { U('hai', 'kg') }.should raise_error(ArgumentError)
|
32
|
+
end
|
33
|
+
|
34
|
+
it "should allow symbols for units" do
|
35
|
+
U(2, :kg).should == U(2, 'kilograms')
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should allow constructing with different units" do
|
39
|
+
w = U(53.97, 'oz')
|
40
|
+
w.in('kg').should == U(1.53, 'kg')
|
41
|
+
w.should == U('53.97 oz')
|
42
|
+
end
|
43
|
+
|
44
|
+
it "should allow constructing from another compatible Unit object" do
|
45
|
+
w = U(53.97, 'oz')
|
46
|
+
w2 = U(w) # defaults to oz, same units as copying object
|
47
|
+
w3 = U(w, 'kg')
|
48
|
+
w.should == w2
|
49
|
+
w.should == w3
|
50
|
+
w2.should == w3
|
51
|
+
w2.should == 53.97
|
52
|
+
w3.should == 1.53
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "equality" do
|
56
|
+
it "should compare between units" do
|
57
|
+
w = U(3.5, 'kg')
|
58
|
+
lbs = w.in('pounds')
|
59
|
+
lbs.to_f.should be_near(7.7162)
|
60
|
+
w.should == lbs
|
61
|
+
w.should == 3.5
|
62
|
+
w.in('lbs').should == 7.716
|
63
|
+
end
|
64
|
+
|
65
|
+
it "should not compare with strings" do
|
66
|
+
w = U(1, 'kg')
|
67
|
+
proc { w == "5" }.should raise_error(ArgumentError)
|
68
|
+
end
|
69
|
+
|
70
|
+
it "should not allow equality between incompatible units" do
|
71
|
+
w = U(53.97, 'oz')
|
72
|
+
v = U(2, 'gallons')
|
73
|
+
proc { w == v }.should raise_error(ArgumentError)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should infer in_* methods for defined units" do
|
78
|
+
w = U(1, 'kg')
|
79
|
+
w.in_grams.should == 1000
|
80
|
+
w.in_kilograms.should == 1
|
81
|
+
end
|
82
|
+
|
83
|
+
describe "changing units in place" do
|
84
|
+
it "should allow changing units with attr writer" do
|
85
|
+
w = U(1, 'kg')
|
86
|
+
w.to_f.should == 1
|
87
|
+
w.unit = 'g'
|
88
|
+
w.to_f.should == 1000
|
89
|
+
end
|
90
|
+
|
91
|
+
it "should not allow changing to an unknown unit" do
|
92
|
+
w = U(1, 'kg')
|
93
|
+
proc { w.in!('blorg') }.should raise_error(ArgumentError)
|
94
|
+
end
|
95
|
+
|
96
|
+
it "should not allow changing to an incompatible unit" do
|
97
|
+
w = U(1, 'kg')
|
98
|
+
proc { w.in!('minutes') }.should raise_error(ArgumentError)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
describe "changing units with in()" do
|
103
|
+
it "should handle basic conversions" do
|
104
|
+
w = U(1.53, 'kg')
|
105
|
+
w.in('grams').should == 1530
|
106
|
+
w.in('lbs').to_f.should be_near(3.37)
|
107
|
+
w.in('lbs').in('ounces').to_f.should be_near(53.97)
|
108
|
+
w.in('pounds').in('grams').in('kg').to_f.should be_near(1.53)
|
109
|
+
end
|
110
|
+
|
111
|
+
it "should not allow changing to an unknown unit" do
|
112
|
+
w = U(1, 'kg')
|
113
|
+
proc { w.in('blorg') }.should raise_error(ArgumentError)
|
114
|
+
proc { w.in_blorg }.should raise_error(ArgumentError)
|
115
|
+
end
|
116
|
+
|
117
|
+
it "should not allow changing to an incompatible unit" do
|
118
|
+
w = U(1, 'kg')
|
119
|
+
proc { w.in('minutes') }.should raise_error(ArgumentError)
|
120
|
+
proc { w.in_hours }.should raise_error(ArgumentError)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: beerxml
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
hash:
|
4
|
+
hash: 27
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 0
|
9
|
-
-
|
10
|
-
version: 0.0.
|
9
|
+
- 2
|
10
|
+
version: 0.0.2
|
11
11
|
platform: ruby
|
12
12
|
authors:
|
13
13
|
- Brian Palmer
|
@@ -15,7 +15,7 @@ autorequire:
|
|
15
15
|
bindir: bin
|
16
16
|
cert_chain: []
|
17
17
|
|
18
|
-
date: 2011-03-
|
18
|
+
date: 2011-03-07 00:00:00 -07:00
|
19
19
|
default_executable:
|
20
20
|
dependencies:
|
21
21
|
- !ruby/object:Gem::Dependency
|
@@ -86,15 +86,15 @@ dependencies:
|
|
86
86
|
requirements:
|
87
87
|
- - ~>
|
88
88
|
- !ruby/object:Gem::Version
|
89
|
-
hash:
|
89
|
+
hash: 9
|
90
90
|
segments:
|
91
91
|
- 2
|
92
|
-
-
|
93
|
-
version: "2.
|
92
|
+
- 5
|
93
|
+
version: "2.5"
|
94
94
|
type: :development
|
95
95
|
version_requirements: *id005
|
96
96
|
- !ruby/object:Gem::Dependency
|
97
|
-
name:
|
97
|
+
name: autotest-standalone
|
98
98
|
prerelease: false
|
99
99
|
requirement: &id006 !ruby/object:Gem::Requirement
|
100
100
|
none: false
|
@@ -108,7 +108,7 @@ dependencies:
|
|
108
108
|
type: :development
|
109
109
|
version_requirements: *id006
|
110
110
|
- !ruby/object:Gem::Dependency
|
111
|
-
name:
|
111
|
+
name: autotest-growl
|
112
112
|
prerelease: false
|
113
113
|
requirement: &id007 !ruby/object:Gem::Requirement
|
114
114
|
none: false
|
@@ -122,7 +122,7 @@ dependencies:
|
|
122
122
|
type: :development
|
123
123
|
version_requirements: *id007
|
124
124
|
- !ruby/object:Gem::Dependency
|
125
|
-
name:
|
125
|
+
name: rcov
|
126
126
|
prerelease: false
|
127
127
|
requirement: &id008 !ruby/object:Gem::Requirement
|
128
128
|
none: false
|
@@ -135,6 +135,20 @@ dependencies:
|
|
135
135
|
version: "0"
|
136
136
|
type: :development
|
137
137
|
version_requirements: *id008
|
138
|
+
- !ruby/object:Gem::Dependency
|
139
|
+
name: yard
|
140
|
+
prerelease: false
|
141
|
+
requirement: &id009 !ruby/object:Gem::Requirement
|
142
|
+
none: false
|
143
|
+
requirements:
|
144
|
+
- - ">="
|
145
|
+
- !ruby/object:Gem::Version
|
146
|
+
hash: 3
|
147
|
+
segments:
|
148
|
+
- 0
|
149
|
+
version: "0"
|
150
|
+
type: :development
|
151
|
+
version_requirements: *id009
|
138
152
|
description: |-
|
139
153
|
Library for parsing and generating beerxml (http://www.beerxml.com/).
|
140
154
|
More than that, this library also contains various methods for doing calculations on beer ingredients and recipes, among other helpers.
|
@@ -149,6 +163,7 @@ extra_rdoc_files: []
|
|
149
163
|
files:
|
150
164
|
- .gitignore
|
151
165
|
- Gemfile
|
166
|
+
- LICENSE
|
152
167
|
- README.md
|
153
168
|
- Rakefile
|
154
169
|
- beerxml.gemspec
|
@@ -163,12 +178,18 @@ files:
|
|
163
178
|
- examples/beerxml.com/water.xml
|
164
179
|
- examples/beerxml.com/yeast.xml
|
165
180
|
- lib/beerxml.rb
|
181
|
+
- lib/beerxml/fermentable.rb
|
166
182
|
- lib/beerxml/hop.rb
|
167
183
|
- lib/beerxml/model.rb
|
184
|
+
- lib/beerxml/properties.rb
|
168
185
|
- lib/beerxml/recipe.rb
|
186
|
+
- lib/beerxml/unit.rb
|
169
187
|
- lib/beerxml/version.rb
|
188
|
+
- spec/hops_spec.rb
|
170
189
|
- spec/parsing_spec.rb
|
190
|
+
- spec/recipes_spec.rb
|
171
191
|
- spec/spec_helper.rb
|
192
|
+
- spec/units_spec.rb
|
172
193
|
has_rdoc: true
|
173
194
|
homepage: https://github.com/codekitchen/beerxml
|
174
195
|
licenses: []
|
@@ -204,5 +225,8 @@ signing_key:
|
|
204
225
|
specification_version: 3
|
205
226
|
summary: Library for parsing and generating beerxml (http://www.beerxml.com/)
|
206
227
|
test_files:
|
228
|
+
- spec/hops_spec.rb
|
207
229
|
- spec/parsing_spec.rb
|
230
|
+
- spec/recipes_spec.rb
|
208
231
|
- spec/spec_helper.rb
|
232
|
+
- spec/units_spec.rb
|