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.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Versioned hash serialization. `include Serialization` does both halves:
5
+ # the instance side (`#to_h`) is mixed in directly, and the class-level
6
+ # entry point (`Amount.load`) is auto-extended onto the including class
7
+ # via the `included` hook below. The compact-string format is the
8
+ # responsibility of {Amount::Parser}.
9
+ module Serialization
10
+ VERSION = 1
11
+
12
+ def self.included(base)
13
+ base.extend(ClassMethods)
14
+ end
15
+
16
+ # Class-level methods automatically extended onto any class that does
17
+ # `include Serialization`.
18
+ module ClassMethods
19
+ # @param payload [Hash]
20
+ # @return [Amount]
21
+ # @raise [Amount::InvalidInput] for unsupported versions or missing keys
22
+ # @example
23
+ # Amount.load(v: 1, atomic: "1500000", symbol: "USDC")
24
+ def load(payload)
25
+ payload = payload.transform_keys(&:to_sym)
26
+ validate_serialization_version!(payload[:v])
27
+
28
+ Amount.new(payload.fetch(:atomic), payload.fetch(:symbol), from: :atomic)
29
+ rescue KeyError => e
30
+ raise Amount::InvalidInput, "amount payload missing key: #{e.key}"
31
+ end
32
+
33
+ private
34
+
35
+ def validate_serialization_version!(version)
36
+ return if version.nil? || version == VERSION
37
+
38
+ raise Amount::InvalidInput, "unsupported amount serialization version: #{version}"
39
+ end
40
+ end
41
+
42
+ # @return [Hash]
43
+ # @example
44
+ # Amount.usdc("1.50").to_h
45
+ # # => { v: 1, atomic: "1500000", symbol: "USDC" }
46
+ def to_h
47
+ {
48
+ v: VERSION,
49
+ atomic: @atomic.to_s,
50
+ symbol: @symbol.to_s
51
+ }
52
+ end
53
+ end
54
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Amount
4
- VERSION = "0.0.2"
4
+ VERSION = "0.0.4"
5
5
  end
data/lib/amount.rb CHANGED
@@ -7,7 +7,11 @@ require_relative "amount/version"
7
7
  require_relative "amount/registry"
8
8
  require_relative "amount/display"
9
9
  require_relative "amount/parser"
10
- require_relative "amount/serializer"
10
+ require_relative "amount/arithmetic"
11
+ require_relative "amount/allocation"
12
+ require_relative "amount/comparison"
13
+ require_relative "amount/conversion"
14
+ require_relative "amount/serialization"
11
15
 
12
16
  # Represents a precise quantity of a registered fungible type.
13
17
  #
@@ -16,6 +20,13 @@ require_relative "amount/serializer"
16
20
  # strings or decimals, while integer inputs are treated as atomic counts unless
17
21
  # `from:` overrides inference.
18
22
  #
23
+ # Behavior is composed from a set of focused mixins:
24
+ # - {Arithmetic} — `+`, `-`, `*`, `/`, `abs`, `-@`
25
+ # - {Comparison} — `<=>`, `==`, `eql?`, `hash`, `same_type?`, sign predicates
26
+ # - {Conversion} — `to(:SYMBOL, rate:)`
27
+ # - {Allocation} — `split(n)`, `allocate(weights)`
28
+ # - {Serialization} — `to_h`
29
+ #
19
30
  # @example Constructing from a UI value
20
31
  # Amount.register :USDC, decimals: 6
21
32
  #
@@ -26,7 +37,12 @@ require_relative "amount/serializer"
26
37
  # Amount.usdc(1_500_000, from: :atomic).decimal.to_s("F")
27
38
  # # => "1.5"
28
39
  class Amount
29
- include Comparable
40
+ include Arithmetic
41
+ include Allocation
42
+ include Comparison
43
+ include Conversion
44
+ include Serialization
45
+
30
46
  extend Forwardable
31
47
 
32
48
  class Error < StandardError; end
