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.
- checksums.yaml +4 -4
- data/.rubocop.yml +37 -0
- data/CHANGELOG.md +113 -0
- data/Gemfile +4 -0
- data/README.md +215 -1
- 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.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} +1 -1
- data/lib/amount/concerns/coercible.rb +65 -0
- data/lib/amount/concerns/constructible.rb +92 -0
- data/lib/amount/{conversion.rb → concerns/convertible.rb} +1 -1
- data/lib/amount/concerns/formattable.rb +112 -0
- data/lib/amount/concerns/localizable.rb +39 -0
- data/lib/amount/{comparison.rb → concerns/orderable.rb} +2 -2
- data/lib/amount/concerns/parsable.rb +58 -0
- data/lib/amount/concerns/registrable.rb +80 -0
- data/lib/amount/{serialization.rb → concerns/serializable.rb} +3 -3
- data/lib/amount/display.rb +81 -46
- data/lib/amount/locale.rb +102 -0
- data/lib/amount/registry.rb +69 -7
- data/lib/amount/ui_parser.rb +109 -0
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +55 -199
- 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 -675
- data/test/test_registry_generator.rb +0 -104
data/lib/amount.rb
CHANGED
|
@@ -3,15 +3,31 @@
|
|
|
3
3
|
require "bigdecimal"
|
|
4
4
|
require "forwardable"
|
|
5
5
|
|
|
6
|
+
# Every core file loads from here, in dependency order; core files don't
|
|
7
|
+
# require each other. The optional adapters (amount/active_record,
|
|
8
|
+
# amount/rspec) are separate entry points that load their own files.
|
|
6
9
|
require_relative "amount/version"
|
|
7
10
|
require_relative "amount/registry"
|
|
11
|
+
require_relative "amount/registry/generated_constructors"
|
|
12
|
+
require_relative "amount/locale"
|
|
13
|
+
require_relative "amount/compact"
|
|
8
14
|
require_relative "amount/display"
|
|
9
15
|
require_relative "amount/parser"
|
|
10
|
-
require_relative "amount/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
require_relative "amount/
|
|
16
|
+
require_relative "amount/ui_parser"
|
|
17
|
+
|
|
18
|
+
# Concerns: modules mixed into Amount. Files live in amount/concerns/, but the
|
|
19
|
+
# constants are Amount::<Name> (the folder is not a namespace).
|
|
20
|
+
require_relative "amount/concerns/registrable"
|
|
21
|
+
require_relative "amount/concerns/localizable"
|
|
22
|
+
require_relative "amount/concerns/parsable"
|
|
23
|
+
require_relative "amount/concerns/constructible"
|
|
24
|
+
require_relative "amount/concerns/coercible"
|
|
25
|
+
require_relative "amount/concerns/calculable"
|
|
26
|
+
require_relative "amount/concerns/allocatable"
|
|
27
|
+
require_relative "amount/concerns/orderable"
|
|
28
|
+
require_relative "amount/concerns/convertible"
|
|
29
|
+
require_relative "amount/concerns/serializable"
|
|
30
|
+
require_relative "amount/concerns/formattable"
|
|
15
31
|
|
|
16
32
|
# Represents a precise quantity of a registered fungible type.
|
|
17
33
|
#
|
|
@@ -20,12 +36,26 @@ require_relative "amount/serialization"
|
|
|
20
36
|
# strings or decimals, while integer inputs are treated as atomic counts unless
|
|
21
37
|
# `from:` overrides inference.
|
|
22
38
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# - {
|
|
27
|
-
#
|
|
28
|
-
# - {
|
|
39
|
+
# This class holds only an amount's identity: its atomic value, its symbol and
|
|
40
|
+
# registry entry, and the decimal view of the value. Everything else comes
|
|
41
|
+
# from concerns:
|
|
42
|
+
# - {Registrable} — `Amount.registry`, `register`, `register_default_rate`,
|
|
43
|
+
# `with_registry`
|
|
44
|
+
# - {Localizable} — `Amount.localize`
|
|
45
|
+
# - {Parsable} — `Amount.parse`, `Amount.parse_ui`
|
|
46
|
+
# - {Constructible} — input inference, `Amount.new` dispatch to a registered
|
|
47
|
+
# subclass, `build`
|
|
48
|
+
# - {Coercible} — `Amount.coerce_decimal`, cross-type operand coercion
|
|
49
|
+
# - {Calculable} — `+`, `-`, `*`, `/`, `abs`, `-@`
|
|
50
|
+
# - {Orderable} — `<=>`, `==`, `eql?`, `hash`, `same_type?`, sign predicates
|
|
51
|
+
# - {Convertible} — `to(:SYMBOL, rate:)`
|
|
52
|
+
# - {Allocatable} — `split(n)`, `allocate(weights)`
|
|
53
|
+
# - {Serializable} — `to_h`, `as_json`, `Amount.load`
|
|
54
|
+
# - {Formattable} — `display_format(unit:, locale:)`, where the number and
|
|
55
|
+
# unit go
|
|
56
|
+
#
|
|
57
|
+
# New behavior belongs in a concern, not in this class. See
|
|
58
|
+
# `.claude/rules/concerns.md` for the shape a concern takes.
|
|
29
59
|
#
|
|
30
60
|
# @example Constructing from a UI value
|
|
31
61
|
# Amount.register :USDC, decimals: 6
|
|
@@ -37,11 +67,17 @@ require_relative "amount/serialization"
|
|
|
37
67
|
# Amount.of_usdc(1_500_000, from: :atomic).decimal.to_s("F")
|
|
38
68
|
# # => "1.5"
|
|
39
69
|
class Amount
|
|
40
|
-
include
|
|
41
|
-
include
|
|
42
|
-
include
|
|
43
|
-
include
|
|
44
|
-
include
|
|
70
|
+
include Registrable
|
|
71
|
+
include Localizable
|
|
72
|
+
include Parsable
|
|
73
|
+
include Constructible
|
|
74
|
+
include Coercible
|
|
75
|
+
include Calculable
|
|
76
|
+
include Allocatable
|
|
77
|
+
include Orderable
|
|
78
|
+
include Convertible
|
|
79
|
+
include Serializable
|
|
80
|
+
include Formattable
|
|
45
81
|
|
|
46
82
|
extend Forwardable
|
|
47
83
|
|
|
@@ -50,128 +86,11 @@ class Amount
|
|
|
50
86
|
class InvalidInput < Error; end
|
|
51
87
|
class UnregisteredType < Error; end
|
|
52
88
|
|
|
53
|
-
class << self
|
|
54
|
-
# @return [Amount::Registry]
|
|
55
|
-
# @example Accessing the shared registry
|
|
56
|
-
# Amount.registry.locked?
|
|
57
|
-
# # => false
|
|
58
|
-
def registry
|
|
59
|
-
Amount.instance_variable_get(:@registry) ||
|
|
60
|
-
replace_registry(Registry.new)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# @param symbol [Symbol, String]
|
|
64
|
-
# @param opts [Hash]
|
|
65
|
-
# @return [void]
|
|
66
|
-
# @example Registering a type
|
|
67
|
-
# Amount.register :USDC,
|
|
68
|
-
# decimals: 6,
|
|
69
|
-
# display_symbol: "$",
|
|
70
|
-
# display_position: :prefix,
|
|
71
|
-
# ui_decimals: 2
|
|
72
|
-
def register(symbol, **opts)
|
|
73
|
-
registry.register(symbol, **opts)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# @param from [Symbol, String]
|
|
77
|
-
# @param to [Symbol, String]
|
|
78
|
-
# @param rate [String, Numeric, BigDecimal]
|
|
79
|
-
# @return [void]
|
|
80
|
-
# @example Registering a directional default rate
|
|
81
|
-
# Amount.register_default_rate :USD, :USDC, "1"
|
|
82
|
-
def register_default_rate(from, to, rate)
|
|
83
|
-
registry.register_default_rate(from, to, rate)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Parses the compact client-facing string representation.
|
|
87
|
-
#
|
|
88
|
-
# Accepts either the default form `SYMBOL|amount` or the explicit versioned
|
|
89
|
-
# form `v1:SYMBOL|amount`.
|
|
90
|
-
#
|
|
91
|
-
# @param str [String]
|
|
92
|
-
# @return [Amount]
|
|
93
|
-
# @raise [InvalidInput]
|
|
94
|
-
# @example Parsing the default compact format
|
|
95
|
-
# Amount.parse("USDC|1.50")
|
|
96
|
-
#
|
|
97
|
-
# @example Parsing the explicit versioned compact format
|
|
98
|
-
# Amount.parse("v1:USDC|1.50")
|
|
99
|
-
def parse(str)
|
|
100
|
-
Parser.new(str).parse
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# When called as `Amount.new` for a symbol whose registry entry binds a
|
|
104
|
-
# custom class, dispatch the construction to that class instead of raising.
|
|
105
|
-
# Direct calls to a subclass (`GoldAmount.new(...)`) still go through the
|
|
106
|
-
# default `Class.new` path. Calls that target the wrong subclass continue
|
|
107
|
-
# to raise from `#initialize`.
|
|
108
|
-
#
|
|
109
|
-
# @param value [Integer, String, Float, BigDecimal, Rational]
|
|
110
|
-
# @param symbol [Symbol, String]
|
|
111
|
-
# @param from [Symbol, nil]
|
|
112
|
-
# @return [Amount]
|
|
113
|
-
def new(value, symbol, from: nil)
|
|
114
|
-
if equal?(::Amount)
|
|
115
|
-
entry_class = registry.lookup(symbol.to_sym).amount_class
|
|
116
|
-
return entry_class.new(value, symbol, from:) if entry_class && entry_class != ::Amount
|
|
117
|
-
end
|
|
118
|
-
super
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Coerces a numeric input to BigDecimal in a way that preserves Rational
|
|
122
|
-
# values. `BigDecimal(value.to_s)` raises `ArgumentError` for Rational
|
|
123
|
-
# because `Rational#to_s` produces strings like `"3/2"`. This helper is
|
|
124
|
-
# the single place every call site should use to convert a scalar / rate /
|
|
125
|
-
# display-unit scale into a BigDecimal.
|
|
126
|
-
#
|
|
127
|
-
# @param value [Numeric, BigDecimal, Rational, String]
|
|
128
|
-
# @return [BigDecimal]
|
|
129
|
-
def coerce_decimal(value)
|
|
130
|
-
case value
|
|
131
|
-
when BigDecimal then value
|
|
132
|
-
when Rational then BigDecimal(value, Float::DIG + 4)
|
|
133
|
-
else BigDecimal(value.to_s)
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Temporarily swaps the global registry. Intended for tests.
|
|
138
|
-
#
|
|
139
|
-
# @param registry [Amount::Registry]
|
|
140
|
-
# @yield
|
|
141
|
-
# @return [Object]
|
|
142
|
-
# @example Using a temporary registry
|
|
143
|
-
# test_registry = Amount::Registry.new
|
|
144
|
-
# Amount.with_registry(test_registry) do
|
|
145
|
-
# Amount.register :TEST, decimals: 2
|
|
146
|
-
# end
|
|
147
|
-
def with_registry(registry)
|
|
148
|
-
original = Amount.instance_variable_get(:@registry)
|
|
149
|
-
replace_registry(registry)
|
|
150
|
-
yield
|
|
151
|
-
ensure
|
|
152
|
-
replace_registry(original)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
private
|
|
156
|
-
|
|
157
|
-
def replace_registry(registry)
|
|
158
|
-
current = Amount.instance_variable_get(:@registry)
|
|
159
|
-
current&.remove_generated_methods!
|
|
160
|
-
Amount.instance_variable_set(:@registry, registry)
|
|
161
|
-
registry&.activate_generated_methods!
|
|
162
|
-
registry
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
|
|
166
89
|
attr_reader :atomic, :symbol, :display
|
|
167
90
|
|
|
168
91
|
# Creates an amount for a registered symbol.
|
|
169
92
|
#
|
|
170
|
-
#
|
|
171
|
-
# - `Integer` => atomic units
|
|
172
|
-
# - `String` => UI decimal value
|
|
173
|
-
# - `Float`, `BigDecimal`, `Rational` => UI decimal value
|
|
174
|
-
# - `from:` overrides inference explicitly
|
|
93
|
+
# See {Constructible} for how `value` is read.
|
|
175
94
|
#
|
|
176
95
|
# @param value [Integer, String, Float, BigDecimal, Rational]
|
|
177
96
|
# @param symbol [Symbol, String] registered type identifier
|
|
@@ -188,12 +107,7 @@ class Amount
|
|
|
188
107
|
def initialize(value, symbol, from: nil)
|
|
189
108
|
@symbol = symbol.to_sym
|
|
190
109
|
@entry = self.class.registry.lookup(@symbol)
|
|
191
|
-
|
|
192
|
-
expected = @entry.amount_class
|
|
193
|
-
if expected && expected != Amount && self.class != Amount && !instance_of?(expected)
|
|
194
|
-
raise InvalidInput, "use #{expected}.new for #{@symbol}"
|
|
195
|
-
end
|
|
196
|
-
|
|
110
|
+
ensure_registered_class!
|
|
197
111
|
@atomic = infer_value(from, value)
|
|
198
112
|
@display = Display.new(self)
|
|
199
113
|
end
|
|
@@ -222,7 +136,7 @@ class Amount
|
|
|
222
136
|
BigDecimal(@atomic) / (BigDecimal(10)**decimals)
|
|
223
137
|
end
|
|
224
138
|
|
|
225
|
-
def_delegators :display, :formatted, :ui, :to_s, :in_unit
|
|
139
|
+
def_delegators :display, :formatted, :ui, :unit_label, :to_s, :in_unit
|
|
226
140
|
|
|
227
141
|
# @return [String]
|
|
228
142
|
# @example Console-friendly inspection
|
|
@@ -231,62 +145,4 @@ class Amount
|
|
|
231
145
|
def inspect
|
|
232
146
|
"#<#{self.class} #{symbol} #{ui}>"
|
|
233
147
|
end
|
|
234
|
-
|
|
235
|
-
private
|
|
236
|
-
|
|
237
|
-
# Builds a same-symbol amount in the receiver's class. Used by every
|
|
238
|
-
# operator that returns an Amount so subclass identity propagates.
|
|
239
|
-
def build(atomic_value)
|
|
240
|
-
self.class.new(atomic_value, symbol, from: :atomic)
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def ensure_same_type!(other)
|
|
244
|
-
return if same_type?(other)
|
|
245
|
-
|
|
246
|
-
raise TypeMismatch, "type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
def coerce_other_to_self_type(other)
|
|
250
|
-
return other if same_type?(other)
|
|
251
|
-
return unless other.is_a?(Amount)
|
|
252
|
-
|
|
253
|
-
other.to(symbol)
|
|
254
|
-
rescue Registry::NoDefaultRate
|
|
255
|
-
nil
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def coerce_other_to_self_type!(other)
|
|
259
|
-
coerce_other_to_self_type(other) || raise(
|
|
260
|
-
TypeMismatch,
|
|
261
|
-
"type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
|
|
262
|
-
)
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def infer_value(from, value)
|
|
266
|
-
case from || infer_type(value)
|
|
267
|
-
when :atomic then value.to_i
|
|
268
|
-
when :ui then ui_to_atomic(value)
|
|
269
|
-
when :float then ui_to_atomic(value.is_a?(Rational) ? value : value.to_s)
|
|
270
|
-
else
|
|
271
|
-
raise InvalidInput, "unknown amount format: #{value.inspect}"
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
def infer_type(value)
|
|
276
|
-
case value
|
|
277
|
-
when Integer then :atomic
|
|
278
|
-
when String then :ui
|
|
279
|
-
when Float, BigDecimal, Rational then :float
|
|
280
|
-
else
|
|
281
|
-
raise InvalidInput, "cannot infer type for #{value.class}"
|
|
282
|
-
end
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def ui_to_atomic(value)
|
|
286
|
-
return (value * (10**decimals)).to_i if value.is_a?(Rational)
|
|
287
|
-
|
|
288
|
-
(BigDecimal(value.to_s) * (BigDecimal(10)**decimals)).to_i
|
|
289
|
-
rescue ArgumentError
|
|
290
|
-
raise InvalidInput, "cannot parse #{value.inspect} as #{symbol}"
|
|
291
|
-
end
|
|
292
148
|
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../test_helper"
|
|
4
|
+
require_relative "../../support/active_record_test_support"
|
|
5
|
+
|
|
6
|
+
class AmountActiveRecordAmountValidatorTest < Minitest::Test
|
|
7
|
+
include ActiveRecordTestSetup
|
|
8
|
+
|
|
9
|
+
def test_accepts_valid_multi_symbol_amount
|
|
10
|
+
assert validated(amount: "USDC|100.00").valid?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def test_rejects_wrong_symbol
|
|
14
|
+
refute validated(amount: "USD|100.00").valid?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_reports_wrong_symbol
|
|
18
|
+
assert_includes errors_on(validated(amount: "USD|100.00"), :amount), "must have symbol USDC"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_rejects_amount_below_lower_bound
|
|
22
|
+
refute validated(amount: "USDC|0.00").valid?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_reports_amount_below_lower_bound
|
|
26
|
+
assert_includes errors_on(validated(amount: "USDC|0.00"), :amount), "must be greater than USDC|0.00"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_rejects_amount_above_upper_bound
|
|
30
|
+
refute validated(amount: "USDC|1000.01").valid?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_reports_amount_above_upper_bound
|
|
34
|
+
assert_includes errors_on(validated(amount: "USDC|1000.01"), :amount), "must be less than or equal to USDC|1000.00"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def test_allows_nil_when_allow_nil_is_set
|
|
38
|
+
assert validated(amount: "USDC|100.00", fee: nil).valid?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_supports_fixed_symbol_numeric_thresholds
|
|
42
|
+
assert validated(amount: "USDC|100.00", fee: 0.5).valid?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_rejects_fixed_symbol_amount_below_lower_bound
|
|
46
|
+
refute validated(amount: "USDC|100.00", fee: 0).valid?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_reports_fixed_symbol_amount_below_lower_bound
|
|
50
|
+
assert_includes errors_on(validated(amount: "USDC|100.00", fee: 0), :fee), "must be greater than SOL|0.0000"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_rejects_fixed_symbol_amount_at_exclusive_upper_bound
|
|
54
|
+
refute validated(amount: "USDC|100.00", fee: 10).valid?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_reports_fixed_symbol_amount_at_exclusive_upper_bound
|
|
58
|
+
assert_includes errors_on(validated(amount: "USDC|100.00", fee: 10), :fee), "must be less than SOL|10.0000"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_rejects_cross_type_threshold_without_rate
|
|
62
|
+
refute cross_type_threshold_model.new(amount: "USDC|2.00").valid?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def test_reports_cross_type_threshold_without_rate
|
|
66
|
+
holding = cross_type_threshold_model.new(amount: "USDC|2.00")
|
|
67
|
+
|
|
68
|
+
assert_includes errors_on(holding, :amount), "cannot compare USDC to USD for greater_than"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_adds_symbol_error_by_key_with_interpolations
|
|
72
|
+
holding = validated(amount: "USD|100.00")
|
|
73
|
+
holding.valid?
|
|
74
|
+
|
|
75
|
+
assert_includes holding.errors.details[:amount], { error: :amount_symbol, expected: :USDC }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_adds_comparison_error_by_key_with_interpolations
|
|
79
|
+
holding = validated(amount: "USDC|0.00")
|
|
80
|
+
holding.valid?
|
|
81
|
+
|
|
82
|
+
assert_includes holding.errors.details[:amount], { error: :greater_than, count: "USDC|0.00" }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_incomparable_error_details
|
|
86
|
+
holding = cross_type_threshold_model.new(amount: "USDC|2.00")
|
|
87
|
+
holding.valid?
|
|
88
|
+
|
|
89
|
+
assert_includes holding.errors.details[:amount],
|
|
90
|
+
{ error: :amount_incomparable, symbol: :USDC, other_symbol: :USD, option: :greater_than }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_symbol_message_can_be_translated_by_the_app
|
|
94
|
+
messages = with_german_messages { errors_on(validated(amount: "USD|100.00"), :amount) }
|
|
95
|
+
|
|
96
|
+
assert_includes messages, "muss das Symbol USDC haben"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_comparison_message_can_be_translated_by_the_app
|
|
100
|
+
messages = with_german_messages { errors_on(validated(amount: "USDC|0.00"), :amount) }
|
|
101
|
+
|
|
102
|
+
assert_includes messages, "muss groesser als USDC|0.00 sein"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def test_uses_registered_rate_for_cross_type_threshold_when_available
|
|
106
|
+
Amount.register_default_rate :USD, :USDC, "1"
|
|
107
|
+
|
|
108
|
+
assert cross_type_threshold_model.new(amount: "USDC|2.00").valid?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_numeric_thresholds_on_multi_symbol_attribute_accept_value_inside_range
|
|
112
|
+
assert bounded_numeric_threshold_model.new(amount: "USDC|50.00").valid?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def test_numeric_thresholds_on_multi_symbol_attribute_reject_value_at_exclusive_lower_bound
|
|
116
|
+
refute bounded_numeric_threshold_model.new(amount: "USDC|0.00").valid?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def test_numeric_thresholds_on_multi_symbol_attribute_report_lower_bound
|
|
120
|
+
holding = bounded_numeric_threshold_model.new(amount: "USDC|0.00")
|
|
121
|
+
|
|
122
|
+
assert_includes errors_on(holding, :amount).join, "greater than"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_numeric_thresholds_on_multi_symbol_attribute_reject_value_above_upper_bound
|
|
126
|
+
refute bounded_numeric_threshold_model.new(amount: "USDC|150.00").valid?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def test_numeric_thresholds_on_multi_symbol_attribute_report_upper_bound
|
|
130
|
+
holding = bounded_numeric_threshold_model.new(amount: "USDC|150.00")
|
|
131
|
+
|
|
132
|
+
assert_includes errors_on(holding, :amount).join, "less than"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The same `greater_than: 1` is interpreted as 1 USDC for a USDC value and
|
|
136
|
+
# as 1 SOL for a SOL value, because the threshold inherits the value's
|
|
137
|
+
# symbol on multi-symbol attributes.
|
|
138
|
+
def test_numeric_threshold_accepts_usdc_value_above_one_usdc
|
|
139
|
+
assert greater_than_one_model.new(amount: "USDC|2.00").valid?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def test_numeric_threshold_rejects_usdc_value_below_one_usdc
|
|
143
|
+
refute greater_than_one_model.new(amount: "USDC|0.50").valid?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def test_numeric_threshold_accepts_sol_value_above_one_sol
|
|
147
|
+
assert greater_than_one_model.new(amount: "SOL|2").valid?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def test_numeric_threshold_rejects_sol_value_below_one_sol
|
|
151
|
+
refute greater_than_one_model.new(amount: "SOL|0.5").valid?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def validated(**attributes)
|
|
157
|
+
ValidatedHolding.new(reserve: "USDC|1.25", **attributes)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def errors_on(record, attribute)
|
|
161
|
+
record.valid?
|
|
162
|
+
record.errors[attribute]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def holdings_model_validating_amount(**options)
|
|
166
|
+
Class.new(::ActiveRecord::Base) do
|
|
167
|
+
self.table_name = "holdings"
|
|
168
|
+
|
|
169
|
+
has_amount :amount
|
|
170
|
+
validates :amount, amount: options
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def cross_type_threshold_model
|
|
175
|
+
holdings_model_validating_amount(greater_than: "USD|1.00")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def bounded_numeric_threshold_model
|
|
179
|
+
holdings_model_validating_amount(greater_than: 0, less_than_or_equal_to: 100)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def greater_than_one_model
|
|
183
|
+
holdings_model_validating_amount(greater_than: 1)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Runs the block under :de with app-provided translations of the
|
|
187
|
+
# validator's error keys.
|
|
188
|
+
def with_german_messages(&)
|
|
189
|
+
original_locales = I18n.available_locales
|
|
190
|
+
I18n.available_locales = original_locales | [:de]
|
|
191
|
+
# rubocop:disable Style/FormatStringToken
|
|
192
|
+
I18n.backend.store_translations(:de, errors: { messages: {
|
|
193
|
+
amount_symbol: "muss das Symbol %{expected} haben",
|
|
194
|
+
greater_than: "muss groesser als %{count} sein"
|
|
195
|
+
} })
|
|
196
|
+
# rubocop:enable Style/FormatStringToken
|
|
197
|
+
I18n.with_locale(:de, &)
|
|
198
|
+
ensure
|
|
199
|
+
I18n.available_locales = original_locales
|
|
200
|
+
end
|
|
201
|
+
end
|