#       Licensed to the Apache Software Foundation (ASF) under one
#       or more contributor license agreements.  See the NOTICE file
#       distributed with this work for additional information
#       regarding copyright ownership.  The ASF licenses this file
#       to you under the Apache License, Version 2.0 (the
#       "License"); you may not use this file except in compliance
#       with the License.  You may obtain a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#       Unless required by applicable law or agreed to in writing,
#       software distributed under the License is distributed on an
#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
#       KIND, either express or implied.  See the License for the
#       specific language governing permissions and limitations
#       under the License.
import inspect
import re
import os
import time
from textwrap import dedent

import pytest

import allura
import hashlib
from mock import patch, Mock

from bson import ObjectId
from tg import tmpl_context as c, app_globals as g
import tg
from oembed import OEmbedError

from ming.odm import ThreadLocalODMSession
from alluratest.controller import (
    setup_basic_test,
    setup_global_objects,
    setup_unit_test,
    setup_trove_categories,
)

from allura import model as M
from allura.lib import helpers as h
from allura.lib.app_globals import ForgeMarkdown
from allura.lib.search import find_shortlinks
from allura.tests import decorators as td

from forgewiki import model as WM
from forgeblog import model as BM


def setup():
    setup_basic_test()
    setup_unit_test()
    setup_with_tools()


def teardown():
    setup()


@td.with_wiki
def setup_with_tools():
    setup_global_objects()


def squish_spaces(text):
    # \s is whitespace
    # \xa0 is &nbsp; in unicode form
    return re.sub(r'[\s\xa0]+', ' ', text)


def get_project_names(r):
    """
    Extracts a list of project names from a wiki page HTML.
    """
    # projects short names are in h2 elements without any attributes
    # there is one more h2 element, but it has `class` attribute
    # re_proj_names = re.compile(r'<h2><a[^>]>(.+)</a></h2>')
    re_proj_names = re.compile(r'<h2><a[^>]+>(.+)</a></h2>')
    return [e for e in re_proj_names.findall(r)]


def get_projects_property_in_the_same_order(names, prop):
    """
    Returns a list of projects properties `prop` in the same order as
    project `names`.
    It is required because results of the query are not in the same order as names.
    """
    projects = M.Project.query.find(dict(name={'$in': names})).all()
    projects_dict = {p['name']: p[prop] for p in projects}
    return [projects_dict[name] for name in names]


