amounts 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf7f88449ef3636b2ad506b569793e7943f6cd16b82bb1013d8eb7c8a866c41b
4
- data.tar.gz: 49e3027de827e8cd1747fd5df795e3a605736a6192298cc403a1a9de19ad158f
3
+ metadata.gz: 8b7620b025be07f1166e655108c2b9cea0232acb60d814cf91b71ecb47ddf1fb
4
+ data.tar.gz: 8f8ab43608f4ced54ab5020c7cbcf69b0741c9203e549558b27356766187efb5
5
5
  SHA512:
6
- metadata.gz: 51e5cc2f4c0e9c1cad9289933283c9d687c36618edb09ed7524c7a366e104c98d46386ebafac1db67f129c72a2605248d3a0bf44b4962cc83aab4ae7154c7af6
7
- data.tar.gz: 738dcbbc4f4aae614d002f4c127ecdb820585e0fee51ec61ddc9381d55acd264ea7db87d1150afb15a96bd803af03d9f2dbd9b672415d4fdf81ec3400e67f06c
6
+ metadata.gz: 466350a8fbfff01e84bd044f23ced9d63288eb4147f13d70df2a3db9b37745cafee46e03cb3d1d9b0b805fff8ece3ca13df029c19786204f21e2fe1336b1e564
7
+ data.tar.gz: 807ba283121158491de02a78084dae5ce12c6360ac106a7d72a1bde6a7f536ac3009fdf329b9e75972fe3631ade8bdae3dd0f2d1f2d2e74f9a023f28d46f5cb1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.4 - 2026-04-26
4
+
5
+ ### Changed (breaking)
6
+
7
+ - The opt-in RSpec integration moved into a dedicated `Amount::RSpec` namespace
8
+ with one file per concern. Constants and require paths changed:
9
+
10
+ | Old | New |
11
+ | --- | --- |
12
+ | `Amount::RSpecMatchers` | `Amount::RSpec::Matchers` |
13
+ | `Amount::RSpecSupport` | `Amount::RSpec::Support` |
14
+ | `lib/amount/rspec_matchers.rb` | `lib/amount/rspec/matchers.rb` |
15
+ | `lib/amount/rspec_support.rb` | `lib/amount/rspec/support.rb` |
16
+
17
+ The ActiveRecord-specific matchers also live in their own file under the same
18
+ pattern (`Amount::ActiveRecord::RSpec::Matchers` in
19
+ `lib/amount/active_record/rspec/matchers.rb`). Top-level `require` paths
20
+ (`require "amount/rspec"` and `require "amount/active_record/rspec"`) are
21
+ unchanged.
22
+
23
+ Update any direct constant references; no backwards-compatibility aliases are
24
+ shipped.
25
+
26
+ ### Changed
27
+
28
+ - The `Amount` instance behavior is now composed from focused mixins instead
29
+ of all living in a single 500+ line file. New modules under `lib/amount/`:
30
+ `Arithmetic` (`+`, `-`, `*`, `/`, `abs`, `-@`),
31
+ `Comparison` (`<=>`, `==`, `eql?`, `hash`, `same_type?`, sign predicates),
32
+ `Conversion` (`to(target_symbol, rate:)`),
33
+ `Allocation` (`split`, `allocate`),
34
+ and `Serialization` (instance `to_h` plus `Serialization::ClassMethods.load`
35
+ auto-extended via the `included` hook).
36
+ Public API unchanged. Shared private helpers (`build`,
37
+ `coerce_other_to_self_type[!]`, `ensure_same_type!`, `infer_value`,
38
+ `infer_type`, `ui_to_atomic`) remain on the main `Amount` class so every
39
+ mixin can call them.
40
+
41
+ ### Removed
42
+
43
+ - `Amount::Serializer` is gone. Its `dump`/`load` class methods moved into
44
+ `Amount::Serialization` (instance `to_h` plus `ClassMethods.load`).
45
+ `Amount.load(hash)` and `Amount#to_h` are unchanged.
46
+
47
+ ## 0.0.3 - 2026-04-26
48
+
49
+ ### Fixed
50
+
51
+ - Rational scalars and rates are now accepted everywhere they are documented to
52
+ work: `Amount#*`, `Amount#/`, `Amount#to(rate:)`, `Amount.register_default_rate`,
53
+ and `display_units[unit][:scale]`. Previously each of these called
54
+ `BigDecimal(value.to_s)`, which raises `ArgumentError` for `Rational#to_s`
55
+ (`"3/2"`). All five sites now go through a single internal helper,
56
+ `Amount.coerce_decimal`, that handles `BigDecimal`, `Rational`, and string-or-
57
+ numeric inputs uniformly.
58
+ - `Amount.load` wraps a missing `:atomic` or `:symbol` key as
59
+ `Amount::InvalidInput` (`"amount payload missing key: atomic"`) instead of
60
+ leaking a raw `KeyError`. This matches the AR adapter's `Type#cast_hash`
61
+ behavior so callers can rescue a single error class.
62
+
3
63
  ## 0.0.2 - 2026-04-26
