mirror of
https://github.com/esphome/esphome.git
synced 2026-10-03 17:55:57 +00:00
Merge branch 'esp8266-native-library-backend' into esp8266-native-build-spec
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
esphome:
|
||||
name: test-list-on-add-lvgl-action
|
||||
|
||||
esp32:
|
||||
board: lolin_c3_mini
|
||||
|
||||
spi:
|
||||
mosi_pin:
|
||||
number: GPIO2
|
||||
ignore_strapping_warning: true
|
||||
clk_pin: GPIO1
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
data_rate: 20MHz
|
||||
model: st7735
|
||||
cs_pin:
|
||||
number: GPIO8
|
||||
ignore_strapping_warning: true
|
||||
dc_pin: GPIO3
|
||||
|
||||
lvgl:
|
||||
widgets:
|
||||
- label:
|
||||
id: later_label
|
||||
text: orig
|
||||
- list:
|
||||
id: test_list
|
||||
on_add:
|
||||
- lvgl.label.update:
|
||||
id: later_label
|
||||
text: "changed"
|
||||
on_remove:
|
||||
- lvgl.label.update:
|
||||
id: later_label
|
||||
text: "removed"
|
||||
@@ -0,0 +1,41 @@
|
||||
esphome:
|
||||
name: test-list-outside-block
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lvgl.list.add:
|
||||
id: test_list
|
||||
switch:
|
||||
transform_rotation: 100
|
||||
drop_shadow_color: 0x000000
|
||||
bg_image_src: my_image
|
||||
|
||||
esp32:
|
||||
board: lolin_c3_mini
|
||||
|
||||
spi:
|
||||
mosi_pin:
|
||||
number: GPIO2
|
||||
ignore_strapping_warning: true
|
||||
clk_pin: GPIO1
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
data_rate: 20MHz
|
||||
model: st7735
|
||||
cs_pin:
|
||||
number: GPIO8
|
||||
ignore_strapping_warning: true
|
||||
dc_pin: GPIO3
|
||||
|
||||
image:
|
||||
- platform: file
|
||||
file: mdi:battery
|
||||
id: my_image
|
||||
resize: 8x8
|
||||
type: binary
|
||||
|
||||
lvgl:
|
||||
widgets:
|
||||
- list:
|
||||
id: test_list
|
||||
@@ -0,0 +1,77 @@
|
||||
esphome:
|
||||
name: test-list
|
||||
|
||||
esp32:
|
||||
board: lolin_c3_mini
|
||||
|
||||
spi:
|
||||
mosi_pin:
|
||||
number: GPIO2
|
||||
ignore_strapping_warning: true
|
||||
clk_pin: GPIO1
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
data_rate: 20MHz
|
||||
model: st7735
|
||||
cs_pin:
|
||||
number: GPIO8
|
||||
ignore_strapping_warning: true
|
||||
dc_pin: GPIO3
|
||||
|
||||
lvgl:
|
||||
theme:
|
||||
label:
|
||||
bg_color: 0xFF0000
|
||||
widgets:
|
||||
- list:
|
||||
id: test_list
|
||||
pad_row: 4
|
||||
on_add:
|
||||
- delay: 10ms
|
||||
- delay: 20ms
|
||||
on_remove:
|
||||
- delay: 10ms
|
||||
- button:
|
||||
id: trigger_button
|
||||
text: "Trigger"
|
||||
on_click:
|
||||
- lvgl.list.add_text:
|
||||
id: test_list
|
||||
text: "Header"
|
||||
- lvgl.list.add_text:
|
||||
id: test_list
|
||||
text: "Pinned"
|
||||
index: 0
|
||||
- lvgl.list.add:
|
||||
id: test_list
|
||||
button:
|
||||
text: "Entry"
|
||||
checkable: true
|
||||
- lvgl.list.add:
|
||||
id: test_list
|
||||
index: 1
|
||||
obj:
|
||||
widgets:
|
||||
- label:
|
||||
text: "Nested"
|
||||
- dropdown:
|
||||
options:
|
||||
- "One"
|
||||
- "Two"
|
||||
- lvgl.list.add:
|
||||
id: test_list
|
||||
obj:
|
||||
widgets:
|
||||
- obj:
|
||||
widgets:
|
||||
- label:
|
||||
text: "Grandchild"
|
||||
- lvgl.list.remove:
|
||||
id: test_list
|
||||
index: 0
|
||||
- lvgl.list.clear:
|
||||
id: test_list
|
||||
- lvgl.list.update:
|
||||
id: test_list
|
||||
pad_row: 8
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Tests for the LVGL ``list`` widget: schema validation for its actions
|
||||
(``lvgl.list.add_text``/``add``/``remove``/``clear``) and the code they generate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.__main__ import generate_cpp_contents
|
||||
from esphome.components.lvgl.widgets.lv_list import (
|
||||
LIST_CREATE_SCHEMA,
|
||||
LIST_REMOVE_SCHEMA,
|
||||
LIST_SCHEMA,
|
||||
list_add_schema,
|
||||
)
|
||||
from esphome.config import read_config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lvgl.list.add schema: id + optional index + exactly one widget-type key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListAddSchema:
|
||||
def test_valid_single_widget(self) -> None:
|
||||
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
|
||||
assert result["id"].id == "my_list"
|
||||
assert "widget" in result
|
||||
|
||||
def test_index_optional_and_templatable(self) -> None:
|
||||
result = list_add_schema({"id": "my_list", "index": 2, "label": {"text": "hi"}})
|
||||
assert result["index"] == 2
|
||||
|
||||
def test_index_omitted_when_not_given(self) -> None:
|
||||
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
|
||||
assert "index" not in result
|
||||
|
||||
def test_missing_id_rejected(self) -> None:
|
||||
with pytest.raises(cv.Invalid, match="required key 'id' not provided"):
|
||||
list_add_schema({"label": {"text": "hi"}})
|
||||
|
||||
def test_no_widget_key_rejected(self) -> None:
|
||||
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
|
||||
list_add_schema({"id": "my_list"})
|
||||
|
||||
def test_two_widget_keys_rejected(self) -> None:
|
||||
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
|
||||
list_add_schema(
|
||||
{
|
||||
"id": "my_list",
|
||||
"label": {"text": "a"},
|
||||
"button": {"text": "b"},
|
||||
}
|
||||
)
|
||||
|
||||
def test_non_mapping_rejected(self) -> None:
|
||||
with pytest.raises(cv.Invalid, match="Expected a mapping"):
|
||||
list_add_schema("not_a_mapping")
|
||||
|
||||
def test_any_registered_widget_type_accepted(self) -> None:
|
||||
for widget_key, widget_conf in (
|
||||
("checkbox", {"text": "Option"}),
|
||||
("switch", {}),
|
||||
("spinner", {}),
|
||||
("obj", {}),
|
||||
("dropdown", {"options": ["a", "b"]}),
|
||||
):
|
||||
result = list_add_schema({"id": "my_list", widget_key: widget_conf})
|
||||
assert widget_key in result["widget"][0]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("widget_key", "widget_conf"),
|
||||
[
|
||||
("buttonmatrix", {"rows": [{"buttons": [{"text": "A"}]}]}),
|
||||
("tabview", {"tabs": [{"name": "Tab1"}]}),
|
||||
("tileview", {"tiles": [{"row": 0, "column": 0}]}),
|
||||
("meter", {"scales": [{"range_from": 0, "range_to": 100}]}),
|
||||
("canvas", {"width": 20, "height": 20}),
|
||||
],
|
||||
)
|
||||
def test_dynamic_widget_unsupported_rejected(
|
||||
self, widget_key: str, widget_conf: dict
|
||||
) -> None:
|
||||
"""buttonmatrix/tabview/tileview all register their own child widgets into
|
||||
the global widget map from inside their to_code - fine for a widget built
|
||||
once at boot, but broken if lvgl.list.add re-enters that on every call.
|
||||
meter/canvas are rejected for a related but distinct reason: they declare a
|
||||
Pvariable (meter's scale/indicator objects; canvas's draw buffer) with
|
||||
cg.Pvariable()/cg.new_Pvariable(), which emits its assignment wherever code
|
||||
is currently being generated -- fine at the top level of a boot-time
|
||||
to_code, but lvgl.list.add's do_add runs inside a lambda. meter's assignment
|
||||
would then end up outside the very lambda that declares the local object it
|
||||
refers to (doesn't compile); canvas's Pvariable is declared once per config
|
||||
site rather than per call, so every call overwrites its one draw buffer
|
||||
(compiles, but leaks the old buffer and shares one buffer across every row).
|
||||
"""
|
||||
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
|
||||
list_add_schema({"id": "my_list", widget_key: widget_conf})
|
||||
|
||||
def test_dynamic_widget_unsupported_rejected_when_nested(self) -> None:
|
||||
"""The check must recurse into `widgets:` so a tabview hidden a few levels
|
||||
deep inside another widget is caught too, not just at the top level.
|
||||
"""
|
||||
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
|
||||
list_add_schema(
|
||||
{
|
||||
"id": "my_list",
|
||||
"obj": {"widgets": [{"tabview": {"tabs": [{"name": "Tab1"}]}}]},
|
||||
}
|
||||
)
|
||||
|
||||
def test_explicit_id_rejected(self) -> None:
|
||||
"""A dynamically-added widget is LocalVariable-scoped and rebuilt fresh
|
||||
on every call, never registered anywhere an id could be looked up by --
|
||||
an explicit id: would otherwise validate fine and then fail confusingly
|
||||
(an uncaught traceback, not a clean config error) the moment anything
|
||||
tries to reference it.
|
||||
"""
|
||||
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
|
||||
list_add_schema(
|
||||
{"id": "my_list", "label": {"id": "dyn_label", "text": "hi"}}
|
||||
)
|
||||
|
||||
def test_explicit_id_rejected_when_nested(self) -> None:
|
||||
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
|
||||
list_add_schema(
|
||||
{
|
||||
"id": "my_list",
|
||||
"obj": {"widgets": [{"label": {"id": "dyn_label", "text": "hi"}}]},
|
||||
}
|
||||
)
|
||||
|
||||
def test_no_explicit_id_still_valid(self) -> None:
|
||||
"""An id is auto-generated (and simply unused) when none is given --
|
||||
only an explicit one is rejected."""
|
||||
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
|
||||
assert "id" in result["widget"][0]["label"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "conf"),
|
||||
[
|
||||
("on_swipe_left", [{"logger.log": "swiped"}]),
|
||||
("on_swipe_right", [{"logger.log": "swiped"}]),
|
||||
("on_swipe_up", [{"logger.log": "swiped"}]),
|
||||
("on_swipe_down", [{"logger.log": "swiped"}]),
|
||||
("on_boot", [{"logger.log": "booted"}]),
|
||||
("align_to", {"id": "some_other_widget", "align": "OUT_LEFT_TOP"}),
|
||||
],
|
||||
)
|
||||
def test_unsupported_trigger_rejected(self, key: str, conf: list) -> None:
|
||||
"""_wire_dynamic_triggers only wires LV_EVENT_TRIGGERS/on_value/on_update --
|
||||
on_swipe_*/on_boot would otherwise validate fine and then silently generate
|
||||
nothing at all for a widget added via lvgl.list.add. align_to is in the same
|
||||
bucket: it's only ever consumed by generate_triggers() reading
|
||||
get_widget_map(), which a widget built via lvgl.list.add never enters.
|
||||
"""
|
||||
with pytest.raises(cv.Invalid, match="is not supported"):
|
||||
list_add_schema({"id": "my_list", "obj": {key: conf}})
|
||||
|
||||
def test_unsupported_trigger_rejected_when_nested(self) -> None:
|
||||
with pytest.raises(cv.Invalid, match="is not supported"):
|
||||
list_add_schema(
|
||||
{
|
||||
"id": "my_list",
|
||||
"obj": {
|
||||
"widgets": [
|
||||
{
|
||||
"label": {
|
||||
"text": "hi",
|
||||
"on_swipe_left": [{"logger.log": "swiped"}],
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lvgl.list.remove: index must be non-negative -- LVGL treats a negative index as
|
||||
# counting back from the end, which would silently delete the wrong row while
|
||||
# reporting a list_index that matches nothing real to on_remove.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListRemoveSchema:
|
||||
def test_negative_index_rejected(self) -> None:
|
||||
with pytest.raises(cv.Invalid, match="at least 0"):
|
||||
LIST_REMOVE_SCHEMA({"id": "my_list", "index": -1})
|
||||
|
||||
def test_zero_index_accepted(self) -> None:
|
||||
result = LIST_REMOVE_SCHEMA({"id": "my_list", "index": 0})
|
||||
assert result["index"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The list widget's own schema: pad_row is shared between create/update, but
|
||||
# on_add/on_remove only make sense at creation time.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListCreateVsModifySchema:
|
||||
def test_create_schema_has_pad_row_and_triggers(self) -> None:
|
||||
keys = {str(k) for k in LIST_CREATE_SCHEMA.schema}
|
||||
assert "pad_row" in keys
|
||||
assert "on_add" in keys
|
||||
assert "on_remove" in keys
|
||||
|
||||
def test_modify_schema_has_pad_row_but_not_triggers(self) -> None:
|
||||
"""``lvgl.list.update`` can change pad_row but can't (re-)declare triggers."""
|
||||
keys = {str(k) for k in LIST_SCHEMA.schema}
|
||||
assert "pad_row" in keys
|
||||
assert "on_add" not in keys
|
||||
assert "on_remove" not in keys
|
||||
|
||||
def test_on_add_single_automation_with_multiple_actions(self) -> None:
|
||||
"""A bare action list under on_add: is one automation with a multi-step
|
||||
`then:`, not multiple independent automations.
|
||||
"""
|
||||
config = LIST_CREATE_SCHEMA({"on_add": [{"delay": "10ms"}, {"delay": "20ms"}]})
|
||||
assert len(config["on_add"]) == 1
|
||||
assert len(config["on_add"][0]["then"]) == 2
|
||||
|
||||
def test_on_add_accepts_multiple_independent_automations(self) -> None:
|
||||
"""Each explicit `then:` entry gets its own Trigger, so on_add can fire
|
||||
more than one independent automation.
|
||||
"""
|
||||
config = LIST_CREATE_SCHEMA(
|
||||
{
|
||||
"on_add": [
|
||||
{"then": [{"delay": "10ms"}]},
|
||||
{"then": [{"delay": "20ms"}]},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert len(config["on_add"]) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_cpp(request: pytest.FixtureRequest) -> str:
|
||||
"""Generate the C++ output for the shared list-widget YAML config once per
|
||||
module -- see test_widget_state.py for why this is module-scoped and
|
||||
inlines the generate_main fixture logic rather than depending on it.
|
||||
"""
|
||||
config_path = Path(request.fspath).parent / "config" / "list_test.yaml"
|
||||
original_path = CORE.config_path
|
||||
try:
|
||||
CORE.config_path = config_path
|
||||
CORE.config = read_config({})
|
||||
generate_cpp_contents(CORE.config)
|
||||
return CORE.cpp_global_section + CORE.cpp_main_section
|
||||
finally:
|
||||
CORE.config_path = original_path
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def test_pad_row_set_at_creation(main_cpp: str) -> None:
|
||||
assert "lv_obj_set_style_pad_row(test_list, 4, LV_PART_MAIN);" in main_cpp
|
||||
|
||||
|
||||
def test_pad_row_updated_via_update_action(main_cpp: str) -> None:
|
||||
assert "lv_obj_set_style_pad_row(test_list, 8, LV_PART_MAIN);" in main_cpp
|
||||
|
||||
|
||||
def test_add_text_appends(main_cpp: str) -> None:
|
||||
assert 'lv_list_add_text(test_list, "Header");' in main_cpp
|
||||
|
||||
|
||||
def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None:
|
||||
"""The index move must happen before on_add fires, so the reported
|
||||
list_index reflects the entry's final position, not where it was appended.
|
||||
"""
|
||||
assert (
|
||||
'lv_obj_t *list_entry_VAR_ = lv_list_add_text(test_list, "Pinned");\n'
|
||||
" lv_obj_move_to_index(list_entry_VAR_, 0);\n"
|
||||
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, list_entry_VAR_));"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_add_button_with_checkable_flag(main_cpp: str) -> None:
|
||||
assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp
|
||||
assert (
|
||||
"lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));"
|
||||
in main_cpp
|
||||
)
|
||||
assert (
|
||||
'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp
|
||||
)
|
||||
|
||||
|
||||
def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None:
|
||||
"""`obj: {widgets: [label, dropdown]}` builds a plain label child and a
|
||||
heap-allocated (compound) dropdown child, both parented to the new row.
|
||||
|
||||
The child variable names carry a `_1` (depth) suffix, distinguishing them
|
||||
from the row's own top-level variable -- necessary so that a child of the
|
||||
*same* widget type as its parent (e.g. `obj: {widgets: [{obj: {...}}]}`)
|
||||
doesn't declare a C++ variable that shadows its own not-yet-initialized
|
||||
self, silently parenting the child to garbage.
|
||||
"""
|
||||
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
|
||||
assert (
|
||||
"lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n"
|
||||
" lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
|
||||
"(lv_state_t)(LV_PART_MAIN));\n"
|
||||
' lv_label_set_text(dyn_label_1_VAR_, "Nested");'
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None:
|
||||
"""A widget added via lvgl.list.add must pick up the same `theme:` styling a
|
||||
statically-declared widget of the same type gets, not render unthemed.
|
||||
"""
|
||||
assert (
|
||||
"lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
|
||||
"(lv_state_t)(LV_PART_MAIN));"
|
||||
) in main_cpp
|
||||
assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp
|
||||
assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp
|
||||
assert (
|
||||
"lvgl::delete_lv_compound_on_delete<LvDropdownType>, LV_EVENT_DELETE, "
|
||||
"dyn_dropdown_1_VAR_);"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> None:
|
||||
"""A child of the same widget type as its parent (`obj: {widgets: [{obj:
|
||||
...}]}`) must get a distinct C++ variable name (or the child's declaration
|
||||
would shadow its own not-yet-initialized self, parenting it to garbage --
|
||||
compiling clean but for a -Wuninitialized warning). A grandchild of a third
|
||||
type proves depth, not just type, drives the disambiguating suffix.
|
||||
"""
|
||||
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
|
||||
assert "lv_obj_t *dyn_obj_1_VAR_ = lv_obj_create(dyn_obj_VAR_);" in main_cpp
|
||||
assert (
|
||||
"lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n"
|
||||
" lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, "
|
||||
"(lv_state_t)(LV_PART_MAIN));\n"
|
||||
' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");'
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_add_moves_row_to_given_index_before_firing_on_add(main_cpp: str) -> None:
|
||||
assert (
|
||||
"lv_obj_move_to_index(dyn_obj_VAR_, 1);\n"
|
||||
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, dyn_obj_VAR_));"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_on_add_fires_once_per_entry_via_shared_trigger(main_cpp: str) -> None:
|
||||
"""A single on_add: automation means a single Trigger instance, reused by
|
||||
every lvgl.list.add_text/add call site.
|
||||
"""
|
||||
assert main_cpp.count("triggerint_id->trigger(lvgl::lv_list_get_row_index(") == 5
|
||||
|
||||
|
||||
def test_remove_guards_against_missing_child_and_fires_before_delete(
|
||||
main_cpp: str,
|
||||
) -> None:
|
||||
"""The index is materialised into a local once (list_index_VAR_) and reused for
|
||||
both the child lookup and the on_remove trigger, so a templatable index isn't
|
||||
evaluated twice.
|
||||
"""
|
||||
assert (
|
||||
"int list_index_VAR_ = 0;\n"
|
||||
" {\n"
|
||||
" lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);\n"
|
||||
" if (list_child_VAR_) {\n"
|
||||
" triggerint_id_2->trigger(list_index_VAR_);\n"
|
||||
" lv_obj_del(list_child_VAR_);"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_remove_out_of_range_lookup_uses_shared_cpp_helper(main_cpp: str) -> None:
|
||||
"""The out-of-range lookup (and its log line) live in a single C++ helper --
|
||||
lvgl::lv_list_get_row_for_remove() in lvgl_esphome.cpp -- rather than being
|
||||
generated inline at every lvgl.list.remove call site, since a config can
|
||||
have many of them and duplicating that logic (and its log string) at each
|
||||
one would waste flash for no benefit.
|
||||
"""
|
||||
assert (
|
||||
"lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);"
|
||||
in main_cpp
|
||||
)
|
||||
assert "ESP_LOGV" not in main_cpp
|
||||
|
||||
|
||||
def test_clear_fires_on_remove_for_every_entry_then_cleans(main_cpp: str) -> None:
|
||||
assert (
|
||||
"for (int list_index = (int) (lv_obj_get_child_count(test_list)) - 1; "
|
||||
"list_index >= 0; list_index--) {\n"
|
||||
" triggerint_id_2->trigger(list_index);\n"
|
||||
" }\n"
|
||||
" lv_obj_clean(test_list);"
|
||||
) in main_cpp
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.
|
||||
|
||||
ListType.to_code() used to build the on_add/on_remove automations directly, during
|
||||
widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only
|
||||
resolves once *all* widgets - including the list itself - have finished being
|
||||
created. Building an automation containing an lvgl action from inside that same
|
||||
widget-creation walk therefore could never complete: codegen deadlocked with
|
||||
"Circular dependency detected!". Fixed by deferring the actual build_automation()
|
||||
call to finish_list_triggers(), run after set_widgets_completed(True) - and,
|
||||
critically, before generate_triggers(), which is what processes other widgets'
|
||||
on_click etc. automations that might reference this list (e.g. via lvgl.list.add),
|
||||
and which therefore need the list's own on_add/on_remove triggers to already exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.__main__ import generate_cpp_contents
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_cpp(request: pytest.FixtureRequest) -> str:
|
||||
config_path = (
|
||||
Path(request.fspath).parent / "config" / "list_on_add_lvgl_action_test.yaml"
|
||||
)
|
||||
original_path = CORE.config_path
|
||||
try:
|
||||
CORE.config_path = config_path
|
||||
CORE.config = read_config({})
|
||||
generate_cpp_contents(CORE.config)
|
||||
return CORE.cpp_main_section
|
||||
finally:
|
||||
CORE.config_path = original_path
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def test_on_add_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
|
||||
assert 'lv_label_set_text(later_label, "changed");' in main_cpp
|
||||
|
||||
|
||||
def test_on_remove_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
|
||||
assert 'lv_label_set_text(later_label, "removed");' in main_cpp
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Regression test for lvgl.list.add called from outside the lvgl: block.
|
||||
|
||||
lv_list.py's list_add_to_code() must call _register_lv_uses() and
|
||||
_register_dynamic_widget_style_uses() before its first await (get_widgets(),
|
||||
which can block until the target list is defined) -- for an action referenced
|
||||
outside the lvgl: block, that wait can outlast lvgl's own to_code, which reads
|
||||
get_lv_uses()/get_styles_used() and flushes everything they drive (USE_LVGL_*
|
||||
defines, plus add_lv_use(image)/screen-transparency/A8-draw-support triggered
|
||||
by style properties) just once, near the end of its run. Every existing
|
||||
list_test.yaml call site lives inside lvgl: widgets:, so neither ordering
|
||||
requirement had any coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.__main__ import generate_cpp_contents
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeneratedOutput:
|
||||
main_cpp: str
|
||||
define_names: set[str]
|
||||
lv_define_names: set[str]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def generated(request: pytest.FixtureRequest) -> GeneratedOutput:
|
||||
config_path = (
|
||||
Path(request.fspath).parent / "config" / "list_outside_block_test.yaml"
|
||||
)
|
||||
original_path = CORE.config_path
|
||||
try:
|
||||
CORE.config_path = config_path
|
||||
CORE.config = read_config({})
|
||||
generate_cpp_contents(CORE.config)
|
||||
# Copy out before CORE.reset() below clears these out from under us.
|
||||
from esphome.components.lvgl import defines as df
|
||||
|
||||
return GeneratedOutput(
|
||||
main_cpp=CORE.cpp_global_section + CORE.cpp_main_section,
|
||||
define_names={d.name for d in CORE.defines},
|
||||
lv_define_names=set(df.get_defines()),
|
||||
)
|
||||
finally:
|
||||
CORE.config_path = original_path
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def test_dynamic_widget_creates_correctly(generated: GeneratedOutput) -> None:
|
||||
assert (
|
||||
"lv_obj_t *dyn_switch_VAR_ = lv_switch_create(test_list);" in generated.main_cpp
|
||||
)
|
||||
|
||||
|
||||
def test_dynamic_widget_type_use_define_is_registered(
|
||||
generated: GeneratedOutput,
|
||||
) -> None:
|
||||
"""The switch type is only ever referenced via the on_boot lvgl.list.add call
|
||||
(never declared as a static widget), so USE_LVGL_SWITCH can only be present
|
||||
if _register_lv_uses() ran in time for lvgl's own to_code to flush it.
|
||||
"""
|
||||
assert "USE_LVGL_SWITCH" in generated.define_names
|
||||
assert "USE_LVGL_LIST" in generated.define_names
|
||||
|
||||
|
||||
def test_dynamic_widget_style_use_defines_are_registered(
|
||||
generated: GeneratedOutput,
|
||||
) -> None:
|
||||
"""bg_image_src/transform_rotation/drop_shadow_color are only ever set on
|
||||
the dynamically-added switch (never on a static widget), so
|
||||
USE_LVGL_IMAGE/LV_COLOR_SCREEN_TRANSP/LV_DRAW_SW_SUPPORT_A8 can only be
|
||||
present if _register_dynamic_widget_style_uses() ran in time for lvgl's own
|
||||
to_code to flush them.
|
||||
"""
|
||||
assert "USE_LVGL_IMAGE" in generated.define_names
|
||||
assert "LV_COLOR_SCREEN_TRANSP" in generated.lv_define_names
|
||||
assert "LV_DRAW_SW_SUPPORT_A8" in generated.lv_define_names
|
||||
@@ -1214,6 +1214,64 @@ lvgl:
|
||||
id: checkbox_id
|
||||
text: Checkbox
|
||||
align: bottom_right
|
||||
- list:
|
||||
id: test_list_id
|
||||
align: top_right
|
||||
width: 150px
|
||||
height: 120px
|
||||
pad_row: 4
|
||||
on_add:
|
||||
- logger.log:
|
||||
format: "list entry added at %d"
|
||||
args: [list_index]
|
||||
on_remove:
|
||||
- logger.log:
|
||||
format: "list entry removed at %d"
|
||||
args: [list_index]
|
||||
on_click:
|
||||
- lvgl.list.add_text:
|
||||
id: test_list_id
|
||||
text: !lambda return "Section";
|
||||
- lvgl.list.add_text:
|
||||
id: test_list_id
|
||||
text: "Pinned section"
|
||||
index: 0
|
||||
- lvgl.list.add:
|
||||
id: test_list_id
|
||||
button:
|
||||
text: "Entry"
|
||||
checkable: true
|
||||
- lvgl.list.add:
|
||||
id: test_list_id
|
||||
index: 1
|
||||
obj:
|
||||
widgets:
|
||||
- label:
|
||||
text: !lambda return "Dynamic row " + std::to_string(millis());
|
||||
- button:
|
||||
widgets:
|
||||
- label:
|
||||
text: "Tap"
|
||||
on_click:
|
||||
- lambda: |-
|
||||
ESP_LOGD("lvgl", "dynamic row button clicked, row %d",
|
||||
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
|
||||
- dropdown:
|
||||
options:
|
||||
- "One"
|
||||
- "Two"
|
||||
on_value:
|
||||
- lambda: |-
|
||||
ESP_LOGD("lvgl", "dynamic row dropdown changed, row %d",
|
||||
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
|
||||
- lvgl.list.remove:
|
||||
id: test_list_id
|
||||
index: 0
|
||||
- lvgl.list.clear:
|
||||
id: test_list_id
|
||||
- lvgl.list.update:
|
||||
id: test_list_id
|
||||
pad_row: 8
|
||||
- slider:
|
||||
id: slider_id
|
||||
align: top_mid
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
esphome:
|
||||
name: lvgl-list-validate
|
||||
|
||||
host:
|
||||
|
||||
logger:
|
||||
|
||||
display:
|
||||
- platform: sdl
|
||||
id: sdl0
|
||||
dimensions:
|
||||
width: 320
|
||||
height: 240
|
||||
|
||||
lvgl:
|
||||
displays: sdl0
|
||||
widgets:
|
||||
# Two independent lists, each with their own on_add/on_remove and, for list_a,
|
||||
# more than one automation under the same trigger key -- checks that the
|
||||
# per-list trigger bookkeeping is keyed correctly and doesn't require exactly
|
||||
# one automation.
|
||||
- list:
|
||||
id: validate_list_a
|
||||
align: center
|
||||
pad_row: 6
|
||||
on_add:
|
||||
- logger.log:
|
||||
format: "a: added %d"
|
||||
args: [list_index]
|
||||
- logger.log:
|
||||
format: "a: also added %d"
|
||||
args: [list_index]
|
||||
on_remove:
|
||||
- logger.log:
|
||||
format: "a: removed %d"
|
||||
args: [list_index]
|
||||
on_boot:
|
||||
# lvgl.list.add_text and lvgl.list.add both take an optional, templatable index.
|
||||
- lvgl.list.add_text:
|
||||
id: validate_list_a
|
||||
text: "Header"
|
||||
index: !lambda return 0;
|
||||
# any registered widget type is valid as the single lvgl.list.add key.
|
||||
- lvgl.list.add:
|
||||
id: validate_list_a
|
||||
checkbox:
|
||||
align: center
|
||||
text: "Option"
|
||||
- lvgl.list.add:
|
||||
id: validate_list_a
|
||||
index: !lambda return 0;
|
||||
switch:
|
||||
align: center
|
||||
- lvgl.list.add:
|
||||
id: validate_list_a
|
||||
spinner:
|
||||
align: center
|
||||
- lvgl.list.add:
|
||||
id: validate_list_a
|
||||
obj:
|
||||
align: center
|
||||
- lvgl.list.remove:
|
||||
id: validate_list_a
|
||||
index: !lambda return 0;
|
||||
- lvgl.list.clear:
|
||||
id: validate_list_a
|
||||
- list:
|
||||
id: validate_list_b
|
||||
align: center
|
||||
on_remove:
|
||||
- logger.log:
|
||||
format: "b: removed %d"
|
||||
args: [list_index]
|
||||
@@ -41,3 +41,6 @@ number:
|
||||
min_value: 2
|
||||
max_value: 100
|
||||
step: 1
|
||||
|
||||
time:
|
||||
- platform: zigbee
|
||||
|
||||
@@ -10,6 +10,3 @@ zigbee:
|
||||
on_start:
|
||||
then:
|
||||
- logger.log: "Started zigbee stack"
|
||||
|
||||
time:
|
||||
- platform: zigbee
|
||||
|
||||
@@ -5,3 +5,6 @@ zigbee:
|
||||
on_join:
|
||||
then:
|
||||
- logger.log: "Joined network"
|
||||
|
||||
time:
|
||||
- platform: zigbee
|
||||
|
||||
Reference in New Issue
Block a user