amounts 0.0.8 → 0.1.0

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +37 -0
  3. data/CHANGELOG.md +113 -0
  4. data/Gemfile +4 -0
  5. data/README.md +215 -1
  6. data/Rakefile +47 -10
  7. data/lib/amount/active_record/amount_validator.rb +9 -18
  8. data/lib/amount/active_record/attribute_definition.rb +3 -1
  9. data/lib/amount/active_record/locale/en.yml +7 -0
  10. data/lib/amount/active_record/model.rb +9 -0
  11. data/lib/amount/active_record.rb +12 -0
  12. data/lib/amount/compact.rb +137 -0
  13. data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
  14. data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +1 -1
  15. data/lib/amount/concerns/coercible.rb +65 -0
  16. data/lib/amount/concerns/constructible.rb +92 -0
  17. data/lib/amount/{conversion.rb → concerns/convertible.rb} +1 -1
  18. data/lib/amount/concerns/formattable.rb +112 -0
  19. data/lib/amount/concerns/localizable.rb +39 -0
  20. data/lib/amount/{comparison.rb → concerns/orderable.rb} +2 -2
  21. data/lib/amount/concerns/parsable.rb +58 -0
  22. data/lib/amount/concerns/registrable.rb +80 -0
  23. data/lib/amount/{serialization.rb → concerns/serializable.rb} +3 -3
  24. data/lib/amount/display.rb +81 -46
  25. data/lib/amount/locale.rb +102 -0
  26. data/lib/amount/registry.rb +69 -7
  27. data/lib/amount/ui_parser.rb +109 -0
  28. data/lib/amount/version.rb +1 -1
  29. data/lib/amount.rb +55 -199
  30. data/test/amount/active_record/amount_validator_test.rb +201 -0
  31. data/test/amount/active_record/attribute_definition_test.rb +234 -0
  32. data/test/amount/active_record/migration_methods_test.rb +60 -0
  33. data/test/amount/active_record/model_test.rb +40 -0
  34. data/test/amount/active_record/type_test.rb +80 -0
  35. data/test/amount/active_record_test.rb +12 -0
  36. data/test/amount/compact_test.rb +371 -0
  37. data/test/amount/concerns/allocatable_test.rb +166 -0
  38. data/test/amount/concerns/calculable_test.rb +152 -0
  39. data/test/amount/concerns/coercible_test.rb +49 -0
  40. data/test/amount/concerns/constructible_test.rb +113 -0
  41. data/test/amount/concerns/convertible_test.rb +54 -0
  42. data/test/amount/concerns/formattable_test.rb +336 -0
  43. data/test/amount/concerns/localizable_test.rb +59 -0
  44. data/test/amount/concerns/orderable_test.rb +83 -0
  45. data/test/amount/concerns/parsable_test.rb +98 -0
  46. data/test/amount/concerns/registrable_test.rb +67 -0
  47. data/test/amount/concerns/serializable_test.rb +98 -0
  48. data/test/amount/display_test.rb +181 -0
  49. data/test/amount/locale_test.rb +590 -0
  50. data/test/amount/parser_test.rb +29 -0
  51. data/test/amount/registry/generated_constructors_test.rb +94 -0
  52. data/test/amount/registry_test.rb +149 -0
  53. data/test/amount/ui_parser_test.rb +284 -0
  54. data/test/amount_test.rb +55 -0
  55. data/test/dummy/config/application.rb +3 -0
  56. data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
  57. data/test/integration/postgresql_test.rb +94 -0
  58. data/test/support/active_record_test_support.rb +65 -0
  59. data/test/support/error_helpers.rb +22 -0
  60. data/test/support/locale_test_setup.rb +36 -0
  61. data/test/test_helper.rb +1 -0
  62. metadata +47 -11
  63. data/test/postgresql_integration_test.rb +0 -71
  64. data/test/test_active_record.rb +0 -375
  65. data/test/test_amount.rb +0 -675
  66. data/test/test_registry_generator.rb +0 -104