4
64
 
5
65
  ### Fixed
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ module ActiveRecord
5
+ module RSpec
6
+ # ActiveRecord-specific matcher definitions. Companion to
7
+ # `Amount::RSpec::Matchers` which holds the gem-core matchers.
8
+ module Matchers
9
+ module_function
10
+
11
+ def define_amount_column_matcher
12
+ ::RSpec::Matchers.define :have_amount_column do |name, *expected_arguments|
13
+ match do |record|
14
+ @name = name
15
+ @expected = Amount::RSpec::Support.coerce_amount_arguments(expected_arguments)
16
+ @definition = record.class.amount_attribute_definitions.fetch(name.to_sym)
17
+
18
+ @definition.read(record) == @expected &&
19
+ record.public_send(@definition.atomic_column).to_i == @expected.atomic &&
20
+ (@definition.fixed_symbol? ||
21
+ record.public_send(@definition.symbol_column) == @expected.symbol.to_s)
22
+ end
23
+
24
+ failure_message do |record|
25
+ "expected #{record.inspect} to have #{@name} column matching #{@expected.inspect}"
26
+ end
27
+ end
28
+ end
29
+
30
+ def define_amount_sum_matcher
31
+ ::RSpec::Matchers.define :match_amounts do |expected_hash|
32
+ match do |actual_hash|
33
+ @expected = expected_hash.to_h do |symbol, value|
34
+ amount = Amount.new(value, symbol)
35
+ [amount.symbol, amount]
36
+ end
37
+ @actual = Amount::RSpec::Support.normalize_amount_sums(actual_hash)
38
+
39
+ @actual == @expected
40
+ end
41
+
42
+ failure_message do |_actual_hash|
43
+ "expected grouped amounts #{@actual.inspect} to match #{@expected.inspect}"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../active_record"
4
4
  require "rspec/expectations"
5
5
  require_relative "../rspec"
6
+ require_relative "rspec/matchers"
6
7
 
7
- Amount::RSpecMatchers.define_amount_column_matcher
8
- Amount::RSpecMatchers.define_amount_sum_matcher
8
+ Amount::ActiveRecord::RSpec::Matchers.define_amount_column_matcher
9
+ Amount::ActiveRecord::RSpec::Matchers.define_amount_sum_matcher
@@ -77,12 +77,11 @@ class Amount
77
77
  private
78
78
 
79
79
  def cast_hash(value)
80
- if value.key?(:atomic) || value.key?("atomic")
80
+ value = value.transform_keys(&:to_sym)
81
+ if value.key?(:atomic)
81
82
  ::Amount.load(value)
82
83
  else
83
- symbol = value.fetch(:symbol) { value.fetch("symbol", fixed_symbol) }
84
- amount_value = value.fetch(:value) { value.fetch("value") }
85
- ::Amount.new(amount_value, symbol)
84
+ ::Amount.new(value.fetch(:value), value.fetch(:symbol, fixed_symbol))
86
85
  end
87
86
  rescue KeyError
