Add new test assertions to WagtailPageTestCase

- Add assertions, and move them to a new TestCase that allows use without forcing authentication for every test
- Add routes and preview modes to RoutablePageTest to facilitate testing
- Move assertion tests out of admin app
- Add custom test assertions for pages
- Use default value for exclude_csrf in assertPageIsEditable
- Use publish action when posting in assertPageIsEditable for better coverage
- Update assertPageIsEditable to always make both a GET and POST request
pull/9388/head
Andy Babic 2022-10-05 18:29:33 +01:00 zatwierdzone przez LB (Ben Johnston)
rodzic 284535166a
commit f6a92bf7d2
5 zmienionych plików z 747 dodań i 5 usunięć

Wyświetl plik

@ -1,6 +1,8 @@
from django.http import HttpResponse
from django.shortcuts import redirect
from wagtail.contrib.routable_page.models import RoutablePage, path, re_path, route
from wagtail.models import PreviewableMixin
def routable_page_external_view(request, arg="ARG NOT SET"):
@ -29,6 +31,14 @@ class RoutablePageTest(RoutablePage):
def archive_by_category(self, request, category_slug):
return HttpResponse("ARCHIVE BY CATEGORY: " + category_slug)
@route(r"^permanant-homepage-redirect/$")
def permanent_homepage_redirect(self, request):
return redirect("/", permanent=True)
@route(r"^temporary-homepage-redirect/$")
def temporary_homepage_redirect(self, request):
return redirect("/", permanent=False)
@route(r"^external/(.+)/$")
@route(r"^external-no-arg/$")
def external_view(self, *args, **kwargs):
@ -59,6 +69,16 @@ class RoutablePageTest(RoutablePage):
"not-a-valid-route",
]
preview_modes = PreviewableMixin.DEFAULT_PREVIEW_MODES + [
("extra", "Extra"),
("broken", "Broken"),
]
def serve_preview(self, request, mode_name):
if mode_name == "broken":
raise AttributeError("Something is broken!")
return super().serve_preview(request, mode_name)
class RoutablePageWithOverriddenIndexRouteTest(RoutablePage):
@route(r"^$")

Wyświetl plik

@ -5,6 +5,8 @@ page types, it can be difficult to construct this data structure by hand;
the ``wagtail.test.utils.form_data`` module provides a set of helper
functions to assist with this.
"""
import bs4
from django.http import QueryDict
from wagtail.admin.rich_text import get_rich_text_editor_widget
@ -137,3 +139,62 @@ def rich_text(value, editor="default", features=None):
"""
widget = get_rich_text_editor_widget(editor, features)
return widget.format_value(value)
def _querydict_from_form(form: bs4.Tag, exclude_csrf: bool = True) -> QueryDict:
data = QueryDict(mutable=True)
for input in form.find_all("input"):
name = input.attrs.get("name")
if (
name
and input.attrs.get("type", "") not in ("checkbox", "radio")
and (not exclude_csrf or name != "csrfmiddlewaretoken")
):
data[name] = input.attrs.get("value", "")
for input in form.find_all("input", type="radio", checked=True):
name = input.attrs.get("name")
if name:
data[name] = input.attrs.get("value")
for input in form.find_all("input", type="checkbox", checked=True):
name = input.attrs.get("name")
if name:
data.appendlist(name, input.attrs.get("value", ""))
for textarea in form.find_all("textarea"):
name = textarea.attrs.get("name")
if name:
data[name] = textarea.get_text()
for select in form.find_all("select"):
name = select.attrs.get("name")
if name:
selected_value = False
for option in select.find_all("option", selected=True):
selected_value = True
data.appendlist(name, option.attrs.get("value", option.get_text()))
if not selected_value:
first_option = select.find("option")
if first_option:
data[name] = first_option.attrs.get(
"value", first_option.get_text()
)
return data
def querydict_from_html(
html: str, form_id: str = None, form_index: int = 0, exclude_csrf: bool = True
) -> QueryDict:
soup = bs4.BeautifulSoup(html, "html5lib")
if form_id is not None:
form = soup.find("form", attrs={"id": form_id})
if form is None:
raise ValueError(f'No form was found with id "{form_id}".')
return _querydict_from_form(form, exclude_csrf)
else:
index = int(form_index)
for i, form in enumerate(soup.find_all("form", limit=index + 1)):
if i == index:
return _querydict_from_form(form, exclude_csrf)
raise ValueError(f"No form was found with index: {form_index}.")

