diff --git a/config/initializers/core_ext.rb b/config/initializers/core_ext.rb
index 7542a65..149e454 100644
--- a/config/initializers/core_ext.rb
+++ b/config/initializers/core_ext.rb
@@ -4,6 +4,9 @@ require 'core_ext/range'
ActiveSupport.on_load :action_dispatch_system_test_case do
prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId
+ # Allow denormalized string comparison,
+ # https://github.com/orgs/teamcapybara/discussions/2841
+ XPath::DSL.prepend CoreExt::XPath::DSL::NormalizeSpaceDefault
end
ActiveSupport.on_load :action_view do
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 2366de8..40d5e48 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -41,6 +41,24 @@ en:
descendant_reference: cannot be changed to any of descendants
self_reference: of the quantitiy cannot be the quantity itself
user_mismatch: has to belong to the same user as quantity
+ activesupport:
+ # Based on: https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-misc-full/main/en/characters.json
+ characters:
+ subsets:
+ # :exemplarCharacters[EN]
+ exemplar: abcdefghijklmnopqrstuvwxyz
+ # :index[EN]
+ index: ABCDEFGHIJKLMNOPQRSTUVWXYZ
+ numbers: 0123456789
+ # :exemplarCharacters[DE, PL] - exemplarCharacters[EN] +
+ # :index[DE, PL] - :index[EN]
+ auxiliary: äößüĆŁÓŚŹŻąćęłńóśźż
+ # :punctuation* + :numbers* - space - numbers
+ punctuation: >-
+ !\"#%&'()*+,-./:;?@[]{|}~§«°»×‐‑–—‘’‚“”„†‡…‰′″−
+ # :exemplarCharacters[EL] + :index[EL]
+ symbols: αάβγδεέζηήθιίϊΐκλμνξοόπρσςτυύϋΰφχψωώΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ
+ space: ' '
actioncontroller:
exceptions:
status:
diff --git a/lib/core_ext/x_path/d_s_l/normalize_space_default.rb b/lib/core_ext/x_path/d_s_l/normalize_space_default.rb
new file mode 100644
index 0000000..5507d98
--- /dev/null
+++ b/lib/core_ext/x_path/d_s_l/normalize_space_default.rb
@@ -0,0 +1,8 @@
+module CoreExt::XPath::DSL::NormalizeSpaceDefault
+ def normalize_space(...)
+ Capybara.default_normalize_ws ? super : current
+ end
+
+ alias_method :n, :normalize_space
+ alias_method :normalize, :normalize_space
+end
diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb
index 57f9ad3..0e5871d 100644
--- a/test/application_system_test_case.rb
+++ b/test/application_system_test_case.rb
@@ -1,7 +1,6 @@
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
- include ActionView::Helpers::SanitizeHelper
include ActionView::Helpers::UrlHelper
# NOTE: geckodriver installed with Firefox, ignore incompatibility warning
@@ -33,14 +32,6 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
button.html_safe)
end
- # Allow skipping interpolations when translating for testing purposes
- INTERPOLATION_PATTERNS = Regexp.union(I18n.config.interpolation_patterns)
- def translate(key, **options)
- translation = options.empty? ? super.split(INTERPOLATION_PATTERNS, 2).first : super
- sanitize(translation, tags: [])
- end
- alias :t :translate
-
DB_CONFIGS = ActiveRecord::Base.configurations.configs_for(env_name: "test")
TEST_CONFIGS = Hash.new(DB_CONFIGS.first.name.to_sym)
class << self
@@ -73,5 +64,43 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
end
end
+ # This regex has to be only strict enough to properly reconstruct number. It
+ # is not used for format validation.
+ NUMBER = /\A(
+ (?\-?\d[\d\.]*)
+ (×(?10(?\s)?(?(?(<-2>)()|(\-))[\d]+)))?
+ |
+ \g
+ )\z/x
+ # Finds table cell in current row corresponding to column name given in locator.
+ # Based on Capybara :table and :table_row selectors.
+ Capybara.add_selector(:table_cell, locator_type: [String, Symbol]) do
+ xpath do |locator|
+ column = XPath.string.n.is(locator.to_s)
+ header_xp = XPath.ancestor(:table)[1].descendant(:tr)[1].descendant(:th)[column]
+ position_xp = XPath.position.equals(header_xp.preceding_sibling.count.plus(1))
+ XPath.descendant(:td).where(header_xp.boolean & position_xp)
+ end
+ node_filter(:with_value) do |node, expected|
+ value =
+ case expected
+ when Float
+ node.text.match(NUMBER) { |m| "#{m['base'] || '1'}e#{m['exp']&.lstrip}".to_f }
+ else
+ node.text
+ end
+ (expected == value).tap do |result|
+ add_error("Expected value to be #{expected.inspect}" \
+ " but was #{value.inspect}") unless result
+ end
+ end
+ end
+
+ Capybara.modify_selector(:table_row) do
+ # Beware: filters of #boolean? type are called within different context.
+ node_filter(:with_focus, :boolean) do |node, value|
+ node.has_selector?(':focus') == value
+ end
+ end
end
diff --git a/test/system/units_test.rb b/test/system/units_test.rb
index dea2479..7a8d3eb 100644
--- a/test/system/units_test.rb
+++ b/test/system/units_test.rb
@@ -21,6 +21,10 @@ class UnitsTest < ApplicationSystemTestCase
}
end
+ def list_symbols
+ all(:table_cell, column_title(:symbol)).map(&:text)
+ end
+
test "index" do
sign_in
# Wait for the table to appear first, only then check row count.
@@ -39,6 +43,8 @@ class UnitsTest < ApplicationSystemTestCase
test "new and create" do
sign_in
+ symbols = list_symbols
+
link_labels.slice!(:new_unit, :new_subunit)
type, label = link_labels.to_a.sample
all(:link, exact_text: label).sample.then do |link|
@@ -46,38 +52,50 @@ class UnitsTest < ApplicationSystemTestCase
link.assert_matches_selector :link, disabled: true
end
- values = nil
- within 'tbody > tr:has(input[type=text], textarea)' do
- assert_selector ':focus'
-
- maxlength = all(:fillable_field).to_h do |field|
- [field[:name], field[:maxlength].to_i || 2**16]
+ attributes = [:symbol, :description]
+ attributes << :multiplier if type == :new_subunit
+ within :table_row, {}, with_focus: true do
+ attributes.map! do |name|
+ field = within(:table_cell, column_title(name)) { find(:fillable_field) }
+ value = case name
+ when :symbol
+ random_string(1..3, 4..field[:maxlength].to_i,
+ allow_blank: false, except: symbols)
+ when :description
+ random_string(0, 1..field[:maxlength].to_i)
+ when :multiplier
+ if rand < 0.5
+ random_float(min: field[:min].to_f, max: field[:max].to_f)
+ else
+ 10.0**rand(-15..15)
+ end
+ end
+ field.fill_in with: value
+ [name, value]
end
- values = {
- symbol: random_string(deep_rand(1..3, 4..maxlength['unit[symbol]']),
- except: @user.units.map(&:symbol), allow_blank: false),
- description: random_string(rand(0..maxlength['unit[description]']))
- }.with_indifferent_access
- within :field, 'unit[multiplier]' do |field|
- values[:multiplier] = random_number(field[:max], field[:step])
- end if type == :new_subunit
- values.each_pair { |name, value| fill_in "unit[#{name}]", with: value }
-
- assert_difference ->{ Unit.count }, 1 do
- click_on t('helpers.submit.create')
- end
+ click_on t('helpers.submit.create')
end
+ attributes = attributes.to_h
- assert_selector '.flash.notice',
- text: t('units.create.success', unit: Unit.last.symbol)
- within 'tbody' do
+ assert_selector '.flash.notice', exact_text: t('units.create.success',
+ unit: attributes[:symbol])
+ new_symbols = list_symbols
+
+ within :table do
assert_no_selector :fillable_field
- assert_selector 'tr', count: @user.units.count
+ assert_equal symbols.length + 1, new_symbols.length
+
+ m = attributes.delete(:multiplier).to_f if type == :new_subunit
+ within :table_row, attributes.transform_keys { |k| column_title(k) } do
+ assert_selector :table_cell, column_title(:multiplier), with_value: m if m
+ end
end
assert_no_selector :link, disabled: true,
exact_text: Regexp.union(link_labels.values)
- assert_equal values, Unit.last.attributes.slice(*values.keys)
+
+ refresh
+ assert_equal new_symbols, list_symbols
end
test "create updates view in order" do
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 49772e0..e29a157 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -7,92 +7,46 @@ class ActiveSupport::TestCase
fixtures :all
include ActionMailer::TestHelper
+ include ActionView::Helpers::SanitizeHelper
include ActionView::Helpers::TranslationHelper
- # List of categorized Unicode characters:
- # * source: http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
- # * file format: http://www.unicode.org/L2/L1999/UnicodeData.html
- # * select from graphic ranges: L, M, N, P, S, Zs
- UNICODE_CHARS = [
- *"\u0020".."\u007E",
- *"\u00A0".."\u00AC",
- *"\u00AE".."\u05FF",
- *"\u0606".."\u061B",
- *"\u061D".."\u06DC",
- *"\u06DE".."\u070E",
- *"\u0710".."\u07FF"
- ]
- def random_string(length, except: [], allow_blank: true)
- # Rails String#blank? is based on [[:space:]] character class (String::BLANK_RE).
- # Here we use more general class including tabs, cariage returns and newlines.
- except << /^\p{Space}+$/ unless allow_blank
+ # Allow skipping interpolations when translating for testing purposes
+ INTERPOLATION_PATTERNS = Regexp.union(I18n.config.interpolation_patterns)
+ def translate(key, **options)
+ translation = options.empty? ? super.split(INTERPOLATION_PATTERNS, 2).first : super
+ sanitize(translation, tags: [])
+ end
+ alias :t :translate
+
+ CHARACTER_SET = {exemplar: 5, index: 1, numbers: 3, auxiliary: 1,
+ punctuation: 1/16r, symbols: 1/16r, space: 1/6r}
+ # Initialize after setting Minitest.seed to ensure reproducibility.
+ def character_set
+ @@character_set ||=
+ CHARACTER_SET.reduce([]) do |chars, (subset, factor)|
+ s = t("activesupport.characters.subsets.#{subset}").chars
+ if factor.is_a?(Rational)
+ elements = factor * chars.length
+ s *= (elements / s.length).ceil
+ chars + s.sample(elements)
+ else
+ chars + s * factor
+ end
+ end
+ end
+
+ def random_string(*length_ranges, allow_blank: true, except: [])
+ length = deep_rand(length_ranges)
+ raise ArgumentError, "Allow blanks for 0 length" if length == 0 && !allow_blank
begin
- result = UNICODE_CHARS.sample(length).join
+ result = character_set.sample(length).join
+ # Rails String#blank? is based on [[:space:]] character class (String::BLANK_RE).
+ # Here we use more general class including tabs, carriage returns and newlines.
+ redo if !allow_blank && /^\p{Space}+$/ === result
end while except.any? { |e| e === result }
result
end
- # Range based string generation. Not finished, but saved as a potential
- # reference for future work. To work properly it needs to sort in collation
- # order of database.
- #def random_between(from, to, maxlength)
- # # TODO: sort UNICODE_CHARS
- # byebug
- # result = ''
- # maxlength.times do |i|
- # case
- # when from[i] == to[i]
- # result += from[i]
- # else
- # from_index = UNICODE_CHARS.bsearch_index(from[i] || UNICODE_CHARS[0])
- # to_index = UNICODE_CHARS.bsearch_index(to[i])
- # index = rand(from_index..to_index)
- # case
- # when index == from_index
- # result += UNICODE_CHARS[index]
- # from[i+1..].each_char do |c|
- # from_index = UNICODE_CHARS.bsearch_index(from[i])
- # index = rand(from_index..UNICODE_CHARS.length-1)
- # result += UNICODE_CHARS[index]
- # break if index != from_index
- # end
- # if result == from
- # if result.length < maxlength
- # result += UNICODE_CHARS.sample
- # else
- # # TODO: succ result[i..]
- # # raise if result == to
- # end
- # end
- # break
- # when index == to_index
- # result += UNICODE_CHARS[index]
- # to[i+1..].each_char do |c|
- # to_index = UNICODE_CHARS.bsearch_index(to[i])
- # index = rand(-1..to_index)
- # break if index == -1
- # result += UNICODE_CHARS[index]
- # break if index != to_index
- # end
- # if result == to
- # if result.length > i+1
- # result = result[..-2]
- # else
- # # TODO: prev result[i..]
- # # raise if result == from
- # end
- # end
- # break
- # else
- # result += UNICODE_CHARS[index]
- # break
- # end
- # end
- # end
- # return result += UNICODE_CHARS.sample(rand(0..maxlength-i-1)).join
- # # if result == from/to ...
- #end
-
def deep_rand(*args)
case args
when Array
@@ -104,16 +58,45 @@ class ActiveSupport::TestCase
end while true
end
- # Assumes: max >= step and step = 1e[-]N, both as strings
- def random_number(max, step)
- max.delete!('.')
- precision = max.length
- start = rand(precision) + 1
- d = (rand(max.to_i) + 1) % 10**start
- length = rand([0, 1..4, 4..precision].sample)
- d = d.truncate(-start + length)
- d = 10**(start - length) if d.zero?
- BigDecimal(step) * d
+ # Return string representation of number selected randomly within given limits.
+ # Distrbiution is roughly uniform first with regard to: sign and exponent, then
+ # significand, with some extra skew towards most expected values and boundaries.
+ # Variants:
+ # min max exp_range range (magnitude)
+ # - + MIN_EXP..min/max 0.5..min/max if min/max.exp
+ # - - max..min 0.5..min if min.exp, max...1.0 if max.exp
+ # - 0 MIN_EXP..min 0.5..min if min.exp
+ # 0 + MIN_EXP..max 0.5..max if max.exp
+ # + + min..max 0.5..max if max.exp, min...1.0 if min.exp
+ # TODO: alternate between floating-point and exponential representation.
+ def random_float(min: -Float::MAX_15, max: Float::MAX_15, except: [])
+ # For now assume ranges are same on both sides of 0.
+ assert_equal -min, max if min.negative? && max.positive?
+
+ include_zero = (min.negative? && max.positive?) || min.zero? || max.zero?
+ precision_range = [1..2, 3..4, 5..Float::DIG-1, Float::DIG]
+ # Returns 0.0 <=> (precision == 0).
+ precision_range << 0 if include_zero
+
+ min_frexp, max_frexp = [min, max].map(&:abs).minmax.map { |f| Math.frexp(f) }
+ fractions, exponents = min_frexp.zip(max_frexp)
+ exponents[0] = Float::MIN_EXP if include_zero
+ exp_range = Range.new(*exponents)
+ exp_range &= [Float::MIN_EXP..-10, -9..-4, -3..0, 1..4, 5..10, 11..Float::MAX_EXP]
+ begin
+ precision = deep_rand(*precision_range)
+ exp = deep_rand(*exp_range)
+
+ range = [0.5, 1.0, true]
+ range = [range[0], fractions[1], false] if exp == exponents[1]
+ range = [fractions[0], range[1], range[2]] if exp == exponents[0] && !include_zero
+
+ fr = rand(Range.new(*range))
+ number = Math.ldexp(fr, exp)
+ number *= [min.negative? ? -1.0 : 1.0, max.positive? ? 1.0 : -1.0].sample
+ number = number.limit(precision).clamp(min, max)
+ end while except.any? { |e| e === number }
+ number.to_s
end
def randomize_user_password!(user)
@@ -131,4 +114,9 @@ class ActiveSupport::TestCase
def with_last_email
yield(ActionMailer::Base.deliveries.last)
end
+
+ def column_title(attribute)
+ model = self.class.name.delete_suffix('Test').singularize.constantize
+ model.human_attribute_name(attribute)
+ end
end