class Test():

    def setup_method(self, method):
        setup()
        p_nbhd = M.Neighborhood.query.get(name='Projects')
        p_test = M.Project.query.get(shortname='test', neighborhood_id=p_nbhd._id)
        self.acl_bak = p_test.acl.copy()

    def teardown_method(self, method):
        user = M.User.by_username('test-admin')
        user.display_name = 'Test Admin'

        p_nbhd = M.Neighborhood.query.get(name='Projects')
        p_test = M.Project.query.get(shortname='test', neighborhood_id=p_nbhd._id)
        p_test.remove_user(M.User.by_username('test-user'))
        p_test.remove_user(M.User.by_username('test-user-0'))
        p_test.acl = self.acl_bak

        ThreadLocalODMSession.flush_all()

    @td.with_wiki
    def test_app_globals(self):
        with h.push_context('test', 'wiki', neighborhood='Projects'):
            assert g.app_static(
                'css/wiki.css') == '/nf/_static_/wiki/css/wiki.css', g.app_static('css/wiki.css')

    def test_macro_projects(self):
        file_name = 'neo-icon-set-454545-256x350.png'
        file_path = os.path.join(
            allura.__path__[0], 'nf', 'allura', 'images', file_name)

        p_nbhd = M.Neighborhood.query.get(name='Projects')
        p_test = M.Project.query.get(shortname='test', neighborhood_id=p_nbhd._id)
        c.project = p_test
        icon_file = open(file_path, 'rb')
        M.ProjectFile.save_image(
            file_name, icon_file, content_type='image/png',
            square=True, thumbnail_size=(48, 48),
            thumbnail_meta=dict(project_id=c.project._id, category='icon'))
        icon_file.close()
        p_test2 = M.Project.query.get(
            shortname='test2', neighborhood_id=p_nbhd._id)
        c.project = p_test2
        icon_file = open(file_path, 'rb')
        M.ProjectFile.save_image(
            file_name, icon_file, content_type='image/png',
            square=True, thumbnail_size=(48, 48),
            thumbnail_meta=dict(project_id=c.project._id, category='icon'))
        icon_file.close()
        p_sub1 = M.Project.query.get(
            shortname='test/sub1', neighborhood_id=p_nbhd._id)
        c.project = p_sub1
        icon_file = open(file_path, 'rb')
        M.ProjectFile.save_image(
            file_name, icon_file, content_type='image/png',
            square=True, thumbnail_size=(48, 48),
            thumbnail_meta=dict(project_id=c.project._id, category='icon'))
        icon_file.close()
        p_test.labels = ['test', 'root']
        p_sub1.labels = ['test', 'sub1']
        # Make one project private
        p_test.private = False
        p_sub1.private = False
        p_test2.private = True

        ThreadLocalODMSession.flush_all()

        with h.push_config(c,
                           project=p_nbhd.neighborhood_project,
                           user=M.User.by_username('test-admin')):
            r = g.markdown_wiki.convert('[[projects]]')
            assert 'alt="Test Project Logo"' in r, r
            assert 'alt="A Subproject Logo"' in r, r
            r = g.markdown_wiki.convert('[[projects labels=root]]')
            assert 'alt="Test Project Logo"' in r, r
            assert 'alt="A Subproject Logo"' not in r, r
            r = g.markdown_wiki.convert('[[projects labels=sub1]]')
            assert 'alt="Test Project Logo"' not in r, r
            assert 'alt="A Subproject Logo"' in r, r
            r = g.markdown_wiki.convert('[[projects labels=test]]')
            assert 'alt="Test Project Logo"' in r, r
            assert 'alt="A Subproject Logo"' in r, r
            r = g.markdown_wiki.convert('[[projects labels=test,root]]')
            assert 'alt="Test Project Logo"' in r, r
            assert 'alt="A Subproject Logo"' not in r, r
            r = g.markdown_wiki.convert('[[projects labels=test,sub1]]')
            assert 'alt="Test Project Logo"' not in r, r
            assert 'alt="A Subproject Logo"' in r, r
            r = g.markdown_wiki.convert('[[projects labels=root|sub1]]')
            assert 'alt="Test Project Logo"' in r, r
            assert 'alt="A Subproject Logo"' in r, r
            r = g.markdown_wiki.convert('[[projects labels=test,root|root,sub1]]')
            assert 'alt="Test Project Logo"' in r, r
            assert 'alt="A Subproject Logo"' not in r, r
            r = g.markdown_wiki.convert('[[projects labels=test,root|test,sub1]]')
            assert 'alt="Test Project Logo"' in r, r
            assert 'alt="A Subproject Logo"' in r, r
            r = g.markdown_wiki.convert('[[projects show_total=True sort=random]]')
            assert '<p>3 Projects' in r, r
            r = g.markdown_wiki.convert(
                '[[projects show_total=True private=True sort=random]]')
            assert '<p>1 Projects' in r, r
            assert 'alt="Test 2 Logo"' in r, r
            assert 'alt="Test Project Logo"' not in r, r
            assert 'alt="A Subproject Logo"' not in r, r

            r = g.markdown_wiki.convert('[[projects show_proj_icon=True]]')
            assert 'alt="Test Project Logo"' in r
            r = g.markdown_wiki.convert('[[projects show_proj_icon=False]]')
            assert 'alt="Test Project Logo"' not in r

    def test_macro_neighborhood_feeds(self):
        p_nbhd = M.Neighborhood.query.get(name='Projects')
        p_test = M.Project.query.get(shortname='test', neighborhood_id=p_nbhd._id)
        with h.push_context('--init--', 'wiki', neighborhood='Projects'):
            r = g.markdown_wiki.convert('[[neighborhood_feeds tool_name=wiki]]')
            assert 'Home modified by' in r, r
            r = re.sub(r'<small>.*? ago</small>', '', r)  # remove "less than 1 second ago" etc
            orig_len = len(r)
            # Make project private & verify we don't see its new feed items
            anon = M.User.anonymous()
            p_test.acl.insert(0, M.ACE.deny(
                M.ProjectRole.anonymous(p_test)._id, 'read'))
            ThreadLocalODMSession.flush_all()
            pg = WM.Page.query.get(title='Home', app_config_id=c.app.config._id)
            pg.text = 'Change'
            with h.push_config(c, user=M.User.by_username('test-admin')):
                pg.commit()
            r = g.markdown_wiki.convert('[[neighborhood_feeds tool_name=wiki]]')
            r = re.sub(r'<small>.*? ago</small>', '', r)  # remove "less than 1 second ago" etc
            new_len = len(r)
            assert new_len == orig_len
            p = BM.BlogPost(title='test me',
                            neighborhood_id=p_test.neighborhood_id)
            p.text = 'test content'
            p.state = 'published'
            p.make_slug()
            with h.push_config(c, user=M.User.by_username('test-admin')):
                p.commit()
            ThreadLocalODMSession.flush_all()
            with h.push_config(c, user=anon):
                r = g.markdown_wiki.convert('[[neighborhood_blog_posts]]')
            assert 'test content' in r

    def test_macro_members(self):
        p_nbhd = M.Neighborhood.query.get(name='Projects')
        p_test = M.Project.query.get(shortname='test', neighborhood_id=p_nbhd._id)
        p_test.add_user(M.User.by_username('test-user'), ['Developer'])
        p_test.add_user(M.User.by_username('test-user-0'), ['Member'])
        ThreadLocalODMSession.flush_all()
        r = g.markdown_wiki.convert('[[members limit=2]]').replace('    ', '').replace('\n', '')
        assert (r ==
                '<div class="markdown_content"><h6>Project Members:</h6>'
                '<ul class="md-users-list">'
                '<li><a href="/u/test-admin/">Test Admin</a> (admin)</li>'
                '<li><a href="/u/test-user/">Test User</a></li>'
                '<li class="md-users-list-more"><a href="/p/test/_members">All Members</a></li>'
                '</ul>'
                '</div>')

    def test_macro_members_escaping(self):
        user = M.User.by_username('test-admin')
        user.display_name = 'Test Admin <script>'
        r = g.markdown_wiki.convert('[[members]]')
        assert (r.replace('\n', '').replace('    ', '') ==
                '<div class="markdown_content"><h6>Project Members:</h6>'
                '<ul class="md-users-list">'
                '<li><a href="/u/test-admin/">Test Admin &lt;script&gt;</a> (admin)</li>'
                '</ul></div>')

    def test_macro_project_admins(self):
        user = M.User.by_username('test-admin')
        user.display_name = 'Test Ådmin <script>'
        with h.push_context('test', neighborhood='Projects'):
            r = g.markdown_wiki.convert('[[project_admins]]')
        assert (r.replace('\n', '') ==
                '<div class="markdown_content"><h6>Project Admins:</h6>'
                '<ul class="md-users-list">'
                '    <li><a href="/u/test-admin/">Test \xc5dmin &lt;script&gt;</a></li>'
                '</ul></div>')

    def test_macro_project_admins_one_br(self):
        p_nbhd = M.Neighborhood.query.get(name='Projects')
        p_test = M.Project.query.get(shortname='test', neighborhood_id=p_nbhd._id)
        p_test.add_user(M.User.by_username('test-user'), ['Admin'])
        ThreadLocalODMSession.flush_all()
        with h.push_config(c, project=p_test):
            r = g.markdown_wiki.convert('[[project_admins]]\n[[download_button]]')

        assert '</a><br/><br/><a href=' not in r, r
        assert '</a></li><li><a href=' in r, r

    def test_macro_include_no_extra_br(self):
        p_nbhd = M.Neighborhood.query.get(name='Projects')
        p_test = M.Project.query.get(shortname='test', neighborhood_id=p_nbhd._id)
        wiki = p_test.app_instance('wiki')
        with h.push_context(p_test._id, app_config_id=wiki.config._id):
            p = WM.Page.upsert(title='Include_1')
            p.text = 'included page 1'
            p.commit()
            p = WM.Page.upsert(title='Include_2')
            p.text = 'included page 2'
            p.commit()
            p = WM.Page.upsert(title='Include_3')
            p.text = 'included page 3'
            p.commit()
            ThreadLocalODMSession.flush_all()
            md = '[[include ref=Include_1]]\n[[include ref=Include_2]]\n[[include ref=Include_3]]'
            html = g.markdown_wiki.convert(md)

        expected_html = '''<div class="markdown_content"><p></p><div>
    <div class="markdown_content"><p>included page 1</p></div>
    </div>
    <div>
    <div class="markdown_content"><p>included page 2</p></div>
    </div>
    <div>
    <div class="markdown_content"><p>included page 3</p></div>
    </div>
    <p></p></div>'''
        assert squish_spaces(html) == squish_spaces(expected_html)

    @td.with_tool('test', 'Wiki', 'wiki2')
    def test_macro_include_permissions(self):
        p_nbhd = M.Neighborhood.query.get(name='Projects')
        p_test = M.Project.query.get(shortname='test', neighborhood_id=p_nbhd._id)
        wiki = p_test.app_instance('wiki')
        wiki2 = p_test.app_instance('wiki2')
        with h.push_context(p_test._id, app_config_id=wiki.config._id):
            p = WM.Page.upsert(title='CanRead')
            p.text = 'Can see this!'
            p.commit()
            ThreadLocalODMSession.flush_all()

        with h.push_context(p_test._id, app_config_id=wiki2.config._id):
            role = M.ProjectRole.by_name('*anonymous')._id
            read_perm = M.ACE.allow(role, 'read')
            acl = c.app.config.acl
            if read_perm in acl:
                acl.remove(read_perm)
            p = WM.Page.upsert(title='CanNotRead')
            p.text = 'Can not see this!'
            p.commit()
            ThreadLocalODMSession.flush_all()

        with h.push_context(p_test._id, app_config_id=wiki.config._id):
            c.user = M.User.anonymous()
            md = '[[include ref=CanRead]]\n[[include ref=wiki2:CanNotRead]]'
            html = g.markdown_wiki.convert(md)
            assert 'Can see this!' in html
            assert 'Can not see this!' not in html
            assert "[[include: you don't have a read permission for wiki2:CanNotRead]]" in html

    @patch('oembed.OEmbedEndpoint.fetch')
    def test_macro_embed(self, oembed_fetch):
        oembed_fetch.return_value = {
            "html": '<iframe width="480" height="270" src="http://www.youtube.com/embed/kOLpSPEA72U?feature=oembed" '
                    'frameborder="0" allowfullscreen></iframe>'
                    '<script>alert(1)</script>)',  # not realistic, but testing if the oembed endpoint got compromised
            "title": "Nature's 3D Printer: MIND BLOWING Cocoon in Rainforest - Smarter Every Day 94",
        }
        r = g.markdown_wiki.convert('[[embed url=http://www.youtube.com/watch?v=kOLpSPEA72U]]')
        assert ('<p><iframe height="270" '
                'src="https://www.youtube-nocookie.com/embed/kOLpSPEA72U?feature=oembed" width="480"></iframe>'
                '&lt;script&gt;alert(1)&lt;/script&gt;</p>' in
                r.replace('\n', ''))

    def test_macro_embed_video_gone(self):
        # this does a real fetch
        r = g.markdown_wiki.convert('[[embed url=https://www.youtube.com/watch?v=OWsFqPZ3v-0]]')
        r = str(r)  # convert away from Markup, to get better assertion diff output
        # either of these could happen depending on the mood of youtube's oembed API:
        assert r in [
            '<div class="markdown_content"><p>Video not available</p></div>',
            '<div class="markdown_content"><p>Could not embed: https://www.youtube.com/watch?v=OWsFqPZ3v-0</p></div>',
        ]

    @patch('oembed.OEmbedEndpoint.fetch')
    def test_macro_embed_video_error(self, oembed_fetch):
        oembed_fetch.side_effect = OEmbedError('Invalid mime-type in response...')
        r = g.markdown_wiki.convert('[[embed url=http://www.youtube.com/watch?v=6YbBmqUnoQM]]')
        assert (r == '<div class="markdown_content"><p>Could not embed: '
                'http://www.youtube.com/watch?v=6YbBmqUnoQM</p></div>')

    def test_macro_embed_notsupported(self):
        r = g.markdown_wiki.convert('[[embed url=http://vimeo.com/46163090]]')
        assert (
            r == '<div class="markdown_content"><p>[[embed url=http://vimeo.com/46163090]]</p></div>')

    def test_markdown_toc(self):
        with h.push_context('test', neighborhood='Projects'):
            r = g.markdown_wiki.convert(dedent("""\
                [TOC]

                # Header 1

                ## Header 2"""))
        assert dedent('''\
            <ul>
            <li><a href="#h-header-1">Header 1</a><ul>
            <li><a href="#h-header-2">Header 2</a></li>
            </ul>
            </li>
            </ul>''') in r

    def test_wiki_artifact_links(self):
        text = g.markdown.convert('See [18:13:49]')
        assert 'See <span>[18:13:49]</span>' in text
        with h.push_context('test', 'wiki', neighborhood='Projects'):
            text = g.markdown.convert('Read [here](Home) about our project')
            assert '<a href="/p/test/wiki/Home/">here</a>' in text
            text = g.markdown.convert('[Go home](test:wiki:Home "mytitle")')
            assert '<a href="/p/test/wiki/Home/" title="mytitle">Go home</a>' in text
            text = g.markdown.convert('See [test:wiki:Home]')
            assert '<a class="alink" href="/p/test/wiki/Home/">[test:wiki:Home]</a>' in text

    def test_markdown_links(self):
        with patch.dict(tg.config, {'nofollow_exempt_domains': 'foobar.net'}):
            text = g.markdown.convert('Read [here](http://foobar.net/) about our project')
            assert ' href="http://foobar.net/">here</a> about' in text

        text = g.markdown.convert('Read [here](http://foobar.net/) about our project')
        assert ' href="http://foobar.net/" rel="nofollow">here</a> about' in text

        text = g.markdown.convert('Read [here](/p/foobar/blah) about our project')
        assert ' href="/p/foobar/blah">here</a> about' in text

        text = g.markdown.convert('Read [here](/p/foobar/blah/) about our project')
        assert ' href="/p/foobar/blah/">here</a> about' in text

        text = g.markdown.convert('Read <http://foobar.net/> about our project')
        assert 'href="http://foobar.net/" rel="nofollow">http://foobar.net/</a> about' in text

    def test_markdown_and_html(self):
        with h.push_context('test', neighborhood='Projects'):
            r = g.markdown_wiki.convert('<div style="float:left">blah</div>')
        assert '<div style="float: left;">blah</div>' in r, r

    def test_markdown_within_html(self):
        with h.push_context('test', neighborhood='Projects'):
            r = g.markdown_wiki.convert('<div style="float:left" markdown>**blah**</div>')
        assert ('<div style="float: left;"><p><strong>blah</strong></p></div>' in
                r.replace('\n', ''))

    def test_markdown_with_html_comments(self):
        text = g.markdown.convert('test <!-- comment -->')
        assert '<div class="markdown_content"><p>test </p></div>' == text, text

    def test_markdown_big_text(self):
        '''If text is too big g.markdown.convert should return plain text'''
        text = 'a' * 40001
        assert g.markdown.convert(text) == '<pre>%s</pre>' % text
        assert g.markdown_wiki.convert(text) == '<pre>%s</pre>' % text
        assert g.markdown.convert('<b>' + text) == '<pre>&lt;b&gt;%s</pre>' % text
        assert g.markdown_commit.convert(text) == '<pre>%s</pre>' % text

    def test_find_shortlinks_big_text(self):
        text = '[a123]  ' + 'a' * 40001
        assert find_shortlinks(text) == []

    def test_markdown_basics(self):
        with h.push_context('test', 'wiki', neighborhood='Projects'):
            text = g.markdown.convert('# Foo!\n[Home]')
            assert (text ==
                    '<div class="markdown_content"><h1 id="h-foo">Foo!</h1>\n'
                    '<p><a class="alink" href="/p/test/wiki/Home/">[Home]</a></p></div>')
            text = g.markdown.convert('# Foo!\n[Rooted]')
            assert (text ==
                    '<div class="markdown_content"><h1 id="h-foo">Foo!</h1>\n'
                    '<p><span>[Rooted]</span></p></div>')

        assert (
            g.markdown.convert('Multi\nLine') ==
            '<div class="markdown_content"><p>Multi<br/>\n'
            'Line</p></div>')
        assert (
            g.markdown.convert('Multi\n\nLine') ==
            '<div class="markdown_content"><p>Multi</p>\n'
            '<p>Line</p></div>')

        # should not raise an exception:
        assert g.markdown.convert("<class 'foo'>") == \
            '''<div class="markdown_content"><p>&lt;class 'foo'=""&gt;&lt;/class&gt;</p></div>'''

        assert g.markdown.convert(dedent('''\
            # Header

            Some text in a regular paragraph

                :::python
                for i in range(10):
                    print i
            ''')) == dedent('''\
                <div class="markdown_content"><h1 id="h-header">Header</h1>
                <p>Some text in a regular paragraph</p>
                <div class="codehilite"><pre><span></span><code><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">):</span>
                    <span class="nb">print</span> <span class="n">i</span>
                </code></pre></div>
                </div>''')
        assert (
            ForgeMarkdown(email=True).convert('[Home]') ==
            # uses localhost:
            '<div class="markdown_content"><p><a class="alink" href="http://localhost/p/test/wiki/Home/">[Home]</a></p></div>')
        assert g.markdown.convert(dedent('''\
            ~~~~
            def foo(): pass
            ~~~~''')) == dedent('''\
                <div class="markdown_content"><div class="codehilite"><pre><span></span><code>def foo(): pass
                </code></pre></div>
                </div>''')

    def test_markdown_link_references(self):
        assert g.markdown.convert(dedent('''\
            You can also put the [link URL][1] below the current paragraph like [this][2].

               [1]: http://url
               [2]: http://another.url "A funky title"
        ''')) == dedent('''\
            <div class="markdown_content"><p>You can also put the <a href="http://url" rel="nofollow">link URL</a> below the current paragraph like <a href="http://another.url" rel="nofollow" title="A funky title">this</a>.</p></div>''')

        assert g.markdown.convert(dedent('''\
            Or you can use a [shortcut][] reference

            [shortcut]: https://example.com/foo
        ''')) == dedent('''\
            <div class="markdown_content"><p>Or you can use a <a href="https://example.com/foo" rel="nofollow">shortcut</a> reference</p></div>''')

    def test_markdown_list_without_break(self):
        # this is not a valid way to make a list in original Markdown or python-markdown
        #   https://github.com/Python-Markdown/markdown/issues/874
        # it is valid in the CommonMark spec https://spec.commonmark.org/0.30/#lists
        # for now the current version of Markdown seems to create a more well formed list with <br/> tags
        # TODO: try https://github.com/adamb70/mdx-breakless-lists
        #       or https://gitlab.com/ayblaq/prependnewline
        assert (
            g.markdown.convert(dedent('''\
    Regular text
    * first item
    * second item''')) ==
            '<div class="markdown_content"><p>Regular text</p>\n'
            '<ul>\n'
            '<li>first item</li>\n'
            '<li>second item</li>\n'
            '</ul></div>')

        assert (
            g.markdown.convert(dedent('''\
    Regular text
    - first item
    - second item''')) ==
            '<div class="markdown_content"><p>Regular text</p>\n'
            '<ul>\n'
            '<li>first item</li>\n'
            '<li>second item</li>\n'
            '</ul></div>')

        assert (
            g.markdown.convert(dedent('''\
    Regular text
    + first item
    + second item''')) ==
            '<div class="markdown_content"><p>Regular text</p>\n'
            '<ul>\n'
            '<li>first item</li>\n'
            '<li>second item</li>\n'
            '</ul></div>')

        assert (
            g.markdown.convert(dedent('''\
    Regular text
    1. first item
    2. second item''')) ==
            '<div class="markdown_content"><p>Regular text</p>\n'
            '<ol>\n'
            '<li>first item</li>\n'
            '<li>second item</li>\n'
            '</ol></div>')

    def test_markdown_autolink(self):
        tgt = 'http://everything2.com/?node=nate+oostendorp'
        s = g.markdown.convert('This is %s' % tgt)
        assert (
            s == f'<div class="markdown_content"><p>This is <a href="{tgt}" rel="nofollow">{tgt}</a></p></div>')
        assert '<a href=' in g.markdown.convert('This is http://domain.net')
        # beginning of doc
        assert '<a href=' in g.markdown.convert('http://domain.net abc')
        # beginning of a line
        assert ('<br/>\n<a href="http://' in
                g.markdown.convert('foobar\nhttp://domain.net abc'))
        # special characters allowed
        assert (' href="http://mst.dn/@acct:%20#target" ' in
                g.markdown.convert('foo http://mst.dn/@acct:%20#target bar'))
        # no conversion of these urls:
        assert ('a blahttp://sdf.com z' in
                g.markdown.convert('a blahttp://sdf.com z'))
        assert ('literal <code>http://domain.net</code> literal' in
                g.markdown.convert('literal `http://domain.net` literal'))
        assert ('<pre><span></span><code>preformatted http://domain.net\n</code></pre>' in
                g.markdown.convert('    :::text\n'
                                   '    preformatted http://domain.net'))

    def test_markdown_autolink_with_escape(self):
        # \_ is unnecessary but valid markdown escaping and should be considered as a regular underscore
        # (it occurs during html2text conversion during project migrations)
        r = g.markdown.convert(r'a http://www.phpmyadmin.net/home\_page/security/\#target b')
        assert 'href="http://www.phpmyadmin.net/home_page/security/#target"' in r, r

    def test_markdown_invalid_script(self):
        r = g.markdown.convert('<script>alert(document.cookies)</script>')
        assert '<div class="markdown_content">&lt;script&gt;alert(document.cookies)&lt;/script&gt;\n</div>' == r

    def test_markdown_invalid_onerror(self):
        r = g.markdown.convert('<img src=x onerror=alert(document.cookie)>')
        assert 'onerror' not in r

    def test_markdown_invalid_tagslash(self):
        r = g.markdown.convert('<div/onload><img src=x onerror=alert(document.cookie)>')
        assert 'onerror' not in r

    def test_markdown_invalid_script_in_link(self):
        r = g.markdown.convert('[xss](http://"><a onmouseover=prompt(document.domain)>xss</a>)')
        assert ('<div class="markdown_content"><p><a '
                '''href='http://"&gt;&lt;a%20onmouseover=prompt(document.domain)&gt;xss&lt;/a' '''
                'rel="nofollow">xss</a>)</p></div>' == r)

    def test_markdown_invalid_script_in_link2(self):
        r = g.markdown.convert('[xss](http://"><img src=x onerror=alert(document.cookie)>)')
        assert ('<div class="markdown_content"><p><a '
                '''href='http://"&gt;&lt;img%20src=x%20onerror=alert(document.cookie)' '''
                'rel="nofollow">xss</a>)</p></div>' == r)

    def test_markdown_invalid_classes_ids(self):
        r = g.markdown.convert("# Test message")
        assert r == '<div class="markdown_content"><h1 id="h-test-message">Test message</h1></div>'

        r = g.markdown.convert('<p class="markdown-class notice" id="markdown-id">This is a test</p>')
        assert r == '<div class="markdown_content"><p class="notice" id="user-content-markdown-id">This is a test</p>\n</div>'

        r = g.markdown.convert('<i class="fa fa-cog">gear icon</i>')
        assert r == '<div class="markdown_content"><p><i class="fa fa-cog">gear icon</i></p></div>'

    def test_markdown_extremely_slow(self):
        # this used to be extremely slow due to catastrophic backtracking in FORGE_LINK_RE / BRK regex composition
        # initially mitigated by using the `regex` lib instead of `re` but now is mitigated by possessive quantifiers
        g.markdown.convert(inspect.cleandoc('''bonjour, voila ce que j'obtient en voulant ajouter un utilisateur a un groupe de sécurite, que ce soit sur un groupe pre-existant, ou sur un groupe crée.
    message d'erreur:

    ERROR: Could not complete the Add UserLogin To SecurityGroup [file:/C:/neogia/ofbizNeogia/applications/securityext/script/org/ofbiz/securityext/securitygroup/SecurityGroupServices.xml#addUserLoginToSecurityGroup] process [problem creating the newEntity value: Exception while inserting the following entity: [GenericEntity:UserLoginSecurityGroup][createdStamp,2006-01-23 17:42:39.312(java.sql.Timestamp)][createdTxStamp,2006-01-23 17:42:38.875(java.sql.Timestamp)][fromDate,2006-01-23 17:42:39.312(java.sql.Timestamp)][groupId,FULLADMIN(java.lang.String)][lastUpdatedStamp,2006-01-23 17:42:39.312(java.sql.Timestamp)][lastUpdatedTxStamp,2006-01-23 17:42:38.875(java.sql.Timestamp)][thruDate,null()][userLoginId,10012(java.lang.String)] (while inserting: [GenericEntity:UserLoginSecurityGroup][createdStamp,2006-01-23 17:42:39.312(java.sql.Timestamp)][createdTxStamp,2006-01-23 17:42:38.875(java.sql.Timestamp)][fromDate,2006-01-23 17:42:39.312(java.sql.Timestamp)][groupId,FULLADMIN(java.lang.String)][lastUpdatedStamp,2006-01-23 17:42:39.312(java.sql.Timestamp)][lastUpdatedTxStamp,2006-01-23 17:42:38.875(java.sql.Timestamp)][thruDate,null()][userLoginId,10012(java.lang.String)] (SQL Exception while executing the following:INSERT INTO public.USER_LOGIN_SECURITY_GROUP (USER_LOGIN_ID, GROUP_ID, FROM_DATE, THRU_DATE, LAST_UPDATED_STAMP, LAST_UPDATED_TX_STAMP, CREATED_STAMP, CREATED_TX_STAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?) (ERROR: insert or update on table &quot;user_login_security_group&quot; violates foreign key constraint &quot;user_secgrp_user&quot;)))].

    à priori les données du formulaire ne sont pas traitées : VALUES (?, ?, ?, ?, ?, ?, ?, ?) ce qui entraine l'echec du traitement SQL.


    Si une idée vous vient à l'esprit, merci de me tenir au courant.

    cordialement, julien.'''))
        assert True   # finished!

    def test_markdown_many_bad_links(self):

        def do_test(size: int):
            text = "[abc](https://example.com/airboot.  " + '[foo](https://example.com/) ' * size
            t1 = time.perf_counter()
            r = g.markdown.convert(text)
            t2 = time.perf_counter()
            assert r.startswith('<div class="markdown_content"><p><span>[abc]</span>(https://example.com/airboot.  <a href="https://example.com/" rel="nofollow">foo</a> <a href="https://example.com/" rel="nofollow">foo</a>')
            return t2 - t1

        short_time = do_test(2)
        long_time = do_test(20)

        slowness = long_time / short_time
        assert slowness < 10, f"Long markdown took {slowness:.1f}x longer ({long_time:.4f}s vs short text ({short_time:.4f}s)"

    def test_macro_include(self):
        r = g.markdown.convert('[[include ref=Home id=foo class=modal data-a=b]]')
        assert '<div class="" id="user-content-foo">' in r, r
        assert 'href="./foo"' in g.markdown.convert('[My foo](foo)')
        assert 'href="..' not in g.markdown.convert('[My foo](./foo)')

    def test_macro_nbhd_feeds(self):
        with h.push_context('--init--', 'wiki', neighborhood='Projects'):
            r = g.markdown_wiki.convert('[[neighborhood_feeds tool_name=wiki]]')
            assert 'Home modified by ' in r, r
            assert '&lt;div class="markdown_content"&gt;' not in r

    def test_sort_alpha(self):
        p_nbhd = M.Neighborhood.query.get(name='Projects')

        with h.push_context(p_nbhd.neighborhood_project._id):
            r = g.markdown_wiki.convert('[[projects sort=alpha]]')
            project_list = get_project_names(r)
            assert project_list == sorted(project_list)

    def test_sort_registered(self):
        p_nbhd = M.Neighborhood.query.get(name='Projects')

        with h.push_context(p_nbhd.neighborhood_project._id):
            r = g.markdown_wiki.convert('[[projects sort=last_registered]]')
            project_names = get_project_names(r)
            ids = get_projects_property_in_the_same_order(project_names, '_id')
            assert ids == sorted(ids, reverse=True)

    def test_sort_updated(self):
        p_nbhd = M.Neighborhood.query.get(name='Projects')

        with h.push_context(p_nbhd.neighborhood_project._id):
            r = g.markdown_wiki.convert('[[projects sort=last_updated]]')
            project_names = get_project_names(r)
            updated_at = get_projects_property_in_the_same_order(
                project_names, 'last_updated')
            assert updated_at == sorted(updated_at, reverse=True)

    def test_filtering(self):
        # set up for test
        from random import choice
        setup_trove_categories()
        random_trove = choice(M.TroveCategory.query.find().all())
        test_project = M.Project.query.get(shortname='test')
        test_project_troves = getattr(test_project, 'trove_' + random_trove.type)
        test_project_troves.append(random_trove._id)
        ThreadLocalODMSession.flush_all()

        p_nbhd = M.Neighborhood.query.get(name='Projects')
        with h.push_config(c,
                           project=p_nbhd.neighborhood_project,
                           user=M.User.by_username('test-admin')):
            r = g.markdown_wiki.convert(
                '[[projects category="%s"]]' % random_trove.fullpath)
            project_names = get_project_names(r)
            assert [test_project.name] == project_names

    def test_projects_macro(self):
        two_column_style = 'width: 330px;'

        p_nbhd = M.Neighborhood.query.get(name='Projects')
        with h.push_config(c,
                           project=p_nbhd.neighborhood_project,
                           user=M.User.anonymous()):
            # test columns
            r = g.markdown_wiki.convert('[[projects display_mode=list columns=2]]')
            assert two_column_style in r
            r = g.markdown_wiki.convert('[[projects display_mode=list columns=3]]')
            assert two_column_style not in r

    @td.with_user_project('test-admin')
    @td.with_user_project('test-user-1')
    def test_myprojects_macro(self):
        h.set_context('u/%s' % (c.user.username), 'wiki', neighborhood='Users')
        r = g.markdown_wiki.convert('[[my_projects]]')
        for p in c.user.my_projects():
            if p.deleted or p.is_nbhd_project:
                continue
            proj_title = f'<h2><a href="{p.url()}">{p.name}</a></h2>'
            assert proj_title in r

        h.set_context('u/test-user-1', 'wiki', neighborhood='Users')
        user = M.User.query.get(username='test-user-1')
        r = g.markdown_wiki.convert('[[my_projects]]')
        for p in user.my_projects():
            if p.deleted or p.is_nbhd_project:
                continue
            proj_title = f'<h2><a href="{p.url()}">{p.name}</a></h2>'
            assert proj_title in r

    def test_hideawards_macro(self):
        p_nbhd = M.Neighborhood.query.get(name='Projects')

        app_config_id = ObjectId()
        award = M.Award(app_config_id=app_config_id)
        award.short = 'Award short'
        award.full = 'Award full'
        award.created_by_neighborhood_id = p_nbhd._id

        project = M.Project.query.get(
            neighborhood_id=p_nbhd._id, shortname='test')

        M.AwardGrant(
            award=award,
            award_url='http://award.org',
            comment='Winner!',
            granted_by_neighborhood=p_nbhd,
            granted_to_project=project)

        ThreadLocalODMSession.flush_all()

        with h.push_context(p_nbhd.neighborhood_project._id):
            r = g.markdown_wiki.convert('[[projects]]')
            assert ('<div class="feature"> <a href="http://award.org" rel="nofollow" title="Winner!">'
                    'Award short</a> </div>' in
                    squish_spaces(r))

            r = g.markdown_wiki.convert('[[projects show_awards_banner=False]]')
            assert 'Award short' not in r

    @td.with_tool('test', 'Blog', 'blog')
    def test_project_blog_posts_macro(self):
        from forgeblog import model as BM
        with h.push_context('test', 'blog', neighborhood='Projects'):
            BM.BlogPost.new(
                title='Test title',
                text='test post',
                state='published',
            )
            BM.BlogPost.new(
                title='Test title2',
                text='test post2',
                state='published',
            )

            r = g.markdown_wiki.convert('[[project_blog_posts]]')
            assert 'Test title</a></h3>' in r
            assert 'Test title2</a></h3>' in r
            assert '<div class="markdown_content"><p>test post</p></div>' in r
            assert '<div class="markdown_content"><p>test post2</p></div>' in r
            assert 'by <em>Test Admin</em>' in r

    def test_project_screenshots_macro(self):
        with h.push_context('test', neighborhood='Projects'):
            M.ProjectFile(project_id=c.project._id, category='screenshot', caption='caption', filename='test_file.jpg')
            ThreadLocalODMSession.flush_all()

            r = g.markdown_wiki.convert('[[project_screenshots]]')

            assert 'href="/p/test/screenshot/test_file.jpg"' in r
            assert 'src="/p/test/screenshot/test_file.jpg/thumb"' in r


