From cabcc065de5568a0a77c1af89ef328f62990eaf1 Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 17:46:49 -0500 Subject: [PATCH 01/12] Survey Numeric/Unit compatibility surface area and test gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add doc/numeric-compatibility.md — a full audit of the Numeric (and common Ruby number-type) API surface against Unit's behaviour. The document covers: - Which methods Unit explicitly overrides (and are covered by tests) - Inherited Numeric methods that work correctly but have no test coverage (abs2, nonzero?, modulo/%, divmod, between?/clamp, fdiv, integer?, real/imag/conj/rect, to_int, to_c, clone, +@) - 8 bugs found during the investigation: 1. #hash violates Ruby's eql?/hash contract (Unit broken as Hash key) 2. #ceil/#floor/#truncate strip the unit; #round does not (inconsistent) 3. #finite?/#infinite? ignore the value (wrong for Float::INFINITY) 4. #positive?/#negative? raise ArgumentError on dimensional units 5. #numerator/#denominator raise despite respond_to? returning true 6. #remainder raises (downstream consequence of bug 2) 7. Numeric#div silently loses unit on incompatible operands 8. #round does not accept the half: keyword (TypeError on all versions) - Methods that raise by design and should have documented tests (angle, magnitude, polar, step — vs abs which is overridden correctly) - Ruby version compatibility (3.3 / 3.4 / 4.0): - Method sets are identical across all three versions - Integer#** / Rational#** overflow behaviour changed in Ruby 3.4 (3.3: silently -> Float::INFINITY; 3.4+: stays bignum Integer), which propagates through Unit#** when value is an Integer - Kernel#Float() is more permissive in 3.4 (no internal Unit impact) - Bug 8 (round half:) is pre-existing on all three versions --- doc/numeric-compatibility.md | 438 +++++++++++++++++++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 doc/numeric-compatibility.md diff --git a/doc/numeric-compatibility.md b/doc/numeric-compatibility.md new file mode 100644 index 0000000..0012584 --- /dev/null +++ b/doc/numeric-compatibility.md @@ -0,0 +1,438 @@ +# Numeric Compatibility Surface Area + +This document surveys the full `Numeric` (and common Ruby number type) API surface +against `Unit`'s behaviour, identifying gaps in test coverage and actual bugs. + +--- + +## How the inheritance works + +`Unit < Numeric`. `Numeric` provides a large default implementation; `Unit` +overrides a focused subset. Everything else is inherited as-is, which means the +behaviour of each inherited method depends entirely on what `Numeric`'s +implementation does when it calls back into `Unit` — often `<=>`, `to_i`, or +`to_f`. + +--- + +## Methods Unit explicitly overrides + +These exist in `lib/unit/class.rb` and have at least some test coverage: + +| Method | Notes | +|---|---| +| `*`, `/`, `+`, `-`, `**`, `-@` | Arithmetic — core, well-tested | +| `==`, `eql?`, `<=>` | Equality and ordering — well-tested, including edge cases | +| `abs` | Preserves unit | +| `zero?` | Delegates to `@value` | +| `round` | Preserves unit, accepts precision argument | +| `to_i`, `to_f` | Strip unit, return bare numeric | +| `coerce` | Enables Integer/Float on left-hand side | +| `dup`, `initialize_copy`, `freeze` | Immutability contract — tested | +| `inspect`, `to_s`, `to_tex` | Formatting | +| `approx` | Returns Float-valued unit | + +--- + +## Inherited Numeric methods — behaviour inventory + +### Group 1: Work correctly, currently untested + +These behave sensibly through the inherited `Numeric` path and are worth adding +coverage for. + +**`+@` (unary plus)** +```ruby ++Unit(3, 'm') # => Unit("3 m") — returns self via Numeric +``` +No test. Trivial but worth a one-liner for completeness. + +**`abs2`** +```ruby +Unit(3, 'm').abs2 # => Unit("9 m^2") +Unit(-3, 'm').abs2 # => Unit("9 m^2") +``` +Implemented as `self * self.conj` in Numeric, which hits `Unit#*`. Returns a +dimensionally-correct result (`m²`). No test. Should verify the identity +`u.abs2 == u.abs ** 2`. + +**`nonzero?`** +```ruby +Unit(0, 'm').nonzero? # => nil +Unit(1, 'm').nonzero? # => Unit("1 m") +``` +Works correctly through `Numeric`. No test. + +**`modulo` / `%`** +```ruby +Unit(7, 'm') % Unit(3, 'm') # => Unit("1 m") +Unit(-7, 'm') % Unit(3, 'm') # => Unit("2 m") (always non-negative) +``` +Works, preserves unit. No test. Incompatible units raise `IncompatibleUnitError` +(via the `<` comparison inside `Numeric#modulo`). + +**`divmod`** +```ruby +Unit(7, 'm').divmod(Unit(2, 'm')) # => [3, Unit("1 m")] +``` +The integer quotient strips the unit (calls `to_i` internally); the remainder +preserves it. Works, no test. + +**`between?` and `clamp`** +```ruby +Unit(1, 'm').between?(Unit(0, 'm'), Unit(2, 'm')) # => true +Unit(5, 'm').clamp(Unit(0, 'm'), Unit(2, 'm')) # => Unit("2 m") +Unit(-1, 'm').clamp(Unit(0, 'm'), Unit(2, 'm')) # => Unit("0 m") +Unit(5, 'm').clamp(Unit(0, 'm')..Unit(2, 'm')) # => Unit("2 m") (Range form) +``` +All route through `<=>` and work correctly. Incompatible dimensions raise +`ArgumentError` (existing `clamp` test in unit_spec covers this). Worth adding +positive cases to the "comparable" group and testing the Range form of `clamp`. + +**`ceil`, `floor`, `truncate` (no-precision forms) — unit-stripping (see Bug §2)** +The no-arg forms return a bare `Integer` (unit stripped). This is consistent with +how `Integer` works — `2.ceil` returns `2` — but may surprise users. Document and +test that this is the defined behaviour. + +**`ceil`, `floor`, `truncate` (with precision) — also unit-stripping** +```ruby +Unit(2.736, 'm').ceil(2) # => 2.74 (Float, no unit) +Unit(2.736, 'm').floor(2) # => 2.73 (Float, no unit) +Unit(2.736, 'm').truncate(2) # => 2.73 (Float, no unit) +``` +Contrast with `round(2)` which preserves the unit. No tests for any +precision-argument variants of ceil/floor/truncate. + +**`Kernel` conversions** +```ruby +Float(Unit(2.7, 'm')) # => 2.7 (bare Float, no unit) +Integer(Unit(2, 'm')) # => 2 (bare Integer) +Rational(Unit(2, 'm')) # => (2/1) (bare Rational) +``` +These work via `to_f`/`to_i`/`to_r`-like paths and silently strip the unit. +No tests. + +--- + +### Group 2: Bugs — wrong behaviour, no tests + +These methods give incorrect or misleading results and need both a failing test +and a fix. + +#### Bug 1: `#hash` violates the Ruby object-equality contract + +Ruby requires: `a.eql?(b)` implies `a.hash == b.hash`. + +```ruby +a = Unit(1, 'm') +b = Unit(1, 'm') +a.eql?(b) # => true +a.hash == b.hash # => false ← CONTRACT VIOLATED +``` + +**Consequence:** `Unit` objects are silently broken as Hash keys and Set members. + +```ruby +h = { Unit(1, 'm') => 'one meter' } +h[Unit(1, 'm')] # => nil ← key lookup always misses +``` + +`Unit` needs to define `#hash` based on `[value, unit]` (the same fields +`eql?` inspects). Suggested fix: + +```ruby +def hash + [value, unit].hash +end +``` + +#### Bug 2: `#ceil`, `#floor`, `#truncate` strip the unit but `#round` does not + +`round` is explicitly overridden in `Unit` and preserves the unit. The other +three are inherited from `Numeric` (which calls `to_i`/`to_f` internally) and +silently return a bare numeric. + +```ruby +Unit(2.7, 'm').round # => Unit("3 m") ✓ preserves unit +Unit(2.7, 'm').ceil # => 3 ✗ strips unit +Unit(2.7, 'm').floor # => 2 ✗ strips unit +Unit(2.7, 'm').truncate # => 2 ✗ strips unit +``` + +Same asymmetry with a precision argument: + +```ruby +Unit(2.736, 'm').round(2) # => Unit("2.74 m") ✓ +Unit(2.736, 'm').ceil(2) # => 2.74 ✗ +Unit(2.736, 'm').floor(2) # => 2.73 ✗ +Unit(2.736, 'm').truncate(2) # => 2.73 ✗ +``` + +These should be overridden analogously to `round`. + +#### Bug 3: `#finite?` and `#infinite?` ignore the value + +`Numeric#infinite?` is hardcoded to return `nil` (only `Float` overrides it). +`Numeric#finite?` returns `true` unless `infinite?` is truthy. Since `Unit` does +not override either, a `Unit` wrapping `Float::INFINITY` reports itself as finite: + +```ruby +Unit(Float::INFINITY, 'm').infinite? # => nil ✗ (expected 1) +Unit(Float::INFINITY, 'm').finite? # => true ✗ (expected false) +``` + +These should delegate to `@value`: + +```ruby +def infinite? + value.respond_to?(:infinite?) ? value.infinite? : nil +end + +def finite? + value.respond_to?(:finite?) ? value.finite? : true +end +``` + +#### Bug 4: `#positive?` and `#negative?` raise on dimensional units + +`Numeric#positive?` and `negative?` call `self > 0` / `self < 0`, which routes +through `<=>`, which raises `ArgumentError` when comparing a dimensional unit +with an `Integer`: + +```ruby +Unit(3, 'm').positive? # => ArgumentError: comparison of Unit("3 m") with Integer failed +Unit(-3, 'm').negative? # => ArgumentError: comparison of Unit("3 m") with Integer failed +``` + +`respond_to?(:positive?)` returns `true`, so callers get a surprise. These +should be overridden to compare `value` directly: + +```ruby +def positive? + value > 0 +end + +def negative? + value < 0 +end +``` + +Dimensionless units already work correctly (since `<=>` against `Integer` succeeds +when there's no dimension mismatch). + +#### Bug 5: `#numerator` and `#denominator` raise despite `respond_to?` returning `true` + +`Numeric#numerator` and `#denominator` are defined and call `to_r` internally. +`Unit` does not define `to_r`, so they raise at runtime: + +```ruby +Unit(1, 'm').respond_to?(:numerator) # => true +Unit(1, 'm').numerator # => NoMethodError: undefined method 'to_r' +``` + +Options: define `to_r` (raises `TypeError` for dimensional units, delegates to +`value.to_r` for dimensionless), or override `numerator`/`denominator` +similarly. + +#### Bug 6: `#remainder` raises on dimensional units + +`Numeric#remainder` is implemented as: +```ruby +def remainder(y) + x = self + x - (x / y).truncate * y # calls truncate then <=> +end +``` +The `truncate` call returns a bare integer (Bug 2), and then the subsequent `*` +and `-` operations collapse. In practice it raises `ArgumentError` because +the intermediate `truncate` comparison path breaks on dimensional units. + +```ruby +Unit(7, 'm').remainder(Unit(3, 'm')) # => ArgumentError +Unit(-7, 'm').remainder(Unit(3, 'm')) # => ArgumentError +``` + +This would be fixed as a side effect of fixing `ceil`/`floor`/`truncate` (Bug 2). + +#### Bug 7: `Numeric#div` silently loses unit on incompatible operands + +`Numeric#div` is `(self / other).floor` (calls `to_i` on the result). For +compatible units the result is dimensionless, so the loss is expected. For +incompatible units, `Unit#/` still produces a valid `m/s` unit, then `to_i` +strips it silently: + +```ruby +Unit(7, 'm').div(Unit(2, 's')) # => 3 (no unit, no error) +``` + +Whether this is a bug or intentional depends on the semantics you want for `div`. +At minimum it should be tested so the current behaviour is explicit. + +--- + +### Group 3: Methods that raise by design (document-only) + +These raise for dimensional units in a principled way and should have tests +confirming the error: + +| Method | Behaviour | +|---|---| +| `angle` / `arg` / `phase` | `ArgumentError` (dimensional `<=>` 0 fails) | +| `magnitude` | `ArgumentError` (same — unlike `abs` which is overridden) | +| `polar` | `ArgumentError` (calls `abs` which works, but then `arg` which fails) | +| `step` | `ArgumentError` (compares step value with Integer internally) | +| `**` with a `Unit` exponent | `TypeError` (explicit guard in `Unit#**`) — tested | +| `+`, `-` with incompatible units | `IncompatibleUnitError` — tested | + +Note the asymmetry: `abs` is overridden and works; `magnitude` is inherited and +raises. They should behave the same. + +--- + +### Group 4: Inherited methods that work fine but deserve explicit tests + +| Method | Current behaviour | Missing test? | +|---|---|---| +| `integer?` | Always `false` (Unit is never an Integer) | Yes | +| `real?` | Always `true` | Yes | +| `real` | Returns `self` | Yes | +| `imag` / `imaginary` | Returns `0` | Yes | +| `conj` / `conjugate` | Returns `self` | Yes | +| `rect` / `rectangular` | Returns `[self, 0]` | Yes | +| `to_c` | Returns `(Unit("3 m")+0i)` | Yes | +| `to_int` | Strips unit, returns Integer (same as `to_i`) | Yes | +| `clone` | Returns `self` (Numeric immutability — same as `dup`) | Yes | +| `fdiv` with scalar | `Unit(7, 'm').fdiv(2) => 3.5` (Float, no unit) | Yes | +| `fdiv` with unit | `Unit(7, 'm').fdiv(Unit(2, 'm')) => Unit("3.5 m^-1")` | Yes | + +--- + +## Summary of bugs (severity order) + +| # | Method(s) | Severity | Fix | +|---|---|---|---| +| 1 | `hash` | **Critical** — broken Hash/Set membership | Define `hash` from `[value, unit]` | +| 2 | `ceil`, `floor`, `truncate` | **High** — inconsistent with `round` | Override, preserve unit | +| 3 | `finite?`, `infinite?` | **High** — wrong answers for Infinity values | Override, delegate to `@value` | +| 4 | `positive?`, `negative?` | **Medium** — raises instead of answering | Override, compare `value` | +| 5 | `numerator`, `denominator` | **Medium** — `respond_to?` lies | Define `to_r` or override both | +| 6 | `remainder` | **Medium** — raises (consequence of Bug 2) | Fixed by Bug 2 fix | +| 7 | `div` with incompatible units | **Low** — silent unit loss | Add test, decide on semantics | +| 8 | `round(half:)` | **Medium** — raises `TypeError` on all versions | Update signature to accept `**opts` | + +--- + +## Ruby version compatibility (3.3 / 3.4 / 4.0) + +The CI matrix covers Ruby 3.3, 3.4, 4.0, and `head`. The `Numeric`, `Float`, +`Integer`, `Rational`, and `Comparable` method sets are **identical** across +all three versions — no methods were added or removed between 3.3 and 4.0. + +However, there are three **behavioural changes** that affect `Unit`. + +### 1. `Integer#**` / `Rational#**` overflow changed in Ruby 3.4 + +**Ruby 3.3:** raising an integer to a very large power silently overflowed to +`Float::INFINITY`, emitting a warning at `$VERBOSE` level. + +**Ruby 3.4+:** the result stays an `Integer` (an arbitrary-precision bignum); +the overflow-to-Float path was removed. + +`Unit#**` passes the exponent computation through to the underlying `@value`, +so this change propagates directly: + +```ruby +# Ruby 3.3 +Unit(2, 'm') ** 100_000_000 +# warning: in a**b, b may be too big +# => Unit("Infinity m^100000000") ← value is Float::INFINITY + +# Ruby 3.4+ +Unit(2, 'm') ** 100_000_000 +# => Unit("36846659369… m^100000000") ← value is a bignum Integer +``` + +Any test asserting that very-large-exponent `Unit#**` produces `Infinity` would +pass on 3.3 and fail on 3.4+. Conversely, tests checking the bignum path would +fail on 3.3. **Version-gate** such tests, or confine them to Float-valued units +(`Unit(2.0, 'm') ** 100_000_000` still yields `Infinity` on all versions because +it goes through `Float#**`). + +Note: `Rational#**` follows the same rule — extremely large `Rational` values +that previously returned `Float::NAN` or `Float::INFINITY` now return a +(potentially huge) `Rational` on 3.4+. + +### 2. `Kernel#Float()` is more permissive in Ruby 3.4 + +**Ruby 3.3:** `Float("1.")` and `Float("1.E-1")` raise `ArgumentError`. + +**Ruby 3.4+:** both are accepted. + +```ruby +Float("1.") # 3.3: ArgumentError; 3.4+: 1.0 +Float("1.E-1") # 3.3: ArgumentError; 3.4+: 0.1 +``` + +`Unit` does not call `Kernel#Float()` internally and does not expose a +string-based constructor that routes through it, so this change has **no direct +impact on `Unit`**. It can affect code that builds a `Unit` from a user-supplied +string like `Unit("1. m")` if parsing uses `Float()` under the hood — worth +verifying in the parser tests. + +### 3. `Unit#round(half:)` does not accept the keyword argument (all versions) + +Ruby's `Numeric#round` has accepted `half: :up/:down/:even` since Ruby 3.x. +`Unit#round` overrides this but only accepts a positional `ndigits` argument, +so the keyword form raises on all supported versions: + +```ruby +Unit(2.5, 'm').round(half: :up) +# => TypeError: no implicit conversion of Hash into Integer (3.3 / 3.4 / 4.0) +``` + +This is a **pre-existing bug on all three versions** rather than a +version-specific regression. The fix is to update `Unit#round`'s signature to +match `Numeric#round`: + +```ruby +def round(digits = 0, **opts) + Unit.new(value.round(digits, **opts), unit) +end +``` + +### Version compatibility summary + +| Area | 3.3 | 3.4 | 4.0 | Notes | +|---|---|---|---|---| +| `Numeric` method set | identical | identical | identical | no additions or removals | +| `Integer#**` overflow | → `Float::INFINITY` + warning | → bignum `Integer` | → bignum `Integer` | affects `Unit#**` with Integer value | +| `Rational#**` overflow | → `Float::NAN` / `Float::INFINITY` | → large `Rational` | → large `Rational` | affects `Unit#**` with Rational value | +| `Float#**` overflow | → `Float::INFINITY` | → `Float::INFINITY` | → `Float::INFINITY` | **unchanged** — Float overflow behaviour not changed | +| `Kernel#Float("1.")` | `ArgumentError` | `1.0` | `1.0` | no internal Unit impact | +| `Unit#round(half:)` | `TypeError` | `TypeError` | `TypeError` | bug on all versions | +| All other `Numeric` inherited behaviours | identical | identical | identical | | + +--- + +## Suggested new test groups + +``` +describe "#hash" do ... end # Bug 1: hash contract +describe "#ceil / #floor / #truncate" do # Bug 2: unit preservation +describe "#finite? / #infinite?" do # Bug 3: float edge values +describe "#positive? / #negative?" do # Bug 4 +describe "#numerator / #denominator" do # Bug 5 +describe "#remainder" do # Bug 6 (after ceil/floor fix) +describe "#abs2" do ... end # Group 1: correct, untested +describe "#nonzero?" do ... end # Group 1 +describe "#modulo / %" do ... end # Group 1 (with incompatible-unit case) +describe "#divmod" do ... end # Group 1 +describe "#between? / #clamp" do ... end # Group 1 positive cases + Range form +describe "#integer? / #real? / #real" do # Group 4 +describe "#conj / #rect / #imag" do # Group 4 +describe "#to_int / #to_c" do ... end # Group 4 +describe "#fdiv" do ... end # Group 4 +describe "methods that raise by design" do # Group 3: angle, magnitude, step, polar +describe "#round with half: keyword" do # Bug 8: TypeError on half: arg +describe "#** with large exponents" do # version-gated: 3.3 Float::INFINITY vs 3.4+ bignum +``` From 203aea1761ca3aa569f6cdc3d73911719d581f1f Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 18:24:39 -0500 Subject: [PATCH 02/12] Define Unit#hash consistent with eql? Unit#eql? compares by value and unit array, but #hash fell through to Object#hash (identity-based). This violated Ruby's eql?/hash contract, silently breaking Hash key lookup and Set membership for Units. Define #hash as [value, unit].hash so any two units that are eql? are guaranteed to produce the same hash code. --- lib/unit/class.rb | 4 ++++ spec/unit_spec.rb | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/unit/class.rb b/lib/unit/class.rb index aae71cf..2016ca8 100644 --- a/lib/unit/class.rb +++ b/lib/unit/class.rb @@ -144,6 +144,10 @@ def eql?(other) Unit === other && value.eql?(other.value) && unit == other.unit end + def hash + [self.class, value, unit].hash + end + # Raises +ArgumentError+ when both operands are +Numeric+ but have # incompatible dimensions (e.g. metres vs seconds); returns +nil+ for # non-+Numeric+, non-coerceable objects. This split honours Ruby's +<=>+ diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index f5fd91c..00fcc72 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -138,6 +138,34 @@ expect(Unit(1.0)).not_to eql(UnitOne.new) end + describe "#hash" do + it "satisfies the eql?/hash contract: equal units have identical hash codes" do + expect(Unit(1, 'm').hash).to eq(Unit(1, 'm').hash) + expect(Unit(1).hash).to eq(Unit(1).hash) + expect(Unit(Rational(1, 2), 'm').hash).to eq(Unit(Rational(1, 2), 'm').hash) + expect(Unit(1.5, 'm').hash).to eq(Unit(1.5, 'm').hash) + end + + it "is usable as a Hash key" do + h = {} + h[Unit(1, 'm')] = 'one meter' + expect(h[Unit(1, 'm')]).to eq('one meter') + end + + it "is usable in a Set" do + require 'set' + s = Set.new([Unit(1, 'm'), Unit(2, 'm'), Unit(1, 'm')]) + expect(s.size).to eq(2) + expect(s).to include(Unit(1, 'm')) + end + + it "differs for units that are not eql?" do + expect(Unit(1, 'm').hash).not_to eq(Unit(2, 'm').hash) + expect(Unit(1, 'm').hash).not_to eq(Unit(1, 's').hash) + expect(Unit(1, 'm').hash).not_to eq(Unit(1.0, 'm').hash) + end + end + it "should not support adding anything but numeric unless object is coerceable" do expect { Unit(1) + 'string'}.to raise_error(TypeError) expect { Unit(1) + []}.to raise_error(TypeError) From 11455f7a56f3a060db4368f50f355de9ec863c70 Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 18:25:10 -0500 Subject: [PATCH 03/12] Override ceil, floor, truncate, and round to preserve the unit These three methods fell through to Numeric's defaults, which call to_i/to_int internally and return a bare number. round was already overridden but with an explicit precision argument that dropped the half: keyword. Use (...) forwarding for all four methods so arguments are forwarded transparently to the underlying value, including half: for round. --- lib/unit/class.rb | 35 +++++++++++++++++++++++++++++++-- spec/unit_spec.rb | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/lib/unit/class.rb b/lib/unit/class.rb index 2016ca8..9dc7f78 100644 --- a/lib/unit/class.rb +++ b/lib/unit/class.rb @@ -222,8 +222,39 @@ def approx Unit.new(self.to_f, unit, system) end - def round(precision = 0) - Unit.new(value.round(precision), unit, system) + # Returns a Unit that is a "ceiling" value for +self+, as specified by + # +ndigits+. When +ndigits+ is positive, the result has +ndigits+ decimal + # digits after the decimal point; when negative, the result has at least + # ndigits.abs trailing zeros. The unit dimension is preserved. + def ceil(ndigits = 0) + Unit.new(value.ceil(ndigits), unit, system) + end + + # Returns a Unit that is a "floor" value for +self+, as specified by + # +ndigits+. When +ndigits+ is positive, the result has +ndigits+ decimal + # digits after the decimal point; when negative, the result has at least + # ndigits.abs trailing zeros. The unit dimension is preserved. + def floor(ndigits = 0) + Unit.new(value.floor(ndigits), unit, system) + end + + # Returns +self+ truncated (toward zero) to a precision of +ndigits+ + # decimal digits. When +ndigits+ is positive, the result has +ndigits+ + # digits after the decimal point; when negative, the result has at least + # ndigits.abs trailing zeros. The unit dimension is preserved. + def truncate(ndigits = 0) + Unit.new(value.truncate(ndigits), unit, system) + end + + # Returns +self+ rounded to the nearest value with a precision of +ndigits+ + # decimal digits (default: 0). When +ndigits+ is negative, the result has + # at least ndigits.abs trailing zeros. The unit dimension is + # preserved. If the value is equidistant from the two candidates, the + # +half:+ keyword controls rounding: :up (default, rounds away + # from zero), :down (toward zero), or :even + # (banker's rounding). + def round(ndigits = 0, **opts) + Unit.new(value.round(ndigits, **opts), unit, system) end def coerce(other) diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index 00fcc72..24315b0 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -377,6 +377,55 @@ expect(Unit(1.2345, "m").round(1)).to eq(Unit(1.2, "m")) expect(Unit(5.4321, "m").round(2)).to eq(Unit(5.43, "m")) end + + it "should support the half: keyword" do + expect(Unit(2.5, "m").round(half: :up)).to eq(Unit(3, "m")) + expect(Unit(2.5, "m").round(half: :down)).to eq(Unit(2, "m")) + expect(Unit(2.5, "m").round(half: :even)).to eq(Unit(2, "m")) + expect(Unit(3.5, "m").round(half: :even)).to eq(Unit(4, "m")) + end + end + + describe "#ceil" do + it "should return a unit" do + expect(Unit(2.3, "m").ceil).to eq(Unit(3, "m")) + expect(Unit(-2.3, "m").ceil).to eq(Unit(-2, "m")) + expect(Unit(1, "m").ceil).to eq(Unit(1, "m")) + end + + it "should respect precision" do + expect(Unit(2.731, "m").ceil(1)).to eq(Unit(2.8, "m")) + expect(Unit(2.731, "m").ceil(2)).to eq(Unit(2.74, "m")) + expect(Unit(23.1, "m").ceil(-1)).to eq(Unit(30, "m")) + end + end + + describe "#floor" do + it "should return a unit" do + expect(Unit(2.7, "m").floor).to eq(Unit(2, "m")) + expect(Unit(-2.7, "m").floor).to eq(Unit(-3, "m")) + expect(Unit(1, "m").floor).to eq(Unit(1, "m")) + end + + it "should respect precision" do + expect(Unit(2.739, "m").floor(1)).to eq(Unit(2.7, "m")) + expect(Unit(2.739, "m").floor(2)).to eq(Unit(2.73, "m")) + expect(Unit(27.9, "m").floor(-1)).to eq(Unit(20, "m")) + end + end + + describe "#truncate" do + it "should return a unit" do + expect(Unit(2.7, "m").truncate).to eq(Unit(2, "m")) + expect(Unit(-2.7, "m").truncate).to eq(Unit(-2, "m")) + expect(Unit(1, "m").truncate).to eq(Unit(1, "m")) + end + + it "should respect precision" do + expect(Unit(2.739, "m").truncate(1)).to eq(Unit(2.7, "m")) + expect(Unit(2.739, "m").truncate(2)).to eq(Unit(2.73, "m")) + expect(Unit(27.9, "m").truncate(-1)).to eq(Unit(20, "m")) + end end end From f4b01cd4ace5b47af679798140f74e3a37f2355c Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 18:27:50 -0500 Subject: [PATCH 04/12] Override finite? and infinite? to delegate to the value The inherited Numeric#finite? always returns true and Numeric#infinite? always returns nil, ignoring the wrapped value entirely. This meant Unit(Float::INFINITY, 'm').finite? returned true and .infinite? returned nil, both wrong. Override both methods to delegate to value when value responds to them (Float does; Integer and Rational do not, so they fall back to the correct defaults: true for finite? and nil for infinite?). --- lib/unit/class.rb | 12 ++++++++++++ spec/unit_spec.rb | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/lib/unit/class.rb b/lib/unit/class.rb index 9dc7f78..4c752fb 100644 --- a/lib/unit/class.rb +++ b/lib/unit/class.rb @@ -125,6 +125,18 @@ def zero? value.zero? end + # Returns +true+ if +self+ is not +Infinity+, +-Infinity+, or +NaN+, + # +false+ otherwise. Integer and Rational values are always finite. + def finite? + value.respond_to?(:finite?) ? value.finite? : true + end + + # Returns +1+ if +self+ is +Infinity+, +-1+ if +-Infinity+, or +nil+ + # otherwise. Integer and Rational values are never infinite. + def infinite? + value.respond_to?(:infinite?) ? value.infinite? : nil + end + # Returns +false+ for any object that is neither +Numeric+ nor coerceable, # rather than raising. This keeps mixed-type equality checks safe (e.g. # comparing against +nil+, strings, or arbitrary objects). diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index 24315b0..1a8e80e 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -428,6 +428,40 @@ end end + describe "#finite?" do + it "returns true for finite values" do + expect(Unit(1, "m").finite?).to be true + expect(Unit(0, "m").finite?).to be true + expect(Unit(-1.5, "m").finite?).to be true + expect(Unit(Rational(1, 3), "m").finite?).to be true + end + + it "returns false for +Infinity" do + expect(Unit(Float::INFINITY, "m").finite?).to be false + end + + it "returns false for -Infinity" do + expect(Unit(-Float::INFINITY, "m").finite?).to be false + end + end + + describe "#infinite?" do + it "returns nil for finite values" do + expect(Unit(1, "m").infinite?).to be_nil + expect(Unit(0, "m").infinite?).to be_nil + expect(Unit(-1.5, "m").infinite?).to be_nil + expect(Unit(Rational(1, 3), "m").infinite?).to be_nil + end + + it "returns 1 for +Infinity" do + expect(Unit(Float::INFINITY, "m").infinite?).to eq(1) + end + + it "returns -1 for -Infinity" do + expect(Unit(-Float::INFINITY, "m").infinite?).to eq(-1) + end + end + end describe "Unit DSL", dsl: true do From aab2c102d556dadbf1bd697b31a74e1e092751b6 Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 18:28:18 -0500 Subject: [PATCH 05/12] Override positive? and negative? to delegate to the value The inherited Numeric#positive? and #negative? call self <=> 0, which raises ArgumentError for dimensional units because comparing metres (or any non-dimensionless unit) against the Integer 0 fails the dimension check in Unit#<=>. Override both methods to delegate directly to value, which already knows how to answer the question without a dimensional comparison. --- lib/unit/class.rb | 10 ++++++++++ spec/unit_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lib/unit/class.rb b/lib/unit/class.rb index 4c752fb..5391054 100644 --- a/lib/unit/class.rb +++ b/lib/unit/class.rb @@ -125,6 +125,16 @@ def zero? value.zero? end + # Returns +true+ if +self+ is greater than 0, +false+ otherwise. + def positive? + value.positive? + end + + # Returns +true+ if +self+ is less than 0, +false+ otherwise. + def negative? + value.negative? + end + # Returns +true+ if +self+ is not +Infinity+, +-Infinity+, or +NaN+, # +false+ otherwise. Integer and Rational values are always finite. def finite? diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index 1a8e80e..d44c387 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -428,6 +428,32 @@ end end + describe "#positive?" do + it "returns true when value is greater than 0" do + expect(Unit(1, "m").positive?).to be true + expect(Unit(0.1, "m").positive?).to be true + expect(Unit(Rational(1, 3), "m").positive?).to be true + end + + it "returns false when value is 0 or less" do + expect(Unit(0, "m").positive?).to be false + expect(Unit(-1, "m").positive?).to be false + end + end + + describe "#negative?" do + it "returns true when value is less than 0" do + expect(Unit(-1, "m").negative?).to be true + expect(Unit(-0.1, "m").negative?).to be true + expect(Unit(Rational(-1, 3), "m").negative?).to be true + end + + it "returns false when value is 0 or greater" do + expect(Unit(0, "m").negative?).to be false + expect(Unit(1, "m").negative?).to be false + end + end + describe "#finite?" do it "returns true for finite values" do expect(Unit(1, "m").finite?).to be true From d414c9fc3087f277b5afee026420937419c09a1c Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 18:28:51 -0500 Subject: [PATCH 06/12] Define to_r, giving numerator and denominator for free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numeric#numerator and Numeric#denominator are both implemented as self.to_r.numerator / self.to_r.denominator. Without to_r, calling either raises NoMethodError even though respond_to? returns true. Follow the Complex precedent: Complex#to_r raises RangeError when the imaginary part is non-zero because it cannot be losslessly represented as a Rational. The same argument applies to dimensional units — a value in metres is not a rational number. Use value.to_r to extract the bare rational without the dimension check. After this change: Unit(3).to_r # => (3/1) Unit(Rational(3,4)).to_r # => (3/4) Unit(3).numerator # => 3 (via Numeric#numerator -> to_r) Unit(3).denominator # => 1 (via Numeric#denominator -> to_r) Unit(3, 'm').to_r # => RangeError: can't convert 3 m into Rational Unit(3, 'm').numerator # => RangeError (same path) --- lib/unit/class.rb | 13 +++++++++++++ spec/unit_spec.rb | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/lib/unit/class.rb b/lib/unit/class.rb index 5391054..9ab478f 100644 --- a/lib/unit/class.rb +++ b/lib/unit/class.rb @@ -125,6 +125,19 @@ def zero? value.zero? end + # Converts +self+ to a +Rational+. Raises +RangeError+ for dimensional + # units (e.g. metres), following the same precedent as +Complex#to_r+ + # which raises when the imaginary part is non-zero. Use +value.to_r+ to + # extract the bare rational without a dimension check. + # + # Defining +to_r+ gives +numerator+ and +denominator+ for free via + # +Numeric#numerator+ / +Numeric#denominator+, which are both + # implemented as self.to_r.numerator etc. + def to_r + raise RangeError, "can't convert #{self} into Rational" unless unit.empty? + @value.to_r + end + # Returns +true+ if +self+ is greater than 0, +false+ otherwise. def positive? value.positive? diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index d44c387..c6bc694 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -428,6 +428,43 @@ end end + describe "#to_r" do + it "converts a dimensionless unit to Rational" do + expect(Unit(3).to_r).to eq(Rational(3)) + expect(Unit(Rational(3, 4)).to_r).to eq(Rational(3, 4)) + expect(Unit(1.5).to_r).to eq(Rational(3, 2)) + end + + it "raises RangeError for dimensional units" do + expect { Unit(3, "m").to_r }.to raise_error(RangeError, /can't convert/) + expect { Unit(1.5, "m/s").to_r }.to raise_error(RangeError) + end + end + + describe "#numerator" do + it "returns the numerator of a dimensionless unit" do + expect(Unit(3).numerator).to eq(3) + expect(Unit(Rational(2, 3)).numerator).to eq(2) + expect(Unit(3.5).numerator).to eq(7) + end + + it "raises RangeError for dimensional units" do + expect { Unit(3, "m").numerator }.to raise_error(RangeError) + end + end + + describe "#denominator" do + it "returns the denominator of a dimensionless unit (always a positive Integer)" do + expect(Unit(3).denominator).to eq(1) + expect(Unit(Rational(2, 3)).denominator).to eq(3) + expect(Unit(3.5).denominator).to eq(2) + end + + it "raises RangeError for dimensional units" do + expect { Unit(3, "m").denominator }.to raise_error(RangeError) + end + end + describe "#positive?" do it "returns true when value is greater than 0" do expect(Unit(1, "m").positive?).to be true From bb4c6b3af997f4e331c4c17813ef4351ed211cc2 Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 18:32:20 -0500 Subject: [PATCH 07/12] Override remainder to use value-level sign comparison Numeric#remainder computes self - (self/other).truncate * other but internally uses self < 0 via <=>, which raises ArgumentError for dimensional units (comparing metres against Integer 0 fails the dimension check in Unit#<=>). Provide an explicit implementation using the same formula; truncate now preserves the unit (since Bug 2 was fixed), so the arithmetic works out correctly for same-dimension operands. --- lib/unit/class.rb | 7 +++++++ spec/unit_spec.rb | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/unit/class.rb b/lib/unit/class.rb index 9ab478f..9bda410 100644 --- a/lib/unit/class.rb +++ b/lib/unit/class.rb @@ -125,6 +125,13 @@ def zero? value.zero? end + # Returns the remainder after dividing +self+ by +other+. The result has + # the same sign as +self+ (the dividend). Contrast with +modulo+/+%+, + # whose result has the same sign as +other+ (the divisor). + def remainder(other) + self - (self / other).truncate * other + end + # Converts +self+ to a +Rational+. Raises +RangeError+ for dimensional # units (e.g. metres), following the same precedent as +Complex#to_r+ # which raises when the imaginary part is non-zero. Use +value.to_r+ to diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index c6bc694..3de9ec1 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -428,6 +428,24 @@ end end + describe "#remainder" do + it "returns the remainder with the sign of the dividend" do + expect(Unit(11, "m").remainder(Unit(4, "m"))).to eq(Unit(3, "m")) + expect(Unit(-11, "m").remainder(Unit(4, "m"))).to eq(Unit(-3, "m")) + expect(Unit(11, "m").remainder(Unit(-4, "m"))).to eq(Unit(3, "m")) + expect(Unit(-11, "m").remainder(Unit(-4, "m"))).to eq(Unit(-3, "m")) + end + + it "returns zero when evenly divisible" do + expect(Unit(12, "m").remainder(Unit(4, "m"))).to eq(Unit(0, "m")) + end + + it "differs from modulo for negative values" do + expect(Unit(-11, "m") % Unit(4, "m")).to eq(Unit(1, "m")) # modulo: sign of divisor + expect(Unit(-11, "m").remainder(Unit(4, "m"))).to eq(Unit(-3, "m")) # remainder: sign of dividend + end + end + describe "#to_r" do it "converts a dimensionless unit to Rational" do expect(Unit(3).to_r).to eq(Rational(3)) @@ -440,7 +458,6 @@ expect { Unit(1.5, "m/s").to_r }.to raise_error(RangeError) end end - describe "#numerator" do it "returns the numerator of a dimensionless unit" do expect(Unit(3).numerator).to eq(3) From 64a4481278c4e174bed67799259467e38a4016fb Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 18:33:05 -0500 Subject: [PATCH 08/12] Add tests for div, documenting unit-preserving behaviour Bug 7 (div silently losing dimension on incompatible units) was fixed as a side effect of overriding floor to preserve the unit: Numeric#div computes (self / other).floor, so floor now returns a Unit instead of stripping back to a bare integer. Add explicit specs to lock in the correct behaviour for compatible units (dimensionless quotient), incompatible units (combined dimension), and division by a plain scalar (unit preserved). --- spec/unit_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index 3de9ec1..e8ef94a 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -428,6 +428,22 @@ end end + describe "#div" do + it "returns the floored integer quotient as a dimensionless unit for compatible units" do + expect(Unit(7, "m").div(Unit(2, "m"))).to eq(Unit(3)) + expect(Unit(-7, "m").div(Unit(2, "m"))).to eq(Unit(-4)) + end + + it "returns a unit with the combined dimension for incompatible units" do + expect(Unit(7, "m").div(Unit(2, "s"))).to eq(Unit(3, "m/s")) + end + + it "preserves the unit when dividing by a plain number" do + expect(Unit(7, "m").div(2)).to eq(Unit(3, "m")) + expect(Unit(-7, "m").div(2)).to eq(Unit(-4, "m")) + end + end + describe "#remainder" do it "returns the remainder with the sign of the dividend" do expect(Unit(11, "m").remainder(Unit(4, "m"))).to eq(Unit(3, "m")) From 0cb63e7a82e5ebe656efa7238d1e9abc2c9b5360 Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 18:34:27 -0500 Subject: [PATCH 09/12] Add tests for inherited Numeric methods that work but lacked coverage Cover the previously untested-but-functional surface area of the Numeric API as identified in doc/numeric-compatibility.md: +@, abs2, nonzero?, integer?, real?, real, imag, conj, rect, modulo/%, divmod, fdiv (scalar case), to_int, between?, clamp --- spec/unit_spec.rb | 134 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index e8ef94a..81e71dc 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -558,6 +558,140 @@ end end + describe "#+@" do + it "returns self" do + u = Unit(3, "m") + expect(+u).to eq(u) + end + end + + describe "#abs2" do + it "returns the square of the value with the unit squared" do + expect(Unit(3, "m").abs2).to eq(Unit(9, "m^2")) + expect(Unit(-3, "m").abs2).to eq(Unit(9, "m^2")) + end + end + + describe "#nonzero?" do + it "returns self when the value is non-zero" do + u = Unit(3, "m") + expect(u.nonzero?).to equal(u) + end + + it "returns nil when the value is zero" do + expect(Unit(0, "m").nonzero?).to be_nil + end + end + + describe "#integer?" do + it "always returns false (Unit is not Integer)" do + expect(Unit(1, "m").integer?).to be false + expect(Unit(1).integer?).to be false + end + end + + describe "#real?" do + it "returns true (Unit is a real numeric)" do + expect(Unit(1, "m").real?).to be true + end + end + + describe "#real" do + it "returns self" do + u = Unit(3, "m") + expect(u.real).to equal(u) + end + end + + describe "#imag" do + it "returns 0" do + expect(Unit(3, "m").imag).to eq(0) + end + end + + describe "#conj" do + it "returns self" do + u = Unit(3, "m") + expect(u.conj).to equal(u) + end + end + + describe "#rect" do + it "returns [self, 0]" do + u = Unit(3, "m") + expect(u.rect).to eq([u, 0]) + end + end + + describe "#modulo" do + it "is an alias for %" do + expect(Unit(7, "m").modulo(Unit(3, "m"))).to eq(Unit(7, "m") % Unit(3, "m")) + end + + it "returns the modulus with the sign of the divisor" do + expect(Unit(-7, "m") % Unit(3, "m")).to eq(Unit(2, "m")) + expect(Unit(7, "m") % Unit(-3, "m")).to eq(Unit(-2, "m")) + end + end + + describe "#divmod" do + it "returns [quotient, modulus] for compatible units" do + q, r = Unit(7, "m").divmod(Unit(2, "m")) + expect(q).to eq(Unit(3)) + expect(r).to eq(Unit(1, "m")) + end + + it "satisfies the divmod identity: self == other * q + r" do + dividend = Unit(7, "m") + divisor = Unit(2, "m") + q, r = dividend.divmod(divisor) + expect(divisor * q + r).to eq(dividend) + end + end + + describe "#fdiv" do + it "returns the float quotient when dividing by a scalar" do + expect(Unit(3, "m").fdiv(2)).to eq(1.5) + end + end + + describe "#to_int" do + it "returns the truncated integer value, stripping the unit" do + expect(Unit(2, "m").to_int).to eq(2) + expect(Unit(2.9, "m").to_int).to eq(2) + end + end + + describe "#between?" do + it "returns true when self is between min and max (inclusive)" do + expect(Unit(1, "m").between?(Unit(0, "m"), Unit(2, "m"))).to be true + expect(Unit(0, "m").between?(Unit(0, "m"), Unit(2, "m"))).to be true + expect(Unit(2, "m").between?(Unit(0, "m"), Unit(2, "m"))).to be true + end + + it "returns false when self is outside the range" do + expect(Unit(3, "m").between?(Unit(0, "m"), Unit(2, "m"))).to be false + end + end + + describe "#clamp" do + it "returns self when within the range" do + expect(Unit(1, "m").clamp(Unit(0, "m"), Unit(2, "m"))).to eq(Unit(1, "m")) + end + + it "returns the minimum when below the range" do + expect(Unit(-1, "m").clamp(Unit(0, "m"), Unit(2, "m"))).to eq(Unit(0, "m")) + end + + it "returns the maximum when above the range" do + expect(Unit(5, "m").clamp(Unit(0, "m"), Unit(2, "m"))).to eq(Unit(2, "m")) + end + + it "accepts a Range argument" do + expect(Unit(5, "m").clamp(Unit(0, "m")..Unit(2, "m"))).to eq(Unit(2, "m")) + end + end + end describe "Unit DSL", dsl: true do From 43e9ec07c1556f829139a6984651809ee628bced Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 24 Jun 2026 18:37:31 -0500 Subject: [PATCH 10/12] Raise RangeError from to_i and to_f for dimensional units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silently stripping the unit from a dimensional quantity (e.g. metres) is unsafe — the caller loses the physical meaning of the number with no indication anything was dropped. Complex is the right precedent: Complex(3,2).to_f raises RangeError: can't convert 3+2i into Float because the imaginary part cannot be represented as a Float. The same argument applies to a dimensional unit. After this change: Unit(3, 'm').to_f # => RangeError: can't convert 3 m into Float Unit(3, 'm').to_i # => RangeError: can't convert 3 m into Integer Unit(3).to_f # => 3.0 (dimensionless: fine) Unit(3).to_i # => 3 Use value.to_f / value.to_i to extract the bare number without the dimension check (e.g. inside Unit itself, or when the caller has already verified the unit is as expected). Two internal callers updated: - approx: now uses @value.to_f directly (it preserves the unit anyway) - fdiv: overridden to use (self/other).approx instead of the inherited Numeric#fdiv which called self.to_f; fdiv now returns a Unit with a float value and the correct combined dimension, fixing the pre-existing bug where fdiv with a Unit divisor returned a wrong dimension. --- doc/numeric-compatibility.md | 433 +++++++++++------------------------ lib/unit/class.rb | 22 +- spec/unit_spec.rb | 45 +++- 3 files changed, 192 insertions(+), 308 deletions(-) diff --git a/doc/numeric-compatibility.md b/doc/numeric-compatibility.md index 0012584..196ec0e 100644 --- a/doc/numeric-compatibility.md +++ b/doc/numeric-compatibility.md @@ -1,7 +1,7 @@ # Numeric Compatibility Surface Area This document surveys the full `Numeric` (and common Ruby number type) API surface -against `Unit`'s behaviour, identifying gaps in test coverage and actual bugs. +against `Unit`'s behaviour, identifying gaps in test coverage and known issues. --- @@ -17,15 +17,20 @@ implementation does when it calls back into `Unit` — often `<=>`, `to_i`, or ## Methods Unit explicitly overrides -These exist in `lib/unit/class.rb` and have at least some test coverage: +These exist in `lib/unit/class.rb` and have test coverage: | Method | Notes | |---|---| -| `*`, `/`, `+`, `-`, `**`, `-@` | Arithmetic — core, well-tested | +| `*`, `/`, `+`, `-`, `**`, `-@`, `+@` | Arithmetic — well-tested | | `==`, `eql?`, `<=>` | Equality and ordering — well-tested, including edge cases | +| `hash` | Defined as `[value, unit].hash` — satisfies `eql?`/`hash` contract | | `abs` | Preserves unit | -| `zero?` | Delegates to `@value` | -| `round` | Preserves unit, accepts precision argument | +| `zero?`, `positive?`, `negative?` | Delegate to `value` | +| `numerator`, `denominator` | Delegate to `value` | +| `finite?`, `infinite?` | Delegate to `value` when `value` responds; correct fallbacks otherwise | +| `remainder` | Explicit implementation: `self - (self / other).truncate * other` | +| `ceil`, `floor`, `truncate` | Override to preserve unit; accept `ndigits` argument | +| `round` | Preserves unit; accepts `ndigits` and `half:` keyword | | `to_i`, `to_f` | Strip unit, return bare numeric | | `coerce` | Enables Integer/Float on left-hand side | | `dup`, `initialize_copy`, `freeze` | Immutability contract — tested | @@ -36,289 +41,156 @@ These exist in `lib/unit/class.rb` and have at least some test coverage: ## Inherited Numeric methods — behaviour inventory -### Group 1: Work correctly, currently untested +### Group 1: Work correctly, with tests -These behave sensibly through the inherited `Numeric` path and are worth adding -coverage for. +These were previously untested; coverage was added alongside the bug fixes. -**`+@` (unary plus)** -```ruby -+Unit(3, 'm') # => Unit("3 m") — returns self via Numeric -``` -No test. Trivial but worth a one-liner for completeness. - -**`abs2`** -```ruby -Unit(3, 'm').abs2 # => Unit("9 m^2") -Unit(-3, 'm').abs2 # => Unit("9 m^2") -``` -Implemented as `self * self.conj` in Numeric, which hits `Unit#*`. Returns a -dimensionally-correct result (`m²`). No test. Should verify the identity -`u.abs2 == u.abs ** 2`. - -**`nonzero?`** -```ruby -Unit(0, 'm').nonzero? # => nil -Unit(1, 'm').nonzero? # => Unit("1 m") -``` -Works correctly through `Numeric`. No test. - -**`modulo` / `%`** -```ruby -Unit(7, 'm') % Unit(3, 'm') # => Unit("1 m") -Unit(-7, 'm') % Unit(3, 'm') # => Unit("2 m") (always non-negative) -``` -Works, preserves unit. No test. Incompatible units raise `IncompatibleUnitError` -(via the `<` comparison inside `Numeric#modulo`). - -**`divmod`** -```ruby -Unit(7, 'm').divmod(Unit(2, 'm')) # => [3, Unit("1 m")] -``` -The integer quotient strips the unit (calls `to_i` internally); the remainder -preserves it. Works, no test. - -**`between?` and `clamp`** -```ruby -Unit(1, 'm').between?(Unit(0, 'm'), Unit(2, 'm')) # => true -Unit(5, 'm').clamp(Unit(0, 'm'), Unit(2, 'm')) # => Unit("2 m") -Unit(-1, 'm').clamp(Unit(0, 'm'), Unit(2, 'm')) # => Unit("0 m") -Unit(5, 'm').clamp(Unit(0, 'm')..Unit(2, 'm')) # => Unit("2 m") (Range form) -``` -All route through `<=>` and work correctly. Incompatible dimensions raise -`ArgumentError` (existing `clamp` test in unit_spec covers this). Worth adding -positive cases to the "comparable" group and testing the Range form of `clamp`. - -**`ceil`, `floor`, `truncate` (no-precision forms) — unit-stripping (see Bug §2)** -The no-arg forms return a bare `Integer` (unit stripped). This is consistent with -how `Integer` works — `2.ceil` returns `2` — but may surprise users. Document and -test that this is the defined behaviour. - -**`ceil`, `floor`, `truncate` (with precision) — also unit-stripping** -```ruby -Unit(2.736, 'm').ceil(2) # => 2.74 (Float, no unit) -Unit(2.736, 'm').floor(2) # => 2.73 (Float, no unit) -Unit(2.736, 'm').truncate(2) # => 2.73 (Float, no unit) -``` -Contrast with `round(2)` which preserves the unit. No tests for any -precision-argument variants of ceil/floor/truncate. - -**`Kernel` conversions** -```ruby -Float(Unit(2.7, 'm')) # => 2.7 (bare Float, no unit) -Integer(Unit(2, 'm')) # => 2 (bare Integer) -Rational(Unit(2, 'm')) # => (2/1) (bare Rational) -``` -These work via `to_f`/`to_i`/`to_r`-like paths and silently strip the unit. -No tests. +| Method | Behaviour | Notes | +|---|---|---| +| `+@` | Returns `self` | | +| `abs2` | Returns `Unit(value**2, unit**2)` — e.g. `Unit(3,'m').abs2 == Unit(9,'m^2')` | Dimensionally correct via `self * self.conj` | +| `nonzero?` | Returns `self` or `nil` | | +| `integer?` | Always `false` | `Unit` is not `Integer` | +| `real?` | Always `true` | | +| `real` | Returns `self` | | +| `imag` / `imaginary` | Returns `0` | | +| `conj` / `conjugate` | Returns `self` | | +| `rect` / `rectangular` | Returns `[self, 0]` | | +| `modulo` / `%` | Preserves unit; result sign matches divisor | | +| `divmod` | Returns `[floored_quotient, remainder]`; quotient is dimensionless for compatible units | | +| `div` | Floored integer quotient; compatible units → dimensionless; incompatible → combined dimension | Unit-preserving as side-effect of overriding `floor` | +| `fdiv(scalar)` | Returns bare `Float` (unit stripped) — e.g. `Unit(3,'m').fdiv(2) => 1.5` | See note below | +| `to_int` | Strips unit, returns `Integer` (same as `to_i`) | | +| `between?` | Works via `<=>` | | +| `clamp` | Works for both two-arg and Range forms | | --- -### Group 2: Bugs — wrong behaviour, no tests - -These methods give incorrect or misleading results and need both a failing test -and a fix. - -#### Bug 1: `#hash` violates the Ruby object-equality contract - -Ruby requires: `a.eql?(b)` implies `a.hash == b.hash`. - -```ruby -a = Unit(1, 'm') -b = Unit(1, 'm') -a.eql?(b) # => true -a.hash == b.hash # => false ← CONTRACT VIOLATED -``` - -**Consequence:** `Unit` objects are silently broken as Hash keys and Set members. - -```ruby -h = { Unit(1, 'm') => 'one meter' } -h[Unit(1, 'm')] # => nil ← key lookup always misses -``` - -`Unit` needs to define `#hash` based on `[value, unit]` (the same fields -`eql?` inspects). Suggested fix: - -```ruby -def hash - [value, unit].hash -end -``` - -#### Bug 2: `#ceil`, `#floor`, `#truncate` strip the unit but `#round` does not - -`round` is explicitly overridden in `Unit` and preserves the unit. The other -three are inherited from `Numeric` (which calls `to_i`/`to_f` internally) and -silently return a bare numeric. +### Group 2: Work correctly, still untested -```ruby -Unit(2.7, 'm').round # => Unit("3 m") ✓ preserves unit -Unit(2.7, 'm').ceil # => 3 ✗ strips unit -Unit(2.7, 'm').floor # => 2 ✗ strips unit -Unit(2.7, 'm').truncate # => 2 ✗ strips unit -``` - -Same asymmetry with a precision argument: - -```ruby -Unit(2.736, 'm').round(2) # => Unit("2.74 m") ✓ -Unit(2.736, 'm').ceil(2) # => 2.74 ✗ -Unit(2.736, 'm').floor(2) # => 2.73 ✗ -Unit(2.736, 'm').truncate(2) # => 2.73 ✗ -``` - -These should be overridden analogously to `round`. - -#### Bug 3: `#finite?` and `#infinite?` ignore the value +| Method | Behaviour | Notes | +|---|---|---| +| `to_c` | Returns `(Unit("3 m")+0i)` | Works via `Numeric#to_c`; unit ends up inside the complex | +| `clone` | Returns a distinct copy (not `self`) | Unlike `dup` on a plain Numeric; `Unit` overrides `initialize_copy` so clone behaves normally | -`Numeric#infinite?` is hardcoded to return `nil` (only `Float` overrides it). -`Numeric#finite?` returns `true` unless `infinite?` is truthy. Since `Unit` does -not override either, a `Unit` wrapping `Float::INFINITY` reports itself as finite: +`Kernel` conversion functions work and strip the unit: ```ruby -Unit(Float::INFINITY, 'm').infinite? # => nil ✗ (expected 1) -Unit(Float::INFINITY, 'm').finite? # => true ✗ (expected false) +Float(Unit(3.5, 'm')) # => 3.5 (bare Float) +Integer(Unit(3, 'm')) # => 3 (bare Integer) +Rational(Unit(3, 'm')) # => (3/1) (bare Rational) ``` -These should delegate to `@value`: - -```ruby -def infinite? - value.respond_to?(:infinite?) ? value.infinite? : nil -end +No tests for any of these. -def finite? - value.respond_to?(:finite?) ? value.finite? : true -end -``` - -#### Bug 4: `#positive?` and `#negative?` raise on dimensional units +--- -`Numeric#positive?` and `negative?` call `self > 0` / `self < 0`, which routes -through `<=>`, which raises `ArgumentError` when comparing a dimensional unit -with an `Integer`: +### Group 3: Known remaining issues -```ruby -Unit(3, 'm').positive? # => ArgumentError: comparison of Unit("3 m") with Integer failed -Unit(-3, 'm').negative? # => ArgumentError: comparison of Unit("3 m") with Integer failed -``` +#### `fdiv` with a Unit divisor returns a wrong dimension -`respond_to?(:positive?)` returns `true`, so callers get a surprise. These -should be overridden to compare `value` directly: +`Numeric#fdiv` is implemented as `self.to_f / other`. `to_f` strips the unit +from `self` (yielding e.g. `3.0`), then `3.0 / Unit(2.0,'m')` goes through +coerce, producing `Unit(1.5, 'm^-1')` instead of the correct `1.5`: ```ruby -def positive? - value > 0 -end - -def negative? - value < 0 -end +Unit(3, 'm').fdiv(Unit(2, 'm')) # => Unit("1.5 m^-1") ← wrong (should be 1.5) +Unit(3, 'm').fdiv(2) # => 1.5 ← correct ``` -Dimensionless units already work correctly (since `<=>` against `Integer` succeeds -when there's no dimension mismatch). +`fdiv` should be overridden to delegate to `(self / other).to_f` for the unit +case, or restricted to scalar divisors only. -#### Bug 5: `#numerator` and `#denominator` raise despite `respond_to?` returning `true` +#### `magnitude` raises but `abs` works — asymmetry -`Numeric#numerator` and `#denominator` are defined and call `to_r` internally. -`Unit` does not define `to_r`, so they raise at runtime: +`abs` is overridden in `Unit` and works correctly. `magnitude` is inherited from +`Numeric` (it is an alias for `abs` in `Numeric` but `Numeric#magnitude` calls +`abs` on `self`, which goes through `<=>` internally on some paths) and raises +`ArgumentError` for dimensional units: ```ruby -Unit(1, 'm').respond_to?(:numerator) # => true -Unit(1, 'm').numerator # => NoMethodError: undefined method 'to_r' +Unit(3, 'm').abs # => Unit("3 m") ✓ +Unit(3, 'm').magnitude # => ArgumentError ✗ ``` -Options: define `to_r` (raises `TypeError` for dimensional units, delegates to -`value.to_r` for dimensionless), or override `numerator`/`denominator` -similarly. +The fix is to alias `magnitude` to `abs` in `Unit`. -#### Bug 6: `#remainder` raises on dimensional units +#### `step` raises for dimensional units -`Numeric#remainder` is implemented as: -```ruby -def remainder(y) - x = self - x - (x / y).truncate * y # calls truncate then <=> -end -``` -The `truncate` call returns a bare integer (Bug 2), and then the subsequent `*` -and `-` operations collapse. In practice it raises `ArgumentError` because -the intermediate `truncate` comparison path breaks on dimensional units. +`Numeric#step` compares the step value against `Integer` internally, triggering +the dimensional `<=>` guard: ```ruby -Unit(7, 'm').remainder(Unit(3, 'm')) # => ArgumentError -Unit(-7, 'm').remainder(Unit(3, 'm')) # => ArgumentError +Unit(0, 'm').step(Unit(3, 'm'), Unit(1, 'm')) { } +# => ArgumentError: comparison of Unit("1 m") with Integer failed ``` -This would be fixed as a side effect of fixing `ceil`/`floor`/`truncate` (Bug 2). +This could be fixed by overriding `step` to work directly on the value while +re-wrapping each yielded result, or by documenting it as unsupported. -#### Bug 7: `Numeric#div` silently loses unit on incompatible operands +#### `angle` / `arg` / `phase` / `polar` raise for dimensional units -`Numeric#div` is `(self / other).floor` (calls `to_i` on the result). For -compatible units the result is dimensionless, so the loss is expected. For -incompatible units, `Unit#/` still produces a valid `m/s` unit, then `to_i` -strips it silently: +All four call `self <=> 0` internally, which fails for dimensional units: ```ruby -Unit(7, 'm').div(Unit(2, 's')) # => 3 (no unit, no error) +Unit(3, 'm').angle # => ArgumentError +Unit(3, 'm').polar # => ArgumentError ``` -Whether this is a bug or intentional depends on the semantics you want for `div`. -At minimum it should be tested so the current behaviour is explicit. +For dimensionless units they work correctly. These are arguably correct +behaviour — a dimensional quantity doesn't have a phase angle — but they should +have tests confirming the error and documenting the intent. --- -### Group 3: Methods that raise by design (document-only) +### Group 4: Methods that raise by design (need confirming tests) -These raise for dimensional units in a principled way and should have tests -confirming the error: +These raise for dimensional units in a principled way. Tests confirming the +error should be added so the behaviour is explicit and can't regress silently: | Method | Behaviour | |---|---| -| `angle` / `arg` / `phase` | `ArgumentError` (dimensional `<=>` 0 fails) | -| `magnitude` | `ArgumentError` (same — unlike `abs` which is overridden) | -| `polar` | `ArgumentError` (calls `abs` which works, but then `arg` which fails) | -| `step` | `ArgumentError` (compares step value with Integer internally) | -| `**` with a `Unit` exponent | `TypeError` (explicit guard in `Unit#**`) — tested | -| `+`, `-` with incompatible units | `IncompatibleUnitError` — tested | - -Note the asymmetry: `abs` is overridden and works; `magnitude` is inherited and -raises. They should behave the same. +| `angle` / `arg` / `phase` | `ArgumentError` — dimensional `<=>` 0 fails | +| `magnitude` | `ArgumentError` — should be aliased to `abs` (see above) | +| `polar` | `ArgumentError` — calls `angle` internally | +| `step` | `ArgumentError` — compares step value with `Integer` | +| `**` with a `Unit` exponent | `TypeError` — explicit guard in `Unit#**` | +| `+`, `-` with incompatible units | `IncompatibleUnitError` | --- -### Group 4: Inherited methods that work fine but deserve explicit tests +## Summary of issues (current state) -| Method | Current behaviour | Missing test? | -|---|---|---| -| `integer?` | Always `false` (Unit is never an Integer) | Yes | -| `real?` | Always `true` | Yes | -| `real` | Returns `self` | Yes | -| `imag` / `imaginary` | Returns `0` | Yes | -| `conj` / `conjugate` | Returns `self` | Yes | -| `rect` / `rectangular` | Returns `[self, 0]` | Yes | -| `to_c` | Returns `(Unit("3 m")+0i)` | Yes | -| `to_int` | Strips unit, returns Integer (same as `to_i`) | Yes | -| `clone` | Returns `self` (Numeric immutability — same as `dup`) | Yes | -| `fdiv` with scalar | `Unit(7, 'm').fdiv(2) => 3.5` (Float, no unit) | Yes | -| `fdiv` with unit | `Unit(7, 'm').fdiv(Unit(2, 'm')) => Unit("3.5 m^-1")` | Yes | +### Fixed ---- +| # | Method(s) | Fix applied | +|---|---|---| +| 1 | `hash` | Defined as `[value, unit].hash` | +| 2 | `ceil`, `floor`, `truncate` | Overridden to preserve unit; consistent with `round` | +| 3 | `finite?`, `infinite?` | Overridden to delegate to `value` | +| 4 | `positive?`, `negative?` | Overridden to delegate to `value` | +| 5 | `numerator`, `denominator` | Overridden to delegate to `value` | +| 6 | `remainder` | Explicit implementation using `truncate` | +| 7 | `div` with incompatible units | Fixed as side-effect of `floor` override | +| 8 | `round(half:)` | Fixed — `round` now accepts `**opts` | + +### Open + +| # | Method(s) | Severity | Proposed fix | +|---|---|---|---| +| 9 | `fdiv` with Unit divisor | **Medium** — wrong dimension in result | Override `fdiv` to use `(self/other).to_f` | +| 10 | `magnitude` vs `abs` | **Low** — asymmetric; magnitude raises | `alias_method :magnitude, :abs` | +| 11 | `step` | **Low** — raises for all dimensional units | Override or document as unsupported | +| 12 | `angle`/`arg`/`phase`/`polar` | **Low** — raise, arguably correct | Add confirming tests | -## Summary of bugs (severity order) +### Untested working behaviour -| # | Method(s) | Severity | Fix | -|---|---|---|---| -| 1 | `hash` | **Critical** — broken Hash/Set membership | Define `hash` from `[value, unit]` | -| 2 | `ceil`, `floor`, `truncate` | **High** — inconsistent with `round` | Override, preserve unit | -| 3 | `finite?`, `infinite?` | **High** — wrong answers for Infinity values | Override, delegate to `@value` | -| 4 | `positive?`, `negative?` | **Medium** — raises instead of answering | Override, compare `value` | -| 5 | `numerator`, `denominator` | **Medium** — `respond_to?` lies | Define `to_r` or override both | -| 6 | `remainder` | **Medium** — raises (consequence of Bug 2) | Fixed by Bug 2 fix | -| 7 | `div` with incompatible units | **Low** — silent unit loss | Add test, decide on semantics | -| 8 | `round(half:)` | **Medium** — raises `TypeError` on all versions | Update signature to accept `**opts` | +| Area | Missing tests | +|---|---| +| `to_c` | Not tested | +| `clone` | Not tested | +| `Kernel` conversions (`Float()`, `Integer()`, `Rational()`) | Not tested | +| `angle`/`magnitude`/`step`/`polar` raise paths | Not tested (Group 4 above) | --- @@ -328,14 +200,14 @@ The CI matrix covers Ruby 3.3, 3.4, 4.0, and `head`. The `Numeric`, `Float`, `Integer`, `Rational`, and `Comparable` method sets are **identical** across all three versions — no methods were added or removed between 3.3 and 4.0. -However, there are three **behavioural changes** that affect `Unit`. +However, there are two **behavioural changes** that affect `Unit`. -### 1. `Integer#**` / `Rational#**` overflow changed in Ruby 3.4 +### `Integer#**` / `Rational#**` overflow changed in Ruby 3.4 **Ruby 3.3:** raising an integer to a very large power silently overflowed to `Float::INFINITY`, emitting a warning at `$VERBOSE` level. -**Ruby 3.4+:** the result stays an `Integer` (an arbitrary-precision bignum); +**Ruby 3.4+:** the result stays an `Integer` (arbitrary-precision bignum); the overflow-to-Float path was removed. `Unit#**` passes the exponent computation through to the underlying `@value`, @@ -352,53 +224,23 @@ Unit(2, 'm') ** 100_000_000 # => Unit("36846659369… m^100000000") ← value is a bignum Integer ``` -Any test asserting that very-large-exponent `Unit#**` produces `Infinity` would -pass on 3.3 and fail on 3.4+. Conversely, tests checking the bignum path would -fail on 3.3. **Version-gate** such tests, or confine them to Float-valued units -(`Unit(2.0, 'm') ** 100_000_000` still yields `Infinity` on all versions because -it goes through `Float#**`). +Any test asserting that very-large-exponent `Unit#**` produces `Infinity` passes +on 3.3 and fails on 3.4+. **Version-gate** such tests, or confine them to +Float-valued units — `Unit(2.0,'m') ** 100_000_000` still yields `Infinity` on +all versions because it goes through `Float#**`. Note: `Rational#**` follows the same rule — extremely large `Rational` values that previously returned `Float::NAN` or `Float::INFINITY` now return a (potentially huge) `Rational` on 3.4+. -### 2. `Kernel#Float()` is more permissive in Ruby 3.4 +### `Kernel#Float()` is more permissive in Ruby 3.4 **Ruby 3.3:** `Float("1.")` and `Float("1.E-1")` raise `ArgumentError`. - **Ruby 3.4+:** both are accepted. -```ruby -Float("1.") # 3.3: ArgumentError; 3.4+: 1.0 -Float("1.E-1") # 3.3: ArgumentError; 3.4+: 0.1 -``` - -`Unit` does not call `Kernel#Float()` internally and does not expose a -string-based constructor that routes through it, so this change has **no direct -impact on `Unit`**. It can affect code that builds a `Unit` from a user-supplied -string like `Unit("1. m")` if parsing uses `Float()` under the hood — worth -verifying in the parser tests. - -### 3. `Unit#round(half:)` does not accept the keyword argument (all versions) - -Ruby's `Numeric#round` has accepted `half: :up/:down/:even` since Ruby 3.x. -`Unit#round` overrides this but only accepts a positional `ndigits` argument, -so the keyword form raises on all supported versions: - -```ruby -Unit(2.5, 'm').round(half: :up) -# => TypeError: no implicit conversion of Hash into Integer (3.3 / 3.4 / 4.0) -``` - -This is a **pre-existing bug on all three versions** rather than a -version-specific regression. The fix is to update `Unit#round`'s signature to -match `Numeric#round`: - -```ruby -def round(digits = 0, **opts) - Unit.new(value.round(digits, **opts), unit) -end -``` +`Unit` does not call `Kernel#Float()` internally, so this has **no direct +impact**. Worth verifying in parser tests if `Unit("1. m")` is a supported +input form. ### Version compatibility summary @@ -407,32 +249,21 @@ end | `Numeric` method set | identical | identical | identical | no additions or removals | | `Integer#**` overflow | → `Float::INFINITY` + warning | → bignum `Integer` | → bignum `Integer` | affects `Unit#**` with Integer value | | `Rational#**` overflow | → `Float::NAN` / `Float::INFINITY` | → large `Rational` | → large `Rational` | affects `Unit#**` with Rational value | -| `Float#**` overflow | → `Float::INFINITY` | → `Float::INFINITY` | → `Float::INFINITY` | **unchanged** — Float overflow behaviour not changed | +| `Float#**` overflow | → `Float::INFINITY` | → `Float::INFINITY` | → `Float::INFINITY` | **unchanged** | | `Kernel#Float("1.")` | `ArgumentError` | `1.0` | `1.0` | no internal Unit impact | -| `Unit#round(half:)` | `TypeError` | `TypeError` | `TypeError` | bug on all versions | -| All other `Numeric` inherited behaviours | identical | identical | identical | | +| All other inherited behaviours | identical | identical | identical | | --- -## Suggested new test groups +## Remaining suggested test additions -``` -describe "#hash" do ... end # Bug 1: hash contract -describe "#ceil / #floor / #truncate" do # Bug 2: unit preservation -describe "#finite? / #infinite?" do # Bug 3: float edge values -describe "#positive? / #negative?" do # Bug 4 -describe "#numerator / #denominator" do # Bug 5 -describe "#remainder" do # Bug 6 (after ceil/floor fix) -describe "#abs2" do ... end # Group 1: correct, untested -describe "#nonzero?" do ... end # Group 1 -describe "#modulo / %" do ... end # Group 1 (with incompatible-unit case) -describe "#divmod" do ... end # Group 1 -describe "#between? / #clamp" do ... end # Group 1 positive cases + Range form -describe "#integer? / #real? / #real" do # Group 4 -describe "#conj / #rect / #imag" do # Group 4 -describe "#to_int / #to_c" do ... end # Group 4 -describe "#fdiv" do ... end # Group 4 -describe "methods that raise by design" do # Group 3: angle, magnitude, step, polar -describe "#round with half: keyword" do # Bug 8: TypeError on half: arg -describe "#** with large exponents" do # version-gated: 3.3 Float::INFINITY vs 3.4+ bignum +```ruby +describe "#to_c" do ... end # Group 2: untested, works +describe "#clone" do ... end # Group 2: untested, works +describe "Kernel conversions" do # Group 2: Float(), Integer(), Rational() +describe "#fdiv with Unit divisor" do ... end # Open issue 9 +describe "#magnitude" do ... end # Open issue 10 (should alias abs) +describe "#step" do ... end # Open issue 11 +describe "raises by design" do # Group 4: angle, polar, step, magnitude +describe "#** with large exponents" do ... end # version-gated: 3.3 vs 3.4+ ``` diff --git a/lib/unit/class.rb b/lib/unit/class.rb index 9bda410..3109b07 100644 --- a/lib/unit/class.rb +++ b/lib/unit/class.rb @@ -125,6 +125,14 @@ def zero? value.zero? end + # Returns the float-valued quotient of +self+ and +other+, with the unit + # dimension preserved (for compatible units, the result is dimensionless). + # Unlike +Numeric#fdiv+, this does not strip the unit from +self+ before + # dividing. + def fdiv(other) + (self / other).approx + end + # Returns the remainder after dividing +self+ by +other+. The result has # the same sign as +self+ (the dividend). Contrast with +modulo+/+%+, # whose result has the same sign as +other+ (the divisor). @@ -252,16 +260,28 @@ def to_tex unit.empty? ? value.to_s : "\SI{#{value}}{#{unit_string('.')}}" end + # Returns the value as an Integer. Raises +RangeError+ if +self+ has a + # physical dimension (e.g. metres), mirroring the behaviour of + # Complex#to_i when the imaginary part is non-zero. + # Use value.to_i to extract the bare number without the check. def to_i + raise RangeError, "can't convert #{self} into Integer" unless unit.empty? @value.to_i end + # Returns the value as a Float. Raises +RangeError+ if +self+ has a + # physical dimension (e.g. metres), mirroring the behaviour of + # Complex#to_f when the imaginary part is non-zero. + # Use value.to_f to extract the bare number without the check. def to_f + raise RangeError, "can't convert #{self} into Float" unless unit.empty? @value.to_f end + # Returns a new Unit with the value approximated as a +Float+, preserving + # the unit dimension. Useful for displaying Rational-valued units as decimals. def approx - Unit.new(self.to_f, unit, system) + Unit.new(@value.to_f, unit, system) end # Returns a Unit that is a "ceiling" value for +self+, as specified by diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index 81e71dc..f88a2dc 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -313,7 +313,7 @@ it 'should work with floating point values' do w = 5.2 * Unit('kilogram') - expect(w.in("pounds").to_int).to eq(11) + expect(w.in("pounds").value.to_i).to eq(11) end it 'should have dimensionless? method' do @@ -650,15 +650,48 @@ end describe "#fdiv" do - it "returns the float quotient when dividing by a scalar" do - expect(Unit(3, "m").fdiv(2)).to eq(1.5) + it "returns a float-valued unit when dividing by a scalar" do + expect(Unit(3, "m").fdiv(2)).to eq(Unit(1.5, "m")) + end + + it "returns a dimensionless float unit for compatible units" do + expect(Unit(3, "m").fdiv(Unit(2, "m"))).to eq(Unit(1.5)) + end + + it "preserves the combined dimension for incompatible units" do + expect(Unit(3, "m").fdiv(Unit(2, "s"))).to eq(Unit(1.5, "m/s")) + end + end + + describe "#to_f" do + it "returns the Float value for a dimensionless unit" do + expect(Unit(3).to_f).to eq(3.0) + expect(Unit(Rational(1, 4)).to_f).to eq(0.25) + end + + it "raises RangeError for a dimensional unit" do + expect { Unit(3, "m").to_f }.to raise_error(RangeError, /can't convert/) + end + end + + describe "#to_i" do + it "returns the Integer value for a dimensionless unit" do + expect(Unit(3).to_i).to eq(3) + expect(Unit(2.9).to_i).to eq(2) + end + + it "raises RangeError for a dimensional unit" do + expect { Unit(3, "m").to_i }.to raise_error(RangeError, /can't convert/) end end describe "#to_int" do - it "returns the truncated integer value, stripping the unit" do - expect(Unit(2, "m").to_int).to eq(2) - expect(Unit(2.9, "m").to_int).to eq(2) + it "returns the Integer value for a dimensionless unit" do + expect(Unit(3).to_int).to eq(3) + end + + it "raises RangeError for a dimensional unit" do + expect { Unit(2, "m").to_int }.to raise_error(RangeError, /can't convert/) end end From 9f51cf2dde4cde0e2ce2ba8d66ca16b4e4e25032 Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Fri, 17 Jul 2026 12:04:58 -0500 Subject: [PATCH 11/12] Support Complex-valued units in real?, real, imag, conj, and rect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complex-valued units are physically meaningful — electrical impedance, phasors, and similar quantities are dimensional values with complex scalars. Unit(Complex(3, 4), 'ohm') was already parseable and rendered correctly, but the Numeric methods that distinguish real from complex gave wrong answers because Numeric's defaults unconditionally treat self as real. Override real?, real, imag/imaginary, conj/conjugate, and rect/rectangular to delegate to value. For real-valued units the behaviour is unchanged (real? => true, imag => 0, real => self, conj => self). For complex-valued units: z = Unit(Complex(3, 4), 'ohm') z.real? # => false z.real # => Unit(3, 'ohm') z.imag # => Unit(4, 'ohm') z.conj # => Unit(Complex(3, -4), 'ohm') z.rect # => [Unit(3, 'ohm'), Unit(4, 'ohm')] z.abs # => Unit(5.0, 'ohm') (was already correct) Document Complex-valued units as an explicit design goal in AGENTS.md and add a usage example to README.markdown. --- AGENTS.md | 9 +++++++++ README.markdown | 12 ++++++++++++ lib/unit/class.rb | 41 +++++++++++++++++++++++++++++++++++++++++ spec/unit_spec.rb | 37 ++++++++++++++++++++++++++++++++----- 4 files changed, 94 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6819e4d..aaf76cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,15 @@ conversions, and expression parsing (`Unit('1 m/s^2')`), plus an optional DSL `binary`, `degree`, and `time`; `scientific`, `imperial`, and `misc` are available on demand. +## Design goals + +- **Complex-valued units are a supported use case.** Impedance in electrical + engineering is a complex-valued quantity (ohms), phasors in signal processing + are complex-valued, and so on. `Unit(Complex(3, 4), 'ohm')` is valid. + Methods that distinguish real from complex (`real?`, `real`, `imag`, `conj`, + `rect`) must delegate to the value rather than unconditionally returning + the `Numeric` defaults. Keep this in mind when adding new numeric methods. + ## Things to know before editing - **`Unit` is a `Numeric` subclass, and modern Ruby treats numerics as immutable diff --git a/README.markdown b/README.markdown index d33c7ba..492f9f4 100644 --- a/README.markdown +++ b/README.markdown @@ -28,6 +28,18 @@ Examples Unit(5.5, 'feet') / 2 == Unit(2.75, 'feet') Unit(2, 'm') ** 2 == Unit(4, 'm^2') +### Complex-valued units + +Units with complex values are supported for domains such as electrical +impedance and signal processing: + + z = Unit(Complex(3, 4), 'ohm') # 3+4i Ω + z.abs # => Unit("5.0 Ω") (magnitude) + z.real # => Unit("3 Ω") + z.imag # => Unit("4 Ω") + z.conj # => Unit("3-4i Ω") + z.real? # => false + ### Conversions Unit(1, 'mile').in('km') == Unit(1.609344, 'km') diff --git a/lib/unit/class.rb b/lib/unit/class.rb index 3109b07..3a9ef9e 100644 --- a/lib/unit/class.rb +++ b/lib/unit/class.rb @@ -121,6 +121,47 @@ def abs Unit.new(value.abs, unit, system) end + # Returns +false+ if the value is a +Complex+ with a non-zero imaginary + # part, +true+ otherwise. Overrides +Numeric#real?+ which unconditionally + # returns +true+. + def real? + value.real? + end + + # Returns the real part of +self+ as a +Unit+. For real-valued units + # returns +self+; for complex-valued units returns a new unit wrapping + # the real component. + def real + return self if value.real? + Unit.new(value.real, unit, system) + end + + # Returns the imaginary part of +self+ as a +Unit+. For real-valued + # units returns +0+ (matching +Numeric#imag+); for complex-valued units + # returns a new unit wrapping the imaginary component. + def imag + return 0 if value.real? + Unit.new(value.imag, unit, system) + end + alias imaginary imag + + # Returns the complex conjugate of +self+ with the unit preserved. For + # real-valued units returns +self+ (matching +Numeric#conj+); for + # complex-valued units negates the imaginary part. + def conj + return self if value.real? + Unit.new(value.conj, unit, system) + end + alias conjugate conj + + # Returns a two-element array [real, imaginary] with the unit + # preserved on each component. For real-valued units returns + # [self, 0] (matching +Numeric#rect+). + def rect + [real, imag] + end + alias rectangular rect + def zero? value.zero? end diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index f88a2dc..95732dc 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -591,36 +591,63 @@ end describe "#real?" do - it "returns true (Unit is a real numeric)" do + it "returns true for a real-valued unit" do expect(Unit(1, "m").real?).to be true + expect(Unit(1.5, "m").real?).to be true + expect(Unit(Rational(1, 2), "m").real?).to be true + end + + it "returns false for a complex-valued unit" do + expect(Unit(Complex(0, 1), "ohm").real?).to be false + expect(Unit(Complex(3, 4), "ohm").real?).to be false end end describe "#real" do - it "returns self" do + it "returns self for a real-valued unit" do u = Unit(3, "m") expect(u.real).to equal(u) end + + it "returns the real part as a Unit for a complex-valued unit" do + expect(Unit(Complex(3, 4), "ohm").real).to eq(Unit(3, "ohm")) + expect(Unit(Complex(0, 1), "ohm").real).to eq(Unit(0, "ohm")) + end end describe "#imag" do - it "returns 0" do + it "returns 0 for a real-valued unit" do expect(Unit(3, "m").imag).to eq(0) end + + it "returns the imaginary part as a Unit for a complex-valued unit" do + expect(Unit(Complex(3, 4), "ohm").imag).to eq(Unit(4, "ohm")) + expect(Unit(Complex(0, 1), "ohm").imag).to eq(Unit(1, "ohm")) + end end describe "#conj" do - it "returns self" do + it "returns self for a real-valued unit" do u = Unit(3, "m") expect(u.conj).to equal(u) end + + it "returns the complex conjugate with unit preserved" do + expect(Unit(Complex(3, 4), "ohm").conj).to eq(Unit(Complex(3, -4), "ohm")) + expect(Unit(Complex(0, 1), "ohm").conj).to eq(Unit(Complex(0, -1), "ohm")) + end end describe "#rect" do - it "returns [self, 0]" do + it "returns [self, 0] for a real-valued unit" do u = Unit(3, "m") expect(u.rect).to eq([u, 0]) end + + it "returns [real_unit, imag_unit] for a complex-valued unit" do + u = Unit(Complex(3, 4), "ohm") + expect(u.rect).to eq([Unit(3, "ohm"), Unit(4, "ohm")]) + end end describe "#modulo" do From 3f67cf5545c7e547bd27f18e51428f09ede5f583 Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Fri, 17 Jul 2026 12:08:10 -0500 Subject: [PATCH 12/12] Update numeric-compatibility survey doc Reflect all changes made since the doc was first written: - hash: note self.class inclusion and why - to_i/to_f: now raise RangeError for dimensional units (not 'strip') - to_r: new entry - numerator/denominator: now via to_r, not direct delegation - fdiv: moved from open issues to fixed - real?/real/imag/conj/rect: moved from inherited Group 1 to overrides table; updated descriptions for complex-value behaviour - to_int: now raises for dimensional units (calls to_i internally) - Complex-valued units: added as explicit design goal section - abs2 for complex values: added to untested list - angle/polar for complex-valued units: added as open issue 14 - Fixed table renumbered: 11 fixed, 3 open --- doc/numeric-compatibility.md | 145 +++++++++++++++++++---------------- 1 file changed, 78 insertions(+), 67 deletions(-) diff --git a/doc/numeric-compatibility.md b/doc/numeric-compatibility.md index 196ec0e..e86d617 100644 --- a/doc/numeric-compatibility.md +++ b/doc/numeric-compatibility.md @@ -15,6 +15,26 @@ implementation does when it calls back into `Unit` — often `<=>`, `to_i`, or --- +## Design goal: Complex-valued units + +Units with complex scalar values are explicitly supported. Electrical impedance, +phasors, and similar quantities are dimensional values with complex scalars: + +```ruby +z = Unit(Complex(3, 4), 'ohm') +z.abs # => Unit("5.0 Ω") +z.real # => Unit("3 Ω") +z.imag # => Unit("4 Ω") +z.conj # => Unit("3-4i Ω") +z.real? # => false +``` + +Methods that distinguish real from complex (`real?`, `real`, `imag`, `conj`, +`rect`) must delegate to `value` rather than returning `Numeric`'s unconditional +real-only defaults. + +--- + ## Methods Unit explicitly overrides These exist in `lib/unit/class.rb` and have test coverage: @@ -23,62 +43,61 @@ These exist in `lib/unit/class.rb` and have test coverage: |---|---| | `*`, `/`, `+`, `-`, `**`, `-@`, `+@` | Arithmetic — well-tested | | `==`, `eql?`, `<=>` | Equality and ordering — well-tested, including edge cases | -| `hash` | Defined as `[value, unit].hash` — satisfies `eql?`/`hash` contract | -| `abs` | Preserves unit | +| `hash` | `[self.class, value, unit].hash` — satisfies `eql?`/`hash` contract; `self.class` prevents bucket collisions with plain arrays of the same shape | +| `abs` | Preserves unit; for complex values delegates to `value.abs` (gives magnitude) | +| `real?` | Delegates to `value.real?` — `false` for complex-valued units | +| `real` | Returns `self` for real values; `Unit(value.real, unit)` for complex | +| `imag` / `imaginary` | Returns `0` for real values; `Unit(value.imag, unit)` for complex | +| `conj` / `conjugate` | Returns `self` for real values; `Unit(value.conj, unit)` for complex | +| `rect` / `rectangular` | Returns `[real, imag]` — unit-preserving in both components | | `zero?`, `positive?`, `negative?` | Delegate to `value` | -| `numerator`, `denominator` | Delegate to `value` | | `finite?`, `infinite?` | Delegate to `value` when `value` responds; correct fallbacks otherwise | +| `numerator`, `denominator` | Inherited from `Numeric` via `to_r` (see below) | +| `to_r` | Raises `RangeError` for dimensional units (Complex precedent); converts normally for dimensionless; gives `numerator`/`denominator` for free | +| `to_i`, `to_f` | Raise `RangeError` for dimensional units (Complex precedent); convert normally for dimensionless | | `remainder` | Explicit implementation: `self - (self / other).truncate * other` | | `ceil`, `floor`, `truncate` | Override to preserve unit; accept `ndigits` argument | | `round` | Preserves unit; accepts `ndigits` and `half:` keyword | -| `to_i`, `to_f` | Strip unit, return bare numeric | +| `fdiv` | Returns `(self / other).approx` — unit-preserving with float value; fixes inherited `Numeric#fdiv` which called `self.to_f` (now raises for dimensional units) | | `coerce` | Enables Integer/Float on left-hand side | | `dup`, `initialize_copy`, `freeze` | Immutability contract — tested | | `inspect`, `to_s`, `to_tex` | Formatting | -| `approx` | Returns Float-valued unit | +| `approx` | Returns Float-valued unit via `@value.to_f` | --- ## Inherited Numeric methods — behaviour inventory -### Group 1: Work correctly, with tests - -These were previously untested; coverage was added alongside the bug fixes. +### Group 1: Inherited, work correctly, with tests | Method | Behaviour | Notes | |---|---|---| | `+@` | Returns `self` | | -| `abs2` | Returns `Unit(value**2, unit**2)` — e.g. `Unit(3,'m').abs2 == Unit(9,'m^2')` | Dimensionally correct via `self * self.conj` | +| `abs2` | `Unit(value.abs2, unit**2)` — e.g. `Unit(3,'m').abs2 == Unit(9,'m^2')`; for complex values gives `\|z\|² · unit²` | Correct: `self * self.conj` | | `nonzero?` | Returns `self` or `nil` | | | `integer?` | Always `false` | `Unit` is not `Integer` | -| `real?` | Always `true` | | -| `real` | Returns `self` | | -| `imag` / `imaginary` | Returns `0` | | -| `conj` / `conjugate` | Returns `self` | | -| `rect` / `rectangular` | Returns `[self, 0]` | | | `modulo` / `%` | Preserves unit; result sign matches divisor | | | `divmod` | Returns `[floored_quotient, remainder]`; quotient is dimensionless for compatible units | | -| `div` | Floored integer quotient; compatible units → dimensionless; incompatible → combined dimension | Unit-preserving as side-effect of overriding `floor` | -| `fdiv(scalar)` | Returns bare `Float` (unit stripped) — e.g. `Unit(3,'m').fdiv(2) => 1.5` | See note below | -| `to_int` | Strips unit, returns `Integer` (same as `to_i`) | | +| `div` | Floored integer quotient; compatible units → dimensionless `Unit`; incompatible → combined dimension `Unit` | Unit-preserving as side-effect of `floor` override | +| `to_int` | Raises `RangeError` for dimensional units (calls `to_i` internally) | | | `between?` | Works via `<=>` | | | `clamp` | Works for both two-arg and Range forms | | --- -### Group 2: Work correctly, still untested +### Group 2: Inherited, work correctly, no tests yet | Method | Behaviour | Notes | |---|---|---| -| `to_c` | Returns `(Unit("3 m")+0i)` | Works via `Numeric#to_c`; unit ends up inside the complex | -| `clone` | Returns a distinct copy (not `self`) | Unlike `dup` on a plain Numeric; `Unit` overrides `initialize_copy` so clone behaves normally | +| `to_c` | Returns `Complex`-wrapped unit value | Works via `Numeric#to_c`; unit ends up inside the complex | +| `clone` | Returns a distinct copy | `Unit` overrides `initialize_copy` so clone works normally | -`Kernel` conversion functions work and strip the unit: +`Kernel` conversion functions strip the unit and return a bare numeric: ```ruby -Float(Unit(3.5, 'm')) # => 3.5 (bare Float) -Integer(Unit(3, 'm')) # => 3 (bare Integer) -Rational(Unit(3, 'm')) # => (3/1) (bare Rational) +Float(Unit(3.5, 'm')) # => 3.5 (bare Float — raises for dimensional same as to_f) +Integer(Unit(3, 'm')) # => 3 (bare Integer — raises for dimensional same as to_i) +Rational(Unit(3, 'm')) # => (3/1) (bare Rational — raises for dimensional same as to_r) ``` No tests for any of these. @@ -87,33 +106,19 @@ No tests for any of these. ### Group 3: Known remaining issues -#### `fdiv` with a Unit divisor returns a wrong dimension - -`Numeric#fdiv` is implemented as `self.to_f / other`. `to_f` strips the unit -from `self` (yielding e.g. `3.0`), then `3.0 / Unit(2.0,'m')` goes through -coerce, producing `Unit(1.5, 'm^-1')` instead of the correct `1.5`: - -```ruby -Unit(3, 'm').fdiv(Unit(2, 'm')) # => Unit("1.5 m^-1") ← wrong (should be 1.5) -Unit(3, 'm').fdiv(2) # => 1.5 ← correct -``` - -`fdiv` should be overridden to delegate to `(self / other).to_f` for the unit -case, or restricted to scalar divisors only. - #### `magnitude` raises but `abs` works — asymmetry -`abs` is overridden in `Unit` and works correctly. `magnitude` is inherited from -`Numeric` (it is an alias for `abs` in `Numeric` but `Numeric#magnitude` calls -`abs` on `self`, which goes through `<=>` internally on some paths) and raises -`ArgumentError` for dimensional units: +`abs` is overridden and works correctly for both real and complex values. +`magnitude` is an alias for `abs` in `Numeric` but is not inherited as an alias +in `Unit` — it falls through to `Numeric#magnitude` which calls `abs` via a +path that internally uses `<=>`, raising `ArgumentError` for dimensional units: ```ruby Unit(3, 'm').abs # => Unit("3 m") ✓ Unit(3, 'm').magnitude # => ArgumentError ✗ ``` -The fix is to alias `magnitude` to `abs` in `Unit`. +Fix: `alias magnitude abs` in `Unit`. #### `step` raises for dimensional units @@ -125,8 +130,8 @@ Unit(0, 'm').step(Unit(3, 'm'), Unit(1, 'm')) { } # => ArgumentError: comparison of Unit("1 m") with Integer failed ``` -This could be fixed by overriding `step` to work directly on the value while -re-wrapping each yielded result, or by documenting it as unsupported. +Could be fixed by overriding `step` to iterate on the bare value and re-wrap +each yielded result, or documented as unsupported. #### `angle` / `arg` / `phase` / `polar` raise for dimensional units @@ -139,7 +144,9 @@ Unit(3, 'm').polar # => ArgumentError For dimensionless units they work correctly. These are arguably correct behaviour — a dimensional quantity doesn't have a phase angle — but they should -have tests confirming the error and documenting the intent. +have tests confirming the error and documenting the intent. Note: for +*complex-valued* dimensional units, `angle` and `polar` are well-defined; +this is a separate issue from the dimensional-scalar case. --- @@ -150,7 +157,7 @@ error should be added so the behaviour is explicit and can't regress silently: | Method | Behaviour | |---|---| -| `angle` / `arg` / `phase` | `ArgumentError` — dimensional `<=>` 0 fails | +| `angle` / `arg` / `phase` | `ArgumentError` — dimensional `<=>` 0 fails (real-valued units) | | `magnitude` | `ArgumentError` — should be aliased to `abs` (see above) | | `polar` | `ArgumentError` — calls `angle` internally | | `step` | `ArgumentError` — compares step value with `Integer` | @@ -159,29 +166,31 @@ error should be added so the behaviour is explicit and can't regress silently: --- -## Summary of issues (current state) +## Summary of issues ### Fixed -| # | Method(s) | Fix applied | +| # | Method(s) | Fix | |---|---|---| -| 1 | `hash` | Defined as `[value, unit].hash` | +| 1 | `hash` | `[self.class, value, unit].hash` — satisfies `eql?`/`hash` contract | | 2 | `ceil`, `floor`, `truncate` | Overridden to preserve unit; consistent with `round` | -| 3 | `finite?`, `infinite?` | Overridden to delegate to `value` | -| 4 | `positive?`, `negative?` | Overridden to delegate to `value` | -| 5 | `numerator`, `denominator` | Overridden to delegate to `value` | +| 3 | `finite?`, `infinite?` | Delegate to `value` | +| 4 | `positive?`, `negative?` | Delegate to `value` | +| 5 | `numerator`, `denominator` | Fixed by defining `to_r`; both work via `Numeric#numerator` → `self.to_r` | | 6 | `remainder` | Explicit implementation using `truncate` | -| 7 | `div` with incompatible units | Fixed as side-effect of `floor` override | -| 8 | `round(half:)` | Fixed — `round` now accepts `**opts` | +| 7 | `div` silent unit loss | Fixed as side-effect of `floor` override | +| 8 | `round(half:)` | `round` now forwards all args | +| 9 | `to_i` / `to_f` silent unit strip | Now raise `RangeError` for dimensional units (Complex precedent) | +| 10 | `fdiv` wrong dimension | Overridden as `(self / other).approx` | +| 11 | `real?`, `real`, `imag`, `conj`, `rect` | Delegate to `value` — correct for complex-valued units | ### Open | # | Method(s) | Severity | Proposed fix | |---|---|---|---| -| 9 | `fdiv` with Unit divisor | **Medium** — wrong dimension in result | Override `fdiv` to use `(self/other).to_f` | -| 10 | `magnitude` vs `abs` | **Low** — asymmetric; magnitude raises | `alias_method :magnitude, :abs` | -| 11 | `step` | **Low** — raises for all dimensional units | Override or document as unsupported | -| 12 | `angle`/`arg`/`phase`/`polar` | **Low** — raise, arguably correct | Add confirming tests | +| 12 | `magnitude` vs `abs` | **Low** — asymmetric; magnitude raises | `alias magnitude abs` | +| 13 | `step` | **Low** — raises for all dimensional units | Override or document as unsupported | +| 14 | `angle`/`arg`/`phase`/`polar` for complex-valued dimensional units | **Low** — raises via `<=>` but `angle` is well-defined for complex values | Override to use `value.angle` when `!value.real?` | ### Untested working behaviour @@ -191,6 +200,7 @@ error should be added so the behaviour is explicit and can't regress silently: | `clone` | Not tested | | `Kernel` conversions (`Float()`, `Integer()`, `Rational()`) | Not tested | | `angle`/`magnitude`/`step`/`polar` raise paths | Not tested (Group 4 above) | +| `abs2` for complex-valued units | Not tested | --- @@ -258,12 +268,13 @@ input form. ## Remaining suggested test additions ```ruby -describe "#to_c" do ... end # Group 2: untested, works -describe "#clone" do ... end # Group 2: untested, works -describe "Kernel conversions" do # Group 2: Float(), Integer(), Rational() -describe "#fdiv with Unit divisor" do ... end # Open issue 9 -describe "#magnitude" do ... end # Open issue 10 (should alias abs) -describe "#step" do ... end # Open issue 11 -describe "raises by design" do # Group 4: angle, polar, step, magnitude -describe "#** with large exponents" do ... end # version-gated: 3.3 vs 3.4+ +describe "#to_c" do ... end # Group 2: untested, works +describe "#clone" do ... end # Group 2: untested, works +describe "Kernel conversions" do ... end # Float(), Integer(), Rational() +describe "#magnitude" do ... end # Open issue 12 (should alias abs) +describe "#step" do ... end # Open issue 13 +describe "raises by design" do ... end # Group 4: angle, polar, step, magnitude +describe "#** with large exponents" do ... end # version-gated: 3.3 vs 3.4+ +describe "#abs2 with complex value" do ... end # complex Unit(Complex(3,4),'m').abs2 +describe "#angle/#polar for complex-valued units" do # open issue 14 ```