measured 2.3.0 → 2.6.0

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.
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