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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +31 -0
- data/CHANGELOG.md +32 -0
- data/README.md +12 -10
- data/Rakefile +18 -0
- data/cache/.keep +0 -0
- data/cache/length.json +2553 -0
- data/cache/volume.json +4163 -0
- data/cache/weight.json +3195 -0
- data/dev.yml +1 -1
- data/gemfiles/{activesupport-4.2.gemfile → activesupport-5.2.gemfile} +1 -1
- data/gemfiles/activesupport-6.0.gemfile +5 -0
- data/gemfiles/activesupport-6.1.gemfile +5 -0
- data/lib/measured.rb +1 -0
- data/lib/measured/arithmetic.rb +1 -0
- data/lib/measured/base.rb +11 -5
- data/lib/measured/cache/json.rb +49 -0
- data/lib/measured/cache/json_writer.rb +9 -0
- data/lib/measured/cache/null.rb +16 -0
- data/lib/measured/conversion_table_builder.rb +42 -12
- data/lib/measured/cycle_detected.rb +11 -0
- data/lib/measured/measurable.rb +18 -21
- data/lib/measured/missing_conversion_path.rb +12 -0
- data/lib/measured/parser.rb +2 -1
- data/lib/measured/unit.rb +16 -26
- data/lib/measured/unit_already_added.rb +11 -0
- data/lib/measured/unit_error.rb +4 -0
- data/lib/measured/unit_system.rb +26 -22
- data/lib/measured/unit_system_builder.rb +11 -4
- data/lib/measured/units/length.rb +3 -0
- data/lib/measured/units/volume.rb +4 -1
- data/lib/measured/units/weight.rb +3 -0
- data/lib/measured/version.rb +2 -1
- data/measured.gemspec +10 -2
- data/test/arithmetic_test.rb +1 -0
- data/test/cache/json_test.rb +43 -0
- data/test/cache/json_writer_test.rb +23 -0
- data/test/cache/null_test.rb +20 -0
- data/test/cache_consistency_test.rb +19 -0
- data/test/conversion_table_builder_test.rb +29 -0
- data/test/measurable_test.rb +5 -5
- data/test/parser_test.rb +1 -0
- data/test/support/always_true_cache.rb +14 -0
- data/test/support/fake_system.rb +1 -0
- data/test/support/subclasses.rb +7 -0
- data/test/test_helper.rb +5 -3
- data/test/unit_error_test.rb +1 -0
- data/test/unit_system_builder_test.rb +49 -3
- data/test/unit_system_test.rb +15 -0
- data/test/unit_test.rb +1 -0
- data/test/units/length_test.rb +5 -4
- data/test/units/volume_test.rb +6 -5
- data/test/units/weight_test.rb +1 -0
- metadata +39 -13
- data/.travis.yml +0 -20
data/dev.yml
CHANGED
data/lib/measured.rb
CHANGED
data/lib/measured/arithmetic.rb
CHANGED
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
|
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
|
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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::
|
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
|
-
@
|
38
|
-
@
|
68
|
+
@direct_conversion_cache ||= {}
|
69
|
+
@direct_conversion_cache[to] ||= {}
|
39
70
|
|
40
|
-
if @
|
41
|
-
@
|
71
|
+
if @direct_conversion_cache[to].key?(from)
|
72
|
+
@direct_conversion_cache[to][from]
|
42
73
|
else
|
43
|
-
@
|
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
|
data/lib/measured/measurable.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
class Measured::Measurable < Numeric
|
2
|
-
DEFAULT_FORMAT_STRING =
|
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
|
-
|
55
|
+
"#{@value_string} #{unit.name}"
|
43
56
|
end
|
44
57
|
|
45
58
|
def humanize
|
46
|
-
|
47
|
-
|
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
|
-
|
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
|
data/lib/measured/parser.rb
CHANGED
@@ -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
|
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
|
-
|
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
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
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
|