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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Measured
3
+ class UnitError < StandardError ; end
4
+ end
@@ -1,17 +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.with_unit_system(self) }
6
- @conversion_table_builder = Measured::ConversionTableBuilder.new(@units)
7
- end
8
-
9
- def unit_names_with_aliases
10
- @unit_names_with_aliases ||= @units.flat_map(&:names).sort
11
- end
12
-
13
- def unit_names
14
- @unit_names ||= @units.map(&:name).sort
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
15
20
  end
16
21
 
17
22
  def unit_or_alias?(name)
@@ -41,16 +46,15 @@ class Measured::UnitSystem
41
46
  value.to_r * conversion
42
47
  end
43
48
 
44
- protected
45
-
46
- def conversion_table
47
- @conversion_table ||= @conversion_table_builder.to_h
49
+ def update_cache
50
+ @conversion_table_builder.update_cache
48
51
  end
49
52
 
50
- def unit_name_to_unit
51
- @unit_name_to_unit ||= @units.inject({}) do |hash, unit|
52
- unit.names.each { |name| hash[name.to_s] = unit }
53
- hash
54
- end
53
+ def cached?
54
+ @conversion_table_builder.cached?
55
55
  end
56
+
57
+ protected
58
+
59
+ attr_reader :unit_name_to_unit, :conversion_table
56
60
  end
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
1
2
  class Measured::UnitSystemBuilder
2
3
  def initialize
3
4
  @units = []
5
+ @cache = nil
4
6
  end
5
7
 
6
8
  def unit(unit_name, aliases: [], value: nil)
@@ -13,8 +15,13 @@ class Measured::UnitSystemBuilder
13
15
  nil
14
16
  end
15
17
 
18
+ def cache(cache_class, *args)
19
+ @cache = {class: cache_class, args: args}
20
+ nil
21
+ end
22
+
16
23
  def build
17
- Measured::UnitSystem.new(@units)
24
+ Measured::UnitSystem.new(@units, cache: @cache)
18
25
  end
19
26
 
20
27
  private
@@ -39,8 +46,8 @@ class Measured::UnitSystemBuilder
39
46
  ["P", "peta", 15],
40
47
  ["E", "exa", 18],
41
48
  ["Z", "zetta", 21],
42
- ["Y", "yotta", 24]
43
- ]
49
+ ["Y", "yotta", 24],
50
+ ].map(&:freeze).freeze
44
51
 
45
52
  def build_si_units(name, aliases: [], value: nil)
46
53
  si_units = [build_unit(name, aliases: aliases, value: value)]
@@ -60,7 +67,7 @@ class Measured::UnitSystemBuilder
60
67
  def check_for_duplicate_unit_names!(unit)
61
68
  names = @units.flat_map(&:names)
62
69
  if names.any? { |name| unit.names.include?(name) }
63
- raise Measured::UnitError, "Unit #{unit.name} has already been added."
70
+ raise Measured::UnitAlreadyAdded.new(unit.name)
64
71
  end
65
72
  end
66
73
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  Measured::Length = Measured.build do
2
3
  si_unit :m, aliases: [:meter, :metre, :meters, :metres]
3
4
 
@@ -5,4 +6,6 @@ Measured::Length = Measured.build do
5
6
  unit :ft, value: "12 in", aliases: [:foot, :feet]
6
7
  unit :yd, value: "3 ft", aliases: [:yard, :yards]
7
8
  unit :mi, value: "5280 ft", aliases: [:mile, :miles]
9
+
10
+ cache Measured::Cache::Json, "length.json"
8
11
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  Measured::Volume = Measured.build do
2
3
  si_unit :l, aliases: [:liter, :litre, :liters, :litres]
3
4
 
@@ -12,4 +13,6 @@ Measured::Volume = Measured.build do
12
13
  unit :us_pt, value: "0.125 us_gal", aliases: [:us_pint, :us_pints]
13
14
  unit :oz, value: "0.00625 gal", aliases: [:fl_oz, :imp_fl_oz, :imperial_fluid_ounce, :imperial_fluid_ounces]
