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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/lib/amount/active_record/rspec/matchers.rb +50 -0
- data/lib/amount/active_record/rspec.rb +3 -2
- data/lib/amount/active_record/type.rb +3 -4
- data/lib/amount/allocation.rb +66 -0
- data/lib/amount/arithmetic.rb +91 -0
- data/lib/amount/comparison.rb +78 -0
- data/lib/amount/conversion.rb +41 -0
- data/lib/amount/display.rb +23 -6
- data/lib/amount/parser.rb +1 -1
- data/lib/amount/registry.rb +16 -7
- data/lib/amount/rspec/matchers.rb +74 -0
- data/lib/amount/rspec/support.rb +49 -0
- data/lib/amount/rspec.rb +9 -9
- data/lib/amount/serialization.rb +54 -0
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +33 -272
- data/test/test_amount.rb +44 -0
- metadata +9 -4
- data/lib/amount/rspec_matchers.rb +0 -105
- data/lib/amount/rspec_support.rb +0 -47
- data/lib/amount/serializer.rb +0 -37
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 "
|
|
6
|
-
require_relative "
|
|
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::
|
|
20
|
-
Amount::
|
|
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::
|
|
24
|
-
Amount::
|
|
25
|
-
Amount::
|
|
26
|
-
Amount::
|
|
27
|
-
Amount::
|
|
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
|
data/lib/amount/version.rb
CHANGED
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/
|
|
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
|
|
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
|
|
476
|
-
return if
|
|
477
|
-
|
|
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
|
-
|
|
253
|
+
other.to(symbol)
|
|
254
|
+
rescue Registry::NoDefaultRate
|
|
255
|
+
nil
|
|
480
256
|
end
|
|
481
257
|
|
|
482
|
-
def
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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.
|
|
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/
|
|
61
|
-
- lib/amount/
|
|
62
|
-
- lib/amount/
|
|
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
|