From 159b4b340f0d29d2ead6d222edefcf13f88fa91e Mon Sep 17 00:00:00 2001 From: cryptogopher Date: Mon, 6 Jul 2026 23:23:19 +0200 Subject: [PATCH] Test Unit using float multiplier Improve string randomization. Use Capybara table selectors. --- config/initializers/core_ext.rb | 3 + config/locales/en.yml | 18 ++ .../x_path/d_s_l/normalize_space_default.rb | 8 + test/application_system_test_case.rb | 47 ++++- test/system/units_test.rb | 66 ++++--- test/test_helper.rb | 166 ++++++++---------- 6 files changed, 186 insertions(+), 122 deletions(-) create mode 100644 lib/core_ext/x_path/d_s_l/normalize_space_default.rb 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