From 7a21fe1f91c6a8cec4af4d76ccac680b1014e1d2 Mon Sep 17 00:00:00 2001 From: Neethan Balaventhan Date: Mon, 9 Mar 2026 23:17:29 -0400 Subject: [PATCH 1/5] feat: extend Unit to accept proc-based functional conversions --- lib/measured/unit.rb | 56 ++++++++++++++++--- test/unit_test.rb | 126 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 7 deletions(-) diff --git a/lib/measured/unit.rb b/lib/measured/unit.rb index b0c83b4..c3470ea 100644 --- a/lib/measured/unit.rb +++ b/lib/measured/unit.rb @@ -2,15 +2,15 @@ class Measured::Unit include Comparable - attr_reader :name, :names, :aliases, :conversion_amount, :conversion_unit, :unit_system, :inverse_conversion_amount + attr_reader :name, :names, :aliases, :conversion_amount, :conversion_unit, :unit_system, :inverse_conversion_amount, :description 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) + @inverse_conversion_amount ||= compute_inverse(@conversion_amount) + @conversion_string = build_conversion_string @unit_system = unit_system end @@ -18,11 +18,15 @@ def with(name: nil, unit_system: nil, aliases: nil, value: nil) self.class.new( name || self.name, aliases: aliases || self.aliases, - value: value || @conversion_string, + value: value || @raw_value, unit_system: unit_system || self.unit_system ) end + def functional? + @conversion_amount.is_a?(Proc) + end + def to_s(with_conversion_string: true) if with_conversion_string && @conversion_string "#{name} (#{@conversion_string})".freeze @@ -44,7 +48,7 @@ def <=>(other) if names_comparison != 0 names_comparison else - conversion_amount <=> other.conversion_amount + comparable_amount(conversion_amount) <=> comparable_amount(other.conversion_amount) end else name <=> other @@ -53,16 +57,54 @@ def <=>(other) private + def comparable_amount(amount) + amount.is_a?(Proc) ? amount.call(Rational(1)) : amount + end + + def compute_inverse(amount) + return nil unless amount + + 1 / amount + end + + def build_conversion_string + return nil unless @conversion_amount || @conversion_unit + + if @conversion_amount.is_a?(Proc) + @description + else + "#{@conversion_amount} #{@conversion_unit}" + end + end + def parse_value(tokens) case tokens when String - tokens = Measured::Parser.parse_string(tokens) + parsed = Measured::Parser.parse_string(tokens) + @raw_value = tokens + [parsed[0].to_r, parsed[1].freeze] when Array raise Measured::UnitError, "Cannot parse [number, unit] formatted tokens from #{tokens}." unless tokens.size == 2 + + if tokens[0].is_a?(Hash) + parse_functional_value(tokens) + else + @raw_value = tokens + [tokens[0].to_r, tokens[1].to_s.freeze] + end else raise Measured::UnitError, "Unit must be defined as string or array, but received #{tokens}" end + end + + def parse_functional_value(tokens) + opts = tokens[0] + forward = opts.fetch(:forward) + backward = opts.fetch(:backward) + @description = opts[:description] + @raw_value = tokens + @inverse_conversion_amount = backward - [tokens[0].to_r, tokens[1].freeze] + [forward, tokens[1].to_s.freeze] end end diff --git a/test/unit_test.rb b/test/unit_test.rb index acf7fb5..211b70e 100644 --- a/test/unit_test.rb +++ b/test/unit_test.rb @@ -88,3 +88,129 @@ class Measured::UnitTest < ActiveSupport::TestCase assert_nil Measured::Unit.new(:pie).inverse_conversion_amount end end + +class Measured::FunctionalUnitTest < ActiveSupport::TestCase + setup do + @unit = Measured::Unit.new(:Pie, value: [ + { + forward: ->(x) { x * Rational(10, 1) }, + backward: ->(x) { x * Rational(1, 10) }, + }, + 'Cake' + ]) + end + + test "#initialize sets conversion_unit" do + assert_equal "Cake", @unit.conversion_unit + end + + test "#conversion_amount is a Proc" do + assert_instance_of Proc, @unit.conversion_amount + assert_equal Rational(10, 1), @unit.conversion_amount.call(1) + end + + test "#inverse_conversion_amount is a Proc" do + assert_instance_of Proc, @unit.inverse_conversion_amount + assert_equal Rational(1, 10), @unit.inverse_conversion_amount.call(1) + end + + test "#functional? returns true" do + assert_predicate @unit, :functional? + end + + test "#functional? returns false for static units" do + refute_predicate Measured::Unit.new(:Pie, value: "10 Cake"), :functional? + end + + test "#to_s returns name with description when provided" do + unit = Measured::Unit.new(:Pie, value: [ + { forward: ->(x) { x * 10 }, backward: ->(x) { x / 10 }, description: "10 Cake" }, + 'Cake' + ]) + assert_equal "Pie (10 Cake)", unit.to_s + end + + test "#to_s returns just name when no description" do + assert_equal "Pie", @unit.to_s + end + + test "#with preserves functional conversion" do + new_unit = @unit.with(unit_system: :fake) + assert_instance_of Proc, new_unit.conversion_amount + assert_equal Rational(10, 1), new_unit.conversion_amount.call(1) + assert_equal :fake, new_unit.unit_system + end + + test "#<=> compares functional units by evaluating at 1" do + assert_equal 0, @unit <=> Measured::Unit.new(:Pie, value: [10, :foo]) + assert_equal(-1, @unit <=> Measured::Unit.new(:Pie, value: [11, :foo])) + assert_equal 1, @unit <=> Measured::Unit.new(:Pie, value: [9, :foo]) + end + + test "#functional? returns false for base units with no value" do + refute_predicate Measured::Unit.new(:Pie), :functional? + end + + test "#inspect includes description when provided" do + unit = Measured::Unit.new(:Pie, aliases: ["Tart"], value: [ + { forward: ->(x) { x * 10 }, backward: ->(x) { x / 10 }, description: "10 Cake" }, + 'Cake' + ]) + assert_equal "#", unit.inspect + end + + test "#inspect omits conversion string when no description" do + assert_equal "#", @unit.inspect + end + + test "#to_s with_conversion_string: false returns just name for functional units" do + assert_equal "Pie", @unit.to_s(with_conversion_string: false) + end + + test "#initialize raises KeyError when :forward is missing" do + assert_raises KeyError do + Measured::Unit.new(:Pie, value: [{ backward: ->(x) { x } }, 'Cake']) + end + end + + test "#initialize raises KeyError when :backward is missing" do + assert_raises KeyError do + Measured::Unit.new(:Pie, value: [{ forward: ->(x) { x } }, 'Cake']) + end + end + + test "#with with explicit value override replaces functional conversion" do + new_unit = @unit.with(value: "5 Cake") + refute_predicate new_unit, :functional? + assert_equal Rational(5, 1), new_unit.conversion_amount + end + + test "#inverse_conversion_amount stores the backward proc" do + assert_instance_of Proc, @unit.inverse_conversion_amount + assert_equal Rational(1, 10), @unit.inverse_conversion_amount.call(1) + assert_equal Rational(5, 10), @unit.inverse_conversion_amount.call(5) + end + + test "#inverse_conversion_amount is distinct from conversion_amount" do + unit = Measured::Unit.new(:K, value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + assert_equal BigDecimal("-272.15"), unit.conversion_amount.call(1) + assert_equal BigDecimal("274.15"), unit.inverse_conversion_amount.call(1) + end + + test "#<=> compares two functional units with different procs" do + small = Measured::Unit.new(:Pie, value: [ + { forward: ->(x) { x * Rational(2, 1) }, backward: ->(x) { x * Rational(1, 2) } }, + "Cake" + ]) + large = Measured::Unit.new(:Pie, value: [ + { forward: ->(x) { x * Rational(100, 1) }, backward: ->(x) { x * Rational(1, 100) } }, + "Cake" + ]) + assert_equal(-1, small <=> large) + assert_equal 1, large <=> small + assert_equal 0, small <=> small + end +end From 4056a03e5ace83c2cf4d2f875e3d25e0545f81c5 Mon Sep 17 00:00:00 2001 From: Neethan Balaventhan Date: Mon, 9 Mar 2026 23:17:34 -0400 Subject: [PATCH 2/5] feat: add FunctionalConversionTableBuilder for proc-based conversions --- lib/measured/cache/cache_error.rb | 4 + .../functional_conversion_table_builder.rb | 106 ++++++++++ ...unctional_conversion_table_builder_test.rb | 191 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 lib/measured/cache/cache_error.rb create mode 100644 lib/measured/functional_conversion_table_builder.rb create mode 100644 test/functional_conversion_table_builder_test.rb diff --git a/lib/measured/cache/cache_error.rb b/lib/measured/cache/cache_error.rb new file mode 100644 index 0000000..9ed5d43 --- /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/functional_conversion_table_builder.rb b/lib/measured/functional_conversion_table_builder.rb new file mode 100644 index 0000000..8a6d884 --- /dev/null +++ b/lib/measured/functional_conversion_table_builder.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true +class Measured::FunctionalConversionTableBuilder + include Measured::ConversionTableBuilderBase + + IDENTITY = ->(x) { x } + + def initialize(units, cache: nil) + @units = units + cache ||= { class: Measured::Cache::Null } + cache_instance = cache[:class].new(*cache[:args]) + unless cache_instance.is_a?(Measured::Cache::Null) + raise Measured::CacheError, "Functional unit systems cannot be cached" + end + end + + def to_h + @table ||= generate_table + end + + def update_cache + raise Measured::CacheError, "Functional unit systems cannot be cached" + end + + def cached? + false + end + + private + + def generate_table + validate_no_cycles + + units.map(&:name).each_with_object({}) do |to_unit, table| + to_table = { to_unit => IDENTITY } + + table.each do |from_unit, from_table| + conversion, inverse = find_conversion(to: from_unit, from: to_unit) + to_table[from_unit] = conversion + from_table[to_unit] = inverse + end + + table[to_unit] = to_table + end + end + + def find_conversion(to:, from:) + result = find_direct_conversion_cached(to: to, from: from) || find_tree_traversal_conversion(to: to, from: from) + raise Measured::MissingConversionPath.new(from, to) unless result + result + end + + def find_direct_conversion(to:, from:) + units.each do |unit| + if unit.name == from && unit.conversion_unit == to + forward = wrap_conversion(unit.conversion_amount) + backward = wrap_conversion(unit.inverse_conversion_amount) + return [forward, backward] + end + + if unit.name == to && unit.conversion_unit == from + backward = wrap_conversion(unit.conversion_amount) + forward = wrap_conversion(unit.inverse_conversion_amount) + return [forward, backward] + end + end + + nil + end + + def wrap_conversion(amount) + return amount if amount.is_a?(Proc) + + ->(x) { x * amount } + end + + def find_tree_traversal_conversion(to:, from:) + identity = ->(x) { x } + traverse(from: from, to: to, units_remaining: units.map(&:name), forward: identity, backward: identity) + end + + def traverse(from:, to:, units_remaining:, forward:, backward:) + units_remaining = units_remaining - [from] + + units_remaining.each do |name| + pair = find_direct_conversion_cached(from: from, to: name) + next unless pair + + step_forward, step_backward = pair + new_forward = compose(step_forward, forward) + new_backward = compose(backward, step_backward) + + if name == to + return [new_forward, new_backward] + else + result = traverse(from: name, to: to, units_remaining: units_remaining, forward: new_forward, backward: new_backward) + return result if result + end + end + + nil + end + + def compose(outer, inner) + ->(x) { outer.call(inner.call(x)) } + end +end diff --git a/test/functional_conversion_table_builder_test.rb b/test/functional_conversion_table_builder_test.rb new file mode 100644 index 0000000..2823cf0 --- /dev/null +++ b/test/functional_conversion_table_builder_test.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true +require "test_helper" + +class Measured::FunctionalConversionTableBuilderTest < ActiveSupport::TestCase + test "#initialize raises when cache is not null" do + invalid_cache = { class: Measured::Cache::Json, args: ["volume.json"] } + valid_cache = { class: Measured::Cache::Null, args: [] } + + assert_raises Measured::CacheError do + Measured::FunctionalConversionTableBuilder.new([], cache: invalid_cache) + end + + assert_nothing_raised do + Measured::FunctionalConversionTableBuilder.new([], cache: valid_cache) + end + end + + test "#to_h returns self-conversion procs on the diagonal" do + table = Measured::FunctionalConversionTableBuilder.new([ + Measured::Unit.new(:base), + ]).to_h + + identity = table["base"]["base"] + assert identity.is_a?(Proc) + assert_equal 42, identity.call(42) + assert_equal BigDecimal("42.5"), identity.call(BigDecimal("42.5")) + assert_instance_of BigDecimal, identity.call(BigDecimal("42.5")) + end + + test "#to_h handles static units by wrapping them as procs" do + table = Measured::FunctionalConversionTableBuilder.new([ + Measured::Unit.new(:m), + Measured::Unit.new(:cm, value: "0.01 m"), + ]).to_h + + assert table["m"]["cm"].is_a?(Proc) + assert_equal Rational(100, 1), table["m"]["cm"].call(1) + assert_equal Rational(1, 100), table["cm"]["m"].call(1) + end + + test "#to_h handles functional conversions" do + table = Measured::FunctionalConversionTableBuilder.new([ + Measured::Unit.new(:C), + Measured::Unit.new(:K, value: [ + { + forward: ->(k) { k - BigDecimal("273.15") }, + backward: ->(c) { c + BigDecimal("273.15") }, + }, "C" + ]), + ]).to_h + + assert_equal BigDecimal("0"), table["K"]["C"].call(BigDecimal("273.15")) + assert_equal BigDecimal("273.15"), table["C"]["K"].call(BigDecimal("0")) + end + + test "#to_h computes indirect functional paths" do + table = Measured::FunctionalConversionTableBuilder.new([ + Measured::Unit.new(:C), + Measured::Unit.new(:K, value: [ + { + forward: ->(k) { k - BigDecimal("273.15") }, + backward: ->(c) { c + BigDecimal("273.15") }, + }, "C" + ]), + Measured::Unit.new(:F, value: [ + { + forward: ->(f) { (f - 32) * Rational(5, 9) }, + backward: ->(c) { c * Rational(9, 5) + 32 }, + }, "C" + ]), + ]).to_h + + # K to F (indirect: K -> C -> F) + # 0 K = -273.15 C = -459.67 F + result = table["K"]["F"].call(BigDecimal("0")) + assert_equal BigDecimal("-459.67"), result + + # F to K (indirect: F -> C -> K) + # 32 F = 0 C = 273.15 K + result = table["F"]["K"].call(BigDecimal("32")) + assert_equal BigDecimal("273.15"), result + end + + test "#to_h handles mixed static and functional units with indirect paths" do + table = Measured::FunctionalConversionTableBuilder.new([ + Measured::Unit.new(:mm), + Measured::Unit.new(:cm, value: [ + { + forward: ->(cm) { Rational(10, 1) * cm }, + backward: ->(mm) { mm * Rational(1, 10) }, + }, "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_entry = table[to][from], "Missing entry table[#{to}][#{from}]" + assert conversion_table_entry.is_a?(Proc), "Expected Proc for table[#{to}][#{from}]" + assert_equal ratio, conversion_table_entry.call(1), "Wrong conversion for table[#{to}][#{from}]" + end + end + + test "#update_cache raises CacheError" do + builder = Measured::FunctionalConversionTableBuilder.new([Measured::Unit.new(:base)]) + assert_raises Measured::CacheError do + builder.update_cache + end + end + + test "#cached? returns false" do + builder = Measured::FunctionalConversionTableBuilder.new([Measured::Unit.new(:base)]) + refute_predicate builder, :cached? + end + + test "#to_h raises on cycles" do + unit1 = Measured::Unit.new(:a, value: [{ forward: ->(x) { x }, backward: ->(x) { x } }, "b"]) + unit2 = Measured::Unit.new(:b, value: [{ forward: ->(x) { x }, backward: ->(x) { x } }, "c"]) + unit3 = Measured::Unit.new(:c, value: [{ forward: ->(x) { x }, backward: ->(x) { x } }, "a"]) + + assert_raises Measured::CycleDetected do + Measured::FunctionalConversionTableBuilder.new([unit1, unit2, unit3]).to_h + end + end + + test "#to_h computes multi-hop paths across 4+ functional units" do + table = Measured::FunctionalConversionTableBuilder.new([ + Measured::Unit.new(:A), + Measured::Unit.new(:B, value: [ + { forward: ->(b) { b * 2 }, backward: ->(a) { a / 2 } }, + "A" + ]), + Measured::Unit.new(:C, value: [ + { forward: ->(c) { c * 3 }, backward: ->(b) { b / 3 } }, + "B" + ]), + Measured::Unit.new(:D, value: [ + { forward: ->(d) { d * 5 }, backward: ->(c) { c / 5 } }, + "C" + ]), + ]).to_h + + # D -> A requires 3 hops: D -> C -> B -> A + # D=1 -> C=5 -> B=15 -> A=30 + assert_equal Rational(30), table["D"]["A"].call(1) + # A -> D is the reverse: A=30 -> B=15 -> C=5 -> D=1 + assert_equal Rational(1), table["A"]["D"].call(30) + + %w(A B C D).each do |from| + %w(A B C D).each do |to| + assert table[from][to].is_a?(Proc), "Missing table[#{from}][#{to}]" + result = table[to][from].call(table[from][to].call(Rational(7))) + assert_equal Rational(7), result, "Round-trip failed for #{from} -> #{to} -> #{from}" + end + end + end + + test "#to_h wraps static Rational amounts as procs preserving type" do + table = Measured::FunctionalConversionTableBuilder.new([ + Measured::Unit.new(:m), + Measured::Unit.new(:cm, value: "0.01 m"), + ]).to_h + + assert_equal Rational(100), table["m"]["cm"].call(Rational(1)) + assert_instance_of Rational, table["m"]["cm"].call(Rational(1)) + assert_equal Rational(50), table["m"]["cm"].call(BigDecimal("0.5")) + end + + test "#initialize with no cache argument defaults to Null and succeeds" do + assert_nothing_raised do + Measured::FunctionalConversionTableBuilder.new([Measured::Unit.new(:base)]) + end + end +end From 328f57904637d91f01a590dbf2012cf82b4f2007 Mon Sep 17 00:00:00 2001 From: Neethan Balaventhan Date: Mon, 9 Mar 2026 23:17:39 -0400 Subject: [PATCH 3/5] feat: wire FunctionalConversionTableBuilder into UnitSystem --- lib/measured/base.rb | 3 + lib/measured/unit_system.rb | 23 ++++++- test/unit_system_test.rb | 132 ++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) diff --git a/lib/measured/base.rb b/lib/measured/base.rb index 0e37cf2..d99005b 100644 --- a/lib/measured/base.rb +++ b/lib/measured/base.rb @@ -38,6 +38,7 @@ 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" @@ -46,7 +47,9 @@ def method_missing(method, *args) require "measured/unit" require "measured/unit_system" require "measured/unit_system_builder" +require "measured/conversion_table_builder_base" require "measured/conversion_table_builder" +require "measured/functional_conversion_table_builder" require "measured/cache/null" require "measured/cache/json_writer" require "measured/cache/json" diff --git a/lib/measured/unit_system.rb b/lib/measured/unit_system.rb index 5047c2c..e685d8c 100644 --- a/lib/measured/unit_system.rb +++ b/lib/measured/unit_system.rb @@ -8,14 +8,23 @@ def initialize(units, cache: nil) next unit unless unit.conversion_unit conversion_unit = @units.find { |u| u.names.include?(unit.conversion_unit) } next unit unless conversion_unit - unit.with(value: [unit.conversion_amount, conversion_unit.name]) + if unit.functional? + unit.with(value: [ + { forward: unit.conversion_amount, backward: unit.inverse_conversion_amount, description: unit.description }, + conversion_unit.name + ]) + else + unit.with(value: [unit.conversion_amount, conversion_unit.name]) + end end @unit_names = @units.map(&:name).sort.freeze @unit_names_with_aliases = @units.flat_map(&:names).sort.freeze @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) + + builder_class = @units.any?(&:functional?) ? Measured::FunctionalConversionTableBuilder : Measured::ConversionTableBuilder + @conversion_table_builder = builder_class.new(@units, cache: cache) @conversion_table = @conversion_table_builder.to_h.freeze end @@ -43,7 +52,15 @@ def convert(value, from:, to:) raise Measured::UnitError, "Cannot find conversion entry from #{from} to #{to}" unless conversion - value.to_r * conversion + if conversion.is_a?(Proc) + conversion.call(value.to_r) + else + value.to_r * conversion + end + end + + def functional? + @units.any?(&:functional?) end def update_cache diff --git a/test/unit_system_test.rb b/test/unit_system_test.rb index 9407050..27e25da 100644 --- a/test/unit_system_test.rb +++ b/test/unit_system_test.rb @@ -114,4 +114,136 @@ 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 uses FunctionalConversionTableBuilder when functional units present" do + c = Measured::Unit.new(:C) + k = Measured::Unit.new(:K, value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + + c_unit = system.unit_for!(:C) + k_unit = system.unit_for!(:K) + + assert_equal BigDecimal("0"), system.convert(BigDecimal("273.15"), from: k_unit, to: c_unit) + assert_equal BigDecimal("273.15"), system.convert(BigDecimal("0"), from: c_unit, to: k_unit) + end + + test "#initialize raises when caching functional unit system" do + assert_raises Measured::CacheError do + Measured.build do + unit :base_unit + unit :other, value: [{ forward: ->(x) { x }, backward: ->(x) { x } }, "base_unit"] + cache Measured::Cache::Json, "test.json" + end + end + end + + test "functional system #unit_names returns sorted unit names" do + c = Measured::Unit.new(:C) + k = Measured::Unit.new(:K, value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + assert_equal %w(C K), system.unit_names + end + + test "functional system #unit_names_with_aliases includes aliases" do + c = Measured::Unit.new(:C, aliases: [:celsius]) + k = Measured::Unit.new(:K, aliases: [:kelvin], value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + assert_equal %w(C K celsius kelvin), system.unit_names_with_aliases + end + + test "functional system #unit_for resolves aliases" do + c = Measured::Unit.new(:C, aliases: [:celsius]) + k = Measured::Unit.new(:K, aliases: [:kelvin], value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + assert_equal "K", system.unit_for(:kelvin).name + assert_equal "C", system.unit_for(:celsius).name + end + + test "functional system #convert raises for unknown unit" do + c = Measured::Unit.new(:C) + k = Measured::Unit.new(:K, value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + unit_bad = Measured::Unit.new(:doesnt_exist) + + assert_raises Measured::UnitError do + system.convert(1, from: system.unit_for!(:C), to: unit_bad) + end + end + + test "functional system #convert handles the same unit" do + c = Measured::Unit.new(:C) + k = Measured::Unit.new(:K, value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + c_unit = system.unit_for!(:C) + + assert_equal BigDecimal("100"), system.convert(BigDecimal("100"), from: c_unit, to: c_unit) + end + + test "functional system preserves description through unit reconstruction" do + c = Measured::Unit.new(:C) + k = Measured::Unit.new(:K, value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") }, description: "celsius + 273.15" }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + k_unit = system.unit_for!(:K) + + assert_equal "celsius + 273.15", k_unit.description + assert_equal "K (celsius + 273.15)", k_unit.to_s + end + + test "functional system #cached? returns false" do + c = Measured::Unit.new(:C) + k = Measured::Unit.new(:K, value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + refute_predicate system, :cached? + end + + test "#functional? returns true for systems with functional units" do + c = Measured::Unit.new(:C) + k = Measured::Unit.new(:K, value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + assert_predicate system, :functional? + end + + test "#functional? returns false for static systems" do + refute_predicate @conversion, :functional? + end + + test "functional system #convert propagates exceptions from procs" do + c = Measured::Unit.new(:C) + k = Measured::Unit.new(:K, value: [ + { forward: ->(k) { raise ArgumentError, "invalid value" }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ]) + system = Measured::UnitSystem.new([c, k]) + + assert_raises ArgumentError do + system.convert(BigDecimal("0"), from: system.unit_for!(:K), to: system.unit_for!(:C)) + end + end end From 970c87ad36305adae3cb984816eb8eb78b5bb869 Mon Sep 17 00:00:00 2001 From: Neethan Balaventhan Date: Mon, 9 Mar 2026 23:17:43 -0400 Subject: [PATCH 4/5] feat: add Temperature acceptance tests as proof of concept --- test/units/temperature_test.rb | 142 +++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 test/units/temperature_test.rb diff --git a/test/units/temperature_test.rb b/test/units/temperature_test.rb new file mode 100644 index 0000000..1544503 --- /dev/null +++ b/test/units/temperature_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "test_helper" + +Measured::Temperature = Measured.build do + unit :C, aliases: [:c, :celsius] + + unit :K, aliases: [:k, :kelvin], convert_to: "C", + forward: ->(k) { k - BigDecimal("273.15") }, + backward: ->(c) { c + BigDecimal("273.15") }, + description: "celsius + 273.15" + + unit :F, aliases: [:f, :fahrenheit], convert_to: "C", + forward: ->(f) { (f - 32) * Rational(5, 9) }, + backward: ->(c) { c * Rational(9, 5) + 32 }, + description: "celsius * 9/5 + 32" +end + +class Measured::TemperatureTest < ActiveSupport::TestCase + test ".unit_names should be the list of base unit names" do + assert_equal %w(C F K), 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" + assert_exact_conversion Measured::Temperature, "100 C", "373.15 K" + end + + test ".convert_to from C to F" do + assert_exact_conversion Measured::Temperature, "0 C", "32 F" + assert_exact_conversion Measured::Temperature, "100 C", "212 F" + assert_exact_conversion Measured::Temperature, "45 C", "113 F" + end + + test ".convert_to from F to C" do + assert_exact_conversion Measured::Temperature, "32 F", "0 C" + assert_exact_conversion Measured::Temperature, "212 F", "100 C" + assert_exact_conversion Measured::Temperature, "113 F", "45 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 + + test "identity conversion" do + temp = Measured::Temperature.new(100, :C) + assert_equal temp, temp.convert_to(:C) + end + + test "arithmetic works" do + a = Measured::Temperature.new(10, :C) + b = Measured::Temperature.new(20, :C) + assert_equal Measured::Temperature.new(30, :C), a + b + end + + test "comparison across units" do + boiling_c = Measured::Temperature.new(100, :C) + boiling_f = Measured::Temperature.new(212, :F) + assert_equal 0, boiling_c <=> boiling_f + end + + test "aliases resolve correctly" do + assert_equal Measured::Temperature.new(100, :C), Measured::Temperature.new(100, :celsius) + assert_equal Measured::Temperature.new(0, :K), Measured::Temperature.new(0, :kelvin) + assert_equal Measured::Temperature.new(32, :F), Measured::Temperature.new(32, :fahrenheit) + end + + test ".unit_names_with_aliases includes all aliases" do + expected = %w(C F K c celsius f fahrenheit k kelvin).sort + assert_equal expected, Measured::Temperature.unit_names_with_aliases + end + + test ".parse parses temperature strings" do + assert_equal Measured::Temperature.new(100, :C), Measured::Temperature.parse("100 C") + assert_equal Measured::Temperature.new(212, :F), Measured::Temperature.parse("212 F") + assert_equal Measured::Temperature.new("273.15", :K), Measured::Temperature.parse("273.15 K") + end + + test ".parse parses negative temperature strings" do + assert_equal Measured::Temperature.new(-40, :C), Measured::Temperature.parse("-40 C") + assert_equal Measured::Temperature.new(-40, :F), Measured::Temperature.parse("-40 F") + end + + test "to_s outputs the number and the unit" do + assert_equal "100 C", Measured::Temperature.new(100, :C).to_s + assert_equal "32 F", Measured::Temperature.new(32, :F).to_s + assert_equal "273.15 K", Measured::Temperature.new("273.15", :K).to_s + end + + test "negative value conversions" do + # -40 is the same in both C and F + assert_exact_conversion Measured::Temperature, "-40 C", "-40 F" + assert_exact_conversion Measured::Temperature, "-40 F", "-40 C" + end + + test "subtraction with same unit" do + a = Measured::Temperature.new(100, :C) + b = Measured::Temperature.new(30, :C) + assert_equal Measured::Temperature.new(70, :C), a - b + end + + test "comparison operators across units" do + freezing_c = Measured::Temperature.new(0, :C) + boiling_c = Measured::Temperature.new(100, :C) + body_f = Measured::Temperature.new("98.6", :F) + + assert boiling_c > body_f + assert freezing_c < body_f + end + + test "equality across units" do + assert_equal Measured::Temperature.new(0, :C), Measured::Temperature.new(32, :F) + assert_equal Measured::Temperature.new(0, :C), Measured::Temperature.new("273.15", :K) + end + + test "convert_to returns new object with correct value" do + temp = Measured::Temperature.new(100, :C) + converted = temp.convert_to(:F) + + assert_equal Measured::Temperature.new(212, :F), converted + assert_equal BigDecimal("100"), temp.value + assert_equal "C", temp.unit.name + end +end From c6f7bb1c96b9c854e173a4fa3023ff7ac150b643 Mon Sep 17 00:00:00 2001 From: Neethan Balaventhan Date: Mon, 9 Mar 2026 23:17:55 -0400 Subject: [PATCH 5/5] refactor: extract shared builder module, add cleaner DSL, fix type safety Extract ConversionTableBuilderBase module to DRY up cycle detection, graph validation, and conversion caching shared by both builders. Add convert_to:/forward:/backward:/description: kwargs to UnitSystemBuilder#unit as a cleaner alternative to the value: hash format. Fix IDENTITY lambda to preserve input types, coerce convert inputs to Rational for exact arithmetic, and use Rational(1) in comparable_amount. Add UnitSystem#functional? for introspection. --- lib/measured/conversion_table_builder.rb | 30 +--------- lib/measured/conversion_table_builder_base.rb | 34 +++++++++++ lib/measured/unit_system_builder.rb | 15 ++++- test/unit_system_builder_test.rb | 57 +++++++++++++++++++ 4 files changed, 106 insertions(+), 30 deletions(-) create mode 100644 lib/measured/conversion_table_builder_base.rb diff --git a/lib/measured/conversion_table_builder.rb b/lib/measured/conversion_table_builder.rb index 495acc3..c85c4cf 100644 --- a/lib/measured/conversion_table_builder.rb +++ b/lib/measured/conversion_table_builder.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true class Measured::ConversionTableBuilder - attr_reader :units + include Measured::ConversionTableBuilderBase def initialize(units, cache: nil) @units = units @@ -39,23 +39,6 @@ def generate_table end end - def validate_no_cycles - graph = units.select { |unit| unit.conversion_unit.present? }.group_by { |unit| unit.name } - validate_acyclic_graph(graph, from: graph.keys[0]) - end - - # This uses a depth-first search algorithm: https://en.wikipedia.org/wiki/Depth-first_search - def validate_acyclic_graph(graph, from:, visited: []) - graph[from]&.each do |edge| - adjacent_node = edge.conversion_unit - if visited.include?(adjacent_node) - raise Measured::CycleDetected.new(edge) - else - validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node]) - end - end - end - def find_conversion(to:, from:) conversion = find_direct_conversion_cached(to: to, from: from) || find_tree_traversal_conversion(to: to, from: from) @@ -64,17 +47,6 @@ def find_conversion(to:, from:) conversion end - def find_direct_conversion_cached(to:, from:) - @direct_conversion_cache ||= {} - @direct_conversion_cache[to] ||= {} - - if @direct_conversion_cache[to].key?(from) - @direct_conversion_cache[to][from] - else - @direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from) - end - end - def find_direct_conversion(to:, from:) units.each do |unit| return unit.conversion_amount if unit.name == from && unit.conversion_unit == to diff --git a/lib/measured/conversion_table_builder_base.rb b/lib/measured/conversion_table_builder_base.rb new file mode 100644 index 0000000..b760200 --- /dev/null +++ b/lib/measured/conversion_table_builder_base.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +module Measured::ConversionTableBuilderBase + attr_reader :units + + private + + def validate_no_cycles + graph = units.select { |unit| unit.conversion_unit.present? }.group_by(&:name) + validate_acyclic_graph(graph, from: graph.keys[0]) + end + + # This uses a depth-first search algorithm: https://en.wikipedia.org/wiki/Depth-first_search + def validate_acyclic_graph(graph, from:, visited: []) + graph[from]&.each do |edge| + adjacent_node = edge.conversion_unit + if visited.include?(adjacent_node) + raise Measured::CycleDetected.new(edge) + else + validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node]) + end + end + end + + def find_direct_conversion_cached(to:, from:) + @direct_conversion_cache ||= {} + @direct_conversion_cache[to] ||= {} + + if @direct_conversion_cache[to].key?(from) + @direct_conversion_cache[to][from] + else + @direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from) + end + end +end diff --git a/lib/measured/unit_system_builder.rb b/lib/measured/unit_system_builder.rb index 3edc6b5..d9e1c87 100644 --- a/lib/measured/unit_system_builder.rb +++ b/lib/measured/unit_system_builder.rb @@ -5,7 +5,8 @@ def initialize @cache = nil end - def unit(unit_name, aliases: [], value: nil) + def unit(unit_name, aliases: [], value: nil, convert_to: nil, forward: nil, backward: nil, description: nil) + value = build_functional_value(convert_to: convert_to, forward: forward, backward: backward, description: description) || value @units << build_unit(unit_name, aliases: aliases, value: value) nil end @@ -64,6 +65,18 @@ def build_unit(name, aliases: [], value: nil) unit end + def build_functional_value(convert_to:, forward:, backward:, description:) + return nil unless convert_to + + unless forward && backward + raise Measured::UnitError, "forward: and backward: are required when convert_to: is specified" + end + + opts = { forward: forward, backward: backward } + opts[:description] = description if description + [opts, convert_to] + end + def check_for_duplicate_unit_names!(unit) names = @units.flat_map(&:names) if names.any? { |name| unit.names.include?(name) } diff --git a/test/unit_system_builder_test.rb b/test/unit_system_builder_test.rb index 8700fe7..1da4d0d 100644 --- a/test/unit_system_builder_test.rb +++ b/test/unit_system_builder_test.rb @@ -136,6 +136,63 @@ class Measured::UnitSystemBuilderTest < ActiveSupport::TestCase assert_equal "lb", measurable.unit_system.unit_for!(:long_ton).conversion_unit end + test "#unit accepts convert_to:, forward:, backward: kwargs for functional conversions" do + measurable = Measured.build do + unit :C + unit :K, convert_to: "C", + forward: ->(k) { k - BigDecimal("273.15") }, + backward: ->(c) { c + BigDecimal("273.15") } + end + + assert_equal 2, measurable.unit_names.count + k_unit = measurable.unit_system.unit_for!(:K) + assert k_unit.functional? + assert_equal "C", k_unit.conversion_unit + end + + test "#unit accepts description: kwarg for functional conversions" do + measurable = Measured.build do + unit :C + unit :K, convert_to: "C", + forward: ->(k) { k - BigDecimal("273.15") }, + backward: ->(c) { c + BigDecimal("273.15") }, + description: "celsius + 273.15" + end + + k_unit = measurable.unit_system.unit_for!(:K) + assert_equal "celsius + 273.15", k_unit.description + end + + test "#unit raises when convert_to: is given without forward: and backward:" do + assert_raises Measured::UnitError do + Measured.build do + unit :C + unit :K, convert_to: "C", forward: ->(k) { k } + end + end + + assert_raises Measured::UnitError do + Measured.build do + unit :C + unit :K, convert_to: "C", backward: ->(c) { c } + end + end + end + + test "#unit still accepts value: with hash format for functional conversions" do + measurable = Measured.build do + unit :C + unit :K, value: [ + { forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } }, + "C" + ] + end + + assert_equal 2, measurable.unit_names.count + k_unit = measurable.unit_system.unit_for!(:K) + assert k_unit.functional? + end + test "#cache sets no cache by default" do measurable = Measured.build do unit :m