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
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/arithmetic"
11
- require_relative "amount/allocation"
12
- require_relative "amount/comparison"
13
- require_relative "amount/conversion"
14
- require_relative "amount/serialization"
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
- # Behavior is composed from a set of focused mixins:
24
- # - {Arithmetic} — `+`, `-`, `*`, `/`, `abs`, `-@`
25
- # - {Comparison} — `<=>`, `==`, `eql?`, `hash`, `same_type?`, sign predicates
26
- # - {Conversion} — `to(:SYMBOL, rate:)`
27
- # - {Allocation} — `split(n)`, `allocate(weights)`
28
- # - {Serialization} — `to_h`
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 Arithmetic
41
- include Allocation
42
- include Comparison
43
- include Conversion
44
- include Serialization
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
- # Input inference rules:
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