amounts 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +37 -0
  3. data/CHANGELOG.md +113 -0
  4. data/Gemfile +4 -0
  5. data/README.md +215 -1
  6. data/Rakefile +47 -10
  7. data/lib/amount/active_record/amount_validator.rb +9 -18
  8. data/lib/amount/active_record/attribute_definition.rb +3 -1
  9. data/lib/amount/active_record/locale/en.yml +7 -0
  10. data/lib/amount/active_record/model.rb +9 -0
  11. data/lib/amount/active_record.rb +12 -0
  12. data/lib/amount/compact.rb +137 -0
  13. data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
  14. data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +1 -1
  15. data/lib/amount/concerns/coercible.rb +65 -0
  16. data/lib/amount/concerns/constructible.rb +92 -0
  17. data/lib/amount/{conversion.rb → concerns/convertible.rb} +1 -1
  18. data/lib/amount/concerns/formattable.rb +112 -0
  19. data/lib/amount/concerns/localizable.rb +39 -0
  20. data/lib/amount/{comparison.rb → concerns/orderable.rb} +2 -2
  21. data/lib/amount/concerns/parsable.rb +58 -0
  22. data/lib/amount/concerns/registrable.rb +80 -0
  23. data/lib/amount/{serialization.rb → concerns/serializable.rb} +3 -3
  24. data/lib/amount/display.rb +81 -46
  25. data/lib/amount/locale.rb +102 -0
  26. data/lib/amount/registry.rb +69 -7
  27. data/lib/amount/ui_parser.rb +109 -0
  28. data/lib/amount/version.rb +1 -1
  29. data/lib/amount.rb +55 -199
  30. data/test/amount/active_record/amount_validator_test.rb +201 -0
  31. data/test/amount/active_record/attribute_definition_test.rb +234 -0
  32. data/test/amount/active_record/migration_methods_test.rb +60 -0
  33. data/test/amount/active_record/model_test.rb +40 -0
  34. data/test/amount/active_record/type_test.rb +80 -0
  35. data/test/amount/active_record_test.rb +12 -0
  36. data/test/amount/compact_test.rb +371 -0
  37. data/test/amount/concerns/allocatable_test.rb +166 -0
  38. data/test/amount/concerns/calculable_test.rb +152 -0
  39. data/test/amount/concerns/coercible_test.rb +49 -0
  40. data/test/amount/concerns/constructible_test.rb +113 -0
  41. data/test/amount/concerns/convertible_test.rb +54 -0
  42. data/test/amount/concerns/formattable_test.rb +336 -0
  43. data/test/amount/concerns/localizable_test.rb +59 -0
  44. data/test/amount/concerns/orderable_test.rb +83 -0
  45. data/test/amount/concerns/parsable_test.rb +98 -0
  46. data/test/amount/concerns/registrable_test.rb +67 -0
  47. data/test/amount/concerns/serializable_test.rb +98 -0
  48. data/test/amount/display_test.rb +181 -0
  49. data/test/amount/locale_test.rb +590 -0
  50. data/test/amount/parser_test.rb +29 -0
  51. data/test/amount/registry/generated_constructors_test.rb +94 -0
  52. data/test/amount/registry_test.rb +149 -0
  53. data/test/amount/ui_parser_test.rb +284 -0
  54. data/test/amount_test.rb +55 -0
  55. data/test/dummy/config/application.rb +3 -0
  56. data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
  57. data/test/integration/postgresql_test.rb +94 -0
  58. data/test/support/active_record_test_support.rb +65 -0
  59. data/test/support/error_helpers.rb +22 -0
  60. data/test/support/locale_test_setup.rb +36 -0
  61. data/test/test_helper.rb +1 -0
  62. metadata +47 -11
  63. data/test/postgresql_integration_test.rb +0 -71
  64. data/test/test_active_record.rb +0 -375
  65. data/test/test_amount.rb +0 -675
  66. data/test/test_registry_generator.rb +0 -104
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class AmountRegistryTest < Minitest::Test
6
+ def setup
7
+ AmountTestSupport.register_default_types!
8
+ end
9
+
10
+ def teardown
11
+ Amount.send(:replace_registry, Amount::Registry.new) if Amount.registry.locked?
12
+ Amount.registry.clear!
13
+ end
14
+
15
+ def test_registry_lock_marks_registry_locked
16
+ Amount.registry.lock!
17
+
18
+ assert Amount.registry.locked?
19
+ end
20
+
21
+ def test_registry_lock_prevents_register
22
+ Amount.registry.lock!
23
+
24
+ assert_raises(Amount::Registry::RegistryLocked) { Amount.register(:DOGE, decimals: 8) }
25
+ end
26
+
27
+ def test_registry_lock_prevents_register_default_rate
28
+ Amount.registry.lock!
29
+
30
+ assert_raises(Amount::Registry::RegistryLocked) { Amount.register_default_rate(:USD, :USDC, 1) }
31
+ end
32
+
33
+ def test_registry_lock_prevents_clear
34
+ Amount.registry.lock!
35
+
36
+ assert_raises(Amount::Registry::RegistryLocked) { Amount.registry.clear! }
37
+ end
38
+
39
+ def test_register_rejects_empty_symbol
40
+ assert_raises(ArgumentError) { Amount.register :"", decimals: 2 }
41
+ end
42
+
43
+ def test_register_rejects_empty_symbol_with_message
44
+ assert_match(/symbol must not be blank/, error_from(ArgumentError) { Amount.register :"", decimals: 2 }.message)
45
+ end
46
+
47
+ def test_register_rejects_nil_symbol
48
+ assert_raises(ArgumentError) { Amount.register nil, decimals: 2 }
49
+ end
50
+
51
+ def test_registry_can_be_read_from_multiple_threads
52
+ errors = Queue.new
53
+
54
+ threads = 10.times.map do
55
+ Thread.new do
56
+ 100.times do
57
+ Amount.registry.lookup(:USDC)
58
+ Amount.registry.default_rate?(:USDC, :USD)
59
+ rescue StandardError => e
60
+ errors << e
61
+ end
62
+ end
63
+ end
64
+
65
+ threads.each(&:join)
66
+
67
+ assert errors.empty?, "expected no thread errors, got #{errors.size}"
68
+ end
69
+
70
+ def test_register_default_rate_accepts_rational
71
+ Amount.register_default_rate :USDC, :USD, Rational(1, 2)
72
+
73
+ assert_equal BigDecimal("0.5"), Amount.registry.default_rate(:USDC, :USD)
74
+ end
75
+
76
+ def test_register_default_rate_rational_converts_exactly
77
+ Amount.register_default_rate :USDC, :USD, Rational(1, 2)
78
+
79
+ assert_equal BigDecimal("0.5"), Amount.new("1", :USDC).to(:USD).decimal
80
+ end
81
+
82
+ def test_prefix_display_position_is_stored_as_its_template
83
+ assert_equal "%u%n", Amount.registry.lookup(:USD).display_format
84
+ end
85
+
86
+ def test_suffix_display_position_is_stored_as_its_template
87
+ assert_equal "%n %u", Amount.registry.lookup(:SOL).display_format
88
+ end
89
+
90
+ def test_display_position_defaults_to_suffix
91
+ Amount.register :PTS, decimals: 0
92
+
93
+ assert_equal :suffix, Amount.registry.lookup(:PTS).display_position
94
+ end
95
+
96
+ def test_default_display_position_is_stored_as_suffix_template
97
+ Amount.register :PTS, decimals: 0
98
+
99
+ assert_equal "%n %u", Amount.registry.lookup(:PTS).display_format
100
+ end
101
+
102
+ def test_display_format_leaves_display_position_unset
103
+ Amount.register :EUR, decimals: 2, display_format: "%u%n"
104
+
105
+ assert_nil Amount.registry.lookup(:EUR).display_position
106
+ end
107
+
108
+ def test_display_format_and_display_position_together_raise
109
+ assert_raises(ArgumentError) { register_eur_with_format_and_position }
110
+ end
111
+
112
+ def test_display_format_and_display_position_together_message
113
+ assert_match(/not both/, error_from(ArgumentError) { register_eur_with_format_and_position }.message)
114
+ end
115
+
116
+ def test_display_format_and_display_position_together_leave_type_unregistered
117
+ error_from(ArgumentError) { register_eur_with_format_and_position }
118
+
119
+ refute Amount.registry.registered?(:EUR)
120
+ end
121
+
122
+ {
123
+ "string_without_number" => "%u",
124
+ "symbol_with_only_number_marker" => :'%n'
125
+ }.each do |name, format|
126
+ define_method("test_display_format_#{name}_raises") do
127
+ assert_raises(ArgumentError) { Amount.register :EUR, decimals: 2, display_format: format }
128
+ end
129
+ end
130
+
131
+ def test_display_unit_format_and_position_together_raise
132
+ assert_raises(Amount::Registry::InvalidDisplayUnit) do
133
+ Amount.register :EUR, decimals: 2,
134
+ display_units: { cents: { scale: 100, format: "%n %u", position: :prefix } }
135
+ end
136
+ end
137
+
138
+ def test_display_unit_format_without_number_raises
139
+ assert_raises(Amount::Registry::InvalidDisplayUnit) do
140
+ Amount.register :EUR, decimals: 2, display_units: { cents: { scale: 100, format: "%u" } }
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def register_eur_with_format_and_position
147
+ Amount.register :EUR, decimals: 2, display_format: "%u%n", display_position: :prefix
148
+ end
149
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+ require_relative "../test_helper"
5
+
6
+ class AmountUiParserTest < Minitest::Test
7
+ NBSP = "\u00A0"
8
+ NARROW_NBSP = "\u202F"
9
+
10
+ CHAR_NAMES = {
11
+ "," => "comma", "." => "dot", "-" => "minus", "+" => "plus", " " => "space", NBSP => "nbsp",
12
+ NARROW_NBSP => "nnbsp", "\n" => "newline", "$" => "dollar", "€" => "euro"
13
+ }.freeze
14
+
15
+ # A method-name-safe spelling of an input, so "1,50,0" and "1.50.0" stay distinct.
16
+ def self.slug(input)
17
+ return "nil" if input.nil?
18
+ return "empty" if input.empty?
19
+
20
+ input.each_char.map { |c| c.match?(/[a-zA-Z0-9]/) ? c : "_#{CHAR_NAMES.fetch(c) { format('u%04x', c.ord) }}_" }
21
+ .join.squeeze("_").delete_prefix("_").delete_suffix("_")
22
+ end
23
+
24
+ def self.case_name(group, verb, input, symbol, opts)
25
+ "test_#{group}_#{verb}_#{slug(input)}_#{symbol.downcase}_#{opts.fetch(:locale, :plain)}"
26
+ end
27
+
28
+ # Defines a test that parses `input` and checks its atomic value. `before`
29
+ # names a private arrange method to run first.
30
+ def self.accepts(group, atomic, input, symbol = :USD, before: nil, **opts)
31
+ define_method(unique_name(case_name(group, :parses, input, symbol, opts))) do
32
+ send(before) if before
33
+
34
+ assert_equal atomic, parse(input, symbol, **opts).atomic
35
+ end
36
+ end
37
+
38
+ # Defines a test that `input` is rejected with Amount::InvalidInput.
39
+ def self.rejects(group, input, symbol = :USD, before: nil, **opts)
40
+ define_method(unique_name(case_name(group, :rejects, input, symbol, opts))) do
41
+ send(before) if before
42
+
43
+ assert_raises(Amount::InvalidInput, "#{input.inspect} should be rejected") { parse(input, symbol, **opts) }
44
+ end
45
+ end
46
+
47
+ # A later define_method with the same name would silently replace a case.
48
+ def self.unique_name(name)
49
+ raise ArgumentError, "duplicate test name #{name}" if method_defined?(name)
50
+
51
+ name
52
+ end
53
+
54
+ def setup
55
+ AmountTestSupport.register_default_types!
56
+ @previous_locale = I18n.locale
57
+ @previous_backend = I18n.backend
58
+ I18n.backend = I18n::Backend::Simple.new
59
+ I18n.available_locales = %i[en de fr]
60
+ I18n.backend.store_translations(:en, number: { format: { delimiter: ",", separator: "." } })
61
+ I18n.backend.store_translations(:de, number: { format: { delimiter: ".", separator: "," } })
62
+ I18n.backend.store_translations(:fr, number: { format: { delimiter: " ", separator: "," } })
63
+ Amount.localize = false
64
+ end
65
+
66
+ def teardown
67
+ Amount.localize = false
68
+ I18n.backend = @previous_backend
69
+ I18n.locale = @previous_locale
70
+ Amount.registry.clear!
71
+ end
72
+
73
+ # Issue examples
74
+
75
+ def test_issue_example_de_equals_new
76
+ assert_equal Amount.new("1500.50", :USD), parse("1.500,50", locale: :de)
77
+ end
78
+
79
+ def test_issue_example_en_equals_new
80
+ assert_equal Amount.new("1500.50", :USD), parse("1,500.50", locale: :en)
81
+ end
82
+
83
+ def test_issue_example_plain_equals_new
84
+ assert_equal Amount.new("1500.50", :USD), parse("1500.50")
85
+ end
86
+
87
+ def test_issue_example_de_atomic
88
+ assert_equal 150_050, parse("1.500,50", locale: :de).atomic
89
+ end
90
+
91
+ # Without a locale there are no delimiters
92
+
93
+ accepts :plain, 150_000, "1500"
94
+ rejects :plain, "1,500.50"
95
+ rejects :plain, "1.500,50"
96
+
97
+ def test_localize_uses_i18n_locale
98
+ Amount.localize = true
99
+ I18n.locale = :de
100
+
101
+ assert_equal 150_050, parse("1.500,50").atomic
102
+ end
103
+
104
+ def test_explicit_locale_overrides_localize
105
+ Amount.localize = true
106
+ I18n.locale = :de
107
+
108
+ assert_equal 150_050, parse("1,500.50", locale: :en).atomic
109
+ end
110
+
111
+ # Whitespace, including NBSP
112
+
113
+ accepts :whitespace, 150_050, " #{NBSP}1.500,50#{NBSP}\n", locale: :de
114
+ accepts :whitespace, 150_050, " 1,500.50 ", locale: :en
115
+ accepts :whitespace, 150_050, "#{NBSP}1500.50#{NARROW_NBSP}"
116
+ accepts :whitespace, 150_050, "1 500,50", locale: :fr
117
+ accepts :whitespace, 150_050, "1#{NBSP}500,50", locale: :fr
118
+
119
+ # Signs
120
+
121
+ accepts :signs, -150_050, "-1.500,50", locale: :de
122
+ accepts :signs, -150_050, "-1,500.50", locale: :en
123
+ accepts :signs, 150_050, "+1,500.50", locale: :en
124
+ accepts :signs, -150_050, "$-1,500.50", locale: :en
125
+ accepts :signs, -150_050, "-$1,500.50", locale: :en
126
+ rejects :signs, "--1.50"
127
+ rejects :signs, "+-1.50"
128
+ rejects :signs, "1.50-"
129
+
130
+ # Delimiter groups
131
+
132
+ accepts :delimiter_groups, 123_456_789_00, "123,456,789.00", locale: :en
133
+ accepts :delimiter_groups, 123_456_789_00, "123.456.789,00", locale: :de
134
+ accepts :delimiter_groups, 150_000, "1500", locale: :en
135
+ accepts :delimiter_groups, 150_000, "1500", locale: :de
136
+ %w[1,50,0 1,5000 12,34,567 ,500 1,,500 1,500,5].each { |bad| rejects :delimiter_groups, bad, locale: :en }
137
+ %w[1.50.0 1.5000 12.34.567 .500 1..500].each { |bad| rejects :delimiter_groups, bad, locale: :de }
138
+
139
+ # Decimal places
140
+
141
+ accepts :decimal_places, 150_000, "1.500,00", locale: :de
142
+ accepts :decimal_places, 150_050, "1.500,50", locale: :de
143
+ accepts :decimal_places, 150_000, "1,500", :USD, locale: :en
144
+ rejects :decimal_places, "1,500.505", locale: :en
145
+ rejects :decimal_places, "1.500,505", locale: :de
146
+ rejects :decimal_places, "0.001"
147
+
148
+ # Separator used twice
149
+
150
+ rejects :separator_twice, "1.500.50", locale: :en
151
+ rejects :separator_twice, "1,500,50", locale: :de
152
+ rejects :separator_twice, "1.50.50"
153
+
154
+ # Malformed numbers
155
+
156
+ ["", " ", NBSP, "abc", "$", "1.", ".5", "1e3", "1,500.", "- ", "1 2"].each do |bad|
157
+ rejects :malformed, bad, locale: :en
158
+ end
159
+ rejects :malformed, nil
160
+
161
+ # Labels at either end
162
+
163
+ accepts :labels, 150_050, "$1,500.50", locale: :en
164
+ accepts :labels, 150_050, "$ 1,500.50", locale: :en
165
+ accepts :labels, 150_050, "1.500,50 $", locale: :de
166
+ accepts :labels, 150_050, "1.500,50$", locale: :de
167
+ accepts :labels, 2_500_000_000, "2.5 SOL", :SOL
168
+ accepts :labels, 1_500, "1.500 logs", :LOGS, locale: :de
169
+ accepts :labels, 1_500, "logs 1,500", :LOGS, locale: :en
170
+
171
+ accepts :euro_label, 150_050, "1.500,50 €", :EUR, before: :register_euro, locale: :de
172
+ accepts :euro_label, 150_050, "€1,500.50", :EUR, before: :register_euro, locale: :en
173
+
174
+ # Another type's label
175
+
176
+ rejects :other_types_label, "1,500.50 SOL", :USD, locale: :en
177
+ rejects :other_types_label, "$1.500,50", :SOL, locale: :de
178
+ rejects :other_types_label, "1.500 logs", :USD, locale: :de
179
+ rejects :other_types_label, "$1,500.50 $", :USD, locale: :en
180
+
181
+ # Plural and translated labels
182
+
183
+ accepts :plural_label, 1, "1 search", :SEARCHES, before: :register_plural_searches
184
+ accepts :plural_label, 1500, "1,500 searches", :SEARCHES, before: :register_plural_searches, locale: :en
185
+ accepts :plural_label, 1500, "1.500 searches", :SEARCHES, before: :register_plural_searches, locale: :de
186
+ rejects :plural_label, "1 logs", :SEARCHES, before: :register_plural_searches
187
+
188
+ accepts :translated_label, 1500, "1.500 Suchen", :SEARCHES, before: :translate_searches, locale: :de
189
+ accepts :translated_label, 1, "1 Suche", :SEARCHES, before: :translate_searches, locale: :de
190
+ accepts :translated_label, 1, "1 s", :SEARCHES, before: :translate_searches, locale: :de
191
+ rejects :translated_label, "1 Suchen", :SEARCHES, before: :translate_searches, locale: :en
192
+
193
+ # Zero decimals
194
+
195
+ accepts :decimals_zero, 1_500_000, "1.500.000", :LOGS, locale: :de
196
+ accepts :decimals_zero, 1_500_000, "1,500,000", :LOGS, locale: :en
197
+ accepts :decimals_zero, -1500, "-1,500", :LOGS, locale: :en
198
+ accepts :decimals_zero, 0, "0", :LOGS
199
+ rejects :decimals_zero, "1,5", :LOGS, locale: :en
200
+ rejects :decimals_zero, "1.500,5", :LOGS, locale: :de
201
+ rejects :decimals_zero, "1.500,0", :LOGS, locale: :de
202
+ rejects :decimals_zero, "1500.5", :LOGS
203
+
204
+ # High precision
205
+
206
+ accepts :high_precision, 1_234_567_891, "1.234567891", :SOL
207
+ accepts :high_precision, 123_456_789_123_456_789_012_345_678, "123456789123456789.012345678", :SOL
208
+
209
+ # Without the i18n gem
210
+
211
+ def test_explicit_locale_without_i18n_raises
212
+ without_i18n { assert_raises(ArgumentError) { parse("1.50", locale: :de) } }
213
+ end
214
+
215
+ def test_plain_parsing_works_without_i18n
216
+ without_i18n { assert_equal 150, parse("1.50").atomic }
217
+ end
218
+
219
+ # Round trips of `ui` output
220
+
221
+ UI_USD_VALUES = %w[0 0.01 0.99 1 9.99 12.34 123.45 999.99 1000 1000.01 12345.67 123456.78 1234567.89
222
+ -0.01 -1 -999.99 -1000 -1234567.89 99999999999.99].freeze
223
+ UI_LOGS_COUNTS = [0, 7, 999, 1000, 1_234_567, -1_500].freeze
224
+
225
+ %i[en de fr].each do |locale|
226
+ define_method("test_round_trips_usd_ui_#{locale}") do
227
+ amounts = UI_USD_VALUES.map { |value| Amount.of_usd(value) }
228
+
229
+ assert_equal amounts, amounts.map { |amount| parse(amount.ui(locale:), locale:) }
230
+ end
231
+
232
+ define_method("test_round_trips_usd_undecorated_ui_#{locale}") do
233
+ amounts = UI_USD_VALUES.map { |value| Amount.of_usd(value) }
234
+
235
+ assert_equal amounts, amounts.map { |amount| parse(amount.ui(locale:, decorated: false), locale:) }
236
+ end
237
+
238
+ define_method("test_round_trips_logs_ui_#{locale}") do
239
+ amounts = UI_LOGS_COUNTS.map { |n| Amount.of_logs(n) }
240
+
241
+ assert_equal amounts, amounts.map { |amount| parse(amount.ui(locale:), :LOGS, locale:) }
242
+ end
243
+ end
244
+
245
+ # (n**3) + n cents for n in 0, 7, 14, ..., 3997, in each locale and sign
246
+ RANGE_CENTS = (0..4000).step(7).map { |n| (n**3) + n }.freeze
247
+
248
+ %i[en de].each do |locale|
249
+ { "positive" => 1, "negative" => -1 }.each do |sign, factor|
250
+ define_method("test_round_trips_over_a_range_#{sign}_#{locale}") do
251
+ amounts = RANGE_CENTS.map { |cents| Amount.new(cents * factor, :USD) }
252
+
253
+ assert_equal amounts, amounts.map { |amount| parse(amount.ui(locale:), locale:) }
254
+ end
255
+ end
256
+ end
257
+
258
+ private
259
+
260
+ def parse(input, symbol = :USD, **opts)
261
+ Amount.parse_ui(input, symbol, **opts)
262
+ end
263
+
264
+ def register_euro
265
+ Amount.register :EUR, decimals: 2, display_symbol: "€"
266
+ end
267
+
268
+ def register_plural_searches
269
+ Amount.register :SEARCHES, decimals: 0, display_symbol: { one: "search", other: "searches" }
270
+ end
271
+
272
+ def translate_searches
273
+ Amount.register :SEARCHES, decimals: 0, display_symbol: "s"
274
+ I18n.backend.store_translations(:de, amounts: { units: { searches: { one: "Suche", other: "Suchen" } } })
275
+ end
276
+
277
+ # Runs the block as if the i18n gem were not loaded.
278
+ def without_i18n
279
+ i18n = Object.send(:remove_const, :I18n)
280
+ yield
281
+ ensure
282
+ Object.const_set(:I18n, i18n)
283
+ end
284
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class AmountTest < Minitest::Test
6
+ def setup
7
+ AmountTestSupport.register_default_types!
8
+ end
9
+
10
+ def teardown
11
+ Amount.registry.clear!
12
+ end
13
+
14
+ def test_inspect_uses_default_ui_representation_for_prefix_type
15
+ assert_equal "#<Amount USDC $1.50>", Amount.new("1.5", :USDC).inspect
16
+ end
17
+
18
+ def test_inspect_uses_default_ui_representation_for_suffix_type
19
+ assert_equal "#<Amount GOLD 1.0000 oz t>", Amount.new("1", :GOLD).inspect
20
+ end
21
+
22
+ def test_unregistered_type_raises
23
+ assert_raises(Amount::Registry::UnknownType) { Amount.new(1, :DOGE) }
24
+ end
25
+
26
+ def test_frozen_amount_renders_ui
27
+ assert_equal "$1.50", frozen_amount.ui
28
+ end
29
+
30
+ def test_frozen_amount_renders_formatted
31
+ assert_equal "1.500000", frozen_amount.formatted
32
+ end
33
+
34
+ def test_frozen_amount_renders_to_s
35
+ assert_equal "USDC|1.50", frozen_amount.to_s
36
+ end
37
+
38
+ def test_very_large_atomic_value_preserves_atomic
39
+ assert_equal 10**30, very_large_amount.atomic
40
+ end
41
+
42
+ def test_very_large_atomic_value_preserves_decimal
43
+ assert_equal "1000000000000000000000.0", very_large_amount.decimal.to_s("F")
44
+ end
45
+
46
+ private
47
+
48
+ def frozen_amount
49
+ Amount.new("1.5", :USDC).freeze
50
+ end
51
+
52
+ def very_large_amount
53
+ Amount.new(10**30, :SOL, from: :atomic)
54
+ end
55
+ end
@@ -4,6 +4,9 @@ require "rails"
4
4
  require "active_model/railtie"