@@ -102,6 +118,22 @@ class Amount
102
118
  super
103
119
  end
104
120
 
121
+ # Coerces a numeric input to BigDecimal in a way that preserves Rational
122
+ # values. `BigDecimal(value.to_s)` raises `ArgumentError` for Rational
123
+ # because `Rational#to_s` produces strings like `"3/2"`. This helper is
124
+ # the single place every call site should use to convert a scalar / rate /
125
+ # display-unit scale into a BigDecimal.
126
+ #
127
+ # @param value [Numeric, BigDecimal, Rational, String]
128
+ # @return [BigDecimal]
129
+ def coerce_decimal(value)
130
+ case value
131
+ when BigDecimal then value
132
+ when Rational then BigDecimal(value, Float::DIG + 4)
133
+ else BigDecimal(value.to_s)
134
+ end
135
+ end
136
+
105
137
  # Temporarily swaps the global registry. Intended for tests.
106
138
  #
107
139
  # @param registry [Amount::Registry]
@@ -131,7 +163,7 @@ class Amount
131
163
  end
132
164
  end
133
165
 
134
- attr_reader :atomic, :symbol
166
+ attr_reader :atomic, :symbol, :display
135
167
 
136
168
  # Creates an amount for a registered symbol.
137
169
  #
@@ -163,6 +195,7 @@ class Amount
163
195
  end
164
196
 
165
197
  @atomic = infer_value(from, value)
198
+ @display = Display.new(self)
166
199
  end
167
200
 
168
201
  # @return [Amount::Registry::Entry]
@@ -189,14 +222,6 @@ class Amount
189
222
  BigDecimal(@atomic) / (BigDecimal(10)**decimals)
190
223
  end
191
224
 
192
- # @return [Amount::Display]
193
- # @example Delegating formatting concerns
194
- # Amount.usdc("1.50").display.ui
195
- # # => "$1.50"
196
- def display
197
- @display ||= Display.new(self)
198
- end
199
-
200
225
  def_delegators :display, :formatted, :ui, :to_s, :in_unit
201
226
 
202
227
  # @return [String]
@@ -207,266 +232,34 @@ class Amount
207
232
  "#<#{self.class} #{symbol} #{ui}>"
208
233
  end
209
234
 