class TestCachedMarkdown:

    def setup_method(self, method):
        setup()
        self.md = ForgeMarkdown()
        self.post = M.Post()
        self.post.text = '**bold**'
        self.expected_html = '<div class="markdown_content"><p><strong>bold</strong></p></div>'

    def test_bad_source_field_name(self):
        with pytest.raises(AttributeError):
            self.md.cached_convert(self.post, 'no_such_field')

    def test_missing_cache_field(self):
        with h.push_context('test', 'wiki', neighborhood='Projects'):
            delattr(self.post, 'text_cache')
            html = self.md.cached_convert(self.post, 'text')
            assert html == self.expected_html

    @patch.dict('allura.lib.app_globals.config', markdown_cache_threshold='-0.01')
    def test_non_ascii(self):
        self.post.text = 'å∫ç'
        expected = '<div class="markdown_content"><p>å∫ç</p></div>'
        # test with empty cache
        assert expected == self.md.cached_convert(self.post, 'text')
        # test with primed cache
        assert expected == self.md.cached_convert(self.post, 'text')

    @patch.dict('allura.lib.app_globals.config', markdown_cache_threshold='-0.01')
    def test_empty_cache(self):
        html = self.md.cached_convert(self.post, 'text')
        assert html == self.expected_html
        assert html == self.post.text_cache.html
        assert hashlib.md5(self.post.text.encode('utf-8')).hexdigest() == self.post.text_cache.md5
        assert self.post.text_cache.render_time > 0

    @patch.dict('allura.lib.app_globals.config', markdown_cache_threshold='-0.01')
    def test_stale_cache(self):
        old = self.md.cached_convert(self.post, 'text')
        self.post.text = 'new, different source text'
        html = self.md.cached_convert(self.post, 'text')
        assert old != html
        assert html == self.post.text_cache.html
        assert hashlib.md5(self.post.text.encode('utf-8')).hexdigest() == self.post.text_cache.md5
        assert self.post.text_cache.render_time > 0

    @patch.dict('allura.lib.app_globals.config', markdown_cache_threshold='-0.01')
    def test_valid_cache(self):
        from markupsafe import Markup
        self.md.cached_convert(self.post, 'text')
        with patch.object(self.md, 'convert') as convert_func:
            html = self.md.cached_convert(self.post, 'text')
            assert html == self.expected_html
            assert isinstance(html, Markup)
            assert not convert_func.called
            self.post.text = "text [[include]] pass"
            html = self.md.cached_convert(self.post, 'text')
            assert convert_func.called

    @patch.dict('allura.lib.app_globals.config', markdown_cache_threshold='-0.01')
    def test_cacheable_macro(self):
        # cachable
        self.post.text = "text [[img src=...]] pass"
        del self.post.text_cache
        self.md.cached_convert(self.post, 'text')
        assert self.post.text_cache.html

        # cachable, its not even a macro!
        self.post.text = "text [[ blah"
        del self.post.text_cache
        self.md.cached_convert(self.post, 'text')
        assert self.post.text_cache.html

        # not cacheable
        self.post.text = "text [[include file=...]] pass"
        del self.post.text_cache
        self.md.cached_convert(self.post, 'text')
        assert not self.post.text_cache.html

        # not cacheable
        self.post.text = "text [[   \n   include file=... ]] pass"
        del self.post.text_cache
        self.md.cached_convert(self.post, 'text')
        assert not self.post.text_cache.html

    @patch.dict('allura.lib.app_globals.config', {})
    def test_no_threshold_defined(self):
        html = self.md.cached_convert(self.post, 'text')
        assert html == self.expected_html
        assert self.post.text_cache.md5 is None
        assert self.post.text_cache.html is None
        assert self.post.text_cache.render_time is None

    @patch.dict('allura.lib.app_globals.config', markdown_cache_threshold='foo')
    def test_invalid_threshold(self):
        html = self.md.cached_convert(self.post, 'text')
        assert html == self.expected_html
        assert self.post.text_cache.md5 is None
        assert self.post.text_cache.html is None
        assert self.post.text_cache.render_time is None

    @patch.dict('allura.lib.app_globals.config', markdown_cache_threshold='99999')
    def test_render_time_below_threshold(self):
        html = self.md.cached_convert(self.post, 'text')
        assert html == self.expected_html
        assert self.post.text_cache.md5 is None
        assert self.post.text_cache.html is None
        assert self.post.text_cache.render_time is None

    @patch.dict('allura.lib.app_globals.config', markdown_cache_threshold='99999')
    def test_big_text(self):
        # between the base limit and the higher .cached limit (2x by default), still renders
        self.post.text = 'a' * 50000
        html = self.md.cached_convert(self.post, 'text')
        assert not html.startswith('<pre>')
        # beyond the .cached limit, falls back to plain text
        self.post.text = 'a' * 80001
        del self.post.text_cache
        html = self.md.cached_convert(self.post, 'text')
        assert html == '<pre>%s</pre>' % self.post.text

    @patch.dict('allura.lib.app_globals.config', {})
    def test_all_expected_keys_exist_in_cache(self):
        self.md.cached_convert(self.post, 'text')
        required_keys = ['fix7528', 'html', 'md5', 'render_time']
        keys = sorted(self.post.text_cache.keys())
        assert required_keys == keys


