diff --git a/docs/Datagrid Tests.md b/docs/Datagrid Tests.md new file mode 100644 index 0000000..97308c4 --- /dev/null +++ b/docs/Datagrid Tests.md @@ -0,0 +1,118 @@ +# DataGrid Tests — Backlog + +Source file: `tests/controls/test_datagrid.py` + +Legend: ✅ Done — ⬜ Pending + +--- + +## TestDataGridBehaviour + +### Edition flow + +| # | Status | Test | Description | +|---|--------|-----------------------------------------------------------|----------------------------------------------------------| +| 1 | ⬜ | `test_i_can_convert_edition_value_for_number` | `"3.14"` → `float`, `"5"` → `int` | +| 2 | ⬜ | `test_i_can_convert_edition_value_for_bool` | `"true"`, `"1"`, `"yes"` → `True`; others → `False` | +| 3 | ⬜ | `test_i_can_convert_edition_value_for_text` | String value returned unchanged | +| 4 | ⬜ | `test_i_can_handle_start_edition` | Sets `edition.under_edition` and returns a cell render | +| 5 | ⬜ | `test_i_cannot_handle_start_edition_when_already_editing` | Second call while `under_edition` is set is a no-op | +| 6 | ⬜ | `test_i_can_handle_save_edition` | Writes value to data service and clears `under_edition` | +| 7 | ⬜ | `test_i_cannot_handle_save_edition_when_not_editing` | Returns partial render without touching the data service | + +### Column management + +| # | Status | Test | Description | +|----|--------|---------------------------------------------------------|----------------------------------------------------------------| +| 8 | ⬜ | `test_i_can_add_new_column` | Appends column to `_state.columns` and `_columns` | +| 9 | ⬜ | `test_i_can_handle_columns_reorder` | Reorders `_state.columns` according to provided list | +| 10 | ⬜ | `test_i_can_handle_columns_reorder_ignores_unknown_ids` | Unknown IDs skipped; known columns not in list appended at end | + +### Mouse selection + +| # | Status | Test | Description | +|----|--------|-------------------------------------------------|---------------------------------------------------------------| +| 11 | ⬜ | `test_i_can_on_mouse_selection_sets_range` | Sets `extra_selected` with `("range", ...)` from two cell IDs | +| 12 | ⬜ | `test_i_cannot_on_mouse_selection_when_outside` | `is_inside=False` leaves `extra_selected` unchanged | + +### Key pressed + +| # | Status | Test | Description | +|----|--------|--------------------------------------------------|----------------------------------------------------------------------------------------------| +| 13 | ⬜ | `test_i_can_on_key_pressed_enter_starts_edition` | `enter` on selected cell enters edition when `enable_edition=True` and nothing under edition | + +### Click + +| # | Status | Test | Description | +|----|--------|---------------------------------------------------|-----------------------------------------------------------------| +| 14 | ⬜ | `test_i_can_on_click_second_click_enters_edition` | Second click on already-selected cell triggers `_enter_edition` | + +### Filtering / sorting + +| # | Status | Test | Description | +|----|--------|--------------------------------------------|-------------------------------------------------------------------------------------| +| 15 | ⬜ | `test_i_can_filter_grid` | `filter()` updates `_state.filtered`; filtered DataFrame excludes non-matching rows | +| 16 | ⬜ | `test_i_can_apply_sort` | `_apply_sort` returns rows in correct order when a sort definition is present | +| 17 | ⬜ | `test_i_can_apply_filter_by_column_values` | Column filter (non-FILTER_INPUT) keeps only matching rows | + +### Format rules priority + +| # | Status | Test | Description | +|----|--------|----------------------------------------------------------------------|------------------------------------------------------------------| +| 18 | ⬜ | `test_i_can_get_format_rules_cell_level_takes_priority` | Cell format overrides row, column and table format | +| 19 | ⬜ | `test_i_can_get_format_rules_row_level_takes_priority_over_column` | Row format overrides column and table when no cell format | +| 20 | ⬜ | `test_i_can_get_format_rules_column_level_takes_priority_over_table` | Column format overrides table when no cell or row format | +| 21 | ⬜ | `test_i_can_get_format_rules_falls_back_to_table_format` | Table format returned when no cell, row or column format defined | + +--- + +## TestDataGridRender + +### Table structure + +| # | Status | Test | Description | +|----|--------|------------------------------------------|-------------------------------------------------------------------------------------------| +| 22 | ✅ | `test_i_can_render_table_wrapper` | ID `tw_{id}`, class `dt2-table-wrapper`, 3 sections: selection manager, table, scrollbars | +| 23 | ✅ | `test_i_can_render_table` | ID `t_{id}`, class `dt2-table`, 3 containers: header, body wrapper, footer | +| 24 | ✅ | `test_i_can_render_table_has_scrollbars` | Scrollbars overlay contains vertical and horizontal tracks | + +### render_partial fragments + +| # | Status | Test | Description | +|----|--------|---------------------------------------------------|--------------------------------------------------------------------------------------| +| 25 | ✅ | `test_i_can_render_partial_body` | Returns `(selection_manager, body_wrapper)` — body wrapper has `hx-on::after-settle` | +| 26 | ✅ | `test_i_can_render_partial_table` | Returns `(selection_manager, table)` — table has `hx-on::after-settle` | +| 27 | ✅ | `test_i_can_render_partial_header` | Returns header with `hx-on::after-settle` containing `setColumnWidth` | +| 28 | ✅ | `test_i_can_render_partial_cell_by_pos` | Returns `(selection_manager, cell)` for a specific `(col, row)` position | +| 29 | ✅ | `test_i_can_render_partial_cell_with_no_position` | Returns only `(selection_manager,)` when no `pos` or `cell_id` given | + +### Edition cell + +| # | Status | Test | Description | +|----|--------|-----------------------------------------------|----------------------------------------------------------------------------------------------------------| +| 30 | ⬜ | `test_i_can_render_body_cell_in_edition_mode` | When `edition.under_edition` matches, `mk_body_cell` returns an input cell with class `dt2-cell-edition` | + +### Cell content — search highlighting + +| # | Status | Test | Description | +|----|--------|-----------------------------------------------------------------------------|-----------------------------------------------------------------| +| 31 | ⬜ | `test_i_can_render_body_cell_content_with_search_highlight` | Matching keyword produces a `Span` with class `dt2-highlight-1` | +| 32 | ⬜ | `test_i_can_render_body_cell_content_with_no_highlight_when_keyword_absent` | Non-matching keyword produces no `dt2-highlight-1` span | + +### Footer + +| # | Status | Test | Description | +|----|--------|-----------------------------------------------------------|--------------------------------------------------------------------------| +| 33 | ⬜ | `test_i_can_render_footers_wrapper` | `mk_footers` renders with ID `tf_{id}` and class `dt2-footer` | +| 34 | ⬜ | `test_i_can_render_aggregation_cell_sum` | `mk_aggregation_cell` with `FooterAggregation.Sum` renders the sum value | +| 35 | ⬜ | `test_i_cannot_render_aggregation_cell_for_hidden_column` | Hidden column returns `Div(cls="dt2-col-hidden")` | + +--- + +## Summary + +| Class | Total | ✅ Done | ⬜ Pending | +|-------------------------|--------|--------|-----------| +| `TestDataGridBehaviour` | 21 | 0 | 21 | +| `TestDataGridRender` | 14 | 8 | 6 | +| **Total** | **35** | **8** | **27** | diff --git a/tests/controls/test_datagrid.py b/tests/controls/test_datagrid.py index 5b80ee1..8a801aa 100644 --- a/tests/controls/test_datagrid.py +++ b/tests/controls/test_datagrid.py @@ -79,14 +79,6 @@ def datagrid_with_full_data(datagrids_manager): return dg -@pytest.fixture -def datagrid_no_edition(datagrid_with_data): - """DataGrid with edition disabled (no RowSelection column, no add-column button).""" - dg = datagrid_with_data - dg._settings.enable_edition = False - dg._init_columns() - return dg - class TestDataGridBehaviour: def test_i_can_create_empty_datagrid(self, datagrids_manager): @@ -150,24 +142,20 @@ class TestDataGridBehaviour: # Element ID Parsing # ------------------------------------------------------------------ - def test_i_can_get_pos_from_cell_element_id(self, datagrid): - """Test that _get_pos_from_element_id correctly parses (col, row) from a cell ID. + @pytest.mark.parametrize("element_id_template, expected", [ + ("tcell_{id}-3-7", (3, 7)), + ("trow_{id}-5", None), + (None, None), + ]) + def test_i_can_get_pos_from_element_id(self, datagrid, element_id_template, expected): + """Test that _get_pos_from_element_id returns the correct (col, row) position or None. - The position tuple (col, row) is used for cell navigation and selection - state tracking. Correct parsing is required for keyboard navigation and - mouse selection to target the right cell. + - Cell IDs ('tcell_…') carry (col, row) indices required for cell navigation. + - Row IDs ('trow_…') have no cell position; None signals no cell can be derived. + - None input is a safe no-op; callers must handle it without raising. """ - element_id = f"tcell_{datagrid._id}-3-7" - assert datagrid._get_pos_from_element_id(element_id) == (3, 7) - - def test_i_can_get_pos_returns_none_for_non_cell_id(self, datagrid): - """Test that _get_pos_from_element_id returns None for row IDs and None input. - - Row and column IDs don't carry a (col, row) position. Returning None - signals that no cell-level position can be derived. - """ - assert datagrid._get_pos_from_element_id(f"trow_{datagrid._id}-5") is None - assert datagrid._get_pos_from_element_id(None) is None + element_id = element_id_template.format(id=datagrid._id) if element_id_template else None + assert datagrid._get_pos_from_element_id(element_id) == expected # ------------------------------------------------------------------ # Static ID Conversions @@ -488,60 +476,28 @@ class TestDataGridRender: ) assert matches(html, expected) - def test_i_can_render_extra_selected_row(self, datagrid): - """Test that a row extra-selection entry renders as a Div with selection_type='row'. + @pytest.mark.parametrize("sel_type, element_id_template", [ + ("row", "trow_{id}-3"), + ("column", "tcol_{id}-2"), + ("range", (0, 0, 2, 2)), + ]) + def test_i_can_render_extra_selected_entry(self, datagrid, sel_type, element_id_template): + """Test that each extra-selection type renders as a child Div with the correct attributes. Why these elements matter: - - selection_type='row': JS applies the row-stripe highlight to the entire row - - element_id: the DOM ID of the row element that JS will highlight + - selection_type: tells JS which highlight strategy to apply (row stripe, + column stripe, or range rectangle) + - element_id: the DOM target JS will highlight; strings are used as-is, + tuples (range bounds) are stringified so JS can parse the coordinates """ dg = datagrid - row_element_id = f"trow_{dg._id}-3" - dg._state.selection.extra_selected.append(("row", row_element_id)) - - html = dg.mk_selection_manager() - - expected = Div( - Div(selection_type="row", element_id=row_element_id), - id=f"tsm_{dg._id}", - ) - assert matches(html, expected) - - def test_i_can_render_extra_selected_column(self, datagrid): - """Test that a column extra-selection entry renders as a Div with selection_type='column'. + element_id = element_id_template.format(id=dg._id) if isinstance(element_id_template, str) else element_id_template + dg._state.selection.extra_selected.append((sel_type, element_id)) - Why these elements matter: - - selection_type='column': JS applies the column-stripe highlight to the entire column - - element_id: the DOM ID of the column header element that JS will highlight - """ - dg = datagrid - col_element_id = f"tcol_{dg._id}-2" - dg._state.selection.extra_selected.append(("column", col_element_id)) - html = dg.mk_selection_manager() - - expected = Div( - Div(selection_type="column", element_id=col_element_id), - id=f"tsm_{dg._id}", - ) - assert matches(html, expected) - - def test_i_can_render_extra_selected_range(self, datagrid): - """Test that a range extra-selection entry renders with the tuple stringified as element_id. - Why these elements matter: - - selection_type='range': JS draws a rectangular highlight over the cell region - - element_id=str(tuple): the range bounds (min_col, min_row, max_col, max_row) - are passed as a string; JS parses this to locate all cells in the rectangle - """ - dg = datagrid - range_bounds = (0, 0, 2, 2) - dg._state.selection.extra_selected.append(("range", range_bounds)) - - html = dg.mk_selection_manager() - expected = Div( - Div(selection_type="range", element_id=f"{range_bounds}"), + Div(selection_type=sel_type, element_id=f"{element_id}"), id=f"tsm_{dg._id}", ) assert matches(html, expected) @@ -612,60 +568,34 @@ class TestDataGridRender: col_headers = find(html, Div(cls=Contains("dt2-cell", "dt2-resizable"))) assert len(col_headers) == 3, "Should have one resizable header cell per visible data column" - def test_i_can_render_row_selection_header_in_edition_mode(self, datagrid_with_data): - """Test that a RowSelection header cell is rendered when edition mode is enabled. + @pytest.mark.parametrize("css_cls, edition_enabled, expected_count", [ + ("dt2-row-selection", True, 1), + ("dt2-row-selection", False, 0), + ("dt2-add-column", True, 1), + ("dt2-add-column", False, 0), + ]) + def test_i_can_render_header_edition_elements_visibility( + self, datagrid_with_data, css_cls, edition_enabled, expected_count): + """Test that edition-specific header elements are present only when edition is enabled. Why these elements matter: - - dt2-row-selection: the selection checkbox column is only meaningful in edition - mode where rows can be individually selected for bulk operations; JS uses this - cell to anchor the row-selection toggle handler - - exactly 1 cell: a second dt2-row-selection would double the checkbox column + - dt2-row-selection: the checkbox column is only meaningful in edition mode; + rendering it in read-only mode would create an orphan misaligned column + - dt2-add-column: the '+' icon exposes mutation UI; it must be hidden in + read-only grids to prevent users from adding columns unintentionally + - expected_count 1 vs 0: exactly one element when enabled, none when disabled, + prevents both missing controls and duplicated ones """ dg = datagrid_with_data + dg._settings.enable_edition = edition_enabled + dg._init_columns() html = dg.mk_headers() - - row_sel_cells = find(html, Div(cls=Contains("dt2-row-selection"))) - assert len(row_sel_cells) == 1, "Edition mode must render exactly one row-selection header cell" - - def test_i_cannot_render_row_selection_header_without_edition_mode(self, datagrid_no_edition): - """Test that no RowSelection header cell is rendered when edition mode is disabled. - Why this matters: - - Without edition, there is no row selection column in _columns; rendering one - would create an orphan cell misaligned with the body rows - """ - dg = datagrid_no_edition - html = dg.mk_headers() - - row_sel_cells = find(html, Div(cls=Contains("dt2-row-selection"))) - assert len(row_sel_cells) == 0, "Without edition mode, no row-selection header cell should be rendered" - - def test_i_can_render_add_column_button_in_edition_mode(self, datagrid_with_data): - """Test that the add-column button is appended to the header in edition mode. - - Why this element matters: - - dt2-add-column: the '+' icon at the end of the header lets users add new - columns interactively; it must be present in edition mode and absent otherwise - to avoid exposing mutation UI in read-only grids - """ - dg = datagrid_with_data - html = dg.mk_headers() - - add_col_cells = find(html, Div(cls=Contains("dt2-add-column"))) - assert len(add_col_cells) == 1, "Edition mode must render exactly one add-column button" - - def test_i_cannot_render_add_column_button_without_edition_mode(self, datagrid_no_edition): - """Test that no add-column button is rendered when edition mode is disabled. - - Why this matters: - - Read-only grids must not expose mutation controls; the absence of dt2-add-column - guarantees that the JS handler for toggling the column editor is never reachable - """ - dg = datagrid_no_edition - html = dg.mk_headers() - - add_col_cells = find(html, Div(cls=Contains("dt2-add-column"))) - assert len(add_col_cells) == 0, "Without edition mode, no add-column button should be rendered" + elements = find(html, Div(cls=Contains(css_cls))) + assert len(elements) == expected_count, ( + f"{'Edition' if edition_enabled else 'Read-only'} mode must render " + f"exactly {expected_count} '{css_cls}' element(s)" + ) def test_i_can_render_headers_in_column_order(self, datagrid_with_data): """Test that resizable header cells appear in the same order as self._columns. @@ -787,10 +717,213 @@ class TestDataGridRender: )) assert len(handles) == 3, "Each data column must have exactly one resize handle with correct command IDs" + # ------------------------------------------------------------------ + # Table structure + # ------------------------------------------------------------------ + + def test_i_can_render_table_wrapper(self, datagrid_with_data): + """Test that mk_table_wrapper renders with correct ID, class and 3 main sections. + + Why these elements matter: + - id=tw_{id}: used by JS to position custom scrollbars over the table + - cls Contains 'dt2-table-wrapper': CSS hook for relative positioning that lets + the scrollbars overlay use absolute coordinates over the table + - tsm_{id}: selection manager lives inside the wrapper so it survives partial + re-renders that target the wrapper + - t_{id}: the table with header, body and footer + - dt2-scrollbars: custom scrollbar overlay (structure tested separately) + """ + dg = datagrid_with_data + html = dg.mk_table_wrapper() + expected = Div( + Div(id=f"tsm_{dg._id}"), # selection manager + Div(id=f"t_{dg._id}"), # table + Div(cls=Contains("dt2-scrollbars")), # scrollbars overlay + id=f"tw_{dg._id}", + cls=Contains("dt2-table-wrapper"), + ) + assert matches(html, expected) + + def test_i_can_render_table(self, datagrid_with_data): + """Test that mk_table renders with correct ID, class and 3 container sections. + + Why these elements matter: + - id=t_{id}: targeted by on_column_changed and render_partial('table') swaps + - cls Contains 'dt2-table': CSS grid container that aligns header, body and + footer columns + - dt2-header-container: wraps the header row with no-scroll behaviour + - tb_{id}: body wrapper, targeted by get_page for lazy-load row appends and by + render_partial('body') for full body swaps on filter/sort + - dt2-footer-container: wraps the aggregation footer with no-scroll behaviour + """ + dg = datagrid_with_data + html = dg.mk_table() + expected = Div( + Div(cls=Contains("dt2-header-container")), # header container + Div(id=f"tb_{dg._id}"), # body wrapper + Div(cls=Contains("dt2-footer-container")), # footer container + id=f"t_{dg._id}", + cls=Contains("dt2-table"), + ) + assert matches(html, expected) + + def test_i_can_render_table_has_scrollbars(self, datagrid_with_data): + """Test that the scrollbars overlay contains both vertical and horizontal tracks. + + Why these elements matter: + - dt2-scrollbars-vertical-wrapper / dt2-scrollbars-horizontal-wrapper: JS resizes + these wrappers to match the live table dimensions on each render + - dt2-scrollbars-vertical / dt2-scrollbars-horizontal: the visible scrollbar + thumbs that JS moves on scroll; missing either disables that scroll axis + """ + dg = datagrid_with_data + html = dg.mk_table_wrapper() + + # Step 1: Find and validate the vertical scrollbar wrapper + vertical = find_one(html, Div(cls=Contains("dt2-scrollbars-vertical-wrapper"))) + assert matches(vertical, Div( + Div(cls=Contains("dt2-scrollbars-vertical")), + cls=Contains("dt2-scrollbars-vertical-wrapper"), + )) + + # Step 2: Find and validate the horizontal scrollbar wrapper + horizontal = find_one(html, Div(cls=Contains("dt2-scrollbars-horizontal-wrapper"))) + assert matches(horizontal, Div( + Div(cls=Contains("dt2-scrollbars-horizontal")), + cls=Contains("dt2-scrollbars-horizontal-wrapper"), + )) + + # ------------------------------------------------------------------ + # render_partial fragments + # ------------------------------------------------------------------ + + def test_i_can_render_partial_body(self, datagrid_with_data): + """Test that render_partial('body') returns (selection_manager, body_wrapper). + + Why these elements matter: + - 2 elements: both the body and the selection manager are sent back together + so the cell highlight is updated in the same response as the body swap + - tsm_{id}: refreshes the cell highlight after the body is replaced + - tb_{id}: the HTMX target for filter and sort re-renders + - hx-on::after-settle Contains 'initDataGrid': re-initialises JS scroll and + resize logic after the new body is inserted into the DOM + """ + dg = datagrid_with_data + result = dg.render_partial("body") + + # Step 1: Verify tuple length + assert len(result) == 2, "render_partial('body') must return (selection_manager, body_wrapper)" + + # Step 2: Verify selection manager + assert matches(result[0], Div(id=f"tsm_{dg._id}")) + + # Step 3: Verify body wrapper ID, class and after-settle attribute + assert matches(result[1], Div(id=f"tb_{dg._id}", cls=Contains("dt2-body-container"))) + assert "initDataGrid" in result[1].attrs.get("hx-on::after-settle", ""), ( + "Body wrapper must carry hx-on::after-settle with initDataGrid to re-init JS after swap" + ) + + def test_i_can_render_partial_table(self, datagrid_with_data): + """Test that render_partial('table') returns (selection_manager, table). + + Why these elements matter: + - 2 elements: body and selection manager are sent back together so the cell + highlight is updated in the same response as the table swap + - t_{id}: full table swap used by on_column_changed when columns are added, + hidden, or reordered; an incorrect ID would leave the old table in the DOM + - hx-on::after-settle Contains 'initDataGrid': re-initialises column resize + and drag-and-drop after the new table structure is inserted + """ + dg = datagrid_with_data + result = dg.render_partial("table") + + # Step 1: Verify tuple length + assert len(result) == 2, "render_partial('table') must return (selection_manager, table)" + + # Step 2: Verify selection manager + assert matches(result[0], Div(id=f"tsm_{dg._id}")) + + # Step 3: Verify table ID, class and after-settle attribute + assert matches(result[1], Div(id=f"t_{dg._id}", cls=Contains("dt2-table"))) + assert "initDataGrid" in result[1].attrs.get("hx-on::after-settle", ""), ( + "Table must carry hx-on::after-settle with initDataGrid to re-init JS after column swap" + ) + + def test_i_can_render_partial_header(self, datagrid_with_data): + """Test that render_partial('header') returns a single header element with setColumnWidth. + + Why these elements matter: + - not a tuple: header swaps are triggered by reset_column_width which uses a + direct HTMX target (#th_{id}); returning a tuple would break the swap + - th_{id}: the HTMX target for the header swap after auto-size + - hx-on::after-settle Contains 'setColumnWidth': applies the new pixel width + to all body cells via JS after the header is swapped in + - col_id in after-settle: JS needs the column ID to target the correct cells + """ + dg = datagrid_with_data + col_id = dg._state.columns[0].col_id + result = dg.render_partial("header", col_id=col_id, optimal_width=200) + + # Step 1: Verify it is a single element, not a tuple + assert not isinstance(result, tuple), "render_partial('header') must return a single element" + + # Step 2: Verify header ID and class + assert matches(result, Div(id=f"th_{dg._id}", cls=Contains("dt2-header"))) + + # Step 3: Verify after-settle contains setColumnWidth and the column ID + after_settle = result.attrs.get("hx-on::after-settle", "") + assert "setColumnWidth" in after_settle, ( + "Header must carry hx-on::after-settle with setColumnWidth to resize body cells" + ) + assert col_id in after_settle, ( + "hx-on::after-settle must include the column ID so JS targets the correct column" + ) + + def test_i_can_render_partial_cell_by_pos(self, datagrid_with_data): + """Test that render_partial('cell', pos=...) returns (selection_manager, cell). + + Why these elements matter: + - 2 elements: cell content and selection manager are sent back together so + the focus highlight is updated in the same response as the cell swap + - tsm_{id}: refreshes the focus highlight after the cell is replaced + - tcell_{id}-{col}-{row}: the HTMX swap target for individual cell updates + (edition entry/exit); an incorrect ID leaves the old cell content in the DOM + """ + dg = datagrid_with_data + name_col = next(c for c in dg._columns if c.title == "name") + col_pos = dg._columns.index(name_col) + result = dg.render_partial("cell", pos=(col_pos, 0)) + + # Step 1: Verify tuple length + assert len(result) == 2, "render_partial('cell', pos=...) must return (selection_manager, cell)" + + # Step 2: Verify selection manager + assert matches(result[0], Div(id=f"tsm_{dg._id}")) + + # Step 3: Verify cell ID and class + assert matches(result[1], Div( + id=f"tcell_{dg._id}-{col_pos}-0", + cls=Contains("dt2-cell"), + )) + + def test_i_can_render_partial_cell_with_no_position(self, datagrid_with_data): + """Test that render_partial() with no position returns only (selection_manager,). + + Why this matters: + - 1 element only: when no valid cell position can be resolved, only the + selection manager is returned to refresh the highlight state + - no cell element: no position means no cell to update in the DOM + """ + dg = datagrid_with_data + result = dg.render_partial() + + assert len(result) == 1, "render_partial() with no position must return only (selection_manager,)" + assert matches(result[0], Div(id=f"tsm_{dg._id}")) + # ------------------------------------------------------------------ # Body # ------------------------------------------------------------------ - + def test_i_can_render_body_wrapper(self, datagrid_with_data): """Test that the body wrapper renders with the correct ID and CSS class.