Initial commit: Web packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit cd458d4b85
791 changed files with 410049 additions and 0 deletions

View file

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_controller
from . import test_converter
from . import test_odoo_editor
from . import test_views
from . import test_tools

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -0,0 +1,222 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import binascii
import json
from io import BytesIO
from PIL import Image
from odoo.tests.common import HttpCase, new_test_user, tagged
from odoo.tools.json import scriptsafe as json_safe
from odoo.tools.misc import file_open
from odoo.addons.http_routing.models.ir_http import slug
@tagged("-at_install", "post_install")
class TestController(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
portal_user = new_test_user(
cls.env, login="portal_user", groups="base.group_portal"
)
cls.portal = portal_user.login
admin_user = new_test_user(
cls.env, login="admin_user", groups="base.group_user,base.group_system"
)
cls.admin = admin_user.login
cls.headers = {"Content-Type": "application/json"}
cls.pixel = "R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="
def _build_payload(self, params=None):
"""
Helper to properly build jsonrpc payload
"""
return {
"jsonrpc": "2.0",
"method": "call",
"id": 0,
"params": params or {},
}
def test_01_upload_document(self):
self.authenticate("admin", "admin")
# Upload document.
response = self.url_open(
"/web_editor/attachment/add_data",
headers={"Content-Type": "application/json"},
data=json_safe.dumps(
{
"params": {
"name": "test.txt",
"data": "SGVsbG8gd29ybGQ=", # base64 Hello world
"is_image": False,
}
}
),
).json()
self.assertFalse(
"error" in response,
"Upload failed: %s" % response.get("error", {}).get("message"),
)
attachment_id = response["result"]["id"]
checksum = response["result"]["checksum"]
# Download document and check content.
response = self.url_open(
"/web/content/%s?unique=%s&download=true" % (attachment_id, checksum)
)
self.assertEqual(200, response.status_code, "Expect response")
self.assertEqual(b"Hello world", response.content, "Expect raw content")
def test_02_illustration_shape(self):
self.authenticate("admin", "admin")
# SVG with all replaceable colors.
svg = b"""
<svg viewBox="0 0 400 400">
<rect width="300" height="300" style="fill:#3AADAA;" />
<rect x="20" y="20" width="300" height="300" style="fill:#7C6576;" />
<rect x="40" y="40" width="300" height="300" style="fill:#F6F6F6;" />
<rect x="60" y="60" width="300" height="300" style="fill:#FFFFFF;" />
<rect x="80" y="80" width="300" height="300" style="fill:#383E45;" />
</svg>
"""
# Need to bypass security check to write image with mimetype image/svg+xml
context = {"binary_field_real_user": self.env["res.users"].sudo().browse([1])}
attachment = (
self.env["ir.attachment"]
.sudo()
.with_context(context)
.create(
{
"name": "test.svg",
"mimetype": "image/svg+xml",
"datas": binascii.b2a_base64(svg, newline=False),
"public": True,
"res_model": "ir.ui.view",
"res_id": 0,
}
)
)
# Shape illustration with slug.
url = "/web_editor/shape/illustration/%s" % slug(attachment)
palette = "c1=%233AADAA&c2=%237C6576&&c3=%23F6F6F6&&c4=%23FFFFFF&&c5=%23383E45"
attachment["url"] = "%s?%s" % (url, palette)
response = self.url_open(url)
self.assertEqual(200, response.status_code, "Expect response")
self.assertEqual(svg, response.content, "Expect unchanged SVG")
response = self.url_open(url + "?c1=%23ABCDEF")
self.assertEqual(200, response.status_code, "Expect response")
self.assertEqual(
len(svg), len(response.content), "Expect same length as original"
)
self.assertTrue("ABCDEF" in str(response.content), "Expect patched c1")
self.assertTrue(
"3AADAA" not in str(response.content), "Old c1 should not be there anymore"
)
# Shape illustration without slug.
url = "/web_editor/shape/illustration/noslug"
attachment["url"] = url
response = self.url_open(url)
self.assertEqual(200, response.status_code, "Expect response")
self.assertEqual(svg, response.content, "Expect unchanged SVG")
response = self.url_open(url + "?c1=%23ABCDEF")
self.assertEqual(200, response.status_code, "Expect response")
self.assertEqual(
len(svg), len(response.content), "Expect same length as original"
)
self.assertTrue("ABCDEF" in str(response.content), "Expect patched c1")
self.assertTrue(
"3AADAA" not in str(response.content), "Old c1 should not be there anymore"
)
def test_03_get_image_info(self):
gif_base64 = "R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
self.authenticate("admin", "admin")
# Upload document.
response = self.url_open(
"/web_editor/attachment/add_data",
headers={"Content-Type": "application/json"},
data=json_safe.dumps(
{
"params": {
"name": "test.gif",
"data": gif_base64,
"is_image": True,
}
}
),
).json()
self.assertFalse(
"error" in response,
"Upload failed: %s" % response.get("error", {}).get("message"),
)
attachment_id = response["result"]["id"]
image_src = response["result"]["image_src"]
mimetype = response["result"]["mimetype"]
self.assertEqual("image/gif", mimetype, "Wrong mimetype")
# Ensure image info can be retrieved.
response = self.url_open(
"/web_editor/get_image_info",
headers={"Content-Type": "application/json"},
data=json_safe.dumps(
{
"params": {
"src": image_src,
}
}
),
).json()
self.assertEqual(
attachment_id, response["result"]["original"]["id"], "Wrong id"
)
self.assertEqual(
image_src, response["result"]["original"]["image_src"], "Wrong image_src"
)
self.assertEqual(
mimetype, response["result"]["original"]["mimetype"], "Wrong mimetype"
)
def test_04_admin_attachment(self):
self.authenticate(self.admin, self.admin)
payload = self._build_payload(
{"name": "pixel", "data": self.pixel, "is_image": True}
)
response = self.url_open(
"/web_editor/attachment/add_data",
data=json.dumps(payload),
headers=self.headers,
)
self.assertEqual(200, response.status_code)
attachment = self.env["ir.attachment"].search([("name", "=", "pixel")])
self.assertTrue(attachment)
domain = [("name", "=", "pixel")]
result = attachment.search_read(domain)
self.assertTrue(len(result), "No attachment fetched")
self.assertEqual(result[0]["id"], attachment.id)
def test_font_to_img(self):
# This test was introduced because the play button was cropped in noble following some adaptation.
# This test is able to reproduce the issue and ensure that the expected result is the right one
# comparing image is not ideal, but this should work in most case, maybe adapted if the font is changed.
response = self.url_open(
"/web_editor/font_to_img/61802/rgb(0,143,140)/rgb(255,255,255)/190x200"
)
img = Image.open(BytesIO(response.content))
self.assertEqual(
img.size,
(201, 200),
"Looks strange regarding request but this is the current result",
)
# Image is a play button
img_reference = Image.open(file_open("web_editor/tests/play.png", "rb"))
self.assertEqual(img, img_reference, "Result image should be the play button")

View file

@ -0,0 +1,224 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import textwrap
from lxml import etree, html
from lxml.builder import E
from odoo.tests import common
from odoo.tests.common import BaseCase
from odoo.addons.web_editor.models.ir_qweb_fields import html_to_text
class TestHTMLToText(BaseCase):
def test_rawstring(self):
self.assertEqual(
"foobar",
html_to_text(E.div("foobar")))
def test_br(self):
self.assertEqual(
"foo\nbar",
html_to_text(E.div("foo", E.br(), "bar")))
self.assertEqual(
"foo\n\nbar\nbaz",
html_to_text(E.div(
"foo", E.br(), E.br(),
"bar", E.br(),
"baz")))
def test_p(self):
self.assertEqual(
"foo\n\nbar\n\nbaz",
html_to_text(E.div(
"foo",
E.p("bar"),
"baz")))
self.assertEqual(
"foo",
html_to_text(E.div(E.p("foo"))))
self.assertEqual(
"foo\n\nbar",
html_to_text(E.div("foo", E.p("bar"))))
self.assertEqual(
"foo\n\nbar",
html_to_text(E.div(E.p("foo"), "bar")))
self.assertEqual(
"foo\n\nbar\n\nbaz",
html_to_text(E.div(
E.p("foo"),
E.p("bar"),
E.p("baz"),
)))
def test_div(self):
self.assertEqual(
"foo\nbar\nbaz",
html_to_text(E.div(
"foo",
E.div("bar"),
"baz"
)))
self.assertEqual(
"foo",
html_to_text(E.div(E.div("foo"))))
self.assertEqual(
"foo\nbar",
html_to_text(E.div("foo", E.div("bar"))))
self.assertEqual(
"foo\nbar",
html_to_text(E.div(E.div("foo"), "bar")))
self.assertEqual(
"foo\nbar\nbaz",
html_to_text(E.div(
"foo",
E.div("bar"),
E.div("baz")
)))
def test_other_block(self):
self.assertEqual(
"foo\nbar\nbaz",
html_to_text(E.div(
"foo",
E.section("bar"),
"baz"
)))
def test_inline(self):
self.assertEqual(
"foobarbaz",
html_to_text(E.div("foo", E.span("bar"), "baz")))
def test_whitespace(self):
self.assertEqual(
"foo bar\nbaz",
html_to_text(E.div(
"foo\nbar",
E.br(),
"baz")
))
self.assertEqual(
"foo bar\nbaz",
html_to_text(E.div(
E.div(E.span("foo"), " bar"),
"baz")))
class TestConvertBack(common.TransactionCase):
def setUp(self):
super(TestConvertBack, self).setUp()
self.env = self.env(context={'inherit_branding': True})
def field_rountrip_result(self, field, value, expected):
model = 'web_editor.converter.test'
record = self.env[model].create({field: value})
t = etree.Element('t')
e = etree.Element('span')
t.append(e)
field_value = 'record.%s' % field
e.set('t-field', field_value)
rendered = self.env['ir.qweb']._render(t, {'record': record})
element = html.fromstring(rendered, parser=html.HTMLParser(encoding='utf-8'))
model = 'ir.qweb.field.' + element.get('data-oe-type', '')
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
value_back = converter.from_html(model, record._fields[field], element)
if isinstance(expected, bytes):
expected = expected.decode('utf-8')
self.assertEqual(value_back, expected)
def field_roundtrip(self, field, value):
self.field_rountrip_result(field, value, value)
def test_integer(self):
self.field_roundtrip('integer', 42)
self.field_roundtrip('integer', 42000)
def test_float(self):
self.field_roundtrip('float', 42.567890)
self.field_roundtrip('float', 324542.567890)
def test_numeric(self):
self.field_roundtrip('numeric', 42.77)
def test_char(self):
self.field_roundtrip('char', "foo bar")
self.field_roundtrip('char', "ⒸⓄⓇⒼⒺ")
def test_selection_str(self):
self.field_roundtrip('selection_str', 'B')
def test_text(self):
self.field_roundtrip('text', textwrap.dedent("""\
You must obey the dance commander
Givin' out the order for fun
You must obey the dance commander
You know that he's the only one
Who gives the orders here,
Alright
Who gives the orders here,
Alright
It would be awesome
If we could dance-a
It would be awesome, yeah
Let's take the chance-a
It would be awesome, yeah
Let's start the show
Because you never know
You never know
You never know until you go"""))
def test_m2o(self):
""" the M2O field conversion (from html) is markedly different from
others as it directly writes into the m2o and returns nothing at all.
"""
field = 'many2one'
subrec1 = self.env['web_editor.converter.test.sub'].create({'name': "Foo"})
subrec2 = self.env['web_editor.converter.test.sub'].create({'name': "Bar"})
record = self.env['web_editor.converter.test'].create({field: subrec1.id})
t = etree.Element('t')
e = etree.Element('span')
t.append(e)
field_value = 'record.%s' % field
e.set('t-field', field_value)
rendered = self.env['ir.qweb']._render(t, {'record': record})
element = html.fromstring(rendered, parser=html.HTMLParser(encoding='utf-8'))
# emulate edition
element.set('data-oe-many2one-id', str(subrec2.id))
element.text = "New content"
model = 'ir.qweb.field.' + element.get('data-oe-type')
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
value_back = converter.from_html('web_editor.converter.test', record._fields[field], element)
self.assertIsNone(
value_back, "the m2o converter should return None to avoid spurious"
" or useless writes on the parent record")
self.assertEqual(
subrec1.name,
"Foo",
"element edition can't change directly the m2o record"
)
self.assertEqual(
record.many2one.name,
"Bar",
"element edition should have been change the m2o id"
)

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
@odoo.tests.tagged("post_install", "-at_install")
class TestOdooEditor(odoo.tests.HttpCase):
def test_odoo_editor_suite(self):
self.browser_js('/web_editor/tests', "", "", login='admin', timeout=1800)

View file

@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo.tests import common, tagged
from odoo.addons.web_editor import tools
@tagged('post_install', '-at_install')
class TestVideoUtils(common.BaseCase):
urls = {
'youtube': 'https://www.youtube.com/watch?v=xCvFZrrQq7k',
'youtube_mobile': 'https://m.youtube.com/watch?v=xCvFZrrQq7k',
'vimeo': 'https://vimeo.com/395399735',
'vimeo_unlisted_video': 'https://vimeo.com/795669787/0763fdb816',
'vimeo_player': 'https://player.vimeo.com/video/395399735',
'vimeo_player_unlisted_video': 'https://player.vimeo.com/video/795669787?h=0763fdb816',
'dailymotion': 'https://www.dailymotion.com/video/x7svr6t',
'youku': 'https://v.youku.com/v_show/id_XMzY1MjY4.html?spm=a2hzp.8244740.0.0',
'instagram': 'https://www.instagram.com/p/B6dXGTxggTG/',
'dailymotion_hub_no_video': 'http://www.dailymotion.com/hub/x9q_Galatasaray',
'dailymotion_hub_#video': 'http://www.dailymotion.com/hub/x9q_Galatasaray#video=x2jvvep',
'dai.ly': 'https://dai.ly/x578has',
'dailymotion_embed': 'https://www.dailymotion.com/embed/video/x578has?autoplay=1',
'dailymotion_video_extra': 'https://www.dailymotion.com/video/x2jvvep_hakan-yukur-klip_sport',
'player_youku': 'https://player.youku.com/player.php/sid/XMTI5Mjg5NjE4MA==/v.swf',
'youku_embed': 'https://player.youku.com/embed/XNTIwMzE1MzUzNg',
}
def test_player_regexes(self):
#youtube
self.assertIsNotNone(re.search(tools.player_regexes['youtube'], TestVideoUtils.urls['youtube']))
#vimeo
self.assertIsNotNone(re.search(tools.player_regexes['vimeo'], TestVideoUtils.urls['vimeo']))
self.assertIsNotNone(re.search(tools.player_regexes['vimeo'], TestVideoUtils.urls['vimeo_unlisted_video']))
self.assertIsNotNone(re.search(tools.player_regexes['vimeo_player'], TestVideoUtils.urls['vimeo_player']))
self.assertIsNotNone(re.search(tools.player_regexes['vimeo_player'], TestVideoUtils.urls['vimeo_player_unlisted_video']))
#dailymotion
self.assertIsNotNone(re.search(tools.player_regexes['dailymotion'], TestVideoUtils.urls['dailymotion']))
#youku
self.assertIsNotNone(re.search(tools.player_regexes['youku'], TestVideoUtils.urls['youku']))
#instagram
self.assertIsNotNone(re.search(tools.player_regexes['instagram'], TestVideoUtils.urls['instagram']))
def test_get_video_source_data(self):
self.assertEqual(3, len(tools.get_video_source_data(TestVideoUtils.urls['youtube'])))
#youtube
self.assertEqual('youtube', tools.get_video_source_data(TestVideoUtils.urls['youtube'])[0])
self.assertEqual('xCvFZrrQq7k', tools.get_video_source_data(TestVideoUtils.urls['youtube'])[1])
self.assertEqual('youtube', tools.get_video_source_data(TestVideoUtils.urls['youtube_mobile'])[0])
self.assertEqual('xCvFZrrQq7k', tools.get_video_source_data(TestVideoUtils.urls['youtube_mobile'])[1])
#vimeo
self.assertEqual('vimeo', tools.get_video_source_data(TestVideoUtils.urls['vimeo'])[0])
self.assertEqual('395399735', tools.get_video_source_data(TestVideoUtils.urls['vimeo'])[1])
self.assertEqual('vimeo', tools.get_video_source_data(TestVideoUtils.urls['vimeo_unlisted_video'])[0])
self.assertEqual('795669787', tools.get_video_source_data(TestVideoUtils.urls['vimeo_unlisted_video'])[1])
self.assertEqual('vimeo', tools.get_video_source_data(TestVideoUtils.urls['vimeo_player'])[0])
self.assertEqual('395399735', tools.get_video_source_data(TestVideoUtils.urls['vimeo_player'])[1])
self.assertEqual('vimeo', tools.get_video_source_data(TestVideoUtils.urls['vimeo_player_unlisted_video'])[0])
self.assertEqual('795669787', tools.get_video_source_data(TestVideoUtils.urls['vimeo_player_unlisted_video'])[1])
#dailymotion
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dailymotion'])[0])
self.assertEqual('x7svr6t', tools.get_video_source_data(TestVideoUtils.urls['dailymotion'])[1])
self.assertEqual(None, tools.get_video_source_data(TestVideoUtils.urls['dailymotion_hub_no_video']))
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_hub_#video'])[0])
self.assertEqual('x2jvvep', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_hub_#video'])[1])
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dai.ly'])[0])
self.assertEqual('x578has', tools.get_video_source_data(TestVideoUtils.urls['dai.ly'])[1])
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_embed'])[0])
self.assertEqual('x578has', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_embed'])[1])
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_video_extra'])[0])
self.assertEqual('x2jvvep', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_video_extra'])[1])
#youku
self.assertEqual('youku', tools.get_video_source_data(TestVideoUtils.urls['youku'])[0])
self.assertEqual('XMzY1MjY4', tools.get_video_source_data(TestVideoUtils.urls['youku'])[1])
self.assertEqual('youku', tools.get_video_source_data(TestVideoUtils.urls['player_youku'])[0])
self.assertEqual('XMTI5Mjg5NjE4MA', tools.get_video_source_data(TestVideoUtils.urls['player_youku'])[1])
self.assertEqual('youku', tools.get_video_source_data(TestVideoUtils.urls['youku_embed'])[0])
self.assertEqual('XNTIwMzE1MzUzNg', tools.get_video_source_data(TestVideoUtils.urls['youku_embed'])[1])
#instagram
self.assertEqual('instagram', tools.get_video_source_data(TestVideoUtils.urls['instagram'])[0])
self.assertEqual('B6dXGTxggTG', tools.get_video_source_data(TestVideoUtils.urls['instagram'])[1])
def test_get_video_url_data(self):
self.assertEqual(2, len(tools.get_video_url_data(TestVideoUtils.urls['youtube'])))
#youtube
self.assertEqual('youtube', tools.get_video_url_data(TestVideoUtils.urls['youtube'])['platform'])
#vimeo
for key in ['vimeo', 'vimeo_player']:
self.assertEqual(tools.get_video_url_data(TestVideoUtils.urls[key]), {
'platform': 'vimeo',
'embed_url': '//player.vimeo.com/video/395399735?autoplay=0'
})
for key in ['vimeo_unlisted_video', 'vimeo_player_unlisted_video']:
self.assertEqual(tools.get_video_url_data(TestVideoUtils.urls[key]), {
'platform': 'vimeo',
'embed_url': '//player.vimeo.com/video/795669787?autoplay=0&h=0763fdb816'
})
#dailymotion
self.assertEqual('dailymotion', tools.get_video_url_data(TestVideoUtils.urls['dailymotion'])['platform'])
#youku
self.assertEqual('youku', tools.get_video_url_data(TestVideoUtils.urls['youku'])['platform'])
#instagram
self.assertEqual('instagram', tools.get_video_url_data(TestVideoUtils.urls['instagram'])['platform'])
def test_valid_video_url(self):
self.assertIsNotNone(re.search(tools.valid_url_regex, TestVideoUtils.urls['youtube']))
@tagged('-standard', 'external')
class TestVideoUtilsExternal(common.BaseCase):
def test_get_video_thumbnail(self):
#youtube
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['youtube']), bytes)
#vimeo
for key in ['vimeo', 'vimeo_unlisted_video', 'vimeo_player', 'vimeo_player_unlisted_video']:
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls[key]), bytes)
#dailymotion
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['dailymotion']), bytes)
#instagram
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['instagram']), bytes)
#default
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['youku']), bytes)

