measured 2.4.0 → 2.7.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 (55) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +31 -0
  3. data/CHANGELOG.md +32 -0
  4. data/README.md +12 -10
  5. data/Rakefile +18 -0
  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 +1 -1
  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 +11 -5
  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 +42 -12
  21. data/lib/measured/cycle_detected.rb +11 -0
  22. data/lib/measured/measurable.rb +18 -21
  23. data/lib/measured/missing_conversion_path.rb +12 -0
  24. data/lib/measured/parser.rb +2 -1
  25. data/lib/measured/unit.rb +16 -26
  26. data/lib/measured/unit_already_added.rb +11 -0
  27. data/lib/measured/unit_error.rb +4 -0
  28. data/lib/measured/unit_system.rb +26 -22
  29. data/lib/measured/unit_system_builder.rb +11 -4
  30. data/lib/measured/units/length.rb +3 -0
  31. data/lib/measured/units/volume.rb +4 -1
  32. data/lib/measured/units/weight.rb +3 -0
  33. data/lib/measured/version.rb +2 -1
  34. data/measured.gemspec +10 -2
  35. data/test/arithmetic_test.rb +1 -0
  36. data/test/cache/json_test.rb +43 -0
  37. data/test/cache/json_writer_test.rb +23 -0
  38. data/test/cache/null_test.rb +20 -0
  39. data/test/cache_consistency_test.rb +19 -0
  40. data/test/conversion_table_builder_test.rb +29 -0
  41. data/test/measurable_test.rb +5 -5
  42. data/test/parser_test.rb +1 -0
  43. data/test/support/always_true_cache.rb +14 -0
  44. data/test/support/fake_system.rb +1 -0
  45. data/test/support/subclasses.rb +7 -0
  46. data/test/test_helper.rb +5 -3
  47. data/test/unit_error_test.rb +1 -0
  48. data/test/unit_system_builder_test.rb +49 -3
  49. data/test/unit_system_test.rb +15 -0
  50. data/test/unit_test.rb +1 -0
  51. data/test/units/length_test.rb +5 -4
  52. data/test/units/volume_test.rb +6 -5
  53. data/test/units/weight_test.rb +1 -0
  54. metadata +39 -13
  55. data/.travis.yml +0 -20
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:
@@ -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'
data/lib/measured.rb CHANGED
@@ -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, :+)
data/lib/measured/base.rb CHANGED
@@ -1,11 +1,11 @@
1
+ # frozen_string_literal: true
1
2
  require "forwardable"
2
3
  require "measured/version"
3
4
  require "active_support/all"
4
5
  require "bigdecimal"
6
+ require "json"
5
7
 
6
8
  module Measured
7
- class UnitError < StandardError ; end
8
-
9
9
  class << self
10
10
  def build(&block)
11
11
  builder = UnitSystemBuilder.new
@@ -22,10 +22,9 @@ module Measured
22
22
 
23
23
  def method_missing(method, *args)
24
24
  class_name = "Measured::#{ method }"
25
+ klass = class_name.safe_constantize
25
26
 
26
- if Measurable.subclasses.map(&:to_s).include?(class_name)
27
- klass = class_name.constantize
28
-
27
+ if klass && klass < Measurable
29
28
  Measured.define_singleton_method(method) do |value, unit|
30
29
  klass.new(value, unit)
31
30
  end
@@ -38,10 +37,17 @@ module Measured
38
37
  end
39
38
  end
40
39
 
40
+ require "measured/unit_error"
41
+ require "measured/cycle_detected"
42
+ require "measured/unit_already_added"
43
+ require "measured/missing_conversion_path"
41
44
  require "measured/arithmetic"
42
45
  require "measured/parser"
43
46
  require "measured/unit"
44
47
  require "measured/unit_system"
45
48
  require "measured/unit_system_builder"
46
49
  require "measured/conversion_table_builder"