14
15
  unit :us_oz, value: "0.0078125 us_gal", aliases: [:us_fl_oz, :us_fluid_ounce, :us_fluid_ounces]
15
- end
16
+
17
+ cache Measured::Cache::Json, "volume.json"
18
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  Measured::Weight = Measured.build do
2
3
  si_unit :g, aliases: [:gram, :grams]
3
4
 
@@ -8,4 +9,6 @@ Measured::Weight = Measured.build do
8
9
  unit :short_ton, value: "2000 lb", aliases: [:short_tons]
9
10
  unit :lb, value: "0.45359237 kg", aliases: [:lbs, :pound, :pounds]
10
11
  unit :oz, value: "1/16 lb", aliases: [:ounce, :ounces]
12
+
13
+ cache Measured::Cache::Json, "weight.json"
11
14
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Measured
2
- VERSION = "2.4.0"
3
+ VERSION = "2.7.0"
3
4
  end
data/measured.gemspec CHANGED
@@ -13,16 +13,24 @@ Gem::Specification.new do |spec|
13
13
  spec.homepage = "https://github.com/Shopify/measured"
14
14
  spec.license = "MIT"
15
15
 
16
+ # Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or
17
+ # delete this section to allow pushing this gem to any host.
18
+ if spec.respond_to?(:metadata)
19
+ spec.metadata['allowed_push_host'] = "https://rubygems.org"
20
+ else
21
+ raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
22
+ end
23
+
16
24
  spec.files = `git ls-files -z`.split("\x0")
17
25
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
26
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
27
  spec.require_paths = ["lib"]
20
28
 
21
- spec.add_runtime_dependency "activesupport", ">= 4.2"
29
+ spec.add_runtime_dependency "activesupport", ">= 5.2"
22
30
 
23
31
  spec.add_development_dependency "rake", "> 10.0"
24
32
  spec.add_development_dependency "minitest", "> 5.5.1"
25
33
  spec.add_development_dependency "minitest-reporters"
26
- spec.add_development_dependency "mocha", "> 1.1.0"
34
+ spec.add_development_dependency "mocha", ">= 1.4.0"
27
35
  spec.add_development_dependency "pry"
28
36
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "test_helper"
2
3
 
