measured 2.3.0 → 2.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +31 -0
  3. data/CHANGELOG.md +25 -0
  4. data/README.md +12 -10
  5. data/Rakefile +22 -1
  6. data/cache/.keep +0 -0
  7. data/cache/length.json +2553 -0
  8. data/cache/volume.json +4163 -0
  9. data/cache/weight.json +3195 -0
  10. data/dev.yml +2 -2
  11. data/gemfiles/{activesupport-4.2.gemfile → activesupport-5.2.gemfile} +1 -1
  12. data/gemfiles/activesupport-6.0.gemfile +5 -0
  13. data/gemfiles/activesupport-6.1.gemfile +5 -0
  14. data/lib/measured.rb +1 -0
  15. data/lib/measured/arithmetic.rb +1 -0
  16. data/lib/measured/base.rb +12 -6
  17. data/lib/measured/cache/json.rb +49 -0
  18. data/lib/measured/cache/json_writer.rb +9 -0
  19. data/lib/measured/cache/null.rb +16 -0
  20. data/lib/measured/conversion_table_builder.rb +91 -0
  21. data/lib/measured/measurable.rb +18 -21
  22. data/lib/measured/missing_conversion_path.rb +12 -0
  23. data/lib/measured/parser.rb +1 -0
  24. data/lib/measured/unit.rb +16 -26
  25. data/lib/measured/unit_already_added.rb +11 -0
  26. data/lib/measured/unit_error.rb +4 -0
  27. data/lib/measured/unit_system.rb +26 -21
  28. data/lib/measured/unit_system_builder.rb +11 -4
  29. data/lib/measured/units/length.rb +3 -0
  30. data/lib/measured/units/volume.rb +4 -1
  31. data/lib/measured/units/weight.rb +3 -0
  32. data/lib/measured/version.rb +2 -1
  33. data/measured.gemspec +13 -5
  34. data/test/arithmetic_test.rb +1 -0
  35. data/test/cache/json_test.rb +43 -0
  36. data/test/cache/json_writer_test.rb +23 -0
  37. data/test/cache/null_test.rb +20 -0
  38. data/test/cache_consistency_test.rb +19 -0
  39. data/test/conversion_table_builder_test.rb +137 -0
  40. data/test/measurable_test.rb +5 -5
  41. data/test/parser_test.rb +1 -0
  42. data/test/support/always_true_cache.rb +14 -0
  43. data/test/support/fake_system.rb +1 -0
  44. data/test/support/subclasses.rb +7 -0
  45. data/test/test_helper.rb +5 -3
  46. data/test/unit_error_test.rb +1 -0
  47. data/test/unit_system_builder_test.rb +49 -3
  48. data/test/unit_system_test.rb +15 -0
  49. data/test/unit_test.rb +5 -0
  50. data/test/units/length_test.rb +5 -4
  51. data/test/units/volume_test.rb +6 -5
  52. data/test/units/weight_test.rb +1 -0
  53. metadata +45 -18
  54. data/.travis.yml +0 -17
  55. data/lib/measured/conversion_table.rb +0 -65
  56. data/test/conversion_table_test.rb +0 -98
data/dev.yml CHANGED
@@ -2,7 +2,7 @@ name: measured
2
2
 
3
3
  up:
4
4
  - ruby:
5
- version: 2.3.1
5
+ version: 2.6.3
6
6
  - bundler
7
7
 
8
8
  commands:
