diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index f3f7cb30eb..42be51cdd9 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -182,8 +182,6 @@ jobs: contents: read # actions/checkout to load the test configs strategy: fail-fast: false - # Modest cap so this smoke test leaves room on the shared runner pool. - max-parallel: 8 matrix: # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) # share a toolchain bundle, so esp32 is exercised on the base variant diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da0937555..a2762faa4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -946,7 +946,6 @@ jobs: ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false - max-parallel: ${{ needs.determine-jobs.outputs.release-pr == 'true' && 32 || 16 }} matrix: batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 22fccdd92a..684f472ebd 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -525,6 +525,52 @@ void IndicatorLine::update_length_() { } #endif +#ifdef USE_LVGL_TABLE +uint32_t lv_table_get_selected_row(lv_obj_t *obj) { + uint32_t row; + uint32_t column; + lv_table_get_selected_cell(obj, &row, &column); + return row; +} + +uint32_t lv_table_get_selected_column(lv_obj_t *obj) { + uint32_t row; + uint32_t column; + lv_table_get_selected_cell(obj, &row, &column); + return column; +} + +void LvTableType::set_obj(lv_obj_t *lv_obj) { + LvCompound::set_obj(lv_obj); + lv_obj_add_event_cb( + lv_obj, + [](lv_event_t *e) { + auto *table = static_cast(lv_event_get_user_data(e)); + table->update_column_widths_(); + }, + LV_EVENT_SIZE_CHANGED, this); +} + +void LvTableType::add_column_width_pct(uint32_t col, uint8_t pct) { + for (auto &i : this->column_pct_) { + if (i.col == col) { + i.pct = pct; + this->update_column_widths_(); + return; + } + } + this->column_pct_.push_back({col, pct}); + this->update_column_widths_(); +} + +void LvTableType::update_column_widths_() { + auto content_width = lv_obj_get_content_width(this->obj); + for (const auto &col : this->column_pct_) { + lv_table_set_column_width(this->obj, col.col, content_width * col.pct / 100); + } +} +#endif // USE_LVGL_TABLE + #ifdef USE_LVGL_KEY_LISTENER LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time) { this->drv_ = lv_indev_create(); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 98b97e26d7..ceba786e43 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -58,6 +58,10 @@ lv_obj_t *lv_container_create(lv_obj_t *parent); void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_end, lv_color_t color_start, lv_color_t color_end, int width, bool local); #endif +#ifdef USE_LVGL_TABLE +uint32_t lv_table_get_selected_row(lv_obj_t *obj); +uint32_t lv_table_get_selected_column(lv_obj_t *obj); +#endif #if LV_COLOR_DEPTH == 16 static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_565; #elif LV_COLOR_DEPTH == 32 @@ -511,6 +515,27 @@ class LvLineType : public LvCompound { FixedVector points_{}; }; #endif +#ifdef USE_LVGL_TABLE +// Unlike most size properties, lv_table_set_column_width() only accepts a literal pixel +// count, so percentage column widths must be recomputed by hand whenever the table's own +// content width changes. +class LvTableType : public LvCompound { + public: + void set_obj(lv_obj_t *lv_obj) override; + // count is the number of percentage-width columns, known at code-generation time. + void init_column_pct(size_t count) { this->column_pct_.init(count); } + void add_column_width_pct(uint32_t col, uint8_t pct); + + protected: + void update_column_widths_(); + + struct ColumnPct { + uint32_t col; + uint8_t pct; + }; + FixedVector column_pct_{}; +}; +#endif // USE_LVGL_TABLE #if defined(USE_LVGL_DROPDOWN) || defined(LV_USE_ROLLER) class LvSelectable : public LvCompound { public: diff --git a/esphome/components/lvgl/widgets/table.py b/esphome/components/lvgl/widgets/table.py new file mode 100644 index 0000000000..efae2be2be --- /dev/null +++ b/esphome/components/lvgl/widgets/table.py @@ -0,0 +1,280 @@ +from contextlib import ExitStack + +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROW, CONF_TEXT, CONF_WIDTH +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.types import ConfigFragmentType, ConfigType, SafeExpType + +from ..automation import action_to_code +from ..defines import CONF_COLUMN, CONF_MAIN, LValidator, literal +from ..lv_validation import lv_int, lv_text, pixels_or_percent, pixels_validator +from ..lvcode import LocalVariable, lv, lv_add, lv_expr +from ..types import LvCompound, LvType, ObjUpdateAction, lv_coord_t +from . import Widget, WidgetType, get_widgets +from .label import CONF_LABEL + +CONF_TABLE = "table" +CONF_CELLS = "cells" +CONF_COLUMNS = "columns" +CONF_ROW_COUNT = "row_count" +CONF_COLUMN_COUNT = "column_count" +CONF_MERGE_RIGHT = "merge_right" +CONF_TEXT_CROP = "text_crop" +CONF_SELECTED_ROW = "selected_row" +CONF_SELECTED_COLUMN = "selected_column" + +CELL_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TEXT, default=""): lv_text, + # Not templatable: the value selects between two different LVGL calls + # (set/clear cell ctrl), so a runtime lambda can't be mapped to a single call. + cv.Optional(CONF_MERGE_RIGHT): cv.boolean, + cv.Optional(CONF_TEXT_CROP): cv.boolean, + } +) + +# A cell can be given as a bare piece of text, or a dict for more control +TABLE_CELL_SCHEMA = cv.maybe_simple_value(CELL_SCHEMA, key=CONF_TEXT) + +# A row can be given as a bare list of cells, or a dict for future extension +ROW_SCHEMA = cv.maybe_simple_value( + cv.Schema({cv.Required(CONF_CELLS): cv.ensure_list(TABLE_CELL_SCHEMA)}), + key=CONF_CELLS, +) + + +def _column_width_validator(value: ConfigFragmentType) -> int | float | list[str]: + """Like pixels_or_percent, but rejects negative widths, which would + defeat the 100%-total check and wrap around in the generated uint8_t pct.""" + if value == SCHEMA_EXTRACT: + return ["pixels", "..%"] + return cv.Any(pixels_validator, cv.percentage)(value) + + +column_width = LValidator( + _column_width_validator, + lv_coord_t, + retmapper=pixels_or_percent.retmapper, + animatable=True, +) + +COLUMN_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WIDTH): column_width, + } +) + + +def _validate_table(config: ConfigType) -> ConfigType: + rows = config.get(CONF_ROWS) + min_row_count = len(rows) if rows else 0 + min_column_count = max(len(row[CONF_CELLS]) for row in rows) if rows else 0 + row_count = config.get(CONF_ROW_COUNT) + if row_count is not None and row_count < min_row_count: + raise cv.Invalid( + f"{CONF_ROW_COUNT} must be at least {min_row_count} to hold all the given rows", + path=[CONF_ROW_COUNT], + ) + column_count = config.get(CONF_COLUMN_COUNT) + if column_count is not None and column_count < min_column_count: + raise cv.Invalid( + f"{CONF_COLUMN_COUNT} must be at least {min_column_count} to hold all the cells in a row", + path=[CONF_COLUMN_COUNT], + ) + column_count = column_count if column_count is not None else min_column_count + columns = config.get(CONF_COLUMNS) + if columns and column_count and len(columns) > column_count: + raise cv.Invalid( + f"{CONF_COLUMNS} defines {len(columns)} columns, but the table has only {column_count}", + path=[CONF_COLUMNS], + ) + total_pct = sum( + width + for column in columns or () + if isinstance((width := column.get(CONF_WIDTH)), float) + ) + if total_pct > 1.0: + raise cv.Invalid( + f"{CONF_COLUMNS} percentage widths add up to {total_pct * 100:.0f}%, which exceeds 100%", + path=[CONF_COLUMNS], + ) + return config + + +TABLE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ROWS): cv.ensure_list(ROW_SCHEMA), + cv.Optional(CONF_ROW_COUNT): cv.positive_int, + cv.Optional(CONF_COLUMN_COUNT): cv.positive_int, + cv.Optional(CONF_COLUMNS): cv.ensure_list(COLUMN_SCHEMA), + cv.Optional(CONF_SELECTED_ROW): lv_int, + cv.Optional(CONF_SELECTED_COLUMN): lv_int, + } +).add_extra(_validate_table) + +lv_table_t = LvType( + "LvTableType", + parents=(LvCompound,), + largs=[(cg.uint32, "row"), (cg.uint32, "column")], + lvalue=lambda w: [ + lv_expr.table_get_selected_row(w.obj), + lv_expr.table_get_selected_column(w.obj), + ], + has_on_value=True, +) + + +async def set_cell_ctrl( + w: Widget, row: SafeExpType, column: SafeExpType, cell: ConfigType +) -> None: + for key, ctrl in ( + (CONF_MERGE_RIGHT, "LV_TABLE_CELL_CTRL_MERGE_RIGHT"), + (CONF_TEXT_CROP, "LV_TABLE_CELL_CTRL_TEXT_CROP"), + ): + if key not in cell: + continue + if cell[key]: + lv.table_set_cell_ctrl(w.obj, row, column, literal(ctrl)) + else: + lv.table_clear_cell_ctrl(w.obj, row, column, literal(ctrl)) + + +async def set_selected_cell(w: Widget, config: ConfigType) -> None: + selected_row = config.get(CONF_SELECTED_ROW) + selected_column = config.get(CONF_SELECTED_COLUMN) + if selected_row is None and selected_column is None: + return + # LV_TABLE_CELL_NONE selects the whole column/row when only one index is given + row_value = ( + await lv_int.process(selected_row) + if selected_row is not None + else literal("LV_TABLE_CELL_NONE") + ) + column_value = ( + await lv_int.process(selected_column) + if selected_column is not None + else literal("LV_TABLE_CELL_NONE") + ) + lv.table_set_selected_cell(w.obj, row_value, column_value) + + +TABLE_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SELECTED_ROW): lv_int, + cv.Optional(CONF_SELECTED_COLUMN): lv_int, + } +) + + +class TableType(WidgetType): + def __init__(self): + super().__init__( + CONF_TABLE, + lv_table_t, + (CONF_MAIN, CONF_ITEMS), + TABLE_SCHEMA, + modify_schema=TABLE_MODIFY_SCHEMA, + ) + + def get_uses(self) -> tuple[str]: + return (CONF_LABEL,) + + async def to_code(self, w: Widget, config: dict) -> None: + rows = config.get(CONF_ROWS) + row_count = config.get(CONF_ROW_COUNT) + column_count = config.get(CONF_COLUMN_COUNT) + if rows is not None: + if row_count is None: + row_count = len(rows) + if column_count is None: + column_count = max((len(row[CONF_CELLS]) for row in rows), default=0) + if row_count is not None: + lv.table_set_row_count(w.obj, row_count) + if column_count is not None: + lv.table_set_column_count(w.obj, column_count) + columns = config.get(CONF_COLUMNS, ()) + pct_column_count = sum( + 1 for column in columns if isinstance(column.get(CONF_WIDTH), float) + ) + if pct_column_count: + lv_add(w.var.init_column_pct(pct_column_count)) + for index, column in enumerate(columns): + if (width := column.get(CONF_WIDTH)) is None: + continue + if isinstance(width, float): + # A percentage: column_width validation leaves it as a 0.0-1.0 + # fraction. LVGL's table widget only accepts a literal pixel width, so + # the actual width is recomputed at runtime from the table's own size. + lv_add(w.var.add_column_width_pct(index, round(width * 100))) + else: + lv.table_set_column_width( + w.obj, index, await column_width.process(width) + ) + for row_index, row in enumerate(rows or ()): + for column_index, cell in enumerate(row[CONF_CELLS]): + lv.table_set_cell_value( + w.obj, + row_index, + column_index, + await lv_text.process(cell[CONF_TEXT]), + ) + await set_cell_ctrl(w, row_index, column_index, cell) + await set_selected_cell(w, config) + + +table_spec = TableType() + + +@automation.register_action( + "lvgl.table.cell.update", + ObjUpdateAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_table_t), + cv.Required(CONF_ROW): lv_int, + cv.Required(CONF_COLUMN): lv_int, + cv.Optional(CONF_TEXT): lv_text, + cv.Optional(CONF_MERGE_RIGHT): cv.boolean, + cv.Optional(CONF_TEXT_CROP): cv.boolean, + } + ).add_extra(cv.has_at_least_one_key(CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)), + synchronous=True, +) +async def table_cell_update_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + widgets = await get_widgets(config) + + async def do_update(w: Widget): + row = await lv_int.process(config[CONF_ROW]) + column = await lv_int.process(config[CONF_COLUMN]) + fields_set = sum( + key in config for key in (CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP) + ) + with ExitStack() as stack: + if fields_set > 1: + # row/column feed more than one generated call below: cache them in + # local variables so a !lambda value is only evaluated once. + row = stack.enter_context( + LocalVariable("row", cg.int_, row, modifier="") + ) + column = stack.enter_context( + LocalVariable("column", cg.int_, column, modifier="") + ) + if CONF_TEXT in config: + lv.table_set_cell_value( + w.obj, row, column, await lv_text.process(config[CONF_TEXT]) + ) + await set_cell_ctrl(w, row, column, config) + + return await action_to_code( + widgets, do_update, action_id, template_arg, args, config + ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index c78e910bc8..57be4e9043 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -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 diff --git a/tests/unit_tests/components/lvgl/__init__.py b/tests/unit_tests/components/lvgl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/lvgl/test_table_codegen.py b/tests/unit_tests/components/lvgl/test_table_codegen.py new file mode 100644 index 0000000000..390f67dffc --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_table_codegen.py @@ -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 + ) diff --git a/tests/unit_tests/components/lvgl/test_table_config.py b/tests/unit_tests/components/lvgl/test_table_config.py new file mode 100644 index 0000000000..047d1781ae --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_table_config.py @@ -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