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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +31 -0
- data/CHANGELOG.md +25 -0
- data/README.md +12 -10
- data/Rakefile +22 -1
- 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 +2 -2
- 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 +12 -6
- 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 +91 -0
- data/lib/measured/measurable.rb +18 -21
- data/lib/measured/missing_conversion_path.rb +12 -0
- data/lib/measured/parser.rb +1 -0
- 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 -21
- 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 +13 -5
- 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 +137 -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 +5 -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 +45 -18
- data/.travis.yml +0 -17
- data/lib/measured/conversion_table.rb +0 -65
- data/test/conversion_table_test.rb +0 -98
data/dev.yml
CHANGED
data/lib/measured.rb
CHANGED
data/lib/measured/arithmetic.rb
CHANGED
data/lib/measured/base.rb
CHANGED
@@ -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
|
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/
|
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,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
|
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
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
|
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
|
data/lib/measured/unit_system.rb
CHANGED
@@ -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.
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
@
|
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
|
-
|
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
|
50
|
-
@
|
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
|