88
87
  raise ::Amount::InvalidInput, "hash input must contain atomic/symbol or value/symbol"
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Splitting and proportional allocation. Both operations return
5
+ # `[parts, remainder]`, preserving the invariant that the parts plus
6
+ # remainder always sum back to the receiver's atomic value (including
7
+ # the sign).
8
+ module Allocation
9
+ # Splits into equal parts and returns the leftover explicitly.
10
+ #
11
+ # @param n [Integer]
12
+ # @return [Array<(Array<Amount>, Amount)>]
13
+ # @raise [ArgumentError] if `n` is not a positive integer
14
+ # @example
15
+ # parts, remainder = Amount.new(10, :LOGS).split(3)
16
+ # parts.map(&:atomic)
17
+ # # => [3, 3, 3]
18
+ # remainder.atomic
19
+ # # => 1
20
+ def split(n)
21
+ raise ArgumentError, "n must be positive" unless n.is_a?(Integer) && n.positive?
22
+
23
+ sign = atomic_sign
24
+ base, remainder = @atomic.abs.divmod(n)
25
+ parts = Array.new(n) { build(sign * base) }
26
+
27
+ [parts, build(sign * remainder)]
28
+ end
29
+
30
+ # Allocates proportionally by integer weights and returns the leftover explicitly.
31
+ #
32
+ # @param weights [Array<Integer>]
33
+ # @return [Array<(Array<Amount>, Amount)>]
34
+ # @raise [ArgumentError] if weights are empty, negative, or sum to zero
35
+ # @example
36
+ # parts, remainder = Amount.new(10, :LOGS).allocate([1, 1, 2])
37
+ # parts.map(&:atomic)
38
+ # # => [2, 2, 5]
39
+ # remainder.atomic
40
+ # # => 1
41
+ def allocate(weights)
42
+ raise ArgumentError, "weights must be non-empty" if weights.empty?
43
+ raise ArgumentError, "weights must be non-negative integers" unless weights.all? { |weight| weight.is_a?(Integer) && weight >= 0 }
44
+
45
+ total = weights.sum
46
+ raise ArgumentError, "weights must sum to positive value" unless total.positive?
47
+
48
+ sign = atomic_sign
49
+ absolute_atomic = @atomic.abs
50
+ allocations = weights.map { |weight| absolute_atomic * weight / total }
51
+ remainder = absolute_atomic - allocations.sum
52
+
53
+ parts = allocations.map { |allocation| build(sign * allocation) }
54
+ [parts, build(sign * remainder)]
55
+ end
56
+
57
+ private
58
+
59
+ def atomic_sign
60
+ return 1 if @atomic.positive?
61
+ return(-1) if @atomic.negative?
62
+
63
+ 0
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Arithmetic operators for `Amount`. Mixed into `Amount` and inherited by
5
+ # any registered subclass.
6
+ #
7
+ # All operators preserve the receiver's class via `build`, so subclass
8
+ # identity (`GoldAmount`) survives through `+`, `-`, `*`, `/`, `abs`, `-@`.
9
+ module Arithmetic
10
+ # @return [Amount]
11
+ # @example
12
+ # Amount.usdc("-1").abs
13
+ # # => #<Amount USDC $1.00>
14
+ def abs
15
+ build(@atomic.abs)
16
+ end
17
+
18
+ # @return [Amount]
19
+ # @example
20
+ # -Amount.usdc("1")
21
+ # # => #<Amount USDC -$1.00>
22
+ def -@
23
+ build(-@atomic)
24
+ end
25
+
26
+ # @param other [Amount]
27
+ # @return [Amount]
28
+ # @raise [TypeMismatch]
29
+ # @example Same-type addition
30
+ # Amount.usdc("1.50") + Amount.usdc("0.50")
31
+ #
32
+ # @example Cross-type addition using a registered directional rate
33
+ # Amount.register_default_rate :USD, :USDC, "1"
34
+ # Amount.usdc("10.00") + Amount.new("5.00", :USD)
35
+ def +(other)
36
+ rhs = coerce_other_to_self_type!(other)
37
+ build(@atomic + rhs.atomic)
38
+ end
39
+
40
+ # @param other [Amount]
41
+ # @return [Amount]
42
+ # @raise [TypeMismatch]
43
+ # @example
44
+ # Amount.usdc("2.00") - Amount.usdc("0.50")
45
+ def -(other)
46
+ rhs = coerce_other_to_self_type!(other)
47
+ build(@atomic - rhs.atomic)
48
+ end
49
+
50
+ # @param scalar [Numeric]
51
+ # @return [Amount]
52
+ # @raise [TypeMismatch]
53
+ # @example
54
+ # Amount.usdc("1.25") * 2
55
+ def *(scalar)
56
+ ensure_scalar!(scalar)
57
+ build((BigDecimal(@atomic) * Amount.coerce_decimal(scalar)).to_i)
58
+ end
59
+
60
+ # @param other [Amount, Numeric]
61
+ # @return [Amount, BigDecimal]
62
+ # @raise [TypeMismatch, ZeroDivisionError]
63
+ # @example Dividing by a scalar returns an amount
64
+ # Amount.usdc("1.00") / 2
65
+ #
66
+ # @example Dividing by an amount returns a ratio
67
+ # Amount.usdc("10.00") / Amount.usdc("2.00")
68
+ def /(other)
69
+ if other.is_a?(Amount)
70
+ ensure_same_type!(other)
71
+ raise ZeroDivisionError if other.zero?
72
+
73
+ BigDecimal(@atomic) / BigDecimal(other.atomic)
74
+ else
75
+ ensure_scalar!(other)
76
+ raise ZeroDivisionError if other.zero?
77
+
78
+ build((BigDecimal(@atomic) / Amount.coerce_decimal(other)).to_i)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def ensure_scalar!(value)
85
+ return if value.is_a?(Integer) || value.is_a?(Float) ||
86
+ value.is_a?(BigDecimal) || value.is_a?(Rational)
87
+
88
+ raise TypeMismatch, "expected scalar, got #{value.class}"
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Comparison, equality, hashing, and sign predicates for `Amount`.
5
+ #
6
+ # Pulls in `Comparable` itself so consumers only need `include Comparison`
7
+ # to get `<`, `<=`, `>`, `>=`, `between?`, `clamp`, and `Enumerable#min`/
8
+ # `#max` alongside the explicit `<=>` / `==` / `eql?` / `hash` defined here.
9
+ module Comparison
10
+ include Comparable
11
+ # @param other [Object]
12
+ # @return [Boolean]
13
+ # @example
14
+ # Amount.usdc("1").same_type?(Amount.usdc("2"))
15
+ # # => true
16
+ def same_type?(other)
17
+ other.is_a?(Amount) && other.symbol == symbol
18
+ end
19
+
20
+ # @return [Boolean]
21
+ # @example
22
+ # Amount.usdc(0, from: :atomic).zero?
23
+ # # => true
24
+ def zero? = @atomic.zero?
25
+
26
+ # @return [Boolean]
27
+ # @example
28
+ # Amount.usdc("1").positive?
29
+ # # => true
30
+ def positive? = @atomic.positive?
31
+
32
+ # @return [Boolean]
33
+ # @example
34
+ # Amount.usdc("-1").negative?
35
+ # # => true
36
+ def negative? = @atomic.negative?
37
+
38
+ # @param other [Object]
39
+ # @return [-1, 0, 1, nil]
40
+ # @example
41
+ # Amount.usdc("1") <=> Amount.usdc("2")
42
+ # # => -1
43
+ def <=>(other)
44
+ return nil unless other.is_a?(Amount)
45
+
46
+ comparable = coerce_other_to_self_type(other)
47
+ return nil unless comparable
48
+
49
+ @atomic <=> comparable.atomic
50
+ end
51
+
52
+ # @param other [Object]
53
+ # @return [Boolean]
54
+ # @example
55
+ # Amount.usdc("1.50") == Amount.usdc("1.50")
56
+ # # => true
57
+ def ==(other)
58
+ same_type?(other) && @atomic == other.atomic
59
+ end
60
+
61
+ # @param other [Object]
62
+ # @return [Boolean]
63
+ # @example Hash-key equality keeps class and symbol identity
64
+ # Amount.usdc("1").eql?(Amount.usdc("1"))
65
+ # # => true
66
+ def eql?(other)
67
+ other.class == self.class && symbol == other.symbol && @atomic == other.atomic
68
+ end
69
+
70
+ # @return [Integer]
71
+ # @example
72
+ # { Amount.usdc("1") => :ok }[Amount.usdc("1")]
73
+ # # => :ok
74
+ def hash
75
+ [self.class, symbol, @atomic].hash
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Cross-type conversion via `to(target_symbol, rate:)`. Uses an explicitly
5
+ # passed rate when provided, otherwise looks up the registered directional
6
+ # default rate. The result class is taken from the target symbol's registry
7
+ # entry, so a `class:`-registered subclass becomes the natural identity for
8
+ # conversion outputs.
9
+ module Conversion
10
+ # @param target_symbol [Symbol, String]
11
+ # @param rate [String, Numeric, BigDecimal, nil]
12
+ # @return [Amount]
13
+ # @raise [Amount::Registry::NoDefaultRate] if no explicit or registered rate is available
14
+ # @example Using an explicit one-off rate
15
+ # Amount.usdc("100").to(:GOLD, rate: "0.00042")
16
+ #
17
+ # @example Using a registered default rate
18
+ # Amount.register_default_rate :USDC, :USD, "1"
19
+ # Amount.usdc("1.50").to(:USD)
20
+ def to(target_symbol, rate: nil)
21
+ target_symbol = target_symbol.to_sym
22
+ return self.class.new(@atomic, symbol, from: :atomic) if target_symbol == symbol
23
+
24
+ rate = resolve_rate(target_symbol, rate)
25
+ target_entry = self.class.registry.lookup(target_symbol)
26
+
27
+ decimal_result = decimal * Amount.coerce_decimal(rate)
28
+ atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
29
+
30
+ target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_rate(target, provided)
36
+ return provided if provided
37
+
38
+ self.class.registry.default_rate(symbol, target)
39
+ end
40
+ end
41
+ end
@@ -32,7 +32,7 @@ class Amount
32
32
  # @return [BigDecimal]
