Source code for dal_alight.test
"""Test helpers for dal_alight — mirrors dal_select2.test without select2."""
import time
from dal.test import stories
[docs]
class AlightStory:
"""CSS selectors and wait logic for the autocomplete-light web component.
Drop-in replacement for ``Select2Story``. Mix into
``AutocompleteTestCase`` subclasses instead of ``Select2Story``.
HTML structure produced by ``AlightWidgetMixin``::
<autocomplete-select>
<input type="hidden" name="{field}" value="1"
slot="values" data-label="Label">
<span slot="deck">
<div data-value="1">Label<span class="clear">×</span></div>
</span>
<autocomplete-select-input slot="input" url="…">
<input id="id_{field}" name="{field}-input" slot="input" />
</autocomplete-select-input>
</autocomplete-select>
The dropdown box is appended to ``<body>`` by the component::
<div class="autocomplete-light-box">
<div data-value="1">Label</div>
<div data-create data-value="foo">Create "foo"</div>
</div>
"""
# Text input embedded inside the web component.
input_selector = 'autocomplete-select-input input'
# Floating dropdown box appended to body.
dropdown_selector = '.autocomplete-light-box'
# Selectable result items (excludes create / group headers).
option_selector = '.autocomplete-light-box [data-value]:not([data-create])'
# Create-on-the-fly entry only (has data-create attribute).
create_option_selector = '.autocomplete-light-box [data-create]'
# Currently selected value label in the deck (single-select).
label_selector = 'autocomplete-select [slot=deck] [data-value]'
# Deck items (multiple-select).
labels_selector = 'autocomplete-select [slot=deck] [data-value]'
# Clear (×) button inside a deck item.
clear_selector = '.clear'
# Clicking this opens the dropdown (same as input_selector for alight).
widget_selector = 'autocomplete-select-input input'
# The outer web component element.
container_selector = 'autocomplete-select'
[docs]
def get_create_option_selector(self, name):
"""Return a selector for the create option that matches ``name`` exactly.
Using ``[data-value="name"]`` prevents clicking a stale create option
left over from a previous query before the new XHR has returned.
"""
escaped = name.replace('"', '\\"')
return f'.autocomplete-light-box [data-create][data-value="{escaped}"]'
[docs]
def wait_script(self):
"""Wait until all alight web components have finished connectedCallback.
autocomplete-select.connectedCallback fires before its child
autocomplete-select-input's callback (parent-first connection order),
so it schedules a 100 ms retry. We must wait for BOTH elements to
have data-bound before interacting: autocomplete-select installs the
autocompleteChoiceSelected listener only on its own retry, and
clicking an option before that listener exists silently does nothing.
"""
tries = 100
while tries:
try:
result = self.browser.evaluate_script(
"customElements.get('autocomplete-select') !== undefined"
" && !document.querySelector("
"'autocomplete-select-input:not([data-bound]),"
" autocomplete-select:not([data-bound])') "
)
if result:
return result
except Exception:
pass
time.sleep(0.15)
tries -= 1
raise Exception(
'autocomplete-select custom element was not defined after 15 seconds.'
)
[docs]
def toggle_autocomplete_widget(self, selector):
"""Open the autocomplete, ensuring focus fires even if already focused.
For alight, ``focus`` on the text input is what triggers the XHR.
When the input already has focus (e.g. after a previous call in
``refresh_autocomplete``), a plain Selenium click does not re-fire
``focus``. Blurring first resets the focus state so the subsequent
click fires a real ``focus`` event and starts a fresh XHR.
"""
self.browser.execute_script(
"""
var el = document.querySelector(arguments[0]);
if (el && document.activeElement === el) { el.blur(); }
""",
selector,
)
self.click(selector)
[docs]
def clean_label(self, label):
"""Remove the × clear button text from a label string."""
return label.replace('×', '').strip()
[docs]
class AlightSelectOption(stories.SelectOption):
"""Single-select user story for the alight backend."""
def _values_selector(self):
"""Hidden inputs live in the component; Django's id is on the search input."""
return 'autocomplete-select:has(%s) [slot=values]' % self.field_selector
[docs]
def get_value(self):
fields = self.case.browser.find_by_css(self._values_selector())
if not fields:
return ''
return fields.first['value']
[docs]
class AlightMultipleMixin(stories.MultipleMixin):
"""Multiple-select assertions using alight hidden value inputs."""
[docs]
def get_values(self):
script = """
window.GET_VALUES = [];
document.querySelectorAll("%s").forEach(function(inp) {
GET_VALUES.push(inp.value);
});
""" % self._values_selector()
self.case.browser.execute_script(script)
return self.case.browser.evaluate_script('window.GET_VALUES')
[docs]
class AlightSelectOptionMultiple(AlightMultipleMixin, AlightSelectOption):
"""Multiple-select user story for the alight backend."""
[docs]
class AlightInlineSelectOption(
stories.InlineSelectOptionMixin,
AlightSelectOption,
):
"""Single-select user story for alight inlines."""
def _scope_inline_input_selector(self):
self.input_selector = '%s %s' % (
self.field_container_selector,
self.input_selector,
)
[docs]
class AlightInlineSelectOptionMultiple(
AlightMultipleMixin,
AlightInlineSelectOption,
):
"""Multiple-select user story for alight inlines."""
[docs]
class AlightCreateOption(AlightSelectOption, stories.CreateOption):
"""Create an option on the fly — alight backend only."""
def create_option(self, name):
create_sel = self.case.get_create_option_selector(name)
self._open_and_type(name)
self.case.browser.is_element_present_by_css(create_sel)
value_selector = self._values_selector()
initial_count = self.case.browser.evaluate_script(
'document.querySelectorAll("%s").length' % value_selector
)
self.case.js_click(create_sel)
deadline = time.time() + 5
while time.time() < deadline:
count = self.case.browser.evaluate_script(
'document.querySelectorAll("%s").length' % value_selector
)
if count > initial_count:
break
if self.case.browser.is_element_present_by_css(
'.invalid-feedback', wait_time=0,
):
break
time.sleep(0.1)
[docs]
class AlightCreateOptionMultiple(AlightMultipleMixin, AlightCreateOption):
"""Multiple-select variant of :class:`AlightCreateOption`."""