class TestEmojis:

    def test_markdown_emoji_atomic(self):
        output = g.markdown.convert(':smile:')
        assert '<p>\U0001F604</p>' in output
        output = g.markdown.convert(':+1:')
        assert '<p>\U0001F44D</p>' in output
        output = g.markdown.convert(':Bosnia_&_Herzegovina:')
        assert '<p>\U0001F1E7\U0001F1E6</p>' in output
        output = g.markdown.convert(':Åland_Islands:')  # emoji code with non-ascii character
        assert '<p>\U0001F1E6\U0001F1FD</p>' in output

    def test_markdown_emoji_with_text(self):
        output = g.markdown.convert('Thumbs up emoji :+1: wow!')
        assert '<p>Thumbs up emoji \U0001F44D wow!</p>' in output
        output = g.markdown.convert('More emojis :+1::camel::three_o’clock: wow!')
        assert '<p>More emojis \U0001F44D\U0001F42B\U0001F552 wow!</p>' in output
        output = g.markdown.convert(':man_bouncing_ball_medium-light_skin_tone:emoji:+1:')
        assert '<p>\U000026F9\U0001F3FC\U0000200D\U00002642\U0000FE0Femoji\U0001F44D</p>' in output

    def test_markdown_emoji_in_code(self):
        output = g.markdown.convert('This will not become an emoji `:+1:`')
        assert '<p>This will not become an emoji <code>:+1:</code></p>' in output
        output = g.markdown.convert('```html\n<p>:camel:</p>\n```')
        assert ':camel:' in output
        output = g.markdown.convert('~~~\n:camel:\n~~~')
        assert '<pre><span></span><code>:camel:\n</code></pre>' in output

    def test_markdown_commit_with_emojis(self):
        output = g.markdown_commit.convert('Thumbs up emoji :+1: wow!')
        assert 'Thumbs up emoji \U0001F44D wow!' in output
        output = g.markdown.convert('More emojis :+1::camel::three_o’clock: wow!')
        assert 'More emojis \U0001F44D\U0001F42B\U0001F552 wow!' in output