33
33
  def in_unit(unit)
34
34
  unit_spec = fetch_display_unit(unit)
35
- @amount.decimal * BigDecimal(unit_spec[:scale].to_s)
35
+ @amount.decimal * Amount.coerce_decimal(unit_spec[:scale])
36
36
  end
37
37
 
38
38
  private
@@ -44,7 +44,7 @@ class Amount
44
44
 
45
45
  def render_display_unit(unit, direction)
46
46
  spec = fetch_display_unit(unit)
47
- scaled = @amount.decimal * BigDecimal(spec[:scale].to_s)
47
+ scaled = @amount.decimal * Amount.coerce_decimal(spec[:scale])
48
48
  decimals = spec[:ui_decimals] || @entry.ui_decimals
49
49
  rounded = round(scaled, decimals, direction)
50
50
 
data/lib/amount/parser.rb CHANGED
@@ -3,8 +3,8 @@
3
3
  class Amount
4
4
  # Parses compact amount strings such as `USDC|1.50`.
5
5
  class Parser
6
- VERSION_PREFIX = /\A(v\d+):(.*)\z/
7
6
  SUPPORTED_VERSION = "v1"
7
+ VERSION_PREFIX = /\A(v\d+):(.*)\z/
8
8
 
9
9
  def initialize(input)
