beerxml 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|