From 93cb477c213329b51bd70a3f7199726f2cfa383d Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 30 Nov 2025 22:48:11 +0100 Subject: [PATCH] Added unit tests for Layout.py --- .claude/commands/unit-tester.md | 324 ++++++++++++++++++++++---- docs/Layout.md | 51 ++++ src/app.py | 2 +- src/myfasthtml/controls/Layout.py | 51 ++-- src/myfasthtml/test/matcher.py | 169 +++++++++----- tests/controls/test_layout.py | 371 ++++++++++++++---------------- tests/testclient/test_finds.py | 59 ++++- tests/testclient/test_matches.py | 12 +- 8 files changed, 712 insertions(+), 327 deletions(-) create mode 100644 docs/Layout.md diff --git a/.claude/commands/unit-tester.md b/.claude/commands/unit-tester.md index 026846d..93a26ec 100644 --- a/.claude/commands/unit-tester.md +++ b/.claude/commands/unit-tester.md @@ -209,79 +209,301 @@ class TestControlRender: - When to use predicates (Contains, StartsWith, AnyValue, etc.) - How to test only what matters (not every detail) - How to read error messages with `^^^` markers -3. **Apply the best practices**: - - Test only important structural elements and attributes - - Use predicates for dynamic/generated values - - Don't over-specify tests with irrelevant details - - Structure tests in layers (overall structure, then details) +3. **Apply the best practices** detailed below + +--- + +#### **UTR-11.1 : Pattern de test en trois étapes (RÈGLE FONDAMENTALE)** + +**Principe :** C'est le pattern par défaut à appliquer pour tous les tests de rendu. Les autres règles sont des compléments à ce pattern. + +**Les trois étapes :** +1. **Extraire l'élément à tester** avec `find_one()` ou `find()` à partir du rendu global +2. **Définir la structure attendue** avec `expected = ...` +3. **Comparer** avec `assert matches(element, expected)` + +**Pourquoi :** Ce pattern permet des messages d'erreur clairs et sépare la recherche de l'élément de la validation de sa structure. + +**Exemple :** +```python +# ✅ BON - Pattern en trois étapes +def test_header_has_two_sides(self, layout): + """Test that there is a left and right header section.""" + # Étape 1 : Extraire l'élément à tester + header = find_one(layout.render(), Header(cls=Contains("mf-layout-header"))) + + # Étape 2 : Définir la structure attendue + expected = Header( + Div(id=f"{layout._id}_hl"), + Div(id=f"{layout._id}_hr"), + ) + + # Étape 3 : Comparer + assert matches(header, expected) + +# ❌ À ÉVITER - Tout imbriqué en une ligne +def test_header_has_two_sides(self, layout): + assert matches( + find_one(layout.render(), Header(cls=Contains("mf-layout-header"))), + Header(Div(id=f"{layout._id}_hl"), Div(id=f"{layout._id}_hr")) + ) +``` + +**Note :** Cette règle s'applique à presque tous les tests. Les autres règles ci-dessous complètent ce pattern fondamental. + +--- + +#### **COMMENT CHERCHER LES ÉLÉMENTS** + +--- + +#### **UTR-11.2 : Privilégier la recherche par ID** + +**Principe :** Toujours chercher un élément par son `id` quand il en a un, plutôt que par classe ou autre attribut. + +**Pourquoi :** Plus robuste, plus rapide, et ciblé (un ID est unique). + +**Exemple :** +```python +# ✅ BON - recherche par ID +drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) + +# ❌ À ÉVITER - recherche par classe quand un ID existe +drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer"))) +``` + +--- + +#### **UTR-11.3 : Utiliser `find_one()` vs `find()` selon le contexte** + +**Principe :** +- `find_one()` : Quand vous cherchez un élément unique et voulez tester sa structure complète +- `find()` : Quand vous cherchez plusieurs éléments ou voulez compter/vérifier leur présence + +**Exemples :** +```python +# ✅ BON - find_one pour structure unique +header = find_one(layout.render(), Header(cls=Contains("mf-layout-header"))) +expected = Header(...) +assert matches(header, expected) + +# ✅ BON - find pour compter +resizers = find(drawer, Div(cls=Contains("mf-resizer-left"))) +assert len(resizers) == 1, "Left drawer should contain exactly one resizer element" +``` + +--- + +#### **COMMENT SPÉCIFIER LA STRUCTURE ATTENDUE** + +--- + +#### **UTR-11.4 : Toujours utiliser `Contains()` pour les attributs `cls` et `style`** + +**Principe :** +- Pour `cls` : Les classes CSS peuvent être dans n'importe quel ordre. Testez uniquement les classes importantes avec `Contains()`. +- Pour `style` : Les propriétés CSS peuvent être dans n'importe quel ordre. Testez uniquement les propriétés importantes avec `Contains()`. + +**Pourquoi :** Évite les faux négatifs dus à l'ordre des classes/propriétés ou aux espaces. + +**Exemples :** +```python +# ✅ BON - Contains pour cls (une ou plusieurs classes) +expected = Div(cls=Contains("mf-layout-drawer")) +expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer")) + +# ✅ BON - Contains pour style +expected = Div(style=Contains("width: 250px")) + +# ❌ À ÉVITER - test exact des classes +expected = Div(cls="mf-layout-drawer mf-layout-left-drawer") + +# ❌ À ÉVITER - test exact du style complet +expected = Div(style="width: 250px; overflow: hidden; display: flex;") +``` + +--- + +#### **UTR-11.5 : Utiliser `TestIcon()` pour tester la présence d'une icône** + +**Principe :** Utilisez `TestIcon("icon_name")` pour tester la présence d'une icône SVG dans le rendu. + +**Le paramètre `name` :** +- **Nom exact** : Utilisez le nom exact de l'import (ex: `TestIcon("panel_right_expand20_regular")`) pour valider une icône spécifique +- **`name=""`** (chaîne vide) : Valide **n'importe quelle icône**. Le test sera passant dès que la structure affichant une icône sera trouvée, peu importe laquelle. +- **JAMAIS `name="svg"`** : Cela causera des échecs de test + +**Exemples :** +```python +from myfasthtml.icons.fluent import panel_right_expand20_regular + +# ✅ BON - Tester une icône spécifique +expected = Header( + Div( + TestIcon("panel_right_expand20_regular"), + cls=Contains("flex", "gap-1") + ) +) + +# ✅ BON - Tester la présence de n'importe quelle icône +expected = Div( + TestIcon(""), # Accepte n'importe quelle icône + cls=Contains("icon-wrapper") +) + +# ❌ À ÉVITER - name="svg" +expected = Div(TestIcon("svg")) # ERREUR : causera un échec +``` + +--- + +#### **UTR-11.6 : Utiliser `TestScript()` pour tester les scripts JavaScript** + +**Principe :** Utilisez `TestScript(code_fragment)` pour vérifier la présence de code JavaScript. Testez uniquement le fragment important, pas le script complet. + +**Exemple :** +```python +# ✅ BON - TestScript avec fragment important +script = find_one(layout.render(), Script()) +expected = TestScript(f"initResizer('{layout._id}');") +assert matches(script, expected) + +# ❌ À ÉVITER - tester tout le contenu du script +expected = Script("(function() { const id = '...'; initResizer(id); })()") +``` + +--- + +#### **COMMENT DOCUMENTER LES TESTS** + +--- + +#### **UTR-11.7 : Justifier le choix des éléments testés** + +**Principe :** Dans la section de documentation du test (après le docstring de description), expliquez **pourquoi chaque élément ou attribut testé a été choisi**. Qu'est-ce qui le rend important pour la fonctionnalité ? + +**Ce qui compte :** Pas la formulation exacte ("Why these elements matter" vs "Why this test matters"), mais **l'explication de la pertinence de ce qui est testé**. + +**Exemples :** +```python +def test_empty_layout_is_rendered(self, layout): + """Test that Layout renders with all main structural sections. + + Why these elements matter: + - 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script) + - _id: Essential for layout identification and resizer initialization + - cls="mf-layout": Root CSS class for layout styling + """ + expected = Div(...) + assert matches(layout.render(), expected) + +def test_left_drawer_is_rendered_when_open(self, layout): + """Test that left drawer renders with correct classes when open. + + Why these elements matter: + - _id: Required for targeting drawer in HTMX updates + - cls Contains "mf-layout-drawer": Base drawer class for styling + - cls Contains "mf-layout-left-drawer": Left-specific drawer positioning + - style Contains width: Drawer width must be applied for sizing + """ + layout._state.left_drawer_open = True + drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) + + expected = Div( + _id=f"{layout._id}_ld", + cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"), + style=Contains("width: 250px") + ) + + assert matches(drawer, expected) +``` + +**Points clés :** +- Expliquez pourquoi l'attribut/élément est important (fonctionnalité, HTMX, styling, etc.) +- Pas besoin de suivre une formulation rigide +- L'important est la **justification du choix**, pas le format + +--- + +#### **UTR-11.8 : Tests de comptage avec messages explicites** + +**Principe :** Quand vous comptez des éléments avec `assert len()`, ajoutez TOUJOURS un message explicite qui explique pourquoi ce nombre est attendu. + +**Exemple :** +```python +# ✅ BON - message explicatif +resizers = find(drawer, Div(cls=Contains("mf-resizer-left"))) +assert len(resizers) == 1, "Left drawer should contain exactly one resizer element" + +dividers = find(content, Div(cls="divider")) +assert len(dividers) >= 1, "Groups should be separated by dividers" + +# ❌ À ÉVITER - pas de message +assert len(resizers) == 1 +``` + +--- + +#### **AUTRES RÈGLES IMPORTANTES** + +--- **Mandatory render test rules:** 1. **Test naming**: Use descriptive names like `test_empty_layout_is_rendered()` not `test_layout_renders_with_all_sections()` + 2. **Documentation format**: Every render test MUST have a docstring with: - First line: Brief description of what is being tested - Blank line - - "Why these elements matter:" or "Why this test matters:" section + - Justification section explaining why tested elements matter (see UTR-11.7) - List of important elements/attributes being tested with explanations (in English) -3. **No inline comments**: Do NOT add comments on each line of the expected structure -4. **Icon testing**: Use `Div(NotStr(name="icon_name"))` to test SVG icons - - Use the exact icon name from the import (e.g., `name="panel_right_expand20_regular"`) - - Use `name=""` (empty string) if the import is not explicit - - NEVER use `name="svg"` - it will cause test failures -5. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components -6. **Explanation focus**: In "Why these elements matter", refer to the logical element (e.g., "Svg") not the technical implementation (e.g., "Div(NotStr(...))") -**Example of proper render test:** -```python -from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject -from fasthtml.common import Div, Header, Button +3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`) -class TestControlRender: - def test_empty_control_is_rendered(self, root_instance): - """Test that control renders with main structural sections. +4. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components - Why these elements matter: - - 3 children: Verifies header, body, and footer are all rendered - - _id: Essential for HTMX targeting and component identification - - cls="control-wrapper": Root CSS class for styling - """ - control = MyControl(root_instance) +5. **Test organization for Controls**: Organize tests into thematic classes: + - `TestControlBehaviour`: Tests for control behavior and logic + - `TestControlRender`: Tests for control HTML rendering - expected = Div( - Header(), - Div(), - Div(), - _id=control._id, - cls="control-wrapper" - ) +6. **Fixture usage**: In `TestControlRender`, use a pytest fixture to create the control instance: + ```python + class TestControlRender: + @pytest.fixture + def layout(self, root_instance): + return Layout(root_instance, app_name="Test App") - assert matches(control.render(), expected) + def test_something(self, layout): + # layout is injected automatically + ``` - def test_header_with_icon_is_rendered(self, root_instance): - """Test that header renders with action icon. +--- - Why these elements matter: - - Svg: Action icon is essential for user interaction - - TestObject(ActionButton): ActionButton component must be present - - cls="header-bar": Required CSS class for header styling - """ - control = MyControl(root_instance) - header = control._mk_header() +#### **Résumé : Les 8 règles UTR-11** - expected = Header( - Div(NotStr(name="action_icon_20_regular")), - TestObject(ActionButton), - cls="header-bar" - ) +**Pattern fondamental** +- **UTR-11.1** : Pattern en trois étapes (extraire → définir expected → comparer) - assert matches(header, expected) -``` +**Comment chercher** +- **UTR-11.2** : Privilégier recherche par ID +- **UTR-11.3** : `find_one()` vs `find()` selon contexte + +**Comment spécifier** +- **UTR-11.4** : Toujours `Contains()` pour `cls` et `style` +- **UTR-11.5** : `TestIcon()` pour tester la présence d'icônes +- **UTR-11.6** : `TestScript()` pour JavaScript + +**Comment documenter** +- **UTR-11.7** : Justifier le choix des éléments testés +- **UTR-11.8** : Messages explicites pour `assert len()` + +--- **When proposing render tests:** - Reference specific patterns from the documentation - Explain why you chose to test certain elements and not others - Justify the use of predicates vs exact values -- Always include "Why these elements matter" documentation +- Always include justification documentation (see UTR-11.7) ### UTR-12: Test Workflow diff --git a/docs/Layout.md b/docs/Layout.md new file mode 100644 index 0000000..11b3c9d --- /dev/null +++ b/docs/Layout.md @@ -0,0 +1,51 @@ +# Layout control + +## Overview + +This component renders the global layout of the application. +This is only one instance per session. + +## State + +| Name | Type | Description | Default | +|----------------------|---------|----------------------------------|---------| +| `left_drawer_open` | boolean | True if the left drawer is open | True | +| `right_drawer_open` | boolean | True if the right drawer is open | True | +| `left_drawer_width` | integer | Width of the left drawer | 250 | +| `right_drawer_width` | integer | Width of the right drawer | 250 | + +## Commands + +| Name | Description | +|-----------------------------------------|----------------------------------------------------------------------------------------| +| `toggle_drawer(side)` | Toggles the drawer on the specified side | +| `update_drawer_width(side, width=None)` | Updates the drawer width on the specified side. The width is given by the HTMX request | + +## Ids + +| Name | Description | +|-------------|-------------------| +| `layout` | Singleton | +| `layout_h` | header | +| `layout_hl` | header left side | +| `layout_hr` | header right side | +| `layout_f` | footer | +| `layout_fl` | footer left side | +| `layout_fr` | footer right side | +| `layout_ld` | left drawer | +| `layout_rd` | right drawer | + +## High Level Hierarchical Structure +``` +MyFastHtml +├── src +│ ├── myfasthtml/ # Main library code +│ │ ├── core/commands.py # Command definitions +│ │ ├── controls/button.py # Control helpers +│ │ └── pages/LoginPage.py # Predefined Login page +│ └── ... +├── tests # Unit and integration tests +├── LICENSE # License file (MIT) +├── README.md # Project documentation +└── pyproject.toml # Build configuration +``` \ No newline at end of file diff --git a/src/app.py b/src/app.py index d605152..dc02e4c 100644 --- a/src/app.py +++ b/src/app.py @@ -83,7 +83,7 @@ def create_sample_treeview(parent): def index(session): session_instance = UniqueInstance(session=session, _id=Ids.UserSession) layout = Layout(session_instance, "Testing Layout") - layout.set_footer("Goodbye World") + layout.footer_left.add("Goodbye World") tabs_manager = TabsManager(layout, _id=f"-tabs_manager") add_tab = tabs_manager.commands.add_tab diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py index 0c00d0a..fe7f2ed 100644 --- a/src/myfasthtml/controls/Layout.py +++ b/src/myfasthtml/controls/Layout.py @@ -37,12 +37,13 @@ class Commands(BaseCommands): def toggle_drawer(self, side: Literal["left", "right"]): return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side) - def update_drawer_width(self, side: Literal["left", "right"]): + def update_drawer_width(self, side: Literal["left", "right"], width: int = None): """ Create a command to update drawer width. Args: side: Which drawer to update ("left" or "right") + width: New width in pixels. Given by the HTMX request Returns: Command: Command object for updating drawer width @@ -123,16 +124,6 @@ class Layout(SingleInstance): self.header_right = self.Content(self) self.footer_left = self.Content(self) self.footer_right = self.Content(self) - self._footer_content = None - - def set_footer(self, content): - """ - Set the footer content. - - Args: - content: FastHTML component(s) or content for the footer - """ - self._footer_content = content def set_main(self, content): """ @@ -190,15 +181,17 @@ class Layout(SingleInstance): return Header( Div( # left self._mk_left_drawer_icon(), - *self.header_left.get_content(), - cls="flex gap-1" + *self._mk_content_wrapper(self.header_left, horizontal=True, show_group_name=False).children, + cls="flex gap-1", + id=f"{self._id}_hl" ), Div( # right - *self.header_right.get_content()[None], + *self._mk_content_wrapper(self.header_right, horizontal=True, show_group_name=False).children, UserProfile(self), - cls="flex gap-1" + cls="flex gap-1", + id=f"{self._id}_hr" ), - cls="mf-layout-header" + cls="mf-layout-header", ) def _mk_footer(self): @@ -208,9 +201,17 @@ class Layout(SingleInstance): Returns: Footer: FastHTML Footer component """ - footer_content = self._footer_content if self._footer_content else "" return Footer( - footer_content, + Div( # left + *self._mk_content_wrapper(self.footer_left, horizontal=True, show_group_name=False).children, + cls="flex gap-1", + id=f"{self._id}_fl" + ), + Div( # right + *self._mk_content_wrapper(self.footer_right, horizontal=True, show_group_name=False).children, + cls="flex gap-1", + id=f"{self._id}_fr" + ), cls="mf-layout-footer footer sm:footer-horizontal" ) @@ -299,6 +300,20 @@ class Layout(SingleInstance): id=f"{self._id}_rdi", command=self.commands.toggle_drawer("right")) + @staticmethod + def _mk_content_wrapper(content: Content, show_group_name: bool = True, horizontal: bool = False): + return Div( + *[ + ( + Div(cls=f"divider {'divider-horizontal' if horizontal else ''}") if index > 0 else None, + group_ft if show_group_name else None, + *[item for item in content.get_content()[group_name]] + ) + for index, (group_name, group_ft) in enumerate(content.get_groups()) + ], + cls="mf-layout-drawer-content" + ) + def render(self): """ Render the complete layout. diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py index e956944..20a28e9 100644 --- a/src/myfasthtml/test/matcher.py +++ b/src/myfasthtml/test/matcher.py @@ -1,9 +1,11 @@ import re from dataclasses import dataclass +from typing import Optional from fastcore.basics import NotStr +from fastcore.xml import FT -from myfasthtml.core.utils import quoted_str +from myfasthtml.core.utils import quoted_str, snake_to_pascal from myfasthtml.test.testclient import MyFT MISSING_ATTR = "** MISSING **" @@ -17,7 +19,18 @@ class Predicate: raise NotImplementedError def __str__(self): - return f"{self.__class__.__name__}({self.value if self.value is not None else ''})" + if self.value is None: + str_value = '' + elif isinstance(self.value, str): + str_value = self.value + elif isinstance(self.value, (list, tuple)): + if len(self.value) == 1: + str_value = self.value[0] + else: + str_value = str(self.value) + else: + str_value = str(self.value) + return f"{self.__class__.__name__}({str_value})" def __repr__(self): return f"{self.__class__.__name__}({self.value if self.value is not None else ''})" @@ -47,20 +60,28 @@ class StartsWith(AttrPredicate): return actual.startswith(self.value) -class Contains(AttrPredicate): +class EndsWith(AttrPredicate): def __init__(self, value): super().__init__(value) def validate(self, actual): - return self.value in actual + return actual.endswith(self.value) + + +class Contains(AttrPredicate): + def __init__(self, *value): + super().__init__(value) + + def validate(self, actual): + return all(val in actual for val in self.value) class DoesNotContain(AttrPredicate): - def __init__(self, value): + def __init__(self, *value): super().__init__(value) def validate(self, actual): - return self.value not in actual + return all(val not in actual for val in self.value) class AnyValue(AttrPredicate): @@ -75,6 +96,14 @@ class AnyValue(AttrPredicate): return actual is not None +class Regex(AttrPredicate): + def __init__(self, pattern): + super().__init__(pattern) + + def validate(self, actual): + return re.match(self.value, actual) is not None + + class ChildrenPredicate(Predicate): """ Predicate given as a child of an element. @@ -122,12 +151,33 @@ class TestObject: self.attrs = kwargs +class TestIcon(TestObject): + def __init__(self, name: Optional[str] = ''): + super().__init__("div") + self.name = snake_to_pascal(name) if (name and name[0].islower()) else name + self.children = [ + TestObject(NotStr, s=Regex(f'' + + class TestCommand(TestObject): def __init__(self, name, **kwargs): super().__init__("Command", **kwargs) self.attrs = {"name": name} | kwargs # name should be first +class TestScript(TestObject): + def __init__(self, script): + super().__init__("script") + self.script = script + self.children = [ + NotStr(self.script), + ] + + @dataclass class DoNotCheck: desc: str = None @@ -136,7 +186,7 @@ class DoNotCheck: def _get_type(x): if hasattr(x, "tag"): return x.tag - if isinstance(x, TestObject): + if isinstance(x, (TestObject, TestIcon)): return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls) return type(x).__name__ @@ -291,21 +341,21 @@ class ErrorOutput: element_type = _get_type(element) expected_type = _get_type(expected) type_error = (" " if element_type == expected_type else "^") * len(element_type) - + element_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in _get_attributes(expected)} expected_attrs = {attr_name: _get_attr(expected, attr_name) for attr_name in _get_attributes(expected)} attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if not self._matches(attr_value, expected_attrs[attr_name])} - + attrs_error = " ".join( len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ") for name, value in element_attrs.items() ) - + if type_error.strip() or attrs_error.strip(): return f" {type_error} {attrs_error}" return None - + # For simple values else: if not self._matches(element, expected): @@ -435,7 +485,7 @@ class Matcher: # Validate the type/tag assert _get_type(actual) == _get_type(expected), \ self._error_msg("The types are different.", _actual=_get_type(actual), _expected=_get_type(expected)) - + # Special conditions (ChildrenPredicate) expected_children = _get_children(expected) for predicate in [c for c in expected_children if isinstance(c, ChildrenPredicate)]: @@ -443,25 +493,25 @@ class Matcher: self._error_msg(f"The condition '{predicate}' is not satisfied.", _actual=actual, _expected=predicate.to_debug(expected)) - + # Compare the attributes expected_attrs = _get_attributes(expected) for expected_attr, expected_value in expected_attrs.items(): actual_value = _get_attr(actual, expected_attr) - + # Check if attribute exists if actual_value == MISSING_ATTR: - self._assert_error(f"'{expected_attr}' is not found in Actual.", + self._assert_error(f"'{expected_attr}' is not found in Actual. (attributes: {self._str_attrs(actual)})", _actual=actual, _expected=expected) - + # Handle Predicate values if isinstance(expected_value, Predicate): assert expected_value.validate(actual_value), \ self._error_msg(f"The condition '{expected_value}' is not satisfied.", _actual=actual, _expected=expected) - + # Handle TestObject recursive matching elif isinstance(expected, TestObject): try: @@ -476,23 +526,23 @@ class Matcher: self._assert_error(f"The values are different for '{expected_attr}'.", _actual=actual_value, _expected=expected_value) - + # Handle regular value comparison else: assert actual_value == expected_value, \ self._error_msg(f"The values are different for '{expected_attr}'.", _actual=actual, _expected=expected) - + # Compare the children (only if present) if expected_children: # Filter out Predicate children expected_children = [c for c in expected_children if not isinstance(c, Predicate)] actual_children = _get_children(actual) - + if len(actual_children) < len(expected_children): self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected) - + for actual_child, expected_child in zip(actual_children, expected_children): assert self.matches(actual_child, expected_child) @@ -517,7 +567,7 @@ class Matcher: def _match_notstr(self, actual, expected): """Match NotStr type.""" - to_compare = actual.s.lstrip('\n').lstrip() + to_compare = _get_attr(actual, "s").lstrip('\n').lstrip() assert to_compare.startswith(expected.s), self._error_msg("Notstr values are different: ", _actual=to_compare, _expected=expected.s) @@ -567,8 +617,9 @@ class Matcher: return elt.__class__.__name__ @staticmethod - def _str_attrs(attrs: dict): + def _str_attrs(element): """Format attributes as a string.""" + attrs = _get_attributes(element) return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items()) @staticmethod @@ -610,75 +661,83 @@ def find(ft, expected): Raises: AssertionError: If no matching elements are found """ - + def _elements_match(actual, expected): """Check if two elements are the same based on tag, attributes, and children.""" - # Quick equality check - if actual == expected: + if isinstance(expected, DoNotCheck): return True - - # Check if both are FT elements - if not (hasattr(actual, "tag") and hasattr(expected, "tag")): - return False - + + if isinstance(expected, NotStr): + to_compare = _get_attr(actual, "s").lstrip('\n').lstrip() + return to_compare.startswith(expected.s) + + if isinstance(actual, NotStr) and _get_type(actual) != _get_type(expected): + return False # to manage the unexpected __eq__ behavior of NotStr + + if not isinstance(expected, (TestObject, FT)): + return actual == expected + # Compare tags if _get_type(actual) != _get_type(expected): return False - + # Compare attributes expected_attrs = _get_attributes(expected) - actual_attrs = _get_attributes(actual) - - for attr_name, attr_value in expected_attrs.items(): - if attr_name not in actual_attrs or actual_attrs[attr_name] != attr_value: + for attr_name, expected_attr_value in expected_attrs.items(): + actual_attr_value = _get_attr(actual, attr_name) + # attribute is missing + if actual_attr_value == MISSING_ATTR: return False - + # manage predicate values + if isinstance(expected_attr_value, Predicate): + return expected_attr_value.validate(actual_attr_value) + # finally compare values + return actual_attr_value == expected_attr_value + # Compare children recursively expected_children = _get_children(expected) actual_children = _get_children(actual) - + for expected_child in expected_children: # Check if this expected child exists somewhere in actual children if not any(_elements_match(actual_child, expected_child) for actual_child in actual_children): return False - + return True - + def _search_tree(current, pattern): """Recursively search for pattern in the tree rooted at current.""" - # Type mismatch - can't be the same - if type(current) != type(pattern): - return [] - - # For non-FT elements, simple equality check - if not hasattr(current, "tag"): - return [current] if current == pattern else [] - # Check if current element matches matches = [] if _elements_match(current, pattern): matches.append(current) - - # Recursively search in children + + # Recursively search in children, in the case that the pattern also appears in children for child in _get_children(current): matches.extend(_search_tree(child, pattern)) - + return matches - + # Normalize input to list elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft] - + # Search in all provided elements all_matches = [] for element in elements_to_search: all_matches.extend(_search_tree(element, expected)) - + # Raise error if nothing found if not all_matches: raise AssertionError(f"No element found for '{expected}'") - + return all_matches +def find_one(ft, expected): + found = find(ft, expected) + assert len(found) == 1, f"Found {len(found)} elements for '{expected}'" + return found[0] + + def _str_attrs(attrs: dict): return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items()) diff --git a/tests/controls/test_layout.py b/tests/controls/test_layout.py index e359021..06aa84c 100644 --- a/tests/controls/test_layout.py +++ b/tests/controls/test_layout.py @@ -4,9 +4,8 @@ import shutil import pytest from fasthtml.components import * -from myfasthtml.controls.Layout import Layout, LayoutState -from myfasthtml.controls.UserProfile import UserProfile -from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject +from myfasthtml.controls.Layout import Layout +from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript from .conftest import root_instance @@ -17,219 +16,219 @@ def cleanup_db(): class TestLayoutBehaviour: """Tests for Layout behavior and logic.""" - + def test_i_can_create_layout(self, root_instance): """Test basic layout creation with app_name.""" layout = Layout(root_instance, app_name="Test App") - + assert layout is not None assert layout.app_name == "Test App" assert layout._state is not None - + def test_i_can_set_main_content(self, root_instance): """Test setting main content area.""" layout = Layout(root_instance, app_name="Test App") content = Div("Main content") - + result = layout.set_main(content) - + assert layout._main_content == content assert result == layout # Should return self for chaining - + def test_i_can_set_footer_content(self, root_instance): """Test setting footer content.""" layout = Layout(root_instance, app_name="Test App") content = Div("Footer content") - + layout.set_footer(content) - + assert layout._footer_content == content - + def test_i_can_add_content_to_left_drawer(self, root_instance): """Test adding content to left drawer.""" layout = Layout(root_instance, app_name="Test App") content = Div("Left drawer content", id="drawer_item") - + layout.left_drawer.add(content) - + drawer_content = layout.left_drawer.get_content() assert None in drawer_content assert content in drawer_content[None] - + def test_i_can_add_content_to_right_drawer(self, root_instance): """Test adding content to right drawer.""" layout = Layout(root_instance, app_name="Test App") content = Div("Right drawer content", id="drawer_item") - + layout.right_drawer.add(content) - + drawer_content = layout.right_drawer.get_content() assert None in drawer_content assert content in drawer_content[None] - + def test_i_can_add_content_to_header_left(self, root_instance): """Test adding content to left side of header.""" layout = Layout(root_instance, app_name="Test App") content = Div("Header left content", id="header_item") - + layout.header_left.add(content) - + header_content = layout.header_left.get_content() assert None in header_content assert content in header_content[None] - + def test_i_can_add_content_to_header_right(self, root_instance): """Test adding content to right side of header.""" layout = Layout(root_instance, app_name="Test App") content = Div("Header right content", id="header_item") - + layout.header_right.add(content) - + header_content = layout.header_right.get_content() assert None in header_content assert content in header_content[None] - + def test_i_can_add_grouped_content(self, root_instance): """Test adding content with custom groups.""" layout = Layout(root_instance, app_name="Test App") group_name = "navigation" content1 = Div("Nav item 1", id="nav1") content2 = Div("Nav item 2", id="nav2") - + layout.left_drawer.add(content1, group=group_name) layout.left_drawer.add(content2, group=group_name) - + drawer_content = layout.left_drawer.get_content() assert group_name in drawer_content assert content1 in drawer_content[group_name] assert content2 in drawer_content[group_name] - + def test_i_cannot_add_duplicate_content(self, root_instance): """Test that content with same ID is not added twice.""" layout = Layout(root_instance, app_name="Test App") content = Div("Content", id="unique_id") - + layout.left_drawer.add(content) layout.left_drawer.add(content) # Try to add again - + drawer_content = layout.left_drawer.get_content() # Content should appear only once assert drawer_content[None].count(content) == 1 - + def test_i_can_toggle_left_drawer(self, root_instance): """Test toggling left drawer open/closed.""" layout = Layout(root_instance, app_name="Test App") - + # Initially open assert layout._state.left_drawer_open is True - + # Toggle to close layout.toggle_drawer("left") assert layout._state.left_drawer_open is False - + # Toggle to open layout.toggle_drawer("left") assert layout._state.left_drawer_open is True - + def test_i_can_toggle_right_drawer(self, root_instance): """Test toggling right drawer open/closed.""" layout = Layout(root_instance, app_name="Test App") - + # Initially open assert layout._state.right_drawer_open is True - + # Toggle to close layout.toggle_drawer("right") assert layout._state.right_drawer_open is False - + # Toggle to open layout.toggle_drawer("right") assert layout._state.right_drawer_open is True - + def test_i_can_update_left_drawer_width(self, root_instance): """Test updating left drawer width.""" layout = Layout(root_instance, app_name="Test App") new_width = 300 - + layout.update_drawer_width("left", new_width) - + assert layout._state.left_drawer_width == new_width - + def test_i_can_update_right_drawer_width(self, root_instance): """Test updating right drawer width.""" layout = Layout(root_instance, app_name="Test App") new_width = 400 - + layout.update_drawer_width("right", new_width) - + assert layout._state.right_drawer_width == new_width - + def test_i_cannot_set_drawer_width_below_minimum(self, root_instance): """Test that drawer width is constrained to minimum 150px.""" layout = Layout(root_instance, app_name="Test App") - + layout.update_drawer_width("left", 100) # Try to set below minimum - + assert layout._state.left_drawer_width == 150 # Should be clamped to min - + def test_i_cannot_set_drawer_width_above_maximum(self, root_instance): """Test that drawer width is constrained to maximum 600px.""" layout = Layout(root_instance, app_name="Test App") - + layout.update_drawer_width("right", 800) # Try to set above maximum - + assert layout._state.right_drawer_width == 600 # Should be clamped to max - + def test_i_cannot_toggle_invalid_drawer_side(self, root_instance): """Test that toggling invalid drawer side raises ValueError.""" layout = Layout(root_instance, app_name="Test App") - + with pytest.raises(ValueError, match="Invalid drawer side"): layout.toggle_drawer("invalid") - + def test_i_cannot_update_invalid_drawer_width(self, root_instance): """Test that updating invalid drawer side raises ValueError.""" layout = Layout(root_instance, app_name="Test App") - + with pytest.raises(ValueError, match="Invalid drawer side"): layout.update_drawer_width("invalid", 250) - + def test_layout_state_has_correct_defaults(self, root_instance): """Test that LayoutState initializes with correct default values.""" layout = Layout(root_instance, app_name="Test App") state = layout._state - + assert state.left_drawer_open is True assert state.right_drawer_open is True assert state.left_drawer_width == 250 assert state.right_drawer_width == 250 - + def test_layout_is_single_instance(self, root_instance): """Test that Layout behaves as SingleInstance (same ID returns same instance).""" layout1 = Layout(root_instance, app_name="Test App", _id="my_layout") layout2 = Layout(root_instance, app_name="Test App", _id="my_layout") - + # Should be the same instance assert layout1 is layout2 - + def test_commands_are_created(self, root_instance): """Test that Layout creates necessary commands.""" layout = Layout(root_instance, app_name="Test App") - + # Test toggle commands left_toggle_cmd = layout.commands.toggle_drawer("left") assert left_toggle_cmd is not None assert left_toggle_cmd.id is not None - + right_toggle_cmd = layout.commands.toggle_drawer("right") assert right_toggle_cmd is not None assert right_toggle_cmd.id is not None - + # Test width update commands left_width_cmd = layout.commands.update_drawer_width("left") assert left_width_cmd is not None assert left_width_cmd.id is not None - + right_width_cmd = layout.commands.update_drawer_width("right") assert right_width_cmd is not None assert right_width_cmd.id is not None @@ -237,8 +236,12 @@ class TestLayoutBehaviour: class TestLayoutRender: """Tests for Layout HTML rendering.""" - - def test_empty_layout_is_rendered(self, root_instance): + + @pytest.fixture + def layout(self, root_instance): + return Layout(root_instance, app_name="Test App") + + def test_empty_layout_is_rendered(self, layout): """Test that Layout renders with all main structural sections. Why these elements matter: @@ -246,101 +249,97 @@ class TestLayoutRender: - _id: Essential for layout identification and resizer initialization - cls="mf-layout": Root CSS class for layout styling """ - layout = Layout(root_instance, app_name="Test App") - expected = Div( - Header(), - Div(), - Main(), - Div(), - Footer(), - Script(), - _id=layout._id, - cls="mf-layout" + Header(), + Div(), # left drawer + Main(), + Div(), # right drawer + Footer(), + Script(), + _id=layout._id, + cls="mf-layout" ) - + assert matches(layout.render(), expected) - - def test_header_with_drawer_icons_is_rendered(self, root_instance): + + def test_header_has_two_sides(self, layout): + """Test that there is a left and right header section.""" + header = find_one(layout.render(), Header(cls="mf-layout-header")) + + expected = Header( + Div(id=f"{layout._id}_hl"), + Div(id=f"{layout._id}_hr"), + ) + + assert matches(header, expected) + + def test_footer_has_two_sides(self, layout): + """Test that there is a left and right footer section.""" + footer = find_one(layout.render(), Footer(cls=Contains("mf-layout-footer"))) + + expected = Footer( + Div(id=f"{layout._id}_fl"), + Div(id=f"{layout._id}_fr"), + ) + + assert matches(footer, expected) + + def test_header_with_drawer_icons_is_rendered(self, layout): """Test that header renders with drawer toggle icons. Why these elements matter: - - 2 Div children: Left/right header structure for organizing controls - - Svg: Toggle icon is essential for user interaction with drawer - - TestObject(UserProfile): UserProfile component must be present in header - - cls="flex gap-1": CSS critical for horizontal alignment of header items - - cls="mf-layout-header": Root header class for styling + - Only the first div is required to test the presence of the icon + - Use TestIcon to test the existence of an icon """ - layout = Layout(root_instance, app_name="Test App") - header = layout._mk_header() - + header = find_one(layout.render(), Header(cls="mf-layout-header")) + expected = Header( - Div( - Div(NotStr(name="panel_right_expand20_regular")), - cls="flex gap-1" - ), - Div( - TestObject(UserProfile), - cls="flex gap-1" - ), - cls="mf-layout-header" + Div( + TestIcon("panel_right_expand20_regular"), + cls="flex gap-1" + ), + cls="mf-layout-header" ) - + assert matches(header, expected) - - def test_footer_is_rendered(self, root_instance): - """Test that footer renders with correct structure. - - Why these elements matter: - - cls Contains "mf-layout-footer": Root footer class for styling - - cls Contains "footer": DaisyUI base footer class - """ - layout = Layout(root_instance, app_name="Test App") - footer = layout._mk_footer() - - expected = Footer( - cls=Contains("mf-layout-footer") - ) - - assert matches(footer, expected) - - def test_main_content_is_rendered(self, root_instance): + + def test_main_content_is_rendered_with_some_element(self, layout): """Test that main content area renders correctly. Why these elements matter: - cls="mf-layout-main": Root main class for styling """ - layout = Layout(root_instance, app_name="Test App") - main = layout._mk_main() - + layout.set_main(Div("Main content")) + main = find_one(layout.render(), Main(cls="mf-layout-main")) + expected = Main( - cls="mf-layout-main" + Div("Main content"), + cls="mf-layout-main" ) - + assert matches(main, expected) - - def test_left_drawer_is_rendered_when_open(self, root_instance): + + def test_left_drawer_is_rendered_when_open(self, layout): """Test that left drawer renders with correct classes when open. Why these elements matter: - - _id: Required for targeting drawer in HTMX updates + - _id: Required for targeting drawer in HTMX updates. search by id whenever possible - cls Contains "mf-layout-drawer": Base drawer class for styling - cls Contains "mf-layout-left-drawer": Left-specific drawer positioning - style Contains width: Drawer width must be applied for sizing """ - layout = Layout(root_instance, app_name="Test App") - drawer = layout._mk_left_drawer() - + layout._state.left_drawer_open = True + drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) + expected = Div( - _id=f"{layout._id}_ld", - cls=Contains("mf-layout-drawer"), - #cls=Contains("mf-layout-left-drawer"), - style=Contains("width: 250px") + _id=f"{layout._id}_ld", + cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"), + style=Contains("width: 250px") ) - + assert matches(drawer, expected) - - def test_left_drawer_has_collapsed_class_when_closed(self, root_instance): + + def test_left_drawer_has_collapsed_class_when_closed(self, layout): """Test that left drawer renders with collapsed class when closed. Why these elements matter: @@ -348,19 +347,18 @@ class TestLayoutRender: - cls Contains "collapsed": Class triggers CSS hiding animation - style Contains "width: 0px": Zero width is crucial for collapse animation """ - layout = Layout(root_instance, app_name="Test App") layout._state.left_drawer_open = False - drawer = layout._mk_left_drawer() - + drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) + expected = Div( - _id=f"{layout._id}_ld", - cls=Contains("collapsed"), - style=Contains("width: 0px") + _id=f"{layout._id}_ld", + cls=Contains("collapsed"), + style=Contains("width: 0px") ) - + assert matches(drawer, expected) - - def test_right_drawer_is_rendered_when_open(self, root_instance): + + def test_right_drawer_is_rendered_when_open(self, layout): """Test that right drawer renders with correct classes when open. Why these elements matter: @@ -369,19 +367,18 @@ class TestLayoutRender: - cls Contains "mf-layout-right-drawer": Right-specific drawer positioning - style Contains width: Drawer width must be applied for sizing """ - layout = Layout(root_instance, app_name="Test App") - drawer = layout._mk_right_drawer() - + layout._state.right_drawer_open = True + drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd")) + expected = Div( - _id=f"{layout._id}_rd", - cls=Contains("mf-layout-drawer"), - #cls=Contains("mf-layout-right-drawer"), - style=Contains("width: 250px") + _id=f"{layout._id}_rd", + cls=Contains("mf-layout-drawer", "mf-layout-right-drawer"), + style=Contains("width: 250px") ) - + assert matches(drawer, expected) - - def test_right_drawer_has_collapsed_class_when_closed(self, root_instance): + + def test_right_drawer_has_collapsed_class_when_closed(self, layout): """Test that right drawer renders with collapsed class when closed. Why these elements matter: @@ -389,94 +386,82 @@ class TestLayoutRender: - cls Contains "collapsed": Class triggers CSS hiding animation - style Contains "width: 0px": Zero width is crucial for collapse animation """ - layout = Layout(root_instance, app_name="Test App") layout._state.right_drawer_open = False - drawer = layout._mk_right_drawer() - + drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd")) + expected = Div( - _id=f"{layout._id}_rd", - cls=Contains("collapsed"), - style=Contains("width: 0px") + _id=f"{layout._id}_rd", + cls=Contains("collapsed"), + style=Contains("width: 0px") ) - + assert matches(drawer, expected) - - def test_drawer_width_is_applied_as_style(self, root_instance): + + def test_drawer_width_is_applied_as_style(self, layout): """Test that custom drawer width is applied as inline style. Why this test matters: - style Contains "width: 300px": Verifies that width updates are reflected in style attribute """ - layout = Layout(root_instance, app_name="Test App") layout._state.left_drawer_width = 300 - drawer = layout._mk_left_drawer() - + drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) + expected = Div( - style=Contains("width: 300px") + style=Contains("width: 300px") ) - + assert matches(drawer, expected) - - def test_left_drawer_has_resizer_element(self, root_instance): + + def test_left_drawer_has_resizer_element(self, layout): """Test that left drawer contains resizer element. Why this test matters: - Resizer element must be present for drawer width adjustment - cls "mf-resizer-left": Left-specific resizer for correct edge positioning """ - layout = Layout(root_instance, app_name="Test App") - drawer = layout._mk_left_drawer() - + drawer = find(layout.render(), Div(id=f"{layout._id}_ld")) resizers = find(drawer, Div(cls=Contains("mf-resizer-left"))) assert len(resizers) == 1, "Left drawer should contain exactly one resizer element" - - def test_right_drawer_has_resizer_element(self, root_instance): + + def test_right_drawer_has_resizer_element(self, layout): """Test that right drawer contains resizer element. Why this test matters: - Resizer element must be present for drawer width adjustment - cls "mf-resizer-right": Right-specific resizer for correct edge positioning """ - layout = Layout(root_instance, app_name="Test App") - drawer = layout._mk_right_drawer() - + drawer = find(layout.render(), Div(id=f"{layout._id}_rd")) resizers = find(drawer, Div(cls=Contains("mf-resizer-right"))) assert len(resizers) == 1, "Right drawer should contain exactly one resizer element" - - def test_drawer_groups_are_separated_by_dividers(self, root_instance): + + def test_drawer_groups_are_separated_by_dividers(self, layout): """Test that multiple groups in drawer are separated by divider elements. Why this test matters: - Dividers provide visual separation between content groups - At least one divider should exist when multiple groups are present """ - layout = Layout(root_instance, app_name="Test App") - + layout.left_drawer.add(Div("Item 1"), group="group1") layout.left_drawer.add(Div("Item 2"), group="group2") - - drawer = layout._mk_left_drawer() - + + drawer = find(layout.render(), Div(id=f"{layout._id}_ld")) content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content")) assert len(content_wrappers) == 1 - + content = content_wrappers[0] - + dividers = find(content, Div(cls="divider")) assert len(dividers) >= 1, "Groups should be separated by dividers" - - def test_resizer_script_is_included(self, root_instance): + + def test_resizer_script_is_included(self, layout): """Test that resizer initialization script is included in render. Why this test matters: - Script element: Required to initialize resizer functionality - Script contains initResizer call: Ensures resizer is activated for this layout instance """ - layout = Layout(root_instance, app_name="Test App") - rendered = layout.render() - - scripts = find(rendered, Script()) - assert len(scripts) == 1, "Layout should contain exactly one script element" - - script_content = str(scripts[0].children[0]) - assert f"initResizer('{layout._id}')" in script_content, "Script must initialize resizer with layout ID" + script = find_one(layout.render(), Script()) + expected = TestScript(f"initResizer('{layout._id}');") + + assert matches(script, expected) diff --git a/tests/testclient/test_finds.py b/tests/testclient/test_finds.py index 66fc773..1ed97a3 100644 --- a/tests/testclient/test_finds.py +++ b/tests/testclient/test_finds.py @@ -1,7 +1,22 @@ import pytest -from fasthtml.components import Div, Span +from fastcore.basics import NotStr +from fasthtml.components import Div, Span, Main -from myfasthtml.test.matcher import find +from myfasthtml.test.matcher import find, TestObject, Contains, StartsWith + + +class Dummy: + def __init__(self, attr1, attr2=None): + self.attr1 = attr1 + self.attr2 = attr2 + + def __eq__(self, other): + return (isinstance(other, Dummy) + and self.attr1 == other.attr1 + and self.attr2 == other.attr2) + + def __hash__(self): + return hash((self.attr1, self.attr2)) @pytest.mark.parametrize('ft, expected', [ @@ -9,10 +24,13 @@ from myfasthtml.test.matcher import find (Div(id="id1"), Div(id="id1")), (Div(Span(id="span_id"), id="div_id1"), Div(Span(id="span_id"), id="div_id1")), (Div(id="id1", id2="id2"), Div(id="id1")), - (Div(Div(id="id2"), id2="id1"), Div(id="id1")), + (Div(Div(id="id2"), id="id1"), Div(id="id1")), + (Dummy(attr1="value"), Dummy(attr1="value")), + (Dummy(attr1="value"), TestObject(Dummy, attr1="value")), + (Div(attr="value1 value2"), Div(attr=Contains("value1"))), ]) def test_i_can_find(ft, expected): - assert find(expected, expected) == [expected] + assert find(ft, expected) == [ft] def test_find_element_by_id_in_a_list(): @@ -25,12 +43,41 @@ def test_find_element_by_id_in_a_list(): def test_i_can_find_sub_element(): a = Div(id="id1") - b = Div(a, id="id2") - c = Div(b, id="id3") + b = Span(a, id="id2") + c = Main(b, id="id3") assert find(c, a) == [a] +def test_i_can_find_when_pattern_appears_also_in_children(): + a1 = Div(id="id1") + b = Div(a1, id="id2") + a2 = Div(b, id="id1") + c = Main(a2, id="id3") + + assert find(c, a1) == [a2, a1] + + +@pytest.mark.parametrize('ft, to_search, expected', [ + (NotStr("hello"), NotStr("hello"), [NotStr("hello")]), + (NotStr("hello my friend"), NotStr("hello"), NotStr("hello my friend")), + (NotStr("hello"), TestObject(NotStr, s="hello"), [NotStr("hello")]), + (NotStr("hello my friend"), TestObject(NotStr, s=StartsWith("hello")), NotStr("hello my friend")), +]) +def test_i_can_manage_notstr_success_path(ft, to_search, expected): + assert find(ft, to_search) == expected + + +@pytest.mark.parametrize('ft, to_search', [ + (NotStr("my friend"), NotStr("hello")), + (NotStr("hello"), Dummy(attr1="hello")), # important, because of the internal __eq__ of NotStr + (NotStr("hello my friend"), TestObject(NotStr, s="hello")), +]) +def test_test_i_can_manage_notstr_failure_path(ft, to_search): + with pytest.raises(AssertionError): + find(ft, to_search) + + @pytest.mark.parametrize('ft, expected', [ (None, Div(id="id1")), (Span(id="id1"), Div(id="id1")), diff --git a/tests/testclient/test_matches.py b/tests/testclient/test_matches.py index c52edf5..65d207a 100644 --- a/tests/testclient/test_matches.py +++ b/tests/testclient/test_matches.py @@ -2,8 +2,10 @@ import pytest from fastcore.basics import NotStr from fasthtml.components import * -from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \ - ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject +from myfasthtml.controls.helpers import mk +from myfasthtml.icons.fluent_p3 import add20_regular +from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, ErrorOutput, \ + ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject, TestIcon, DoNotCheck from myfasthtml.test.testclient import MyFT @@ -50,6 +52,9 @@ class TestMatches: (Dummy(123, "value"), TestObject(Dummy, attr2="value")), (Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))), (Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2="value")), + (mk.icon(add20_regular), TestIcon("Add20Regular")), + (mk.icon(add20_regular), TestIcon("add20_regular")), + (mk.icon(add20_regular), TestIcon()), ]) def test_i_can_match(self, actual, expected): assert matches(actual, expected) @@ -93,7 +98,8 @@ class TestMatches: (Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"), (Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"), (Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different"), - (Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"), + (Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), + "The condition 'Contains(value2)' is not satisfied"), ]) def test_i_can_detect_errors(self, actual, expected, error_message):