View file

@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import TransactionCase
class TestViews(TransactionCase):
def setUp(self):
super().setUp()
View = self.env['ir.ui.view']
self.first_view = View.create({
'name': 'Test View 1',
'type': 'qweb',
'arch': '<div>Hello World</div>',
'key': 'web_editor.test_first_view',
})
self.second_view = View.create({
'name': 'Test View 2',
'type': 'qweb',
'arch': '<div><t t-call="web_editor.test_first_view"/></div>',
'key': 'web_editor.test_second_view',
})
def test_infinite_inherit_loop(self):
# Creates an infinite loop: A t-call B and A inherit from B
View = self.env['ir.ui.view']
self.second_view.write({
'inherit_id': self.first_view.id,
})
# Test for RecursionError: maximum recursion depth exceeded in this function
View._views_get(self.first_view)
def test_oe_structure_as_inherited_view(self):
View = self.env['ir.ui.view']
base = View.create({
'name': 'Test View oe_structure',
'type': 'qweb',
'arch': """<xpath expr='//t[@t-call="web_editor.test_first_view"]' position='after'>
<div class="oe_structure" id='oe_structure_test_view_oe_structure'/>
</xpath>""",
'key': 'web_editor.oe_structure_view',
'inherit_id': self.second_view.id
})
# check view mode
self.assertEqual(base.mode, 'extension')
# update content of the oe_structure
value = '''<div class="oe_structure" id="oe_structure_test_view_oe_structure" data-oe-id="%s"
data-oe-xpath="/div" data-oe-model="ir.ui.view" data-oe-field="arch">
<p>Hello World!</p>
</div>''' % base.id
base.save(value=value, xpath='/xpath/div')
self.assertEqual(len(base.inherit_children_ids), 1)
self.assertEqual(base.inherit_children_ids.mode, 'extension')
self.assertIn(
'<p>Hello World!</p>',
base.inherit_children_ids.get_combined_arch(),
)
def test_find_available_name(self):
View = self.env['ir.ui.view']
used_names = ['Unrelated name']
initial_name = "Test name"
name = View._find_available_name(initial_name, used_names)
self.assertEqual(initial_name, name)
used_names.append(name)
name = View._find_available_name(initial_name, used_names)
self.assertEqual('Test name (2)', name)
used_names.append(name)
name = View._find_available_name(initial_name, used_names)
self.assertEqual('Test name (3)', name)