Implemented Delete feature in DataGridsManager.py. There is still a bug as DBEngine.delete is not implemented

Improved readability for tests using matcher
This commit is contained in:
2026-02-21 18:31:11 +01:00
parent 730f55d65b
commit d447220eae
12 changed files with 460 additions and 49 deletions

View File

@@ -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()

View File

@@ -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)
@@ -241,6 +249,55 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
# 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):
"""
Recreate the content for a tab managed by this DataGridsManager.

View File

@@ -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,

View File

@@ -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)
)

View File

@@ -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):

View File

@@ -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()]

View File

@@ -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
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_bound_commands, collector.results)
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]:

View File

@@ -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, [])

View File

@@ -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"<svg name='{self.icon}'" if self.icon else ""
return f'<span>{icon_str}<span>{self.label}</span></span>'
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'<svg name="{match.group(1)}" />'
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)

View File

@@ -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")
),

View File

@@ -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):
@@ -199,6 +214,187 @@ class TestCommandExecute:
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"

View File

@@ -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")
^^^ |'''