210
- # @return [Boolean]
211
- # @example
212
- # Amount.usdc(0, from: :atomic).zero?
213
- # # => true
214
- def zero? = @atomic.zero?
215
-
216
- # @return [Boolean]
217
- # @example
218
- # Amount.usdc("1").positive?
219
- # # => true
220
- def positive? = @atomic.positive?
221
-
222
- # @return [Boolean]
223
- # @example
224
- # Amount.usdc("-1").negative?
225
- # # => true
226
- def negative? = @atomic.negative?
227
-
228
- # @param other [Object]
229
- # @return [Boolean]
230
- # @example
231
- # Amount.usdc("1").same_type?(Amount.usdc("2"))
232
- # # => true
233
- def same_type?(other)
234
- other.is_a?(Amount) && other.symbol == symbol
235
- end
236
-
237
- # @return [Amount]
238
- # @example
239
- # Amount.usdc("-1").abs
240
- # # => #<Amount USDC $1.00>
241
- def abs
242
- build(@atomic.abs)
243
- end
244
-
245
- # @return [Amount]
246
- # @example
247
- # -Amount.usdc("1")
248
- # # => #<Amount USDC -$1.00>
249
- def -@
250
- build(-@atomic)
251
- end
252
-
253
- # @param other [Amount]
254
- # @return [Amount]
255
- # @raise [TypeMismatch]
256
- # @example Same-type addition
257
- # Amount.usdc("1.50") + Amount.usdc("0.50")
258
- #
259
- # @example Cross-type addition using a registered directional rate
260
- # Amount.register_default_rate :USD, :USDC, "1"
261
- # Amount.usdc("10.00") + Amount.new("5.00", :USD)
262
- def +(other)
263
- rhs = coerce_other_to_self_type!(other)
264
- build(@atomic + rhs.atomic)
265
- end
266
-
267
- # @param other [Amount]
268
- # @return [Amount]
269
- # @raise [TypeMismatch]
270
- # @example
271
- # Amount.usdc("2.00") - Amount.usdc("0.50")
272
- def -(other)
273
- rhs = coerce_other_to_self_type!(other)
274
- build(@atomic - rhs.atomic)
275
- end
276
-
277
- # @param scalar [Numeric]
278
- # @return [Amount]
279
- # @raise [TypeMismatch]
280
- # @example
281
- # Amount.usdc("1.25") * 2
282
- def *(scalar)
283
- ensure_scalar!(scalar)
284
- build((BigDecimal(@atomic) * BigDecimal(scalar.to_s)).to_i)
285
- end
286
-
287
- # @param other [Amount, Numeric]
288
- # @return [Amount, BigDecimal]
289
- # @raise [TypeMismatch, ZeroDivisionError]
290
- # @example Dividing by a scalar returns an amount
291
- # Amount.usdc("1.00") / 2
292
- #
293
- # @example Dividing by an amount returns a ratio
294
- # Amount.usdc("10.00") / Amount.usdc("2.00")
295
- def /(other)
296
- if other.is_a?(Amount)
297
- ensure_same_type!(other)
298
- raise ZeroDivisionError if other.zero?
299
-
300
- BigDecimal(@atomic) / BigDecimal(other.atomic)
301
- else
302
- ensure_scalar!(other)
303
- raise ZeroDivisionError if other.zero?
304
-
305
- build((BigDecimal(@atomic) / BigDecimal(other.to_s)).to_i)
306
- end
307
- end
308
-
309
- # Splits into equal parts and returns the leftover explicitly.
310
- #
311
- # @param n [Integer]
312
- # @return [Array<(Array<Amount>, Amount)>]
313
- # @raise [ArgumentError] if `n` is not a positive integer
314
- # @example
315
- # parts, remainder = Amount.new(10, :LOGS).split(3)
316
- # parts.map(&:atomic)
317
- # # => [3, 3, 3]
318
- # remainder.atomic
319
- # # => 1
320
- def split(n)
321
- raise ArgumentError, "n must be positive" unless n.is_a?(Integer) && n.positive?
322
-
323
- sign = atomic_sign
324
- base, remainder = @atomic.abs.divmod(n)
325
- parts = Array.new(n) { build(sign * base) }
326
-
327
- [parts, build(sign * remainder)]
328
- end
329
-
330
- # Allocates proportionally by integer weights and returns the leftover explicitly.
331
- #
332
- # @param weights [Array<Integer>]
333
- # @return [Array<(Array<Amount>, Amount)>]
334
- # @raise [ArgumentError] if weights are empty, negative, or sum to zero
335
- # @example
336
- # parts, remainder = Amount.new(10, :LOGS).allocate([1, 1, 2])
337
- # parts.map(&:atomic)
338
- # # => [2, 2, 5]
339
- # remainder.atomic
340
- # # => 1
341
- def allocate(weights)
342
- raise ArgumentError, "weights must be non-empty" if weights.empty?
343
- raise ArgumentError, "weights must be non-negative integers" unless weights.all? { |weight| weight.is_a?(Integer) && weight >= 0 }
344
-
345
- total = weights.sum
346
- raise ArgumentError, "weights must sum to positive value" unless total.positive?
347
-
348
- sign = atomic_sign
349
- absolute_atomic = @atomic.abs
350
- allocations = weights.map { |weight| absolute_atomic * weight / total }
351
- remainder = absolute_atomic - allocations.sum
352
-
353
- parts = allocations.map { |allocation| build(sign * allocation) }
354
- [parts, build(sign * remainder)]
355
- end
356
-
357
- # @param other [Object]
358
- # @return [-1, 0, 1, nil]
359
- # @example
360
- # Amount.usdc("1") <=> Amount.usdc("2")
361
- # # => -1
362
- def <=>(other)
363
- return nil unless other.is_a?(Amount)
364
-
365
- comparable = coerce_other_to_self_type(other)
366
- return nil unless comparable
367
-
368
- @atomic <=> comparable.atomic
369
- end
370
-
371
- # @param other [Object]
372
- # @return [Boolean]
373
- # @example
374
- # Amount.usdc("1.50") == Amount.usdc("1.50")
375
- # # => true
376
- def ==(other)
377
- same_type?(other) && @atomic == other.atomic
378
- end
379
-
380
- # @param other [Object]
381
- # @return [Boolean]
382
- # @example Hash-key equality keeps class and symbol identity
383
- # Amount.usdc("1").eql?(Amount.usdc("1"))
384
- # # => true
385
- def eql?(other)
386
- other.class == self.class && symbol == other.symbol && @atomic == other.atomic
387
- end
388
-
389
- # @return [Integer]
390
- # @example
391
- # { Amount.usdc("1") => :ok }[Amount.usdc("1")]
392
- # # => :ok
393
- def hash
394
- [self.class, symbol, @atomic].hash
395
- end
396
-
397
- # @param target_symbol [Symbol, String]
398
- # @param rate [String, Numeric, BigDecimal, nil]
399
- # @return [Amount]
400
- # @raise [Amount::Registry::NoDefaultRate] if no explicit or registered rate is available
401
- # @example Using an explicit one-off rate
402
- # Amount.usdc("100").to(:GOLD, rate: "0.00042")
403
- #
404
- # @example Using a registered default rate
405
- # Amount.register_default_rate :USDC, :USD, "1"
406
- # Amount.usdc("1.50").to(:USD)
407
- def to(target_symbol, rate: nil)
408
- target_symbol = target_symbol.to_sym
409
- return self.class.new(@atomic, symbol, from: :atomic) if target_symbol == symbol
410
-
411
- rate = resolve_rate(target_symbol, rate)
412
- target_entry = self.class.registry.lookup(target_symbol)
413
-
414
- decimal_result = decimal * BigDecimal(rate.to_s)
415
- atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
416
-
417
- target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
418
- end
419
-
420
- # @return [Hash]
421
- # @example
422
- # Amount.usdc("1.50").to_h
423
- # # => { v: 1, atomic: "1500000", symbol: "USDC" }
424
- def to_h
425
- Serializer.dump(self)
426
- end
427
-
428
- # @param hash [Hash]
429
- # @return [Amount]
430
- # @raise [InvalidInput] for unsupported serialized versions
431
- # @example Loading the current versioned payload
432
- # Amount.load(v: 1, atomic: "1500000", symbol: "USDC")
433
- #
434
- # @example Loading the legacy unversioned payload
435
- # Amount.load(atomic: 1500000, symbol: :USDC)
436
- def self.load(hash)
437
- Serializer.load(hash)
438
- end
439
-
440
235
  private
