diff options
author | David Heinemeier Hansson <david@loudthinking.com> | 2006-09-03 19:54:21 +0000 |
---|---|---|
committer | David Heinemeier Hansson <david@loudthinking.com> | 2006-09-03 19:54:21 +0000 |
commit | 3142502964f94d6144312ae2c368b4c4589fa25a (patch) | |
tree | b7a9ec374c717bb6e635f545171c4ecec359136f | |
parent | 6fcc81b7f9a8b27b898794dfe4f0a8202393e8a3 (diff) | |
download | rails-3142502964f94d6144312ae2c368b4c4589fa25a.tar.gz rails-3142502964f94d6144312ae2c368b4c4589fa25a.tar.bz2 rails-3142502964f94d6144312ae2c368b4c4589fa25a.zip |
Added assert_select* for CSS selector-based testing (deprecates assert_tag) #5936 [assaf.arkin@gmail.com]
git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@4929 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
-rw-r--r-- | actionpack/CHANGELOG | 2 | ||||
-rw-r--r-- | actionpack/lib/action_controller/assert_select.rb | 557 | ||||
-rw-r--r-- | actionpack/lib/action_controller/assert_tag.rb | 121 | ||||
-rw-r--r-- | actionpack/lib/action_controller/assertions.rb | 111 | ||||
-rw-r--r-- | actionpack/lib/action_controller/test_process.rb | 2 | ||||
-rw-r--r-- | actionpack/lib/action_controller/vendor/html-scanner/html/document.rb | 1 | ||||
-rw-r--r-- | actionpack/lib/action_controller/vendor/html-scanner/html/selector.rb | 822 | ||||
-rw-r--r-- | actionpack/test/controller/assert_select_test.rb | 490 | ||||
-rw-r--r-- | actionpack/test/controller/selector_test.rb | 628 |
9 files changed, 2624 insertions, 110 deletions
diff --git a/actionpack/CHANGELOG b/actionpack/CHANGELOG index fcbb7f3a2f..d5213be69a 100644 --- a/actionpack/CHANGELOG +++ b/actionpack/CHANGELOG @@ -1,5 +1,7 @@ *SVN* +* Added assert_select* for CSS selector-based testing (deprecates assert_tag) #5936 [assaf.arkin@gmail.com] + * radio_button_tag generates unique id attributes. #3353 [Bob Silva, somekool@gmail.com] * strip_tags returns nil for a blank arg such as nil or "". #2229 [duncan@whomwah.com] diff --git a/actionpack/lib/action_controller/assert_select.rb b/actionpack/lib/action_controller/assert_select.rb new file mode 100644 index 0000000000..01bb3ca28d --- /dev/null +++ b/actionpack/lib/action_controller/assert_select.rb @@ -0,0 +1,557 @@ +#-- +# Copyright (c) 2006 Assaf Arkin (http://labnotes.org) +# Under MIT and/or CC By license. +#++ + +require 'test/unit' +require 'test/unit/assertions' +require 'rexml/document' +require File.dirname(__FILE__) + "/vendor/html-scanner/html/document" + + +module ActionController + module Assertions + # Adds the #assert_select method for use in Rails functional + # test cases. + # + # Use #assert_select to make assertions on the response HTML of a controller + # action. You can also call #assert_select within another #assert_select to + # make assertions on elements selected by the enclosing assertion. + # + # Use #css_select to select elements without making an assertions, either + # from the response HTML or elements selected by the enclosing assertion. + # + # In addition to HTML responses, you can make the following assertions: + # * #assert_select_rjs -- Assertions on HTML content of RJS update and + # insertion operations. + # * #assert_select_encoded -- Assertions on HTML encoded inside XML, + # for example for dealing with feed item descriptions. + # * #assert_select_email -- Assertions on the HTML body of an e-mail. + # + # Also see HTML::Selector for learning how to use selectors. + module SelectorAssertions + # :call-seq: + # css_select(selector) => array + # css_select(element, selector) => array + # + # Select and return all matching elements. + # + # If called with a single argument, uses that argument as a selector + # to match all elements of the current page. Returns an empty array + # if no match is found. + # + # If called with two arguments, uses the first argument as the base + # element and the second argument as the selector. Attempts to match the + # base element and any of its children. Returns an empty array if no + # match is found. + # + # The selector may be a CSS selector expression (+String+), an expression + # with substitution values (+Array+) or an HTML::Selector object. + # + # For example: + # forms = css_select("form") + # forms.each do |form| + # inputs = css_select(form, "input") + # ... + # end + def css_select(*args) + # See assert_select to understand what's going on here. + arg = args.shift + + if arg.is_a?(HTML::Node) + root = arg + arg = args.shift + elsif arg == nil + raise ArgumentError, "First arugment is either selector or element to select, but nil found. Perhaps you called assert_select with an element that does not exist?" + elsif @selected + matches = [] + @selected.each do |selected| + subset = css_select(selected, HTML::Selector.new(arg.dup, args.dup)) + subset.each do |match| + matches << match unless matches.any? { |m| m.equal?(match) } + end + end + + return matches + else + root = response_from_page_or_rjs + end + + case arg + when String + selector = HTML::Selector.new(arg, args) + when Array + selector = HTML::Selector.new(*arg) + when HTML::Selector + selector = arg + else raise ArgumentError, "Expecting a selector as the first argument" + end + + selector.select(root) + end + + # :call-seq: + # assert_select(selector, equality?, message?) + # assert_select(element, selector, equality?, message?) + # + # An assertion that selects elements and makes one or more equality tests. + # + # If the first argument is an element, selects all matching elements + # starting from (and including) that element and all its children in + # depth-first order. + # + # If no element if specified, calling #assert_select will select from the + # response HTML. Calling #assert_select inside an #assert_select block will + # run the assertion for each element selected by the enclosing assertion. + # + # For example: + # assert_select "ol>li" do |elements| + # elements.each do |element| + # assert_select element, "li" + # end + # end + # Or for short: + # assert_select "ol>li" do + # assert_select "li" + # end + # + # The selector may be a CSS selector expression (+String+), an expression + # with substitution values, or an HTML::Selector object. + # + # === Equality Tests + # + # The equality test may be one of the following: + # * <tt>nil/true</tt> -- Assertion is true if at least one element is + # selected. + # * <tt>String</tt> -- Assertion is true if the text value of all + # selected elements equals to the string. + # * <tt>Regexp</tt> -- Assertion is true if the text value of all + # selected elements matches the regular expression. + # * <tt>false</tt> -- Assertion is true if no element is selected. + # * <tt>Integer</tt> -- Assertion is true if exactly that number of + # elements are selected. + # * <tt>Range</tt> -- Assertion is true if the number of selected + # elements fit the range. + # + # To perform more than one equality tests, use a hash the following keys: + # * <tt>:text</tt> -- Assertion is true if the text value of each + # selected elements equals to the value (+String+ or +Regexp+). + # * <tt>:count</tt> -- Assertion is true if the number of matched elements + # is equal to the value. + # * <tt>:minimum</tt> -- Assertion is true if the number of matched + # elements is at least that value. + # * <tt>:maximum</tt> -- Assertion is true if the number of matched + # elements is at most that value. + # + # If the method is called with a block, once all equality tests are + # evaluated the block is called with an array of all matched elements. + # + # === Examples + # + # # At least one form element + # assert_select "form" + # + # # Form element includes four input fields + # assert_select "form input", 4 + # + # # Page title is "Welcome" + # assert_select "title", "Welcome" + # + # # Page title is "Welcome" and there is only one title element + # assert_select "title", {:count=>1, :text=>"Welcome"}, + # "Wrong title or more than one title element" + # + # # Page contains no forms + # assert_select "form", false, "This page must contain no forms" + # + # # Test the content and style + # assert_select "body div.header ul.menu" + # + # # Use substitution values + # assert_select "ol>li#?", /item-\d+/ + # + # # All input fields in the form have a name + # assert_select "form input" do + # assert_select "[name=?]", /.+/ # Not empty + # end + def assert_select(*args, &block) + # Start with optional element followed by mandatory selector. + arg = args.shift + + if arg.is_a?(HTML::Node) + # First argument is a node (tag or text, but also HTML root), + # so we know what we're selecting from. + root = arg + arg = args.shift + elsif arg == nil + # This usually happens when passing a node/element that + # happens to be nil. + raise ArgumentError, "First arugment is either selector or element to select, but nil found. Perhaps you called assert_select with an element that does not exist?" + elsif @selected + root = HTML::Node.new(nil) + root.children.concat @selected + else + # Otherwise just operate on the response document. + root = response_from_page_or_rjs + end + + # First or second argument is the selector: string and we pass + # all remaining arguments. Array and we pass the argument. Also + # accepts selector itself. + case arg + when String + selector = HTML::Selector.new(arg, args) + when Array + selector = HTML::Selector.new(*arg) + when HTML::Selector + selector = arg + else raise ArgumentError, "Expecting a selector as the first argument" + end + + # Next argument is used for equality tests. + equals = {} + case arg = args.shift + when Hash + equals = arg + when String, Regexp + equals[:text] = arg + when Integer + equals[:count] = arg + when Range + equals[:minimum] = arg.begin + equals[:maximum] = arg.end + when FalseClass + equals[:count] = 0 + when NilClass, TrueClass + equals[:minimum] = 1 + else raise ArgumentError, "I don't understand what you're trying to match" + end + + # If we have a text test, by default we're looking for at least one match. + # Without this statement text tests pass even if nothing is selected. + # Can always override by specifying minimum or count. + if equals[:text] + equals[:minimum] ||= 1 + end + # If a count is specified, it takes precedence over minimum/maximum. + if equals[:count] + equals[:minimum] = equals[:maximum] = equals.delete(:count) + end + + # Last argument is the message we use if the assertion fails. + message = args.shift + #- message = "No match made with selector #{selector.inspect}" unless message + if args.shift + raise ArgumentError, "Not expecting that last argument, you either have too many arguments, or they're the wrong type" + end + + matches = selector.select(root) + # Equality test. + equals.each do |type, value| + case type + when :text + for match in matches + text = "" + stack = match.children.reverse + while node = stack.pop + if node.tag? + stack.concat node.children.reverse + else + text << node.content + end + end + text.strip! unless match.name == "pre" + if value.is_a?(Regexp) + assert text =~ value, build_message(message, <<EOT, value, text) +<?> expected but was +<?>. +EOT + else + assert_equal value.to_s, text, message + end + end + when :html + for match in matches + html = match.children.map(&:to_s).join + html.strip! unless match.name == "pre" + if value.is_a?(Regexp) + assert html =~ value, build_message(message, <<EOT, value, html) +<?> expected but was +<?>. +EOT + else + assert_equal value.to_s, html, message + end + end + when :minimum + assert matches.size >= value, message || "Expecting at least #{value} selected elements, found #{matches.size}" + when :maximum + assert matches.size <= value, message || "Expecting at most #{value} selected elements, found #{matches.size}" + else raise ArgumentError, "I don't support the equality test #{key}" + end + end + + # If a block is given call that block. Set @selected to allow + # nested assert_select, which can be nested several levels deep. + if block_given? && !matches.empty? + begin + in_scope, @selected = @selected, matches + yield matches + ensure + @selected = in_scope + end + end + + # Returns all matches elements. + matches + end + + # :call-seq: + # assert_select_rjs(id?) { |elements| ... } + # assert_select_rjs(statement, id?) { |elements| ... } + # assert_select_rjs(:insert, position, id?) { |elements| ... } + # + # Selects content from the RJS response. + # + # === Narrowing down + # + # With no arguments, asserts that one or more elements are updated or + # inserted by RJS statements. + # + # Use the +id+ argument to narrow down the assertion to only statements + # that update or insert an element with that identifier. + # + # Use the first argument to narrow down assertions to only statements + # of that type. Possible values are +:replace+, +:replace_html+ and + # +:insert_html+. + # + # Use the argument +:insert+ followed by an insertion position to narrow + # down the assertion to only statements that insert elements in that + # position. Possible values are +:top+, +:bottom+, +:before+ and +:after+. + # + # === Using blocks + # + # Without a block, #assert_select_rjs merely asserts that the response + # contains one or more RJS statements that replace or update content. + # + # With a block, #assert_select_rjs also selects all elements used in + # these statements and passes them to the block. Nested assertions are + # supported. + # + # Calling #assert_select_rjs with no arguments and using nested asserts + # asserts that the HTML content is returned by one or more RJS statements. + # Using #assert_select directly makes the same assertion on the content, + # but without distinguishing whether the content is returned in an HTML + # or JavaScript. + # + # === Examples + # + # # Updating the element foo. + # assert_select_rjs :update, "foo" + # + # # Inserting into the element bar, top position. + # assert_select rjs, :insert, :top, "bar" + # + # # Changing the element foo, with an image. + # assert_select_rjs "foo" do + # assert_select "img[src=/images/logo.gif"" + # end + # + # # RJS inserts or updates a list with four items. + # assert_select_rjs do + # assert_select "ol>li", 4 + # end + # + # # The same, but shorter. + # assert_select "ol>li", 4 + def assert_select_rjs(*args, &block) + arg = args.shift + + # If the first argument is a symbol, it's the type of RJS statement we're looking + # for (update, replace, insertion, etc). Otherwise, we're looking for just about + # any RJS statement. + if arg.is_a?(Symbol) + if arg == :insert + arg = args.shift + insertion = "insert_#{arg}".to_sym + raise ArgumentError, "Unknown RJS insertion type #{arg}" unless RJS_STATEMENTS[insertion] + statement = "(#{RJS_STATEMENTS[insertion]})" + else + raise ArgumentError, "Unknown RJS statement type #{arg}" unless RJS_STATEMENTS[arg] + statement = "(#{RJS_STATEMENTS[arg]})" + end + arg = args.shift + else + statement = "#{RJS_STATEMENTS[:any]}" + end + + # Next argument we're looking for is the element identifier. If missing, we pick + # any element. + if arg.is_a?(String) + id = Regexp.quote(arg) + arg = args.shift + else + id = "[^\"]*" + end + + pattern = Regexp.new("#{statement}\\(\"#{id}\", #{RJS_PATTERN_HTML}\\)", Regexp::MULTILINE) + + # Duplicate the body since the next step involves destroying it. + matches = nil + @response.body.gsub(pattern) do |match| + html = $2 + # RJS encodes double quotes and line breaks. + html.gsub!(/\\"/, "\"") + html.gsub!(/\\n/, "\n") + matches ||= [] + matches.concat HTML::Document.new(html).root.children.select { |n| n.tag? } + "" + end + if matches + if block_given? + begin + in_scope, @selected = @selected, matches + yield matches + ensure + @selected = in_scope + end + end + matches + else + # RJS statement not found. + flunk args.shift || "No RJS statement that replaces or inserts HTML content." + end + end + + # :call-seq: + # assert_select_encoded(element?) { |elements| ... } + # + # Extracts the content of an element, treats it as encoded HTML and runs + # nested assertion on it. + # + # You typically call this method within another assertion to operate on + # all currently selected elements. You can also pass an element or array + # of elements. + # + # The content of each element is un-encoded, and wrapped in the root + # element +encoded+. It then calls the block with all un-encoded elements. + # + # === Example + # + # assert_select_feed :rss, 2.0 do + # # Select description element of each feed item. + # assert_select "channel>item>description" do + # # Run assertions on the encoded elements. + # assert_select_encoded do + # assert_select "p" + # end + # end + # end + def assert_select_encoded(element = nil, &block) + case element + when Array + elements = element + when HTML::Node + elements = [element] + when nil + unless elements = @selected + raise ArgumentError, "First argument is optional, but must be called from a nested assert_select" + end + else + raise ArgumentError, "Argument is optional, and may be node or array of nodes" + end + + fix_content = lambda do |node| + # Gets around a bug in the Rails 1.1 HTML parser. + node.content.gsub(/<!\[CDATA\[(.*)(\]\]>)?/m) { CGI.escapeHTML($1) } + end + + selected = elements.map do |element| + text = element.children.select{ |c| not c.tag? }.map{ |c| fix_content[c] }.join + root = HTML::Document.new(CGI.unescapeHTML("<encoded>#{text}</encoded>")).root + css_select(root, "encoded:root", &block)[0] + end + + begin + old_selected, @selected = @selected, selected + assert_select ":root", &block + ensure + @selected = old_selected + end + end + + # :call-seq: + # assert_select_email { } + # + # Extracts the body of an email and runs nested assertions on it. + # + # You must enable deliveries for this assertion to work, use: + # ActionMailer::Base.perform_deliveries = true + # + # === Example + # + # assert_select_email do + # assert_select "h1", "Email alert" + # end + def assert_select_email(&block) + deliveries = ActionMailer::Base.deliveries + assert !deliveries.empty?, "No e-mail in delivery list" + + for delivery in deliveries + for part in delivery.parts + if part["Content-Type"].to_s =~ /^text\/html\W/ + root = HTML::Document.new(part.body).root + assert_select root, ":root", &block + end + end + end + end + + protected + unless const_defined?(:RJS_STATEMENTS) + RJS_STATEMENTS = { + :replace => /Element\.replace/, + :replace_html => /Element\.update/ + } + RJS_INSERTIONS = [:top, :bottom, :before, :after] + RJS_INSERTIONS.each do |insertion| + RJS_STATEMENTS["insert_#{insertion}".to_sym] = Regexp.new(Regexp.quote("new Insertion.#{insertion.to_s.camelize}")) + end + RJS_STATEMENTS[:any] = Regexp.new("(#{RJS_STATEMENTS.values.join('|')})") + RJS_STATEMENTS[:insert_html] = Regexp.new(RJS_INSERTIONS.collect do |insertion| + Regexp.quote("new Insertion.#{insertion.to_s.camelize}") + end.join('|')) + RJS_PATTERN_HTML = /"((\\"|[^"])*)"/ + RJS_PATTERN_EVERYTHING = Regexp.new("#{RJS_STATEMENTS[:any]}\\(\"([^\"]*)\", #{RJS_PATTERN_HTML}\\)", + Regexp::MULTILINE) + end + + # #assert_select and #css_select call this to obtain the content in the HTML + # page, or from all the RJS statements, depending on the type of response. + def response_from_page_or_rjs() + content_type = @response.headers["Content-Type"] + if content_type && content_type =~ /text\/javascript/ + body = @response.body.dup + root = HTML::Node.new(nil) + while true + next if body.sub!(RJS_PATTERN_EVERYTHING) do |match| + # RJS encodes double quotes and line breaks. + html = $3 + html.gsub!(/\\"/, "\"") + html.gsub!(/\\n/, "\n") + matches = HTML::Document.new(html).root.children.select { |n| n.tag? } + root.children.concat matches + "" + end + break + end + root + else + html_document.root + end + end + end + end +end + +Test::Unit::TestCase.send :include, ActionController::Assertions::SelectorAssertions
\ No newline at end of file diff --git a/actionpack/lib/action_controller/assert_tag.rb b/actionpack/lib/action_controller/assert_tag.rb new file mode 100644 index 0000000000..64d39b0010 --- /dev/null +++ b/actionpack/lib/action_controller/assert_tag.rb @@ -0,0 +1,121 @@ +require 'test/unit' +require 'test/unit/assertions' +require 'rexml/document' +require File.dirname(__FILE__) + "/vendor/html-scanner/html/document" + +module ActionController + module Assertions + module TagAssertions + # Asserts that there is a tag/node/element in the body of the response + # that meets all of the given conditions. The +conditions+ parameter must + # be a hash of any of the following keys (all are optional): + # + # * <tt>:tag</tt>: the node type must match the corresponding value + # * <tt>:attributes</tt>: a hash. The node's attributes must match the + # corresponding values in the hash. + # * <tt>:parent</tt>: a hash. The node's parent must match the + # corresponding hash. + # * <tt>:child</tt>: a hash. At least one of the node's immediate children + # must meet the criteria described by the hash. + # * <tt>:ancestor</tt>: a hash. At least one of the node's ancestors must + # meet the criteria described by the hash. + # * <tt>:descendant</tt>: a hash. At least one of the node's descendants + # must meet the criteria described by the hash. + # * <tt>:sibling</tt>: a hash. At least one of the node's siblings must + # meet the criteria described by the hash. + # * <tt>:after</tt>: a hash. The node must be after any sibling meeting + # the criteria described by the hash, and at least one sibling must match. + # * <tt>:before</tt>: a hash. The node must be before any sibling meeting + # the criteria described by the hash, and at least one sibling must match. + # * <tt>:children</tt>: a hash, for counting children of a node. Accepts + # the keys: + # * <tt>:count</tt>: either a number or a range which must equal (or + # include) the number of children that match. + # * <tt>:less_than</tt>: the number of matching children must be less + # than this number. + # * <tt>:greater_than</tt>: the number of matching children must be + # greater than this number. + # * <tt>:only</tt>: another hash consisting of the keys to use + # to match on the children, and only matching children will be + # counted. + # * <tt>:content</tt>: the textual content of the node must match the + # given value. This will not match HTML tags in the body of a + # tag--only text. + # + # Conditions are matched using the following algorithm: + # + # * if the condition is a string, it must be a substring of the value. + # * if the condition is a regexp, it must match the value. + # * if the condition is a number, the value must match number.to_s. + # * if the condition is +true+, the value must not be +nil+. + # * if the condition is +false+ or +nil+, the value must be +nil+. + # + # Usage: + # + # # assert that there is a "span" tag + # assert_tag :tag => "span" + # + # # assert that there is a "span" tag with id="x" + # assert_tag :tag => "span", :attributes => { :id => "x" } + # + # # assert that there is a "span" tag using the short-hand + # assert_tag :span + # + # # assert that there is a "span" tag with id="x" using the short-hand + # assert_tag :span, :attributes => { :id => "x" } + # + # # assert that there is a "span" inside of a "div" + # assert_tag :tag => "span", :parent => { :tag => "div" } + # + # # assert that there is a "span" somewhere inside a table + # assert_tag :tag => "span", :ancestor => { :tag => "table" } + # + # # assert that there is a "span" with at least one "em" child + # assert_tag :tag => "span", :child => { :tag => "em" } + # + # # assert that there is a "span" containing a (possibly nested) + # # "strong" tag. + # assert_tag :tag => "span", :descendant => { :tag => "strong" } + # + # # assert that there is a "span" containing between 2 and 4 "em" tags + # # as immediate children + # assert_tag :tag => "span", + # :children => { :count => 2..4, :only => { :tag => "em" } } + # + # # get funky: assert that there is a "div", with an "ul" ancestor + # # and an "li" parent (with "class" = "enum"), and containing a + # # "span" descendant that contains text matching /hello world/ + # assert_tag :tag => "div", + # :ancestor => { :tag => "ul" }, + # :parent => { :tag => "li", + # :attributes => { :class => "enum" } }, + # :descendant => { :tag => "span", + # :child => /hello world/ } + # + # <strong>Please note</strong: #assert_tag and #assert_no_tag only work + # with well-formed XHTML. They recognize a few tags as implicitly self-closing + # (like br and hr and such) but will not work correctly with tags + # that allow optional closing tags (p, li, td). <em>You must explicitly + # close all of your tags to use these assertions.</em> + def assert_tag(*opts) + clean_backtrace do + opts = opts.size > 1 ? opts.last.merge({ :tag => opts.first.to_s }) : opts.first + tag = find_tag(opts) + assert tag, "expected tag, but no tag found matching #{opts.inspect} in:\n#{@response.body.inspect}" + end + end + + # Identical to #assert_tag, but asserts that a matching tag does _not_ + # exist. (See #assert_tag for a full discussion of the syntax.) + def assert_no_tag(*opts) + clean_backtrace do + opts = opts.size > 1 ? opts.last.merge({ :tag => opts.first.to_s }) : opts.first + tag = find_tag(opts) + assert !tag, "expected no tag, but found tag matching #{opts.inspect} in:\n#{@response.body.inspect}" + end + end + end + end +end + +Test::Unit::TestCase.send :include, ActionController::Assertions::TagAssertions
\ No newline at end of file diff --git a/actionpack/lib/action_controller/assertions.rb b/actionpack/lib/action_controller/assertions.rb index 443449345b..db130ea3d1 100644 --- a/actionpack/lib/action_controller/assertions.rb +++ b/actionpack/lib/action_controller/assertions.rb @@ -221,115 +221,6 @@ module Test #:nodoc: assert_generates(path, options, defaults, extras, message) end - # Asserts that there is a tag/node/element in the body of the response - # that meets all of the given conditions. The +conditions+ parameter must - # be a hash of any of the following keys (all are optional): - # - # * <tt>:tag</tt>: the node type must match the corresponding value - # * <tt>:attributes</tt>: a hash. The node's attributes must match the - # corresponding values in the hash. - # * <tt>:parent</tt>: a hash. The node's parent must match the - # corresponding hash. - # * <tt>:child</tt>: a hash. At least one of the node's immediate children - # must meet the criteria described by the hash. - # * <tt>:ancestor</tt>: a hash. At least one of the node's ancestors must - # meet the criteria described by the hash. - # * <tt>:descendant</tt>: a hash. At least one of the node's descendants - # must meet the criteria described by the hash. - # * <tt>:sibling</tt>: a hash. At least one of the node's siblings must - # meet the criteria described by the hash. - # * <tt>:after</tt>: a hash. The node must be after any sibling meeting - # the criteria described by the hash, and at least one sibling must match. - # * <tt>:before</tt>: a hash. The node must be before any sibling meeting - # the criteria described by the hash, and at least one sibling must match. - # * <tt>:children</tt>: a hash, for counting children of a node. Accepts - # the keys: - # * <tt>:count</tt>: either a number or a range which must equal (or - # include) the number of children that match. - # * <tt>:less_than</tt>: the number of matching children must be less - # than this number. - # * <tt>:greater_than</tt>: the number of matching children must be - # greater than this number. - # * <tt>:only</tt>: another hash consisting of the keys to use - # to match on the children, and only matching children will be - # counted. - # * <tt>:content</tt>: the textual content of the node must match the - # given value. This will not match HTML tags in the body of a - # tag--only text. - # - # Conditions are matched using the following algorithm: - # - # * if the condition is a string, it must be a substring of the value. - # * if the condition is a regexp, it must match the value. - # * if the condition is a number, the value must match number.to_s. - # * if the condition is +true+, the value must not be +nil+. - # * if the condition is +false+ or +nil+, the value must be +nil+. - # - # Usage: - # - # # assert that there is a "span" tag - # assert_tag :tag => "span" - # - # # assert that there is a "span" tag with id="x" - # assert_tag :tag => "span", :attributes => { :id => "x" } - # - # # assert that there is a "span" tag using the short-hand - # assert_tag :span - # - # # assert that there is a "span" tag with id="x" using the short-hand - # assert_tag :span, :attributes => { :id => "x" } - # - # # assert that there is a "span" inside of a "div" - # assert_tag :tag => "span", :parent => { :tag => "div" } - # - # # assert that there is a "span" somewhere inside a table - # assert_tag :tag => "span", :ancestor => { :tag => "table" } - # - # # assert that there is a "span" with at least one "em" child - # assert_tag :tag => "span", :child => { :tag => "em" } - # - # # assert that there is a "span" containing a (possibly nested) - # # "strong" tag. - # assert_tag :tag => "span", :descendant => { :tag => "strong" } - # - # # assert that there is a "span" containing between 2 and 4 "em" tags - # # as immediate children - # assert_tag :tag => "span", - # :children => { :count => 2..4, :only => { :tag => "em" } } - # - # # get funky: assert that there is a "div", with an "ul" ancestor - # # and an "li" parent (with "class" = "enum"), and containing a - # # "span" descendant that contains text matching /hello world/ - # assert_tag :tag => "div", - # :ancestor => { :tag => "ul" }, - # :parent => { :tag => "li", - # :attributes => { :class => "enum" } }, - # :descendant => { :tag => "span", - # :child => /hello world/ } - # - # <strong>Please note</strong: #assert_tag and #assert_no_tag only work - # with well-formed XHTML. They recognize a few tags as implicitly self-closing - # (like br and hr and such) but will not work correctly with tags - # that allow optional closing tags (p, li, td). <em>You must explicitly - # close all of your tags to use these assertions.</em> - def assert_tag(*opts) - clean_backtrace do - opts = opts.size > 1 ? opts.last.merge({ :tag => opts.first.to_s }) : opts.first - tag = find_tag(opts) - assert tag, "expected tag, but no tag found matching #{opts.inspect} in:\n#{@response.body.inspect}" - end - end - - # Identical to #assert_tag, but asserts that a matching tag does _not_ - # exist. (See #assert_tag for a full discussion of the syntax.) - def assert_no_tag(*opts) - clean_backtrace do - opts = opts.size > 1 ? opts.last.merge({ :tag => opts.first.to_s }) : opts.first - tag = find_tag(opts) - assert !tag, "expected no tag, but found tag matching #{opts.inspect} in:\n#{@response.body.inspect}" - end - end - # test 2 html strings to be equivalent, i.e. identical up to reordering of attributes def assert_dom_equal(expected, actual, message="") clean_backtrace do @@ -382,4 +273,4 @@ module Test #:nodoc: end end end -end +end
\ No newline at end of file diff --git a/actionpack/lib/action_controller/test_process.rb b/actionpack/lib/action_controller/test_process.rb index 118e48e5cf..511c759e97 100644 --- a/actionpack/lib/action_controller/test_process.rb +++ b/actionpack/lib/action_controller/test_process.rb @@ -1,4 +1,6 @@ require File.dirname(__FILE__) + '/assertions' +require File.dirname(__FILE__) + '/assert_select' +require File.dirname(__FILE__) + '/assert_tag' require File.dirname(__FILE__) + '/deprecated_assertions' module ActionController #:nodoc: diff --git a/actionpack/lib/action_controller/vendor/html-scanner/html/document.rb b/actionpack/lib/action_controller/vendor/html-scanner/html/document.rb index 25230f2ee4..19b349e886 100644 --- a/actionpack/lib/action_controller/vendor/html-scanner/html/document.rb +++ b/actionpack/lib/action_controller/vendor/html-scanner/html/document.rb @@ -1,5 +1,6 @@ require File.dirname(__FILE__) + '/tokenizer' require File.dirname(__FILE__) + '/node' +require File.dirname(__FILE__) + '/selector' module HTML #:nodoc: diff --git a/actionpack/lib/action_controller/vendor/html-scanner/html/selector.rb b/actionpack/lib/action_controller/vendor/html-scanner/html/selector.rb new file mode 100644 index 0000000000..1fa26918cd --- /dev/null +++ b/actionpack/lib/action_controller/vendor/html-scanner/html/selector.rb @@ -0,0 +1,822 @@ +#-- +# Copyright (c) 2006 Assaf Arkin (http://labnotes.org) +# Under MIT and/or CC By license. +#++ + +module HTML + + # Selects HTML elements using CSS 2 selectors. + # + # The +Selector+ class uses CSS selector expressions to match and select + # HTML elements. + # + # For example: + # selector = HTML::Selector.new "form.login[action=/login]" + # creates a new selector that matches any +form+ element with the class + # +login+ and an attribute +action+ with the value <tt>/login</tt>. + # + # === Matching Elements + # + # Use the #match method to determine if an element matches the selector. + # + # For simple selectors, the method returns an array with that element, + # or +nil+ if the element does not match. For complex selectors (see below) + # the method returns an array with all matched elements, of +nil+ if no + # match found. + # + # For example: + # if selector.match(element) + # puts "Element is a login form" + # end + # + # === Selecting Elements + # + # Use the #select method to select all matching elements starting with + # one element and going through all children in depth-first order. + # + # This method returns an array of all matching elements, an empty array + # if no match is found + # + # For example: + # selector = HTML::Selector.new "input[type=text]" + # matches = selector.select(element) + # matches.each do |match| + # puts "Found text field with name #{match.attributes['name']}" + # end + # + # === Expressions + # + # Selectors can match elements using any of the following criteria: + # * <tt>name</tt> -- Match an element based on its name (tag name). + # For example, <tt>p</tt> to match a paragraph. You can use <tt>*</tt> + # to match any element. + # * <tt>#</tt><tt>id</tt> -- Match an element based on its identifier (the + # <tt>id</tt> attribute). For example, <tt>#</tt><tt>page</tt>. + # * <tt>.class</tt> -- Match an element based on its class name, all + # class names if more than one specified. + # * <tt>[attr]</tt> -- Match an element that has the specified attribute. + # * <tt>[attr=value]</tt> -- Match an element that has the specified + # attribute and value. (More operators are supported see below) + # * <tt>:pseudo-class</tt> -- Match an element based on a pseudo class, + # such as <tt>:nth-child</tt> and <tt>:empty</tt>. + # * <tt>:not(expr)</tt> -- Match an element that does not match the + # negation expression. + # + # When using a combination of the above, the element name comes first + # followed by identifier, class names, attributes, pseudo classes and + # negation in any order. Do not seprate these parts with spaces! + # Space separation is used for descendant selectors. + # + # For example: + # selector = HTML::Selector.new "form.login[action=/login]" + # The matched element must be of type +form+ and have the class +login+. + # It may have other classes, but the class +login+ is required to match. + # It must also have an attribute called +action+ with the value + # <tt>/login</tt>. + # + # This selector will match the following element: + # <form class="login form" method="post" action="/login"> + # but will not match the element: + # <form method="post" action="/logout"> + # + # === Attribute Values + # + # Several operators are supported for matching attributes: + # * <tt>name</tt> -- The element must have an attribute with that name. + # * <tt>name=value</tt> -- The element must have an attribute with that + # name and value. + # * <tt>name^=value</tt> -- The attribute value must start with the + # specified value. + # * <tt>name$=value</tt> -- The attribute value must end with the + # specified value. + # * <tt>name*=value</tt> -- The attribute value must contain the + # specified value. + # * <tt>name~=word</tt> -- The attribute value must contain the specified + # word (space separated). + # * <tt>name|=word</tt> -- The attribute value must start with specified + # word. + # + # For example, the following two selectors match the same element: + # #my_id + # [id=my_id] + # and so do the following two selectors: + # .my_class + # [class~=my_class] + # + # === Alternatives, siblings, children + # + # Complex selectors use a combination of expressions to match elements: + # * <tt>expr1 expr2</tt> -- Match any element against the second expression + # if it has some parent element that matches the first expression. + # * <tt>expr1 > expr2</tt> -- Match any element against the second expression + # if it is the child of an element that matches the first expression. + # * <tt>expr1 + expr2</tt> -- Match any element against the second expression + # if it immediately follows an element that matches the first expression. + # * <tt>expr1 ~ expr2</tt> -- Match any element against the second expression + # that comes after an element that matches the first expression. + # * <tt>expr1, expr2</tt> -- Match any element against the first expression, + # or against the second expression. + # + # Since children and sibling selectors may match more than one element given + # the first element, the #match method may return more than one match. + # + # === Pseudo classes + # + # Pseudo classes were introduced in CSS 3. They are most often used to select + # elements in a given position: + # * <tt>:root</tt> -- Match the element only if it is the root element + # (no parent element). + # * <tt>:empty</tt> -- Match the element only if it has no child elements, + # and no text content. + # * <tt>:only-child</tt> -- Match the element if it is the only child (element) + # of its parent element. + # * <tt>:only-of-type</tt> -- Match the element if it is the only child (element) + # of its parent element and its type. + # * <tt>:first-child</tt> -- Match the element if it is the first child (element) + # of its parent element. + # * <tt>:first-of-type</tt> -- Match the element if it is the first child (element) + # of its parent element of its type. + # * <tt>:last-child</tt> -- Match the element if it is the last child (element) + # of its parent element. + # * <tt>:last-of-type</tt> -- Match the element if it is the last child (element) + # of its parent element of its type. + # * <tt>:nth-child(b)</tt> -- Match the element if it is the b-th child (element) + # of its parent element. The value <tt>b</tt> specifies its index, starting with 1. + # * <tt>:nth-child(an+b)</tt> -- Match the element if it is the b-th child (element) + # in each group of <tt>a</tt> child elements of its parent element. + # * <tt>:nth-child(-an+b)</tt> -- Match the element if it is the first child (element) + # in each group of <tt>a</tt> child elements, up to the first <tt>b</tt> child + # elements of its parent element. + # * <tt>:nth-child(odd)</tt> -- Match element in the odd position (i.e. first, third). + # Same as <tt>:nth-child(2n+1)</tt>. + # * <tt>:nth-child(even)</tt> -- Match element in the even position (i.e. second, + # fourth). Same as <tt>:nth-child(2n+2)</tt>. + # * <tt>:nth-of-type(..)</tt> -- As above, but only counts elements of its type. + # * <tt>:nth-last-child(..)</tt> -- As above, but counts from the last child. + # * <tt>:nth-last-of-type(..)</tt> -- As above, but counts from the last child and + # only elements of its type. + # * <tt>:not(selector)</tt> -- Match the element only if the element does not + # match the simple selector. + # + # As you can see, <tt>:nth-child<tt> pseudo class and its varient can get quite + # tricky and the CSS specification doesn't do a much better job explaining it. + # But after reading the examples and trying a few combinations, it's easy to + # figure out. + # + # For example: + # table tr:nth-child(odd) + # Selects every second row in the table starting with the first one. + # + # div p:nth-child(4) + # Selects the fourth paragraph in the +div+, but not if the +div+ contains + # other elements, since those are also counted. + # + # div p:nth-of-type(4) + # Selects the fourth paragraph in the +div+, counting only paragraphs, and + # ignoring all other elements. + # + # div p:nth-of-type(-n+4) + # Selects the first four paragraphs, ignoring all others. + # + # And you can always select an element that matches one set of rules but + # not another using <tt>:not</tt>. For example: + # p:not(.post) + # Matches all paragraphs that do not have the class <tt>.post</tt>. + # + # === Substitution Values + # + # You can use substitution with identifiers, class names and element values. + # A substitution takes the form of a question mark (<tt>?</tt>) and uses the + # next value in the argument list following the CSS expression. + # + # The substitution value may be a string or a regular expression. All other + # values are converted to strings. + # + # For example: + # selector = HTML::Selector.new "#?", /^\d+$/ + # matches any element whose identifier consists of one or more digits. + # + # See http://www.w3.org/TR/css3-selectors/ + class Selector + + + # An invalid selector. + class InvalidSelectorError < StandardError ; end + + + class << self + + # :call-seq: + # Selector.for_class(cls) => selector + # + # Creates a new selector for the given class name. + def for_class(cls) + self.new([".?", cls]) + end + + + # :call-seq: + # Selector.for_id(id) => selector + # + # Creates a new selector for the given id. + def for_id(id) + self.new(["#?", id]) + end + + end + + + # :call-seq: + # Selector.new(string, [values ...]) => selector + # + # Creates a new selector from a CSS 2 selector expression. + # + # The first argument is the selector expression. All other arguments + # are used for value substitution. + # + # Throws InvalidSelectorError is the selector expression is invalid. + def initialize(selector, *values) + raise ArgumentError, "CSS expression cannot be empty" if selector.empty? + @source = "" + values = values[0] if values.size == 1 && values[0].is_a?(Array) + # We need a copy to determine if we failed to parse, and also + # preserve the original pass by-ref statement. + statement = selector.strip.dup + # Create a simple selector, along with negation. + simple_selector(statement, values).each { |name, value| instance_variable_set("@#{name}", value) } + + # Alternative selector. + if statement.sub!(/^\s*,\s*/, "") + second = Selector.new(statement, values) + (@alternates ||= []) << second + # If there are alternate selectors, we group them in the top selector. + if alternates = second.instance_variable_get(:@alternates) + second.instance_variable_set(:@alternates, nil) + @alternates.concat alternates + end + @source << " , " << second.to_s + # Sibling selector: create a dependency into second selector that will + # match element immediately following this one. + elsif statement.sub!(/^\s*\+\s*/, "") + second = next_selector(statement, values) + @depends = lambda do |element, first| + if element = next_element(element) + second.match(element, first) + end + end + @source << " + " << second.to_s + # Adjacent selector: create a dependency into second selector that will + # match all elements following this one. + elsif statement.sub!(/^\s*~\s*/, "") + second = next_selector(statement, values) + @depends = lambda do |element, first| + matches = [] + while element = next_element(element) + if subset = second.match(element, first) + if first && !subset.empty? + matches << subset.first + break + else + matches.concat subset + end + end + end + matches.empty? ? nil : matches + end + @source << " ~ " << second.to_s + # Child selector: create a dependency into second selector that will + # match a child element of this one. + elsif statement.sub!(/^\s*>\s*/, "") + second = next_selector(statement, values) + @depends = lambda do |element, first| + matches = [] + element.children.each do |child| + if child.tag? && subset = second.match(child, first) + if first && !subset.empty? + matches << subset.first + break + else + matches.concat subset + end + end + end + matches.empty? ? nil : matches + end + @source << " > " << second.to_s + # Descendant selector: create a dependency into second selector that + # will match all descendant elements of this one. Note, + elsif statement =~ /^\s+\S+/ && statement != selector + second = next_selector(statement, values) + @depends = lambda do |element, first| + matches = [] + stack = element.children.reverse + while node = stack.pop + next unless node.tag? + if subset = second.match(node, first) + if first && !subset.empty? + matches << subset.first + break + else + matches.concat subset + end + elsif children = node.children + stack.concat children.reverse + end + end + matches.empty? ? nil : matches + end + @source << " " << second.to_s + else + # The last selector is where we check that we parsed + # all the parts. + unless statement.empty? || statement.strip.empty? + raise ArgumentError, "Invalid selector: #{statement}" + end + end + end + + + # :call-seq: + # match(element, first?) => array or nil + # + # Matches an element against the selector. + # + # For a simple selector this method returns an array with the + # element if the element matches, nil otherwise. + # + # For a complex selector (sibling and descendant) this method + # returns an array with all matching elements, nil if no match is + # found. + # + # Use +first_only=true+ if you are only interested in the first element. + # + # For example: + # if selector.match(element) + # puts "Element is a login form" + # end + def match(element, first_only = false) + # Match element if no element name or element name same as element name + if matched = (!@tag_name || @tag_name == element.name) + # No match if one of the attribute matches failed + for attr in @attributes + if element.attributes[attr[0]] !~ attr[1] + matched = false + break + end + end + end + + # Pseudo class matches (nth-child, empty, etc). + if matched + for pseudo in @pseudo + unless pseudo.call(element) + matched = false + break + end + end + end + + # Negation. Same rules as above, but we fail if a match is made. + if matched && @negation + for negation in @negation + if negation[:tag_name] == element.name + matched = false + else + for attr in negation[:attributes] + if element.attributes[attr[0]] =~ attr[1] + matched = false + break + end + end + end + if matched + for pseudo in negation[:pseudo] + if pseudo.call(element) + matched = false + break + end + end + end + break unless matched + end + end + + # If element matched but depends on another element (child, + # sibling, etc), apply the dependent matches instead. + if matched && @depends + matches = @depends.call(element, first_only) + else + matches = matched ? [element] : nil + end + + # If this selector is part of the group, try all the alternative + # selectors (unless first_only). + if @alternates && (!first_only || !matches) + @alternates.each do |alternate| + break if matches && first_only + if subset = alternate.match(element, first_only) + if matches + matches.concat subset + else + matches = subset + end + end + end + end + + matches + end + + + # :call-seq: + # select(root) => array + # + # Selects and returns an array with all matching elements, beginning + # with one node and traversing through all children depth-first. + # Returns an empty array if no match is found. + # + # The root node may be any element in the document, or the document + # itself. + # + # For example: + # selector = HTML::Selector.new "input[type=text]" + # matches = selector.select(element) + # matches.each do |match| + # puts "Found text field with name #{match.attributes['name']}" + # end + def select(root) + matches = [] + stack = [root] + while node = stack.pop + if node.tag? && subset = match(node, false) + subset.each do |match| + matches << match unless matches.any? { |item| item.equal?(match) } + end + elsif children = node.children + stack.concat children.reverse + end + end + matches + end + + + # Similar to #select but returns the first matching element. Returns +nil+ + # if no element matches the selector. + def select_first(root) + stack = [root] + while node = stack.pop + if node.tag? && subset = match(node, true) + return subset.first if !subset.empty? + elsif children = node.children + stack.concat children.reverse + end + end + nil + end + + + def to_s #:nodoc: + @source + end + + + # Return the next element after this one. Skips sibling text nodes. + # + # With the +name+ argument, returns the next element with that name, + # skipping other sibling elements. + def next_element(element, name = nil) + if siblings = element.parent.children + found = false + siblings.each do |node| + if node.equal?(element) + found = true + elsif found && node.tag? + return node if (name.nil? || node.name == name) + end + end + end + nil + end + + + protected + + + # Creates a simple selector given the statement and array of + # substitution values. + # + # Returns a hash with the values +tag_name+, +attributes+, + # +pseudo+ (classes) and +negation+. + # + # Called the first time with +can_negate+ true to allow + # negation. Called a second time with false since negation + # cannot be negated. + def simple_selector(statement, values, can_negate = true) + tag_name = nil + attributes = [] + pseudo = [] + negation = [] + + # Element name. (Note that in negation, this can come at + # any order, but for simplicity we allow if only first). + statement.sub!(/^(\*|[[:alpha:]][\w\-]*)/) do |match| + match.strip! + tag_name = match.downcase unless match == "*" + @source << match + "" # Remove + end + + # Get identifier, class, attribute name, pseudo or negation. + while true + # Element identifier. + next if statement.sub!(/^#(\?|[\w\-]+)/) do |match| + id = $1 + if id == "?" + id = values.shift + end + @source << "##{id}" + id = Regexp.new("^#{Regexp.escape(id.to_s)}$") unless id.is_a?(Regexp) + attributes << ["id", id] + "" # Remove + end + + # Class name. + next if statement.sub!(/^\.([\w\-]+)/) do |match| + class_name = $1 + @source << ".#{class_name}" + class_name = Regexp.new("(^|\s)#{Regexp.escape(class_name)}($|\s)") unless class_name.is_a?(Regexp) + attributes << ["class", class_name] + "" # Remove + end + + # Attribute value. + next if statement.sub!(/^\[\s*([[:alpha:]][\w\-]*)\s*((?:[~|^$*])?=)?\s*('[^']*'|"[^*]"|[^\]]*)\s*\]/) do |match| + name, equality, value = $1, $2, $3 + if value == "?" + value = values.shift + else + # Handle single and double quotes. + value.strip! + if (value[0] == ?" || value[0] == ?') && value[0] == value[-1] + value = value[1..-2] + end + end + @source << "[#{name}#{equality}'#{value}']" + attributes << [name.downcase.strip, attribute_match(equality, value)] + "" # Remove + end + + # Root element only. + next if statement.sub!(/^:root/) do |match| + pseudo << lambda do |element| + element.parent.nil? || !element.parent.tag? + end + @source << ":root" + "" # Remove + end + + # Nth-child including last and of-type. + next if statement.sub!(/^:nth-(last-)?(child|of-type)\((odd|even|(\d+|\?)|(-?\d*|\?)?n([+\-]\d+|\?)?)\)/) do |match| + reverse = $1 == "last-" + of_type = $2 == "of-type" + @source << ":nth-#{$1}#{$2}(" + case $3 + when "odd" + pseudo << nth_child(2, 1, of_type, reverse) + @source << "odd)" + when "even" + pseudo << nth_child(2, 2, of_type, reverse) + @source << "even)" + when /^(\d+|\?)$/ # b only + b = ($1 == "?" ? values.shift : $1).to_i + pseudo << nth_child(0, b, of_type, reverse) + @source << "#{b})" + when /^(-?\d*|\?)?n([+\-]\d+|\?)?$/ + a = ($1 == "?" ? values.shift : + $1 == "" ? 1 : $1 == "-" ? -1 : $1).to_i + b = ($2 == "?" ? values.shift : $2).to_i + pseudo << nth_child(a, b, of_type, reverse) + @source << (b >= 0 ? "#{a}n+#{b})" : "#{a}n#{b})") + else + raise ArgumentError, "Invalid nth-child #{match}" + end + "" # Remove + end + # First/last child (of type). + next if statement.sub!(/^:(first|last)-(child|of-type)/) do |match| + reverse = $1 == "last" + of_type = $2 == "of-type" + pseudo << nth_child(0, 1, of_type, reverse) + @source << ":#{$1}-#{$2}" + "" # Remove + end + # Only child (of type). + next if statement.sub!(/^:only-(child|of-type)/) do |match| + of_type = $1 == "of-type" + pseudo << only_child(of_type) + @source << ":only-#{$1}" + "" # Remove + end + + # Empty: no child elements or meaningful content (whitespaces + # are ignored). + next if statement.sub!(/^:empty/) do |match| + pseudo << lambda do |element| + empty = true + for child in element.children + if child.tag? || !child.content.strip.empty? + empty = false + break + end + end + empty + end + @source << ":empty" + "" # Remove + end + # Content: match the text content of the element, stripping + # leading and trailing spaces. + next if statement.sub!(/^:content\(\s*(\?|'[^']*'|"[^"]*"|[^)]*)\s*\)/) do |match| + content = $1 + if content == "?" + content = values.shift + elsif (content[0] == ?" || content[0] == ?') && content[0] == content[-1] + content = content[1..-2] + end + @source << ":content('#{content}')" + content = Regexp.new("^#{Regexp.escape(content.to_s)}$") unless content.is_a?(Regexp) + pseudo << lambda do |element| + text = "" + for child in element.children + unless child.tag? + text << child.content + end + end + text.strip =~ content + end + "" # Remove + end + + # Negation. Create another simple selector to handle it. + if statement.sub!(/^:not\(\s*/, "") + raise ArgumentError, "Double negatives are not missing feature" unless can_negate + @source << ":not(" + negation << simple_selector(statement, values, false) + raise ArgumentError, "Negation not closed" unless statement.sub!(/^\s*\)/, "") + @source << ")" + next + end + + # No match: moving on. + break + end + + # Return hash. The keys are mapped to instance variables. + {:tag_name=>tag_name, :attributes=>attributes, :pseudo=>pseudo, :negation=>negation} + end + + + # Create a regular expression to match an attribute value based + # on the equality operator (=, ^=, |=, etc). + def attribute_match(equality, value) + regexp = value.is_a?(Regexp) ? value : Regexp.escape(value.to_s) + case equality + when "=" then + # Match the attribute value in full + Regexp.new("^#{regexp}$") + when "~=" then + # Match a space-separated word within the attribute value + Regexp.new("(^|\s)#{regexp}($|\s)") + when "^=" + # Match the beginning of the attribute value + Regexp.new("^#{regexp}") + when "$=" + # Match the end of the attribute value + Regexp.new("#{regexp}$") + when "*=" + # Match substring of the attribute value + regexp.is_a?(Regexp) ? regexp : Regexp.new(regexp) + when "|=" then + # Match the first space-separated item of the attribute value + Regexp.new("^#{regexp}($|\s)") + else + raise InvalidSelectorError, "Invalid operation/value" unless value.empty? + # Match all attributes values (existence check) + // + end + end + + + # Returns a lambda that can match an element against the nth-child + # pseudo class, given the following arguments: + # * +a+ -- Value of a part. + # * +b+ -- Value of b part. + # * +of_type+ -- True to test only elements of this type (of-type). + # * +reverse+ -- True to count in reverse order (last-). + def nth_child(a, b, of_type, reverse) + # a = 0 means select at index b, if b = 0 nothing selected + return lambda { |element| false } if a == 0 && b == 0 + # a < 0 and b < 0 will never match against an index + return lambda { |element| false } if a < 0 && b < 0 + b = a + b + 1 if b < 0 # b < 0 just picks last element from each group + b -= 1 unless b == 0 # b == 0 is same as b == 1, otherwise zero based + lambda do |element| + # Element must be inside parent element. + return false unless element.parent && element.parent.tag? + index = 0 + # Get siblings, reverse if counting from last. + siblings = element.parent.children + siblings = siblings.reverse if reverse + # Match element name if of-type, otherwise ignore name. + name = of_type ? element.name : nil + found = false + for child in siblings + # Skip text nodes/comments. + if child.tag? && (name == nil || child.name == name) + if a == 0 + # Shortcut when a == 0 no need to go past count + if index == b + found = child.equal?(element) + break + end + elsif a < 0 + # Only look for first b elements + break if index > b + if child.equal?(element) + found = (index % a) == 0 + break + end + else + # Otherwise, break if child found and count == an+b + if child.equal?(element) + found = (index % a) == b + break + end + end + index += 1 + end + end + found + end + end + + + # Creates a only child lambda. Pass +of-type+ to only look at + # elements of its type. + def only_child(of_type) + lambda do |element| + # Element must be inside parent element. + return false unless element.parent && element.parent.tag? + name = of_type ? element.name : nil + other = false + for child in element.parent.children + # Skip text nodes/comments. + if child.tag? && (name == nil || child.name == name) + unless child.equal?(element) + other = true + break + end + end + end + !other + end + end + + + # Called to create a dependent selector (sibling, descendant, etc). + # Passes the remainder of the statement that will be reduced to zero + # eventually, and array of substitution values. + # + # This method is called from four places, so it helps to put it here + # for resue. The only logic deals with the need to detect comma + # separators (alternate) and apply them to the selector group of the + # top selector. + def next_selector(statement, values) + second = Selector.new(statement, values) + # If there are alternate selectors, we group them in the top selector. + if alternates = second.instance_variable_get(:@alternates) + second.instance_variable_set(:@alternates, nil) + (@alternates ||= []).concat alternates + end + second + end + + end + + + # See HTML::Selector.new + def self.selector(statement, *values) + Selector.new(statement, *values) + end + + + class Tag + + def select(selector, *values) + selector = HTML::Selector.new(selector, values) + selector.select(self) + end + + end + +end diff --git a/actionpack/test/controller/assert_select_test.rb b/actionpack/test/controller/assert_select_test.rb new file mode 100644 index 0000000000..2117c852c0 --- /dev/null +++ b/actionpack/test/controller/assert_select_test.rb @@ -0,0 +1,490 @@ +#-- +# Copyright (c) 2006 Assaf Arkin (http://labnotes.org) +# Under MIT and/or CC By license. +#++ + +require File.dirname(__FILE__) + '/../abstract_unit' +require File.dirname(__FILE__) + '/fake_controllers' +require "action_mailer" + +class AssertSelectTest < Test::Unit::TestCase + class AssertSelectController < ActionController::Base + def response_with=(content) + @content = content + end + + def response_with(&block) + @update = block + end + + def html() + render :text=>@content, :layout=>false, :content_type=>Mime::HTML + @content = nil + end + + def rjs() + render :update do |page| + @update.call page + end + @update = nil + end + + def xml() + render :text=>@content, :layout=>false, :content_type=>Mime::XML + @content = nil + end + + def rescue_action(e) + raise e + end + end + + class AssertSelectMailer < ActionMailer::Base + def test(html) + recipients "test <test@test.host>" + from "test@test.host" + subject "Test e-mail" + part :content_type=>"text/html", :body=>html + end + end + + AssertionFailedError = Test::Unit::AssertionFailedError + + def setup + @controller = AssertSelectController.new + @request = ActionController::TestRequest.new + @response = ActionController::TestResponse.new + ActionMailer::Base.delivery_method = :test + ActionMailer::Base.perform_deliveries = true + ActionMailer::Base.deliveries = [] + end + + + def teardown + ActionMailer::Base.deliveries.clear + end + + + # + # Test assert select. + # + + def test_assert_select + render_html %Q{<div id="1"></div><div id="2"></div>} + assert_select "div", 2 + assert_raises(AssertionFailedError) { assert_select "div", 3 } + assert_raises(AssertionFailedError){ assert_select "p" } + end + + + def test_equality_true_false + render_html %Q{<div id="1"></div><div id="2"></div>} + assert_nothing_raised { assert_select "div" } + assert_raises(AssertionFailedError) { assert_select "p" } + assert_nothing_raised { assert_select "div", true } + assert_raises(AssertionFailedError) { assert_select "p", true } + assert_raises(AssertionFailedError) { assert_select "div", false } + assert_nothing_raised { assert_select "p", false } + end + + + def test_equality_string_and_regexp + render_html %Q{<div id="1">foo</div><div id="2">foo</div>} + assert_nothing_raised { assert_select "div", "foo" } + assert_raises(AssertionFailedError) { assert_select "div", "bar" } + assert_nothing_raised { assert_select "div", :text=>"foo" } + assert_raises(AssertionFailedError) { assert_select "div", :text=>"bar" } + assert_nothing_raised { assert_select "div", /(foo|bar)/ } + assert_raises(AssertionFailedError) { assert_select "div", /foobar/ } + assert_nothing_raised { assert_select "div", :text=>/(foo|bar)/ } + assert_raises(AssertionFailedError) { assert_select "div", :text=>/foobar/ } + assert_raises(AssertionFailedError) { assert_select "p", :text=>/foobar/ } + end + + + def test_equality_of_html + render_html %Q{<p>\n<em>"This is <strong>not</strong> a big problem,"</em> he said.\n</p>} + text = "\"This is not a big problem,\" he said." + html = "<em>\"This is <strong>not</strong> a big problem,\"</em> he said." + assert_nothing_raised { assert_select "p", text } + assert_raises(AssertionFailedError) { assert_select "p", html } + assert_nothing_raised { assert_select "p", :html=>html } + assert_raises(AssertionFailedError) { assert_select "p", :html=>text } + # No stripping for pre. + render_html %Q{<pre>\n<em>"This is <strong>not</strong> a big problem,"</em> he said.\n</pre>} + text = "\n\"This is not a big problem,\" he said.\n" + html = "\n<em>\"This is <strong>not</strong> a big problem,\"</em> he said.\n" + assert_nothing_raised { assert_select "pre", text } + assert_raises(AssertionFailedError) { assert_select "pre", html } + assert_nothing_raised { assert_select "pre", :html=>html } + assert_raises(AssertionFailedError) { assert_select "pre", :html=>text } + end + + + def test_equality_of_instances + render_html %Q{<div id="1">foo</div><div id="2">foo</div>} + assert_nothing_raised { assert_select "div", 2 } + assert_raises(AssertionFailedError) { assert_select "div", 3 } + assert_nothing_raised { assert_select "div", 1..2 } + assert_raises(AssertionFailedError) { assert_select "div", 3..4 } + assert_nothing_raised { assert_select "div", :count=>2 } + assert_raises(AssertionFailedError) { assert_select "div", :count=>3 } + assert_nothing_raised { assert_select "div", :minimum=>1 } + assert_nothing_raised { assert_select "div", :minimum=>2 } + assert_raises(AssertionFailedError) { assert_select "div", :minimum=>3 } + assert_nothing_raised { assert_select "div", :maximum=>2 } + assert_nothing_raised { assert_select "div", :maximum=>3 } + assert_raises(AssertionFailedError) { assert_select "div", :maximum=>1 } + assert_nothing_raised { assert_select "div", :minimum=>1, :maximum=>2 } + assert_raises(AssertionFailedError) { assert_select "div", :minimum=>3, :maximum=>4 } + end + + + def test_substitution_values + render_html %Q{<div id="1">foo</div><div id="2">foo</div>} + assert_select "div#?", /\d+/ do |elements| + assert_equal 2, elements.size + end + assert_select "div" do + assert_select "div#?", /\d+/ do |elements| + assert_equal 2, elements.size + assert_select "#1" + assert_select "#2" + end + end + end + + + def test_nested_assert_select + render_html %Q{<div id="1">foo</div><div id="2">foo</div>} + assert_select "div" do |elements| + assert_equal 2, elements.size + assert_select elements[0], "#1" + assert_select elements[1], "#2" + end + assert_select "div" do + assert_select "div" do |elements| + assert_equal 2, elements.size + # Testing in a group is one thing + assert_select "#1,#2" + # Testing individually is another. + assert_select "#1" + assert_select "#2" + assert_select "#3", false + end + end + end + + + def test_assert_select_from_rjs + render_rjs do |page| + page.replace_html "test", "<div id=\"1\">foo</div>\n<div id=\"2\">foo</div>" + end + assert_select "div" do |elements| + assert elements.size == 2 + assert_select "#1" + assert_select "#2" + end + assert_select "div#?", /\d+/ do |elements| + assert_select "#1" + assert_select "#2" + end + # With multiple results. + render_rjs do |page| + page.replace_html "test", "<div id=\"1\">foo</div>" + page.replace_html "test2", "<div id=\"2\">foo</div>" + end + assert_select "div" do |elements| + assert elements.size == 2 + assert_select "#1" + assert_select "#2" + end + end + + + # + # Test css_select. + # + + + def test_css_select + render_html %Q{<div id="1"></div><div id="2"></div>} + assert 2, css_select("div").size + assert 0, css_select("p").size + end + + + def test_nested_css_select + render_html %Q{<div id="1">foo</div><div id="2">foo</div>} + assert_select "div#?", /\d+/ do |elements| + assert_equal 1, css_select(elements[0], "div").size + assert_equal 1, css_select(elements[1], "div").size + end + assert_select "div" do + assert_equal 2, css_select("div").size + css_select("div").each do |element| + # Testing as a group is one thing + assert !css_select("#1,#2").empty? + # Testing individually is another + assert !css_select("#1").empty? + assert !css_select("#2").empty? + end + end + end + + + def test_css_select_from_rjs + # With one result. + render_rjs do |page| + page.replace_html "test", "<div id=\"1\">foo</div>\n<div id=\"2\">foo</div>" + end + assert_equal 2, css_select("div").size + assert_equal 1, css_select("#1").size + assert_equal 1, css_select("#2").size + # With multiple results. + render_rjs do |page| + page.replace_html "test", "<div id=\"1\">foo</div>" + page.replace_html "test2", "<div id=\"2\">foo</div>" + end + assert_equal 2, css_select("div").size + assert_equal 1, css_select("#1").size + assert_equal 1, css_select("#2").size + end + + + # + # Test assert_select_rjs. + # + + + def test_assert_select_rjs + # Test that we can pick up all statements in the result. + render_rjs do |page| + page.replace "test", "<div id=\"1\">foo</div>" + page.replace_html "test2", "<div id=\"2\">foo</div>" + page.insert_html :top, "test3", "<div id=\"3\">foo</div>" + end + found = false + assert_select_rjs do + assert_select "#1" + assert_select "#2" + assert_select "#3" + found = true + end + assert found + # Test that we fail if there is nothing to pick. + render_rjs do |page| + end + assert_raises(AssertionFailedError) { assert_select_rjs } + end + + + def test_assert_select_rjs_with_id + # Test that we can pick up all statements in the result. + render_rjs do |page| + page.replace "test1", "<div id=\"1\">foo</div>" + page.replace_html "test2", "<div id=\"2\">foo</div>" + page.insert_html :top, "test3", "<div id=\"3\">foo</div>" + end + assert_select_rjs "test1" do + assert_select "div", 1 + assert_select "#1" + end + assert_select_rjs "test2" do + assert_select "div", 1 + assert_select "#2" + end + assert_select_rjs "test3" do + assert_select "div", 1 + assert_select "#3" + end + assert_raises(AssertionFailedError) { assert_select_rjs "test4" } + end + + + def test_assert_select_rjs_for_replace + render_rjs do |page| + page.replace "test1", "<div id=\"1\">foo</div>" + page.replace_html "test2", "<div id=\"2\">foo</div>" + page.insert_html :top, "test3", "<div id=\"3\">foo</div>" + end + # Replace. + assert_select_rjs :replace do + assert_select "div", 1 + assert_select "#1" + end + assert_select_rjs :replace, "test1" do + assert_select "div", 1 + assert_select "#1" + end + assert_raises(AssertionFailedError) { assert_select_rjs :replace, "test2" } + # Replace HTML. + assert_select_rjs :replace_html do + assert_select "div", 1 + assert_select "#2" + end + assert_select_rjs :replace_html, "test2" do + assert_select "div", 1 + assert_select "#2" + end + assert_raises(AssertionFailedError) { assert_select_rjs :replace_html, "test1" } + end + + + def test_assert_select_rjs_for_insert + render_rjs do |page| + page.replace "test1", "<div id=\"1\">foo</div>" + page.replace_html "test2", "<div id=\"2\">foo</div>" + page.insert_html :top, "test3", "<div id=\"3\">foo</div>" + end + # Non-positioned. + assert_select_rjs :insert_html do + assert_select "div", 1 + assert_select "#3" + end + assert_select_rjs :insert_html, "test3" do + assert_select "div", 1 + assert_select "#3" + end + assert_raises(AssertionFailedError) { assert_select_rjs :insert_html, "test1" } + # Positioned. + render_rjs do |page| + page.insert_html :top, "test1", "<div id=\"1\">foo</div>" + page.insert_html :bottom, "test2", "<div id=\"2\">foo</div>" + page.insert_html :before, "test3", "<div id=\"3\">foo</div>" + page.insert_html :after, "test4", "<div id=\"4\">foo</div>" + end + assert_select_rjs :insert, :top do + assert_select "div", 1 + assert_select "#1" + end + assert_select_rjs :insert, :bottom do + assert_select "div", 1 + assert_select "#2" + end + assert_select_rjs :insert, :before do + assert_select "div", 1 + assert_select "#3" + end + assert_select_rjs :insert, :after do + assert_select "div", 1 + assert_select "#4" + end + assert_select_rjs :insert_html do + assert_select "div", 4 + end + end + + + def test_nested_assert_select_rjs + # Simple selection from a single result. + render_rjs do |page| + page.replace_html "test", "<div id=\"1\">foo</div>\n<div id=\"2\">foo</div>" + end + assert_select_rjs "test" do |elements| + assert_equal 2, elements.size + assert_select "#1" + assert_select "#2" + end + # Deal with two results. + render_rjs do |page| + page.replace_html "test", "<div id=\"1\">foo</div>" + page.replace_html "test2", "<div id=\"2\">foo</div>" + end + assert_select_rjs "test" do |elements| + assert_equal 1, elements.size + assert_select "#1" + end + assert_select_rjs "test2" do |elements| + assert_equal 1, elements.size + assert_select "#2" + end + end + + + def test_feed_item_encoded + render_xml <<-EOF +<rss version="2.0"> + <channel> + <item> + <description> + <![CDATA[ + <p>Test 1</p> + ]]> + </description> + </item> + <item> + <description> + <![CDATA[ + <p>Test 2</p> + ]]> + </description> + </item> + </channel> +</rss> +EOF + assert_select "channel item description" do + # Test element regardless of wrapper. + assert_select_encoded do + assert_select "p", :count=>2, :text=>/Test/ + end + # Test through encoded wrapper. + assert_select_encoded do + assert_select "encoded p", :count=>2, :text=>/Test/ + end + # Use :root instead (recommended) + assert_select_encoded do + assert_select ":root p", :count=>2, :text=>/Test/ + end + # Test individually. + assert_select "description" do |elements| + assert_select_encoded elements[0] do + assert_select "p", "Test 1" + end + assert_select_encoded elements[1] do + assert_select "p", "Test 2" + end + end + end + + # Test that we only un-encode element itself. + assert_select "channel item" do + assert_select_encoded do + assert_select "p", 0 + end + end + end + + + # + # Test assert_select_email + # + + def test_assert_select_email + assert_raises(AssertionFailedError) { assert_select_email {} } + AssertSelectMailer.deliver_test "<div><p>foo</p><p>bar</p></div>" + assert_select_email do + assert_select "div:root" do + assert_select "p:first-child", "foo" + assert_select "p:last-child", "bar" + end + end + end + + + protected + def render_html(html) + @controller.response_with = html + get :html + end + + def render_rjs(&block) + @controller.response_with &block + get :rjs + end + + def render_xml(xml) + @controller.response_with = xml + get :xml + end +end
\ No newline at end of file diff --git a/actionpack/test/controller/selector_test.rb b/actionpack/test/controller/selector_test.rb new file mode 100644 index 0000000000..d004290097 --- /dev/null +++ b/actionpack/test/controller/selector_test.rb @@ -0,0 +1,628 @@ +#-- +# Copyright (c) 2006 Assaf Arkin (http://labnotes.org) +# Under MIT and/or CC By license. +#++ + +require File.dirname(__FILE__) + '/../abstract_unit' +require File.dirname(__FILE__) + '/fake_controllers' + +class SelectorTest < Test::Unit::TestCase + # + # Basic selector: element, id, class, attributes. + # + + def test_element + parse(%Q{<div id="1"></div><p></p><div id="2"></div>}) + # Match element by name. + select("div") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "2", @matches[1].attributes["id"] + # Not case sensitive. + select("DIV") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "2", @matches[1].attributes["id"] + # Universal match (all elements). + select("*") + assert_equal 3, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal nil, @matches[1].attributes["id"] + assert_equal "2", @matches[2].attributes["id"] + end + + + def test_identifier + parse(%Q{<div id="1"></div><p></p><div id="2"></div>}) + # Match element by ID. + select("div#1") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + # Match element by ID, substitute value. + select("div#?", 2) + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + # Element name does not match ID. + select("p#?", 2) + assert_equal 0, @matches.size + # Use regular expression. + select("#?", /\d/) + assert_equal 2, @matches.size + end + + + def test_class_name + parse(%Q{<div id="1" class=" foo "></div><p id="2" class=" foo bar "></p><div id="3" class="bar"></div>}) + # Match element with specified class. + select("div.foo") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + # Match any element with specified class. + select("*.foo") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "2", @matches[1].attributes["id"] + # Match elements with other class. + select("*.bar") + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + # Match only element with both class names. + select("*.bar.foo") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + end + + + def test_attribute + parse(%Q{<div id="1"></div><p id="2" title="" bar="foo"></p><div id="3" title="foo"></div>}) + # Match element with attribute. + select("div[title]") + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + # Match any element with attribute. + select("*[title]") + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + # Match alement with attribute value. + select("*[title=foo]") + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + # Match alement with attribute and attribute value. + select("[bar=foo][title]") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + # Not case sensitive. + select("[BAR=foo][TiTle]") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + end + + + def test_attribute_quoted + parse(%Q{<div id="1" title="foo"></div><div id="2" title="bar"></div><div id="3" title=" bar "></div>}) + # Match without quotes. + select("[title = bar]") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + # Match with single quotes. + select("[title = 'bar' ]") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + # Match with double quotes. + select("[title = \"bar\" ]") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + # Match with spaces. + select("[title = \" bar \" ]") + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + end + + + def test_attribute_equality + parse(%Q{<div id="1" title="foo bar"></div><div id="2" title="barbaz"></div>}) + # Match (fail) complete value. + select("[title=bar]") + assert_equal 0, @matches.size + # Match space-separate word. + select("[title~=foo]") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + select("[title~=bar]") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + # Match beginning of value. + select("[title^=ba]") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + # Match end of value. + select("[title$=ar]") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + # Match text in value. + select("[title*=bar]") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "2", @matches[1].attributes["id"] + # Match first space separated word. + select("[title|=foo]") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + select("[title|=bar]") + assert_equal 0, @matches.size + end + + + # + # Selector composition: groups, sibling, children + # + + + def test_selector_group + parse(%Q{<h1 id="1"></h1><h2 id="2"></h2><h3 id="3"></h3>}) + # Simple group selector. + select("h1,h3") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + select("h1 , h3") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + # Complex group selector. + parse(%Q{<h1 id="1"><a href="foo"></a></h1><h2 id="2"><a href="bar"></a></h2><h3 id="2"><a href="baz"></a></h3>}) + select("h1 a, h3 a") + assert_equal 2, @matches.size + assert_equal "foo", @matches[0].attributes["href"] + assert_equal "baz", @matches[1].attributes["href"] + # And now for the three selector challange. + parse(%Q{<h1 id="1"><a href="foo"></a></h1><h2 id="2"><a href="bar"></a></h2><h3 id="2"><a href="baz"></a></h3>}) + select("h1 a, h2 a, h3 a") + assert_equal 3, @matches.size + assert_equal "foo", @matches[0].attributes["href"] + assert_equal "bar", @matches[1].attributes["href"] + assert_equal "baz", @matches[2].attributes["href"] + end + + + def test_sibling_selector + parse(%Q{<h1 id="1"></h1><h2 id="2"></h2><h3 id="3"></h3>}) + # Test next sibling. + select("h1+*") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + select("h1+h2") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + select("h1+h3") + assert_equal 0, @matches.size + select("*+h3") + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + # Test any sibling. + select("h1~*") + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + select("h2~*") + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + end + + + def test_children_selector + parse(%Q{<div><p id="1"><span id="2"></span></p></div><div><p id="3"><span id="4" class="foo"></span></p></div>}) + # Test child selector. + select("div>p") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + select("div>span") + assert_equal 0, @matches.size + select("div>p#3") + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + select("div>p>span") + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "4", @matches[1].attributes["id"] + # Test descendant selector. + select("div p") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + select("div span") + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "4", @matches[1].attributes["id"] + select("div *#3") + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + select("div *#4") + assert_equal 1, @matches.size + assert_equal "4", @matches[0].attributes["id"] + # This is here because it failed before when whitespaces + # were not properly stripped. + select("div .foo") + assert_equal 1, @matches.size + assert_equal "4", @matches[0].attributes["id"] + end + + + # + # Pseudo selectors: root, nth-child, empty, content, etc + # + + + def test_root_selector + parse(%Q{<div id="1"><div id="2"></div></div>}) + # Can only find element if it's root. + select(":root") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + select("#1:root") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + select("#2:root") + assert_equal 0, @matches.size + # Opposite for nth-child. + select("#1:nth-child(1)") + assert_equal 0, @matches.size + end + + + def test_nth_child_odd_even + parse(%Q{<table><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # Test odd nth children. + select("tr:nth-child(odd)") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + # Test even nth children. + select("tr:nth-child(even)") + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "4", @matches[1].attributes["id"] + end + + + def test_nth_child_a_is_zero + parse(%Q{<table><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # Test the third child. + select("tr:nth-child(0n+3)") + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + # Same but an can be omitted when zero. + select("tr:nth-child(3)") + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + # Second element (but not every second element). + select("tr:nth-child(0n+2)") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + # Before first and past last returns nothing.: + assert_raises(ArgumentError) { select("tr:nth-child(-1)") } + select("tr:nth-child(0)") + assert_equal 0, @matches.size + select("tr:nth-child(5)") + assert_equal 0, @matches.size + end + + + def test_nth_child_a_is_one + parse(%Q{<table><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # a is group of one, pick every element in group. + select("tr:nth-child(1n+0)") + assert_equal 4, @matches.size + # Same but a can be omitted when one. + select("tr:nth-child(n+0)") + assert_equal 4, @matches.size + # Same but b can be omitted when zero. + select("tr:nth-child(n)") + assert_equal 4, @matches.size + end + + + def test_nth_child_b_is_zero + parse(%Q{<table><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # If b is zero, pick the n-th element (here each one). + select("tr:nth-child(n+0)") + assert_equal 4, @matches.size + # If b is zero, pick the n-th element (here every second). + select("tr:nth-child(2n+0)") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + # If a and b are both zero, no element selected. + select("tr:nth-child(0n+0)") + assert_equal 0, @matches.size + select("tr:nth-child(0)") + assert_equal 0, @matches.size + end + + + def test_nth_child_a_is_negative + parse(%Q{<table><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # Since a is -1, picks the first three elements. + select("tr:nth-child(-n+3)") + assert_equal 3, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "2", @matches[1].attributes["id"] + assert_equal "3", @matches[2].attributes["id"] + # Since a is -2, picks the first in every second of first four elements. + select("tr:nth-child(-2n+3)") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + # Since a is -2, picks the first in every second of first three elements. + select("tr:nth-child(-2n+2)") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + end + + + def test_nth_child_b_is_negative + parse(%Q{<table><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # Select last of four. + select("tr:nth-child(4n-1)") + assert_equal 1, @matches.size + assert_equal "4", @matches[0].attributes["id"] + # Select first of four. + select("tr:nth-child(4n-4)") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + # Select last of every second. + select("tr:nth-child(2n-1)") + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "4", @matches[1].attributes["id"] + # Select nothing since an+b always < 0 + select("tr:nth-child(-1n-1)") + assert_equal 0, @matches.size + end + + + def test_nth_child_substitution_values + parse(%Q{<table><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # Test with ?n?. + select("tr:nth-child(?n?)", 2, 1) + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "3", @matches[1].attributes["id"] + select("tr:nth-child(?n?)", 2, 2) + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "4", @matches[1].attributes["id"] + select("tr:nth-child(?n?)", 4, 2) + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + # Test with ? (b only). + select("tr:nth-child(?)", 3) + assert_equal 1, @matches.size + assert_equal "3", @matches[0].attributes["id"] + select("tr:nth-child(?)", 5) + assert_equal 0, @matches.size + end + + + def test_nth_last_child + parse(%Q{<table><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # Last two elements. + select("tr:nth-last-child(-n+2)") + assert_equal 2, @matches.size + assert_equal "3", @matches[0].attributes["id"] + assert_equal "4", @matches[1].attributes["id"] + # All old elements counting from last one. + select("tr:nth-last-child(odd)") + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "4", @matches[1].attributes["id"] + end + + + def test_nth_of_type + parse(%Q{<table><thead></thead><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # First two elements. + select("tr:nth-of-type(-n+2)") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "2", @matches[1].attributes["id"] + # All old elements counting from last one. + select("tr:nth-last-of-type(odd)") + assert_equal 2, @matches.size + assert_equal "2", @matches[0].attributes["id"] + assert_equal "4", @matches[1].attributes["id"] + end + + + def test_first_and_last + parse(%Q{<table><thead></thead><tr id="1"></tr><tr id="2"></tr><tr id="3"></tr><tr id="4"></tr></table>}) + # First child. + select("tr:first-child") + assert_equal 0, @matches.size + select(":first-child") + assert_equal 1, @matches.size + assert_equal "thead", @matches[0].name + # First of type. + select("tr:first-of-type") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + select("thead:first-of-type") + assert_equal 1, @matches.size + assert_equal "thead", @matches[0].name + select("div:first-of-type") + assert_equal 0, @matches.size + # Last child. + select("tr:last-child") + assert_equal 1, @matches.size + assert_equal "4", @matches[0].attributes["id"] + # Last of type. + select("tr:last-of-type") + assert_equal 1, @matches.size + assert_equal "4", @matches[0].attributes["id"] + select("thead:last-of-type") + assert_equal 1, @matches.size + assert_equal "thead", @matches[0].name + select("div:last-of-type") + assert_equal 0, @matches.size + end + + + def test_first_and_last + # Only child. + parse(%Q{<table><tr></tr></table>}) + select("table:only-child") + assert_equal 0, @matches.size + select("tr:only-child") + assert_equal 1, @matches.size + assert_equal "tr", @matches[0].name + parse(%Q{<table><tr></tr><tr></tr></table>}) + select("tr:only-child") + assert_equal 0, @matches.size + # Only of type. + parse(%Q{<table><thead></thead><tr></tr><tr></tr></table>}) + select("thead:only-of-type") + assert_equal 1, @matches.size + assert_equal "thead", @matches[0].name + select("td:only-of-type") + assert_equal 0, @matches.size + end + + + def test_empty + parse(%Q{<table><tr></tr></table>}) + select("table:empty") + assert_equal 0, @matches.size + select("tr:empty") + assert_equal 1, @matches.size + parse(%Q{<div> </div>}) + select("div:empty") + assert_equal 1, @matches.size + end + + + def test_content + parse(%Q{<div> </div>}) + select("div:content()") + assert_equal 1, @matches.size + parse(%Q{<div>something </div>}) + select("div:content()") + assert_equal 0, @matches.size + select("div:content(something)") + assert_equal 1, @matches.size + select("div:content( 'something' )") + assert_equal 1, @matches.size + select("div:content( \"something\" )") + assert_equal 1, @matches.size + select("div:content(?)", "something") + assert_equal 1, @matches.size + select("div:content(?)", /something/) + assert_equal 1, @matches.size + end + + + # + # Test negation. + # + + + def test_element_negation + parse(%Q{<p></p><div></div>}) + select("*") + assert_equal 2, @matches.size + select("*:not(p)") + assert_equal 1, @matches.size + assert_equal "div", @matches[0].name + select("*:not(div)") + assert_equal 1, @matches.size + assert_equal "p", @matches[0].name + select("*:not(span)") + assert_equal 2, @matches.size + end + + + def test_id_negation + parse(%Q{<p id="1"></p><p id="2"></p>}) + select("p") + assert_equal 2, @matches.size + select(":not(#1)") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + select(":not(#2)") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + end + + + def test_class_name_negation + parse(%Q{<p class="foo"></p><p class="bar"></p>}) + select("p") + assert_equal 2, @matches.size + select(":not(.foo)") + assert_equal 1, @matches.size + assert_equal "bar", @matches[0].attributes["class"] + select(":not(.bar)") + assert_equal 1, @matches.size + assert_equal "foo", @matches[0].attributes["class"] + end + + + def test_attribute_negation + parse(%Q{<p title="foo"></p><p title="bar"></p>}) + select("p") + assert_equal 2, @matches.size + select(":not([title=foo])") + assert_equal 1, @matches.size + assert_equal "bar", @matches[0].attributes["title"] + select(":not([title=bar])") + assert_equal 1, @matches.size + assert_equal "foo", @matches[0].attributes["title"] + end + + + def test_pseudo_class_negation + parse(%Q{<div><p id="1"></p><p id="2"></p></div>}) + select("p") + assert_equal 2, @matches.size + select("p:not(:first-child)") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + select("p:not(:nth-child(2))") + assert_equal 1, @matches.size + assert_equal "1", @matches[0].attributes["id"] + end + + + def test_negation_details + parse(%Q{<p id="1"></p><p id="2"></p><p id="3"></p>}) + assert_raises(ArgumentError) { select(":not(") } + assert_raises(ArgumentError) { select(":not(:not())") } + select("p:not(#1):not(#3)") + assert_equal 1, @matches.size + assert_equal "2", @matches[0].attributes["id"] + end + + + def test_select_from_element + parse(%Q{<div><p id="1"></p><p id="2"></p></div>}) + select("div") + @matches = @matches[0].select("p") + assert_equal 2, @matches.size + assert_equal "1", @matches[0].attributes["id"] + assert_equal "2", @matches[1].attributes["id"] + end + + +protected + + def parse(html) + @html = HTML::Document.new(html).root + end + + def select(*selector) + @matches = HTML.selector(*selector).select(@html) + end + +end |