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
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
|