amounts 0.0.3 → 0.0.5

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.
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?)
@@ -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.3"
4
+ VERSION = "0.0.5"
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
@@ -147,7 +163,7 @@ class Amount
147
163
  end
148
164
  end
149
165
 
150
- attr_reader :atomic, :symbol
166
+ attr_reader :atomic, :symbol, :display
151
167
 
152
168
  # Creates an amount for a registered symbol.
153
169
  #
@@ -179,6 +195,7 @@ class Amount
179
195
  end
180
196
 
181
197
  @atomic = infer_value(from, value)
198
+ @display = Display.new(self)
182
199
  end
183
200
 
184
201
  # @return [Amount::Registry::Entry]
@@ -205,14 +222,6 @@ class Amount
205
222
  BigDecimal(@atomic) / (BigDecimal(10)**decimals)
206
223
  end
207
224
 
208
- # @return [Amount::Display]
209
- # @example Delegating formatting concerns
210
- # Amount.usdc("1.50").display.ui
211
- # # => "$1.50"
212
- def display
213
- @display ||= Display.new(self)
214
- end
215
-
216
225
  def_delegators :display, :formatted, :ui, :to_s, :in_unit
217
226
 
218
227
  # @return [String]
@@ -223,266 +232,34 @@ class Amount
223
232
  "#<#{self.class} #{symbol} #{ui}>"
224
233
  end
225
234
 
