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 +4 -4
- data/CHANGELOG.md +60 -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 +2 -2
- data/lib/amount/parser.rb +1 -1
- data/lib/amount/registry.rb +17 -8
- 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 +49 -272
- data/test/test_amount.rb +65 -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 -35
|
@@ -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
|
|
@@ -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
|
|
460
|
-
return if
|
|
461
|
-
|
|
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
|
-
|
|
253
|
+
other.to(symbol)
|
|
254
|
+
rescue Registry::NoDefaultRate
|
|
255
|
+
nil
|
|
464
256
|
end
|
|
465
257
|
|
|
466
|
-
def
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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.
|
|
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/
|
|
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
|