From d447220eae21c39f0a7a620f99e5772cbca98711 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 21 Feb 2026 18:31:11 +0100 Subject: [PATCH] Implemented Delete feature in DataGridsManager.py. There is still a bug as DBEngine.delete is not implemented Improved readability for tests using matcher --- src/myfasthtml/controls/DataGrid.py | 14 +- src/myfasthtml/controls/DataGridsManager.py | 57 +++++ src/myfasthtml/controls/IconsHelper.py | 9 +- src/myfasthtml/controls/TreeView.py | 5 +- src/myfasthtml/controls/helpers.py | 2 +- src/myfasthtml/core/DataGridsRegistry.py | 30 ++- src/myfasthtml/core/commands.py | 44 +++- src/myfasthtml/core/instances.py | 14 +- src/myfasthtml/test/matcher.py | 40 ++- tests/controls/test_treeview.py | 26 +- tests/core/test_commands.py | 261 +++++++++++++++++++- tests/testclient/test_matches.py | 7 + 12 files changed, 460 insertions(+), 49 deletions(-) diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index a3fa3be..65d02e6 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -1022,12 +1022,16 @@ class DataGrid(MultipleInstance): def delete(self): """ - remove DBEngine entries - :return: + Remove DBEngine entries for this DataGrid. + + Deletes all persistent state associated with this grid: + - State (columns, rows, selection, formatting, etc.) + - Settings (namespace, name, visibility options, etc.) + - DataFrame store (data and fast access structures) """ - # self._state.delete() - # self._settings.delete() - pass + self._state.delete() + self._settings.delete() + self._df_store.delete() def __ft__(self): return self.render() diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 998f439..fc5efa7 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -73,6 +73,13 @@ class Commands(BaseCommands): self._owner.select_document, key="SelectNode") + def delete_grid(self): + return Command("DeleteGrid", + "Delete grid", + self._owner, + self._owner.delete_grid, + key="DeleteNode") + class DataGridsManager(SingleInstance, DatagridMetadataProvider): @@ -85,6 +92,7 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider): self._state = DataGridsState(self) self._tree = self._mk_tree() self._tree.bind_command("SelectNode", self.commands.show_document()) + self._tree.bind_command("DeleteNode", self.commands.delete_grid(), when="before") self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None) self._registry = DataGridsRegistry(parent) @@ -240,6 +248,55 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider): except StopIteration: # the selected node is not a document (it's a folder) return None + + def delete_grid(self, node_id): + """ + Delete a grid and all its associated resources. + + This method is called BEFORE TreeView._delete_node() to ensure we can + access the node's bag to retrieve the document_id. + + Args: + node_id: ID of the TreeView node to delete + + Returns: + None (TreeView will handle the node removal) + """ + document_id = self._tree.get_bag(node_id) + if document_id is None: + # Node is a folder, not a document - nothing to clean up + return None + + try: + # Find the document + document = next(filter(lambda x: x.document_id == document_id, self._state.elements)) + + # Get the DataGrid instance + dg = DataGrid(self, _id=document.datagrid_id) + + # Close the tab + self._tabs_manager.close_tab(document.tab_id) + + # Remove from registry + self._registry.remove(document.datagrid_id) + + # Clean up DataGrid (delete DBEngine entries) + dg.delete() + + # Remove from InstancesManager + InstancesManager.remove(self._session, document.datagrid_id) + + # Remove DocumentDefinition from state + self._state.elements = [d for d in self._state.elements if d.document_id != document_id] + self._state.save() + + except StopIteration: + # Document not found - already deleted or invalid state + pass + + # Note: We do NOT call tree._delete_node() here because TreeView will do it + # automatically after this "before" bound command completes + return None def create_tab_content(self, tab_id): """ diff --git a/src/myfasthtml/controls/IconsHelper.py b/src/myfasthtml/controls/IconsHelper.py index efa3428..542df2f 100644 --- a/src/myfasthtml/controls/IconsHelper.py +++ b/src/myfasthtml/controls/IconsHelper.py @@ -2,18 +2,23 @@ from myfasthtml.core.constants import ColumnType from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \ number_row20_regular from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \ - checkbox_checked20_filled, math_formula16_regular, folder20_regular + checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular default_icons = { + # default type icons None: question20_regular, True: checkbox_checked20_regular, False: checkbox_unchecked20_regular, "Brain": brain_circuit20_regular, - "TreeViewFolder" : folder20_regular, + # TreeView icons + "TreeViewFolder": folder20_regular, + "TreeViewFile": document20_regular, + + # Datagrid column icons ColumnType.RowIndex: number_symbol20_regular, ColumnType.Text: text_field20_regular, ColumnType.Number: number_row20_regular, diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py index 60677f5..8e03024 100644 --- a/src/myfasthtml/controls/TreeView.py +++ b/src/myfasthtml/controls/TreeView.py @@ -192,7 +192,7 @@ class TreeView(MultipleInstance): if self.conf.icons: self._state.icon_config = self.conf.icons else: - self._state.icon_config = {"folder": "TreeViewFolder"} + self._state.icon_config = {"folder": "TreeViewFolder", "file": "TreeViewFile"} def set_icon_config(self, config: dict[str, str]): """ @@ -458,8 +458,9 @@ class TreeView(MultipleInstance): ), command=CommandTemplate("TreeView.SaveRename", self.commands.save_rename, args=[node_id])) else: label_element = mk.label( - Span(node.label, cls="mf-treenode-label text-sm"), + node.label, icon=icon, + cls="mf-treenode-label text-sm", enable_button=False, command=self.commands.select_node(node_id) ) diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index e1bc59c..90308a6 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -105,7 +105,7 @@ class mk: kwargs) icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None text_part = Span(text, cls=f"text-{size} truncate") - return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding) + return mk.mk(Span(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding) @staticmethod def convert_size(size: str): diff --git a/src/myfasthtml/core/DataGridsRegistry.py b/src/myfasthtml/core/DataGridsRegistry.py index ff43a09..5c199b3 100644 --- a/src/myfasthtml/core/DataGridsRegistry.py +++ b/src/myfasthtml/core/DataGridsRegistry.py @@ -15,7 +15,7 @@ class DataGridsRegistry(SingleInstance): def put(self, namespace, name, datagrid_id): """ - + :param namespace: :param name: :param datagrid_id: @@ -25,6 +25,18 @@ class DataGridsRegistry(SingleInstance): all_entries[datagrid_id] = (namespace, name) self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries) + def remove(self, datagrid_id): + """ + Remove a datagrid from the registry. + + Args: + datagrid_id: ID of the datagrid to remove + """ + all_entries = self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY) + if datagrid_id in all_entries: + del all_entries[datagrid_id] + self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries) + def get_all_tables(self): all_entries = self._get_all_entries() return [f"{namespace}.{name}" for (namespace, name) in all_entries.values()] @@ -45,28 +57,28 @@ class DataGridsRegistry(SingleInstance): try: as_fullname_dict = self._get_entries_as_full_name_dict() grid_id = as_fullname_dict[table_name] - + # load dataframe from dedicated store store = self._db_manager.load(f"{grid_id}#df") df = store["ne_df"] if store else None return df[column_name].tolist() if df is not None else [] - + except KeyError: return [] - + def get_row_count(self, table_name): try: as_fullname_dict = self._get_entries_as_full_name_dict() grid_id = as_fullname_dict[table_name] - + # load dataframe from dedicated store store = self._db_manager.load(f"{grid_id}#df") df = store["ne_df"] if store else None return len(df) if df is not None else 0 - + except KeyError: return 0 - + def get_column_type(self, table_name, column_name): """ Get the type of a column. @@ -81,11 +93,11 @@ class DataGridsRegistry(SingleInstance): try: as_fullname_dict = self._get_entries_as_full_name_dict() grid_id = as_fullname_dict[table_name] - + # load datagrid state state_id = f"{grid_id}#state" state = self._db_manager.load(state_id) - + if state and "columns" in state: for col in state["columns"]: if col.col_id == column_name: diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 756d2f7..3681b0a 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -3,7 +3,8 @@ import inspect import json import logging import uuid -from typing import Optional +from dataclasses import dataclass +from typing import Optional, Literal from myutils.observable import NotObservableError, ObservableResultCollector @@ -15,6 +16,19 @@ logger = logging.getLogger("Commands") AUTO_SWAP_OOB = "__auto_swap_oob__" +@dataclass +class BoundCommand: + """ + Represents a command bound to another command. + + Attributes: + command: The command to execute + when: When to execute the bound command ("before" or "after" the main command) + """ + command: 'Command' + when: Literal["before", "after"] = "after" + + class Command: """ Represents the base command class for defining executable actions. @@ -128,19 +142,31 @@ class Command: def execute(self, client_response: dict = None): logger.debug(f"Executing command {self.name} with arguments {client_response=}") with ObservableResultCollector(self._bindings) as collector: + # Execute "before" bound commands + ret_from_before_commands = [] + if self.owner: + before_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "before"] + for bound_cmd in before_commands: + logger.debug(f" will execute bound command {bound_cmd.command.name} BEFORE...") + r = bound_cmd.command.execute(client_response) + ret_from_before_commands.append(r) + + # Execute main callback kwargs = self._create_kwargs(self.default_kwargs, client_response, {"client_response": client_response or {}}) ret = self.callback(*self.default_args, **kwargs) - - ret_from_bound_commands = [] + + # Execute "after" bound commands + ret_from_after_commands = [] if self.owner: - for command in self.owner.get_bound_commands(self.name): - logger.debug(f" will execute bound command {command.name}...") - r = command.execute(client_response) - ret_from_bound_commands.append(r) # it will be flatten if needed later - - all_ret = flatten(ret, ret_from_bound_commands, collector.results) + after_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "after"] + for bound_cmd in after_commands: + logger.debug(f" will execute bound command {bound_cmd.command.name} AFTER...") + r = bound_cmd.command.execute(client_response) + ret_from_after_commands.append(r) + + all_ret = flatten(ret, ret_from_before_commands, ret_from_after_commands, collector.results) # Set the hx-swap-oob attribute on all elements returned by the callback if self._htmx_extra[AUTO_SWAP_OOB]: diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index 662a974..6804c86 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -3,6 +3,7 @@ import uuid from typing import Optional from myfasthtml.controls.helpers import Ids +from myfasthtml.core.commands import BoundCommand from myfasthtml.core.constants import NO_DEFAULT_VALUE from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal @@ -109,9 +110,18 @@ class BaseInstance: parent = self.get_parent() return parent.get_full_id() if parent else None - def bind_command(self, command, command_to_bind): + def bind_command(self, command, command_to_bind, when="after"): + """ + Bind a command to another command. + + Args: + command: Command name or Command instance to bind to + command_to_bind: Command to execute when the main command is triggered + when: "before" or "after" - when to execute the bound command (default: "after") + """ command_name = command.name if hasattr(command, "name") else command - self._bound_commands.setdefault(command_name, []).append(command_to_bind) + bound = BoundCommand(command=command_to_bind, when=when) + self._bound_commands.setdefault(command_name, []).append(bound) def get_bound_commands(self, command_name): return self._bound_commands.get(command_name, []) diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py index 24b84ba..2199966 100644 --- a/src/myfasthtml/test/matcher.py +++ b/src/myfasthtml/test/matcher.py @@ -1,9 +1,10 @@ import re from dataclasses import dataclass -from typing import Optional, Any +from typing import Optional, Any, Literal from fastcore.basics import NotStr from fastcore.xml import FT +from fasthtml.components import Span from myfasthtml.core.commands import Command from myfasthtml.core.utils import quoted_str, snake_to_pascal @@ -185,8 +186,27 @@ class TestObject: self.attrs = kwargs +class TestLabel(TestObject): + def __init__(self, label: str, icon: str = None, command=None): + super().__init__("span") + self.label = label + self.icon = snake_to_pascal(icon) if (icon and icon[0].islower()) else icon + + self.children = [] + if self.icon: + self.children.append(TestIcon(self.icon, wrapper="span")) + self.children.append(Span(label)) + + if command: + self.attrs |= command.get_htmx_params() + + def __str__(self): + icon_str = f"{icon_str}{self.label}' + + class TestIcon(TestObject): - def __init__(self, name: Optional[str] = '', wrapper="div", command=None): + def __init__(self, name: Optional[str] = '', wrapper: Literal["div", "span"] = "div", command=None): super().__init__(wrapper) self.wrapper = wrapper self.name = snake_to_pascal(name) if (name and name[0].islower()) else name @@ -239,18 +259,28 @@ class Skip: def _get_type(x): if hasattr(x, "tag"): return x.tag - if isinstance(x, (TestObject, TestIcon)): + if isinstance(x, TestObject): return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls) return type(x).__name__ def _get_attr(x, attr): + if isinstance(x, TestObject) and "s" in x.attrs and isinstance(x.attrs["s"], Regex): + return x.attrs["s"].value + " />" + if hasattr(x, "attrs"): return x.attrs.get(attr, MISSING_ATTR) if not hasattr(x, attr): return MISSING_ATTR + if isinstance(x, NotStr) and attr == "s": + # Special case for NotStr: return the name of the svg + svg = getattr(x, attr, MISSING_ATTR) + match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', svg) + if match: + return f'' + return getattr(x, attr, MISSING_ATTR) @@ -453,9 +483,9 @@ class ErrorComparisonOutput: expected = self.adjust(expected, actual) actual_max_length = len(max(actual, key=len)) - # expected_max_length = len(max(expected, key=len)) + expected_max_length = len(max(expected, key=len)) - output = [] + output = [f"{' Actual ':=^{actual_max_length}} | {' Expected ':=^{expected_max_length}}"] for a, e in zip(actual, expected): line = f"{a:<{actual_max_length}} | {e}".rstrip() output.append(line) diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py index c64c374..63d9a53 100644 --- a/tests/controls/test_treeview.py +++ b/tests/controls/test_treeview.py @@ -7,7 +7,7 @@ from fasthtml.components import * from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.TreeView import TreeView, TreeNode from myfasthtml.test.matcher import matches, TestObject, TestCommand, TestIcon, find_one, find, Contains, \ - DoesNotContain + DoesNotContain, TestLabel from .conftest import root_instance @@ -46,7 +46,7 @@ class TestTreeviewBehaviour: assert state.opened == [] assert state.selected is None assert state.editing is None - assert state.icon_config == {} + assert state.icon_config == {'file': 'TreeViewFile', 'folder': 'TreeViewFolder'} def test_i_can_create_empty_treeview(self, root_instance): """Test creating an empty TreeView.""" @@ -674,7 +674,7 @@ class TestTreeViewRender: Div( Div( TestIcon("chevron_right20_regular"), # Collapsed toggle icon - Span("Parent"), # Label + TestLabel("Parent", icon="folder20_regular"), Div( # Action buttons TestIcon("add_circle20_regular"), TestIcon("edit20_regular"), @@ -736,7 +736,7 @@ class TestTreeViewRender: expected_child_container = Div( Div( None, # No icon for leaf nodes - Span("Child1"), + TestLabel("Child1", icon="Document20Regular"), Div(), # action buttons cls=Contains("mf-treenode") ), @@ -764,7 +764,7 @@ class TestTreeViewRender: expected = Div( Div( None, # No icon for leaf nodes - Span("Leaf Node"), # Label + TestLabel("Leaf Node", icon="Document20Regular"), # Label Div(), # Action buttons still present ), cls=Contains("mf-treenode"), @@ -792,7 +792,7 @@ class TestTreeViewRender: expected = Div( Div( None, # No icon for leaf nodes - Span("Selected Node"), + TestLabel("Selected Node", icon="Document20Regular"), Div(), # Action buttons cls=Contains("mf-treenode", "selected") ), @@ -872,7 +872,7 @@ class TestTreeViewRender: root_expected = Div( Div( TestIcon("chevron_down20_regular"), # Expanded icon - Span("Root"), + TestLabel("Root", icon="Folder20Regular"), Div(), # Action buttons cls=Contains("mf-treenode"), style=Contains("padding-left: 0px") @@ -887,10 +887,10 @@ class TestTreeViewRender: child_expected = Div( Div( TestIcon("chevron_down20_regular"), # Expanded icon - Span("Child"), + TestLabel("Child", icon="Folder20Regular"), Div(), # Action buttons cls=Contains("mf-treenode"), - style=Contains("padding-left: 20px") + style=Contains("padding-left: 45px") ), cls="mf-treenode-container", data_node_id=child.id @@ -902,10 +902,10 @@ class TestTreeViewRender: grandchild_expected = Div( Div( None, # No icon for leaf nodes - Span("Grandchild"), + TestLabel("Grandchild", icon="Document20Regular"), Div(), # Action buttons cls=Contains("mf-treenode"), - style=Contains("padding-left: 40px") + style=Contains("padding-left: 90px") ), cls="mf-treenode-container", data_node_id=grandchild.id @@ -1071,7 +1071,7 @@ class TestTreeViewRender: expected_root1 = Div( Div( None, # No icon for leaf nodes - Span("Root 1"), + TestLabel("Root 1", icon="Folder20Regular"), Div(), # Action buttons cls=Contains("mf-treenode") ), @@ -1082,7 +1082,7 @@ class TestTreeViewRender: expected_root2 = Div( Div( None, # No icon for leaf nodes - Span("Root 2"), + TestLabel("Root 2", icon="Folder20Regular"), Div(), # Action buttons cls=Contains("mf-treenode") ), diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py index 2a9c719..395f47f 100644 --- a/tests/core/test_commands.py +++ b/tests/core/test_commands.py @@ -5,8 +5,9 @@ import pytest from fasthtml.components import Button, Div from myutils.observable import make_observable, bind -from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand +from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand, BoundCommand from myfasthtml.core.constants import ROUTE_ROOT, Routes +from myfasthtml.core.instances import BaseInstance from myfasthtml.test.matcher import matches @@ -24,6 +25,20 @@ def reset_command_manager(): CommandsManager.reset() +@pytest.fixture +def session(): + """Create a test session.""" + return {"user_info": {"id": "test-user-123"}} + + +@pytest.fixture +def owner(session): + """Create a BaseInstance owner for testing bind_command.""" + res = BaseInstance(parent=None, session=session, _id="test-owner") + res._bound_commands.clear() + return res + + class TestCommandDefault: def test_i_can_create_a_command_with_no_params(self): @@ -198,6 +213,187 @@ class TestCommandExecute: assert "hx-swap-oob" not in res[0].attrs assert "hx-swap-oob" not in res[1].attrs assert "hx-swap-oob" not in res[3].attrs + + @pytest.mark.parametrize("when", ["before", "after"]) + def test_i_can_execute_bound_command_with_when(self, owner, when): + """Test that bound commands execute before or after main callback based on when parameter.""" + execution_order = [] + + def main_callback(): + execution_order.append("main") + return Div(id="main") + + def bound_callback(): + execution_order.append(when) + return Div(id=when) + + main_command = Command('main', 'Main command', owner, main_callback) + bound_command = Command('bound', 'Bound command', owner, bound_callback) + + owner.bind_command(main_command, bound_command, when=when) + + res = main_command.execute() + + if when == "before": + assert execution_order == ["before", "main"] + else: + assert execution_order == ["main", "after"] + + assert isinstance(res, list) + + def test_i_can_execute_multiple_bound_commands_in_correct_order(self, owner): + """Test that multiple bound commands execute in correct order: before1, before2, main, after1, after2.""" + execution_order = [] + + def main_callback(): + execution_order.append("main") + return Div(id="main") + + def before1_callback(): + execution_order.append("before1") + return Div(id="before1") + + def before2_callback(): + execution_order.append("before2") + return Div(id="before2") + + def after1_callback(): + execution_order.append("after1") + return Div(id="after1") + + def after2_callback(): + execution_order.append("after2") + return Div(id="after2") + + main_command = Command('main', 'Main command', owner, main_callback) + before1_command = Command('before1', 'Before 1 command', owner, before1_callback) + before2_command = Command('before2', 'Before 2 command', owner, before2_callback) + after1_command = Command('after1', 'After 1 command', owner, after1_callback) + after2_command = Command('after2', 'After 2 command', owner, after2_callback) + + owner.bind_command(main_command, before1_command, when="before") + owner.bind_command(main_command, before2_command, when="before") + owner.bind_command(main_command, after1_command, when="after") + owner.bind_command(main_command, after2_command, when="after") + + res = main_command.execute() + + assert execution_order == ["before1", "before2", "main", "after1", "after2"] + assert isinstance(res, list) + + def test_main_callback_result_is_first_in_all_ret(self, owner): + """Test that main callback result is always all_ret[0] for HTMX target.""" + + def main_callback(): + return Div(id="main", cls="main-result") + + def before_callback(): + return Div(id="before") + + def after_callback(): + return Div(id="after") + + main_command = Command('main', 'Main command', owner, main_callback) + before_command = Command('before', 'Before command', owner, before_callback) + after_command = Command('after', 'After command', owner, after_callback) + + owner.bind_command(main_command, before_command, when="before") + owner.bind_command(main_command, after_command, when="after") + + res = main_command.execute() + + assert isinstance(res, list) + assert res[0].attrs["id"] == "main" + assert res[0].attrs.get("class") == "main-result" + + def test_hx_swap_oob_is_applied_to_bound_commands_results(self, owner): + """Test that hx-swap-oob is applied to bound commands results but not to main result.""" + + def main_callback(): + return Div(id="main") + + def before_callback(): + return Div(id="before") + + def after_callback(): + return Div(id="after") + + main_command = Command('main', 'Main command', owner, main_callback) + before_command = Command('before', 'Before command', owner, before_callback) + after_command = Command('after', 'After command', owner, after_callback) + + owner.bind_command(main_command, before_command, when="before") + owner.bind_command(main_command, after_command, when="after") + + res = main_command.execute() + + assert isinstance(res, list) + assert len(res) == 3 + # Main result should NOT have hx-swap-oob + assert "hx-swap-oob" not in res[0].attrs + # Bound commands results should have hx-swap-oob + assert res[1].attrs["hx-swap-oob"] == "true" + assert res[2].attrs["hx-swap-oob"] == "true" + + def test_bound_commands_without_return_do_not_affect_main_result(self, owner): + """Test that bound commands without return value do not affect main result.""" + + def main_callback(): + return Div(id="main") + + def before_callback(): + # No return value + pass + + def after_callback(): + # No return value + pass + + main_command = Command('main', 'Main command', owner, main_callback) + before_command = Command('before', 'Before command', owner, before_callback) + after_command = Command('after', 'After command', owner, after_callback) + + owner.bind_command(main_command, before_command, when="before") + owner.bind_command(main_command, after_command, when="after") + + res = main_command.execute() + + # When bound commands return None, they are still included in the result list + # but the main callback result should still be first + assert isinstance(res, list) + assert res[0].attrs["id"] == "main" + + def test_i_can_combine_bound_commands_with_observable_bindings(self, owner): + """Test that bound commands work correctly with observable bindings.""" + data = Data("initial") + execution_order = [] + + def on_data_change(old, new): + execution_order.append("observable") + return Div(id="observable", cls="data-changed") + + def main_callback(): + execution_order.append("main") + data.value = "modified" + return Div(id="main") + + def before_callback(): + execution_order.append("before") + return Div(id="before") + + make_observable(data) + bind(data, "value", on_data_change) + + main_command = Command('main', 'Main command', owner, main_callback).bind(data) + before_command = Command('before', 'Before command', owner, before_callback) + + owner.bind_command(main_command, before_command, when="before") + + res = main_command.execute() + + # Execution order: before -> main -> observable change + assert execution_order == ["before", "main", "observable"] + assert isinstance(res, list) class TestLambaCommand: @@ -209,3 +405,66 @@ class TestLambaCommand: def test_by_default_target_is_none(self): command = LambdaCommand(None, lambda: "Hello World") assert command.get_htmx_params()["hx-swap"] == "none" + + +class TestBoundCommand: + """Tests for BoundCommand dataclass.""" + + @pytest.mark.parametrize("when_param, expected_when", [ + (None, "after"), # default + ("before", "before"), # explicit before + ("after", "after"), # explicit after + ]) + def test_i_can_create_bound_command(self, when_param, expected_when): + """Test that BoundCommand can be created with different when values.""" + command = Command('test', 'Command description', None, callback) + + if when_param is None: + bound = BoundCommand(command=command) + else: + bound = BoundCommand(command=command, when=when_param) + + assert bound.command is command + assert bound.when == expected_when + + +class TestCommandBindCommand: + """Tests for binding commands to other commands with when parameter.""" + + @pytest.mark.parametrize("when_param, expected_when", [ + (None, "after"), # default + ("before", "before"), # explicit before + ("after", "after"), # explicit after + ]) + def test_i_can_bind_command_with_when(self, owner, when_param, expected_when): + """Test that a command can be bound to another command with when parameter.""" + main_command = Command('main', 'Main command', owner, callback) + bound_command = Command('bound', 'Bound command', owner, callback) + + if when_param is None: + owner.bind_command(main_command, bound_command) + else: + owner.bind_command(main_command, bound_command, when=when_param) + + bound_commands = owner.get_bound_commands('main') + + assert len(bound_commands) == 1 + assert bound_commands[0].command is bound_command + assert bound_commands[0].when == expected_when + + def test_i_can_bind_multiple_commands_with_different_when(self, owner): + """Test that multiple commands can be bound with different when values.""" + main_command = Command('main', 'Main command', owner, callback) + before_command = Command('before', 'Before command', owner, callback) + after_command = Command('after', 'After command', owner, callback) + + owner.bind_command(main_command, before_command, when="before") + owner.bind_command(main_command, after_command, when="after") + + bound_commands = owner.get_bound_commands('main') + + assert len(bound_commands) == 2 + assert bound_commands[0].command is before_command + assert bound_commands[0].when == "before" + assert bound_commands[1].command is after_command + assert bound_commands[1].when == "after" diff --git a/tests/testclient/test_matches.py b/tests/testclient/test_matches.py index 8ed7d6c..1bbc5dc 100644 --- a/tests/testclient/test_matches.py +++ b/tests/testclient/test_matches.py @@ -351,6 +351,7 @@ class TestErrorComparisonOutput: res = comparison_out.render() assert "\n" + res == ''' +===== Actual ====== | ==== Expected ===== (div "attr1"="value1" | (div "attr1"="value1" (p "id"="p_id") | (p "id"="p_id") ) | )''' @@ -366,6 +367,7 @@ class TestErrorComparisonOutput: res = comparison_out.render() assert "\n" + res == ''' +======= Actual ======== | ====== Expected ======= (div "id"="div_id" ... | (div "id"="div_id" ... (span "class"="cls" ... | (span "class"="cls" ... (div "attr1"="value1" | (div "attr1"="value1" @@ -383,6 +385,7 @@ class TestErrorComparisonOutput: res = comparison_out.render() assert "\n" + res == ''' +========= Actual ========= | ==== Expected ===== (div "attr2"="** MISSING **" | (div "attr2"="value1" ^^^^^^^^^^^^^^^^^^^^^^^ | (p "id"="p_id") | (p "id"="p_id") @@ -399,6 +402,7 @@ class TestErrorComparisonOutput: res = comparison_out.render() assert "\n" + res == ''' +===== Actual ====== | ==== Expected ===== (div "attr1"="value2" | (div "attr1"="value1" ^^^^^^^^^^^^^^^^ | (p "id"="p_id") | (p "id"="p_id") @@ -415,6 +419,7 @@ class TestErrorComparisonOutput: res = comparison_out.render() assert "\n" + res == ''' +===== Actual ====== | ==== Expected ===== (div "attr1"="value1" | (div "attr1"="value1" (p "id"="p_id") | (span "id"="s_id") ^ ^^^^^^^^^^^ | @@ -432,6 +437,7 @@ class TestErrorComparisonOutput: assert "\n" + debug_output == """ Path : 'div' Error : The condition 'Contains(value2)' is not satisfied. +====== Actual ====== | ========== Expected ========== (div "attr1"="value1") | (div "attr1"="Contains(value2)") ^^^^^^^^^^^^^^^^ |""" @@ -447,6 +453,7 @@ Error : The condition 'Contains(value2)' is not satisfied. res = comparison_out.render() assert "\n" + res == ''' +============= Actual ============= | ============= Expected ============= (div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2") ^^^ |'''