Added some unit tests for the grid
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user