3
4
  class Measured::ArithmeticTest < ActiveSupport::TestCase
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ require "test_helper"
3
+
4
+ class Measured::Cache::JsonTest < ActiveSupport::TestCase
5
+ setup do
6
+ @cache = Measured::Cache::Json.new("test.json")
7
+ @table_json = { "a" => { "b" => { "numerator" => 2, "denominator" => 3 } } }.to_json
8
+ @table_hash = { "a" => { "b" => Rational(2, 3) } }
9
+ end
10
+
11
+ test "#initialize sets the filename and path" do
12
+ assert_equal "test.json", @cache.filename
13
+ assert_match(/.+\/cache\/test\.json$/, @cache.path.to_s)
14
+ refute_match "../", @cache.path.to_s
15
+ end
16
+
17
+ test "#exist? returns false if the file does not exist" do
18
+ File.expects(:exist?).with(@cache.path).returns(false)
19
+ refute_predicate @cache, :exist?
20
+ end
21
+
22
+ test "#exist? returns true if the file exists" do
23
+ File.expects(:exist?).with(@cache.path).returns(true)
24
+ assert_predicate @cache, :exist?
25
+ end
26
+
27
+ test "#read returns nil if the file does not exist" do
28
+ File.expects(:exist?).with(@cache.path).returns(false)
29
+ assert_nil @cache.read
30
+ end
31
+
32
+ test "#read loads the file if it exists" do
33
+ File.expects(:exist?).with(@cache.path).returns(true)
34
+ File.expects(:read).with(@cache.path).returns(@table_json)
35
+ assert_equal @table_hash, @cache.read
36
+ end
37
+
38
+ test "#write raises not implemented" do
39
+ assert_raises(ArgumentError) do
40
+ @cache.write({})
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require "test_helper"
3
+
4
+ class Measured::Cache::JsonWriterTest < ActiveSupport::TestCase
5
+ class JsonTestWithWriter < Measured::Cache::Json
6
+ prepend Measured::Cache::JsonWriter
7
+ end
8
+
9
+ setup do
10
+ @cache = JsonTestWithWriter.new("test.json")
11
+ @table_json = JSON.pretty_generate({ "a" => { "b" => { "numerator" => 2, "denominator" => 3 } } })
12
+ @table_hash = { "a" => { "b" => Rational(2, 3) } }
13
+ end
14
+
15
+ test "#write writes the file" do
16
+ f = stub
17
+ f.expects(:write).with("// Do not modify this file directly. Regenerate it with 'rake cache:write'.\n")
18
+ f.expects(:write).with(@table_json)
19
+
20
+ File.expects(:open).with(@cache.path, "w").returns(123).yields(f)
21
+ assert_equal 123, @cache.write(@table_hash)
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ require "test_helper"
3
+
4
+ class Measured::Cache::NullTest < ActiveSupport::TestCase
5
+ setup do
6
+ @cache = Measured::Cache::Null.new
7
+ end
8
+
9
+ test "#exist? false" do
10
+ assert_equal false, @cache.exist?
11
+ end
12
+
13
+ test "#read returns nil" do
14
+ assert_nil @cache.read
15
+ end
16
+
17
+ test "#write returns nil" do
18
+ assert_nil @cache.write({})
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ require "test_helper"
3
+
4
+ # In general we do not want to expose the inner workings of the caching and unit systems as part of the API.
5
+ # But we do want to be able to test that there is no conflict between what's in the cache file and what is defined
6
+ # in the unit systems. So we compromise by reaching into implementation to force compare them.
7
+ class Measured::CacheConsistencyTest < ActiveSupport::TestCase
8
+ measurable_subclasses.select { |m| m.unit_system.cached? }.each do |measurable|
9
+ test "cached measurable #{ measurable } is not out of sync with the definition" do
10
+ builder = measurable.unit_system.instance_variable_get("@conversion_table_builder")
11
+ cache = builder.instance_variable_get("@cache")
12
+
13
+ expected = builder.send(:generate_table)
14
+ actual = cache.read
15
+
16
+ assert expected == actual, "The contents of the file cache for `#{ measurable }` does not match what the unit system generated.\nTry running `rake cache:write` to update the caches."
17
+ end
18
+ end
19
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "test_helper"
2
3
 
3
4
  class Measured::ConversionTableBuilderTest < ActiveSupport::TestCase
@@ -115,4 +116,32 @@ class Measured::ConversionTableBuilderTest < ActiveSupport::TestCase
115
116
  assert_instance_of Rational, value
116
117
  end
117
118
  end
119
+
120
+ test "#to_h raises exception when there are cycles" do
121
+ unit1 = Measured::Unit.new(:pallets, value: "1 liters")
122
+ unit2 = Measured::Unit.new(:liters, value: "0.1 cases")
123
+ unit3 = Measured::Unit.new(:cases, value: "0.1 pallets")
124
+
125
+ assert_raises(Measured::CycleDetected) do
126
+ Measured::ConversionTableBuilder.new([unit1, unit2, unit3]).to_h
127
+ end
128
+ end
129
+
130
+ test "#cached? returns true if there's a cache" do
131
+ builder = Measured::ConversionTableBuilder.new([Measured::Unit.new(:test)], cache: { class: AlwaysTrueCache })
132
+ assert_predicate builder, :cached?
133
+ end
134
+
135
+ test "#cached? returns false if there is not a cache" do
136
+ builder = Measured::ConversionTableBuilder.new([Measured::Unit.new(:test)])
137
+ refute_predicate builder, :cached?
138
+ end
139
+
140
+ test "#write_cache pushes the generated table into the cache and writes it" do
141
+ builder = Measured::ConversionTableBuilder.new([Measured::Unit.new(:test)], cache: { class: AlwaysTrueCache })
142
+ AlwaysTrueCache.any_instance.expects(:exist?).returns(false)
143
+ table = builder.to_h
144
+ AlwaysTrueCache.any_instance.expects(:write).with(table).returns(123)
145
+ assert_equal 123, builder.update_cache
146
+ end
118
147
  end