441
236
 
237
+ # Builds a same-symbol amount in the receiver's class. Used by every
238
+ # operator that returns an Amount so subclass identity propagates.
442
239
  def build(atomic_value)
443
240
  self.class.new(atomic_value, symbol, from: :atomic)
444
241
  end
445
242
 
446
- def atomic_sign
447
- return 1 if @atomic.positive?
448
- return(-1) if @atomic.negative?
449
-
450
- 0
451
- end
452
-
453
243
  def ensure_same_type!(other)
454
244
  return if same_type?(other)
455
245
 
456
246
  raise TypeMismatch, "type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
457
247
  end
458
248
 
459
- def ensure_scalar!(value)
460
- return if value.is_a?(Integer) || value.is_a?(Float) ||
461
- value.is_a?(BigDecimal) || value.is_a?(Rational)
249
+ def coerce_other_to_self_type(other)
250
+ return other if same_type?(other)
251
+ return unless other.is_a?(Amount)
462
252
 
463
- raise TypeMismatch, "expected scalar, got #{value.class}"
253
+ other.to(symbol)
254
+ rescue Registry::NoDefaultRate
255
+ nil
464
256
  end
465
257
 
466
- def resolve_rate(target, provided)
467
- return provided if provided
468
-
469
- self.class.registry.default_rate(symbol, target)
258
+ def coerce_other_to_self_type!(other)
259
+ coerce_other_to_self_type(other) || raise(
260
+ TypeMismatch,
261
+ "type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
262
+ )
470
263
  end
