Improving functionalities and adding unit tests

This commit is contained in:
2025-07-25 09:50:36 +02:00
parent aa8aa8f58c
commit fb82365980
4 changed files with 151 additions and 34 deletions

View File

@@ -7,7 +7,7 @@ from components.BaseComponent import BaseComponentSingleton
from components.undo_redo.assets.icons import icon_redo, icon_undo from components.undo_redo.assets.icons import icon_redo, icon_undo
from components.undo_redo.commands import UndoRedoCommandManager from components.undo_redo.commands import UndoRedoCommandManager
from components.undo_redo.constants import UNDO_REDO_INSTANCE_ID from components.undo_redo.constants import UNDO_REDO_INSTANCE_ID
from components_helpers import mk_icon from components_helpers import mk_icon, mk_tooltip
logger = logging.getLogger("UndoRedoApp") logger = logging.getLogger("UndoRedoApp")
@@ -40,50 +40,67 @@ class UndoRedo(BaseComponentSingleton):
self.index += 1 self.index += 1
def undo(self): def undo(self):
logger.info(f"Undo command") logger.debug(f"Undo command")
return self if self.index == 0:
logger.debug(f" No command to undo.")
return self
self.index -= 1
command = self.history[self.index]
logger.debug(f" Undoing command {command.name} ({command.desc})")
res = command.undo()
return self, res
def redo(self): def redo(self):
logger.info("Redo command") logger.debug("Redo command")
if self.index > 0: if self.index >= len(self.history):
self.history.clear() logger.debug(f" No command to redo.")
self.index = 0 return self
else:
self.push("something") command = self.history[self.index]
return self logger.debug(f" Redoing command {command.name} ({command.desc})")
res = command.redo()
self.index += 1
return self, res
def __ft__(self): def __ft__(self, oob=False):
return Div( return Div(
self._mk_undo(), self._mk_undo(),
self._mk_redo(), self._mk_redo(),
id=self._id, id=self._id,
cls="flex" cls="flex",
hx_swap_oob="true" if oob else None
) )
def _mk_undo(self): def _mk_undo(self):
if self._can_undo(): if self._can_undo():
return mk_icon(icon_undo, command = self.history[self.index - 1]
size=24, return mk_tooltip(mk_icon(icon_undo,
**self._commands.undo()) size=24,
**self._commands.undo()),
"Undo")
else: else:
return mk_icon(icon_undo, return mk_tooltip(mk_icon(icon_undo,
size=24, size=24,
can_select=False, can_select=False,
cls="mmt-btn-disabled") cls="mmt-btn-disabled"),
"Nothing to undo")
def _mk_redo(self): def _mk_redo(self):
if self._can_redo(): if self._can_redo():
return mk_icon(icon_redo, return mk_tooltip(mk_icon(icon_redo,
size=24, size=24,
**self._commands.redo()) **self._commands.redo()),
"Redo")
else: else:
return mk_icon(icon_redo, return mk_tooltip(mk_icon(icon_redo,
size=24, size=24,
can_select=False, can_select=False,
cls="mmt-btn-disabled") cls="mmt-btn-disabled"),
"Nothing to redo")
def _can_undo(self): def _can_undo(self):
return self.index > 0 return self.index > 0
def _can_redo(self): def _can_redo(self):
return self.index < len(self.history) - 1 return self.index < len(self.history)

View File

@@ -43,6 +43,12 @@ class Contains:
""" """
s: str s: str
@dataclasses.dataclass
class DoesNotContain:
"""
To check if the attribute does not contain a specific value
"""
s: str
@dataclasses.dataclass @dataclasses.dataclass
class JsonViewerNode: class JsonViewerNode:
@@ -449,6 +455,11 @@ def matches(actual, expected, path=""):
elif isinstance(expected.attrs[expected_attr], Contains): elif isinstance(expected.attrs[expected_attr], Contains):
assert expected.attrs[expected_attr].s in actual.attrs[expected_attr], \ assert expected.attrs[expected_attr].s in actual.attrs[expected_attr], \
f"{print_path(path)}Attribute '{expected_attr}' does not contain '{expected.attrs[expected_attr].s}': actual='{actual.attrs[expected_attr]}', expected ='{expected.attrs[expected_attr].s}'." f"{print_path(path)}Attribute '{expected_attr}' does not contain '{expected.attrs[expected_attr].s}': actual='{actual.attrs[expected_attr]}', expected ='{expected.attrs[expected_attr].s}'."
elif isinstance(expected.attrs[expected_attr], DoesNotContain):
assert expected.attrs[expected_attr].s not in actual.attrs[expected_attr], \
f"{print_path(path)}Attribute '{expected_attr}' does contain '{expected.attrs[expected_attr].s}' while it must not: actual='{actual.attrs[expected_attr]}'."
else: else:
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \ assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
@@ -750,13 +761,14 @@ def icon(name: str):
return NotStr(f'<svg name="{name}"') return NotStr(f'<svg name="{name}"')
def div_icon(name: str): def div_icon(name: str, cls=None):
""" """
Test if an element is an icon wrapped in a div Test if an element is an icon wrapped in a div
:param name: :param name:
:param cls:
:return: :return:
""" """
return Div(NotStr(f'<svg name="{name}"')) return Div(NotStr(f'<svg name="{name}"'), cls=cls)
def span_icon(name: str): def span_icon(name: str):