@@ -14,5 +14,5 @@ commands:
14
14
  if [[ $# -eq 0 ]]; then
15
15
  bundle exec rake test
16
16
  else
17
- bundle exec ruby -Itest "$@"
17
+ bundle exec rake test "$@"
18
18
  fi
@@ -2,4 +2,4 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec path: '..'
4
4
 
5
- gem 'activesupport', '~> 4.2'
5
+ gem 'activesupport', '~> 5.2'
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec path: '..'
4
+
5
+ gem 'activesupport', '~> 6.0'
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec path: '..'
4
+
5
+ gem 'activesupport', '~> 6.1'
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "measured/base"
2
3
 
3
4
  require "measured/units/length"
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Measured::Arithmetic
2
3
  def +(other)
3
4
  arithmetic_operation(other, :+)
@@ -1,10 +1,11 @@
1
+ # frozen_string_literal: true
2
+ require "forwardable"
1
3
  require "measured/version"
2
4
  require "active_support/all"
3
5
  require "bigdecimal"
6
+ require "json"
4
7
 
5
8
  module Measured
6
- class UnitError < StandardError ; end
7
-
8
9
  class << self
9
10
  def build(&block)
10
11
  builder = UnitSystemBuilder.new
@@ -21,10 +22,9 @@ module Measured
21
22
 
22
23
  def method_missing(method, *args)
23
24
  class_name = "Measured::#{ method }"
25
+ klass = class_name.safe_constantize
24
26
 
25
- if Measurable.subclasses.map(&:to_s).include?(class_name)
26
- klass = class_name.constantize
27
-
27
+ if klass && klass < Measurable
28
28
  Measured.define_singleton_method(method) do |value, unit|
29
29
  klass.new(value, unit)
30
30
  end
@@ -37,10 +37,16 @@ module Measured
37
37
  end
38
38
  end
39
39
 
40
+ require "measured/unit_error"
41
+ require "measured/unit_already_added"
42
+ require "measured/missing_conversion_path"
40
43
  require "measured/arithmetic"
41
44
  require "measured/parser"
42
45
  require "measured/unit"
43
46
  require "measured/unit_system"
44
47
  require "measured/unit_system_builder"
45
- require "measured/conversion_table"
48
+ require "measured/conversion_table_builder"
49
+ require "measured/cache/null"
50
+ require "measured/cache/json_writer"
51
+ require "measured/cache/json"
46
52
  require "measured/measurable"
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+ module Measured::Cache
3
+ class Json
4
+ attr_reader :filename, :path
5
+
6
+ def initialize(filename)
7
+ @filename = filename
8
+ @path = Pathname.new(File.join(File.dirname(__FILE__), "../../../cache", @filename)).cleanpath
9
+ end
10
+
11
+ def exist?
12
+ File.exist?(@path)
13
+ end
14
+
15
+ def read
16
+ return unless exist?
17
+ decode(JSON.load(File.read(@path)))
18
+ end
19
+
20
+ def write(table)
21
+ raise ArgumentError, "Cannot overwrite file cache at runtime."
22
+ end
23
+
24
+ private
25
+
26
+ # JSON dump and load of Rational objects exists, but it changes the behaviour of JSON globally if required.
27
+ # Instead, the same marshalling technique is rewritten here to prevent changing this behaviour project wide.
28
+ # https://github.com/ruby/ruby/blob/trunk/ext/json/lib/json/add/rational.rb
29
+ def encode(table)
30
+ table.each_with_object(table.dup) do |(k1, v1), accu|
31
+ v1.each do |k2, v2|
32
+ if v2.is_a?(Rational)
33
+ accu[k1][k2] = { "numerator" => v2.numerator, "denominator" => v2.denominator }
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ def decode(table)
40
+ table.each_with_object(table.dup) do |(k1, v1), accu|
41
+ v1.each do |k2, v2|
42
+ if v2.is_a?(Hash)
43
+ accu[k1][k2] = Rational(v2["numerator"], v2["denominator"])
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ module Measured::Cache::JsonWriter
3
+ def write(table)
4
+ File.open(@path, "w") do |f|
5
+ f.write("// Do not modify this file directly. Regenerate it with 'rake cache:write'.\n")
6
+ f.write(JSON.pretty_generate(encode(table)))
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ module Measured::Cache
3
+ class Null
4
+ def exist?
5
+ false
6
+ end
7
+
8
+ def read
9
+ nil
10
+ end
11
+
12
+ def write(table)
13
+ nil
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+ class Measured::ConversionTableBuilder
3
+ attr_reader :units
4
+
5
+ def initialize(units, cache: nil)
6
+ @units = units
7
+ cache ||= { class: Measured::Cache::Null }
8
+ @cache = cache[:class].new(*cache[:args])
9
+ end
10
+
11
+ def to_h
12
+ return @cache.read if cached?
13
+ generate_table
14
+ end
15
+
16
+ def update_cache
17
+ @cache.write(generate_table)
18
+ end
19
+
20
+ def cached?
21
+ @cache.exist?
22
+ end
23
+
24
+ private
25
+
26
+ def generate_table
27
+ units.map(&:name).each_with_object({}) do |to_unit, table|
28
+ to_table = {to_unit => Rational(1, 1)}
29
+
30
+ table.each do |from_unit, from_table|
31
+ conversion = find_conversion(to: from_unit, from: to_unit)
32
+ to_table[from_unit] = conversion
33
+ from_table[to_unit] = 1 / conversion
34
+ end
35
+
36
+ table[to_unit] = to_table
37
+ end
38
+ end
39
+
40
+ def find_conversion(to:, from:)
41
+ conversion = find_direct_conversion_cached(to: to, from: from) || find_tree_traversal_conversion(to: to, from: from)
42
+
43
+ raise Measured::MissingConversionPath.new(from, to) unless conversion
44
+
45
+ conversion
46
+ end
47
+
48
+ def find_direct_conversion_cached(to:, from:)
49
+ @direct_conversion_cache ||= {}
50
+ @direct_conversion_cache[to] ||= {}
51
+
52
+ if @direct_conversion_cache[to].key?(from)
53
+ @direct_conversion_cache[to][from]
54
+ else
55
+ @direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from)
56
+ end
57
+ end
58
+
59
+ def find_direct_conversion(to:, from:)
60
+ units.each do |unit|
61
+ return unit.conversion_amount if unit.name == from && unit.conversion_unit == to
62
+ return unit.inverse_conversion_amount if unit.name == to && unit.conversion_unit == from
63
+ end
64
+
65
+ nil
66
+ end
67
+
68
+ def find_tree_traversal_conversion(to:, from:)
69
+ traverse(from: from, to: to, units_remaining: units.map(&:name), amount: 1)
70
+ end
71
+
72
+ def traverse(from:, to:, units_remaining:, amount:)
73
+ units_remaining = units_remaining - [from]
74
+
75
+ units_remaining.each do |name|
76
+ conversion = find_direct_conversion_cached(from: from, to: name)
77
+
78
+ if conversion
79
+ new_amount = amount * conversion
80
+ if name == to
81
+ return new_amount
82
+ else
83
+ result = traverse(from: name, to: to, units_remaining: units_remaining, amount: new_amount)
84
+ return result if result
85
+ end
86
+ end
87
+ end
88
+
89
+ nil
90
+ end
91
+ end
@@ -1,5 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  class Measured::Measurable < Numeric
2
- DEFAULT_FORMAT_STRING = '%.2<value>f %<unit>s'
3
+ DEFAULT_FORMAT_STRING = "%.2<value>f %<unit>s"
3
4
 
4
5
  include Measured::Arithmetic
5
6
 
@@ -19,6 +20,18 @@ class Measured::Measurable < Numeric
19
20
  else
20
21
  BigDecimal(value)
21
22
  end
23
+
24
+ @value_string = begin
25
+ str = case value
26
+ when Rational
27
+ value.denominator == 1 ? value.numerator.to_s : value.to_f.to_s
28
+ when BigDecimal
29
+ value.to_s("F")
30
+ else
31
+ value.to_f.to_s
32
+ end
33
+ str.gsub(/\.0*\Z/, "")
34
+ end.freeze
22
35
  end
23
36
 
24
37
  def convert_to(new_unit)
@@ -39,18 +52,16 @@ class Measured::Measurable < Numeric
39
52
  end
40
53
 
41
54
  def to_s
42
- @to_s ||= "#{value_string} #{unit.name}"
55
+ "#{@value_string} #{unit.name}"
43
56
  end
44
57
 
45
58
  def humanize
46
- @humanize ||= begin
47
- unit_string = value == 1 ? unit.name : ActiveSupport::Inflector.pluralize(unit.name)
48
- "#{value_string} #{unit_string}"
49
- end
59
+ unit_string = value == 1 ? unit.name : ActiveSupport::Inflector.pluralize(unit.name)
60
+ "#{@value_string} #{unit_string}"
50
61
  end
51
62
 
52
63
  def inspect
53
- @inspect ||= "#<#{self.class}: #{value_string} #{unit.inspect}>"
64
+ "#<#{self.class}: #{@value_string} #{unit.inspect}>"
54
65
  end
55
66
 
56
67
  def <=>(other)
@@ -86,18 +97,4 @@ class Measured::Measurable < Numeric
86
97
  def unit_from_unit_or_name!(value)
87
98
  value.is_a?(Measured::Unit) ? value : self.class.unit_system.unit_for!(value)
88
99
  end
89
-
90
- def value_string
91
- @value_string ||= begin
92
- str = case value
93
- when Rational
94
- value.denominator == 1 ? value.numerator.to_s : value.to_f.to_s
95
- when BigDecimal
96
- value.to_s("F")
97
- else
98
- value.to_f.to_s
99
- end
100
- str.gsub(/\.0*\Z/, "")
101
- end
102
- end
103
100
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module Measured
3
+ class MissingConversionPath < UnitError
4
+ attr_reader :from, :to
5
+
6
+ def initialize(from, to)
7
+ super("Cannot find conversion path from #{from} to #{to}.")
8
+ @from = from
9
+ @to = to
10
+ end
11
+ end
12
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Measured::Parser
2
3
  extend self
3
4
 
@@ -1,43 +1,41 @@
1
+ # frozen_string_literal: true
1
2
  class Measured::Unit
2
3
  include Comparable
3
4
 
4
- attr_reader :name, :aliases, :conversion_amount, :conversion_unit, :unit_system
5
+ attr_reader :name, :names, :aliases, :conversion_amount, :conversion_unit, :unit_system, :inverse_conversion_amount
5
6
 
6
7
  def initialize(name, aliases: [], value: nil, unit_system: nil)
7
8
  @name = name.to_s.freeze
8
9
  @aliases = aliases.map(&:to_s).map(&:freeze).freeze
10
+ @names = ([@name] + @aliases).sort!.freeze
9
11
  @conversion_amount, @conversion_unit = parse_value(value) if value
12
+ @inverse_conversion_amount = (1 / conversion_amount if conversion_amount)
13
+ @conversion_string = ("#{conversion_amount} #{conversion_unit}" if conversion_amount || conversion_unit)
10
14
  @unit_system = unit_system
11
15
  end
12
16
 
13
- def with_unit_system(unit_system)
17
+ def with(name: nil, unit_system: nil, aliases: nil, value: nil)
14
18
  self.class.new(
15
- name,
16
- aliases: aliases,
17
- value: conversion_string,
18
- unit_system: unit_system
19
+ name || self.name,
20
+ aliases: aliases || self.aliases,
21
+ value: value || @conversion_string,
22
+ unit_system: unit_system || self.unit_system
19
23
  )
20
24
  end
21
25
 
22
- def names
23
- @names ||= ([name] + aliases).sort!.freeze
24
- end
25
-
26
26
  def to_s
27
- @to_s ||= if conversion_string
28
- "#{name} (#{conversion_string})".freeze
27
+ if @conversion_string
28
+ "#{name} (#{@conversion_string})".freeze
29
29
  else
30
30
  name
31
31
  end
32
32
  end
33
33
 
34
34
  def inspect
35
- @inspect ||= begin
36
- pieces = [name]
37
- pieces << "(#{aliases.join(", ")})" if aliases.any?
38
- pieces << conversion_string if conversion_string
39
- "#<#{self.class.name}: #{pieces.join(" ")}>".freeze
40
- end
35
+ pieces = [name]
36
+ pieces << "(#{aliases.join(", ")})" if aliases.any?
37
+ pieces << @conversion_string if @conversion_string
38
+ "#<#{self.class.name}: #{pieces.join(" ")}>".freeze
41
39
  end
42
40
 
43
41
  def <=>(other)
@@ -53,16 +51,8 @@ class Measured::Unit
53
51
  end
54
52
  end
55
53
 
56
- def inverse_conversion_amount
57
- @inverse_conversion_amount ||= 1 / conversion_amount
58
- end
59
-
60
54
  private
61
55
 
62
- def conversion_string
63
- @conversion_string ||= ("#{conversion_amount} #{conversion_unit}" if conversion_amount || conversion_unit)
64
- end
65
-
66
56
  def parse_value(tokens)
67
57
  case tokens
68
58
  when String
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ module Measured
3
+ class UnitAlreadyAdded < UnitError
4
+ attr_reader :unit_name
5
+
6
+ def initialize(unit_name)
7
+ super("Unit #{unit_name} has already been added.")
8
+ @unit_name = unit_name
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Measured
3
+ class UnitError < StandardError ; end
4
+ end
@@ -1,16 +1,22 @@
1
+ # frozen_string_literal: true
1
2
  class Measured::UnitSystem
2
- attr_reader :units
3
-
4
- def initialize(units)
5
- @units = units.map { |unit| unit.with_unit_system(self) }
6
- end
7
-
8
- def unit_names_with_aliases
9
- @unit_names_with_aliases ||= @units.flat_map(&:names).sort
10
- end
11
-
12
- def unit_names
13
- @unit_names ||= @units.map(&:name).sort
3
+ attr_reader :units, :unit_names, :unit_names_with_aliases
4
+
5
+ def initialize(units, cache: nil)
6
+ @units = units.map { |unit| unit.with(unit_system: self) }
7
+ @units = @units.map do |unit|
8
+ next unit unless unit.conversion_unit
9
+ conversion_unit = @units.find { |u| u.names.include?(unit.conversion_unit) }
10
+ next unit unless conversion_unit
11
+ unit.with(value: [unit.conversion_amount, conversion_unit.name])
12
+ end
13
+ @unit_names = @units.map(&:name).sort.freeze
14
+ @unit_names_with_aliases = @units.flat_map(&:names).sort.freeze
15
+ @unit_name_to_unit = @units.each_with_object({}) do |unit, hash|
16
+ unit.names.each { |name| hash[name.to_s] = unit }
17
+ end
18
+ @conversion_table_builder = Measured::ConversionTableBuilder.new(@units, cache: cache)
19
+ @conversion_table = @conversion_table_builder.to_h.freeze
14
20
  end
15
21
 
16
22
  def unit_or_alias?(name)
@@ -40,16 +46,15 @@ class Measured::UnitSystem
40
46
  value.to_r * conversion
41
47
  end
42
48
 
43
- protected
44
-
45
- def conversion_table
46
- @conversion_table ||= Measured::ConversionTable.build(@units)
49
+ def update_cache
50
+ @conversion_table_builder.update_cache
47
51
  end
48
52
 
49
- def unit_name_to_unit
50
- @unit_name_to_unit ||= @units.inject({}) do |hash, unit|
51
- unit.names.each { |name| hash[name.to_s] = unit }
52
- hash
53
- end
53
+ def cached?
54
+ @conversion_table_builder.cached?
54
55
  end
56
+
57
+ protected
58
+
59
+ attr_reader :unit_name_to_unit, :conversion_table
55
60
  end