471
264
 
472
265
  def infer_value(from, value)
@@ -496,20 +289,4 @@ class Amount
496
289
  rescue ArgumentError
497
290
  raise InvalidInput, "cannot parse #{value.inspect} as #{symbol}"
498
291
  end
499
-
500
- def coerce_other_to_self_type!(other)
501
- coerce_other_to_self_type(other) || raise(
502
- TypeMismatch,
503
- "type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
504
- )
505
- end
506
-
507
- def coerce_other_to_self_type(other)
508
- return other if same_type?(other)
509
- return unless other.is_a?(Amount)
510
-
511
- other.to(symbol)
512
- rescue Registry::NoDefaultRate
513
- nil
514
- end
515
292
  end
data/test/test_amount.rb CHANGED
@@ -126,6 +126,32 @@ class AmountTest < Minitest::Test
126
126
  assert_raises(Amount::Registry::UnknownType) { Amount.new(1, :DOGE) }
127
127
  end
128
128
 
129
+ def test_register_rejects_empty_symbol
130
+ error = assert_raises(ArgumentError) { Amount.register :"", decimals: 2 }
131
+ assert_match(/symbol must not be blank/, error.message)
132
+ end
133
+
134
+ def test_register_rejects_nil_symbol
135
+ assert_raises(ArgumentError) { Amount.register nil, decimals: 2 }
136
+ end
137
+
138
+ def test_frozen_amount_renders_display
139
+ amount = Amount.new("1.5", :USDC).freeze
140
+
141
+ assert_equal "$1.50", amount.ui
142
+ assert_equal "1.500000", amount.formatted
143
+ assert_equal "USDC|1.5", amount.to_s
144
+ end
145
+
146
+ def test_frozen_amount_arithmetic_returns_unfrozen_result
147
+ a = Amount.new("1", :USDC).freeze
148
+ b = Amount.new("2", :USDC).freeze
149
+ sum = a + b
150
+
151
+ assert_equal Amount.new("3", :USDC), sum
152
+ refute sum.frozen?
153
+ end
154
+
129
155
  def test_formatted_respects_storage_decimals
130
156
  assert_equal "1.500000", Amount.new("1.5", :USDC).formatted
131
157
  assert_equal "1.500000000", Amount.new("1.5", :SOL).formatted
@@ -229,6 +255,8 @@ class AmountTest < Minitest::Test
229
255
  assert_equal Amount.new("3", :USDC), Amount.new("1", :USDC) * 3
230
256
  assert_equal Amount.new("1.5", :USDC), Amount.new("1", :USDC) * 1.5
231
257
  assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) * -2
258
+ assert_equal Amount.new("3", :USDC), Amount.new("2", :USDC) * BigDecimal("1.5")
259
+ assert_equal Amount.new("3", :USDC), Amount.new("2", :USDC) * Rational(3, 2)
232
260
  end
233
261
 
234
262
  def test_amount_times_amount_raises
@@ -240,6 +268,8 @@ class AmountTest < Minitest::Test
240
268
  def test_scalar_division_returns_amount
241
269
  assert_equal Amount.new("0.5", :USDC), Amount.new("1", :USDC) / 2
242
270
  assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) / -0.5