226
- # @return [Boolean]
227
- # @example
228
- # Amount.usdc(0, from: :atomic).zero?
229
- # # => true
230
- def zero? = @atomic.zero?
231
-
232
- # @return [Boolean]
233
- # @example
234
- # Amount.usdc("1").positive?
235
- # # => true
236
- def positive? = @atomic.positive?
237
-
238
- # @return [Boolean]
239
- # @example
240
- # Amount.usdc("-1").negative?
241
- # # => true
242
- def negative? = @atomic.negative?
243
-
244
- # @param other [Object]
245
- # @return [Boolean]
246
- # @example
247
- # Amount.usdc("1").same_type?(Amount.usdc("2"))
248
- # # => true
249
- def same_type?(other)
250
- other.is_a?(Amount) && other.symbol == symbol
251
- end
252
-
253
- # @return [Amount]
254
- # @example
255
- # Amount.usdc("-1").abs
256
- # # => #<Amount USDC $1.00>
257
- def abs
258
- build(@atomic.abs)
259
- end
260
-
261
- # @return [Amount]
262
- # @example
263
- # -Amount.usdc("1")
264
- # # => #<Amount USDC -$1.00>
265
- def -@
266
- build(-@atomic)
267
- end
268
-
269
- # @param other [Amount]
270
- # @return [Amount]
271
- # @raise [TypeMismatch]
272
- # @example Same-type addition
273
- # Amount.usdc("1.50") + Amount.usdc("0.50")
274
- #
275
- # @example Cross-type addition using a registered directional rate
276
- # Amount.register_default_rate :USD, :USDC, "1"
277
- # Amount.usdc("10.00") + Amount.new("5.00", :USD)
278
- def +(other)
279
- rhs = coerce_other_to_self_type!(other)
280
- build(@atomic + rhs.atomic)
281
- end
282
-
283
- # @param other [Amount]
284
- # @return [Amount]
285
- # @raise [TypeMismatch]
286
- # @example
287
- # Amount.usdc("2.00") - Amount.usdc("0.50")
288
- def -(other)
289
- rhs = coerce_other_to_self_type!(other)
290
- build(@atomic - rhs.atomic)
291
- end
292
-
293
- # @param scalar [Numeric]
294
- # @return [Amount]
295
- # @raise [TypeMismatch]
296
- # @example
297
- # Amount.usdc("1.25") * 2
298
- def *(scalar)
299
- ensure_scalar!(scalar)
300
- build((BigDecimal(@atomic) * Amount.coerce_decimal(scalar)).to_i)
301
- end
302
-
303
- # @param other [Amount, Numeric]
304
- # @return [Amount, BigDecimal]
305
- # @raise [TypeMismatch, ZeroDivisionError]
306
- # @example Dividing by a scalar returns an amount
307
- # Amount.usdc("1.00") / 2
308
- #
309
- # @example Dividing by an amount returns a ratio
310
- # Amount.usdc("10.00") / Amount.usdc("2.00")
311
- def /(other)
312
- if other.is_a?(Amount)
313
- ensure_same_type!(other)
314
- raise ZeroDivisionError if other.zero?
315
-
316
- BigDecimal(@atomic) / BigDecimal(other.atomic)
317
- else
318
- ensure_scalar!(other)
319
- raise ZeroDivisionError if other.zero?
320
-
321
- build((BigDecimal(@atomic) / Amount.coerce_decimal(other)).to_i)
322
- end
323
- end
324
-
325
- # Splits into equal parts and returns the leftover explicitly.
326
- #
327
- # @param n [Integer]
328
- # @return [Array<(Array<Amount>, Amount)>]
329
- # @raise [ArgumentError] if `n` is not a positive integer
330
- # @example
331
- # parts, remainder = Amount.new(10, :LOGS).split(3)
332
- # parts.map(&:atomic)
333
- # # => [3, 3, 3]
334
- # remainder.atomic
335
- # # => 1
336
- def split(n)
337
- raise ArgumentError, "n must be positive" unless n.is_a?(Integer) && n.positive?
338
-
339
- sign = atomic_sign
340
- base, remainder = @atomic.abs.divmod(n)
341
- parts = Array.new(n) { build(sign * base) }
342
-
343
- [parts, build(sign * remainder)]
344
- end
345
-
346
- # Allocates proportionally by integer weights and returns the leftover explicitly.
347
- #
348
- # @param weights [Array<Integer>]
349
- # @return [Array<(Array<Amount>, Amount)>]
350
- # @raise [ArgumentError] if weights are empty, negative, or sum to zero
351
- # @example
352
- # parts, remainder = Amount.new(10, :LOGS).allocate([1, 1, 2])
353
- # parts.map(&:atomic)
354
- # # => [2, 2, 5]
355
- # remainder.atomic
356
- # # => 1
357
- def allocate(weights)
358
- raise ArgumentError, "weights must be non-empty" if weights.empty?
359
- raise ArgumentError, "weights must be non-negative integers" unless weights.all? { |weight| weight.is_a?(Integer) && weight >= 0 }
360
-
361
- total = weights.sum
362
- raise ArgumentError, "weights must sum to positive value" unless total.positive?
363
-
364
- sign = atomic_sign
365
- absolute_atomic = @atomic.abs
366
- allocations = weights.map { |weight| absolute_atomic * weight / total }
367
- remainder = absolute_atomic - allocations.sum
368
-
369
- parts = allocations.map { |allocation| build(sign * allocation) }
370
- [parts, build(sign * remainder)]
371
- end
372
-
373
- # @param other [Object]
374
- # @return [-1, 0, 1, nil]
375
- # @example
376
- # Amount.usdc("1") <=> Amount.usdc("2")
377
- # # => -1
378
- def <=>(other)
379
- return nil unless other.is_a?(Amount)
380
-
381
- comparable = coerce_other_to_self_type(other)
382
- return nil unless comparable
383
-
384
- @atomic <=> comparable.atomic
385
- end
386
-
387
- # @param other [Object]
388
- # @return [Boolean]
389
- # @example
390
- # Amount.usdc("1.50") == Amount.usdc("1.50")
391
- # # => true
392
- def ==(other)
393
- same_type?(other) && @atomic == other.atomic
394
- end
395
-
396
- # @param other [Object]
397
- # @return [Boolean]
398
- # @example Hash-key equality keeps class and symbol identity
399
- # Amount.usdc("1").eql?(Amount.usdc("1"))
400
- # # => true
401
- def eql?(other)
402
- other.class == self.class && symbol == other.symbol && @atomic == other.atomic
403
- end
404
-
405
- # @return [Integer]
406
- # @example
407
- # { Amount.usdc("1") => :ok }[Amount.usdc("1")]
408
- # # => :ok
409
- def hash
410
- [self.class, symbol, @atomic].hash
411
- end
412
-
413
- # @param target_symbol [Symbol, String]
414
- # @param rate [String, Numeric, BigDecimal, nil]
415
- # @return [Amount]
416
- # @raise [Amount::Registry::NoDefaultRate] if no explicit or registered rate is available
417
- # @example Using an explicit one-off rate
418
- # Amount.usdc("100").to(:GOLD, rate: "0.00042")
419
- #
420
- # @example Using a registered default rate
421
- # Amount.register_default_rate :USDC, :USD, "1"
422
- # Amount.usdc("1.50").to(:USD)
423
- def to(target_symbol, rate: nil)
424
- target_symbol = target_symbol.to_sym
425
- return self.class.new(@atomic, symbol, from: :atomic) if target_symbol == symbol
426
-
427
- rate = resolve_rate(target_symbol, rate)
428
- target_entry = self.class.registry.lookup(target_symbol)
429
-
430
- decimal_result = decimal * Amount.coerce_decimal(rate)
431
- atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
432
-
433
- target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
434
- end
435
-
436
- # @return [Hash]
437
- # @example
438
- # Amount.usdc("1.50").to_h
439
- # # => { v: 1, atomic: "1500000", symbol: "USDC" }
440
- def to_h
441
- Serializer.dump(self)
442
- end
443
-
444
- # @param hash [Hash]
445
- # @return [Amount]
446
- # @raise [InvalidInput] for unsupported serialized versions
447
- # @example Loading the current versioned payload
448
- # Amount.load(v: 1, atomic: "1500000", symbol: "USDC")
449
- #
450
- # @example Loading the legacy unversioned payload
451
- # Amount.load(atomic: 1500000, symbol: :USDC)
452
- def self.load(hash)
453
- Serializer.load(hash)
454
- end
455
-
456
235
  private
