Merge branch 'esp8266-native-pch' into esp32-idf-pch

This commit is contained in:
J. Nick Koston
2026-08-25 18:18:23 -05:00
9 changed files with 731 additions and 3 deletions
+32
View File
@@ -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