271
+ assert_equal Amount.new("2", :USDC), Amount.new("3", :USDC) / Rational(3, 2)
272
+ assert_equal Amount.new("2", :USDC), Amount.new("3", :USDC) / BigDecimal("1.5")
243
273
  end
244
274
 
245
275
  def test_amount_division_returns_ratio
@@ -353,6 +383,33 @@ class AmountTest < Minitest::Test
353
383
  assert_equal :GOLD, gold.symbol
354
384
  end
355
385
 
386
+ def test_to_with_rational_rate
387
+ converted = Amount.new("4", :USDC).to(:USD, rate: Rational(1, 2))
388
+
389
+ assert_equal BigDecimal("2"), converted.decimal
390
+ assert_equal :USD, converted.symbol
391
+ end
392
+
393
+ def test_register_default_rate_accepts_rational
394
+ Amount.register_default_rate :USDC, :USD, Rational(1, 2)
395
+
396
+ assert_equal BigDecimal("0.5"), Amount.registry.default_rate(:USDC, :USD)
397
+ assert_equal BigDecimal("0.5"), Amount.new("1", :USDC).to(:USD).decimal
398
+ end
399
+
400
+ def test_display_units_with_rational_scale
401
+ Amount.registry.clear!
402
+ Amount.register :METAL, decimals: 4, display_symbol: "x", display_position: :suffix,
403
+ ui_decimals: 2,
404
+ display_units: { half: { scale: Rational(1, 2), symbol: "h", ui_decimals: 2 } },
405
+ default_display: :half
406
+
407
+ metal = Amount.new("1", :METAL)
408
+
409
+ assert_equal BigDecimal("0.5"), metal.in_unit(:half)
410
+ assert_match(/\A0\.50/, metal.ui(unit: :half))
411
+ end
412
+
356
413
  def test_to_without_rate_requires_default
357
414
  assert_raises(Amount::Registry::NoDefaultRate) do
358
415
  Amount.new("1", :USDC).to(:GOLD)
@@ -406,6 +463,14 @@ class AmountTest < Minitest::Test
406
463
  assert_equal Amount.new("1.5", :USDC), amount
407
464
  end
408
465
 
466
+ def test_load_wraps_missing_keys_as_invalid_input
467
+ error = assert_raises(Amount::InvalidInput) { Amount.load(v: 1, symbol: "USDC") }
468
+ assert_match(/missing key: atomic/, error.message)
469
+
470
+ error = assert_raises(Amount::InvalidInput) { Amount.load({}) }
471
+ assert_match(/missing key/, error.message)
472
+ end
473
+
409
474
  def test_load_rejects_unknown_serialization_version
410
475
  assert_raises(Amount::InvalidInput) do
411
476
  Amount.load(v: 2, atomic: "1500000", symbol: "USDC")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: amounts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seb Scholl
@@ -51,15 +51,20 @@ files:
51
51
  - lib/amount/active_record/migration_methods.rb
52
52
  - lib/amount/active_record/model.rb
53
53
  - lib/amount/active_record/rspec.rb
54
+ - lib/amount/active_record/rspec/matchers.rb
54
55
  - lib/amount/active_record/type.rb
56
+ - lib/amount/allocation.rb
57
+ - lib/amount/arithmetic.rb
58
+ - lib/amount/comparison.rb
59
+ - lib/amount/conversion.rb
55
60
  - lib/amount/display.rb
56
61
  - lib/amount/parser.rb
57
62
  - lib/amount/registry.rb
58
63
  - lib/amount/registry/generated_constructors.rb
59
64
  - lib/amount/rspec.rb
60
- - lib/amount/rspec_matchers.rb
61
- - lib/amount/rspec_support.rb
62
- - lib/amount/serializer.rb
65
+ - lib/amount/rspec/matchers.rb
66
+ - lib/amount/rspec/support.rb
67
+ - lib/amount/serialization.rb
63
68
  - lib/amount/version.rb
64
69
  - test/dummy/app/models/holding.rb
