amounts 0.0.1 → 0.0.3
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 +39 -0
- data/README.md +1 -1
- data/lib/amount/active_record/amount_validator.rb +6 -3
- data/lib/amount/display.rb +2 -2
- data/lib/amount/registry.rb +1 -1
- data/lib/amount/serializer.rb +2 -0
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +43 -6
- data/test/test_active_record.rb +63 -0
- data/test/test_amount.rb +84 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 95dbf17ecb45d86b36277c6ecdaf611bdcb99d12eb97a3fc42b5f3bf2cd1ec50
|
|
4
|
+
data.tar.gz: 67fcceae2bae4582f4c4e2866f453727a23f444703e47717ab9e7420e8ff20de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15833dd17e8b167cfc61812af65dc0addad29912459a3886df4bd54b01cd8c43d055654cd971838a1c9b5fc76bca8cf83e8eaa72e25eaa4901eef93177275e4a
|
|
7
|
+
data.tar.gz: 002e0c5539b2ed22551a87d0d696a8cbfd2b87565b3c991a91fbbacb3c81e722d3b8b1af62bf1aa0604e08691805bfb2375cf2015f06bd57c62228525b9454ca
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.3 - 2026-04-26
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Rational scalars and rates are now accepted everywhere they are documented to
|
|
8
|
+
work: `Amount#*`, `Amount#/`, `Amount#to(rate:)`, `Amount.register_default_rate`,
|
|
9
|
+
and `display_units[unit][:scale]`. Previously each of these called
|
|
10
|
+
`BigDecimal(value.to_s)`, which raises `ArgumentError` for `Rational#to_s`
|
|
11
|
+
(`"3/2"`). All five sites now go through a single internal helper,
|
|
12
|
+
`Amount.coerce_decimal`, that handles `BigDecimal`, `Rational`, and string-or-
|
|
13
|
+
numeric inputs uniformly.
|
|
14
|
+
- `Amount.load` wraps a missing `:atomic` or `:symbol` key as
|
|
15
|
+
`Amount::InvalidInput` (`"amount payload missing key: atomic"`) instead of
|
|
16
|
+
leaking a raw `KeyError`. This matches the AR adapter's `Type#cast_hash`
|
|
17
|
+
behavior so callers can rescue a single error class.
|
|
18
|
+
|
|
19
|
+
## 0.0.2 - 2026-04-26
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `Amount.new(value, symbol)` now dispatches to the registered subclass when the
|
|
24
|
+
symbol was registered with `class:`, so `Amount.parse`, `Amount.load`, and the
|
|
25
|
+
ActiveRecord adapter (`Type#cast`, `Type#deserialize`) construct the correct
|
|
26
|
+
type instead of raising `InvalidInput`. The previous behavior made it
|
|
27
|
+
impossible to read a custom-class amount back out of an ActiveRecord column.
|
|
28
|
+
The strict-mode error is preserved for explicit wrong-subclass usage
|
|
29
|
+
(`OtherSubclass.new(value, :GOLD)`).
|
|
30
|
+
- `Rational` UI input is now accepted as documented. Previously
|
|
31
|
+
`Amount.new(Rational(3, 2), :USDC)` raised because the value was stringified
|
|
32
|
+
into `"3/2"` before reaching `BigDecimal()`. Rational input is now converted
|
|
33
|
+
via integer math (`(rational * 10**decimals).to_i`), giving exact results for
|
|
34
|
+
finite fractions and well-defined truncate-toward-zero behavior for
|
|
35
|
+
repeating fractions.
|
|
36
|
+
- `AmountValidator` thresholds for multi-symbol `has_amount` attributes accept
|
|
37
|
+
raw numerics. `validates :amount, amount: { greater_than: 0 }` now means
|
|
38
|
+
"greater than zero in the value's symbol" instead of producing
|
|
39
|
+
`"has invalid greater_than constraint: raw numeric assignment requires a
|
|
40
|
+
fixed symbol"`. Fixed-symbol attributes are unchanged.
|
|
41
|
+
|
|
3
42
|
## 0.0.1 - 2026-04-25
|
|
4
43
|
|
|
5
44
|
- Initial release.
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://github.com/zarpay/amounts/actions/workflows/ci.yml)
|
|
5
5
|
[](https://github.com/zarpay/amounts/releases)
|
|
6
6
|
[](https://github.com/zarpay/amounts/blob/main/LICENSE.txt)
|
|
7
|
-
[](https://rubygems.org/gems/amounts)
|
|
8
8
|
|
|
9
9
|
`amounts` is a Ruby gem for precise quantities of fungible things: money, crypto tokens, commodities, inventory units, points, and similar value-like amounts. It stores every value as an arbitrary-precision atomic `Integer`, keeps type identity in a registry, rejects accidental cross-type math unless an explicit directional rate exists, and offers an optional ActiveRecord adapter without making Rails part of the core runtime.
|
|
10
10
|
|
|
@@ -56,7 +56,7 @@ class Amount
|
|
|
56
56
|
COMPARATORS.each do |option_name, operator|
|
|
57
57
|
next unless options.key?(option_name)
|
|
58
58
|
|
|
59
|
-
other = coerce_comparison_amount(record, attribute, options.fetch(option_name))
|
|
59
|
+
other = coerce_comparison_amount(record, attribute, options.fetch(option_name), value)
|
|
60
60
|
next unless other
|
|
61
61
|
|
|
62
62
|
compare_amounts(record, attribute, value, operator, other, option_name)
|
|
@@ -65,10 +65,13 @@ class Amount
|
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
def coerce_comparison_amount(record, attribute, candidate)
|
|
69
|
-
definition = fetch_definition(record, attribute)
|
|
68
|
+
def coerce_comparison_amount(record, attribute, candidate, value)
|
|
70
69
|
return candidate if candidate.is_a?(::Amount)
|
|
71
70
|
|
|
71
|
+
definition = fetch_definition(record, attribute)
|
|
72
|
+
return definition.type.cast(candidate) if definition.type.fixed_symbol
|
|
73
|
+
return ::Amount.new(candidate, value.symbol, from: :float) if candidate.is_a?(Numeric)
|
|
74
|
+
|
|
72
75
|
definition.type.cast(candidate)
|
|
73
76
|
end
|
|
74
77
|
|
data/lib/amount/display.rb
CHANGED
|
@@ -32,7 +32,7 @@ class Amount
|
|
|
32
32
|
# @return [BigDecimal]
|
|
33
33
|
def in_unit(unit)
|
|
34
34
|
unit_spec = fetch_display_unit(unit)
|
|
35
|
-
@amount.decimal *
|
|
35
|
+
@amount.decimal * Amount.coerce_decimal(unit_spec[:scale])
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
private
|
|
@@ -44,7 +44,7 @@ class Amount
|
|
|
44
44
|
|
|
45
45
|
def render_display_unit(unit, direction)
|
|
46
46
|
spec = fetch_display_unit(unit)
|
|
47
|
-
scaled = @amount.decimal *
|
|
47
|
+
scaled = @amount.decimal * Amount.coerce_decimal(spec[:scale])
|
|
48
48
|
decimals = spec[:ui_decimals] || @entry.ui_decimals
|
|
49
49
|
rounded = round(scaled, decimals, direction)
|
|
50
50
|
|
data/lib/amount/registry.rb
CHANGED
data/lib/amount/serializer.rb
CHANGED
data/lib/amount/version.rb
CHANGED
data/lib/amount.rb
CHANGED
|
@@ -84,6 +84,40 @@ class Amount
|
|
|
84
84
|
Parser.new(str).parse
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
# When called as `Amount.new` for a symbol whose registry entry binds a
|
|
88
|
+
# custom class, dispatch the construction to that class instead of raising.
|
|
89
|
+
# Direct calls to a subclass (`GoldAmount.new(...)`) still go through the
|
|
90
|
+
# default `Class.new` path. Calls that target the wrong subclass continue
|
|
91
|
+
# to raise from `#initialize`.
|
|
92
|
+
#
|
|
93
|
+
# @param value [Integer, String, Float, BigDecimal, Rational]
|
|
94
|
+
# @param symbol [Symbol, String]
|
|
95
|
+
# @param from [Symbol, nil]
|
|
96
|
+
# @return [Amount]
|
|
97
|
+
def new(value, symbol, from: nil)
|
|
98
|
+
if equal?(::Amount)
|
|
99
|
+
entry_class = registry.lookup(symbol.to_sym).amount_class
|
|
100
|
+
return entry_class.new(value, symbol, from:) if entry_class && entry_class != ::Amount
|
|
101
|
+
end
|
|
102
|
+
super
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Coerces a numeric input to BigDecimal in a way that preserves Rational
|
|
106
|
+
# values. `BigDecimal(value.to_s)` raises `ArgumentError` for Rational
|
|
107
|
+
# because `Rational#to_s` produces strings like `"3/2"`. This helper is
|
|
108
|
+
# the single place every call site should use to convert a scalar / rate /
|
|
109
|
+
# display-unit scale into a BigDecimal.
|
|
110
|
+
#
|
|
111
|
+
# @param value [Numeric, BigDecimal, Rational, String]
|
|
112
|
+
# @return [BigDecimal]
|
|
113
|
+
def coerce_decimal(value)
|
|
114
|
+
case value
|
|
115
|
+
when BigDecimal then value
|
|
116
|
+
when Rational then BigDecimal(value, Float::DIG + 4)
|
|
117
|
+
else BigDecimal(value.to_s)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
87
121
|
# Temporarily swaps the global registry. Intended for tests.
|
|
88
122
|
#
|
|
89
123
|
# @param registry [Amount::Registry]
|
|
@@ -139,8 +173,9 @@ class Amount
|
|
|
139
173
|
@symbol = symbol.to_sym
|
|
140
174
|
@entry = self.class.registry.lookup(@symbol)
|
|
141
175
|
|
|
142
|
-
|
|
143
|
-
|
|
176
|
+
expected = @entry.amount_class
|
|
177
|
+
if expected && expected != Amount && self.class != Amount && !instance_of?(expected)
|
|
178
|
+
raise InvalidInput, "use #{expected}.new for #{@symbol}"
|
|
144
179
|
end
|
|
145
180
|
|
|
146
181
|
@atomic = infer_value(from, value)
|
|
@@ -262,7 +297,7 @@ class Amount
|
|
|
262
297
|
# Amount.usdc("1.25") * 2
|
|
263
298
|
def *(scalar)
|
|
264
299
|
ensure_scalar!(scalar)
|
|
265
|
-
build((BigDecimal(@atomic) *
|
|
300
|
+
build((BigDecimal(@atomic) * Amount.coerce_decimal(scalar)).to_i)
|
|
266
301
|
end
|
|
267
302
|
|
|
268
303
|
# @param other [Amount, Numeric]
|
|
@@ -283,7 +318,7 @@ class Amount
|
|
|
283
318
|
ensure_scalar!(other)
|
|
284
319
|
raise ZeroDivisionError if other.zero?
|
|
285
320
|
|
|
286
|
-
build((BigDecimal(@atomic) /
|
|
321
|
+
build((BigDecimal(@atomic) / Amount.coerce_decimal(other)).to_i)
|
|
287
322
|
end
|
|
288
323
|
end
|
|
289
324
|
|
|
@@ -392,7 +427,7 @@ class Amount
|
|
|
392
427
|
rate = resolve_rate(target_symbol, rate)
|
|
393
428
|
target_entry = self.class.registry.lookup(target_symbol)
|
|
394
429
|
|
|
395
|
-
decimal_result = decimal *
|
|
430
|
+
decimal_result = decimal * Amount.coerce_decimal(rate)
|
|
396
431
|
atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
|
|
397
432
|
|
|
398
433
|
target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
|
|
@@ -454,7 +489,7 @@ class Amount
|
|
|
454
489
|
case from || infer_type(value)
|
|
455
490
|
when :atomic then value.to_i
|
|
456
491
|
when :ui then ui_to_atomic(value)
|
|
457
|
-
when :float then ui_to_atomic(value.to_s)
|
|
492
|
+
when :float then ui_to_atomic(value.is_a?(Rational) ? value : value.to_s)
|
|
458
493
|
else
|
|
459
494
|
raise InvalidInput, "unknown amount format: #{value.inspect}"
|
|
460
495
|
end
|
|
@@ -471,6 +506,8 @@ class Amount
|
|
|
471
506
|
end
|
|
472
507
|
|
|
473
508
|
def ui_to_atomic(value)
|
|
509
|
+
return (value * (10**decimals)).to_i if value.is_a?(Rational)
|
|
510
|
+
|
|
474
511
|
(BigDecimal(value.to_s) * (BigDecimal(10)**decimals)).to_i
|
|
475
512
|
rescue ArgumentError
|
|
476
513
|
raise InvalidInput, "cannot parse #{value.inspect} as #{symbol}"
|
data/test/test_active_record.rb
CHANGED
|
@@ -309,4 +309,67 @@ class AmountActiveRecordTest < Minitest::Test
|
|
|
309
309
|
|
|
310
310
|
assert holding.valid?
|
|
311
311
|
end
|
|
312
|
+
|
|
313
|
+
def test_amount_validator_accepts_numeric_threshold_on_multi_symbol_attribute
|
|
314
|
+
klass = Class.new(::ActiveRecord::Base) do
|
|
315
|
+
self.table_name = "holdings"
|
|
316
|
+
|
|
317
|
+
has_amount :amount
|
|
318
|
+
validates :amount, amount: { greater_than: 0, less_than_or_equal_to: 100 }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
above = klass.new(amount: "USDC|50.00")
|
|
322
|
+
assert above.valid?
|
|
323
|
+
|
|
324
|
+
at_zero = klass.new(amount: "USDC|0.00")
|
|
325
|
+
refute at_zero.valid?
|
|
326
|
+
assert_includes at_zero.errors[:amount].join, "greater than"
|
|
327
|
+
|
|
328
|
+
above_max = klass.new(amount: "USDC|150.00")
|
|
329
|
+
refute above_max.valid?
|
|
330
|
+
assert_includes above_max.errors[:amount].join, "less than"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def test_amount_validator_numeric_threshold_uses_value_symbol
|
|
334
|
+
klass = Class.new(::ActiveRecord::Base) do
|
|
335
|
+
self.table_name = "holdings"
|
|
336
|
+
|
|
337
|
+
has_amount :amount
|
|
338
|
+
validates :amount, amount: { greater_than: 1 }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The same `greater_than: 1` is interpreted as 1 USDC for a USDC value
|
|
342
|
+
# and as 1 SOL for a SOL value, because the threshold inherits the
|
|
343
|
+
# value's symbol on multi-symbol attributes.
|
|
344
|
+
assert klass.new(amount: "USDC|2.00").valid?
|
|
345
|
+
refute klass.new(amount: "USDC|0.50").valid?
|
|
346
|
+
assert klass.new(amount: "SOL|2").valid?
|
|
347
|
+
refute klass.new(amount: "SOL|0.5").valid?
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def test_custom_class_symbol_round_trips_through_active_record
|
|
351
|
+
metal_class = Class.new(Amount) do
|
|
352
|
+
def self.name
|
|
353
|
+
"MetalAmount"
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
Amount.register :METAL, decimals: 4, class: metal_class
|
|
357
|
+
|
|
358
|
+
::ActiveRecord::Schema.define do
|
|
359
|
+
create_table :metal_holdings, force: true do |t|
|
|
360
|
+
t.amount :weight, symbol: :METAL, null: false
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
klass = Class.new(::ActiveRecord::Base) do
|
|
365
|
+
self.table_name = "metal_holdings"
|
|
366
|
+
has_amount :weight, symbol: :METAL
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
record = klass.create!(weight: metal_class.new("2.5", :METAL))
|
|
370
|
+
record.reload
|
|
371
|
+
|
|
372
|
+
assert_instance_of metal_class, record.weight
|
|
373
|
+
assert_equal 25_000, record.weight.atomic
|
|
374
|
+
end
|
|
312
375
|
end
|
data/test/test_amount.rb
CHANGED
|
@@ -26,6 +26,20 @@ class AmountTest < Minitest::Test
|
|
|
26
26
|
assert_equal 1_500_000, Amount.new(1.5, :USDC).atomic
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def test_construct_from_rational_treats_as_ui
|
|
30
|
+
assert_equal 1_500_000, Amount.new(Rational(3, 2), :USDC).atomic
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_construct_from_rational_is_exact_for_repeating_fractions
|
|
34
|
+
# 1/3 has no finite decimal expansion. With six decimals of storage
|
|
35
|
+
# the atomic representation is 333333 (truncated toward zero).
|
|
36
|
+
assert_equal 333_333, Amount.new(Rational(1, 3), :USDC).atomic
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_construct_from_rational_with_explicit_ui_from
|
|
40
|
+
assert_equal 1_500_000, Amount.new(Rational(3, 2), :USDC, from: :ui).atomic
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
def test_explicit_from_overrides_inference
|
|
30
44
|
amount = Amount.new("1500000", :USDC, from: :atomic)
|
|
31
45
|
|
|
@@ -215,6 +229,8 @@ class AmountTest < Minitest::Test
|
|
|
215
229
|
assert_equal Amount.new("3", :USDC), Amount.new("1", :USDC) * 3
|
|
216
230
|
assert_equal Amount.new("1.5", :USDC), Amount.new("1", :USDC) * 1.5
|
|
217
231
|
assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) * -2
|
|
232
|
+
assert_equal Amount.new("3", :USDC), Amount.new("2", :USDC) * BigDecimal("1.5")
|
|
233
|
+
assert_equal Amount.new("3", :USDC), Amount.new("2", :USDC) * Rational(3, 2)
|
|
218
234
|
end
|
|
219
235
|
|
|
220
236
|
def test_amount_times_amount_raises
|
|
@@ -226,6 +242,8 @@ class AmountTest < Minitest::Test
|
|
|
226
242
|
def test_scalar_division_returns_amount
|
|
227
243
|
assert_equal Amount.new("0.5", :USDC), Amount.new("1", :USDC) / 2
|
|
228
244
|
assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) / -0.5
|
|
245
|
+
assert_equal Amount.new("2", :USDC), Amount.new("3", :USDC) / Rational(3, 2)
|
|
246
|
+
assert_equal Amount.new("2", :USDC), Amount.new("3", :USDC) / BigDecimal("1.5")
|
|
229
247
|
end
|
|
230
248
|
|
|
231
249
|
def test_amount_division_returns_ratio
|
|
@@ -339,6 +357,33 @@ class AmountTest < Minitest::Test
|
|
|
339
357
|
assert_equal :GOLD, gold.symbol
|
|
340
358
|
end
|
|
341
359
|
|
|
360
|
+
def test_to_with_rational_rate
|
|
361
|
+
converted = Amount.new("4", :USDC).to(:USD, rate: Rational(1, 2))
|
|
362
|
+
|
|
363
|
+
assert_equal BigDecimal("2"), converted.decimal
|
|
364
|
+
assert_equal :USD, converted.symbol
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def test_register_default_rate_accepts_rational
|
|
368
|
+
Amount.register_default_rate :USDC, :USD, Rational(1, 2)
|
|
369
|
+
|
|
370
|
+
assert_equal BigDecimal("0.5"), Amount.registry.default_rate(:USDC, :USD)
|
|
371
|
+
assert_equal BigDecimal("0.5"), Amount.new("1", :USDC).to(:USD).decimal
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def test_display_units_with_rational_scale
|
|
375
|
+
Amount.registry.clear!
|
|
376
|
+
Amount.register :METAL, decimals: 4, display_symbol: "x", display_position: :suffix,
|
|
377
|
+
ui_decimals: 2,
|
|
378
|
+
display_units: { half: { scale: Rational(1, 2), symbol: "h", ui_decimals: 2 } },
|
|
379
|
+
default_display: :half
|
|
380
|
+
|
|
381
|
+
metal = Amount.new("1", :METAL)
|
|
382
|
+
|
|
383
|
+
assert_equal BigDecimal("0.5"), metal.in_unit(:half)
|
|
384
|
+
assert_match(/\A0\.50/, metal.ui(unit: :half))
|
|
385
|
+
end
|
|
386
|
+
|
|
342
387
|
def test_to_without_rate_requires_default
|
|
343
388
|
assert_raises(Amount::Registry::NoDefaultRate) do
|
|
344
389
|
Amount.new("1", :USDC).to(:GOLD)
|
|
@@ -392,6 +437,14 @@ class AmountTest < Minitest::Test
|
|
|
392
437
|
assert_equal Amount.new("1.5", :USDC), amount
|
|
393
438
|
end
|
|
394
439
|
|
|
440
|
+
def test_load_wraps_missing_keys_as_invalid_input
|
|
441
|
+
error = assert_raises(Amount::InvalidInput) { Amount.load(v: 1, symbol: "USDC") }
|
|
442
|
+
assert_match(/missing key: atomic/, error.message)
|
|
443
|
+
|
|
444
|
+
error = assert_raises(Amount::InvalidInput) { Amount.load({}) }
|
|
445
|
+
assert_match(/missing key/, error.message)
|
|
446
|
+
end
|
|
447
|
+
|
|
395
448
|
def test_load_rejects_unknown_serialization_version
|
|
396
449
|
assert_raises(Amount::InvalidInput) do
|
|
397
450
|
Amount.load(v: 2, atomic: "1500000", symbol: "USDC")
|
|
@@ -469,4 +522,35 @@ class AmountCustomClassTest < Minitest::Test
|
|
|
469
522
|
assert parts.all? { |part| part.instance_of?(GoldAmount) }
|
|
470
523
|
assert_instance_of GoldAmount, remainder
|
|
471
524
|
end
|
|
525
|
+
|
|
526
|
+
def test_amount_new_dispatches_to_registered_subclass
|
|
527
|
+
assert_instance_of GoldAmount, Amount.new("1", :GOLD)
|
|
528
|
+
assert_instance_of GoldAmount, Amount.new(100_000_000, :GOLD, from: :atomic)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def test_parse_dispatches_to_registered_subclass
|
|
532
|
+
assert_instance_of GoldAmount, Amount.parse("GOLD|1.0")
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def test_load_dispatches_to_registered_subclass
|
|
536
|
+
payload = GoldAmount.new("1", :GOLD).to_h
|
|
537
|
+
restored = Amount.load(payload)
|
|
538
|
+
|
|
539
|
+
assert_instance_of GoldAmount, restored
|
|
540
|
+
assert_equal "24k", restored.purity_estimate
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def test_explicit_wrong_subclass_still_raises
|
|
544
|
+
other = Class.new(Amount)
|
|
545
|
+
|
|
546
|
+
error = assert_raises(Amount::InvalidInput) { other.new("1", :GOLD) }
|
|
547
|
+
assert_match(/use AmountCustomClassTest::GoldAmount\.new for GOLD/, error.message)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def test_amount_new_with_default_class_is_unchanged
|
|
551
|
+
Amount.register :USDC, decimals: 6
|
|
552
|
+
instance = Amount.new("1", :USDC)
|
|
553
|
+
|
|
554
|
+
assert_instance_of Amount, instance
|
|
555
|
+
end
|
|
472
556
|
end
|