mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 02:05:59 +00:00
Merge branch 'esp8266-native-pch' into esp32-idf-pch
This commit is contained in:
@@ -1181,6 +1181,38 @@ lvgl:
|
||||
- logger.log:
|
||||
format: "bar value %f"
|
||||
args: [x]
|
||||
- table:
|
||||
id: table_id
|
||||
align: top_mid
|
||||
y: 60
|
||||
columns:
|
||||
- width: 40%
|
||||
- width: 80
|
||||
rows:
|
||||
- ["Name", "Value"]
|
||||
- cells:
|
||||
- text: "Temp"
|
||||
merge_right: true
|
||||
- text: "22.5"
|
||||
text_crop: true
|
||||
selected_row: 0
|
||||
on_value:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "table selected row %u col %u"
|
||||
args: [row, column]
|
||||
on_click:
|
||||
then:
|
||||
- lvgl.table.cell.update:
|
||||
id: table_id
|
||||
row: 1
|
||||
column: 1
|
||||
text: !lambda return str_sprintf("%.1f", (float) rand() / RAND_MAX * 100);
|
||||
merge_right: false
|
||||
- lvgl.table.update:
|
||||
id: table_id
|
||||
selected_row: !lambda return (int) ((float) rand() / RAND_MAX * 2);
|
||||
selected_column: 0
|
||||
- line:
|
||||
id: lv_line_id
|
||||
align: center
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Tests for the LVGL table widget's C++ code generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.automation import ACTION_REGISTRY
|
||||
from esphome.components.lvgl.defines import set_widgets_completed
|
||||
from esphome.components.lvgl.lvcode import LvContext
|
||||
from esphome.components.lvgl.schemas import container_schema
|
||||
from esphome.components.lvgl.trigger import generate_triggers
|
||||
from esphome.components.lvgl.widgets import Widget, widget_to_code
|
||||
from esphome.components.lvgl.widgets.table import table_spec
|
||||
from esphome.const import (
|
||||
CONF_AUTOMATION_ID,
|
||||
CONF_ON_VALUE,
|
||||
CONF_THEN,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_TYPE_ID,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArguments
|
||||
from esphome.yaml_util import make_data_base
|
||||
|
||||
|
||||
async def _create_table(raw_config: dict) -> Widget:
|
||||
"""Validate `raw_config` as a table widget and generate its creation code."""
|
||||
config = container_schema(table_spec)(raw_config)
|
||||
parent = MockObj("parent_obj")
|
||||
async with LvContext():
|
||||
return await widget_to_code(config, table_spec, parent)
|
||||
|
||||
|
||||
def _statements() -> list[str]:
|
||||
return [str(s) for s in CORE.main_statements]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_sets_row_and_column_count(setup_core) -> None:
|
||||
await _create_table(
|
||||
{"id": "table_counts", "rows": [["Name", "Value"], ["Temp", "22.5"]]}
|
||||
)
|
||||
statements = _statements()
|
||||
assert any("lv_table_set_row_count(table_counts->obj, 2)" in s for s in statements)
|
||||
assert any(
|
||||
"lv_table_set_column_count(table_counts->obj, 2)" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_writes_cell_values(setup_core) -> None:
|
||||
await _create_table({"id": "table_cells", "rows": [["Name", "Value"]]})
|
||||
statements = _statements()
|
||||
assert any(
|
||||
'lv_table_set_cell_value(table_cells->obj, 0, 0, "Name")' in s
|
||||
for s in statements
|
||||
)
|
||||
assert any(
|
||||
'lv_table_set_cell_value(table_cells->obj, 0, 1, "Value")' in s
|
||||
for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_sets_cell_control_flags(setup_core) -> None:
|
||||
await _create_table(
|
||||
{
|
||||
"id": "table_ctrl",
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{"text": "wide", "merge_right": True},
|
||||
{"text": "cropped", "text_crop": True},
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_MERGE_RIGHT)"
|
||||
in s
|
||||
for s in statements
|
||||
)
|
||||
assert any(
|
||||
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 1, LV_TABLE_CELL_CTRL_TEXT_CROP)"
|
||||
in s
|
||||
for s in statements
|
||||
)
|
||||
# text_crop omitted for cell 0: no clear_cell_ctrl() should be emitted.
|
||||
assert not any(
|
||||
"table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_TEXT_CROP" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pixel_column_width_calls_lvgl_directly(setup_core) -> None:
|
||||
await _create_table({"id": "table_px", "columns": [{"width": 96}]})
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_column_width(table_px->obj, 0, 96)" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_percent_column_width_uses_the_dynamic_helper(setup_core) -> None:
|
||||
"""Regression test: lv_table_set_column_width() only accepts a literal
|
||||
pixel count, so a percentage width must not be passed to it directly -
|
||||
it has to go through the LvTableType helper that recomputes it at
|
||||
runtime from the table's actual content width.
|
||||
"""
|
||||
await _create_table({"id": "table_pct", "columns": [{"width": "40%"}]})
|
||||
statements = _statements()
|
||||
assert any("table_pct->init_column_pct(1)" in s for s in statements)
|
||||
assert any("table_pct->add_column_width_pct(0, 40)" in s for s in statements)
|
||||
assert not any(
|
||||
"lv_table_set_column_width(table_pct->obj, 0" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_cell_with_both_indices(setup_core) -> None:
|
||||
await _create_table(
|
||||
{"id": "table_sel_both", "selected_row": 1, "selected_column": 2}
|
||||
)
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_selected_cell(table_sel_both->obj, 1, 2)" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_cell_with_only_row_selects_whole_row(setup_core) -> None:
|
||||
await _create_table({"id": "table_sel_row", "selected_row": 1})
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_selected_cell(table_sel_row->obj, 1, LV_TABLE_CELL_NONE)" in s
|
||||
for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_cell_omitted_entirely_when_not_configured(
|
||||
setup_core,
|
||||
) -> None:
|
||||
await _create_table({"id": "table_no_selection", "rows": [["a"]]})
|
||||
statements = _statements()
|
||||
assert not any("lv_table_set_selected_cell" in s for s in statements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None:
|
||||
await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]})
|
||||
set_widgets_completed(True)
|
||||
# Only inspect statements emitted by the action below, not by creation.
|
||||
before = len(_statements())
|
||||
|
||||
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
|
||||
config = entry.schema(
|
||||
{"id": "table_update", "row": 1, "column": 1, "text": "new value"}
|
||||
)
|
||||
action_id = ID("test_cell_update_action", is_declaration=True, type=entry.type_id)
|
||||
await entry.coroutine_fun(config, action_id, TemplateArguments(), [])
|
||||
|
||||
statements = _statements()[before:]
|
||||
assert any(
|
||||
'lv_table_set_cell_value(table_update->obj, 1, 1, "new value")' in s
|
||||
for s in statements
|
||||
)
|
||||
# Neither control flag was specified, so neither call should be emitted.
|
||||
assert not any("LV_TABLE_CELL_CTRL" in s for s in statements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_value_registers_a_value_changed_event_callback(setup_core) -> None:
|
||||
config = container_schema(table_spec)(
|
||||
{
|
||||
"id": "table_on_value",
|
||||
"rows": [["a"]],
|
||||
"on_value": [
|
||||
{"lambda": make_data_base("id(table_on_value).get_selected_row();")}
|
||||
],
|
||||
}
|
||||
)
|
||||
# Auto-generated IDs (trigger/automation/action) are normally resolved to
|
||||
# unique names by esphome's full config pass before code generation; do
|
||||
# that by hand here since this test only exercises the widget/trigger
|
||||
# codegen slice in isolation.
|
||||
automation_conf = config[CONF_ON_VALUE][0]
|
||||
automation_conf[CONF_TRIGGER_ID].resolve([])
|
||||
automation_conf[CONF_AUTOMATION_ID].resolve([])
|
||||
automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([])
|
||||
|
||||
parent = MockObj("parent_obj")
|
||||
async with LvContext():
|
||||
await widget_to_code(config, table_spec, parent)
|
||||
set_widgets_completed(True)
|
||||
await generate_triggers()
|
||||
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"table_on_value->obj" in s
|
||||
and "add_event_cb" in s
|
||||
and "LV_EVENT_VALUE_CHANGED" in s
|
||||
for s in statements
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for the LVGL table widget's configuration validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import ACTION_REGISTRY
|
||||
from esphome.components.lvgl.widgets.table import (
|
||||
CONF_MERGE_RIGHT,
|
||||
CONF_TEXT_CROP,
|
||||
TABLE_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def test_minimal_config_is_valid() -> None:
|
||||
assert TABLE_SCHEMA({}) == {}
|
||||
|
||||
|
||||
def test_row_shorthand_expands_to_plain_cells() -> None:
|
||||
config = TABLE_SCHEMA({"rows": [["Name", "Value"]]})
|
||||
[row] = config["rows"]
|
||||
assert row["cells"] == [{"text": "Name"}, {"text": "Value"}]
|
||||
|
||||
|
||||
def test_row_dict_form_with_cell_overrides() -> None:
|
||||
config = TABLE_SCHEMA(
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"Temp",
|
||||
{"text": "22.5", "text_crop": True, "merge_right": True},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
[row] = config["rows"]
|
||||
assert row["cells"][0] == {"text": "Temp"}
|
||||
assert row["cells"][1] == {
|
||||
"text": "22.5",
|
||||
"merge_right": True,
|
||||
"text_crop": True,
|
||||
}
|
||||
|
||||
|
||||
def test_row_count_defaults_are_not_injected_by_the_schema() -> None:
|
||||
# Inference of row/column counts from `rows` happens at code generation
|
||||
# time, not during validation - the schema should leave them unset.
|
||||
config = TABLE_SCHEMA({"rows": [["a", "b"], ["c"]]})
|
||||
assert "row_count" not in config
|
||||
assert "column_count" not in config
|
||||
|
||||
|
||||
def test_explicit_row_and_column_count_are_kept() -> None:
|
||||
config = TABLE_SCHEMA({"row_count": 5, "column_count": 3})
|
||||
assert config["row_count"] == 5
|
||||
assert config["column_count"] == 3
|
||||
|
||||
|
||||
def test_row_count_too_small_for_given_rows_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="row_count"):
|
||||
TABLE_SCHEMA({"rows": [["a"], ["b"], ["c"]], "row_count": 2})
|
||||
|
||||
|
||||
def test_column_count_too_small_for_given_cells_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="column_count"):
|
||||
TABLE_SCHEMA({"rows": [["a", "b", "c"]], "column_count": 2})
|
||||
|
||||
|
||||
def test_columns_list_longer_than_column_count_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="columns"):
|
||||
TABLE_SCHEMA(
|
||||
{
|
||||
"column_count": 1,
|
||||
"columns": [{"width": 10}, {"width": 20}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_columns_list_matching_inferred_column_count_is_valid() -> None:
|
||||
config = TABLE_SCHEMA(
|
||||
{
|
||||
"rows": [["a", "b"]],
|
||||
"columns": [{"width": 10}, {"width": 20}],
|
||||
}
|
||||
)
|
||||
assert [c["width"] for c in config["columns"]] == [10, 20]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "expected"),
|
||||
[
|
||||
(100, 100),
|
||||
("50%", 0.5),
|
||||
("32px", 32),
|
||||
],
|
||||
)
|
||||
def test_column_width_accepts_pixels_and_percent(width, expected) -> None:
|
||||
config = TABLE_SCHEMA({"columns": [{"width": width}]})
|
||||
assert config["columns"][0]["width"] == expected
|
||||
|
||||
|
||||
def test_columns_percent_widths_summing_over_100_percent_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="columns"):
|
||||
TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "50%"}]})
|
||||
|
||||
|
||||
def test_columns_percent_widths_summing_to_100_percent_is_valid() -> None:
|
||||
config = TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "40%"}]})
|
||||
assert [c["width"] for c in config["columns"]] == [0.6, 0.4]
|
||||
|
||||
|
||||
def test_columns_mixed_pixel_and_percent_widths_ignore_pixels_in_the_total() -> None:
|
||||
# Pixel widths aren't part of the percentage budget, so they shouldn't
|
||||
# count towards the 100% limit.
|
||||
config = TABLE_SCHEMA(
|
||||
{"columns": [{"width": 200}, {"width": "80%"}, {"width": "20%"}]}
|
||||
)
|
||||
assert [c["width"] for c in config["columns"]] == [200, 0.8, 0.2]
|
||||
|
||||
|
||||
def test_selected_row_and_selected_column_are_independently_optional() -> None:
|
||||
config = TABLE_SCHEMA({"selected_row": 1})
|
||||
assert config["selected_row"] == 1
|
||||
assert "selected_column" not in config
|
||||
|
||||
|
||||
def test_cell_update_action_requires_at_least_one_field() -> None:
|
||||
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
|
||||
with pytest.raises(cv.Invalid):
|
||||
entry.schema({"id": "some_table", "row": 0, "column": 0})
|
||||
|
||||
|
||||
def test_cell_update_action_accepts_a_single_field() -> None:
|
||||
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
|
||||
config = entry.schema(
|
||||
{"id": "some_table", "row": 0, "column": 0, "merge_right": True}
|
||||
)
|
||||
assert config[CONF_MERGE_RIGHT] is True
|
||||
assert CONF_TEXT_CROP not in config
|
||||
Reference in New Issue
Block a user