Wyświetl plik

@ -1,18 +1,32 @@
from typing import Any, Dict, Optional
from unittest import mock
from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.http import Http404
from django.test import TestCase
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.text import slugify
from wagtail.coreutils import get_dummy_request
from wagtail.models import Page
from .form_data import querydict_from_html
from .wagtail_tests import WagtailTestUtils
AUTH_BACKEND = settings.AUTHENTICATION_BACKENDS[0]
class WagtailPageTests(WagtailTestUtils, TestCase):
class WagtailPageTestCase(WagtailTestUtils, TestCase):
"""
A set of asserts to help write tests for your own Wagtail site.
A set of assertions to help write tests for custom Wagtail page types
"""
def setUp(self):
super().setUp()
self.login()
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.dummy_request = get_dummy_request()
def _testCanCreateAt(self, parent_model, child_model):
return child_model in parent_model.allowed_subpage_models()
@ -148,3 +162,288 @@ class WagtailPageTests(WagtailTestUtils, TestCase):
self.assertEqual(
set(child_model.allowed_parent_page_models()), set(parent_models), msg=msg
)
def assertPageIsRoutable(
self,
page: Page,
route_path: Optional[str] = "/",
msg: Optional[str] = None,
):
"""
Asserts that ``page`` can be routed to without raising a ``Http404`` error.
For page types with multiple routes, you can use ``route_path`` to specify an alternate route to test.
"""
path = page.get_url(self.dummy_request)
if route_path != "/":
path = path.rstrip("/") + "/" + route_path.lstrip("/")
site = page.get_site()
if site is None:
msg = self._formatMessage(
msg,
'Failed to route to "%s" for %s "%s". The page does not belong to any sites.'
% (type(page).__name__, route_path, page),
)
raise self.failureException(msg)
path_components = [component for component in path.split("/") if component]
try:
page, args, kwargs = site.root_page.localized.specific.route(
self.dummy_request, path_components
)
except Http404:
msg = self._formatMessage(
msg,
'Failed to route to "%(route_path)s" for %(page_type)s "%(page)s". A Http404 was raised for path: "%(full_path)s".'
% {
"route_path": route_path,
"page_type": type(page).__name__,
"page": page,
"full_path": path,
},
)
raise self.failureException(msg)
def assertPageIsRenderable(
self,
page: Page,
route_path: Optional[str] = "/",
query_data: Optional[Dict[str, Any]] = None,
post_data: Optional[Dict[str, Any]] = None,
user: Optional[AbstractBaseUser] = None,
accept_404: Optional[bool] = False,
accept_redirect: Optional[bool] = False,
msg: Optional[str] = None,
):
"""
Asserts that ``page`` can be rendered without raising a fatal error.
For page types with multiple routes, you can use ``route_path`` to specify an alternate route to test.
When ``post_data`` is provided, the test makes a ``POST`` request with ``post_data`` in the request body. Otherwise, a ``GET`` request is made.
When supplied, ``query_data`` is converted to a querystring and added to the request URL (regardless of whether ``post_data`` is provided).
When ``user`` is provided, the test is conducted with them as the active user.
By default, the assertion will fail if the request to the page URL results in a 301, 302 or 404 HTTP response. If you are testing a page/route
where a 404 response is expected, you can use ``accept_404=True`` to indicate this, and the assertion will pass when encountering a 404. Likewise,
if you are testing a page/route where a redirect response is expected, you can use `accept_redirect=True` to indicate this, and the assertion will
pass when encountering 301 or 302.
"""
if user:
self.client.force_login(user, AUTH_BACKEND)
path = page.get_url(self.dummy_request)
if route_path != "/":
path = path.rstrip("/") + "/" + route_path.lstrip("/")
post_kwargs = {}
if post_data is not None:
post_kwargs = {"data": post_data}
if query_data:
post_kwargs["QUERYSTRING"] = urlencode(query_data, doseq=True)
try:
if post_data is None:
resp = self.client.get(path, data=query_data)
else:
resp = self.client.post(path, **post_kwargs)
except Exception as e:
msg = self._formatMessage(
msg,
'Failed to render route "%(route_path)s" for %(page_type)s "%(page)s":\n%(exc)s'
% {
"route_path": route_path,
"page_type": type(page).__name__,
"page": page,
"exc": e,
},
)
raise self.failureException(msg)
finally:
if user:
self.client.logout()
if (
resp.status_code == 200
or (accept_404 and resp.status_code == 404)
or (accept_redirect and resp.status_code in (301, 302))
or isinstance(resp, mock.MagicMock)
):
return
msg = self._formatMessage(
msg,
'Failed to render route "%(route_path)s" for %(page_type)s "%(page)s":\nA HTTP %(code)s response was received for path: "%(full_path)s".'
% {
"route_path": route_path,
"page_type": type(page).__name__,
"page": page,
"code": resp.status_code,
"full_path": path,
},
)
raise self.failureException(msg)
def assertPageIsEditable(
self,
page: Page,
post_data: Optional[Dict[str, Any]] = None,
user: Optional[AbstractBaseUser] = None,
msg: Optional[str] = None,
):
"""
Asserts that the page edit view works for ``page`` without raising a fatal error.
When ``user`` is provided, the test is conducted with them as the active user. Otherwise, a superuser is created and used for the test.
After a successful ``GET`` request, a ``POST`` request is made with field data in the request body. If ``post_data`` is provided, that will be used for this purpose. If not, this data will be extracted from the ``GET`` response HTML.
"""
if user:
# rule out permission issues early on
if not page.permissions_for_user(user).can_edit():
self._formatMessage(
msg,
'Failed to load edit view for %(page_type)s "%(page)s":\nUser "%(user)s" have insufficient permissions.'
% {
"page_type": type(page).__name__,
"page": page,
"user": user,
},
)
raise self.failureException(msg)
else:
if not hasattr(self, "_pageiseditable_superuser"):
self._pageiseditable_superuser = self.create_superuser(
"assertpageiseditable"
)
user = self._pageiseditable_superuser
self.client.force_login(user, AUTH_BACKEND)
path = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.id})
try:
response = self.client.get(path)
except Exception as e:
self.client.logout()
msg = self._formatMessage(
msg,
'Failed to load edit view via GET for %(page_type)s "%(page)s":\n%(exc)s'
% {"page_type": type(page).__name__, "page": page, "exc": e},
)
raise self.failureException(msg)
if response.status_code != 200:
self.client.logout()
msg = self._formatMessage(
msg,
'Failed to load edit view via GET for %(page_type)s "%(page)s":\nReceived response with HTTP status code: %(code)s.'
% {
"page_type": type(page).__name__,
"page": page,
"code": response.status_code,
},
)
raise self.failureException(msg)
if post_data is not None:
data_to_post = post_data
else:
data_to_post = querydict_from_html(
response.content.decode(), form_id="page-edit-form"
)
data_to_post["action-publish"] = ""
try:
response = self.client.post(path, data_to_post)
except Exception as e:
msg = self._formatMessage(
msg,
'Failed to load edit view via POST for %(page_type)s "%(page)s":\n%(exc)s'
% {"page_type": type(page).__name__, "page": page, "exc": e},
)
raise self.failureException(msg)
finally:
page.save() # undo any changes to page
self.client.logout()
def assertPageIsPreviewable(
self,
page: Page,
mode: Optional[str] = "",
post_data: Optional[Dict[str, Any]] = None,
user: Optional[AbstractBaseUser] = None,
msg: Optional[str] = None,
):
"""
Asserts that the page preview view can be loaded for ``page`` without raising a fatal error.
For page types that support multiple preview modes, ``mode`` can be used to specify the preview mode to be tested.
When ``user`` is provided, the test is conducted with them as the active user. Otherwise, a superuser is created and used for the test.
To load the preview, the test client needs to make a ``POST`` request including all required field data in the request body.
If ``post_data`` is provided, that will be used for this purpose. If not, the method will attempt to extract this data from the page edit view.
"""
if not user:
if not hasattr(self, "_pageispreviewable_superuser"):
self._pageispreviewable_superuser = self.create_superuser(
"assertpageispreviewable"
)
user = self._pageispreviewable_superuser
self.client.force_login(user, AUTH_BACKEND)
if post_data is None:
edit_path = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.id})
html = self.client.get(edit_path).content.decode()
post_data = querydict_from_html(html, form_id="page-edit-form")
preview_path = reverse(
"wagtailadmin_pages:preview_on_edit", kwargs={"page_id": page.id}
)
try:
response = self.client.post(
preview_path, data=post_data, QUERYSTRING=f"mode={mode}"
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
response.content.decode(),
{"is_valid": True, "is_available": True},
)
except Exception as e:
self.client.logout()
msg = self._formatMessage(
msg,
'Failed to load preview for %(page_type)s "%(page)s" with mode="%(mode)s":\n%(exc)s'
% {
"page_type": type(page).__name__,
"page": page,
"mode": mode,
"exc": e,
},
)
raise self.failureException(msg)
try:
self.client.get(preview_path, data={"mode": mode})
except Exception as e:
msg = self._formatMessage(
msg,
'Failed to load preview for %(page_type)s "%(page)s" with mode="%(mode)s":\n%(exc)s'
% {
"page_type": type(page).__name__,
"page": page,
"mode": mode,
"exc": e,
},
)
raise self.failureException(msg)
finally:
self.client.logout()
class WagtailPageTests(WagtailPageTestCase):
def setUp(self):
super().setUp()
self.login()