50
+ require "measured/cache/null"
51
+ require "measured/cache/json_writer"
52
+ require "measured/cache/json"
47
53
  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), nil, freeze: true))
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
@@ -1,14 +1,32 @@
1
+ # frozen_string_literal: true
1
2
  class Measured::ConversionTableBuilder
2
3
  attr_reader :units
3
4
 
4
- def initialize(units)
5
+ def initialize(units, cache: nil)
5
6
  @units = units
7
+ cache ||= { class: Measured::Cache::Null }
8
+ @cache = cache[:class].new(*cache[:args])
6
9
  end
7
10
 
8
11
  def to_h
9
- table = {}
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
10
25
 
11
- units.map{|u| u.name}.each do |to_unit|
26
+ def generate_table
27
+ validate_no_cycles
28
+
29
+ units.map(&:name).each_with_object({}) do |to_unit, table|
12
30
  to_table = {to_unit => Rational(1, 1)}
13
31
 
14
32
  table.each do |from_unit, from_table|
@@ -19,28 +37,41 @@ class Measured::ConversionTableBuilder
19
37
 
20
38
  table[to_unit] = to_table
21
39
  end
40
+ end
22
41
 
23
- table
42
+ def validate_no_cycles
43
+ graph = units.select { |unit| unit.conversion_unit.present? }.group_by { |unit| unit.name }
44
+ validate_acyclic_graph(graph, from: graph.keys[0])
24
45
  end
25
46
 
26
- private
47
+ # This uses a depth-first search algorithm: https://en.wikipedia.org/wiki/Depth-first_search
48
+ def validate_acyclic_graph(graph, from:, visited: [])
49
+ graph[from]&.each do |edge|
50
+ adjacent_node = edge.conversion_unit
51
+ if visited.include?(adjacent_node)
52
+ raise Measured::CycleDetected.new(edge)
53
+ else
54
+ validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node])
55
+ end
56
+ end
57
+ end
27
58
 
28
59
  def find_conversion(to:, from:)
29
60
  conversion = find_direct_conversion_cached(to: to, from: from) || find_tree_traversal_conversion(to: to, from: from)
30
61
 
31
- raise Measured::UnitError, "Cannot find conversion path from #{ from } to #{ to }." unless conversion
62
+ raise Measured::MissingConversionPath.new(from, to) unless conversion
32
63
 
33
64
  conversion
34
65
  end
35
66
 
36
67
  def find_direct_conversion_cached(to:, from:)
37
- @cache ||= {}
38
- @cache[to] ||= {}
68
+ @direct_conversion_cache ||= {}
69
+ @direct_conversion_cache[to] ||= {}
39
70
 
40
- if @cache[to].key?(from)
41
- @cache[to][from]
71
+ if @direct_conversion_cache[to].key?(from)
72
+ @direct_conversion_cache[to][from]
42
73
  else
43
- @cache[to][from] = find_direct_conversion(to: to, from: from)
74
+ @direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from)
44
75
  end
45
76
  end
46
77
 
@@ -76,5 +107,4 @@ class Measured::ConversionTableBuilder
76
107
 
77
108
  nil
78
109
  end
79
-
80
110
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ module Measured
3
+ class CycleDetected < UnitError
4
+ attr_reader :unit
5
+
6
+ def initialize(unit)
7
+ super("The following conversion introduces cycles in the unit system: #{unit}. Remove the conversion or fix the cycle.")
8
+ @unit = unit
9
+ end
10
+ end
11
+ 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
 
@@ -32,6 +33,6 @@ module Measured::Parser
32
33
 
33
34
  raise Measured::UnitError, "Cannot parse measurement from '#{string}'" unless result
34
35
 
35
- [result.captures[0].to_r, result.captures[1]]
36
+ [result.captures[0].to_r, -result.captures[1]]
36
37
  end
37
38
  end
data/lib/measured/unit.rb CHANGED
@@ -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 if 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