@@ -1,7 +1,7 @@
1
+ # frozen_string_literal: true
1
2
  require "test_helper"
2
3
 
3
4
  class Measured::MeasurableTest < ActiveSupport::TestCase
4
-
5
5
  setup do
6
6
  @arcane = Magic.unit_system.unit_for!(:arcane)
7
7
  @fireball = Magic.unit_system.unit_for!(:fireball)
@@ -261,7 +261,7 @@ class Measured::MeasurableTest < ActiveSupport::TestCase
261
261
 
262
262
  test "#<=> doesn't compare against zero" do
263
263
  assert_nil @magic <=> 0
264
- assert_nil @magic <=> BigDecimal.new(0)
264
+ assert_nil @magic <=> BigDecimal(0)
265
265
  assert_nil @magic <=> 0.00
266
266
  end
267
267
 
@@ -279,7 +279,7 @@ class Measured::MeasurableTest < ActiveSupport::TestCase
279
279
  test "#== doesn't compare against zero" do
280
280
  arcane_zero = Magic.new(0, :arcane)
281
281
  refute_equal arcane_zero, 0
282
- refute_equal arcane_zero, BigDecimal.new(0)
282
+ refute_equal arcane_zero, BigDecimal(0)
283
283
  refute_equal arcane_zero, 0.0
284
284
  end
285
285
 
@@ -295,10 +295,10 @@ class Measured::MeasurableTest < ActiveSupport::TestCase
295
295
 
296
296
  test "#> and #< should not compare against zero" do
297
297
  assert_raises(ArgumentError) { @magic > 0 }
298
- assert_raises(ArgumentError) { @magic > BigDecimal.new(0) }
298
+ assert_raises(ArgumentError) { @magic > BigDecimal(0) }
299
299
  assert_raises(ArgumentError) { @magic > 0.00 }
300
300
  assert_raises(ArgumentError) { @magic < 0 }
301
- assert_raises(ArgumentError) { @magic < BigDecimal.new(0) }
301
+ assert_raises(ArgumentError) { @magic < BigDecimal(0) }
302
302
  assert_raises(ArgumentError) { @magic < 0.00 }
303
303
  end
304
304
  end
data/test/parser_test.rb CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "test_helper"
2
3
 
3
4
  class Measured::ParserTest < ActiveSupport::TestCase
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ class AlwaysTrueCache
3
+ def exist?
4
+ true
5
+ end
6
+
7
+ def read
8
+ {}
9
+ end
10
+
11
+ def write(*)
12
+ nil
13
+ end
14
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  Magic = Measured.build do
2
3
  unit :magic_missile, aliases: [:magic_missiles, "magic missile"]
3
4
  unit :fireball, value: "2/3 magic_missile", aliases: [:fire, :fireballs]
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ # Extract the subclasses that exist early on because other classes will get added by tests
3
+ # later on in execution and in an unpredictable order.
4
+ class ActiveSupport::TestCase
5
+ cattr_accessor :measurable_subclasses
6
+ self.measurable_subclasses = Measured::Measurable.subclasses
7
+ end
data/test/test_helper.rb CHANGED
@@ -1,17 +1,19 @@
1
+ # frozen_string_literal: true
2
+ require "pry" unless ENV["CI"]
1
3
  require "measured"
2
4
  require "minitest/reporters"
3
5
  require "minitest/autorun"
4
- require "mocha/setup"
5
- require "pry" unless ENV["CI"]
6
+ require "mocha/minitest"
6
7
 
7
8
  ActiveSupport.test_order = :random
8
9
 
9
10
  Minitest::Reporters.use! [Minitest::Reporters::ProgressReporter.new(color: true)]
10
11
 
12
+ require "support/subclasses"
11
13
  require "support/fake_system"
14
+ require "support/always_true_cache"
12
15
 
13
16
  class ActiveSupport::TestCase
14
-
15
17
  protected
16
18
 
17
19
  def assert_close_bigdecimal exp, act, delta = BigDecimal('0.000001')
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "test_helper"
2
3
 
3
4
  class Measured::UnitErrorTest < ActiveSupport::TestCase