5
5
  require "active_record/railtie"
6
6
 
7
+ # What Bundler.require does in an app: the railtie then loads the adapter.
8
+ require_relative "../../../lib/amounts"
9
+
7
10
  module Dummy
8
11
  class Application < Rails::Application
9
12
  config.root = File.expand_path("..", __dir__)
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../test_helper"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require_relative "../../../../lib/amounts"
7
+ require_relative "../../../../lib/generators/amount/active_record/registry_generator"
8
+ require "rails/generators"
9
+
10
+ class AmountActiveRecordRegistryGeneratorTest < Minitest::Test
11
+ PRESETS_DIR = File.expand_path("../../../../lib/generators/amount/active_record/templates/presets", __dir__)
12
+
13
+ def setup
14
+ @destination_root = Dir.mktmpdir("amounts-generator")
15
+ end
16
+
17
+ def teardown
18
+ FileUtils.remove_entry(@destination_root)
19
+ end
20
+
21
+ {
22
+ "rails_generate_command" => "rails generate amounts:registry fiat",
23
+ "registry_overview" => "# Registry overview:",
24
+ "fiat_section" => "# Major Fiat Currencies:",
25
+ "usd_description" => "# USD: United States dollar.",
26
+ "usd_fact" => "# Fact: Uses 2 decimal places for cents.",
27
+ "usd_preset_default" => "# Preset default: Uses \"$\" as the default display symbol in an English-oriented UI.",
28
+ "usd_registration" => "Amount.register :USD",
29
+ "jpy_registration" => "Amount.register :JPY"
30
+ }.each do |name, text|
31
+ define_method("test_fiat_initializer_includes_#{name}") do
32
+ assert_includes generated("fiat"), text
33
+ end
34
+ end
35
+
36
+ def test_fiat_initializer_excludes_btc_registration
37
+ refute_includes generated("fiat"), "Amount.register :BTC"
38
+ end
39
+
40
+ %w[USD USDC].each do |symbol|
41
+ define_method("test_all_initializer_registers_#{symbol}_once") do
42
+ assert_equal 1, generated("all").scan(/^\s*Amount\.register :#{symbol},/).size
43
+ end
44
+ end
45
+
46
+ %w[GOLD BTC].each do |symbol|
47
+ define_method("test_all_initializer_registers_#{symbol}") do
48
+ assert_includes generated("all"), "Amount.register :#{symbol}"
49
+ end
50
+ end
51
+
52
+ {
53
+ "btc_description" => "# BTC: Bitcoin.",
54
+ "ethereum_source" => "# Source: Ethereum denomination docs: https://ethereum.org/en/developers/docs/intro-to-ether/",
55
+ "usdt_registration" => "Amount.register :USDT"
56
+ }.each do |name, text|
57
+ define_method("test_crypto_initializer_includes_#{name}") do
58
+ assert_includes generated("crypto"), text
59
+ end
60
+ end
61
+
62
+ %w[MATIC POL].each do |symbol|
63
+ define_method("test_crypto_initializer_excludes_stale_#{symbol}_registration") do
64
+ refute_includes generated("crypto"), "Amount.register :#{symbol}"
65
+ end
66
+ end
67
+
68
+ %w[fiat metals crypto all].each do |preset|
69
+ define_method("test_static_#{preset}_preset_file_exists") do
70
+ assert File.exist?(preset_file(preset))
71
+ end
72
+ end
73
+
74
+ {
75
+ "fiat_section" => "# Major Fiat Currencies:",
76
+ "metals_section" => "# Precious And Industrial Metals:",
77
+ "crypto_section" => "# Large-Cap Crypto Assets:",
78
+ "usd_registration" => "Amount.register :USD",
79
+ "gold_registration" => "Amount.register :GOLD",
80
+ "btc_registration" => "Amount.register :BTC"
81
+ }.each do |name, text|
82
+ define_method("test_all_static_preset_file_includes_#{name}") do
83
+ assert_includes File.read(preset_file("all")), text
84
+ end
85
+ end
86
+
87
+ def test_rejects_unknown_preset
88
+ _stdout, stderr = capture_io do
89
+ invoke_generator("unknown")
90
+ end
91
+ assert_includes stderr, "unknown preset"
92
+ end
93
+
94
+ private
95
+
96
+ # The initializer generated for the preset, generated once per test.
97
+ def generated(preset)
98
+ @generated ||= {}
99
+ @generated[preset] ||= begin
100
+ invoke_generator(preset)
101
+ File.read(File.join(@destination_root, "config/initializers/amounts.rb"))
102
+ end
103
+ end
104
+
105
+ def invoke_generator(preset)
106
+ Amount::ActiveRecord::RegistryGenerator.start(
107
+ [preset],
108
+ destination_root: @destination_root,
109
+ shell: Thor::Shell::Basic.new
110
+ )
111
+ end
112
+
113
+ def preset_file(name)
114
+ File.join(PRESETS_DIR, "#{name}.fragment")
115
+ end
116
+ end