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