data/test/test_amount.rb DELETED
@@ -1,675 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require_relative "test_helper"
5
-
6
- class AmountTest < Minitest::Test
7
- def setup
8
- AmountTestSupport.register_default_types!
9
- end
10
-
11
- def teardown
12
- Amount.registry.clear!
13
- end
14
-
15
- def test_construct_from_integer_treats_as_atomic
16
- amount = Amount.new(1_000_000, :USDC)
17
-
18
- assert_equal 1_000_000, amount.atomic
19
- assert_equal BigDecimal("1"), amount.decimal
20
- end
21
-
22
- def test_construct_from_string_treats_as_ui
23
- assert_equal 1_500_000, Amount.new("1.50", :USDC).atomic
24
- end
25
-
26
- def test_construct_from_float_treats_as_decimal
27
- assert_equal 1_500_000, Amount.new(1.5, :USDC).atomic
28
- end
29
-
30
- def test_construct_from_rational_treats_as_ui
31
- assert_equal 1_500_000, Amount.new(Rational(3, 2), :USDC).atomic
32
- end
33
-
34
- def test_construct_from_rational_is_exact_for_repeating_fractions
35
- # 1/3 has no finite decimal expansion. With six decimals of storage
36
- # the atomic representation is 333333 (truncated toward zero).
37
- assert_equal 333_333, Amount.new(Rational(1, 3), :USDC).atomic
38
- end
39
-
40
- def test_construct_from_rational_with_explicit_ui_from
41
- assert_equal 1_500_000, Amount.new(Rational(3, 2), :USDC, from: :ui).atomic
42
- end
43
-
44
- def test_explicit_from_overrides_inference
45
- amount = Amount.new("1500000", :USDC, from: :atomic)
46
-
47
- assert_equal 1_500_000, amount.atomic
48
- assert_equal BigDecimal("1.5"), amount.decimal
49
- end
50
-
51
- def test_parse_roundtrips_to_s
52
- amount = Amount.parse("USDC|1.5")
53
-
54
- assert_equal 1_500_000, amount.atomic
55
- assert_equal "USDC|1.50", amount.to_s
56
- end
57
-
58
- def test_parse_accepts_explicit_v1_prefix
59
- amount = Amount.parse("v1:USDC|1.5")
60
-
61
- assert_equal Amount.new("1.5", :USDC), amount
62
- end
63
-
64
- def test_parse_rejects_unknown_version_prefix
65
- assert_raises(Amount::InvalidInput) { Amount.parse("v2:USDC|1.5") }
66
- end
67
-
68
- def test_generated_constructor_matches_new
69
- assert_equal Amount.new("1.50", :USDC), Amount.of_usdc("1.50")
70
- end
71
-
72
- def test_generated_constructor_accepts_from_keyword
73
- assert_equal 1_500_000, Amount.of_usdc(1_500_000, from: :atomic).atomic
74
- end
75
-
76
- def test_multi_word_symbol_generates_constructor
77
- Amount.register :OIL_WTI_BBL, decimals: 4
78
-
79
- assert_equal Amount.new("1.5", :OIL_WTI_BBL), Amount.of_oil_wti_bbl("1.5")
80
- end
81
-
82
- def test_non_method_safe_symbol_skips_constructor_generation
83
- Amount.register :'USDC.e', decimals: 6
84
-
85
- refute Amount.respond_to?(:of_usdc_e)
86
- end
87
-
88
- def test_generated_constructor_uses_of_prefix
89
- # Without the `of_` prefix the generated method would shadow
90
- # `Object#try` once ActiveSupport is loaded — see the :TRY regression.
91
- Amount.register :TRY, decimals: 2
92
- assert Amount.respond_to?(:of_try)
93
- assert_equal Amount.new("100", :TRY), Amount.of_try("100")
94
- end
95
-
96
- def test_clear_removes_generated_constructor_methods
97
- assert Amount.respond_to?(:of_usdc)
98
-
99
- Amount.registry.clear!
100
-
101
- refute Amount.respond_to?(:of_usdc)
102
- end
103
-
104
- def test_registry_lock_prevents_mutation
105
- Amount.registry.lock!
106
-
107
- assert Amount.registry.locked?
108
- assert_raises(Amount::Registry::RegistryLocked) { Amount.register(:DOGE, decimals: 8) }
109
- assert_raises(Amount::Registry::RegistryLocked) { Amount.register_default_rate(:USD, :USDC, 1) }
110
- assert_raises(Amount::Registry::RegistryLocked) { Amount.registry.clear! }
111
- ensure
112
- Amount.send(:replace_registry, Amount::Registry.new)
113
- end
114
-
115
- def test_constructor_collision_raises_at_registration_time
116
- Amount.singleton_class.send(:define_method, :of_collide) { :existing }
117
-
118
- assert_raises(Amount::Registry::AlreadyRegistered) do
119
- Amount.register :COLLIDE, decimals: 2
120
- end
121
- ensure
122
- Amount.singleton_class.send(:remove_method, :of_collide) if Amount.singleton_class.method_defined?(:of_collide)
123
- end
124
-
125
- def test_inspect_uses_default_ui_representation
126
- assert_equal "#<Amount USDC $1.50>", Amount.new("1.5", :USDC).inspect
127
- assert_equal "#<Amount GOLD 1.0000 oz t>", Amount.new("1", :GOLD).inspect
128
- end
129
-
130
- def test_parse_rejects_missing_value
131
- assert_raises(Amount::InvalidInput) { Amount.parse("USDC|") }
132
- end
133
-
134
- def test_parse_rejects_missing_symbol
135
- assert_raises(Amount::InvalidInput) { Amount.parse("|1.5") }
136
- end
137
-
138
- def test_unregistered_type_raises
139
- assert_raises(Amount::Registry::UnknownType) { Amount.new(1, :DOGE) }
140
- end
141
-
142
- def test_register_rejects_empty_symbol
143
- error = assert_raises(ArgumentError) { Amount.register :"", decimals: 2 }
144
- assert_match(/symbol must not be blank/, error.message)
145
- end
146
-
147
- def test_register_rejects_nil_symbol
148
- assert_raises(ArgumentError) { Amount.register nil, decimals: 2 }
149
- end
150
-
151
- def test_frozen_amount_renders_display
152
- amount = Amount.new("1.5", :USDC).freeze
153
-
154
- assert_equal "$1.50", amount.ui
155
- assert_equal "1.500000", amount.formatted
156
- assert_equal "USDC|1.50", amount.to_s
157
- end
158
-
159
- def test_frozen_amount_arithmetic_returns_unfrozen_result
160
- a = Amount.new("1", :USDC).freeze
161
- b = Amount.new("2", :USDC).freeze
162
- sum = a + b
163
-
164
- assert_equal Amount.new("3", :USDC), sum
165
- refute sum.frozen?
166
- end
167
-
168
- def test_formatted_respects_storage_decimals
169
- assert_equal "1.500000", Amount.new("1.5", :USDC).formatted
170
- assert_equal "1.500000000", Amount.new("1.5", :SOL).formatted
171
- assert_equal "1", Amount.new(1, :LOGS).formatted
172
- end
173
-
174
- def test_ui_respects_ui_decimals_and_position
175
- assert_equal "$1.50", Amount.new("1.5", :USDC).ui
176
- assert_equal "$1.56", Amount.new("1.567", :USDC).ui
177
- assert_equal "1.5000 SOL", Amount.new("1.5", :SOL).ui
178
- assert_equal "1 logs", Amount.new(1, :LOGS).ui
179
- end
180
-
181
- def test_ui_ceil_direction
182
- assert_equal "$1.57", Amount.new("1.561", :USDC).ui(direction: :ceil)
183
- end
184
-
185
- def test_ui_decorated_false_omits_the_display_symbol
186
- assert_equal "1.50", Amount.new("1.5", :USDC).ui(decorated: false)
187
- assert_equal "1.5000", Amount.new("1.5", :SOL).ui(decorated: false)
188
- assert_equal "1", Amount.new(1, :LOGS).ui(decorated: false)
189
- end
190
-
191
- def test_ui_decorated_false_with_ceil_direction
192
- assert_equal "1.57", Amount.new("1.561", :USDC).ui(direction: :ceil, decorated: false)
193
- end
194
-
195
- def test_display_units_scale_output
196
- gold = Amount.new("1.5", :GOLD)
197
-
198
- assert_equal "1.5000 oz t", gold.ui
199
- assert_equal "46.65 g", gold.ui(unit: :gram)
200
- assert_equal "0.04665 kg", gold.ui(unit: :kg)
201
- end
202
-
203
- def test_display_units_with_decorated_false
204
- gold = Amount.new("1.5", :GOLD)
205
-
206
- assert_equal "1.5000", gold.ui(decorated: false)
207
- assert_equal "46.65", gold.ui(unit: :gram, decorated: false)
208
- assert_equal "0.04665", gold.ui(unit: :kg, decorated: false)
209
- end
210
-
211
- def test_in_unit_returns_raw_bigdecimal
212
- gold = Amount.new("1.0", :GOLD)
213
-
214
- assert_in_delta 31.1035, gold.in_unit(:gram).to_f, 0.0001
215
- end
216
-
217
- def test_unknown_display_unit_raises
218
- assert_raises(Amount::Registry::InvalidDisplayUnit) do
219
- Amount.new("1", :GOLD).ui(unit: :pound)
220
- end
221
- end
222
-
223
- def test_trim_zeros_strips_trailing_zeros
224
- Amount.register :TZ, decimals: 9, display_symbol: "TZ", display_position: :suffix, trim_zeros: true
225
-
226
- assert_equal "2.5 TZ", Amount.new("2.5", :TZ).ui
227
- assert_equal "1 TZ", Amount.new("1.0", :TZ).ui
228
- assert_equal "0 TZ", Amount.new("0.0", :TZ).ui
229
- assert_equal "1.123 TZ", Amount.new("1.123", :TZ).ui
230
- assert_equal "0.00005 TZ", Amount.new(50_000, :TZ, from: :atomic).ui
231
- end
232
-
233
- def test_trim_zeros_false_preserves_trailing_zeros
234
- assert_equal "$1.50", Amount.new("1.5", :USDC).ui
235
- assert_equal "$1.00", Amount.new("1.0", :USDC).ui
236
- end
237
-
238
- def test_trim_zeros_with_decorated_false
239
- Amount.register :TZ, decimals: 9, display_symbol: "TZ", display_position: :suffix, trim_zeros: true
240
-
241
- assert_equal "2.5", Amount.new("2.5", :TZ).ui(decorated: false)
242
- assert_equal "1", Amount.new("1.0", :TZ).ui(decorated: false)
243
- end
244
-
245
- def test_trim_zeros_with_display_units
246
- Amount.register :TZG, decimals: 8, display_symbol: "oz t", display_position: :suffix,
247
- trim_zeros: true,
248
- display_units: { gram: { scale: "31.1035", symbol: "g", ui_decimals: 4 } }
249
-
250
- assert_equal "31.1035 g", Amount.new("1.0", :TZG).ui(unit: :gram)
251
- end
252
-
253
- def test_trim_zeros_display_unit_override
254
- Amount.register :TZO, decimals: 6, display_symbol: "T", display_position: :suffix,
255
- trim_zeros: true,
256
- display_units: { fixed: { scale: 1, symbol: "F", ui_decimals: 2, trim_zeros: false } }
257
-
258
- assert_equal "1.50 F", Amount.new("1.5", :TZO).ui(unit: :fixed)
259
- end
260
-
261
- def test_trim_zeros_call_site_override
262
- assert_equal "1.5 SOL", Amount.new("1.5", :SOL).ui(trim_zeros: true)
263
- assert_equal "1.0000 SOL", Amount.new("1.0", :SOL).ui(trim_zeros: false)
264
- end
265
-
266
- def test_trim_zeros_call_site_overrides_registry
267
- Amount.register :TZR, decimals: 6, display_symbol: "T", display_position: :suffix, trim_zeros: true
268
-
269
- assert_equal "1.500000 T", Amount.new("1.5", :TZR).ui(trim_zeros: false)
270
- end
271
-
272
- def test_predicates
273
- assert Amount.new(0, :USDC).zero?
274
- assert Amount.new(1, :USDC).positive?
275
- assert Amount.new(-1, :USDC).negative?
276
- end
277
-
278
- def test_same_type
279
- assert Amount.new(1, :USDC).same_type?(Amount.new(2, :USDC))
280
- refute Amount.new(1, :USDC).same_type?(Amount.new(1, :SOL))
281
- refute Amount.new(1, :USDC).same_type?(1)
282
- end
283
-
284
- def test_addition_same_type
285
- assert_equal Amount.new("2.0", :USDC), Amount.new("1.5", :USDC) + Amount.new("0.5", :USDC)
286
- end
287
-
288
- def test_addition_cross_type_uses_registered_rate
289
- Amount.register_default_rate :USD, :USDC, 1
290
-
291
- result = Amount.new("10.00", :USDC) + Amount.new("5.00", :USD)
292
-
293
- assert_equal Amount.new("15.00", :USDC), result
294
- end
295
-
296
- def test_addition_different_type_raises_without_rate
297
- assert_raises(Amount::TypeMismatch) do
298
- Amount.new(1, :USDC) + Amount.new(1, :SOL)
299
- end
300
- end
301
-
302
- def test_subtraction_cross_type_uses_registered_rate
303
- Amount.register_default_rate :USD, :USDC, 1
304
-
305
- result = Amount.new("10.00", :USDC) - Amount.new("5.00", :USD)
306
-
307
- assert_equal Amount.new("5.00", :USDC), result
308
- end
309
-
310
- def test_subtraction_different_type_raises_without_rate
311
- assert_raises(Amount::TypeMismatch) do
312
- Amount.new(1, :USDC) - Amount.new(1, :USD)
313
- end
314
- end
315
-
316
- def test_asymmetric_rate_registration_only_allows_configured_direction
317
- Amount.register_default_rate :USD, :USDC, 1
318
-
319
- assert_equal Amount.new("15.00", :USDC), Amount.new("10.00", :USDC) + Amount.new("5.00", :USD)
320
-
321
- assert_raises(Amount::TypeMismatch) do
322
- Amount.new("5.00", :USD) + Amount.new("10.00", :USDC)
323
- end
324
- end
325
-
326
- def test_unary_minus
327
- assert_equal Amount.new("-1", :USDC), -Amount.new("1", :USDC)
328
- end
329
-
330
- def test_abs
331
- assert_equal Amount.new("1", :USDC), Amount.new("-1", :USDC).abs
332
- end
333
-
334
- def test_scalar_multiplication
335
- assert_equal Amount.new("3", :USDC), Amount.new("1", :USDC) * 3
336
- assert_equal Amount.new("1.5", :USDC), Amount.new("1", :USDC) * 1.5
337
- assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) * -2
338
- assert_equal Amount.new("3", :USDC), Amount.new("2", :USDC) * BigDecimal("1.5")
339
- assert_equal Amount.new("3", :USDC), Amount.new("2", :USDC) * Rational(3, 2)
340
- end
341
-
342
- def test_amount_times_amount_raises
343
- assert_raises(Amount::TypeMismatch) do
344
- Amount.new("1", :USDC) * Amount.new("1", :USDC)
345
- end
346
- end
347
-
348
- def test_scalar_division_returns_amount
349
- assert_equal Amount.new("0.5", :USDC), Amount.new("1", :USDC) / 2
350
- assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) / -0.5
351
- assert_equal Amount.new("2", :USDC), Amount.new("3", :USDC) / Rational(3, 2)
352
- assert_equal Amount.new("2", :USDC), Amount.new("3", :USDC) / BigDecimal("1.5")
353
- end
354
-
355
- def test_amount_division_returns_ratio
356
- ratio = Amount.new("10", :USDC) / Amount.new("2", :USDC)
357
-
358
- assert_equal BigDecimal(5), ratio
359
- end
360
-
361
- def test_division_by_zero
362
- assert_raises(ZeroDivisionError) { Amount.new(1, :USDC) / 0 }
363
- end
364
-
365
- def test_split_preserves_total_exactly_with_remainder
366
- amount = Amount.new(10, :LOGS)
367
- parts, remainder = amount.split(3)
368
-
369
- assert_equal [3, 3, 3], parts.map(&:atomic)
370
- assert_equal 1, remainder.atomic
371
- assert_equal amount.atomic, parts.sum(&:atomic) + remainder.atomic
372
- end
373
-
374
- def test_split_even_division_returns_zero_remainder
375
- parts, remainder = Amount.new(9, :LOGS).split(3)
376
-
377
- assert_equal [3, 3, 3], parts.map(&:atomic)
378
- assert_equal 0, remainder.atomic
379
- end
380
-
381
- def test_split_one_returns_same_amount_and_zero_remainder
382
- parts, remainder = Amount.new(9, :LOGS).split(1)
383
-
384
- assert_equal [9], parts.map(&:atomic)
385
- assert_equal 0, remainder.atomic
386
- end
387
-
388
- def test_split_negative_amount_rounds_toward_zero_with_negative_remainder
389
- parts, remainder = Amount.new(-10, :LOGS).split(3)
390
-
391
- assert_equal [-3, -3, -3], parts.map(&:atomic)
392
- assert_equal(-1, remainder.atomic)
393
- assert_equal(-10, parts.sum(&:atomic) + remainder.atomic)
394
- end
395
-
396
- def test_allocate_by_weights
397
- amount = Amount.new(100, :LOGS)
398
- parts, remainder = amount.allocate([1, 1, 2])
399
-
400
- assert_equal [25, 25, 50], parts.map(&:atomic)
401
- assert_equal 0, remainder.atomic
402
- assert_equal amount.atomic, parts.sum(&:atomic) + remainder.atomic
403
- end
404
-
405
- def test_allocate_remainder_is_explicit
406
- parts, remainder = Amount.new(10, :LOGS).allocate([1, 1, 1])
407
-
408
- assert_equal [3, 3, 3], parts.map(&:atomic)
409
- assert_equal 1, remainder.atomic
410
- end
411
-
412
- def test_allocate_allows_zero_weights
413
- parts, remainder = Amount.new(10, :LOGS).allocate([0, 1])
414
-
415
- assert_equal [0, 10], parts.map(&:atomic)
416
- assert_equal 0, remainder.atomic
417
- end
418
-
419
- def test_allocate_negative_amount_rounds_toward_zero_with_negative_remainder
420
- parts, remainder = Amount.new(-10, :LOGS).allocate([1, 1, 1])
421
-
422
- assert_equal [-3, -3, -3], parts.map(&:atomic)
423
- assert_equal(-1, remainder.atomic)
424
- assert_equal(-10, parts.sum(&:atomic) + remainder.atomic)
425
- end
426
-
427
- def test_comparison_same_type
428
- left = Amount.new("1", :USDC)
429
- right = Amount.new("2", :USDC)
430
-
431
- assert left < right
432
- assert right > left
433
- assert_equal Amount.new("1", :USDC), left
434
- end
435
-
436
- def test_comparison_cross_type_uses_registered_rate
437
- Amount.register_default_rate :USD, :USDC, 1
438
-
439
- assert_operator Amount.new("10.00", :USDC), :>, Amount.new("5.00", :USD)
440
- assert_equal 0, Amount.new("5.00", :USDC) <=> Amount.new("5.00", :USD)
441
- end
442
-
443
- def test_comparison_different_type_nil_without_rate
444
- assert_nil(Amount.new(1, :USDC) <=> Amount.new(1, :SOL))
445
- end
446
-
447
- def test_sorting
448
- amounts = [Amount.new("3", :USDC), Amount.new("1", :USDC), Amount.new("2", :USDC)]
449
-
450
- assert_equal ["1.0", "2.0", "3.0"], amounts.sort.map { |amount| amount.decimal.to_s("F") }
451
- end
452
-
453
- def test_to_same_symbol_is_identity
454
- amount = Amount.new("1", :USDC)
455
-
456
- assert_equal amount, amount.to(:USDC)
457
- end
458
-
459
- def test_to_with_explicit_rate
460
- gold = Amount.new("1000", :USDC).to(:GOLD, rate: "0.00042")
461
-
462
- assert_equal BigDecimal("0.42"), gold.decimal
463
- assert_equal :GOLD, gold.symbol
464
- end
465
-
466
- def test_to_with_rational_rate
467
- converted = Amount.new("4", :USDC).to(:USD, rate: Rational(1, 2))
468
-
469
- assert_equal BigDecimal("2"), converted.decimal
470
- assert_equal :USD, converted.symbol
471
- end
472
-
473
- def test_register_default_rate_accepts_rational
474
- Amount.register_default_rate :USDC, :USD, Rational(1, 2)
475
-
476
- assert_equal BigDecimal("0.5"), Amount.registry.default_rate(:USDC, :USD)
477
- assert_equal BigDecimal("0.5"), Amount.new("1", :USDC).to(:USD).decimal
478
- end
479
-
480
- def test_display_units_with_rational_scale
481
- Amount.registry.clear!
482
- Amount.register :METAL, decimals: 4, display_symbol: "x", display_position: :suffix,
483
- ui_decimals: 2,
484
- display_units: { half: { scale: Rational(1, 2), symbol: "h", ui_decimals: 2 } },
485
- default_display: :half
486
-
487
- metal = Amount.new("1", :METAL)
488
-
489
- assert_equal BigDecimal("0.5"), metal.in_unit(:half)
490
- assert_match(/\A0\.50/, metal.ui(unit: :half))
491
- end
492
-
493
- def test_to_without_rate_requires_default
494
- assert_raises(Amount::Registry::NoDefaultRate) do
495
- Amount.new("1", :USDC).to(:GOLD)
496
- end
497
- end
498
-
499
- def test_to_uses_registered_default_rate
500
- Amount.register_default_rate :USDC, :USD, 1
501
-
502
- usd = Amount.new("1.5", :USDC).to(:USD)
503
-
504
- assert_equal BigDecimal("1.5"), usd.decimal
505
- assert_equal :USD, usd.symbol
506
- end
507
-
508
- def test_cross_type_addition_via_explicit_conversion
509
- gold = Amount.new("0.0001", :GOLD)
510
- usdc = Amount.new("100", :USDC)
511
- result = gold + usdc.to(:GOLD, rate: "0.00000042")
512
-
513
- assert_equal BigDecimal("0.000142"), result.decimal
514
- end
515
-
516
- def test_hash_equality_semantics
517
- one = Amount.new("1.5", :USDC)
518
- two = Amount.new("1.5", :USDC)
519
- three = Amount.new("1.5", :USD)
520
-
521
- data = { one => :first }
522
-
523
- assert_equal :first, data[two]
524
- assert_nil data[three]
525
- end
526
-
527
- def test_to_h_and_load_round_trip_for_large_negative_amount
528
- amount = Amount.new(-(10**30), :USDC, from: :atomic)
529
-
530
- assert_equal amount, Amount.load(amount.to_h)
531
- end
532
-
533
- def test_to_h_returns_versioned_string_safe_payload
534
- assert_equal(
535
- { v: 1, atomic: "1500000", symbol: "USDC" },
536
- Amount.new("1.5", :USDC).to_h
537
- )
538
- end
539
-
540
- def test_load_accepts_legacy_unversioned_payload
541
- amount = Amount.load(atomic: 1_500_000, symbol: :USDC)
542
-
543
- assert_equal Amount.new("1.5", :USDC), amount
544
- end
545
-
546
- def test_load_wraps_missing_keys_as_invalid_input
547
- error = assert_raises(Amount::InvalidInput) { Amount.load(v: 1, symbol: "USDC") }
548
- assert_match(/missing key: atomic/, error.message)
549
-
550
- error = assert_raises(Amount::InvalidInput) { Amount.load({}) }
551
- assert_match(/missing key/, error.message)
552
- end
553
-
554
- def test_as_json_returns_compact_string
555
- assert_equal "USDC|1.50", Amount.new("1.5", :USDC).as_json
556
- end
557
-
558
- def test_to_json_returns_quoted_compact_string
559
- assert_equal '"USDC|1.50"', Amount.new("1.5", :USDC).to_json
560
- end
561
-
562
- def test_as_json_works_when_nested_in_hash
563
- hash = { amount: Amount.new("1.5", :USDC) }
564
- assert_equal({ "amount" => "USDC|1.50" }, JSON.parse(hash.to_json))
565
- end
566
-
567
- def test_load_rejects_unknown_serialization_version
568
- assert_raises(Amount::InvalidInput) do
569
- Amount.load(v: 2, atomic: "1500000", symbol: "USDC")
570
- end
571
- end
572
-
573
- def test_very_large_atomic_value_preserves_precision
574
- amount = Amount.new(10**30, :SOL, from: :atomic)
575
-
576
- assert_equal 10**30, amount.atomic
577
- assert_equal "1000000000000000000000.0", amount.decimal.to_s("F")
578
- end
579
-
580
- def test_registry_can_be_read_from_multiple_threads
581
- errors = Queue.new
582
-
583
- threads = 10.times.map do
584
- Thread.new do
585
- 100.times do
586
- Amount.registry.lookup(:USDC)
587
- Amount.registry.default_rate?(:USDC, :USD)
588
- rescue StandardError => error
589
- errors << error
590
- end
591
- end
592
- end
593
-
594
- threads.each(&:join)
595
-
596
- assert errors.empty?, "expected no thread errors, got #{errors.size}"
597
- end
598
- end
599
-
600
- class AmountCustomClassTest < Minitest::Test
601
- class GoldAmount < Amount
602
- def purity_estimate
603
- "24k"
604
- end
605
- end
606
-
607
- def setup
608
- Amount.registry.clear!
609
- Amount.register :GOLD, decimals: 8, display_symbol: "oz t",
610
- display_position: :suffix, ui_decimals: 4,
611
- class: GoldAmount
612
- end
613
-
614
- def teardown
615
- Amount.registry.clear!
616
- end
617
-
618
- def test_subclass_instance_from_own_new
619
- gold = GoldAmount.new("1", :GOLD)
620
-
621
- assert_instance_of GoldAmount, gold
622
- assert_equal "24k", gold.purity_estimate
623
- end
624
-
625
- def test_arithmetic_returns_subclass
626
- gold = GoldAmount.new("1", :GOLD)
627
-
628
- assert_instance_of GoldAmount, gold + gold
629
- end
630
-
631
- def test_generated_constructor_returns_subclass
632
- gold = Amount.of_gold("1", from: :ui)
633
-
634
- assert_instance_of GoldAmount, gold
635
- assert_equal "24k", gold.purity_estimate
636
- end
637
-
638
- def test_split_returns_subclass_parts_and_remainder
639
- parts, remainder = GoldAmount.new("3", :GOLD).split(2)
640
-
641
- assert parts.all? { |part| part.instance_of?(GoldAmount) }
642
- assert_instance_of GoldAmount, remainder
643
- end
644
-
645
- def test_amount_new_dispatches_to_registered_subclass
646
- assert_instance_of GoldAmount, Amount.new("1", :GOLD)
647
- assert_instance_of GoldAmount, Amount.new(100_000_000, :GOLD, from: :atomic)
648
- end
649
-
650
- def test_parse_dispatches_to_registered_subclass
651
- assert_instance_of GoldAmount, Amount.parse("GOLD|1.0")
652
- end
653
-
654
- def test_load_dispatches_to_registered_subclass
655
- payload = GoldAmount.new("1", :GOLD).to_h
656
- restored = Amount.load(payload)
657
-
658
- assert_instance_of GoldAmount, restored
659
- assert_equal "24k", restored.purity_estimate
660
- end
661
-
662
- def test_explicit_wrong_subclass_still_raises
663
- other = Class.new(Amount)
664
-
665
- error = assert_raises(Amount::InvalidInput) { other.new("1", :GOLD) }
666
- assert_match(/use AmountCustomClassTest::GoldAmount\.new for GOLD/, error.message)
667
- end
668
-
669
- def test_amount_new_with_default_class_is_unchanged
670
- Amount.register :USDC, decimals: 6
671
- instance = Amount.new("1", :USDC)
672
-
673
- assert_instance_of Amount, instance
674
- end
675
- end