Skip to content

Commit 378c206

Browse files
committed
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, remove dead Proc branch from compute_inverse, and use Rational(1) in comparable_amount. Add UnitSystem#dynamic? for introspection.
1 parent f37218b commit 378c206

12 files changed

Lines changed: 246 additions & 82 deletions

lib/measured/base.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def method_missing(method, *args)
4747
require "measured/unit"
4848
require "measured/unit_system"
4949
require "measured/unit_system_builder"
50+
require "measured/conversion_table_builder_base"
5051
require "measured/conversion_table_builder"
5152
require "measured/dynamic_conversion_table_builder"
5253
require "measured/cache/null"

lib/measured/conversion_table_builder.rb

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22
class Measured::ConversionTableBuilder
3-
attr_reader :units
3+
include Measured::ConversionTableBuilderBase
44

55
def initialize(units, cache: nil)
66
@units = units
@@ -39,23 +39,6 @@ def generate_table
3939
end
4040
end
4141

42-
def validate_no_cycles
43-
graph = units.select { |unit| unit.conversion_unit.present? }.group_by { |unit| unit.name }
44-
validate_acyclic_graph(graph, from: graph.keys[0])
45-
end
46-
47-
# This uses a depth-first search algorithm: https://en.wikipedia.org/wiki/Depth-first_search
48-
def validate_acyclic_graph(graph, from:, visited: [])
49-
graph[from]&.each do |edge|
50-
adjacent_node = edge.conversion_unit
51-
if visited.include?(adjacent_node)
52-
raise Measured::CycleDetected.new(edge)
53-
else
54-
validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node])
55-
end
56-
end
57-
end
58-
5942
def find_conversion(to:, from:)
6043
conversion = find_direct_conversion_cached(to: to, from: from) || find_tree_traversal_conversion(to: to, from: from)
6144

@@ -64,17 +47,6 @@ def find_conversion(to:, from:)
6447
conversion
6548
end
6649

67-
def find_direct_conversion_cached(to:, from:)
68-
@direct_conversion_cache ||= {}
69-
@direct_conversion_cache[to] ||= {}
70-
71-
if @direct_conversion_cache[to].key?(from)
72-
@direct_conversion_cache[to][from]
73-
else
74-
@direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from)
75-
end
76-
end
77-
7850
def find_direct_conversion(to:, from:)
7951
units.each do |unit|
8052
return unit.conversion_amount if unit.name == from && unit.conversion_unit == to
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
module Measured::ConversionTableBuilderBase
3+
attr_reader :units
4+
5+
private
6+
7+
def validate_no_cycles
8+
graph = units.select { |unit| unit.conversion_unit.present? }.group_by(&:name)
9+
validate_acyclic_graph(graph, from: graph.keys[0])
10+
end
11+
12+
# This uses a depth-first search algorithm: https://en.wikipedia.org/wiki/Depth-first_search
13+
def validate_acyclic_graph(graph, from:, visited: [])
14+
graph[from]&.each do |edge|
15+
adjacent_node = edge.conversion_unit
16+
if visited.include?(adjacent_node)
17+
raise Measured::CycleDetected.new(edge)
18+
else
19+
validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node])
20+
end
21+
end
22+
end
23+
24+
def find_direct_conversion_cached(to:, from:)
25+
@direct_conversion_cache ||= {}
26+
@direct_conversion_cache[to] ||= {}
27+
28+
if @direct_conversion_cache[to].key?(from)
29+
@direct_conversion_cache[to][from]
30+
else
31+
@direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from)
32+
end
33+
end
34+
end

lib/measured/dynamic_conversion_table_builder.rb

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# frozen_string_literal: true
22
class Measured::DynamicConversionTableBuilder
3-
IDENTITY = ->(x) { x * Rational(1, 1) }
3+
include Measured::ConversionTableBuilderBase
44

5-
attr_reader :units
5+
IDENTITY = ->(x) { x }
66

77
def initialize(units, cache: nil)
88
@units = units
@@ -14,7 +14,7 @@ def initialize(units, cache: nil)
1414
end
1515

1616
def to_h
17-
generate_table
17+
@table ||= generate_table
1818
end
1919

2020
def update_cache
@@ -43,39 +43,12 @@ def generate_table
4343
end
4444
end
4545

46-
def validate_no_cycles
47-
graph = units.select { |unit| unit.conversion_unit.present? }.group_by(&:name)
48-
validate_acyclic_graph(graph, from: graph.keys[0])
49-
end
50-
51-
def validate_acyclic_graph(graph, from:, visited: [])
52-
graph[from]&.each do |edge|
53-
adjacent_node = edge.conversion_unit
54-
if visited.include?(adjacent_node)
55-
raise Measured::CycleDetected.new(edge)
56-
else
57-
validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node])
58-
end
59-
end
60-
end
61-
6246
def find_conversion(to:, from:)
6347
result = find_direct_conversion_cached(to: to, from: from) || find_tree_traversal_conversion(to: to, from: from)
6448
raise Measured::MissingConversionPath.new(from, to) unless result
6549
result
6650
end
6751

