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
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+ require_relative "../test_helper"
5
+ require_relative "../support/locale_test_setup"
6
+
7
+ class AmountCompactTest < Minitest::Test
8
+ include LocaleTestSetup
9
+
10
+ def setup
11
+ super
12
+ Amount.register :TOKENS, decimals: 0
13
+ end
14
+
15
+ # Off by default
16
+
17
+ def test_without_compact_output_is_unchanged
18
+ assert_equal "50000000 TOKENS", tokens(50_000_000).ui
19
+ end
20
+
21
+ def test_compact_false_output_is_unchanged
22
+ assert_equal "50000000 TOKENS", tokens(50_000_000).ui(compact: false)
23
+ end
24
+
25
+ def test_without_compact_prefix_type_output_is_unchanged
26
+ assert_equal "$1500000.00", Amount.of_usd("1500000").ui
27
+ end
28
+
29
+ # Thresholds
30
+
31
+ def test_below_one_thousand_is_formatted_normally
32
+ assert_equal "999 TOKENS", tokens(999).ui(compact: true)
33
+ end
34
+
35
+ def test_below_one_thousand_keeps_decimals
36
+ assert_equal "$999.99", Amount.of_usd("999.99").ui(compact: true)
37
+ end
38
+
39
+ def test_zero_is_formatted_normally
40
+ assert_equal "0 TOKENS", tokens(0).ui(compact: true)
41
+ end
42
+
43
+ # Each row: count => expected compact output (default floor rounding).
44
+ {
45
+ 1_000 => "1K TOKENS",
46
+ 999_999 => "999K TOKENS",
47
+ 1_000_000 => "1M TOKENS",
48
+ 999_999_999 => "999M TOKENS",
49
+ 1_000_000_000 => "1B TOKENS",
50
+ 1_000_000_000_000 => "1T TOKENS"
51
+ }.each do |count, expected|
52
+ define_method("test_scale_threshold_#{count}") do
53
+ assert_equal expected, tokens(count).ui(compact: true)
54
+ end
55
+ end
56
+
57
+ {
58
+ 1_500_000_000_000_000 => "1.5Qa TOKENS",
59
+ 10**18 => "1Qi TOKENS",
60
+ 10**21 => "1Sx TOKENS",
61
+ 10**24 => "1Sp TOKENS",
62
+ 10**27 => "1Oc TOKENS",
63
+ 10**30 => "1No TOKENS",
64
+ 10**33 => "1Dc TOKENS",
65
+ (10**21) - 1 => "999Qi TOKENS"
66
+ }.each do |count, expected|
67
+ define_method("test_scales_past_trillions_#{count}") do
68
+ assert_equal expected, tokens(count).ui(compact: true)
69
+ end
70
+ end
71
+
72
+ def test_scales_past_trillions_ceil_rounds_into_sextillions
73
+ assert_equal "1Sx TOKENS", tokens((10**21) - 1).ui(compact: true, direction: :ceil)
74
+ end
75
+
76
+ def test_beyond_decillions_stays_in_decillions
77
+ assert_equal "1500Dc TOKENS", tokens(15 * (10**35)).ui(compact: true)
78
+ end
79
+
80
+ def test_beyond_decillions_stays_in_decillions_with_locale_delimiter
81
+ assert_equal "1.500Dc TOKENS", tokens(15 * (10**35)).ui(compact: true, locale: :de)
82
+ end
83
+
84
+ # Precision and rounding
85
+
86
+ {
87
+ 1_234 => "1.23K TOKENS",
88
+ 12_345_678 => "12.3M TOKENS",
89
+ 123_456_789_012 => "123B TOKENS"
90
+ }.each do |count, expected|
91
+ define_method("test_three_significant_digits_by_default_#{count}") do
92
+ assert_equal expected, tokens(count).ui(compact: true)
93
+ end
94
+ end
95
+
96
+ {
97
+ 50_000_000 => "50M TOKENS",
98
+ 1_500 => "1.5K TOKENS",
99
+ 1_010 => "1.01K TOKENS"
100
+ }.each do |count, expected|
101
+ define_method("test_trailing_zeros_are_trimmed_#{count}") do
102
+ assert_equal expected, tokens(count).ui(compact: true)
103
+ end
104
+ end
105
+
106
+ def test_floor_never_rounds_up
107
+ assert_equal "1.99M TOKENS", tokens(1_999_999).ui(compact: true)
108
+ end
109
+
110
+ def test_ceil_rounds_up_to_whole
111
+ assert_equal "2M TOKENS", tokens(1_999_999).ui(compact: true, direction: :ceil)
112
+ end
113
+
114
+ def test_ceil_rounds_up_last_significant_digit
115
+ assert_equal "1.24K TOKENS", tokens(1_234).ui(compact: true, direction: :ceil)
116
+ end
117
+
118
+ def test_rounding_into_the_next_scale_uses_that_scale_millions
119
+ assert_equal "1M TOKENS", tokens(999_999).ui(compact: true, direction: :ceil)
120
+ end
121
+
122
+ def test_rounding_into_the_next_scale_uses_that_scale_trillions
123
+ assert_equal "1T TOKENS", tokens(999_999_999_999).ui(compact: true, direction: :ceil)
124
+ end
125
+
126
+ # floor goes toward negative infinity, as `ui` does for any negative amount
127
+
128
+ def test_negative_exact_value
129
+ assert_equal "-1.5M TOKENS", tokens(-1_500_000).ui(compact: true)
130
+ end
131
+
132
+ def test_negative_floor_rounds_away_from_zero
133
+ assert_equal "-2M TOKENS", tokens(-1_999_999).ui(compact: true)
134
+ end
135
+
136
+ def test_negative_ceil_rounds_toward_zero
137
+ assert_equal "-1.99M TOKENS", tokens(-1_999_999).ui(compact: true, direction: :ceil)
138
+ end
139
+
140
+ def test_negative_floor_rounds_into_the_next_scale
141
+ assert_equal "-1M TOKENS", tokens(-999_999).ui(compact: true)
142
+ end
143
+
144
+ def test_negative_below_one_thousand_is_formatted_normally
145
+ assert_equal "-999 TOKENS", tokens(-999).ui(compact: true)
146
+ end
147
+
148
+ def test_negative_prefix_type
149
+ assert_equal "$-1.5K", Amount.of_usd("-1500").ui(compact: true)
150
+ end
151
+
152
+ def test_precision_two_floors
153
+ assert_equal "1.2M TOKENS", tokens(1_299_999).ui(compact: { precision: 2 })
154
+ end
155
+
156
+ def test_precision_one
157
+ assert_equal "1M TOKENS", tokens(1_299_999).ui(compact: { precision: 1 })
158
+ end
159
+
160
+ def test_precision_two_trims_trailing_zero
161
+ assert_equal "150K TOKENS", tokens(155_000).ui(compact: { precision: 2 })
162
+ end
163
+
164
+ def test_precision_five
165
+ assert_equal "1.2345M TOKENS", tokens(1_234_567).ui(compact: { precision: 5 })
166
+ end
167
+
168
+ def test_empty_options_hash_uses_default_precision
169
+ assert_equal "1.23M TOKENS", tokens(1_234_567).ui(compact: {})
170
+ end
171
+
172
+ {
173
+ "symbol" => :yes,
174
+ "zero_precision" => { precision: 0 },
175
+ "string_precision" => { precision: "2" },
176
+ "unknown_key" => { digits: 2 }
177
+ }.each do |name, compact|
178
+ define_method("test_invalid_compact_option_raises_#{name}") do
179
+ assert_raises(ArgumentError) { tokens(1).ui(compact:) }
180
+ end
181
+ end
182
+
183
+ def test_compact_ignores_fractional_atomic_noise
184
+ assert_equal "$1.23M", Amount.of_usdc("1234567.891234").ui(compact: true)
185
+ end
186
+
187
+ # Labels and templates
188
+
189
+ def test_prefix_type_wraps_the_compacted_number
190
+ assert_equal "$1.5M", Amount.of_usd("1500000").ui(compact: true)
191
+ end
192
+
193
+ def test_suffix_type_wraps_the_compacted_number
194
+ assert_equal "2.5K SOL", Amount.of_sol("2500").ui(compact: true)
195
+ end
196
+
197
+ def test_display_format_wraps_the_compacted_number
198
+ Amount.register :EUR, decimals: 2, display_symbol: "€", display_format: "%n %u"
199
+
200
+ assert_equal "1.5M €", Amount.of_eur("1500000").ui(compact: true)
201
+ end
202
+
203
+ def test_undecorated_returns_just_the_compacted_number
204
+ assert_equal "50M", tokens(50_000_000).ui(compact: true, decorated: false)
205
+ end
206
+
207
+ def test_undecorated_keeps_the_locale_scale_word
208
+ german_keys
209
+
210
+ assert_equal "1,5 Mio.", tokens(1_500_000).ui(compact: true, decorated: false, locale: :de)
211
+ end
212
+
213
+ def test_plural_label_agrees_with_the_full_value
214
+ register_searches
215
+
216
+ assert_equal "1K searches", Amount.of_searches(1_000).ui(compact: true)
217
+ end
218
+
219
+ def test_singular_label_below_one_thousand
220
+ register_searches
221
+
222
+ assert_equal "1 search", Amount.of_searches(1).ui(compact: true)
223
+ end
224
+
225
+ def test_display_unit_compacts_the_scaled_number
226
+ assert_equal "31.1K g", Amount.of_gold("1000").ui(unit: :gram, compact: true)
227
+ end
228
+
229
+ def test_display_unit_compacts_the_scaled_number_with_ceil
230
+ assert_equal "31.2K g", Amount.of_gold("1000").ui(unit: :gram, compact: true, direction: :ceil)
231
+ end
232
+
233
+ def test_display_unit_compacts_another_unit
234
+ assert_equal "1K oz t", Amount.of_gold("1000").ui(unit: :oz_t, compact: true)
235
+ end
236
+
237
+ def test_display_unit_below_one_thousand_uses_its_decimals
238
+ assert_equal "311.03 g", Amount.of_gold("10").ui(unit: :gram, compact: true)
239
+ end
240
+
241
+ def test_wire_format_ignores_compact
242
+ assert_equal "TOKENS|50000000", tokens(50_000_000).to_s
243
+ end
244
+
245
+ # Scale words
246
+
247
+ def test_short_forms_when_not_localizing_even_with_locale_keys
248
+ german_keys
249
+
250
+ assert_equal "1.5M TOKENS", tokens(1_500_000).ui(compact: true)
251
+ end
252
+
253
+ def test_en_with_rails_keys_million
254
+ english_keys
255
+
256
+ assert_equal "50 Million TOKENS", tokens(50_000_000).ui(compact: true, locale: :en)
257
+ end
258
+
259
+ def test_en_with_rails_keys_thousand
260
+ english_keys
261
+
262
+ assert_equal "1.23 Thousand TOKENS", tokens(1_234).ui(compact: true, locale: :en)
263
+ end
264
+
265
+ def test_en_with_rails_keys_billion_prefix_type
266
+ english_keys
267
+
268
+ assert_equal "$1.5 Billion", Amount.of_usd("1500000000").ui(compact: true, locale: :en)
269
+ end
270
+
271
+ def test_en_with_rails_keys_quadrillion
272
+ english_keys
273
+
274
+ assert_equal "1.5 Quadrillion TOKENS", tokens(1_500_000_000_000_000).ui(compact: true, locale: :en)
275
+ end
276
+
277
+ # no `quintillion` key: falls back to the short form
278
+ def test_en_with_rails_keys_missing_quintillion_falls_back_to_short_form
279
+ english_keys
280
+
281
+ assert_equal "2Qi TOKENS", tokens(2 * (10**18)).ui(compact: true, locale: :en)
282
+ end
283
+
284
+ {
285
+ 1_500_000 => "1,5 Mio. Token",
286
+ 1_999_999 => "1,99 Mio. Token",
287
+ 12_000 => "12 Tsd. Token",
288
+ 999 => "999 Token"
289
+ }.each do |count, expected|
290
+ define_method("test_de_with_its_own_keys_#{count}") do
291
+ german_keys
292
+
293
+ assert_equal expected, tokens(count).ui(compact: true, locale: :de)
294
+ end
295
+ end
296
+
297
+ def test_localize_true_uses_i18n_locale
298
+ german_keys
299
+ Amount.localize = true
300
+ I18n.locale = :de
301
+
302
+ assert_equal "1,5 Mio. Token", tokens(1_500_000).ui(compact: true)
303
+ end
304
+
305
+ def test_pluralized_scale_word_one
306
+ store_decimal_units(:de, units: { million: { one: "Million", other: "Millionen" } })
307
+
308
+ assert_equal "1 Million TOKENS", tokens(1_000_000).ui(compact: true, locale: :de)
309
+ end
310
+
311
+ def test_pluralized_scale_word_other
312
+ store_decimal_units(:de, units: { million: { one: "Million", other: "Millionen" } })
313
+
314
+ assert_equal "2,5 Millionen TOKENS", tokens(2_500_000).ui(compact: true, locale: :de)
315
+ end
316
+
317
+ def test_locale_format_joins_number_and_word
318
+ store_decimal_units(:de, format: "%n%u", units: { thousand: "k" })
319
+
320
+ assert_equal "1,5k TOKENS", tokens(1_500).ui(compact: true, locale: :de)
321
+ end
322
+
323
+ def test_missing_locale_keys_fall_back_to_short_form
324
+ assert_equal "1,5M TOKENS", tokens(1_500_000).ui(compact: true, locale: :de)
325
+ end
326
+
327
+ def test_missing_locale_word_falls_back_to_short_form
328
+ german_keys
329
+
330
+ assert_equal "1,5B Token", tokens(1_500_000_000).ui(compact: true, locale: :de)
331
+ end
332
+
333
+ def test_locale_format_without_number_is_ignored
334
+ store_decimal_units(:de, format: "%u", units: { thousand: "Tsd." })
335
+
336
+ assert_equal "1,5 Tsd. TOKENS", tokens(1_500).ui(compact: true, locale: :de)
337
+ end
338
+
339
+ def test_compact_works_without_i18n
340
+ without_i18n { assert_equal "50M TOKENS", tokens(50_000_000).ui(compact: true) }
341
+ end
342
+
343
+ private
344
+
345
+ def tokens(count)
346
+ Amount.of_tokens(count)
347
+ end
348
+
349
+ def register_searches
350
+ Amount.register :SEARCHES, decimals: 0, display_symbol: { one: "search", other: "searches" }
351
+ end
352
+
353
+ # number.human.decimal_units, the keys Rails' number_to_human reads
354
+ def store_decimal_units(locale, units:, format: nil)
355
+ decimal_units = { units: }
356
+ decimal_units[:format] = format if format
357
+ I18n.backend.store_translations(locale, number: { human: { decimal_units: } })
358
+ end
359
+
360
+ def english_keys
361
+ store_decimal_units(:en, format: "%n %u", units: {
362
+ unit: "", thousand: "Thousand", million: "Million", billion: "Billion", trillion: "Trillion",
363
+ quadrillion: "Quadrillion"
364
+ })
365
+ end
366
+
367
+ def german_keys
368
+ store_decimal_units(:de, format: "%n %u", units: { thousand: "Tsd.", million: "Mio." })
369
+ I18n.backend.store_translations(:de, amounts: { units: { tokens: "Token" } })
370
+ end
371
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../test_helper"
4
+
5
+ class AmountAllocatableTest < Minitest::Test
6
+ class GoldAmount < Amount
7
+ def purity_estimate
8
+ "24k"
9
+ end
10
+ end
11
+
12
+ def setup
13
+ AmountTestSupport.register_default_types!
14
+ end
15
+
16
+ def teardown
17
+ Amount.registry.clear!
18
+ end
19
+
20
+ def test_split_with_remainder_parts
21
+ parts, = Amount.new(10, :LOGS).split(3)
22
+
23
+ assert_equal [3, 3, 3], parts.map(&:atomic)
24
+ end
25
+
26
+ def test_split_with_remainder_remainder
27
+ _, remainder = Amount.new(10, :LOGS).split(3)
28
+
29
+ assert_equal 1, remainder.atomic
30
+ end
31
+
32
+ def test_split_with_remainder_preserves_total
33
+ parts, remainder = Amount.new(10, :LOGS).split(3)
34
+
35
+ assert_equal 10, parts.sum(&:atomic) + remainder.atomic
36
+ end
37
+
38
+ def test_split_even_division_parts
39
+ parts, = Amount.new(9, :LOGS).split(3)
40
+
41
+ assert_equal [3, 3, 3], parts.map(&:atomic)
42
+ end
43
+
44
+ def test_split_even_division_returns_zero_remainder
45
+ _, remainder = Amount.new(9, :LOGS).split(3)
46
+
47
+ assert_equal 0, remainder.atomic
48
+ end
49
+
50
+ def test_split_one_returns_same_amount
51
+ parts, = Amount.new(9, :LOGS).split(1)
52
+
53
+ assert_equal [9], parts.map(&:atomic)
54
+ end
55
+
56
+ def test_split_one_returns_zero_remainder
57
+ _, remainder = Amount.new(9, :LOGS).split(1)
58
+
59
+ assert_equal 0, remainder.atomic
60
+ end
61
+
62
+ def test_split_negative_amount_parts_round_toward_zero
63
+ parts, = Amount.new(-10, :LOGS).split(3)
64
+
65
+ assert_equal [-3, -3, -3], parts.map(&:atomic)
66
+ end
67
+
68
+ def test_split_negative_amount_has_negative_remainder
69
+ _, remainder = Amount.new(-10, :LOGS).split(3)
70
+
71
+ assert_equal(-1, remainder.atomic)
72
+ end
73
+
74
+ def test_split_negative_amount_preserves_total
75
+ parts, remainder = Amount.new(-10, :LOGS).split(3)
76
+
77
+ assert_equal(-10, parts.sum(&:atomic) + remainder.atomic)
78
+ end
79
+
80
+ def test_allocate_by_weights_parts
81
+ parts, = Amount.new(100, :LOGS).allocate([1, 1, 2])
82
+
83
+ assert_equal [25, 25, 50], parts.map(&:atomic)
84
+ end
85
+
86
+ def test_allocate_by_weights_zero_remainder
87
+ _, remainder = Amount.new(100, :LOGS).allocate([1, 1, 2])
88
+
89
+ assert_equal 0, remainder.atomic
90
+ end
91
+
92
+ def test_allocate_by_weights_preserves_total
93
+ parts, remainder = Amount.new(100, :LOGS).allocate([1, 1, 2])
94
+
95
+ assert_equal 100, parts.sum(&:atomic) + remainder.atomic
96
+ end
97
+
98
+ def test_allocate_uneven_parts
99
+ parts, = Amount.new(10, :LOGS).allocate([1, 1, 1])
100
+
101
+ assert_equal [3, 3, 3], parts.map(&:atomic)
102
+ end
103
+
104
+ def test_allocate_remainder_is_explicit
105
+ _, remainder = Amount.new(10, :LOGS).allocate([1, 1, 1])
106
+
107
+ assert_equal 1, remainder.atomic
108
+ end
109
+
110
+ def test_allocate_allows_zero_weights
111
+ parts, = Amount.new(10, :LOGS).allocate([0, 1])
112
+
113
+ assert_equal [0, 10], parts.map(&:atomic)
114
+ end
115
+
116
+ def test_allocate_with_zero_weight_has_zero_remainder
117
+ _, remainder = Amount.new(10, :LOGS).allocate([0, 1])
118
+
119
+ assert_equal 0, remainder.atomic
120
+ end
121
+
122
+ def test_allocate_negative_amount_parts_round_toward_zero
123
+ parts, = Amount.new(-10, :LOGS).allocate([1, 1, 1])
124
+
125
+ assert_equal [-3, -3, -3], parts.map(&:atomic)
126
+ end
127
+
128
+ def test_allocate_negative_amount_has_negative_remainder
129
+ _, remainder = Amount.new(-10, :LOGS).allocate([1, 1, 1])
130
+
131
+ assert_equal(-1, remainder.atomic)
132
+ end
133
+
134
+ def test_allocate_negative_amount_preserves_total
135
+ parts, remainder = Amount.new(-10, :LOGS).allocate([1, 1, 1])
136
+
137
+ assert_equal(-10, parts.sum(&:atomic) + remainder.atomic)
138
+ end
139
+
140
+ def test_split_returns_subclass_parts
141
+ parts, = gold_split
142
+
143
+ assert parts.all? { |part| part.instance_of?(GoldAmount) }
144
+ end
145
+
146
+ def test_split_returns_subclass_remainder
147
+ _, remainder = gold_split
148
+
149
+ assert_instance_of GoldAmount, remainder
150
+ end
151
+
152
+ private
153
+
154
+ def gold_split
155
+ register_gold_subclass!
156
+ GoldAmount.new("3", :GOLD).split(2)
157
+ end
158
+
159
+ # Replaces the default registry with GOLD bound to GoldAmount.
160
+ def register_gold_subclass!
161
+ Amount.registry.clear!
162
+ Amount.register :GOLD, decimals: 8, display_symbol: "oz t",
163
+ display_position: :suffix, ui_decimals: 4,
164
+ class: GoldAmount
165
+ end
166
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require_relative "../../test_helper"
5
+
6
+ class AmountCalculableTest < Minitest::Test
7
+ class GoldAmount < Amount
8
+ def purity_estimate
9
+ "24k"
10
+ end
11
+ end
12
+
13
+ def setup
14
+ AmountTestSupport.register_default_types!
15
+ end
16
+
17
+ def teardown
18
+ Amount.registry.clear!
19
+ end
20
+
21
+ def test_frozen_amount_addition_returns_correct_sum
22
+ assert_equal Amount.new("3", :USDC), frozen_sum
23
+ end
24
+
25
+ def test_frozen_amount_addition_returns_unfrozen_result
26
+ refute_predicate frozen_sum, :frozen?
27
+ end
28
+
29
+ def test_addition_same_type
30
+ assert_equal Amount.new("2.0", :USDC), Amount.new("1.5", :USDC) + Amount.new("0.5", :USDC)
31
+ end
32
+
33
+ def test_addition_cross_type_uses_registered_rate
34
+ Amount.register_default_rate :USD, :USDC, 1
35
+
36
+ result = Amount.new("10.00", :USDC) + Amount.new("5.00", :USD)
37
+
38
+ assert_equal Amount.new("15.00", :USDC), result
39
+ end
40
+
41
+ def test_addition_different_type_raises_without_rate
42
+ assert_raises(Amount::TypeMismatch) do
43
+ Amount.new(1, :USDC) + Amount.new(1, :SOL)
44
+ end
45
+ end
46
+
47
+ def test_subtraction_cross_type_uses_registered_rate
48
+ Amount.register_default_rate :USD, :USDC, 1
49
+
50
+ result = Amount.new("10.00", :USDC) - Amount.new("5.00", :USD)
51
+
52
+ assert_equal Amount.new("5.00", :USDC), result
53
+ end
54
+
55
+ def test_subtraction_different_type_raises_without_rate
56
+ assert_raises(Amount::TypeMismatch) do
57
+ Amount.new(1, :USDC) - Amount.new(1, :USD)
58
+ end
59
+ end
60
+
61
+ def test_asymmetric_rate_allows_configured_direction
62
+ Amount.register_default_rate :USD, :USDC, 1
63
+
64
+ assert_equal Amount.new("15.00", :USDC), Amount.new("10.00", :USDC) + Amount.new("5.00", :USD)
65
+ end
66
+
67
+ def test_asymmetric_rate_rejects_reverse_direction
68
+ Amount.register_default_rate :USD, :USDC, 1
69
+
70
+ assert_raises(Amount::TypeMismatch) do
71
+ Amount.new("5.00", :USD) + Amount.new("10.00", :USDC)
72
+ end
73
+ end
74
+
75
+ def test_unary_minus
76
+ assert_equal Amount.new("-1", :USDC), -Amount.new("1", :USDC)
77
+ end
78
+
79
+ def test_abs
80
+ assert_equal Amount.new("1", :USDC), Amount.new("-1", :USDC).abs
81
+ end
82
+
83
+ # name => [expected, amount, factor]
84
+ {
85
+ "integer" => ["3", "1", 3],
86
+ "float" => ["1.5", "1", 1.5],
87
+ "negative_by_negative" => ["2", "-1", -2],
88
+ "bigdecimal" => ["3", "2", BigDecimal("1.5")],
89
+ "rational" => ["3", "2", Rational(3, 2)]
90
+ }.each do |name, (expected, amount, factor)|
91
+ define_method("test_scalar_multiplication_by_#{name}") do
92
+ assert_equal Amount.new(expected, :USDC), Amount.new(amount, :USDC) * factor
93
+ end
94
+ end
95
+
96
+ def test_amount_times_amount_raises
97
+ assert_raises(Amount::TypeMismatch) do
98
+ Amount.new("1", :USDC) * Amount.new("1", :USDC)
99
+ end
100
+ end
101
+
102
+ # name => [expected, amount, divisor]
103
+ {
104
+ "integer" => ["0.5", "1", 2],
105
+ "negative_float" => ["2", "-1", -0.5],
106
+ "rational" => ["2", "3", Rational(3, 2)],
107
+ "bigdecimal" => ["2", "3", BigDecimal("1.5")]
108
+ }.each do |name, (expected, amount, divisor)|
109
+ define_method("test_scalar_division_by_#{name}_returns_amount") do
110
+ assert_equal Amount.new(expected, :USDC), Amount.new(amount, :USDC) / divisor
111
+ end
112
+ end
113
+
114
+ def test_amount_division_returns_ratio
115
+ ratio = Amount.new("10", :USDC) / Amount.new("2", :USDC)
116
+
117
+ assert_equal BigDecimal(5), ratio
118
+ end
119
+
120
+ def test_division_by_zero
121
+ assert_raises(ZeroDivisionError) { Amount.new(1, :USDC) / 0 }
122
+ end
123
+
124
+ def test_cross_type_addition_via_explicit_conversion
125
+ gold = Amount.new("0.0001", :GOLD)
126
+ usdc = Amount.new("100", :USDC)
127
+ result = gold + usdc.to(:GOLD, rate: "0.00000042")
128
+
129
+ assert_equal BigDecimal("0.000142"), result.decimal
130
+ end
131
+
132
+ def test_arithmetic_returns_subclass
133
+ register_gold_subclass!
134
+ gold = GoldAmount.new("1", :GOLD)
135
+
136
+ assert_instance_of GoldAmount, gold + gold
137
+ end
138
+
139
+ private
140
+
141
+ def frozen_sum
142
+ Amount.new("1", :USDC).freeze + Amount.new("2", :USDC).freeze
143
+ end
144
+
145
+ # Replaces the default registry with GOLD bound to GoldAmount.
146
+ def register_gold_subclass!
147
+ Amount.registry.clear!
148
+ Amount.register :GOLD, decimals: 8, display_symbol: "oz t",
149
+ display_position: :suffix, ui_decimals: 4,
150
+ class: GoldAmount
151
+ end
152
+ end