measured 2.4.0 → 2.7.0

Sign up to get free protection for your applications and to get access to all the features.
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