68-
def find_direct_conversion_cached(to:, from:)
69-
@direct_conversion_cache ||= {}
70-
@direct_conversion_cache[to] ||= {}
71-
72-
if @direct_conversion_cache[to].key?(from)
73-
@direct_conversion_cache[to][from]
74-
else
75-
@direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from)
76-
end
77-
end
78-
7952
def find_direct_conversion(to:, from:)
8053
units.each do |unit|
8154
if unit.name == from && unit.conversion_unit == to

lib/measured/unit.rb

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
class Measured::Unit
33
include Comparable
44

5-
attr_reader :name, :names, :aliases, :conversion_amount, :conversion_unit, :unit_system, :inverse_conversion_amount
5+
attr_reader :name, :names, :aliases, :conversion_amount, :conversion_unit, :unit_system, :inverse_conversion_amount, :description
66

77
def initialize(name, aliases: [], value: nil, unit_system: nil)
88
@name = name.to_s.freeze
@@ -58,12 +58,11 @@ def <=>(other)
5858
private
5959

6060
def comparable_amount(amount)
61-
amount.is_a?(Proc) ? amount.call(1) : amount
61+
amount.is_a?(Proc) ? amount.call(Rational(1)) : amount
6262
end
6363

6464
def compute_inverse(amount)
6565
return nil unless amount
66-
return amount if amount.is_a?(Proc) # inverse stored separately for dynamic
6766

6867
1 / amount
6968
end
@@ -72,7 +71,7 @@ def build_conversion_string
7271
return nil unless @conversion_amount || @conversion_unit
7372

7473
if @conversion_amount.is_a?(Proc)
75-
@description # may be nil — that's fine, to_s will just show name
74+
@description
7675
else
7776
"#{@conversion_amount} #{@conversion_unit}"
7877
end

lib/measured/unit_system.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def initialize(units, cache: nil)
1010
next unit unless conversion_unit
1111
if unit.dynamic?
1212
unit.with(value: [
13-
{ forward: unit.conversion_amount, backward: unit.inverse_conversion_amount },
13+
{ forward: unit.conversion_amount, backward: unit.inverse_conversion_amount, description: unit.description },
1414
conversion_unit.name
1515
])
1616
else
@@ -53,12 +53,16 @@ def convert(value, from:, to:)
5353
raise Measured::UnitError, "Cannot find conversion entry from #{from} to #{to}" unless conversion
5454

5555
if conversion.is_a?(Proc)
56-
conversion.call(value).to_r
56+
conversion.call(value.to_r)
5757
else
5858
value.to_r * conversion
5959
end
6060
end
6161

62+
def dynamic?
63+
@units.any?(&:dynamic?)
64+
end
65+
6266
def update_cache
6367
@conversion_table_builder.update_cache
6468
end

lib/measured/unit_system_builder.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ def initialize
55
@cache = nil
66
end
77

8-
def unit(unit_name, aliases: [], value: nil)
8+
def unit(unit_name, aliases: [], value: nil, convert_to: nil, forward: nil, backward: nil, description: nil)
9+
value = build_dynamic_value(convert_to: convert_to, forward: forward, backward: backward, description: description) || value
910
@units << build_unit(unit_name, aliases: aliases, value: value)
1011
nil
1112
end
@@ -64,6 +65,18 @@ def build_unit(name, aliases: [], value: nil)
6465
unit
6566
end
6667

68+
def build_dynamic_value(convert_to:, forward:, backward:, description:)
69+
return nil unless convert_to
70+
71+
unless forward && backward
72+
raise Measured::UnitError, "forward: and backward: are required when convert_to: is specified"
73+
end
74+
75+
opts = { forward: forward, backward: backward }
76+
opts[:description] = description if description
77+
[opts, convert_to]
78+
end
79+
6780
def check_for_duplicate_unit_names!(unit)
6881
names = @units.flat_map(&:names)
6982
if names.any? { |name| unit.names.include?(name) }

test/dynamic_conversion_table_builder_test.rb

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ class Measured::DynamicConversionTableBuilderTest < ActiveSupport::TestCase
2020
Measured::Unit.new(:base),
2121
]).to_h
2222

23-
assert table["base"]["base"].is_a?(Proc)
24-
assert_equal Rational(42, 1), table["base"]["base"].call(42)
23+
identity = table["base"]["base"]
24+
assert identity.is_a?(Proc)
25+
assert_equal 42, identity.call(42)
26+
assert_equal BigDecimal("42.5"), identity.call(BigDecimal("42.5"))
27+
assert_instance_of BigDecimal, identity.call(BigDecimal("42.5"))
2528
end
2629