View File

@@ -57,7 +57,8 @@ def sample_structure():
"Path 'div[class=a long attr]':\n\tAttribute 'class' does not start with 'different start': actual='a long attr', expected ='different start'."), "Path 'div[class=a long attr]':\n\tAttribute 'class' does not start with 'different start': actual='a long attr', expected ='different start'."),
(Div(cls="a long attr"), Div(cls=Contains("not included")), (Div(cls="a long attr"), Div(cls=Contains("not included")),
"Path 'div[class=a long attr]':\n\tAttribute 'class' does not contain 'not included': actual='a long attr', expected ='not included'."), "Path 'div[class=a long attr]':\n\tAttribute 'class' does not contain 'not included': actual='a long attr', expected ='not included'."),
(Div(cls="a long attr"), Div(cls=DoesNotContain("long attr")),
"Path 'div[class=a long attr]':\n\tAttribute 'class' does contain 'long attr' while it must not: actual='a long attr'."),
]) ])
def test_matches_error_expected(value, expected, expected_error): def test_matches_error_expected(value, expected, expected_error):
with pytest.raises(AssertionError) as error: with pytest.raises(AssertionError) as error:
@@ -75,6 +76,7 @@ def test_matches_error_expected(value, expected, expected_error):
(Div(), Div(Empty)), (Div(), Div(Empty)),
(Div(cls="a long attr"), Div(cls=StartsWith("a long"))), (Div(cls="a long attr"), Div(cls=StartsWith("a long"))),
(Div(cls="a long attr"), Div(cls=Contains("long"))), (Div(cls="a long attr"), Div(cls=Contains("long"))),
(Div(cls="a long attr"), Div(cls=DoesNotContain("xxxx"))),
]) ])
def test_matches_success_expected(value, expected): def test_matches_success_expected(value, expected):
assert matches(value, expected) assert matches(value, expected)

View File

@@ -1,7 +1,24 @@
from components.undo_redo.components.UndoRedo import UndoRedo import pytest
from core.settings_management import SettingsManager, MemoryDbEngine from fasthtml.components import Div
from components.undo_redo.components.UndoRedo import UndoRedo, CommandHistory
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, div_icon, Contains, DoesNotContain
from my_mocks import tabs_manager
class UndoableCommand(CommandHistory):
def __init__(self, old_value=0, new_value=0):
super().__init__("command", f"The new value is {new_value}")
self.old_value = old_value
self.new_value = new_value
def undo(self):
return Div(self.old_value, hx_swap_oob="true")
def redo(self):
return Div(self.new_value, hx_swap_oob="true")
TEST_UNDO_REDO_INSTANCE_ID = "test_undo_redo_instance_id"
@pytest.fixture @pytest.fixture
def undo_redo(session, tabs_manager): def undo_redo(session, tabs_manager):
@@ -11,5 +28,74 @@ def undo_redo(session, tabs_manager):
tabs_manager=tabs_manager) tabs_manager=tabs_manager)
def test_i_can_render(undo_redo):
actual = undo_redo.__ft__()
expected = Div(
Div(div_icon("undo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to undo"),
Div(div_icon("redo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to redo"),
id=undo_redo.get_id(),
)
assert matches(actual, expected)
def test_i_can_render_when_undoing_and_redoing(undo_redo):
undo_redo.push(UndoableCommand())
undo_redo.push(UndoableCommand())
actual = undo_redo.__ft__()
expected = Div(
Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo "),
Div(div_icon("redo", cls=Contains("mmt-btn-disabled")), data_tooltip=""),
id=undo_redo.get_id(),
)
assert matches(actual, expected)
undo_redo.undo() # The command is now undone. We can redo it and undo the first command.
actual = undo_redo.__ft__()
expected = Div(
div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")),
div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")),
id=undo_redo.get_id(),
)
assert matches(actual, expected)
undo_redo.undo() # Undo again, I cannot undo anymore.
actual = undo_redo.__ft__()
expected = Div(
div_icon("undo", cls=Contains("mmt-btn-disabled")),
div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")),
id=undo_redo.get_id(),
)
assert matches(actual, expected)
undo_redo.redo() # Redo once.
actual = undo_redo.__ft__()
expected = Div(
div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")),
div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")),
id=undo_redo.get_id(),
)
assert matches(actual, expected)
undo_redo.redo() # Redo a second time.
actual = undo_redo.__ft__()
expected = Div(
div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")),
div_icon("redo", cls=Contains("mmt-btn-disabled")),
id=undo_redo.get_id(),
)
assert matches(actual, expected)
def test_i_can_undo_and_redo(undo_redo): def test_i_can_undo_and_redo(undo_redo):
pass undo_redo.push(UndoableCommand(0, 1))
undo_redo.push(UndoableCommand(1, 2))
self, res = undo_redo.undo()
expected = Div(1, hx_swap_oob="true")
assert matches(res, expected)
self, res = undo_redo.redo()
expected = Div(2, hx_swap_oob="true")
assert matches(res, expected)