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.
- checksums.yaml +4 -4
- data/.rubocop.yml +37 -0
- data/CHANGELOG.md +142 -1
- data/Gemfile +4 -0
- data/README.md +218 -4
- data/Rakefile +47 -10
- data/lib/amount/active_record/amount_validator.rb +9 -18
- data/lib/amount/active_record/attribute_definition.rb +3 -1
- data/lib/amount/active_record/locale/en.yml +7 -0
- data/lib/amount/active_record/model.rb +9 -0
- data/lib/amount/active_record/railtie.rb +1 -1
- data/lib/amount/active_record.rb +12 -0
- data/lib/amount/compact.rb +137 -0
- data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
- data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +9 -9
- data/lib/amount/concerns/coercible.rb +65 -0
- data/lib/amount/concerns/constructible.rb +92 -0
- data/lib/amount/{conversion.rb → concerns/convertible.rb} +3 -3
- data/lib/amount/concerns/formattable.rb +112 -0
- data/lib/amount/concerns/localizable.rb +39 -0
- data/lib/amount/{comparison.rb → concerns/orderable.rb} +10 -10
- data/lib/amount/concerns/parsable.rb +58 -0
- data/lib/amount/concerns/registrable.rb +80 -0
- data/lib/amount/{serialization.rb → concerns/serializable.rb} +4 -4
- data/lib/amount/display.rb +86 -51
- data/lib/amount/locale.rb +102 -0
- data/lib/amount/registry/generated_constructors.rb +10 -5
- data/lib/amount/registry.rb +72 -9
- data/lib/amount/rspec.rb +4 -4
- data/lib/amount/ui_parser.rb +109 -0
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +61 -205
- data/test/amount/active_record/amount_validator_test.rb +201 -0
- data/test/amount/active_record/attribute_definition_test.rb +234 -0
- data/test/amount/active_record/migration_methods_test.rb +60 -0
- data/test/amount/active_record/model_test.rb +40 -0
- data/test/amount/active_record/type_test.rb +80 -0
- data/test/amount/active_record_test.rb +12 -0
- data/test/amount/compact_test.rb +371 -0
- data/test/amount/concerns/allocatable_test.rb +166 -0
- data/test/amount/concerns/calculable_test.rb +152 -0
- data/test/amount/concerns/coercible_test.rb +49 -0
- data/test/amount/concerns/constructible_test.rb +113 -0
- data/test/amount/concerns/convertible_test.rb +54 -0
- data/test/amount/concerns/formattable_test.rb +336 -0
- data/test/amount/concerns/localizable_test.rb +59 -0
- data/test/amount/concerns/orderable_test.rb +83 -0
- data/test/amount/concerns/parsable_test.rb +98 -0
- data/test/amount/concerns/registrable_test.rb +67 -0
- data/test/amount/concerns/serializable_test.rb +98 -0
- data/test/amount/display_test.rb +181 -0
- data/test/amount/locale_test.rb +590 -0
- data/test/amount/parser_test.rb +29 -0
- data/test/amount/registry/generated_constructors_test.rb +94 -0
- data/test/amount/registry_test.rb +149 -0
- data/test/amount/ui_parser_test.rb +284 -0
- data/test/amount_test.rb +55 -0
- data/test/dummy/config/application.rb +3 -0
- data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
- data/test/integration/postgresql_test.rb +94 -0
- data/test/support/active_record_test_support.rb +65 -0
- data/test/support/error_helpers.rb +22 -0
- data/test/support/locale_test_setup.rb +36 -0
- data/test/test_helper.rb +1 -0
- metadata +47 -11
- data/test/postgresql_integration_test.rb +0 -71
- data/test/test_active_record.rb +0 -375
- data/test/test_amount.rb +0 -663
- 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
|