10
10
  @input = input
@@ -21,23 +21,30 @@ class Amount
21
21
  # Amount.registry.lock!
22
22
  class Registry
23
23
  class UnknownType < StandardError; end
24
- class AlreadyRegistered < StandardError; end
25
- class InvalidDisplayUnit < StandardError; end
26
24
  class NoDefaultRate < StandardError; end
27
25
  class RegistryLocked < StandardError; end
26
+ class AlreadyRegistered < StandardError; end
27
+ class InvalidDisplayUnit < StandardError; end
28
28
 
29
29
  Entry = Struct.new(
30
- :symbol, :decimals, :display_symbol, :display_position,
31
- :ui_decimals, :display_units, :default_display, :amount_class,
30
+ :symbol,
31
+ :decimals,
32
+ :display_symbol,
33
+ :display_position,
34
+ :ui_decimals,
35
+ :display_units,
36
+ :default_display,
37
+ :amount_class,
32
38
  keyword_init: true
33
39
  )
34
40
 
35
41
  def initialize
36
- @entries = {}
42
+ @entries = {}
37
43
  @default_rates = {}
38
- @generated_constructors = GeneratedConstructors.new
39
- @locked = false
44
+ @locked = false
45
+
40
46
  @lock = Mutex.new
47
+ @generated_constructors = GeneratedConstructors.new
41
48
  end
