amounts 0.0.7 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +37 -0
- data/CHANGELOG.md +142 -1
- data/Gemfile +4 -0
- data/README.md +218 -4
- data/Rakefile +47 -10
- data/lib/amount/active_record/amount_validator.rb +9 -18
- data/lib/amount/active_record/attribute_definition.rb +3 -1
- data/lib/amount/active_record/locale/en.yml +7 -0
- data/lib/amount/active_record/model.rb +9 -0
- data/lib/amount/active_record/railtie.rb +1 -1
- data/lib/amount/active_record.rb +12 -0
- data/lib/amount/compact.rb +137 -0
- data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
- data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +9 -9
- data/lib/amount/concerns/coercible.rb +65 -0
- data/lib/amount/concerns/constructible.rb +92 -0
- data/lib/amount/{conversion.rb → concerns/convertible.rb} +3 -3
- data/lib/amount/concerns/formattable.rb +112 -0
- data/lib/amount/concerns/localizable.rb +39 -0
- data/lib/amount/{comparison.rb → concerns/orderable.rb} +10 -10
- data/lib/amount/concerns/parsable.rb +58 -0
- data/lib/amount/concerns/registrable.rb +80 -0
- data/lib/amount/{serialization.rb → concerns/serializable.rb} +4 -4
- data/lib/amount/display.rb +86 -51
- data/lib/amount/locale.rb +102 -0
- data/lib/amount/registry/generated_constructors.rb +10 -5
- data/lib/amount/registry.rb +72 -9
- data/lib/amount/rspec.rb +4 -4
- data/lib/amount/ui_parser.rb +109 -0
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +61 -205
- data/test/amount/active_record/amount_validator_test.rb +201 -0
- data/test/amount/active_record/attribute_definition_test.rb +234 -0
- data/test/amount/active_record/migration_methods_test.rb +60 -0
- data/test/amount/active_record/model_test.rb +40 -0
- data/test/amount/active_record/type_test.rb +80 -0
- data/test/amount/active_record_test.rb +12 -0
- data/test/amount/compact_test.rb +371 -0
- data/test/amount/concerns/allocatable_test.rb +166 -0
- data/test/amount/concerns/calculable_test.rb +152 -0
- data/test/amount/concerns/coercible_test.rb +49 -0
- data/test/amount/concerns/constructible_test.rb +113 -0
- data/test/amount/concerns/convertible_test.rb +54 -0
- data/test/amount/concerns/formattable_test.rb +336 -0
- data/test/amount/concerns/localizable_test.rb +59 -0
- data/test/amount/concerns/orderable_test.rb +83 -0
- data/test/amount/concerns/parsable_test.rb +98 -0
- data/test/amount/concerns/registrable_test.rb +67 -0
- data/test/amount/concerns/serializable_test.rb +98 -0
- data/test/amount/display_test.rb +181 -0
- data/test/amount/locale_test.rb +590 -0
- data/test/amount/parser_test.rb +29 -0
- data/test/amount/registry/generated_constructors_test.rb +94 -0
- data/test/amount/registry_test.rb +149 -0
- data/test/amount/ui_parser_test.rb +284 -0
- data/test/amount_test.rb +55 -0
- data/test/dummy/config/application.rb +3 -0
- data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
- data/test/integration/postgresql_test.rb +94 -0
- data/test/support/active_record_test_support.rb +65 -0
- data/test/support/error_helpers.rb +22 -0
- data/test/support/locale_test_setup.rb +36 -0
- data/test/test_helper.rb +1 -0
- metadata +47 -11
- data/test/postgresql_integration_test.rb +0 -71
- data/test/test_active_record.rb +0 -375
- data/test/test_amount.rb +0 -663
- data/test/test_registry_generator.rb +0 -104
|
@@ -0,0 +1,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
|
data/test/amount_test.rb
ADDED
|
@@ -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
|