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/doc/numeric-compatibility.md b/doc/numeric-compatibility.md
new file mode 100644
index 0000000..e86d617
--- /dev/null
+++ b/doc/numeric-compatibility.md
@@ -0,0 +1,280 @@
+# 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 known issues.
+
+---
+
+## 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`.
+
+---
+
+## 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:
+
+| Method | Notes |
+|---|---|
+| `*`, `/`, `+`, `-`, `**`, `-@`, `+@` | Arithmetic — well-tested |
+| `==`, `eql?`, `<=>` | Equality and ordering — well-tested, including edge cases |
+| `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` |
+| `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 |
+| `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 via `@value.to_f` |
+
+---
+
+## Inherited Numeric methods — behaviour inventory
+
+### Group 1: Inherited, work correctly, with tests
+
+| Method | Behaviour | Notes |
+|---|---|---|
+| `+@` | Returns `self` | |
+| `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` |
+| `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 `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: Inherited, work correctly, no tests yet
+
+| Method | Behaviour | Notes |
+|---|---|---|
+| `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 strip the unit and return a bare numeric:
+
+```ruby
+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.
+
+---
+
+### Group 3: Known remaining issues
+
+#### `magnitude` raises but `abs` works — asymmetry
+
+`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 ✗
+```
+
+Fix: `alias magnitude abs` in `Unit`.
+
+#### `step` raises for dimensional units
+
+`Numeric#step` compares the step value against `Integer` internally, triggering
+the dimensional `<=>` guard:
+
+```ruby
+Unit(0, 'm').step(Unit(3, 'm'), Unit(1, 'm')) { }
+# => ArgumentError: comparison of Unit("1 m") with Integer failed
+```
+
+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
+
+All four call `self <=> 0` internally, which fails for dimensional units:
+
+```ruby
+Unit(3, 'm').angle # => ArgumentError
+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. Note: for
+*complex-valued* dimensional units, `angle` and `polar` are well-defined;
+this is a separate issue from the dimensional-scalar case.
+
+---
+
+### Group 4: Methods that raise by design (need confirming tests)
+
+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 (real-valued units) |
+| `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` |
+
+---
+
+## Summary of issues
+
+### Fixed
+
+| # | Method(s) | Fix |
+|---|---|---|
+| 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?` | 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` 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 |
+|---|---|---|---|
+| 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
+
+| 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) |
+| `abs2` for complex-valued units | Not tested |
+
+---
+
+## 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 two **behavioural changes** that affect `Unit`.
+
+### `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` (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` 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+.
+
+### `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.
+
+`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
+
+| 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** |
+| `Kernel#Float("1.")` | `ArgumentError` | `1.0` | `1.0` | no internal Unit impact |
+| All other inherited behaviours | identical | identical | identical | |
+
+---
+
+## 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 ... 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
+```
diff --git a/lib/unit/class.rb b/lib/unit/class.rb
index aae71cf..3a9ef9e 100644
--- a/lib/unit/class.rb
+++ b/lib/unit/class.rb
@@ -121,10 +121,101 @@ 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
+ # 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).
+ 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
+ # 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?
+ 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?
+ 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).
@@ -144,6 +235,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 +<=>+
@@ -206,20 +301,63 @@ 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)
- end
-
- def round(precision = 0)
- Unit.new(value.round(precision), unit, system)
+ Unit.new(@value.to_f, unit, system)
+ end
+
+ # 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 f5fd91c..95732dc 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)
@@ -285,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
@@ -349,6 +377,379 @@
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
+
+ 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"))
+ 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))
+ 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
+ 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
+ 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
+
+ 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 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 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 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 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] 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
+ 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 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 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
+
+ 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