42
49
 
43
50
  # Registers a new fungible type.
@@ -67,6 +74,8 @@ class Amount
67
74
  def register(symbol, decimals:, display_symbol: symbol.to_s, display_position: :suffix,
68
75
  ui_decimals: decimals, display_units: nil, default_display: nil,
69
76
  class: nil)
77
+ raise ArgumentError, "symbol must not be blank" if symbol.nil? || symbol.to_s.empty?
78
+
70
79
  symbol = symbol.to_sym
71
80
 
72
81
  @lock.synchronize do
@@ -151,7 +160,7 @@ class Amount
151
160
 
152
161
  @lock.synchronize do
153
162
  ensure_unlocked!
154
- @default_rates[[from, to]] = BigDecimal(rate.to_s)
163
+ @default_rates[[from, to]] = Amount.coerce_decimal(rate)
155
164
  end
156
165
  end
157
166
 
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ module RSpec
5
+ # Internal matcher helpers for the opt-in RSpec integration.
6
+ #
7
+ # All bare `RSpec` references inside this module are fully qualified to
8
+ # `::RSpec` to dodge the constant-lookup ambiguity introduced by living
9
+ # under `Amount::RSpec`.
10
+ module Matchers
11
+ module_function
12
+
13
+ def define_amount_equality_matcher(name, &expected_builder)
14
+ ::RSpec::Matchers.define name do |*arguments|
15
+ match do |actual|
16
+ @expected = instance_exec(*arguments, &expected_builder)
17
+ actual.is_a?(Amount) && actual == @expected
18
+ end
19
+
20
+ failure_message do |actual|
21
+ return "expected #{actual.inspect} to be an Amount equal to #{@expected.inspect}" unless actual.is_a?(Amount)
22
+
23
+ "expected #{actual.inspect} to equal amount #{@expected.inspect}"
24
+ end
25
+ end
26
+ end
27
+
28
+ def define_amount_predicate_matcher(name, description, &predicate)
29
+ ::RSpec::Matchers.define name do
30
+ match do |actual|
31
+ actual.is_a?(Amount) && instance_exec(actual, &predicate)
32
+ end
33
+
34
+ failure_message do |actual|
35
+ "expected #{actual.inspect} to be #{description}"
36
+ end
37
+ end
38
+ end
39
+
40
+ def define_amount_type_matcher
41
+ ::RSpec::Matchers.define :be_amount_of do |expected_symbol|
42
+ match do |actual|
43
+ @expected_symbol = expected_symbol.to_sym
44
+ actual.is_a?(Amount) && actual.symbol == @expected_symbol
45
+ end
46
+
47
+ failure_message do |actual|
48
+ "expected #{actual.inspect} to be an Amount of #{@expected_symbol}"
49
+ end
50
+ end
51
+ end
52
+
53
+ def define_approximate_amount_matcher
54
+ ::RSpec::Matchers.define :be_approximately_amount do |*expected_arguments, within:|
55
+ match do |actual|
56
+ @expected = Amount::RSpec::Support.coerce_amount_arguments(expected_arguments)
57
+ @within = Amount::RSpec::Support.coerce_delta(@expected, within)
58
+
59
+ actual.is_a?(Amount) &&
60
+ actual.same_type?(@expected) &&
61
+ @within.same_type?(@expected) &&
62
+ (actual - @expected).abs.atomic <= @within.atomic
63
+ end
64
+
65
+ failure_message do |actual|
66
+ return "expected #{actual.inspect} to be an Amount within #{@within.inspect} of #{@expected.inspect}" unless actual.is_a?(Amount)
67
+
68
+ "expected #{actual.inspect} to be within #{@within.inspect} of #{@expected.inspect}"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ module RSpec
5
+ # Shared coercion helpers for opt-in RSpec integrations.
6
+ module Support
7
+ module_function
8
+
9
+ def coerce_amount_arguments(arguments)
10
+ case arguments.length
11
+ when 1
12
+ coerce_amount(arguments.first)
13
+ when 2
14
+ Amount.new(arguments.last, arguments.first)
15
+ else
16
+ raise ArgumentError, "expected an Amount, a parse string, or a symbol/value pair"
17
+ end
18
+ end
19
+
20
+ def coerce_amount(value)
21
+ case value
22
+ when Amount then value
23
+ when String then Amount.parse(value)
24
+ when Hash then Amount.load(value)
25
+ else
26
+ raise ArgumentError, "cannot coerce #{value.inspect} into an Amount"
27
+ end
28
+ end
29
+
30
+ def coerce_delta(expected_amount, within)
31
+ case within
32
+ when Amount
33
+ within
34
+ when Integer, Float, BigDecimal, Rational, String
35
+ Amount.new(within, expected_amount.symbol)
36
+ else
37
+ raise ArgumentError, "cannot coerce #{within.inspect} into an amount delta"
38
+ end
39
+ end
40
+
41
+ def normalize_amount_sums(sum_hash)
42
+ sum_hash.to_h do |symbol, atomic|
43
+ amount = Amount.new(atomic, symbol, from: :atomic)
44
+ [amount.symbol, amount]
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/amount/rspec.rb CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  require_relative "../amount"
4
4
  require "rspec/expectations"