class TestUserMentions:

    def test_markdown_user_mention_default(self):
        output = g.markdown.convert('Hello.. @nouser1, how are you?')
        assert 'Hello.. @nouser1, how are you?' in output
        u1 = M.User.register(dict(username='admin1'), make_project=True)
        ThreadLocalODMSession.flush_all()
        output = g.markdown.convert('Hello.. @admin1, how are you?')
        assert 'class="user-mention"' in output
        assert 'href="/u/admin1/profile/"' in output
        u2 = M.User.register(dict(username='admin-2'), make_project=True)
        ThreadLocalODMSession.flush_all()
        output = g.markdown.convert('Do you know @ab? @admin-2 has solved it!')
        assert 'Do you know @ab?' in output
        assert 'class="user-mention"' in output
        assert 'href="/u/admin-2/profile/"' in output
        output = g.markdown.convert('test@admin1.com Hey!')
        assert 'test@admin1.com Hey!' in output

    def test_markdown_user_mention_in_code(self):
        u1 = M.User.register(dict(username='admin-user-4'), make_project=True)
        ThreadLocalODMSession.flush_all()
        output = g.markdown.convert('Hello.. `@admin-user-4, how` are you?')
        assert 'class="user-mention"' not in output
        assert '<code>' in output
        assert ('href="%s"' % u1.url()) not in output
        output = g.markdown.convert('Hello.. This is code \n~~~python\nprint("@admin-user-4")\n~~~')
        assert 'class="user-mention"' not in output
        assert '<div class="codehilite">' in output
        assert ('href="%s"' % u1.url()) not in output

    @patch('allura.lib.widgets.forms.NeighborhoodProjectShortNameValidator')
    def test_markdown_user_mention_underscores(self, NeighborhoodProjectShortNameValidator):
        username = 'r_808__'
        NeighborhoodProjectShortNameValidator.to_python.return_value = username
        u1 = M.User.register(dict(username=username), make_project=True)
        ThreadLocalODMSession.flush_all()
        output = g.markdown.convert(f'Hello.. @{username}, how are you?')
        assert 'class="user-mention"' in output