Wyświetl plik

@ -0,0 +1,189 @@
from django.test import SimpleTestCase
from wagtail.test.utils.form_data import querydict_from_html
class TestQueryDictFromHTML(SimpleTestCase):
html = """
<form id="personal-details">
<input type="hidden" name="csrfmiddlewaretoken" value="Z783HTL5Bc2J54WhAtEeR3eefM1FBkq0EbTfNnYnepFGuJSOfvosFvwjeKYtMwFr">
<input type="hidden" name="no_value_input">
<input type="hidden" value="no name input">
<div class="mt-8 max-w-md">
<div class="grid grid-cols-1 gap-6">
<label class="block">
<span class="text-gray-700">Full name</span>
<input type="text" name="name" value="Jane Doe" class="mt-1 block w-full" placeholder="">
</label>
<label class="block">
<span class="text-gray-700">Email address</span>
<input type="email" name="email" class="mt-1 block w-full" value="jane@example.com" placeholder="name@example.com">
</label>
</div>
</div>
</form>
<form id="event-details">
<div class="mt-8 max-w-md">
<div class="grid grid-cols-1 gap-6">
<label class="block">
<span class="text-gray-700">When is your event?</span>
<input type="date" name="date" class="mt-1 block w-full" value="2023-01-01">
</label>
<label class="block">
<span class="text-gray-700">What type of event is it?</span>
<select name="event_type" class="block w-full mt-1">
<option value="corporate">Corporate event</option>
<option value="wedding">Wedding</option>
<option value="birthday">Birthday</option>
<option value="other" selected>Other</option>
</select>
</label>
<label class="block">
<span class="text-gray-700">What age groups is it suitable for?</span>
<select name="ages" class="block w-full mt-1" multiple>
<option>Infants</option>
<option>Children</option>
<option>Teenagers</option>
<option selected>18-30</option>
<option selected>30-50</option>
<option>50-70</option>
<option>70+</option>
</select>
</label>
</div>
</div>
</form>
<form id="market-research">
<div class="mt-8 max-w-md">
<div class="grid grid-cols-1 gap-6">
<fieldset class="block">
<legend>How many pets do you have?</legend>
<div class="radio-list">
<div class="radio">
<label>
<input type="radio" name="pets" value="0" />
None
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="pets" value="1" />
One
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="pets" value="2" checked />
Two
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="pets" value="3+" />
Three or more
</label>
</div>
</div>
</fieldset>
<fieldset class="block">
<legend>Which two colours do you like best?</legend>
<div class="checkbox-list">
<div class="checkbox">
<label>
<input type="checkbox" name="colours" value="cyan">
Cyan
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="colours" value="magenta" checked />
Magenta
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="colours" value="yellow" />
Yellow
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="colours" value="black" checked />
Black
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="colours" value="white" />
White
</label>
</div>
</div>
</fieldset>
<label class="block">
<span class="text-gray-700">Tell us what you love</span>
<textarea name="love" class="mt-1 block w-full" rows="3">Comic books</textarea>
</label>
</div>
</div>
</form>
"""
personal_details = [
("no_value_input", [""]),
("name", ["Jane Doe"]),
("email", ["jane@example.com"]),
]
event_details = [
("date", ["2023-01-01"]),
("event_type", ["other"]),
("ages", ["18-30", "30-50"]),
]
market_research = [
("pets", ["2"]),
("colours", ["magenta", "black"]),
("love", ["Comic books"]),
]
def test_html_only(self):
# data should be extracted from the 'first' form by default
result = querydict_from_html(self.html)
self.assertEqual(list(result.lists()), self.personal_details)
def test_include_csrf(self):
result = querydict_from_html(self.html, exclude_csrf=False)
expected_result = [
(
"csrfmiddlewaretoken",
["Z783HTL5Bc2J54WhAtEeR3eefM1FBkq0EbTfNnYnepFGuJSOfvosFvwjeKYtMwFr"],
)
] + self.personal_details
self.assertEqual(list(result.lists()), expected_result)
def test_form_index(self):
for index, expected_data in (
(0, self.personal_details),
("2", self.market_research),
(1, self.event_details),
):
result = querydict_from_html(self.html, form_index=index)
self.assertEqual(list(result.lists()), expected_data)
def test_form_id(self):
for id, expected_data in (
("event-details", self.event_details),
("personal-details", self.personal_details),
("market-research", self.market_research),
):
result = querydict_from_html(self.html, form_id=id)
self.assertEqual(list(result.lists()), expected_data)
def test_invalid_form_id(self):
with self.assertRaises(ValueError):
querydict_from_html(self.html, form_id="invalid-id")
def test_invalid_index(self):
with self.assertRaises(ValueError):
querydict_from_html(self.html, form_index=5)

