Test Unit using float multiplier

Improve string randomization. Use Capybara table selectors.
This commit is contained in:
2026-07-06 23:23:19 +02:00
parent 774b1eb19b
commit 159b4b340f
6 changed files with 186 additions and 122 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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(
(?<base>\-?\d[\d\.]*)
(×(?<pow>10(?<ws>\s)?(?<exp>(?(<-2>)()|(\-))[\d]+)))?
|
\g<pow>
)\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

View File

@@ -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
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

View File

@@ -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