class TestHandlePaging:

    def setup_method(self, method):
        prefs = {}
        c.user = Mock()

        def get_pref(name):
            return prefs.get(name)

        def set_pref(name, value):
            prefs[name] = value
        c.user.get_pref = get_pref
        c.user.set_pref = set_pref

    def test_with_limit(self):
        assert g.handle_paging(10, 0) == (10, 0, 0)
        assert g.handle_paging(10, 2) == (10, 2, 20)
        # handle paging must not mess up user preferences
        assert c.user.get_pref('results_per_page') is None
        # maximum enforced
        assert g.handle_paging(99999999, 0) == (500, 0, 0)

    def test_without_limit(self):
        # default limit = 25
        assert g.handle_paging(None, 0) == (25, 0, 0)
        assert g.handle_paging(None, 2) == (25, 2, 50)
        # handle paging must not mess up user preferences
        assert c.user.get_pref('results_per_page') is None

        # user has page size preference
        c.user.set_pref('results_per_page', 100)
        assert g.handle_paging(None, 0) == (100, 0, 0)
        assert g.handle_paging(None, 2) == (100, 2, 200)
        # handle paging must not mess up user preferences
        assert c.user.get_pref('results_per_page') == 100

    def test_without_limit_with_default(self):
        # default limit is not used when explicitly provided
        assert g.handle_paging(None, 0, 30) == (30, 0, 0)
        assert g.handle_paging(None, 2, 30) == (30, 2, 60)
        # handle paging must not mess up user preferences
        assert c.user.get_pref('results_per_page') is None

        # user has page size preference, which is not affected by default
        c.user.set_pref('results_per_page', 25)
        assert g.handle_paging(None, 0, 30) == (25, 0, 0)
        assert g.handle_paging(None, 2, 30) == (25, 2, 50)
        # handle paging must not mess up user preferences
        assert c.user.get_pref('results_per_page') == 25

    def test_with_invalid_limit(self):
        assert g.handle_paging('foo', 0, 30) == (30, 0, 0)

        c.user.set_pref('results_per_page', 'bar')
        assert g.handle_paging(None, 0, 30) == (30, 0, 0)

    def test_with_invalid_page(self):
        assert g.handle_paging(10, 'asdf', 30) == (10, 0, 0)