Wyświetl plik

@ -0,0 +1,173 @@
from unittest import mock
from django.conf import settings
from wagtail.models import Page
from wagtail.test.routablepage.models import RoutablePageTest
from wagtail.test.utils import WagtailPageTestCase
class TestCustomPageAssertions(WagtailPageTestCase):
@classmethod
def setUpTestData(cls):
cls.superuser = cls.create_superuser("super")
def setUp(self):
self.parent = Page.objects.get(id=2)
self.page = RoutablePageTest(
title="Hello world!",
slug="hello-world",
)
self.parent.add_child(instance=self.page)
def test_is_routable(self):
self.assertPageIsRoutable(self.page)
def test_is_routable_with_alternative_route(self):
self.assertPageIsRoutable(self.page, "archive/year/1984/")
def test_is_routable_fails_for_draft_page(self):
self.page.live = False
self.page.save()
with self.assertRaises(self.failureException):
self.assertPageIsRoutable(self.page)
def test_is_routable_fails_for_invalid_route_path(self):
with self.assertRaises(self.failureException):
self.assertPageIsRoutable(self.page, "invalid-route-path/")
@mock.patch("django.test.testcases.Client.get")
@mock.patch("django.test.testcases.Client.force_login")
def test_is_renderable(self, mocked_force_login, mocked_get):
self.assertPageIsRenderable(self.page)
mocked_force_login.assert_not_called()
mocked_get.assert_called_once_with("/hello-world/", data=None)
@mock.patch("django.test.testcases.Client.get")
@mock.patch("django.test.testcases.Client.force_login")
def test_is_renderable_for_alternative_route(self, mocked_force_login, mocked_get):
self.assertPageIsRenderable(self.page, "archive/year/1984/")
mocked_force_login.assert_not_called()
mocked_get.assert_called_once_with("/hello-world/archive/year/1984/", data=None)
@mock.patch("django.test.testcases.Client.get")
@mock.patch("django.test.testcases.Client.force_login")
def test_is_renderable_for_user(self, mocked_force_login, mocked_get):
self.assertPageIsRenderable(self.page, user=self.superuser)
mocked_force_login.assert_called_once_with(
self.superuser, settings.AUTHENTICATION_BACKENDS[0]
)
mocked_get.assert_called_once_with("/hello-world/", data=None)
@mock.patch("django.test.testcases.Client.get")
def test_is_renderable_with_query_data(self, mocked_get):
query_data = {"p": 1, "q": "test"}
self.assertPageIsRenderable(self.page, query_data=query_data)
mocked_get.assert_called_once_with("/hello-world/", data=query_data)
@mock.patch("django.test.testcases.Client.post")
def test_is_renderable_with_query_and_post_data(self, mocked_post):
query_data = {"p": 1, "q": "test"}
post_data = {"subscribe": True}
self.assertPageIsRenderable(
self.page, query_data=query_data, post_data=post_data
)
mocked_post.assert_called_once_with(
"/hello-world/", data=post_data, QUERYSTRING="p=1&q=test"
)
def test_is_renderable_for_draft_page(self):
self.page.live = False
self.page.save()
# When accept_404 is False (the default) the test should fail
with self.assertRaises(self.failureException):
self.assertPageIsRenderable(self.page)
# When accept_404 is True, the test should pass
self.assertPageIsRenderable(self.page, accept_404=True)
def test_is_renderable_for_invalid_route_path(self):
# When accept_404 is False (the default) the test should fail
with self.assertRaises(self.failureException):
self.assertPageIsRenderable(self.page, "invalid-route-path/")
# When accept_404 is True, the test should pass
self.assertPageIsRenderable(self.page, "invalid-route-path/", accept_404=True)
def test_is_rendereable_accept_redirect(self):
redirect_route_paths = [
"permanant-homepage-redirect/",
"temporary-homepage-redirect/",
]
# When accept_redirect is False (the default) the tests should fail
for route_path in redirect_route_paths:
with self.assertRaises(self.failureException):
self.assertPageIsRenderable(self.page, route_path)
# When accept_redirect is True, the tests should pass
for route_path in redirect_route_paths:
self.assertPageIsRenderable(self.page, route_path, accept_redirect=True)
def test_is_editable(self):
self.assertPageIsEditable(self.page)
@mock.patch("django.test.testcases.Client.force_login")
def test_is_editable_always_authenticates(self, mocked_force_login):
try:
self.assertPageIsEditable(self.page)
except self.failureException:
pass
mocked_force_login.assert_called_with(
self._pageiseditable_superuser, settings.AUTHENTICATION_BACKENDS[0]
)
try:
self.assertPageIsEditable(self.page, user=self.superuser)
except self.failureException:
pass
mocked_force_login.assert_called_with(
self.superuser, settings.AUTHENTICATION_BACKENDS[0]
)
@mock.patch("django.test.testcases.Client.get")
@mock.patch("django.test.testcases.Client.force_login")
def test_is_editable_with_permission_lacking_user(
self, mocked_force_login, mocked_get
):
user = self.create_user("bob")
with self.assertRaises(self.failureException):
self.assertPageIsEditable(self.page, user=user)
mocked_force_login.assert_not_called()
mocked_get.assert_not_called()
def test_is_editable_with_post_data(self):
self.assertPageIsEditable(
self.page,
post_data={
"title": "Goodbye world?",
"slug": "goodbye-world",
"content": "goodbye",
},
)
def test_is_previewable(self):
self.assertPageIsPreviewable(self.page)
def test_is_previewable_with_post_data(self):
self.assertPageIsPreviewable(
self.page, post_data={"title": "test", "slug": "test"}
)
def test_is_previewable_with_custom_user(self):
self.assertPageIsPreviewable(self.page, user=self.superuser)
def test_is_previewable_for_alternative_mode(self):
self.assertPageIsPreviewable(self.page, mode="extra")
def test_is_previewable_for_broken_mode(self):
with self.assertRaises(self.failureException):
self.assertPageIsPreviewable(self.page, mode="broken")