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