class TestIconRender:

    def setup_method(self, method):
        self.i = g.icons['edit']

    def test_default(self):
        html = '<a class="icon" href="#" title="Edit"><i class="fa fa-edit"></i></a>'
        assert html == self.i.render()

    def test_show_title(self):
        html = '<a class="icon" href="#" title="Edit"><i class="fa fa-edit"></i>&nbsp;Edit</a>'
        assert html == self.i.render(show_title=True)

        html = '<a class="icon" href="#" title="&lt;script&gt;"><i class="fa fa-edit"></i>&nbsp;&lt;script&gt;</a>'
        assert html == self.i.render(show_title=True, title="<script>")

    def test_extra_css(self):
        html = '<a class="icon reply btn" href="#" title="Edit"><i class="fa fa-edit"></i></a>'
        assert html == self.i.render(extra_css='reply btn')

    def test_no_closing_tag(self):
        html = '<a class="icon" href="#" title="Edit"><i class="fa fa-edit"></i>'
        assert html == self.i.render(closing_tag=False)

    def test_tag(self):
        html = '<div class="icon" title="Edit"><i class="fa fa-edit"></i></div>'
        assert html == self.i.render(tag='div')

    def test_kwargs(self):
        html = '<a class="icon" data-id="123" href="#" title="Edit"><i class="fa fa-edit"></i></a>'
        assert html == self.i.render(**{'data-id': '123'})

    def test_escaping(self):
        html = '<a class="icon &#34;" data-url="&gt;" href="#" title="Edit"><i class="fa fa-edit"></i></a>'
        assert html == self.i.render(extra_css='"', **{'data-url': '>'})
