diff --git a/lib/measured/base.rb b/lib/measured/base.rb index 0e37cf2..ddc0ab6 100644 --- a/lib/measured/base.rb +++ b/lib/measured/base.rb @@ -38,15 +38,18 @@ def method_missing(method, *args) end require "measured/unit_error" +require "measured/cache/cache_error" require "measured/cycle_detected" require "measured/unit_already_added" require "measured/missing_conversion_path" require "measured/arithmetic" require "measured/parser" +require "measured/unit_conversion" require "measured/unit" require "measured/unit_system" require "measured/unit_system_builder" require "measured/conversion_table_builder" +require "measured/dynamic_conversion_table_builder" require "measured/cache/null" require "measured/cache/json_writer" require "measured/cache/json" diff --git a/lib/measured/cache/cache_error.rb b/lib/measured/cache/cache_error.rb new file mode 100644 index 0000000..0b3cec4 --- /dev/null +++ b/lib/measured/cache/cache_error.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +module Measured + class CacheError < StandardError ; end +end diff --git a/lib/measured/conversion_table_builder.rb b/lib/measured/conversion_table_builder.rb index 495acc3..013564b 100644 --- a/lib/measured/conversion_table_builder.rb +++ b/lib/measured/conversion_table_builder.rb @@ -6,6 +6,7 @@ def initialize(units, cache: nil) @units = units cache ||= { class: Measured::Cache::Null } @cache = cache[:class].new(*cache[:args]) + after_initialize end def to_h @@ -23,6 +24,9 @@ def cached? private + # Used for DynamicConversionTableBuilder + def after_initialize; end + def generate_table validate_no_cycles diff --git a/lib/measured/dynamic_conversion_table_builder.rb b/lib/measured/dynamic_conversion_table_builder.rb new file mode 100644 index 0000000..fa3f7c6 --- /dev/null +++ b/lib/measured/dynamic_conversion_table_builder.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true +module Measured + class DynamicConversionTableBuilder < ConversionTableBuilder + SELF_CONVERSION = ->(x) { x * Rational(1, 1) } + + private + + def after_initialize + @units.map!(&:to_dynamic) + validate_cache + end + + def validate_cache + return if @cache.is_a?(Measured::Cache::Null) + + raise CacheError, "Dynamic unit systems cannot be cached" + end + + def generate_table + validate_no_cycles + + units.map(&:name).each_with_object({}) do |to_unit, table| + to_table = {to_unit => SELF_CONVERSION} + + table.each do |from_unit, from_table| + conversion, inverse_conversion = find_conversion(to: from_unit, from: to_unit) + to_table[from_unit] = conversion + from_table[to_unit] = inverse_conversion + end + + table[to_unit] = to_table + end + end + + def find_direct_conversion(to:, from:) + units.each do |unit| + if unit.name == from && unit.conversion_unit == to + return [unit.conversion_amount, unit.inverse_conversion_amount] + end + + if unit.name == to && unit.conversion_unit == from + return [unit.inverse_conversion_amount, unit.conversion_amount] + end + end + + nil + end + + def find_tree_traversal_conversion(to:, from:) + traverse(from: from, to: to, units_remaining: units.map(&:name), amount: ->(x) { x }, inverse_amount: ->(x) { x }) + end + + def traverse(from:, to:, units_remaining:, amount:, inverse_amount:) + units_remaining = units_remaining - [from] + + units_remaining.each do |name| + conversion, inverse_conversion = find_direct_conversion_cached(from: from, to: name) + + if conversion + new_amount = ->(x) { conversion.call amount.call(x) } + new_inverse_amount = ->(x) { inverse_amount.call inverse_conversion.call(x) } + + if name == to + return [new_amount, new_inverse_amount] + else + result = traverse(from: name, to: to, units_remaining: units_remaining, amount: new_amount, inverse_amount: new_inverse_amount) + return result if result + end + end + end + + nil + end + end +end diff --git a/lib/measured/unit.rb b/lib/measured/unit.rb index b0c83b4..42418a3 100644 --- a/lib/measured/unit.rb +++ b/lib/measured/unit.rb @@ -2,30 +2,33 @@ class Measured::Unit include Comparable - attr_reader :name, :names, :aliases, :conversion_amount, :conversion_unit, :unit_system, :inverse_conversion_amount + attr_reader :name, :names, :aliases, :unit_system, :unit_conversion def initialize(name, aliases: [], value: nil, unit_system: nil) @name = name.to_s.freeze @aliases = aliases.map(&:to_s).map(&:freeze).freeze @names = ([@name] + @aliases).sort!.freeze - @conversion_amount, @conversion_unit = parse_value(value) if value - @inverse_conversion_amount = (1 / conversion_amount if conversion_amount) - @conversion_string = ("#{conversion_amount} #{conversion_unit}" if conversion_amount || conversion_unit) + @unit_conversion = Measured::UnitConversion.parse(value) @unit_system = unit_system end def with(name: nil, unit_system: nil, aliases: nil, value: nil) + value ||= @unit_conversion.to_s + if dynamic? + value = @unit_conversion.value + end + self.class.new( name || self.name, aliases: aliases || self.aliases, - value: value || @conversion_string, + value: value, unit_system: unit_system || self.unit_system ) end def to_s(with_conversion_string: true) - if with_conversion_string && @conversion_string - "#{name} (#{@conversion_string})".freeze + if with_conversion_string && @unit_conversion.to_s + "#{name} (#{@unit_conversion})".freeze else name end @@ -34,7 +37,7 @@ def to_s(with_conversion_string: true) def inspect pieces = [name] pieces << "(#{aliases.join(", ")})" if aliases.any? - pieces << @conversion_string if @conversion_string + pieces << @unit_conversion if @unit_conversion.to_s "#<#{self.class.name}: #{pieces.join(" ")}>".freeze end @@ -44,25 +47,42 @@ def <=>(other) if names_comparison != 0 names_comparison else - conversion_amount <=> other.conversion_amount + compared_value(conversion_amount) <=> compared_value(other.conversion_amount) end else name <=> other end end + def conversion_unit + @unit_conversion.unit + end + + def conversion_amount + @unit_conversion.amount + end + + def inverse_conversion_amount + @unit_conversion.inverse_amount + end + + def dynamic? + @unit_conversion.dynamic? + end + + def to_dynamic + @unit_conversion = @unit_conversion.to_dynamic + self + end + private - def parse_value(tokens) - case tokens - when String - tokens = Measured::Parser.parse_string(tokens) - when Array - raise Measured::UnitError, "Cannot parse [number, unit] formatted tokens from #{tokens}." unless tokens.size == 2 + def compared_value(conversion_amount) + case conversion_amount + when Proc + conversion_amount.call(1) else - raise Measured::UnitError, "Unit must be defined as string or array, but received #{tokens}" + conversion_amount end - - [tokens[0].to_r, tokens[1].freeze] end end diff --git a/lib/measured/unit_conversion.rb b/lib/measured/unit_conversion.rb new file mode 100644 index 0000000..208613d --- /dev/null +++ b/lib/measured/unit_conversion.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Measured + class UnitConversion + def self.parse(tokens, description: nil) + if tokens.nil? + return StaticUnitConversion.new(amount: nil, unit: nil) + end + + case tokens + when String + tokens = Measured::Parser.parse_string(tokens) + when Array + raise Measured::UnitError, "Cannot parse [number, unit] formatted tokens from #{tokens}." unless tokens.size == 2 + else + raise Measured::UnitError, "Unit must be defined as string or array, but received #{tokens}" + end + + case tokens[0] + when Hash + DynamicUnitConversion.new( + amount: tokens[0][:conversion], + inverse_amount: tokens[0][:reverse_conversion], + description: tokens[0][:description], + unit: tokens[1].freeze + ) + else + StaticUnitConversion.new( + amount: tokens[0].to_r, + unit: tokens[1].freeze + ) + end + end + end + + class DynamicUnitConversion + attr_reader :amount, :inverse_amount, :unit + + def initialize(amount:, inverse_amount:, unit:, description: nil) + @amount = amount + @inverse_amount = inverse_amount + @unit = unit + @description = description + end + + def value + [ + { + conversion: amount, + reverse_conversion: inverse_amount, + description: @description + }, + unit + ] + end + + def dynamic? + true + end + + def static? + false + end + + def to_s + @description + end + + def to_dynamic + self + end + end + + class StaticUnitConversion + attr_reader :amount, :unit + + def initialize(amount:, unit:) + @amount = amount + @unit = unit + end + + def dynamic? + false + end + + def static? + true + end + + def to_s + return unless amount && unit + + "#{amount} #{unit}" + end + + def inverse_amount + return unless amount + + (1 / amount) + end + + def to_dynamic + DynamicUnitConversion.new( + amount: ->(x) { x * amount }, + inverse_amount: ->(x) { x * inverse_amount }, + unit: unit, + description: to_s + ) + end + end +end diff --git a/lib/measured/unit_system.rb b/lib/measured/unit_system.rb index 5047c2c..6fe718e 100644 --- a/lib/measured/unit_system.rb +++ b/lib/measured/unit_system.rb @@ -15,7 +15,13 @@ def initialize(units, cache: nil) @unit_name_to_unit = @units.each_with_object({}) do |unit, hash| unit.names.each { |name| hash[name.to_s] = unit } end - @conversion_table_builder = Measured::ConversionTableBuilder.new(@units, cache: cache) + + table_builder = Measured::ConversionTableBuilder + if @units.any?(&:dynamic?) + table_builder = Measured::DynamicConversionTableBuilder + end + + @conversion_table_builder = table_builder.new(@units, cache: cache) @conversion_table = @conversion_table_builder.to_h.freeze end @@ -42,8 +48,12 @@ def convert(value, from:, to:) conversion = conversion_table.fetch(from.name, {})[to.name] raise Measured::UnitError, "Cannot find conversion entry from #{from} to #{to}" unless conversion - - value.to_r * conversion + case conversion + when Proc + conversion.call(value).to_r + else + value.to_r * conversion + end end def update_cache diff --git a/test/dynamic_conversion_table_builder_test.rb b/test/dynamic_conversion_table_builder_test.rb new file mode 100644 index 0000000..c797f7f --- /dev/null +++ b/test/dynamic_conversion_table_builder_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require "test_helper" + +class Measured::DynamicConversionTableBuilderTest < ActiveSupport::TestCase + test "#initialize raises when cache is not null" do + invalid_cache_arguments = { class: Measured::Cache::Json, args: ["volume.json"] } + valid_cache_arguments = { class: Measured::Cache::Null, args: [] } + + assert_raises Measured::CacheError do + Measured::DynamicConversionTableBuilder.new([], cache: invalid_cache_arguments) + end + + assert_nothing_raised do + Measured::DynamicConversionTableBuilder.new([], cache: valid_cache_arguments) + end + end + + test "#to_h returns expected nested hashes in an indrect path" do + conversion_table = Measured::DynamicConversionTableBuilder.new([ + Measured::Unit.new(:mm), + Measured::Unit.new(:cm, value: [ + { + conversion: ->(cm) { Rational(10,1) * cm }, + reverse_conversion: ->(mm) { mm * Rational(1,10) }, + description: '10 mm', + }, + "mm" + ]), + Measured::Unit.new(:dm, value: "10 cm"), + Measured::Unit.new(:m, value: "10 dm"), + ]).to_h + + [ + ["m", "m", Rational(1,1)], + ["m", "dm", Rational(10,1)], + ["m", "cm", Rational(100,1)], + ["m", "mm", Rational(1000,1)], + ["dm", "m", Rational(1,10)], + ["dm", "dm", Rational(1,1)], + ["dm", "cm", Rational(10,1)], + ["dm", "mm", Rational(100,1)], + ["cm", "m", Rational(1,100)], + ["cm", "dm", Rational(1,10)], + ["cm", "cm", Rational(1,1)], + ["cm", "mm", Rational(10,1)], + ["mm", "m", Rational(1,1000)], + ["mm", "dm", Rational(1,100)], + ["mm", "cm", Rational(1,10)], + ["mm", "mm", Rational(1,1)] + ].each do |(to, from, ratio)| + assert conversion_table[to][from].is_a?(Proc) + assert_equal ratio, conversion_table[to][from].call(1) + end + end +end \ No newline at end of file diff --git a/test/unit_conversion_test.rb b/test/unit_conversion_test.rb new file mode 100644 index 0000000..4f91b54 --- /dev/null +++ b/test/unit_conversion_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true +require "test_helper" + +class Measured::UnitConversionTest < ActiveSupport::TestCase + setup do + @unit_conversion = Measured::UnitConversion.parse("10 Cake") + end + + test "#initialize parses out the unit and the number part" do + assert_equal 10, @unit_conversion.amount + assert_equal "Cake", @unit_conversion.unit + + unit_conversion = Measured::UnitConversion.parse(["5.5", "sweets"]) + assert_equal BigDecimal("5.5"), unit_conversion.amount + assert_equal "sweets", unit_conversion.unit + + unit_conversion = Measured::UnitConversion.parse("1/3 bitter pie") + assert_equal Rational(1, 3), unit_conversion.amount + assert_equal "bitter pie", unit_conversion.unit + end + + test "#initialize raises if the format of the value is incorrect" do + assert_raises Measured::UnitError do + Measured::UnitConversion.parse("hello") + end + + assert_raises Measured::UnitError do + Measured::UnitConversion.parse("123456") + end + end + + test "#to_s returns an expected string" do + assert_nil Measured::UnitConversion.parse(nil).to_s + assert_equal "1/2 sweet", Measured::UnitConversion.parse("0.5 sweet").to_s + end + + test "#inverse_amount returns 1/amount" do + assert_equal Rational(1, 10), @unit_conversion.inverse_amount + end + + test "#inverse_amount handles nil for base unit" do + assert_nil Measured::UnitConversion.parse(nil).inverse_amount + end +end + +class Measured::StaticUnitConversionTest < ActiveSupport::TestCase + setup do + @unit_conversion = Measured::UnitConversion.parse("10 Cake") + end + + test "#static? & #dynamic?" do + refute @unit_conversion.dynamic? + assert @unit_conversion.static? + end + + test "#to_dynamic maps a static unit into a dynamic one" do + new_unit = @unit_conversion.to_dynamic + + assert_equal "Cake", new_unit.unit + assert_equal "10/1 Cake", new_unit.to_s + + assert new_unit.amount.is_a?(Proc) + assert_equal 10, new_unit.amount.call(1) + + assert new_unit.inverse_amount.is_a?(Proc) + assert_equal 1, new_unit.inverse_amount.call(10) + end +end + +class Measured::DynamicUnitConversionTest < ActiveSupport::TestCase + setup do + @unit_conversion = Measured::UnitConversion.parse([ + { + conversion: proc { |x| x * Rational(10, 1) }, + reverse_conversion: proc { |x| x * Rational(1, 10) } + }, + "sweets" + ]) + end + + test "#static? & #dynamic?" do + assert @unit_conversion.dynamic? + refute @unit_conversion.static? + end + + test "#initialize parses out the unit and the number part" do + assert_equal 10, @unit_conversion.amount.call(1) + assert_equal "sweets", @unit_conversion.unit + end + + test "#to_s returns an expected string" do + assert_nil @unit_conversion.to_s + + unit_conversion = Measured::UnitConversion.parse([ + { + conversion: proc { |x| x * Rational(10, 1) }, + reverse_conversion: proc { |x| x * Rational(1, 10) }, + description: 'some description' + }, + "sweets" + ]) + + assert_equal 'some description', unit_conversion.to_s + end + + test "#inverse_amount returns 1/amount" do + assert_equal 0.1, @unit_conversion.inverse_amount.call(1) + end + + test "#to_dynamic returns itself" do + assert @unit_conversion.to_dynamic == @unit_conversion + end +end diff --git a/test/unit_system_test.rb b/test/unit_system_test.rb index 9407050..b3769ed 100644 --- a/test/unit_system_test.rb +++ b/test/unit_system_test.rb @@ -114,4 +114,15 @@ class Measured::UnitSystemTest < ActiveSupport::TestCase conversion = Measured::UnitSystem.new([@unit_m, @unit_in, @unit_ft], cache: { class: AlwaysTrueCache }) assert_predicate conversion, :cached? end + + test "#initialize raises when caching dynamic unit system" do + assert_raises Measured::CacheError do + Measured.build do + unit :base_unit + unit :unit, value: [{conversion: ->(x) {x}, reverse_conversion: ->(x) {x}, description: '1 base unit'}, "base_unit"] + + cache Measured::Cache::Json, "length.json" + end + end + end end diff --git a/test/unit_test.rb b/test/unit_test.rb index acf7fb5..6ec6557 100644 --- a/test/unit_test.rb +++ b/test/unit_test.rb @@ -88,3 +88,60 @@ class Measured::UnitTest < ActiveSupport::TestCase assert_nil Measured::Unit.new(:pie).inverse_conversion_amount end end + +class Measured::DyanmicUnitTest < ActiveSupport::TestCase + setup do + @unit = Measured::Unit.new(:Pie, value: [ + { + conversion: ->(x) { x * Rational(10, 1) }, + reverse_conversion: ->(x) { x * Rational(1,10) }, + description: 'some description' + }, + 'Cake' + ]) + end + + test "#initialize converts the name to a string" do + assert_equal "Pie", @unit.name + end + + test "#initialize parses out the unit and the number part" do + assert_equal 10, @unit.conversion_amount.call(1) + assert_equal "Cake", @unit.conversion_unit + end + + test "#to_s returns an expected string" do + assert_equal "Pie (some description)", @unit.to_s + assert_equal "Pie", @unit.to_s(with_conversion_string: false) + end + + test "#inspect returns an expected string" do + assert_equal "#", @unit.inspect + end + + test "#<=> compares non-Unit classes against name" do + assert_equal 1, @unit <=> "Pap" + assert_equal (-1), @unit <=> "Pop" + end + + test "#<=> is 0 for Unit instances that should be equivalent" do + assert_equal 0, @unit <=> Measured::Unit.new(:Pie, value: "10 cake") + assert_equal 0, @unit <=> Measured::Unit.new("Pie", value: "10 cake") + assert_equal 0, @unit <=> Measured::Unit.new("Pie", value: [10, :cake]) + end + + test "#<=> is -1 for units with names that come after Pie lexicographically" do + assert_equal (-1), @unit <=> Measured::Unit.new(:Pigs, value: "10 bacon") + assert_equal (-1), @unit <=> Measured::Unit.new("Pig", aliases: %w(Pigs), value: "10 bacon") + end + + test "#<=> compares #conversion_amount when unit names the same" do + assert_equal (-1), @unit <=> Measured::Unit.new(:Pie, value: [11, :pancake]) + assert_equal 0, @unit <=> Measured::Unit.new(:Pie, value: [10, :foo]) + assert_equal 1, @unit <=> Measured::Unit.new(:Pie, value: [9, :pancake]) + end + + test "#inverse_conversion_amount returns 1/amount" do + assert_equal Rational(1, 10), @unit.inverse_conversion_amount.call(1) + end +end diff --git a/test/units/temperature_test.rb b/test/units/temperature_test.rb new file mode 100644 index 0000000..ea96d5d --- /dev/null +++ b/test/units/temperature_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true +Measured::Temperature = Measured.build do + unit :C, aliases: [:c, :celsius] + + unit :K, value: [ + { + conversion: ->(k) { k - BigDecimal('273.15') }, + reverse_conversion: ->(c) { c + BigDecimal('273.15') }, + description: 'celsius + 273.15' + }, 'C'], aliases: [:k, :kelvin] + + unit :F, value: [ + { + conversion: ->(f) { (f-32) * Rational(5,9) }, + reverse_conversion: ->(c) { c * Rational(9,5) + 32 }, + description: '9 * celsius / 5 + 32' + }, 'C'], aliases: [:f, :farenheit] +end + +require "test_helper" + +class Measured::TemperatureTest < ActiveSupport::TestCase + test ".unit_names should be the list of base unit names" do + expected_units = %w(C F K) + assert_equal expected_units.sort, Measured::Temperature.unit_names + end + + test ".name" do + assert_equal "temperature", Measured::Temperature.name + end + + test "Measured::Temperature() delegates automatically to .new" do + assert_equal Measured::Temperature.new(1, :C), Measured::Temperature(1, :C) + end + + test ".convert_to from C to K" do + assert_exact_conversion Measured::Temperature, "0 C", "273.15 K" + assert_exact_conversion Measured::Temperature, "10 C", "283.15 K" + end + + test ".convert_to from C to F" do + assert_exact_conversion Measured::Temperature, "45 C", "113 F" + assert_exact_conversion Measured::Temperature, "0 C", "32 F" + end + + test ".convert_to from F to C" do + assert_exact_conversion Measured::Temperature, "113 F", "45 C" + assert_exact_conversion Measured::Temperature, "32 F", "0 C" + end + + test ".convert_to from F to K" do + assert_exact_conversion Measured::Temperature, "32 F", "273.15 K" + end + + test ".convert_to from K to C" do + assert_exact_conversion Measured::Temperature, "273.15 K", "0 C" + assert_exact_conversion Measured::Temperature, "0 K", "-273.15 C" + end + + test ".convert_to from K to F" do + assert_exact_conversion Measured::Temperature, "0 K", "-459.67 F" + end +end