5
- require_relative "rspec_support"
6
- require_relative "rspec_matchers"
5
+ require_relative "rspec/support"
6
+ require_relative "rspec/matchers"
7
7
 
8
8
  # Opt-in RSpec matchers for `Amount`.
9
9
  #
@@ -16,12 +16,12 @@ require_relative "rspec_matchers"
16
16
  # expect(Amount.usdc("1.50")).to be_amount_of(:USDC)
17
17
  # expect(Amount.usdc("1.50")).to be_positive_amount
18
18
  # expect(Amount.usdc("1.55")).to be_approximately_amount(:USDC, "1.50", within: "0.10")
19
- Amount::RSpecMatchers.define_amount_equality_matcher(:eq_amount) do |*expected_arguments|
20
- Amount::RSpecSupport.coerce_amount_arguments(expected_arguments)
19
+ Amount::RSpec::Matchers.define_amount_equality_matcher(:eq_amount) do |*expected_arguments|
20
+ Amount::RSpec::Support.coerce_amount_arguments(expected_arguments)
21
21
  end
22
22
 
23
- Amount::RSpecMatchers.define_amount_type_matcher
24
- Amount::RSpecMatchers.define_amount_predicate_matcher(:be_zero_amount, "a zero Amount", &:zero?)
25
- Amount::RSpecMatchers.define_amount_predicate_matcher(:be_positive_amount, "a positive Amount", &:positive?)
26
- Amount::RSpecMatchers.define_amount_predicate_matcher(:be_negative_amount, "a negative Amount", &:negative?)
27
- Amount::RSpecMatchers.define_approximate_amount_matcher
23
+ Amount::RSpec::Matchers.define_amount_type_matcher
24
+ Amount::RSpec::Matchers.define_approximate_amount_matcher
25
+ Amount::RSpec::Matchers.define_amount_predicate_matcher(:be_zero_amount, "a zero Amount", &:zero?)
26
+ Amount::RSpec::Matchers.define_amount_predicate_matcher(:be_positive_amount, "a positive Amount", &:positive?)
27
+ Amount::RSpec::Matchers.define_amount_predicate_matcher(:be_negative_amount, "a negative Amount", &:negative?)