457
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.
458
239
  def build(atomic_value)
459
240
  self.class.new(atomic_value, symbol, from: :atomic)
460
241
  end
461
242
 
462
- def atomic_sign
463
- return 1 if @atomic.positive?
464
- return(-1) if @atomic.negative?
465
-
466
- 0
467
- end
468
-
469
243
  def ensure_same_type!(other)
470
244
  return if same_type?(other)
471
245
 
472
246
  raise TypeMismatch, "type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
473
247
  end
474
248
 
475
- def ensure_scalar!(value)
476
- return if value.is_a?(Integer) || value.is_a?(Float) ||
477
- 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)
478
252
 
479
- raise TypeMismatch, "expected scalar, got #{value.class}"
253
+ other.to(symbol)
254
+ rescue Registry::NoDefaultRate
255
+ nil
480
256
  end
481
257
 
482
- def resolve_rate(target, provided)
483
- return provided if provided
484
-
485
- 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
+ )
486
263
  end
487
264
 
488
265
  def infer_value(from, value)
@@ -512,20 +289,4 @@ class Amount
512
289
  rescue ArgumentError
513
290
  raise InvalidInput, "cannot parse #{value.inspect} as #{symbol}"
514
291
  end
515
-
516
- def coerce_other_to_self_type!(other)
517
- coerce_other_to_self_type(other) || raise(
518
- TypeMismatch,
519
- "type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
520
- )
521
- end
522
-
523
- def coerce_other_to_self_type(other)
524
- return other if same_type?(other)
525
- return unless other.is_a?(Amount)
526
-
527
- other.to(symbol)
528
- rescue Registry::NoDefaultRate
529
- nil
530
- end
531
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
@@ -143,6 +169,16 @@ class AmountTest < Minitest::Test
143
169
  assert_equal "$1.57", Amount.new("1.561", :USDC).ui(direction: :ceil)
144
170
  end
145
171
 
172
+ def test_ui_decorated_false_omits_the_display_symbol
173
+ assert_equal "1.50", Amount.new("1.5", :USDC).ui(decorated: false)
174
+ assert_equal "1.5000", Amount.new("1.5", :SOL).ui(decorated: false)
175
+ assert_equal "1", Amount.new(1, :LOGS).ui(decorated: false)
176
+ end
177
+
178
+ def test_ui_decorated_false_with_ceil_direction
179
+ assert_equal "1.57", Amount.new("1.561", :USDC).ui(direction: :ceil, decorated: false)
180
+ end
181
+
146
182
  def test_display_units_scale_output
147
183
  gold = Amount.new("1.5", :GOLD)
148
184
 
@@ -151,6 +187,14 @@ class AmountTest < Minitest::Test
151
187
  assert_equal "0.04665 kg", gold.ui(unit: :kg)
152
188
  end
153
189
 
190
+ def test_display_units_with_decorated_false
191
+ gold = Amount.new("1.5", :GOLD)
192
+
193
+ assert_equal "1.5000", gold.ui(decorated: false)
194
+ assert_equal "46.65", gold.ui(unit: :gram, decorated: false)
195
+ assert_equal "0.04665", gold.ui(unit: :kg, decorated: false)
196
+ end
197
+
154
198
  def test_in_unit_returns_raw_bigdecimal
155
199
  gold = Amount.new("1.0", :GOLD)
156
200
 
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.3
4
+ version: 0.0.5
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