2730
test "#to_h handles static units by wrapping them as procs" do
@@ -137,6 +140,49 @@ class Measured::DynamicConversionTableBuilderTest < ActiveSupport::TestCase
137140
end
138141
end
139142

143+
test "#to_h computes multi-hop paths across 4+ dynamic units" do
144+
table = Measured::DynamicConversionTableBuilder.new([
145+
Measured::Unit.new(:A),
146+
Measured::Unit.new(:B, value: [
147+
{ forward: ->(b) { b * 2 }, backward: ->(a) { a / 2 } },
148+
"A"
149+
]),
150+
Measured::Unit.new(:C, value: [
151+
{ forward: ->(c) { c * 3 }, backward: ->(b) { b / 3 } },
152+
"B"
153+
]),
154+
Measured::Unit.new(:D, value: [
155+
{ forward: ->(d) { d * 5 }, backward: ->(c) { c / 5 } },
156+
"C"
157+
]),
158+
]).to_h
159+
160+
# D -> A requires 3 hops: D -> C -> B -> A
161+
# D=1 -> C=5 -> B=15 -> A=30
162+
assert_equal Rational(30), table["D"]["A"].call(1)
163+
# A -> D is the reverse: A=30 -> B=15 -> C=5 -> D=1
164+
assert_equal Rational(1), table["A"]["D"].call(30)
165+
166+
%w(A B C D).each do |from|
167+
%w(A B C D).each do |to|
168+
assert table[from][to].is_a?(Proc), "Missing table[#{from}][#{to}]"
169+
result = table[to][from].call(table[from][to].call(Rational(7)))
170+
assert_equal Rational(7), result, "Round-trip failed for #{from} -> #{to} -> #{from}"
171+
end
172+
end
173+
end
174+
175+
test "#to_h wraps static Rational amounts as procs preserving type" do
176+
table = Measured::DynamicConversionTableBuilder.new([
177+
Measured::Unit.new(:m),
178+
Measured::Unit.new(:cm, value: "0.01 m"),
179+
]).to_h
180+
181+
assert_equal Rational(100), table["m"]["cm"].call(Rational(1))
182+
assert_instance_of Rational, table["m"]["cm"].call(Rational(1))
183+
assert_equal Rational(50), table["m"]["cm"].call(BigDecimal("0.5"))
184+
end
185+
140186
test "#initialize with no cache argument defaults to Null and succeeds" do
141187
assert_nothing_raised do
142188
Measured::DynamicConversionTableBuilder.new([Measured::Unit.new(:base)])

test/unit_system_builder_test.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,63 @@ class Measured::UnitSystemBuilderTest < ActiveSupport::TestCase
136136
assert_equal "lb", measurable.unit_system.unit_for!(:long_ton).conversion_unit
137137
end
138138

139+
test "#unit accepts convert_to:, forward:, backward: kwargs for dynamic conversions" do
140+
measurable = Measured.build do
141+
unit :C
142+
unit :K, convert_to: "C",
143+
forward: ->(k) { k - BigDecimal("273.15") },
144+
backward: ->(c) { c + BigDecimal("273.15") }
145+
end
146+
147+
assert_equal 2, measurable.unit_names.count
148+
k_unit = measurable.unit_system.unit_for!(:K)
149+
assert k_unit.dynamic?
150+
assert_equal "C", k_unit.conversion_unit
151+
end
152+
153+
test "#unit accepts description: kwarg for dynamic conversions" do
154+
measurable = Measured.build do
155+
unit :C
156+
unit :K, convert_to: "C",
157+
forward: ->(k) { k - BigDecimal("273.15") },
158+
backward: ->(c) { c + BigDecimal("273.15") },
159+
description: "celsius + 273.15"
160+
end
161+
162+
k_unit = measurable.unit_system.unit_for!(:K)
163+
assert_equal "celsius + 273.15", k_unit.description
164+
end
165+
166+
test "#unit raises when convert_to: is given without forward: and backward:" do
167+
assert_raises Measured::UnitError do
168+
Measured.build do
169+
unit :C
170+
unit :K, convert_to: "C", forward: ->(k) { k }
171+
end
172+
end
173+
174+
assert_raises Measured::UnitError do
175+
Measured.build do
176+
unit :C
177+
unit :K, convert_to: "C", backward: ->(c) { c }
178+
end
179+
end
180+
end
181+
182+
test "#unit still accepts value: with hash format for dynamic conversions" do
183+
measurable = Measured.build do
184+
unit :C
185+
unit :K, value: [
186+
{ forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } },
187+
"C"
188+
]
189+
end
190+
191+
assert_equal 2, measurable.unit_names.count
192+
k_unit = measurable.unit_system.unit_for!(:K)
193+
assert k_unit.dynamic?
194+
end
195+
139196
test "#cache sets no cache by default" do
140197
measurable = Measured.build do
141198
unit :m

0 commit comments

Comments
 (0)