mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 03:16:37 +00:00
[lvgl] Add lvgl.theme.update action (#17678)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
Claude Sonnet 5
parent
ad3e2f83b8
commit
98ee7e0f82
@@ -0,0 +1,55 @@
|
||||
"""Tests for LVGL's multi-instance config cross-checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.lvgl import defines as df, multi_conf_validate
|
||||
from esphome.components.lvgl.schemas import theme_schema
|
||||
from esphome.config_validation import Invalid
|
||||
|
||||
|
||||
def _config(displays: list[str], theme: dict | None = None) -> dict:
|
||||
config = {
|
||||
df.CONF_DISPLAYS: displays,
|
||||
"log_level": "WARN",
|
||||
"color_depth": 16,
|
||||
"byte_order": "big_endian",
|
||||
df.CONF_TRANSPARENCY_KEY: 0x000400,
|
||||
}
|
||||
if theme is not None:
|
||||
config[df.CONF_THEME] = theme
|
||||
return config
|
||||
|
||||
|
||||
class TestThemeOnMultipleInstances:
|
||||
def test_raises_when_two_instances_have_theme(self) -> None:
|
||||
configs = [
|
||||
_config(["disp_a"], theme={df.CONF_DARK_MODE: True}),
|
||||
_config(["disp_b"], theme={df.CONF_DARK_MODE: False}),
|
||||
]
|
||||
with pytest.raises(Invalid, match="'theme' may only be set on one"):
|
||||
multi_conf_validate(configs)
|
||||
|
||||
def test_raises_even_with_an_empty_theme_block(self) -> None:
|
||||
# `theme: {}` still creates a CONF_THEME key (with dark_mode defaulted
|
||||
# by the schema), so it should be treated the same as a populated one.
|
||||
# Run it through the real schema rather than hand-building the dict,
|
||||
# so this actually pins that defaulting behaviour.
|
||||
configs = [
|
||||
_config(["disp_a"], theme=theme_schema({})),
|
||||
_config(["disp_b"], theme=theme_schema({})),
|
||||
]
|
||||
with pytest.raises(Invalid, match="'theme' may only be set on one"):
|
||||
multi_conf_validate(configs)
|
||||
|
||||
def test_passes_when_only_one_instance_has_theme(self) -> None:
|
||||
configs = [
|
||||
_config(["disp_a"], theme={df.CONF_DARK_MODE: True}),
|
||||
_config(["disp_b"]),
|
||||
]
|
||||
multi_conf_validate(configs)
|
||||
|
||||
def test_passes_when_no_instance_has_theme(self) -> None:
|
||||
configs = [_config(["disp_a"]), _config(["disp_b"])]
|
||||
multi_conf_validate(configs)
|
||||
@@ -13,12 +13,7 @@ import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import config_validation as cv
|
||||
import esphome.components.lvgl
|
||||
from esphome.components.lvgl import (
|
||||
_theme_schema,
|
||||
defines as df,
|
||||
schemas as lvgl_schemas,
|
||||
)
|
||||
from esphome.components.lvgl import defines as df, schemas as lvgl_schemas
|
||||
from esphome.components.lvgl.schemas import (
|
||||
ALIGN_TO_SCHEMA,
|
||||
FLAG_SCHEMA,
|
||||
@@ -31,6 +26,8 @@ from esphome.components.lvgl.schemas import (
|
||||
obj_schema,
|
||||
part_dict,
|
||||
part_schema,
|
||||
theme_schema,
|
||||
theme_update_schema,
|
||||
)
|
||||
from esphome.components.lvgl.types import LvType
|
||||
from esphome.components.lvgl.widgets import WidgetType
|
||||
@@ -43,7 +40,7 @@ def _clear_obj_dict_cache() -> Generator[None]:
|
||||
cache.clear()
|
||||
# The lazily-built theme schema is cached on _build_theme_schema; clear it
|
||||
# too so each test starts from a clean slate.
|
||||
build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None)
|
||||
build_theme = getattr(lvgl_schemas, "_build_theme_schema", None)
|
||||
if build_theme is not None and hasattr(build_theme, "cache_clear"):
|
||||
build_theme.cache_clear()
|
||||
yield
|
||||
@@ -173,12 +170,12 @@ def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None:
|
||||
|
||||
|
||||
def test_theme_schema_merges_obj_dict_and_full_style_props() -> None:
|
||||
# _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
|
||||
# theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
|
||||
# share many STYLE_SCHEMA marker instances. Exercise the merged schema
|
||||
# end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict
|
||||
# and a FULL_STYLE-only property) to lock the behaviour against future
|
||||
# regressions in either source.
|
||||
out = _theme_schema(
|
||||
out = theme_schema(
|
||||
{
|
||||
df.CONF_DARK_MODE: True,
|
||||
"obj": {
|
||||
@@ -202,7 +199,7 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non
|
||||
# any_widget_schema explicitly supports external components registering
|
||||
# widgets lazily, and the device builder revalidates in-process, so a
|
||||
# widget registered after first use must invalidate the cached snapshot.
|
||||
_theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
|
||||
theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
|
||||
|
||||
name = "test_self_heal_widget"
|
||||
assert name not in WIDGET_TYPES
|
||||
@@ -210,18 +207,68 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non
|
||||
# manually so the next theme call sees the new entry.
|
||||
WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True)
|
||||
try:
|
||||
out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
|
||||
out = theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
|
||||
assert out[name]["bg_color"] == 0x010203
|
||||
finally:
|
||||
WIDGET_TYPES.pop(name, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_path"),
|
||||
[
|
||||
({"button": {"styles": ["foo"]}}, ["button", "styles"]),
|
||||
(
|
||||
{"button": {"pressed": {"styles": ["foo"]}}},
|
||||
["button", "pressed", "styles"],
|
||||
),
|
||||
(
|
||||
{"arc": {"indicator": {"styles": ["foo"]}}},
|
||||
["arc", "indicator", "styles"],
|
||||
),
|
||||
(
|
||||
{"arc": {"indicator": {"pressed": {"styles": ["foo"]}}}},
|
||||
["arc", "indicator", "pressed", "styles"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_theme_schema_rejects_styles_key(
|
||||
config: dict, expected_path: list[str]
|
||||
) -> None:
|
||||
# `styles:` (references to named styles) is accepted by FULL_STYLE_SCHEMA
|
||||
# but silently dropped by style_set when building a theme's hidden style
|
||||
# -- it only walks ALL_STYLES. Reject it instead of quietly doing nothing,
|
||||
# at the top level and when nested under a part and/or state.
|
||||
with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
|
||||
theme_schema(config)
|
||||
assert exc_info.value.path == expected_path
|
||||
|
||||
|
||||
def test_theme_update_schema_rejects_styles_key() -> None:
|
||||
with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
|
||||
theme_update_schema({"label": {"styles": ["foo"]}})
|
||||
assert exc_info.value.path == ["label", "styles"]
|
||||
|
||||
|
||||
def test_theme_update_schema_does_not_request_untargeted_main_default() -> None:
|
||||
# collect_parts() unconditionally seeds a main/default entry even when
|
||||
# only a specific state (here "pressed") was targeted -- registering a
|
||||
# request for that spurious entry would make theme_to_code create an
|
||||
# unused, empty style and attach it to every widget of this type.
|
||||
theme_update_schema({"label": {"pressed": {"text_color": 0x010203}}})
|
||||
assert df.get_theme_update_requests()["label"] == {("main", "pressed"): None}
|
||||
|
||||
|
||||
def test_theme_update_schema_requests_explicit_main_default() -> None:
|
||||
theme_update_schema({"label": {"text_color": 0x010203}})
|
||||
assert df.get_theme_update_requests()["label"] == {("main", "default"): None}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
|
||||
)
|
||||
def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None:
|
||||
# _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
|
||||
# theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
|
||||
# collision, dict-spread keeps the first source's marker (and its default)
|
||||
# but the last source's value, whereas .extend() would take both from the
|
||||
# later source. The two are equivalent today because the overlapping
|
||||
|
||||
@@ -295,6 +295,22 @@ lvgl:
|
||||
id: style_test
|
||||
bg_color: blue
|
||||
bg_opa: !lambda return 0.5;
|
||||
# `obj` is already themed above -- exercises updating an existing hidden style.
|
||||
- lvgl.theme.update:
|
||||
obj:
|
||||
border_width: 2
|
||||
# `label` is never mentioned under `theme:` -- exercises lazily creating the
|
||||
# hidden style and getting it attached to already-built label widgets.
|
||||
- lvgl.theme.update:
|
||||
label:
|
||||
text_color: red
|
||||
# `button` is never mentioned under `theme:`, and only a non-default state is
|
||||
# targeted here -- exercises that no spurious, empty main/default style is
|
||||
# created (and attached to every button) alongside the requested one.
|
||||
- lvgl.theme.update:
|
||||
button:
|
||||
pressed:
|
||||
bg_color: red
|
||||
- lvgl.image.update:
|
||||
id: lv_image
|
||||
src:
|
||||
|
||||
Reference in New Issue
Block a user