65
70
  - test/dummy/bin/rails
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Amount
4
- # Internal matcher helpers for the opt-in RSpec integration.
5
- module RSpecMatchers
6
- module_function
7
-
8
- def define_amount_equality_matcher(name, &expected_builder)
9
- RSpec::Matchers.define name do |*arguments|
10
- match do |actual|
11
- @expected = instance_exec(*arguments, &expected_builder)
12
- actual.is_a?(Amount) && actual == @expected
13
- end
14
-
15
- failure_message do |actual|
16
- return "expected #{actual.inspect} to be an Amount equal to #{@expected.inspect}" unless actual.is_a?(Amount)
17
-
18
- "expected #{actual.inspect} to equal amount #{@expected.inspect}"
19
- end
20
- end
21
- end
22
-
23
- def define_amount_predicate_matcher(name, description, &predicate)
24
- RSpec::Matchers.define name do
25
- match do |actual|
26
- actual.is_a?(Amount) && instance_exec(actual, &predicate)
27
- end
28
-
29
- failure_message do |actual|
30
- "expected #{actual.inspect} to be #{description}"
31
- end
32
- end
33
- end
34
-
35
- def define_amount_type_matcher
36
- RSpec::Matchers.define :be_amount_of do |expected_symbol|
37
- match do |actual|
38
- @expected_symbol = expected_symbol.to_sym
39
- actual.is_a?(Amount) && actual.symbol == @expected_symbol
40
- end
41
-
42
- failure_message do |actual|
43
- "expected #{actual.inspect} to be an Amount of #{@expected_symbol}"
44
- end
45
- end
46
- end
47
-
48
- def define_approximate_amount_matcher
49
- RSpec::Matchers.define :be_approximately_amount do |*expected_arguments, within:|
50
- match do |actual|
51
- @expected = Amount::RSpecSupport.coerce_amount_arguments(expected_arguments)
52
- @within = Amount::RSpecSupport.coerce_delta(@expected, within)
53
-
54
- actual.is_a?(Amount) &&
55
- actual.same_type?(@expected) &&
56
- @within.same_type?(@expected) &&
57
- (actual - @expected).abs.atomic <= @within.atomic
58
- end
59
-
60
- failure_message do |actual|
61
- return "expected #{actual.inspect} to be an Amount within #{@within.inspect} of #{@expected.inspect}" unless actual.is_a?(Amount)
62
-
63
- "expected #{actual.inspect} to be within #{@within.inspect} of #{@expected.inspect}"
64
- end
65
- end
66
- end
67
-
68
- def define_amount_column_matcher
69
- RSpec::Matchers.define :have_amount_column do |name, *expected_arguments|
70
- match do |record|
71
- @name = name
72
- @expected = Amount::RSpecSupport.coerce_amount_arguments(expected_arguments)
73
- @definition = record.class.amount_attribute_definitions.fetch(name.to_sym)
74
-
75
- @definition.read(record) == @expected &&
76
- record.public_send(@definition.atomic_column).to_i == @expected.atomic &&
77
- (@definition.fixed_symbol? ||
78
- record.public_send(@definition.symbol_column) == @expected.symbol.to_s)
79
- end
80
-
81
- failure_message do |record|
82
- "expected #{record.inspect} to have #{@name} column matching #{@expected.inspect}"
83
- end
84
- end
85
- end
86
-
87
- def define_amount_sum_matcher
88
- RSpec::Matchers.define :match_amounts do |expected_hash|
89
- match do |actual_hash|
90
- @expected = expected_hash.to_h do |symbol, value|
91
- amount = Amount.new(value, symbol)
92
- [amount.symbol, amount]
93
- end
94
- @actual = Amount::RSpecSupport.normalize_amount_sums(actual_hash)
95
-
96
- @actual == @expected
97
- end
98
-
99
- failure_message do |_actual_hash|
100
- "expected grouped amounts #{@actual.inspect} to match #{@expected.inspect}"
101
- end
102
- end
103
- end
104
- end
105
- end