Refactoring DataGrid to use DataService.py
This commit is contained in:
955
tests/controls/test_datagrid.py
Normal file
955
tests/controls/test_datagrid.py
Normal file
@@ -0,0 +1,955 @@
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fasthtml.components import Div, Script
|
||||
|
||||
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
|
||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
from myfasthtml.core.data.DataServicesManager import DataServicesManager
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
from myfasthtml.test.matcher import AnyValue, Contains, NoChildren, find, find_one, matches, TestLabel, TestObject
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datagrids_manager(root_instance):
|
||||
"""Create a DataGridsManager instance for testing."""
|
||||
InstancesManager.reset()
|
||||
TabsManager(root_instance) # just define it
|
||||
return DataGridsManager(root_instance)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datagrid(datagrids_manager):
|
||||
"""Create an empty DataGrid for testing."""
|
||||
return DataGrid(datagrids_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datagrid_with_data(datagrids_manager):
|
||||
"""Create a DataGrid loaded with a sample DataFrame (name, age, active columns)."""
|
||||
session = datagrids_manager._session
|
||||
dsm = InstancesManager.get_by_type(session, DataServicesManager)
|
||||
|
||||
df = pd.DataFrame({
|
||||
"name": ["Alice", "Bob", "Charlie"],
|
||||
"age": [25, 30, 35],
|
||||
"active": [True, False, True],
|
||||
})
|
||||
|
||||
data_service = dsm.create_service("test.grid1", save_state=False)
|
||||
data_service.load_dataframe(df)
|
||||
|
||||
grid_id = DataGrid.get_grid_id_from_data_service_id(data_service.get_id())
|
||||
conf = DatagridConf(namespace="test", name="grid1")
|
||||
return DataGrid(datagrids_manager, conf=conf, save_state=False, _id=grid_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datagrid_with_full_data(datagrids_manager):
|
||||
"""Create a DataGrid covering all basic column types: Text, Number and Bool.
|
||||
|
||||
Designed to be extended with additional column types (Datetime, Enum, Formula)
|
||||
as they are added to the test suite.
|
||||
"""
|
||||
session = datagrids_manager._session
|
||||
dsm = InstancesManager.get_by_type(session, DataServicesManager)
|
||||
|
||||
df = pd.DataFrame({
|
||||
"name": ["Alice", "Bob", "Charlie"], # Text
|
||||
"age": [25, 30, 35], # Number
|
||||
"active": [True, False, True], # Bool
|
||||
})
|
||||
|
||||
data_service = dsm.create_service("test.full_types", save_state=False)
|
||||
data_service.load_dataframe(df)
|
||||
|
||||
grid_id = DataGrid.get_grid_id_from_data_service_id(data_service.get_id())
|
||||
conf = DatagridConf(namespace="test", name="full_types")
|
||||
dg = DataGrid(datagrids_manager, conf=conf, save_state=False, _id=grid_id)
|
||||
|
||||
# Assign distinct widths (name=150, age=80, active=120) so tests that rely
|
||||
# on column widths are not masked by a uniform default value of 100px.
|
||||
for col_ui_state, width in zip(dg._state.columns, [150, 80, 120]):
|
||||
col_ui_state.width = width
|
||||
dg._init_columns()
|
||||
|
||||
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):
|
||||
dg = DataGrid(datagrids_manager)
|
||||
assert dg is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Table Name
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_get_table_name_with_namespace(self, datagrids_manager):
|
||||
"""Test that get_table_name() returns 'namespace.name' when a namespace is set.
|
||||
|
||||
The dot-separated format is required by FormulaEngine for cross-table
|
||||
reference resolution.
|
||||
"""
|
||||
conf = DatagridConf(namespace="reports", name="sales")
|
||||
dg = DataGrid(datagrids_manager, conf=conf)
|
||||
assert dg.get_table_name() == "reports.sales"
|
||||
|
||||
def test_i_can_get_table_name_without_namespace(self, datagrids_manager):
|
||||
"""Test that get_table_name() returns just the name when namespace is absent.
|
||||
|
||||
Grids without a namespace use the plain name, which is valid for
|
||||
single-namespace applications.
|
||||
"""
|
||||
conf = DatagridConf(name="employees")
|
||||
dg = DataGrid(datagrids_manager, conf=conf)
|
||||
assert dg.get_table_name() == "employees"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Element ID Generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("mode, pos, expected_template", [
|
||||
("row", (2, 5), "trow_{id}-5"),
|
||||
("column", (2, 5), "tcol_{id}-2"),
|
||||
("cell", (2, 5), "tcell_{id}-2-5"),
|
||||
])
|
||||
def test_i_can_get_element_id_from_pos(self, datagrid, mode, pos, expected_template):
|
||||
"""Test that _get_element_id_from_pos generates the correct element ID per selection mode.
|
||||
|
||||
Why these cases matter:
|
||||
- 'row' mode: ID references the row index (pos[1]) to target HTMX row updates.
|
||||
- 'column' mode: ID references the column index (pos[0]) for column highlighting.
|
||||
- 'cell' mode: ID includes both indices for precise cell targeting and navigation.
|
||||
"""
|
||||
result = datagrid._get_element_id_from_pos(mode, pos)
|
||||
expected = expected_template.format(id=datagrid._id)
|
||||
assert result == expected
|
||||
|
||||
def test_i_can_get_element_id_when_pos_is_none(self, datagrid):
|
||||
"""Test that _get_element_id_from_pos returns None when position is None.
|
||||
|
||||
None position means no cell is selected; the caller must receive None
|
||||
to know that no DOM element should be targeted.
|
||||
"""
|
||||
assert datagrid._get_element_id_from_pos("cell", None) is None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Static ID Conversions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_convert_grid_id_to_data_service_id_and_back(self, datagrid):
|
||||
"""Test that the grid↔data-service ID conversion is a perfect round-trip.
|
||||
|
||||
DataGrid and DataService share the same UUID but use different prefixes.
|
||||
The round-trip property ensures neither conversion loses information, so
|
||||
DataGrid can always locate its companion DataService from its own ID.
|
||||
"""
|
||||
grid_id = datagrid.get_id()
|
||||
data_service_id = DataGrid.get_data_service_id_from_data_grid_id(grid_id)
|
||||
assert data_service_id != grid_id
|
||||
assert DataGrid.get_grid_id_from_data_service_id(data_service_id) == grid_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Column Management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_move_column(self, datagrid_with_data):
|
||||
"""Test that move_column reorders _state.columns correctly.
|
||||
|
||||
Column order in _state.columns drives both header and body rendering.
|
||||
Moving the last column to the first position verifies the pop-then-insert
|
||||
logic for the source-after-target case.
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
initial_order = [c.col_id for c in dg._state.columns]
|
||||
last_col_id = initial_order[-1]
|
||||
first_col_id = initial_order[0]
|
||||
|
||||
dg.move_column(last_col_id, first_col_id)
|
||||
|
||||
new_order = [c.col_id for c in dg._state.columns]
|
||||
assert new_order[0] == last_col_id
|
||||
|
||||
# init_columns keeps the order
|
||||
dg._init_columns()
|
||||
assert [c.col_id for c in dg._columns][1:] == new_order
|
||||
|
||||
def test_i_cannot_move_column_when_column_not_found(self, datagrid_with_data):
|
||||
"""Test that move_column does not alter state when a col_id is unknown.
|
||||
|
||||
An unknown column ID must be silently ignored (logged as a warning) so
|
||||
that stale JS events cannot corrupt the column order.
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
initial_order = [c.col_id for c in dg._state.columns]
|
||||
|
||||
dg.move_column("nonexistent_col", initial_order[0])
|
||||
|
||||
assert [c.col_id for c in dg._state.columns] == initial_order
|
||||
|
||||
def test_i_can_move_column_to_same_position(self, datagrid_with_data):
|
||||
"""Test that move_column is a no-op when source and target are the same column.
|
||||
|
||||
Dropping a column header back onto itself must not change the order,
|
||||
preventing unnecessary state saves and re-renders.
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
initial_order = [c.col_id for c in dg._state.columns]
|
||||
first_col_id = initial_order[0]
|
||||
|
||||
dg.move_column(first_col_id, first_col_id)
|
||||
|
||||
assert [c.col_id for c in dg._state.columns] == initial_order
|
||||
|
||||
def test_i_can_set_column_width(self, datagrid_with_data):
|
||||
"""Test that handle_set_column_width persists the new pixel width in state.
|
||||
|
||||
Column width in _state.columns is the source of truth for header and
|
||||
body cell sizing. A correct update ensures the resized width survives
|
||||
the next render cycle.
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
col_id = dg._state.columns[0].col_id
|
||||
|
||||
dg.handle_set_column_width(col_id, "300")
|
||||
|
||||
col = next(c for c in dg._state.columns if c.col_id == col_id)
|
||||
assert col.width == 300
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Column Width Calculation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_calculate_optimal_column_width_returns_default_for_unknown_column(self, datagrid):
|
||||
"""Test that calculate_optimal_column_width returns 150 for an unknown column.
|
||||
|
||||
A safe default prevents layout breakage when a col_id from JS does not
|
||||
match any known column (e.g. after a column was deleted mid-session).
|
||||
"""
|
||||
result = datagrid.calculate_optimal_column_width("nonexistent_col")
|
||||
assert result == 150
|
||||
|
||||
@pytest.mark.parametrize("col_title, expected_width", [
|
||||
("name", 86), # data dominates: max("name"=4, "Charlie"=7) → 7*8+30=86
|
||||
("age", 54), # title dominates: max("age"=3, "35"=2) → 3*8+30=54
|
||||
("active", 78), # title dominates: max("active"=6, "False"=5) → 6*8+30=78
|
||||
])
|
||||
def test_i_can_calculate_optimal_column_width_with_data(self, datagrid_with_data, col_title, expected_width):
|
||||
"""Test that calculate_optimal_column_width returns the correct pixel width based on content.
|
||||
|
||||
The formula max(title_length, max_data_length) * 8 + 30 must produce the
|
||||
right width for each column so that the auto-size feature fits all visible
|
||||
content without truncation.
|
||||
|
||||
Why these cases matter:
|
||||
- 'name' (data dominates): "Charlie" (7 chars) > "name" (4 chars) → 86px
|
||||
- 'age' (title dominates): "age" (3 chars) > "35" (2 chars) → 54px
|
||||
- 'active' (title dominates): "active" (6 chars) > "False" (5 chars) → 78px
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
col = next(c for c in dg._columns if c.title == col_title)
|
||||
|
||||
result = dg.calculate_optimal_column_width(col.col_id)
|
||||
|
||||
assert result == expected_width
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Selection and Interaction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_on_key_pressed_esc_clears_selection(self, datagrid):
|
||||
"""Test that pressing ESC resets both the focused cell and extra selections.
|
||||
|
||||
ESC is the standard 'deselect all' shortcut. Both selected and
|
||||
extra_selected must be cleared so the grid visually deselects everything
|
||||
and subsequent navigation starts from a clean state.
|
||||
"""
|
||||
dg = datagrid
|
||||
dg._state.selection.selected = (1, 2)
|
||||
dg._state.selection.extra_selected.append(("range", (0, 0, 2, 2)))
|
||||
|
||||
dg.on_key_pressed("esc", has_focus=True, is_inside=True)
|
||||
|
||||
assert dg._state.selection.selected is None
|
||||
assert dg._state.selection.extra_selected == []
|
||||
|
||||
def test_i_can_on_click_outside_does_not_update_position(self, datagrid):
|
||||
"""Test that a click outside the grid does not change the selected position.
|
||||
|
||||
Clicks on surrounding UI elements must not accidentally move the grid
|
||||
cursor, so is_inside=False must be a complete no-op for selection state.
|
||||
"""
|
||||
dg = datagrid
|
||||
dg._state.selection.selected = (1, 2)
|
||||
|
||||
dg.on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2")
|
||||
|
||||
assert dg._state.selection.selected == (1, 2)
|
||||
|
||||
def test_i_can_on_click_on_cell_updates_position(self, datagrid):
|
||||
"""Test that clicking a cell sets selection.selected to the cell's (col, row) position.
|
||||
|
||||
The selected position drives the focus highlight rendered by
|
||||
mk_selection_manager. Correct parsing and storage ensures the right cell
|
||||
is visually highlighted after each click.
|
||||
"""
|
||||
dg = datagrid
|
||||
cell_id = f"tcell_{dg._id}-2-5"
|
||||
|
||||
dg.on_click("click", is_inside=True, cell_id=cell_id)
|
||||
|
||||
assert dg._state.selection.selected == (2, 5)
|
||||
|
||||
def test_i_can_change_selection_mode(self, datagrid):
|
||||
"""Test that change_selection_mode updates selection_mode from the CycleStateControl.
|
||||
|
||||
The selection mode (row / column / cell) determines how clicks and
|
||||
keyboard events interpret the selected position. The grid must store
|
||||
the mode chosen by the cycle selector so rendering and JS handlers
|
||||
receive a consistent value.
|
||||
"""
|
||||
dg = datagrid
|
||||
assert dg._state.selection.selection_mode == "cell"
|
||||
|
||||
dg._selection_mode_selector.cycle_state()
|
||||
dg.change_selection_mode()
|
||||
assert dg._state.selection.selection_mode == "row"
|
||||
|
||||
dg._selection_mode_selector.cycle_state()
|
||||
dg.change_selection_mode()
|
||||
assert dg._state.selection.selection_mode == "column"
|
||||
|
||||
dg._selection_mode_selector.cycle_state()
|
||||
dg.change_selection_mode()
|
||||
assert dg._state.selection.selection_mode == "cell"
|
||||
|
||||
|
||||
class TestDataGridRender:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Global structure (UTR-11.1)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_no_data_layout_is_rendered(self, datagrid):
|
||||
"""Test that DataGrid renders a placeholder when no DataFrame is loaded.
|
||||
|
||||
Why these elements matter:
|
||||
- Div tag: render must return a valid element, not raise
|
||||
- "No data to display !": gives clear feedback and prevents a crash
|
||||
when the DataService has no loaded DataFrame
|
||||
"""
|
||||
html = datagrid.render()
|
||||
assert matches(html, Div("No data to display !"))
|
||||
|
||||
def test_layout_is_rendered(self, datagrid_with_data):
|
||||
"""Test that DataGrid renders all 4 main structural sections when data is loaded.
|
||||
|
||||
Why these elements matter:
|
||||
- id=dg._id: root ID required for JS init (initDataGrid) and HTMX targeting
|
||||
- cls Contains 'grid': CSS grid layout controls header/body row sizing
|
||||
- child[0] Div: filter bar + toolbar icons
|
||||
- child[1] DoNotCheck: Panel containing the scrollable table
|
||||
- child[2] Script: initDataGrid call that activates JS behaviour
|
||||
- child[3] DoNotCheck: Keyboard handler for in-grid shortcuts
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
html = dg.render()
|
||||
expected = Div(
|
||||
Div(
|
||||
TestObject("Query"), # filter bar
|
||||
Div(), # toolbar icons
|
||||
cls=Contains("flex"),
|
||||
),
|
||||
TestObject("Panel"), # Panel containing the table
|
||||
Script(Contains("initDataGrid")), # initDataGrid script
|
||||
Div(), # Mouse and Keyboard handler
|
||||
id=dg._id,
|
||||
cls=Contains("grid"),
|
||||
)
|
||||
assert matches(html, expected)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Selection Manager
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_render_selection_manager_with_no_selection(self, datagrid):
|
||||
"""Test that the selection manager renders with no children when nothing is selected.
|
||||
|
||||
Why these elements matter:
|
||||
- id=tsm_{id}: required by updateDatagridSelection JS to locate the manager
|
||||
- NoChildren: an empty manager signals JS that nothing is selected,
|
||||
preventing stale highlight artefacts between renders
|
||||
"""
|
||||
dg = datagrid
|
||||
html = dg.mk_selection_manager()
|
||||
expected = Div(
|
||||
NoChildren(),
|
||||
id=f"tsm_{dg._id}",
|
||||
)
|
||||
assert matches(html, expected)
|
||||
|
||||
def test_i_can_render_selection_manager_with_selected_cell(self, datagrid):
|
||||
"""Test that the selection manager renders one focus element when a cell is selected.
|
||||
|
||||
Why these elements matter:
|
||||
- Div child: each selected element becomes a child directive read by JS
|
||||
- selection_type='focus': tells JS to apply the focus highlight style
|
||||
- element_id: the cell DOM ID that JS will scroll to and highlight
|
||||
"""
|
||||
dg = datagrid
|
||||
dg._state.selection.selected = (2, 5)
|
||||
|
||||
html = dg.mk_selection_manager()
|
||||
|
||||
expected = Div(
|
||||
Div(selection_type="focus", element_id=f"tcell_{dg._id}-2-5"),
|
||||
id=f"tsm_{dg._id}",
|
||||
)
|
||||
assert matches(html, expected)
|
||||
|
||||
@pytest.mark.parametrize("mode", ["row", "column", "range", None])
|
||||
def test_i_can_render_selection_manager_selection_mode_attribute(self, datagrid, mode):
|
||||
"""Test that the selection_mode attribute on the manager Div reflects the current mode.
|
||||
|
||||
Why this element matters:
|
||||
- selection_mode attribute: read by updateDatagridSelection JS to know which
|
||||
highlight strategy to apply (row stripe, column stripe, range rectangle, or
|
||||
single cell focus). All four valid values must round-trip correctly.
|
||||
"""
|
||||
dg = datagrid
|
||||
dg._state.selection.selection_mode = mode
|
||||
|
||||
html = dg.mk_selection_manager()
|
||||
|
||||
expected = Div(
|
||||
id=f"tsm_{dg._id}",
|
||||
selection_mode=f"{mode}",
|
||||
)
|
||||
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'.
|
||||
|
||||
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
|
||||
"""
|
||||
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'.
|
||||
|
||||
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}"),
|
||||
id=f"tsm_{dg._id}",
|
||||
)
|
||||
assert matches(html, expected)
|
||||
|
||||
def test_i_can_render_selection_manager_with_multiple_extra_selected(self, datagrid):
|
||||
"""Test that all extra_selected entries are rendered as individual children.
|
||||
|
||||
Why these elements matter:
|
||||
- 3 children: each extra selection must produce exactly one Div child;
|
||||
missing entries would cause JS to miss highlights, extra entries would
|
||||
cause phantom highlights on unselected cells
|
||||
"""
|
||||
dg = datagrid
|
||||
dg._state.selection.extra_selected.append(("row", f"trow_{dg._id}-1"))
|
||||
dg._state.selection.extra_selected.append(("column", f"tcol_{dg._id}-0"))
|
||||
dg._state.selection.extra_selected.append(("range", (1, 1, 3, 3)))
|
||||
|
||||
html = dg.mk_selection_manager()
|
||||
|
||||
children = find(html, Div(selection_type=AnyValue()))
|
||||
assert len(children) == 3, "Each extra_selected entry must produce exactly one child Div"
|
||||
|
||||
def test_i_can_render_selection_manager_with_focus_and_extra_selected(self, datagrid):
|
||||
"""Test that a focused cell and an extra selection are both rendered as children.
|
||||
|
||||
Why these elements matter:
|
||||
- focus child: the primary selection must always be present when selected is set
|
||||
- extra child: secondary selections must not replace the focus entry; both must
|
||||
coexist so JS can apply focus highlight and secondary highlights simultaneously
|
||||
"""
|
||||
dg = datagrid
|
||||
dg._state.selection.selected = (2, 5)
|
||||
dg._state.selection.extra_selected.append(("row", f"trow_{dg._id}-5"))
|
||||
|
||||
html = dg.mk_selection_manager()
|
||||
|
||||
expected = Div(
|
||||
Div(selection_type="focus", element_id=f"tcell_{dg._id}-2-5"),
|
||||
Div(selection_type="row", element_id=f"trow_{dg._id}-5"),
|
||||
id=f"tsm_{dg._id}",
|
||||
)
|
||||
assert matches(html, expected)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Headers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_render_headers(self, datagrid_with_data):
|
||||
"""Test that the header row renders with the correct ID and one cell per visible data column.
|
||||
|
||||
Why these elements matter:
|
||||
- id=th_{id}: targeted by reset_column_width to swap the header after auto-size
|
||||
- cls Contains 'dt2-header': CSS hook for header styling and sticky positioning
|
||||
- 3 data cells: one resizable cell per data column; verifies all DataFrame
|
||||
columns are represented (RowSelection and add-button columns excluded)
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
html = dg.mk_headers()
|
||||
|
||||
# Step 1: Validate global header structure
|
||||
expected = Div(
|
||||
id=f"th_{dg._id}",
|
||||
cls=Contains("dt2-header"),
|
||||
)
|
||||
assert matches(html, expected)
|
||||
|
||||
# Step 2: Count the visible data column headers
|
||||
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.
|
||||
|
||||
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
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
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"
|
||||
|
||||
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.
|
||||
|
||||
Why this matters:
|
||||
- The header order drives column alignment with body rows; a mismatch between
|
||||
header and body column order produces visually broken tables where header
|
||||
labels do not correspond to the values beneath them
|
||||
- data-col: JS drag-and-drop uses this attribute to identify the column being
|
||||
moved; it must match the col_id in _columns for move_column to work correctly
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
html = dg.mk_headers()
|
||||
|
||||
expected_order = [c.col_id for c in dg._columns if c.type != ColumnType.RowSelection_]
|
||||
rendered_cells = find(html, Div(cls=Contains("dt2-cell", "dt2-resizable")))
|
||||
rendered_order = [cell.attrs.get("data-col") for cell in rendered_cells]
|
||||
|
||||
assert rendered_order == expected_order, (
|
||||
f"Header column order {rendered_order} does not match _columns order {expected_order}"
|
||||
)
|
||||
|
||||
def test_i_can_render_hidden_column_is_not_rendered(self, datagrid_with_data):
|
||||
"""Test that a hidden column produces no header cell.
|
||||
|
||||
Why this matters:
|
||||
- Columns with visible=False must be completely absent from the header so that
|
||||
body cell alignment is preserved; a hidden header cell would shift all
|
||||
subsequent column headers one position to the right
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
dg._state.columns[0].visible = False
|
||||
dg._init_columns()
|
||||
|
||||
html = dg.mk_headers()
|
||||
|
||||
col_headers = find(html, Div(cls=Contains("dt2-cell", "dt2-resizable")))
|
||||
assert len(col_headers) == 2, "Hiding one column must reduce the resizable header count from 3 to 2"
|
||||
|
||||
@pytest.mark.parametrize("col_title, expected_icon", [
|
||||
("name", "text_field20_regular"), # Text column
|
||||
("age", "number_row20_regular"), # Number column
|
||||
("active", "checkbox_checked20_filled"), # Bool column
|
||||
])
|
||||
def test_i_can_render_header_cell_with_correct_icon_for_column_type(
|
||||
self, datagrid_with_full_data, col_title, expected_icon):
|
||||
"""Test that each column header renders the correct type icon in its label.
|
||||
|
||||
Why these elements matter:
|
||||
- TestLabel(col_title, icon): the label inside _mk_header_name must carry the
|
||||
right icon so the user can visually identify the column type at a glance
|
||||
- data-col: used to locate the cell in the tree; ensures we test the right column
|
||||
- Parametrization covers the three fundamental types (Text, Number, Bool) so
|
||||
that adding a new type requires only a new row in the parametrize decorator
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
col = next(c for c in dg._columns if c.title == col_title)
|
||||
|
||||
html = dg.mk_headers()
|
||||
|
||||
# Step 1: Find the header cell for this column
|
||||
cell = find_one(html, Div(data_col=col.col_id, cls=Contains("dt2-cell", "dt2-resizable")))
|
||||
|
||||
# Step 2: Verify the header name section contains the correct icon
|
||||
expected = Div(
|
||||
Div(
|
||||
TestLabel(col_title, icon=expected_icon),
|
||||
),
|
||||
)
|
||||
assert matches(cell, expected)
|
||||
|
||||
@pytest.mark.parametrize("col_title, expected_width", [
|
||||
("name", 150),
|
||||
("age", 80),
|
||||
("active", 120),
|
||||
])
|
||||
def test_i_can_render_header_cell_width_matches_state(
|
||||
self, datagrid_with_full_data, col_title, expected_width):
|
||||
"""Test that the style attribute of each header cell reflects its width in _columns.
|
||||
|
||||
Why these elements matter:
|
||||
- style Contains 'width:{n}px': the inline style is the sole mechanism that
|
||||
sizes each column; a mismatch between _columns state and the rendered style
|
||||
would produce misaligned headers and body cells after a resize operation
|
||||
- Distinct widths (150 / 80 / 120) in the fixture guarantee that a correct
|
||||
match cannot happen by accident with a uniform default value
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
col = next(c for c in dg._columns if c.title == col_title)
|
||||
|
||||
html = dg.mk_headers()
|
||||
|
||||
cell = find_one(html, Div(data_col=col.col_id, cls=Contains("dt2-cell", "dt2-resizable")))
|
||||
|
||||
assert matches(cell, Div(style=Contains(f"width:{expected_width}px")))
|
||||
|
||||
def test_i_can_render_header_resize_handle_has_correct_commands(self, datagrid_with_full_data):
|
||||
"""Test that every resizable header cell contains a resize handle with the correct command IDs.
|
||||
|
||||
Why these elements matter:
|
||||
- dt2-resize-handle: the DOM target for JS drag-to-resize; its absence completely
|
||||
disables column resizing for the affected column
|
||||
- data-command-id: JS fires this command on mouseup to persist the new width to
|
||||
the server; an incorrect ID would silently discard every resize action
|
||||
- data-reset-command-id: JS fires this command on double-click to auto-size the
|
||||
column; an incorrect ID would break the double-click reset feature
|
||||
- 3 handles: one per data column; a missing handle disables resize for that column
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
resize_cmd_id = dg.commands.set_column_width().id
|
||||
reset_cmd_id = dg.commands.reset_column_width().id
|
||||
|
||||
html = dg.mk_headers()
|
||||
|
||||
handles = find(html, Div(
|
||||
cls=Contains("dt2-resize-handle"),
|
||||
data_command_id=resize_cmd_id,
|
||||
data_reset_command_id=reset_cmd_id,
|
||||
))
|
||||
assert len(handles) == 3, "Each data column must have exactly one resize handle with correct command IDs"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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.
|
||||
|
||||
Why these elements matter:
|
||||
- id=tb_{id}: targeted by get_page to append new rows during lazy loading
|
||||
and by render_partial('body') to swap the whole body on filter/sort
|
||||
- cls Contains 'dt2-body-container': CSS hook that enables the custom
|
||||
JS scrollbar and overflow handling
|
||||
"""
|
||||
dg = datagrid_with_data
|
||||
html = dg.mk_body_wrapper()
|
||||
expected = Div(
|
||||
id=f"tb_{dg._id}",
|
||||
cls=Contains("dt2-body-container"),
|
||||
)
|
||||
assert matches(html, expected)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Body rows
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_render_body_row_count(self, datagrid_with_full_data):
|
||||
"""Test that mk_body_content_page returns one row per DataFrame row plus the add-row button.
|
||||
|
||||
Why these elements matter:
|
||||
- 3 rows: one per DataFrame row (Alice, Bob, Charlie); a missing row means data
|
||||
was silently dropped during rendering
|
||||
- 1 add-row button: required for edition mode; its absence disables row insertion
|
||||
- total 4: the exact count prevents both missing and phantom rows
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
|
||||
rows = dg.mk_body_content_page(0)
|
||||
|
||||
assert len(rows) == 4, "Expected 3 data rows and 1 add-row button in edition mode"
|
||||
|
||||
def test_i_can_render_row_structure(self, datagrid_with_full_data):
|
||||
"""Test that a rendered row carries the correct ID, CSS class and data-row attribute.
|
||||
|
||||
Why these elements matter:
|
||||
- id=tr_{id}-{row_index}: targeted by handle_add_row (outerHTML swap) and lazy
|
||||
loading; an incorrect ID breaks row-level HTMX updates
|
||||
- cls Contains 'dt2-row': CSS hook for row styling and hover effects
|
||||
- data_row: read by JS click handlers to identify which row was interacted with
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
len_columns_1 = len(dg._columns) - 1
|
||||
|
||||
row = dg.mk_row(0, None, len_columns_1)
|
||||
|
||||
expected = Div(
|
||||
id=f"tr_{dg._id}-0",
|
||||
cls=Contains("dt2-row"),
|
||||
data_row="0",
|
||||
)
|
||||
assert matches(row, expected)
|
||||
|
||||
def test_i_can_render_row_has_correct_cell_count(self, datagrid_with_full_data):
|
||||
"""Test that each row renders exactly one cell per column in _columns.
|
||||
|
||||
Why this matters:
|
||||
- cell count must equal column count: a mismatch shifts body cells out of
|
||||
alignment with header cells, producing a visually broken table
|
||||
- includes RowSelection_: the selection column must be present so the body
|
||||
grid matches the header grid column by column
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
len_columns_1 = len(dg._columns) - 1
|
||||
|
||||
row = dg.mk_row(0, None, len_columns_1)
|
||||
|
||||
assert len(row.children) == len(dg._columns), (
|
||||
f"Row must have {len(dg._columns)} cells (one per column), got {len(row.children)}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Body cells
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_render_body_cell_structure(self, datagrid_with_full_data):
|
||||
"""Test that a data cell carries the correct ID, class, style and data-col attribute.
|
||||
|
||||
Why these elements matter:
|
||||
- id=tcell_{id}-{col_pos}-{row_index}: parsed by _get_pos_from_element_id for
|
||||
click/keyboard selection; an incorrect ID breaks cell-level navigation
|
||||
- cls Contains 'dt2-cell': base CSS class for cell layout and borders
|
||||
- style Contains 'width:{n}px': inline style that aligns the cell with its header;
|
||||
a mismatch causes the column to appear misaligned after rendering
|
||||
- data_col: read by JS resize drag to identify which column is being resized
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
name_col = next(c for c in dg._columns if c.title == "name")
|
||||
col_pos = dg._columns.index(name_col)
|
||||
|
||||
cell = dg.mk_body_cell(col_pos, 0, name_col, None, is_last=False)
|
||||
|
||||
expected = Div(
|
||||
id=f"tcell_{dg._id}-{col_pos}-0",
|
||||
cls=Contains("dt2-cell"),
|
||||
style=Contains(f"width:{name_col.width}px"),
|
||||
data_col=name_col.col_id,
|
||||
)
|
||||
assert matches(cell, expected)
|
||||
|
||||
def test_i_can_render_last_body_cell_has_dt2_last_cell_class(self, datagrid_with_full_data):
|
||||
"""Test that the last cell in a row carries the dt2-last-cell class.
|
||||
|
||||
Why this element matters:
|
||||
- dt2-last-cell: CSS hook that removes the right border on the final column
|
||||
so the table edge looks clean; applied only to the last column to avoid
|
||||
removing borders on intermediate cells
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
last_col_pos = len(dg._columns) - 1
|
||||
last_col_def = dg._columns[last_col_pos]
|
||||
|
||||
cell = dg.mk_body_cell(last_col_pos, 0, last_col_def, None, is_last=True)
|
||||
|
||||
assert matches(cell, Div(cls=Contains("dt2-cell", "dt2-last-cell")))
|
||||
|
||||
def test_i_can_render_body_cell_row_selection(self, datagrid_with_full_data):
|
||||
"""Test that the RowSelection_ column renders a selection cell, not a data cell.
|
||||
|
||||
Why this element matters:
|
||||
- dt2-row-selection: the DOM hook used by JS to toggle per-row checkboxes in
|
||||
edition mode; it must not carry dt2-cell markup which would give it a data
|
||||
cell appearance and break the selection column layout
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
row_sel_col = dg._columns[0] # RowSelection_ is always inserted first
|
||||
|
||||
cell = dg.mk_body_cell(0, 0, row_sel_col, None, is_last=False)
|
||||
|
||||
assert matches(cell, Div(cls=Contains("dt2-row-selection")))
|
||||
|
||||
def test_i_cannot_render_body_cell_when_hidden(self, datagrid_with_full_data):
|
||||
"""Test that mk_body_cell returns None for a column marked as not visible.
|
||||
|
||||
Why this matters:
|
||||
- None return: the row renderer filters out None values so the hidden column
|
||||
produces no DOM element; any non-None return would insert a phantom cell
|
||||
and shift all subsequent cells one position to the right
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
dg._state.columns[0].visible = False # hide "name" (first data column)
|
||||
dg._init_columns()
|
||||
|
||||
hidden_col = next(c for c in dg._columns if c.title == "name")
|
||||
col_pos = dg._columns.index(hidden_col)
|
||||
|
||||
cell = dg.mk_body_cell(col_pos, 0, hidden_col, None, is_last=False)
|
||||
|
||||
assert cell is None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Body cell content
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("col_title, expected_css_class, expected_value", [
|
||||
("name", "dt2-cell-content-text", "Alice"),
|
||||
("age", "dt2-cell-content-number", "25"),
|
||||
("active", "dt2-cell-content-checkbox", None),
|
||||
])
|
||||
def test_i_can_render_body_cell_content_for_column_type(
|
||||
self, datagrid_with_full_data, col_title, expected_css_class, expected_value):
|
||||
"""Test that cell content carries the correct CSS class and value for each column type.
|
||||
|
||||
Why these elements matter:
|
||||
- dt2-cell-content-text / number / checkbox: type-specific CSS classes that
|
||||
control text alignment, font weight and boolean display; an incorrect class
|
||||
makes numbers left-aligned or text right-aligned
|
||||
- expected_value ('Alice', '25'): the actual DataFrame value must appear in the
|
||||
rendered content so the cell is not empty or showing a wrong row
|
||||
- Bool uses None for expected_value: the value is an icon, not a text string,
|
||||
so only the wrapper class is verified
|
||||
"""
|
||||
dg = datagrid_with_full_data
|
||||
col_def = next(c for c in dg._columns if c.title == col_title)
|
||||
col_pos = dg._columns.index(col_def)
|
||||
|
||||
content = dg.mk_body_cell_content(col_pos, 0, col_def, None)
|
||||
|
||||
assert expected_css_class in str(content), (
|
||||
f"Expected CSS class '{expected_css_class}' in cell content for column '{col_title}'"
|
||||
)
|
||||
if expected_value is not None:
|
||||
assert expected_value in str(content), (
|
||||
f"Expected value '{expected_value}' in cell content for column '{col_title}'"
|
||||
)
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
|
||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
from myfasthtml.core.formatting.dataclasses import FormatRule, Style
|
||||
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
|
||||
@@ -38,8 +38,8 @@ def datagrid(manager):
|
||||
|
||||
# Add some rows
|
||||
grid._state.rows = [
|
||||
DataGridRowState(0),
|
||||
DataGridRowState(1),
|
||||
DataGridRowUiState(0),
|
||||
DataGridRowUiState(1),
|
||||
]
|
||||
|
||||
yield grid
|
||||
|
||||
@@ -3,9 +3,11 @@ import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.TreeView import TreeNode
|
||||
from myfasthtml.core.data.DataServicesManager import DataServicesManager
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
from .conftest import root_instance
|
||||
|
||||
@@ -16,7 +18,7 @@ def cleanup_db():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datagrid_manager(root_instance):
|
||||
def datagrids_manager(root_instance):
|
||||
"""Create a DataGridsManager instance for testing."""
|
||||
InstancesManager.reset()
|
||||
TabsManager(root_instance) # just define it
|
||||
@@ -26,7 +28,7 @@ def datagrid_manager(root_instance):
|
||||
class TestDataGridsManagerBehaviour:
|
||||
"""Tests for DataGridsManager behavior and logic."""
|
||||
|
||||
def test_i_can_create_new_grid_with_nothing_selected(self, datagrid_manager):
|
||||
def test_i_can_create_new_grid_with_nothing_selected(self, datagrids_manager):
|
||||
"""Test creating a new grid when no node is selected.
|
||||
|
||||
Verifies that:
|
||||
@@ -35,10 +37,10 @@ class TestDataGridsManagerBehaviour:
|
||||
- Node is selected and in edit mode
|
||||
- Document definition is created
|
||||
"""
|
||||
result = datagrid_manager.new_grid()
|
||||
datagrids_manager.handle_new_grid()
|
||||
|
||||
# Verify tree structure
|
||||
tree = datagrid_manager._tree
|
||||
tree = datagrids_manager._tree
|
||||
assert len(tree._state.items) == 2, "Should have Untitled folder + Sheet1 node"
|
||||
|
||||
# Find the Untitled folder and Sheet1 node
|
||||
@@ -55,13 +57,13 @@ class TestDataGridsManagerBehaviour:
|
||||
assert tree._state.editing == sheet.id, "Sheet1 should be in edit mode"
|
||||
|
||||
# Verify document definition
|
||||
assert len(datagrid_manager._state.elements) == 1, "Should have one document"
|
||||
doc = datagrid_manager._state.elements[0]
|
||||
assert len(datagrids_manager._state.elements) == 1, "Should have one document"
|
||||
doc = datagrids_manager._state.elements[0]
|
||||
assert doc.namespace == "Untitled"
|
||||
assert doc.name == "Sheet1"
|
||||
assert doc.type == "excel"
|
||||
|
||||
def test_i_can_create_new_grid_under_selected_folder(self, datagrid_manager):
|
||||
def test_i_can_create_new_grid_under_selected_folder(self, datagrids_manager):
|
||||
"""Test creating a new grid when a folder is selected.
|
||||
|
||||
Verifies that:
|
||||
@@ -69,24 +71,24 @@ class TestDataGridsManagerBehaviour:
|
||||
- Namespace matches folder name
|
||||
"""
|
||||
# Create a folder and select it
|
||||
folder_id = datagrid_manager._tree.ensure_path("MyFolder")
|
||||
datagrid_manager._tree._select_node(folder_id)
|
||||
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
|
||||
datagrids_manager._tree._select_node(folder_id)
|
||||
|
||||
result = datagrid_manager.new_grid()
|
||||
result = datagrids_manager.handle_new_grid()
|
||||
|
||||
# Verify the new grid is under MyFolder
|
||||
tree = datagrid_manager._tree
|
||||
tree = datagrids_manager._tree
|
||||
nodes = list(tree._state.items.values())
|
||||
sheet = [n for n in nodes if n.label == "Sheet1"][0]
|
||||
|
||||
assert sheet.parent == folder_id, "Sheet1 should be under MyFolder"
|
||||
|
||||
# Verify document definition
|
||||
doc = datagrid_manager._state.elements[0]
|
||||
doc = datagrids_manager._state.elements[0]
|
||||
assert doc.namespace == "MyFolder"
|
||||
assert doc.name == "Sheet1"
|
||||
|
||||
def test_i_can_create_new_grid_under_selected_leaf_parent(self, datagrid_manager):
|
||||
def test_i_can_create_new_grid_under_selected_leaf_parent(self, datagrids_manager):
|
||||
"""Test creating a new grid when a leaf node is selected.
|
||||
|
||||
Verifies that:
|
||||
@@ -94,62 +96,62 @@ class TestDataGridsManagerBehaviour:
|
||||
- Not under the leaf itself
|
||||
"""
|
||||
# Create a folder with a leaf
|
||||
folder_id = datagrid_manager._tree.ensure_path("MyFolder")
|
||||
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
|
||||
leaf = TreeNode(label="ExistingSheet", type="excel", parent=folder_id)
|
||||
datagrid_manager._tree.add_node(leaf, parent_id=folder_id)
|
||||
datagrids_manager._tree.add_node(leaf, parent_id=folder_id)
|
||||
|
||||
# Select the leaf
|
||||
datagrid_manager._tree._select_node(leaf.id)
|
||||
datagrids_manager._tree._select_node(leaf.id)
|
||||
|
||||
result = datagrid_manager.new_grid()
|
||||
result = datagrids_manager.handle_new_grid()
|
||||
|
||||
# Verify the new grid is under MyFolder (not under ExistingSheet)
|
||||
tree = datagrid_manager._tree
|
||||
tree = datagrids_manager._tree
|
||||
nodes = list(tree._state.items.values())
|
||||
new_sheet = [n for n in nodes if n.label == "Sheet1"][0]
|
||||
|
||||
assert new_sheet.parent == folder_id, "Sheet1 should be under MyFolder (leaf's parent)"
|
||||
assert new_sheet.parent != leaf.id, "Sheet1 should not be under the leaf"
|
||||
|
||||
def test_new_grid_generates_unique_sheet_names(self, datagrid_manager):
|
||||
def test_new_grid_generates_unique_sheet_names(self, datagrids_manager):
|
||||
"""Test that new_grid generates unique sequential sheet names.
|
||||
|
||||
Verifies Sheet1, Sheet2, Sheet3... generation.
|
||||
"""
|
||||
# Create first grid
|
||||
datagrid_manager.new_grid()
|
||||
assert datagrid_manager._state.elements[0].name == "Sheet1"
|
||||
datagrids_manager.handle_new_grid()
|
||||
assert datagrids_manager._state.elements[0].name == "Sheet1"
|
||||
|
||||
# Create second grid
|
||||
datagrid_manager.new_grid()
|
||||
assert datagrid_manager._state.elements[1].name == "Sheet2"
|
||||
datagrids_manager.handle_new_grid()
|
||||
assert datagrids_manager._state.elements[1].name == "Sheet2"
|
||||
|
||||
# Create third grid
|
||||
datagrid_manager.new_grid()
|
||||
assert datagrid_manager._state.elements[2].name == "Sheet3"
|
||||
datagrids_manager.handle_new_grid()
|
||||
assert datagrids_manager._state.elements[2].name == "Sheet3"
|
||||
|
||||
def test_new_grid_expands_parent_folder(self, datagrid_manager):
|
||||
def test_new_grid_expands_parent_folder(self, datagrids_manager):
|
||||
"""Test that creating a new grid automatically expands the parent folder.
|
||||
|
||||
Verifies parent is added to tree._state.opened.
|
||||
"""
|
||||
result = datagrid_manager.new_grid()
|
||||
result = datagrids_manager.handle_new_grid()
|
||||
|
||||
tree = datagrid_manager._tree
|
||||
tree = datagrids_manager._tree
|
||||
nodes = list(tree._state.items.values())
|
||||
untitled = [n for n in nodes if n.label == "Untitled"][0]
|
||||
|
||||
# Verify parent is expanded
|
||||
assert untitled.id in tree._state.opened, "Parent folder should be expanded"
|
||||
|
||||
def test_new_grid_selects_and_edits_new_node(self, datagrid_manager):
|
||||
def test_new_grid_selects_and_edits_new_node(self, datagrids_manager):
|
||||
"""Test that new grid node is both selected and in edit mode.
|
||||
|
||||
Verifies _state.selected and _state.editing are set to new node.
|
||||
"""
|
||||
result = datagrid_manager.new_grid()
|
||||
result = datagrids_manager.handle_new_grid()
|
||||
|
||||
tree = datagrid_manager._tree
|
||||
tree = datagrids_manager._tree
|
||||
nodes = list(tree._state.items.values())
|
||||
sheet = [n for n in nodes if n.label == "Sheet1"][0]
|
||||
|
||||
@@ -159,17 +161,17 @@ class TestDataGridsManagerBehaviour:
|
||||
# Verify edit mode
|
||||
assert tree._state.editing == sheet.id, "New node should be in edit mode"
|
||||
|
||||
def test_new_grid_creates_document_definition(self, datagrid_manager):
|
||||
def test_new_grid_creates_document_definition(self, datagrids_manager):
|
||||
"""Test that new_grid creates a DocumentDefinition with correct fields.
|
||||
|
||||
Verifies document_id, namespace, name, type, tab_id, datagrid_id.
|
||||
"""
|
||||
result = datagrid_manager.new_grid()
|
||||
result = datagrids_manager.handle_new_grid()
|
||||
|
||||
# Verify document was created
|
||||
assert len(datagrid_manager._state.elements) == 1, "Should have one document"
|
||||
assert len(datagrids_manager._state.elements) == 1, "Should have one document"
|
||||
|
||||
doc = datagrid_manager._state.elements[0]
|
||||
doc = datagrids_manager._state.elements[0]
|
||||
|
||||
# Verify all fields
|
||||
assert doc.document_id is not None, "Should have document_id"
|
||||
@@ -180,34 +182,34 @@ class TestDataGridsManagerBehaviour:
|
||||
assert doc.tab_id is not None, "Should have tab_id"
|
||||
assert doc.datagrid_id is not None, "Should have datagrid_id"
|
||||
|
||||
def test_new_grid_creates_datagrid_and_registers(self, datagrid_manager):
|
||||
def test_new_grid_creates_datagrid_and_registers(self, datagrids_manager):
|
||||
"""Test that new_grid creates a DataGrid and registers it.
|
||||
|
||||
Verifies DataGrid exists and is in registry with namespace.name.
|
||||
"""
|
||||
result = datagrid_manager.new_grid()
|
||||
result = datagrids_manager.handle_new_grid()
|
||||
|
||||
doc = datagrid_manager._state.elements[0]
|
||||
doc = datagrids_manager._state.elements[0]
|
||||
|
||||
# Verify DataGrid is registered
|
||||
entries = datagrid_manager._registry.get_all_entries()
|
||||
entries = datagrids_manager._registry.get_all_entries()
|
||||
assert doc.datagrid_id in entries, "DataGrid should be registered by grid_id"
|
||||
assert entries[doc.datagrid_id] == ("Untitled", "Sheet1"), "Registry entry should match namespace and name"
|
||||
|
||||
# Verify DataGrid exists in InstancesManager
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
datagrid = InstancesManager.get(datagrid_manager._session, doc.datagrid_id, None)
|
||||
datagrid = InstancesManager.get(datagrids_manager._session, doc.datagrid_id, None)
|
||||
assert datagrid is not None, "DataGrid instance should exist"
|
||||
|
||||
def test_new_grid_creates_tab_with_datagrid(self, datagrid_manager):
|
||||
def test_new_grid_creates_tab_with_datagrid(self, datagrids_manager):
|
||||
"""Test that new_grid creates a tab with correct label and content.
|
||||
|
||||
Verifies tab is created via TabsManager with DataGrid as content.
|
||||
"""
|
||||
result = datagrid_manager.new_grid()
|
||||
result = datagrids_manager.handle_new_grid()
|
||||
|
||||
doc = datagrid_manager._state.elements[0]
|
||||
tabs_manager = datagrid_manager._tabs_manager
|
||||
doc = datagrids_manager._state.elements[0]
|
||||
tabs_manager = datagrids_manager._tabs_manager
|
||||
|
||||
# Verify tab exists in TabsManager
|
||||
assert doc.tab_id in tabs_manager._state.tabs, "Tab should exist in TabsManager"
|
||||
@@ -216,47 +218,82 @@ class TestDataGridsManagerBehaviour:
|
||||
tab_metadata = tabs_manager._state.tabs[doc.tab_id]
|
||||
assert tab_metadata['label'] == "Sheet1", "Tab label should be Sheet1"
|
||||
|
||||
def test_generate_unique_sheet_name_with_no_children(self, datagrid_manager):
|
||||
def test_i_can_access_data_service_after_new_grid(self, datagrids_manager):
|
||||
"""Test that a DataService is accessible via datagrid_id after new_grid().
|
||||
|
||||
Why this matters:
|
||||
- DataService is now created first and its id becomes the datagrid_id
|
||||
- Verifies the DataService is properly registered in DataServicesManager
|
||||
- Ensures the link between datagrid_id and DataService is established
|
||||
"""
|
||||
datagrids_manager.handle_new_grid()
|
||||
|
||||
doc = datagrids_manager._state.elements[0]
|
||||
datagrid_id = doc.datagrid_id
|
||||
dataservice_id = DataGrid.get_data_service_id_from_data_grid_id(datagrid_id)
|
||||
|
||||
dataservice_manager = InstancesManager.get_by_type(datagrids_manager._session, DataServicesManager)
|
||||
service = dataservice_manager.get_service(dataservice_id)
|
||||
|
||||
assert service is not None, "DataService should be accessible via dataservice_id (taken from datagrid_id)"
|
||||
|
||||
def test_new_grid_data_service_has_correct_table_name(self, datagrids_manager):
|
||||
"""Test that the DataService receives the correct table_name after new_grid().
|
||||
|
||||
Why this matters:
|
||||
- table_name is now passed to create_service() before the DataGrid exists
|
||||
- Verifies the namespace.name format is correctly built and assigned
|
||||
"""
|
||||
datagrids_manager.handle_new_grid()
|
||||
|
||||
doc = datagrids_manager._state.elements[0]
|
||||
dataservice_manager = InstancesManager.get_by_type(datagrids_manager._session, DataServicesManager)
|
||||
dataservice_id = DataGrid.get_data_service_id_from_data_grid_id(doc.datagrid_id)
|
||||
data_service = dataservice_manager.get_service(dataservice_id)
|
||||
|
||||
assert data_service.table_name == "Untitled.Sheet1", "DataService table_name should be 'Untitled.Sheet1'"
|
||||
|
||||
def test_generate_unique_sheet_name_with_no_children(self, datagrids_manager):
|
||||
"""Test _generate_unique_sheet_name on an empty folder.
|
||||
|
||||
Verifies it returns "Sheet1" when no children exist.
|
||||
"""
|
||||
folder_id = datagrid_manager._tree.ensure_path("EmptyFolder")
|
||||
folder_id = datagrids_manager._tree.ensure_path("EmptyFolder")
|
||||
|
||||
name = datagrid_manager._generate_unique_sheet_name(folder_id)
|
||||
name = datagrids_manager._generate_unique_sheet_name(folder_id)
|
||||
|
||||
assert name == "Sheet1", "Should generate Sheet1 for empty folder"
|
||||
|
||||
def test_generate_unique_sheet_name_with_existing_sheets(self, datagrid_manager):
|
||||
def test_generate_unique_sheet_name_with_existing_sheets(self, datagrids_manager):
|
||||
"""Test _generate_unique_sheet_name with existing sheets.
|
||||
|
||||
Verifies it generates the next sequential number.
|
||||
"""
|
||||
folder_id = datagrid_manager._tree.ensure_path("MyFolder")
|
||||
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
|
||||
|
||||
# Add Sheet1 and Sheet2 manually
|
||||
sheet1 = TreeNode(label="Sheet1", type="excel", parent=folder_id)
|
||||
sheet2 = TreeNode(label="Sheet2", type="excel", parent=folder_id)
|
||||
datagrid_manager._tree.add_node(sheet1, parent_id=folder_id)
|
||||
datagrid_manager._tree.add_node(sheet2, parent_id=folder_id)
|
||||
datagrids_manager._tree.add_node(sheet1, parent_id=folder_id)
|
||||
datagrids_manager._tree.add_node(sheet2, parent_id=folder_id)
|
||||
|
||||
name = datagrid_manager._generate_unique_sheet_name(folder_id)
|
||||
name = datagrids_manager._generate_unique_sheet_name(folder_id)
|
||||
|
||||
assert name == "Sheet3", "Should generate Sheet3 when Sheet1 and Sheet2 exist"
|
||||
|
||||
def test_generate_unique_sheet_name_skips_gaps(self, datagrid_manager):
|
||||
def test_generate_unique_sheet_name_skips_gaps(self, datagrids_manager):
|
||||
"""Test _generate_unique_sheet_name fills gaps in sequence.
|
||||
|
||||
Verifies it generates Sheet2 when Sheet1 and Sheet3 exist (missing Sheet2).
|
||||
"""
|
||||
folder_id = datagrid_manager._tree.ensure_path("MyFolder")
|
||||
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
|
||||
|
||||
# Add Sheet1 and Sheet3 (skip Sheet2)
|
||||
sheet1 = TreeNode(label="Sheet1", type="excel", parent=folder_id)
|
||||
sheet3 = TreeNode(label="Sheet3", type="excel", parent=folder_id)
|
||||
datagrid_manager._tree.add_node(sheet1, parent_id=folder_id)
|
||||
datagrid_manager._tree.add_node(sheet3, parent_id=folder_id)
|
||||
datagrids_manager._tree.add_node(sheet1, parent_id=folder_id)
|
||||
datagrids_manager._tree.add_node(sheet3, parent_id=folder_id)
|
||||
|
||||
name = datagrid_manager._generate_unique_sheet_name(folder_id)
|
||||
name = datagrids_manager._generate_unique_sheet_name(folder_id)
|
||||
|
||||
assert name == "Sheet2", "Should generate Sheet2 to fill the gap"
|
||||
Reference in New Issue
Block a user