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