3 Commits

Author SHA1 Message Date
96ed447eae Testing Layout 2025-11-29 23:47:11 +01:00
1be75263ad refactored matcher and wrote readme.txt 2025-11-29 22:53:39 +01:00
1d20fb8650 Added TreeView and Panel 2025-11-29 18:15:20 +01:00
23 changed files with 4054 additions and 512 deletions

View File

@@ -0,0 +1,242 @@
# Developer Mode
You are now in **Developer Mode** - the standard mode for writing code in the MyFastHtml project.
## Primary Objective
Write production-quality code by:
1. Exploring available options before implementation
2. Validating approach with user
3. Implementing only after approval
4. Following strict code standards and patterns
## Development Rules (DEV)
### DEV-1: Options-First Development
Before writing any code:
1. **Explain available options first** - Present different approaches to solve the problem
2. **Wait for validation** - Ensure mutual understanding of requirements before implementation
3. **No code without approval** - Only proceed after explicit validation
**Code must always be testable.**
### DEV-2: Question-Driven Collaboration
**Ask questions to clarify understanding or suggest alternative approaches:**
- Ask questions **one at a time**
- Wait for complete answer before asking the next question
- Indicate progress: "Question 1/5" if multiple questions are needed
- Never assume - always clarify ambiguities
### DEV-3: Communication Standards
**Conversations**: French or English (match user's language)
**Code, documentation, comments**: English only
### DEV-4: Code Standards
**Follow PEP 8** conventions strictly:
- Variable and function names: `snake_case`
- Explicit, descriptive naming
- **No emojis in code**
**Documentation**:
- Use Google or NumPy docstring format
- Document all public functions and classes
- Include type hints where applicable
### DEV-5: Dependency Management
**When introducing new dependencies:**
- List all external dependencies explicitly
- Propose alternatives using Python standard library when possible
- Explain why each dependency is needed
### DEV-6: Unit Testing with pytest
**Test naming patterns:**
- Passing tests: `test_i_can_xxx` - Tests that should succeed
- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
**Test structure:**
- Use **functions**, not classes (unless inheritance is required)
- Before writing tests, **list all planned tests with explanations**
- Wait for validation before implementing tests
**Example:**
```python
def test_i_can_create_command_with_valid_name():
"""Test that a command can be created with a valid name."""
cmd = Command("valid_name", "description", lambda: None)
assert cmd.name == "valid_name"
def test_i_cannot_create_command_with_empty_name():
"""Test that creating a command with empty name raises ValueError."""
with pytest.raises(ValueError):
Command("", "description", lambda: None)
```
### DEV-7: File Management
**Always specify the full file path** when adding or modifying files:
```
✅ Modifying: src/myfasthtml/core/commands.py
✅ Creating: tests/core/test_new_feature.py
```
### DEV-8: Command System - HTMX Target-Callback Alignment
**CRITICAL RULE:** When creating or modifying Commands, the callback's return value MUST match the HTMX configuration.
**Two-part requirement:**
1. The HTML structure returned by the callback must correspond to the `target` specified in `.htmx()`
2. Commands must be bound to FastHTML elements using `mk.mk()` or helper shortcuts
**Important: FastHTML Auto-Rendering**
- Just return self if you can the whole component to be re-rendered if the class has `__ft__()` method
- FastHTML automatically calls `__ft__()` which returns `render()` for you
**Binding Commands to Elements**
Use the `mk` helper from `myfasthtml.controls.helpers`:
```python
from myfasthtml.controls.helpers import mk
# Generic binding
mk.mk(element, cmd)
# Shortcut for buttons
mk.button("Label", command=cmd)
# Shortcut for icons
mk.icon(icon_svg, command=cmd)
# Shortcut for clickable labels
mk.label("Label", command=cmd)
# Shortcut for dialog buttons
mk.dialog_buttons([("OK", cmd_ok), ("Cancel", cmd_cancel)])
```
**Examples:**
**Correct - Component with __ft__(), returns self:**
```python
# In Commands class
def toggle_node(self, node_id: str):
return Command(
"ToggleNode",
f"Toggle node {node_id}",
self._owner._toggle_node, # Returns self (not self.render()!)
node_id
).htmx(target=f"#{self._owner.get_id()}")
# In TreeView class
def _toggle_node(self, node_id: str):
"""Toggle expand/collapse state of a node."""
if node_id in self._state.opened:
self._state.opened.remove(node_id)
else:
self._state.opened.append(node_id)
return self # FastHTML calls __ft__() automatically
def __ft__(self):
"""FastHTML magic method for rendering."""
return self.render()
# In render method - bind command to element
def _render_node(self, node_id: str, level: int = 0):
toggle = mk.mk(
Span("▼" if is_expanded else "▶", cls="mf-treenode-toggle"),
command=self.commands.toggle_node(node_id)
)
```
**Correct - Using shortcuts:**
```python
# Button with command
button = mk.button("Click me", command=self.commands.do_action())
# Icon with command
icon = mk.icon(icon_svg, size=20, command=self.commands.toggle())
# Clickable label with command
label = mk.label("Select", command=self.commands.select())
```
**Incorrect - Explicitly calling render():**
```python
def _toggle_node(self, node_id: str):
# ...
return self.render() # ❌ Don't do this if you have __ft__()!
```
**Incorrect - Not binding command to element:**
```python
# ❌ Command created but not bound to any element
toggle = Span("▼", cls="toggle") # No mk.mk()!
cmd = self.commands.toggle_node(node_id) # Command exists but not used
```
**Validation checklist:**
1. What HTML does the callback return (via `__ft__()` if present)?
2. What is the `target` ID in `.htmx()`?
3. Do they match?
4. Is the command bound to an element using `mk.mk()` or shortcuts?
**Common patterns:**
- **Full component re-render**: Callback returns `self` (with `__ft__()`), target is `#{self._id}`
- **Partial update**: Callback returns specific element, target is that element's ID
- **Multiple updates**: Use swap OOB with multiple elements returned
### DEV-9: Error Handling Protocol
**When errors occur:**
1. **Explain the problem clearly first**
2. **Do not propose a fix immediately**
3. **Wait for validation** that the diagnosis is correct
4. Only then propose solutions
## Managing Rules
To disable a specific rule, the user can say:
- "Disable DEV-8" (do not apply the HTMX alignment rule)
- "Enable DEV-8" (re-enable a previously disabled rule)
When a rule is disabled, acknowledge it and adapt behavior accordingly.
## Reference
For detailed architecture and patterns, refer to CLAUDE.md in the project root.
## Other Personas
- Use `/technical-writer` to switch to documentation mode
- Use `/unit-tester` to switch unit testing mode
- Use `/reset` to return to default Claude Code mode

13
.claude/commands/reset.md Normal file
View File

@@ -0,0 +1,13 @@
# Reset to Default Mode
You are now back to **default Claude Code mode**.
Follow the standard Claude Code guidelines without any specific persona or specialized behavior.
Refer to CLAUDE.md for project-specific architecture and patterns.
## Available Personas
You can switch to specialized modes:
- `/developer` - Full development mode with validation workflow
- `/technical-writer` - User documentation writing mode

View File

@@ -0,0 +1,64 @@
# Technical Writer Persona
You are now acting as a **Technical Writer** specialized in user-facing documentation.
## Your Role
Focus on creating and improving **user documentation** for the MyFastHtml library:
- README sections and examples
- Usage guides and tutorials
- Getting started documentation
- Code examples for end users
- API usage documentation (not API reference)
## What You Don't Handle
- Docstrings in code (handled by developers)
- Internal architecture documentation
- Code comments
- CLAUDE.md (handled by developers)
## Documentation Principles
**Clarity First:**
- Write for developers who are new to MyFastHtml
- Explain the "why" not just the "what"
- Use concrete, runnable examples
- Progressive complexity (simple → advanced)
**Structure:**
- Start with the problem being solved
- Show minimal working example
- Explain key concepts
- Provide variations and advanced usage
- Link to related documentation
**Examples Must:**
- Be complete and runnable
- Include necessary imports
- Show expected output when relevant
- Use realistic variable names
- Follow the project's code standards (PEP 8, snake_case, English)
## Communication Style
**Conversations:** French or English (match user's language)
**Written documentation:** English only
## Workflow
1. **Ask questions** to understand what needs documentation
2. **Propose structure** before writing content
3. **Wait for validation** before proceeding
4. **Write incrementally** - one section at a time
5. **Request feedback** after each section
## Style Evolution
The documentation style will improve iteratively based on feedback. Start with clear, simple writing and refine over time.
## Exiting This Persona
To return to normal mode:
- Use `/developer` to switch to developer mode
- Use `/reset` to return to default Claude Code mode

View File

@@ -0,0 +1,314 @@
# Unit Tester Mode
You are now in **Unit Tester Mode** - specialized mode for writing unit tests for existing code in the MyFastHtml project.
## Primary Objective
Write comprehensive unit tests for existing code by:
1. Analyzing the code to understand its behavior
2. Identifying test cases (success paths and edge cases)
3. Proposing test plan for validation
4. Implementing tests only after approval
## Unit Test Rules (UTR)
### UTR-1: Test Analysis Before Implementation
Before writing any tests:
1. **Check for existing tests first** - Look for corresponding test file (e.g., `src/foo/bar.py``tests/foo/test_bar.py`)
2. **Analyze the code thoroughly** - Read and understand the implementation
3. **If tests exist**: Identify what's already covered and what's missing
4. **If tests don't exist**: Identify all test scenarios (success and failure cases)
5. **Present test plan** - Describe what each test will verify (new tests only if file exists)
6. **Wait for validation** - Only proceed after explicit approval
### UTR-2: Test Naming Conventions
- **Passing tests**: `test_i_can_xxx` - Tests that should succeed
- **Failing tests**: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
**Example:**
```python
def test_i_can_create_command_with_valid_name():
"""Test that a command can be created with a valid name."""
cmd = Command("valid_name", "description", lambda: None)
assert cmd.name == "valid_name"
def test_i_cannot_create_command_with_empty_name():
"""Test that creating a command with empty name raises ValueError."""
with pytest.raises(ValueError):
Command("", "description", lambda: None)
```
### UTR-3: Use Functions, Not Classes (Default)
- Use **functions** for tests by default
- Only use classes when inheritance or grouping is required (see UTR-10)
- Before writing tests, **list all planned tests with explanations**
- Wait for validation before implementing tests
### UTR-4: Do NOT Test Python Built-ins
**Do NOT test Python's built-in functionality.**
**Bad example - Testing Python list behavior:**
```python
def test_i_can_add_child_to_node(self):
"""Test that we can add a child ID to the children list."""
parent_node = TreeNode(label="Parent", type="folder")
child_id = "child_123"
parent_node.children.append(child_id) # Just testing list.append()
assert child_id in parent_node.children # Just testing list membership
```
This test validates that Python's `list.append()` works correctly, which is not our responsibility.
**Good example - Testing business logic:**
```python
def test_i_can_add_child_node(self, root_instance):
"""Test adding a child node to a parent."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id) # Testing OUR method
assert child.id in tree_view._state.items # Verify state updated
assert child.id in parent.children # Verify relationship established
assert child.parent == parent.id # Verify bidirectional link
```
This test validates the `add_node()` method's logic: state management, relationship creation, bidirectional linking.
**Other examples of what NOT to test:**
- Setting/getting attributes: `obj.value = 5; assert obj.value == 5`
- Dictionary operations: `d["key"] = "value"; assert "key" in d`
- String concatenation: `result = "hello" + "world"; assert result == "helloworld"`
- Type checking: `assert isinstance(obj, MyClass)` (unless type validation is part of your logic)
### UTR-5: Test Business Logic Only
**What TO test:**
- Your business logic and algorithms
- Your validation rules
- Your state transformations
- Your integration between components
- Your error handling for invalid inputs
- Your side effects (database updates, command registration, etc.)
### UTR-6: Test Coverage Requirements
For each code element, consider testing:
**Functions/Methods:**
- Valid inputs (typical use cases)
- Edge cases (empty values, None, boundaries)
- Error conditions (invalid inputs, exceptions)
- Return values and side effects
**Classes:**
- Initialization (default values, custom values)
- State management (attributes, properties)
- Methods (all public methods)
- Integration (interactions with other classes)
**Components (Controls):**
- Creation and initialization
- State changes
- Commands and their effects
- Rendering (if applicable)
- Edge cases and error conditions
### UTR-7: Ask Questions One at a Time
**Ask questions to clarify understanding:**
- Ask questions **one at a time**
- Wait for complete answer before asking the next question
- Indicate progress: "Question 1/5" if multiple questions are needed
- Never assume behavior - always verify understanding
### UTR-8: Communication Language
**Conversations**: French or English (match user's language)
**Code, documentation, comments**: English only
### UTR-9: Code Standards
**Follow PEP 8** conventions strictly:
- Variable and function names: `snake_case`
- Explicit, descriptive naming
- **No emojis in code**
**Documentation**:
- Use Google or NumPy docstring format
- Every test should have a clear docstring explaining what it verifies
- Include type hints where applicable
### UTR-10: Test File Organization
**File paths:**
- Always specify the full file path when creating test files
- Mirror source structure: `src/myfasthtml/core/commands.py``tests/core/test_commands.py`
**Example:**
```
✅ Creating: tests/core/test_new_feature.py
✅ Modifying: tests/controls/test_treeview.py
```
**Test organization for Controls:**
Controls are classes with `__ft__()` and `render()` methods. For these components, organize tests into thematic classes:
```python
class TestControlBehaviour:
"""Tests for control behavior and logic."""
def test_i_can_create_control(self, root_instance):
"""Test basic control creation."""
control = MyControl(root_instance)
assert control is not None
def test_i_can_update_state(self, root_instance):
"""Test state management."""
# Test state changes, data updates, etc.
pass
class TestControlRender:
"""Tests for control HTML rendering."""
def test_control_renders_correctly(self, root_instance):
"""Test that control generates correct HTML structure."""
# Test HTML output, attributes, classes, etc.
pass
def test_control_renders_with_custom_config(self, root_instance):
"""Test rendering with custom configuration."""
# Test different rendering scenarios
pass
```
**Why separate behaviour and render tests:**
- **Behaviour tests**: Focus on logic, state management, commands, and interactions
- **Render tests**: Focus on HTML structure, attributes, and visual representation
- **Clarity**: Makes it clear what aspect of the control is being tested
- **Maintenance**: Easier to locate and update tests when behaviour or rendering changes
**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed.
### UTR-11: Required Reading for Control Render Tests
**Before writing ANY render tests for Controls, you MUST:**
1. **Read the matcher documentation**: `docs/testing_rendered_components.md`
2. **Understand the key concepts**:
- How `matches()` and `find()` work
- 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)
**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
- 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
class TestControlRender:
def test_empty_control_is_rendered(self, root_instance):
"""Test that control renders with main structural sections.
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)
expected = Div(
Header(),
Div(),
Div(),
_id=control._id,
cls="control-wrapper"
)
assert matches(control.render(), expected)
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()
expected = Header(
Div(NotStr(name="action_icon_20_regular")),
TestObject(ActionButton),
cls="header-bar"
)
assert matches(header, expected)
```
**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
### UTR-12: Test Workflow
1. **Receive code to test** - User provides file path or code section
2. **Check existing tests** - Look for corresponding test file and read it if it exists
3. **Analyze code** - Read and understand implementation
4. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
5. **Propose test plan** - List new/missing tests with brief explanations
6. **Wait for approval** - User validates the test plan
7. **Implement tests** - Write all approved tests
8. **Verify** - Ensure tests follow naming conventions and structure
9. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
## Managing Rules
To disable a specific rule, the user can say:
- "Disable UTR-4" (do not apply the rule about testing Python built-ins)
- "Enable UTR-4" (re-enable a previously disabled rule)
When a rule is disabled, acknowledge it and adapt behavior accordingly.
## Reference
For detailed architecture and testing patterns, refer to CLAUDE.md in the project root.
## Other Personas
- Use `/developer` to switch to development mode
- Use `/technical-writer` to switch to documentation mode
- Use `/reset` to return to default Claude Code mode

View File

@@ -95,6 +95,36 @@ def test_i_cannot_create_command_with_empty_name():
3. **Wait for validation** that the diagnosis is correct 3. **Wait for validation** that the diagnosis is correct
4. Only then propose solutions 4. Only then propose solutions
## Available Personas
This project includes specialized personas (slash commands) for different types of work:
### `/developer` - Development Mode (Default)
**Use for:** Writing code, implementing features, fixing bugs
Activates the full development workflow with:
- Options-first approach before coding
- Step-by-step validation
- Strict PEP 8 compliance
- Test-driven development with `test_i_can_xxx` / `test_i_cannot_xxx` patterns
### `/technical-writer` - Documentation Mode
**Use for:** Writing user-facing documentation
Focused on creating:
- README sections and examples
- Usage guides and tutorials
- Getting started documentation
- Code examples for end users
**Does NOT handle:**
- Docstrings (developer responsibility)
- Internal architecture docs
- CLAUDE.md updates
### `/reset` - Default Claude Code
**Use for:** Return to standard Claude Code behavior without personas
## Development Commands ## Development Commands
### Testing ### Testing

View File

@@ -18,6 +18,7 @@ clean-tests:
rm -rf .sesskey rm -rf .sesskey
rm -rf tests/.sesskey rm -rf tests/.sesskey
rm -rf tests/*.db rm -rf tests/*.db
rm -rf tests/.myFastHtmlDb
# Alias to clean everything # Alias to clean everything
clean: clean-build clean-tests clean: clean-build clean-tests

View File

@@ -0,0 +1,895 @@
# Testing Rendered Components with Matcher
## Introduction
When testing FastHTML components, you need to verify that the HTML they generate is correct. Traditional approaches like string comparison are fragile and hard to maintain. The matcher module provides two powerful functions that make component testing simple and reliable:
- **`matches(actual, expected)`** - Validates that a rendered element matches your expectations
- **`find(ft, expected)`** - Searches for specific elements within an HTML tree
**Key principle**: Test only what matters. The matcher compares only the elements and attributes you explicitly define in your `expected` pattern, ignoring everything else.
### Why use matcher?
**Without matcher:**
```python
# Fragile - breaks if whitespace or attribute order changes
assert str(component.render()) == '<div id="x" class="y"><p>Text</p></div>'
```
**With matcher:**
```python
# Robust - tests only what matters
from myfasthtml.test.matcher import matches
from fasthtml.common import Div, P
actual = component.render()
expected = Div(P("Text"))
matches(actual, expected) # Passes - ignores id and class
```
---
## Part 1: Function Reference
### matches() - Validate Elements
#### Purpose
`matches()` validates that a rendered element structure corresponds exactly to an expected pattern. It's the primary tool for testing component rendering.
**When to use it:**
- Verifying component output in tests
- Checking HTML structure
- Validating attributes and content
#### Basic Syntax
```python
from myfasthtml.test.matcher import matches
matches(actual, expected)
```
- **`actual`**: The element to test (usually from `component.render()`)
- **`expected`**: The pattern to match against (only include what you want to test)
- **Returns**: `True` if matches, raises `AssertionError` if not
#### Simple Examples
**Example 1: Basic structure matching**
```python
from myfasthtml.test.matcher import matches
from fasthtml.common import Div, P
# The actual rendered element
actual = Div(P("Hello World"), id="container", cls="main")
# Expected pattern - tests only the structure
expected = Div(P("Hello World"))
matches(actual, expected) # ✅ Passes - id and cls are ignored
```
**Example 2: Testing specific attributes**
```python
from fasthtml.common import Button
actual = Button("Click me",
id="btn-1",
cls="btn btn-primary",
hx_post="/submit",
hx_target="#result")
# Test only the HTMX attribute we care about
expected = Button("Click me", hx_post="/submit")
matches(actual, expected) # ✅ Passes
```
**Example 3: Nested structure**
```python
from fasthtml.common import Div, H1, Form, Input, Button
actual = Div(
H1("Registration Form"),
Form(
Input(name="email", type="email"),
Input(name="password", type="password"),
Button("Submit", type="submit")
),
id="page",
cls="container"
)
# Test only the important parts
expected = Div(
H1("Registration Form"),
Form(
Input(name="email"),
Button("Submit")
)
)
matches(actual, expected) # ✅ Passes - ignores password field and attributes
```
#### Predicates Reference
Predicates allow flexible validation when you don't know the exact value but want to validate a pattern.
##### AttrPredicate - For attribute values
**Contains(value)** - Attribute contains the value
```python
from myfasthtml.test.matcher import Contains
from fasthtml.common import Div
actual = Div(cls="container main-content active")
expected = Div(cls=Contains("main-content"))
matches(actual, expected) # ✅ Passes
```
**StartsWith(value)** - Attribute starts with the value
```python
from myfasthtml.test.matcher import StartsWith
from fasthtml.common import Input
actual = Input(id="input-username-12345")
expected = Input(id=StartsWith("input-username"))
matches(actual, expected) # ✅ Passes
```
**DoesNotContain(value)** - Attribute does not contain the value
```python
from myfasthtml.test.matcher import DoesNotContain
from fasthtml.common import Div
actual = Div(cls="container active")
expected = Div(cls=DoesNotContain("disabled"))
matches(actual, expected) # ✅ Passes
```
**AnyValue()** - Attribute exists with any non-None value
```python
from myfasthtml.test.matcher import AnyValue
from fasthtml.common import Button
actual = Button("Click", data_action="submit-form", data_id="123")
expected = Button("Click", data_action=AnyValue())
matches(actual, expected) # ✅ Passes - just checks data_action exists
```
##### ChildrenPredicate - For element children
**Empty()** - Element has no children and no attributes
```python
from myfasthtml.test.matcher import Empty
from fasthtml.common import Div
actual = Div()
expected = Div(Empty())
matches(actual, expected) # ✅ Passes
```
**NoChildren()** - Element has no children (but can have attributes)
```python
from myfasthtml.test.matcher import NoChildren
from fasthtml.common import Div
actual = Div(id="container", cls="empty")
expected = Div(NoChildren())
matches(actual, expected) # ✅ Passes - has attributes but no children
```
**AttributeForbidden(attr_name)** - Attribute must not be present
```python
from myfasthtml.test.matcher import AttributeForbidden
from fasthtml.common import Button
actual = Button("Click me")
expected = Button("Click me", AttributeForbidden("disabled"))
matches(actual, expected) # ✅ Passes - disabled attribute is not present
```
#### Error Messages Explained
When a test fails, `matches()` provides a visual diff showing exactly where the problem is:
```python
from fasthtml.common import Div, Button
actual = Div(Button("Submit", cls="btn-primary"), id="form")
expected = Div(Button("Cancel", cls="btn-secondary"))
matches(actual, expected)
```
**Error output:**
```
Path : 'div.button'
Error : The values are different
(div "id"="form" | (div
(button "cls"="btn-prim | (button "cls"="btn-seco
^^^^^^^^^^^^^^^^ |
"Submit") | "Cancel")
^^^^^^^ |
) | )
```
**Reading the error:**
- **Left side**: Actual element
- **Right side**: Expected pattern
- **`^^^` markers**: Highlight differences 'only on the left side', the right side (the expected pattern) is always correct
- **Path**: Shows location in the tree (`div.button` = button inside div)
---
### find() - Search Elements
#### Purpose
`find()` searches for all elements matching a pattern within an HTML tree. It's useful when you need to verify the presence of specific elements without knowing their exact position. Or when you want to validate (using matches) a subset of elements.
**When to use it:**
- Finding elements by attributes
- Verifying element count
- Extracting elements for further validation
- Testing without strict hierarchy requirements
#### Basic Syntax
```python
from myfasthtml.test.matcher import find
results = find(ft, expected)
```
- **`ft`**: Element or list of elements to search in
- **`expected`**: Pattern to match, follows the same syntax and rules as `matches()`
- **Returns**: List of all matching elements
- **Raises**: `AssertionError` if no matches found
#### Simple Examples
**Example 1: Find all elements of a type**
```python
from myfasthtml.test.matcher import find
from fasthtml.common import Div, P
page = Div(
Div(P("First paragraph")),
Div(P("Second paragraph")),
P("Third paragraph")
)
# Find all paragraphs
paragraphs = find(page, P())
assert len(paragraphs) == 3
```
**Example 2: Find by attribute**
```python
from fasthtml.common import Div, Button
from myfasthtml.test.matcher import find
page = Div(
Button("Cancel", cls="btn-secondary"),
Div(Button("Submit", cls="btn-primary", id="submit"))
)
# Find the primary button
primary_buttons = find(page, Button(cls="btn-primary"))
assert len(primary_buttons) == 1
assert primary_buttons[0].attrs["id"] == "submit"
```
**Example 3: Find nested structure**
```python
from fasthtml.common import Div, Form, Input
page = Div(
Div(Input(name="search")),
Form(
Input(name="email", type="email"),
Input(name="password", type="password")
)
)
# Find all email inputs
email_inputs = find(page, Input(type="email"))
assert len(email_inputs) == 1
assert email_inputs[0].attrs["name"] == "email"
```
**Example 4: Search in a list**
```python
from fasthtml.common import Div, P, Span
elements = [
Div(P("First")),
Div(P("Second")),
Span(P("Third"))
]
# Find all paragraphs across all elements
all_paragraphs = find(elements, P())
assert len(all_paragraphs) == 3
```
#### Common Patterns
**Verify element count:**
```python
buttons = find(page, Button())
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
```
**Check element exists:**
```python
submit_buttons = find(page, Button(type="submit"))
assert len(submit_buttons) > 0, "No submit button found"
```
**Extract for further testing:**
```python
form = find(page, Form())[0] # Get first form
inputs = find(form, Input()) # Find inputs within form
assert all(inp.attrs.get("type") in ["text", "email"] for inp in inputs)
```
**Handle missing elements:**
```python
try:
admin_section = find(page, Div(id="admin"))
print("Admin section found")
except AssertionError:
print("Admin section not present")
```
---
## Part 2: Testing Rendered Components Guide
This section provides practical patterns for testing different aspects of rendered components.
### 1. Testing Element Structure
**Goal**: Verify the hierarchy and organization of elements.
**Pattern**: Use `matches()` with only the structural elements you care about.
```python
from myfasthtml.test.matcher import matches
from fasthtml.common import Div, Header, Nav, Main, Footer
# Your component renders a page layout
actual = page_component.render()
# Test only the main structure
expected = Div(
Header(Nav()),
Main(),
Footer()
)
matches(actual, expected)
```
**Tip**: Don't include every single child element. Focus on the important structural elements.
### 2. Testing Attributes
**Goal**: Verify that specific attributes are set correctly.
**Pattern**: Include only the attributes you want to test.
```python
from fasthtml.common import Button
# Component renders a button with HTMX
actual = button_component.render()
# Test only HTMX attributes
expected = Button(
hx_post="/api/submit",
hx_target="#result",
hx_swap="innerHTML"
)
matches(actual, expected)
```
**With predicates for dynamic values:**
```python
from myfasthtml.test.matcher import StartsWith
# Test that id follows a pattern
expected = Button(id=StartsWith("btn-"))
matches(actual, expected)
```
### 3. Testing Content
**Goal**: Verify text content in elements.
**Pattern**: Match elements with their text content.
```python
from fasthtml.common import Div, H1, P
actual = article_component.render()
expected = Div(
H1("Article Title"),
P("First paragraph content")
)
matches(actual, expected)
```
**Partial content matching:**
```python
from myfasthtml.test.matcher import Contains
# Check that paragraph contains key phrase
expected = P(Contains("important information"))
matches(actual, expected)
```
### 4. Testing with Predicates
**Goal**: Validate patterns rather than exact values.
**Pattern**: Use predicates for flexibility.
**Example: Testing generated IDs**
```python
from myfasthtml.test.matcher import StartsWith, AnyValue
from fasthtml.common import Div
# Component generates unique IDs
actual = widget_component.render()
expected = Div(
id=StartsWith("widget-"),
data_timestamp=AnyValue() # Just check it exists
)
matches(actual, expected)
```
**Example: Testing CSS classes**
```python
from myfasthtml.test.matcher import Contains
from fasthtml.common import Button
actual = dynamic_button.render()
# Check button has 'active' class among others
expected = Button(cls=Contains("active"))
matches(actual, expected)
```
**Example: Forbidden attributes**
```python
from myfasthtml.test.matcher import AttributeForbidden
from fasthtml.common import Input
# Verify input is NOT disabled
actual = input_component.render()
expected = Input(
name="username",
AttributeForbidden("disabled")
)
matches(actual, expected)
```
### 5. Combining matches() and find()
**Goal**: First find elements, then validate them in detail.
**Pattern**: Use `find()` to locate, then `matches()` to validate.
**Example: Testing a form**
```python
from myfasthtml.test.matcher import find, matches
from fasthtml.common import Form, Input, Button
# Render a complex page
actual = page_component.render()
# Step 1: Find the registration form
forms = find(actual, Form(id="registration"))
assert len(forms) == 1
# Step 2: Validate the form structure
registration_form = forms[0]
expected_form = Form(
Input(name="email", type="email"),
Input(name="password", type="password"),
Button("Register", type="submit")
)
matches(registration_form, expected_form)
```
**Example: Testing multiple similar elements**
```python
from fasthtml.common import Div, Card
actual = dashboard.render()
# Find all cards
cards = find(actual, Card())
# Verify we have the right number
assert len(cards) == 3
# Validate each card has required structure
for card in cards:
expected_card = Card(
Div(cls="card-header"),
Div(cls="card-body")
)
matches(card, expected_card)
```
### 6. Testing Edge Cases
**Testing empty elements:**
```python
from myfasthtml.test.matcher import Empty, NoChildren
from fasthtml.common import Div
# Test completely empty element
actual = Div()
expected = Div(Empty())
matches(actual, expected)
# Test element with attributes but no children
actual = Div(id="container", cls="empty-state")
expected = Div(NoChildren())
matches(actual, expected)
```
**Testing absence of elements:**
```python
from myfasthtml.test.matcher import find
from fasthtml.common import Div
actual = basic_view.render()
# Verify admin section is not present
try:
find(actual, Div(id="admin-section"))
assert False, "Admin section should not be present"
except AssertionError as e:
if "No element found" in str(e):
pass # Expected - admin section absent
else:
raise
```
---
## Part 3: How It Works (Technical Overview)
Understanding how the matcher works helps you write better tests and debug failures more effectively.
### The Matching Algorithm
When you call `matches(actual, expected)`, here's what happens:
1. **Type Comparison**: Checks if elements have the same type/tag
- For FastHTML elements: compares `.tag` (e.g., "div", "button")
- For Python objects: compares class types
2. **Attribute Validation**: For each attribute in `expected`:
- Checks if attribute exists in `actual`
- If it's a `Predicate`: calls `.validate(actual_value)`
- Otherwise: checks for exact equality
- **Key point**: Only attributes in `expected` are checked
3. **Children Validation**:
- Applies `ChildrenPredicate` validators if present
- Recursively matches children in order
- **Key point**: Only checks as many children as defined in `expected`
4. **Path Tracking**: Maintains a path through the tree for error reporting
### Understanding the Path
The matcher builds a path as it traverses your element tree:
```
div#container.form.input[name=email]
```
This path means:
- Started at a `div` with `id="container"`
- Went to a `form` element
- Then to an `input` with `name="email"`
The path appears in error messages to help you locate problems:
```
Path : 'div#form.input[name=email]'
Error : 'type' is not found in Actual
```
### How Predicates Work
Predicates are objects that implement a `validate()` method:
```python
class Contains(AttrPredicate):
def validate(self, actual):
return self.value in actual
```
When matching encounters a predicate:
1. Gets the actual attribute value
2. Calls `predicate.validate(actual_value)`
3. If returns `False`, reports validation error
This allows flexible matching without hardcoding exact values.
### Error Output Generation
When a test fails, the matcher generates a side-by-side comparison:
**Process:**
1. Renders both `actual` and `expected` as tree structures
2. Compares them element by element
3. Marks differences with `^^^` characters
4. Aligns output for easy visual comparison
**Example:**
```
(div "id"="old" | (div "id"="new"
^^^^ |
```
The `^^^` appears under attributes or content that don't match.
### The find() Algorithm
`find()` uses depth-first search:
1. **Check current element**: Does it match the pattern?
- If yes: add to results
2. **Search children**: Recursively search all children
3. **Return all matches**: Collects matches from entire tree
**Key difference from matches()**: `find()` looks for any occurrence anywhere in the tree, while `matches()` validates exact structure.
---
## Best Practices
### Do's ✅
**Test only what matters**
```python
# ✅ Good - tests only the submit action
expected = Button("Submit", hx_post="/api/save")
# ❌ Bad - tests irrelevant details
expected = Button("Submit", hx_post="/api/save", id="btn-123", cls="btn btn-primary")
```
**Use predicates for dynamic values**
```python
# ✅ Good - flexible for generated IDs
expected = Div(id=StartsWith("generated-"))
# ❌ Bad - brittle, will break on regeneration
expected = Div(id="generated-12345")
```
**Structure tests in layers**
```python
# ✅ Good - separate concerns
# Test 1: Overall structure
matches(page, Div(Header(), Main(), Footer()))
# Test 2: Header details
header = find(page, Header())[0]
matches(header, Header(Nav(), Div(cls="user-menu")))
# ❌ Bad - everything in one giant test
matches(page, Div(
Header(Nav(...), Div(...)),
Main(...),
Footer(...)
))
```
**Verify element counts with find()**
```python
# ✅ Good - explicit count check
buttons = find(page, Button())
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
# ❌ Bad - implicit assumption
button = find(page, Button())[0] # Fails if 0 or multiple buttons
```
### Don'ts ❌
**Don't test implementation details**
```python
# ❌ Bad - internal div structure might change
expected = Div(
Div(
Div(Button("Click"))
)
)
# ✅ Good - test the important element
expected = Div(Button("Click"))
```
**Don't use exact string matching**
```python
# ❌ Bad - fragile
assert str(component.render()) == '<div><p>Text</p></div>'
# ✅ Good - structural matching
matches(component.render(), Div(P("Text")))
```
**Don't over-specify**
```python
# ❌ Bad - tests too much
expected = Form(
Input(name="email", type="email", id="email-input", cls="form-control"),
Input(name="password", type="password", id="pwd-input", cls="form-control"),
Button("Submit", type="submit", id="submit-btn", cls="btn btn-primary")
)
# ✅ Good - tests what matters
expected = Form(
Input(name="email", type="email"),
Input(name="password", type="password"),
Button("Submit", type="submit")
)
```
### Performance Tips
**Reuse patterns**
```python
# Define reusable patterns
STANDARD_BUTTON = Button(cls=Contains("btn"))
# Use in multiple tests
matches(actual, Div(STANDARD_BUTTON))
```
**Use find() efficiently**
```python
# ✅ Good - specific pattern
buttons = find(page, Button(cls="primary"))
# ❌ Inefficient - too broad then filter
all_buttons = find(page, Button())
primary = [b for b in all_buttons if "primary" in b.attrs.get("cls", "")]
```
---
## Quick Reference
### Import Statements
```python
# Core functions
from myfasthtml.test.matcher import matches, find
# AttrPredicates
from myfasthtml.test.matcher import Contains, StartsWith, DoesNotContain, AnyValue
# ChildrenPredicates
from myfasthtml.test.matcher import Empty, NoChildren, AttributeForbidden
# For custom test objects
from myfasthtml.test.matcher import TestObject
```
### Common Patterns Cheatsheet
```python
# Basic validation
matches(actual, expected)
# Find and validate
elements = find(tree, pattern)
assert len(elements) == 1
matches(elements[0], detailed_pattern)
# Flexible attribute matching
expected = Div(
id=StartsWith("prefix-"),
cls=Contains("active"),
data_value=AnyValue()
)
# Empty element validation
expected = Div(Empty()) # No children, no attributes
expected = Div(NoChildren()) # No children, attributes OK
# Forbidden attribute
expected = Button(AttributeForbidden("disabled"))
# Multiple children
expected = Div(
Header(),
Main(),
Footer()
)
```
---
## Conclusion
The matcher module provides powerful tools for testing FastHTML components:
- **`matches()`** validates structure with precise, readable error messages
- **`find()`** locates elements anywhere in your HTML tree
- **Predicates** enable flexible, maintainable tests
By focusing on what matters and using the right patterns, you can write component tests that are both robust and easy to maintain.
**Next steps:**
- Practice with simple components first
- Gradually introduce predicates as needed
- Review error messages carefully - they guide you to the problem
- Refactor tests to remove duplication
Happy testing! 🧪

View File

@@ -10,9 +10,11 @@ from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import Ids, mk from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.instances import UniqueInstance from myfasthtml.core.instances import UniqueInstance
from myfasthtml.icons.carbon import volume_object_storage from myfasthtml.icons.carbon import volume_object_storage
from myfasthtml.icons.fluent_p2 import key_command16_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular from myfasthtml.icons.fluent_p3 import folder_open20_regular
from myfasthtml.myfastapp import create_app from myfasthtml.myfastapp import create_app
@@ -31,6 +33,52 @@ app, rt = create_app(protect_routes=True,
base_url="http://localhost:5003") base_url="http://localhost:5003")
def create_sample_treeview(parent):
"""
Create a sample TreeView with a file structure for testing.
Args:
parent: Parent instance for the TreeView
Returns:
TreeView: Configured TreeView instance with sample data
"""
tree_view = TreeView(parent, _id="-treeview")
# Create sample file structure
projects = TreeNode(label="Projects", type="folder")
tree_view.add_node(projects)
myfasthtml = TreeNode(label="MyFastHtml", type="folder")
tree_view.add_node(myfasthtml, parent_id=projects.id)
app_py = TreeNode(label="app.py", type="file")
tree_view.add_node(app_py, parent_id=myfasthtml.id)
readme = TreeNode(label="README.md", type="file")
tree_view.add_node(readme, parent_id=myfasthtml.id)
src_folder = TreeNode(label="src", type="folder")
tree_view.add_node(src_folder, parent_id=myfasthtml.id)
controls_py = TreeNode(label="controls.py", type="file")
tree_view.add_node(controls_py, parent_id=src_folder.id)
documents = TreeNode(label="Documents", type="folder")
tree_view.add_node(documents, parent_id=projects.id)
notes = TreeNode(label="notes.txt", type="file")
tree_view.add_node(notes, parent_id=documents.id)
todo = TreeNode(label="todo.md", type="file")
tree_view.add_node(todo, parent_id=documents.id)
# Expand all nodes to show the full structure
#tree_view.expand_all()
return tree_view
@rt("/") @rt("/")
def index(session): def index(session):
session_instance = UniqueInstance(session=session, _id=Ids.UserSession) session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
@@ -51,7 +99,7 @@ def index(session):
commands_debugger = CommandsDebugger(layout) commands_debugger = CommandsDebugger(layout)
btn_show_commands_debugger = mk.label("Commands", btn_show_commands_debugger = mk.label("Commands",
icon=None, icon=key_command16_regular,
command=add_tab("Commands", commands_debugger), command=add_tab("Commands", commands_debugger),
id=commands_debugger.get_id()) id=commands_debugger.get_id())
@@ -62,13 +110,17 @@ def index(session):
btn_popup = mk.label("Popup", btn_popup = mk.label("Popup",
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown"))) command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
# Create TreeView with sample data
tree_view = create_sample_treeview(layout)
layout.header_left.add(tabs_manager.add_tab_btn()) layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer) layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger") layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger, "Debugger") layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_file_upload, "Test") layout.left_drawer.add(btn_file_upload, "Test")
layout.left_drawer.add(btn_popup, "Test") layout.left_drawer.add(btn_popup, "Test")
layout.left_drawer.add(tree_view, "TreeView")
layout.set_main(tabs_manager) layout.set_main(tabs_manager)
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o", keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
add_tab("File Open", FileUpload(layout, _id="-file_upload"))) add_tab("File Open", FileUpload(layout, _id="-file_upload")))

View File

@@ -465,4 +465,210 @@
.mf-dropdown.is-visible { .mf-dropdown.is-visible {
display: block; display: block;
opacity: 1; opacity: 1;
} }
/* *********************************************** */
/* ************** TreeView Component ************* */
/* *********************************************** */
/* TreeView Container */
.mf-treeview {
width: 100%;
user-select: none;
}
/* TreeNode Container */
.mf-treenode-container {
width: 100%;
}
/* TreeNode Element */
.mf-treenode {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 2px 0.5rem;
cursor: pointer;
transition: background-color 0.15s ease;
border-radius: 0.25rem;
}
/* Input for Editing */
.mf-treenode-input {
flex: 1;
padding: 2px 0.25rem;
border: 1px solid var(--color-primary);
border-radius: 0.25rem;
background-color: var(--color-base-100);
outline: none;
}
.mf-treenode:hover {
background-color: var(--color-base-200);
}
.mf-treenode.selected {
background-color: var(--color-primary);
color: var(--color-primary-content);
}
/* Toggle Icon */
.mf-treenode-toggle {
flex-shrink: 0;
width: 20px;
text-align: center;
font-size: 0.75rem;
}
/* Node Label */
.mf-treenode-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mf-treenode-input:focus {
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent);
}
/* Action Buttons - Hidden by default, shown on hover */
.mf-treenode-actions {
display: none;
gap: 0.1rem;
white-space: nowrap;
margin-left: 0.5rem;
}
.mf-treenode:hover .mf-treenode-actions {
display: flex;
}
/* *********************************************** */
/* ********** Generic Resizer Classes ************ */
/* *********************************************** */
/* Generic resizer - used by both Layout and Panel */
.mf-resizer {
position: absolute;
width: 4px;
cursor: col-resize;
background-color: transparent;
transition: background-color 0.2s ease;
z-index: 100;
top: 0;
bottom: 0;
}
.mf-resizer:hover {
background-color: rgba(59, 130, 246, 0.3);
}
/* Active state during resize */
.mf-resizing .mf-resizer {
background-color: rgba(59, 130, 246, 0.5);
}
/* Prevent text selection during resize */
.mf-resizing {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Cursor override for entire body during resize */
.mf-resizing * {
cursor: col-resize !important;
}
/* Visual indicator for resizer on hover - subtle border */
.mf-resizer::before {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 40px;
background-color: rgba(156, 163, 175, 0.4);
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.mf-resizer:hover::before,
.mf-resizing .mf-resizer::before {
opacity: 1;
}
/* Resizer positioning */
/* Left resizer is on the right side of the left panel */
.mf-resizer-left {
right: 0;
}
/* Right resizer is on the left side of the right panel */
.mf-resizer-right {
left: 0;
}
/* Position indicator for resizer */
.mf-resizer-left::before {
right: 1px;
}
.mf-resizer-right::before {
left: 1px;
}
/* Disable transitions during resize for smooth dragging */
.mf-item-resizing {
transition: none !important;
}
/* *********************************************** */
/* *************** Panel Component *************** */
/* *********************************************** */
/* Container principal du panel */
.mf-panel {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Panel gauche */
.mf-panel-left {
position: relative;
flex-shrink: 0;
width: 250px;
min-width: 150px;
max-width: 400px;
height: 100%;
overflow: auto;
border-right: 1px solid var(--color-border-primary);
}
/* Panel principal (centre) */
.mf-panel-main {
flex: 1;
height: 100%;
overflow: auto;
min-width: 0; /* Important pour permettre le shrink du flexbox */
}
/* Panel droit */
.mf-panel-right {
position: relative;
flex-shrink: 0;
width: 300px;
min-width: 150px;
max-width: 500px;
height: 100%;
overflow: auto;
border-left: 1px solid var(--color-border-primary);
}

View File

@@ -1,40 +1,43 @@
/** /**
* Layout Drawer Resizer * Generic Resizer
* *
* Handles resizing of left and right drawers with drag functionality. * Handles resizing of elements with drag functionality.
* Communicates with server via HTMX to persist width changes. * Communicates with server via HTMX to persist width changes.
* Works for both Layout drawers and Panel sides.
*/ */
/** /**
* Initialize drawer resizer functionality for a specific layout instance * Initialize resizer functionality for a specific container
* *
* @param {string} layoutId - The ID of the layout instance to initialize * @param {string} containerId - The ID of the container instance to initialize
* @param {Object} options - Configuration options
* @param {number} options.minWidth - Minimum width in pixels (default: 150)
* @param {number} options.maxWidth - Maximum width in pixels (default: 600)
*/ */
function initLayoutResizer(layoutId) { function initResizer(containerId, options = {}) {
'use strict';
const MIN_WIDTH = 150; const MIN_WIDTH = options.minWidth || 150;
const MAX_WIDTH = 600; const MAX_WIDTH = options.maxWidth || 600;
let isResizing = false; let isResizing = false;
let currentResizer = null; let currentResizer = null;
let currentDrawer = null; let currentItem = null;
let startX = 0; let startX = 0;
let startWidth = 0; let startWidth = 0;
let side = null; let side = null;
const layoutElement = document.getElementById(layoutId); const containerElement = document.getElementById(containerId);
if (!layoutElement) { if (!containerElement) {
console.error(`Layout element with ID "${layoutId}" not found`); console.error(`Container element with ID "${containerId}" not found`);
return; return;
} }
/** /**
* Initialize resizer functionality for this layout instance * Initialize resizer functionality for this container instance
*/ */
function initResizers() { function initResizers() {
const resizers = layoutElement.querySelectorAll('.mf-layout-resizer'); const resizers = containerElement.querySelectorAll('.mf-resizer');
resizers.forEach(resizer => { resizers.forEach(resizer => {
// Remove existing listener if any to avoid duplicates // Remove existing listener if any to avoid duplicates
@@ -51,24 +54,24 @@ function initLayoutResizer(layoutId) {
currentResizer = e.target; currentResizer = e.target;
side = currentResizer.dataset.side; side = currentResizer.dataset.side;
currentDrawer = currentResizer.closest('.mf-layout-drawer'); currentItem = currentResizer.parentElement;
if (!currentDrawer) { if (!currentItem) {
console.error('Could not find drawer element'); console.error('Could not find item element');
return; return;
} }
isResizing = true; isResizing = true;
startX = e.clientX; startX = e.clientX;
startWidth = currentDrawer.offsetWidth; startWidth = currentItem.offsetWidth;
// Add event listeners for mouse move and up // Add event listeners for mouse move and up
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
// Add resizing class for visual feedback // Add resizing class for visual feedback
document.body.classList.add('mf-layout-resizing'); document.body.classList.add('mf-resizing');
currentDrawer.classList.add('mf-layout-drawer-resizing'); currentItem.classList.add('mf-item-resizing');
} }
/** /**
@@ -92,8 +95,8 @@ function initLayoutResizer(layoutId) {
// Constrain width between min and max // Constrain width between min and max
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth)); newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth));
// Update drawer width visually // Update item width visually
currentDrawer.style.width = `${newWidth}px`; currentItem.style.width = `${newWidth}px`;
} }
/** /**
@@ -109,11 +112,11 @@ function initLayoutResizer(layoutId) {
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
// Remove resizing classes // Remove resizing classes
document.body.classList.remove('mf-layout-resizing'); document.body.classList.remove('mf-resizing');
currentDrawer.classList.remove('mf-layout-drawer-resizing'); currentItem.classList.remove('mf-item-resizing');
// Get final width // Get final width
const finalWidth = currentDrawer.offsetWidth; const finalWidth = currentItem.offsetWidth;
const commandId = currentResizer.dataset.commandId; const commandId = currentResizer.dataset.commandId;
if (!commandId) { if (!commandId) {
@@ -122,24 +125,24 @@ function initLayoutResizer(layoutId) {
} }
// Send width update to server // Send width update to server
saveDrawerWidth(commandId, finalWidth); saveWidth(commandId, finalWidth);
// Reset state // Reset state
currentResizer = null; currentResizer = null;
currentDrawer = null; currentItem = null;
side = null; side = null;
} }
/** /**
* Save drawer width to server via HTMX * Save width to server via HTMX
*/ */
function saveDrawerWidth(commandId, width) { function saveWidth(commandId, width) {
htmx.ajax('POST', '/myfasthtml/commands', { htmx.ajax('POST', '/myfasthtml/commands', {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded" "Content-Type": "application/x-www-form-urlencoded"
}, },
swap: "outerHTML", swap: "outerHTML",
target: `#${currentDrawer.id}`, target: `#${currentItem.id}`,
values: { values: {
c_id: commandId, c_id: commandId,
width: width width: width
@@ -150,8 +153,8 @@ function initLayoutResizer(layoutId) {
// Initialize resizers // Initialize resizers
initResizers(); initResizers();
// Re-initialize after HTMX swaps within this layout // Re-initialize after HTMX swaps within this container
layoutElement.addEventListener('htmx:afterSwap', function (event) { containerElement.addEventListener('htmx:afterSwap', function (event) {
initResizers(); initResizers();
}); });
} }

View File

@@ -28,7 +28,6 @@ class Dropdown(MultipleInstance):
self.content = content self.content = content
self.commands = Commands(self) self.commands = Commands(self)
self._state = DropdownState() self._state = DropdownState()
self._toggle_command = self.commands.toggle()
def toggle(self): def toggle(self):
self._state.opened = not self._state.opened self._state.opened = not self._state.opened
@@ -55,7 +54,7 @@ class Dropdown(MultipleInstance):
self._mk_content(), self._mk_content(),
cls="mf-dropdown-wrapper" cls="mf-dropdown-wrapper"
), ),
Keyboard(self, "-keyboard").add("esc", self.commands.close()), Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
Mouse(self, "-mouse").add("click", self.commands.click()), Mouse(self, "-mouse").add("click", self.commands.click()),
id=self._id id=self._id
) )

View File

@@ -1,3 +1,4 @@
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.VisNetwork import VisNetwork from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.instances import SingleInstance, InstancesManager from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list from myfasthtml.core.network_utils import from_parent_child_list
@@ -6,9 +7,14 @@ from myfasthtml.core.network_utils import from_parent_child_list
class InstancesDebugger(SingleInstance): class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None): def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self._panel = Panel(self, _id="-panel")
def render(self): def render(self):
s_name = InstancesManager.get_session_user_name nodes, edges = self._get_nodes_and_edges()
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
return self._panel.set_main(vis_network)
def _get_nodes_and_edges(self):
instances = self._get_instances() instances = self._get_instances()
nodes, edges = from_parent_child_list( nodes, edges = from_parent_child_list(
instances, instances,
@@ -23,9 +29,7 @@ class InstancesDebugger(SingleInstance):
for node in nodes: for node in nodes:
node["shape"] = "box" node["shape"] = "box"
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis") return nodes, edges
# vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}})
return vis_network
def _get_instances(self): def _get_instances(self):
return list(InstancesManager.instances.values()) return list(InstancesManager.instances.values())

View File

@@ -7,7 +7,7 @@ from myfasthtml.core.instances import MultipleInstance
class Keyboard(MultipleInstance): class Keyboard(MultipleInstance):
def __init__(self, parent, _id=None, combinations=None): def __init__(self, parent, combinations=None, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.combinations = combinations or {} self.combinations = combinations or {}

View File

@@ -123,6 +123,7 @@ class Layout(SingleInstance):
self.header_right = self.Content(self) self.header_right = self.Content(self)
self.footer_left = self.Content(self) self.footer_left = self.Content(self)
self.footer_right = self.Content(self) self.footer_right = self.Content(self)
self._footer_content = None
def set_footer(self, content): def set_footer(self, content):
""" """
@@ -141,6 +142,7 @@ class Layout(SingleInstance):
content: FastHTML component(s) or content for the main area content: FastHTML component(s) or content for the main area
""" """
self._main_content = content self._main_content = content
return self
def toggle_drawer(self, side: Literal["left", "right"]): def toggle_drawer(self, side: Literal["left", "right"]):
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}") logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
@@ -233,7 +235,7 @@ class Layout(SingleInstance):
Div: FastHTML Div component for left drawer Div: FastHTML Div component for left drawer
""" """
resizer = Div( resizer = Div(
cls="mf-layout-resizer mf-layout-resizer-right", cls="mf-resizer mf-resizer-left",
data_command_id=self.commands.update_drawer_width("left").id, data_command_id=self.commands.update_drawer_width("left").id,
data_side="left" data_side="left"
) )
@@ -266,8 +268,9 @@ class Layout(SingleInstance):
Returns: Returns:
Div: FastHTML Div component for right drawer Div: FastHTML Div component for right drawer
""" """
resizer = Div( resizer = Div(
cls="mf-layout-resizer mf-layout-resizer-left", cls="mf-resizer mf-resizer-right",
data_command_id=self.commands.update_drawer_width("right").id, data_command_id=self.commands.update_drawer_width("right").id,
data_side="right" data_side="right"
) )
@@ -311,7 +314,7 @@ class Layout(SingleInstance):
self._mk_main(), self._mk_main(),
self._mk_right_drawer(), self._mk_right_drawer(),
self._mk_footer(), self._mk_footer(),
Script(f"initLayoutResizer('{self._id}');"), Script(f"initResizer('{self._id}');"),
id=self._id, id=self._id,
cls="mf-layout", cls="mf-layout",
) )

View File

@@ -0,0 +1,102 @@
from dataclasses import dataclass
from typing import Literal
from fasthtml.components import Div
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance
@dataclass
class PanelConf:
left: bool = False
right: bool = True
class Commands(BaseCommands):
def toggle_side(self, side: Literal["left", "right"]):
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
def update_side_width(self, side: Literal["left", "right"]):
"""
Create a command to update panel's side width.
Args:
side: Which panel's side to update ("left" or "right")
Returns:
Command: Command object for updating panel's side width
"""
return Command(
f"UpdatePanelSideWidth_{side}",
f"Update {side} side panel width",
self._owner.update_side_width,
side
)
class Panel(MultipleInstance):
def __init__(self, parent, conf=None, _id=None):
super().__init__(parent, _id=_id)
self.conf = conf or PanelConf()
self.commands = Commands(self)
self._main = None
self._right = None
self._left = None
def update_side_width(self, side, width):
pass
def toggle_side(self, side):
pass
def set_main(self, main):
self._main = main
return self
def set_right(self, right):
self._right = right
return self
def set_left(self, left):
self._left = left
return self
def _mk_right(self):
if not self.conf.right:
return None
resizer = Div(
cls="mf-resizer mf-resizer-right",
data_command_id=self.commands.update_side_width("right").id,
data_side="right"
)
return Div(resizer, self._right, cls="mf-panel-right")
def _mk_left(self):
if not self.conf.left:
return None
resizer = Div(
cls="mf-resizer mf-resizer-left",
data_command_id=self.commands.update_side_width("left").id,
data_side="left"
)
return Div(self._left, resizer, cls="mf-panel-left")
def render(self):
return Div(
self._mk_left(),
Div(self._main, cls="mf-panel-main"),
self._mk_right(),
Script(f"initResizer('{self._id}');"),
cls="mf-panel",
id=self._id,
)
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,400 @@
"""
TreeView component for hierarchical data visualization with inline editing.
This component provides an interactive tree structure with expand/collapse,
selection, and inline editing capabilities.
"""
import uuid
from dataclasses import dataclass, field
from typing import Optional
from fasthtml.components import Div, Input, Span
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular
from myfasthtml.icons.fluent_p2 import chevron_down20_regular, add_circle20_regular, delete20_regular
@dataclass
class TreeNode:
"""
Represents a node in the tree structure.
Attributes:
id: Unique identifier (auto-generated UUID if not provided)
label: Display text for the node
type: Node type for icon mapping
parent: ID of parent node (None for root)
children: List of child node IDs
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
label: str = ""
type: str = "default"
parent: Optional[str] = None
children: list[str] = field(default_factory=list)
class TreeViewState(DbObject):
"""
Persistent state for TreeView component.
Attributes:
items: Dictionary mapping node IDs to TreeNode instances
opened: List of expanded node IDs
selected: Currently selected node ID
editing: Node ID currently being edited (None if not editing)
icon_config: Mapping of node types to icon identifiers
"""
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
self.items: dict[str, TreeNode] = {}
self.opened: list[str] = []
self.selected: Optional[str] = None
self.editing: Optional[str] = None
self.icon_config: dict[str, str] = {}
class Commands(BaseCommands):
"""Command handlers for TreeView actions."""
def toggle_node(self, node_id: str):
"""Create command to expand/collapse a node."""
return Command(
"ToggleNode",
f"Toggle node {node_id}",
self._owner._toggle_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def add_child(self, parent_id: str):
"""Create command to add a child node."""
return Command(
"AddChild",
f"Add child to {parent_id}",
self._owner._add_child,
parent_id
).htmx(target=f"#{self._owner.get_id()}")
def add_sibling(self, node_id: str):
"""Create command to add a sibling node."""
return Command(
"AddSibling",
f"Add sibling to {node_id}",
self._owner._add_sibling,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def start_rename(self, node_id: str):
"""Create command to start renaming a node."""
return Command(
"StartRename",
f"Start renaming {node_id}",
self._owner._start_rename,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def save_rename(self, node_id: str):
"""Create command to save renamed node."""
return Command(
"SaveRename",
f"Save rename for {node_id}",
self._owner._save_rename,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def cancel_rename(self):
"""Create command to cancel renaming."""
return Command(
"CancelRename",
"Cancel rename",
self._owner._cancel_rename
).htmx(target=f"#{self._owner.get_id()}")
def delete_node(self, node_id: str):
"""Create command to delete a node."""
return Command(
"DeleteNode",
f"Delete node {node_id}",
self._owner._delete_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def select_node(self, node_id: str):
"""Create command to select a node."""
return Command(
"SelectNode",
f"Select node {node_id}",
self._owner._select_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
class TreeView(MultipleInstance):
"""
Interactive TreeView component with hierarchical data visualization.
Supports:
- Expand/collapse nodes
- Add child/sibling nodes
- Inline rename
- Delete nodes
- Node selection
"""
def __init__(self, parent, items: Optional[dict] = None, _id: Optional[str] = None):
"""
Initialize TreeView component.
Args:
parent: Parent instance
items: Optional initial items dictionary {node_id: TreeNode}
_id: Optional custom ID
"""
super().__init__(parent, _id=_id)
self._state = TreeViewState(self)
self.commands = Commands(self)
if items:
self._state.items = items
def set_icon_config(self, config: dict[str, str]):
"""
Set icon configuration for node types.
Args:
config: Dictionary mapping node types to icon identifiers
Format: {type: "provider.icon_name"}
"""
self._state.icon_config = config
def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None):
"""
Add a node to the tree.
Args:
node: TreeNode instance to add
parent_id: Optional parent node ID (None for root)
insert_index: Optional index to insert at in parent's children list.
If None, appends to end. If provided, inserts at that position.
"""
self._state.items[node.id] = node
node.parent = parent_id
if parent_id and parent_id in self._state.items:
parent = self._state.items[parent_id]
if node.id not in parent.children:
if insert_index is not None:
parent.children.insert(insert_index, node.id)
else:
parent.children.append(node.id)
def expand_all(self):
"""Expand all nodes that have children."""
for node_id, node in self._state.items.items():
if node.children and node_id not in self._state.opened:
self._state.opened.append(node_id)
def _toggle_node(self, node_id: str):
"""Toggle expand/collapse state of a node."""
if node_id in self._state.opened:
self._state.opened.remove(node_id)
else:
self._state.opened.append(node_id)
return self
def _add_child(self, parent_id: str, new_label: Optional[str] = None):
"""Add a child node to a parent."""
if parent_id not in self._state.items:
raise ValueError(f"Parent node {parent_id} does not exist")
parent = self._state.items[parent_id]
new_node = TreeNode(
label=new_label or "New Node",
type=parent.type
)
self.add_node(new_node, parent_id=parent_id)
# Auto-expand parent
if parent_id not in self._state.opened:
self._state.opened.append(parent_id)
return self
def _add_sibling(self, node_id: str, new_label: Optional[str] = None):
"""Add a sibling node next to a node."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
node = self._state.items[node_id]
if node.parent is None:
raise ValueError("Cannot add sibling to root node")
parent = self._state.items[node.parent]
new_node = TreeNode(
label=new_label or "New Node",
type=node.type
)
# Insert after current node
insert_idx = parent.children.index(node_id) + 1
self.add_node(new_node, parent_id=node.parent, insert_index=insert_idx)
return self
def _start_rename(self, node_id: str):
"""Start renaming a node (sets editing state)."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
self._state.editing = node_id
return self
def _save_rename(self, node_id: str, node_label: str):
"""Save renamed node with new label."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
self._state.items[node_id].label = node_label
self._state.editing = None
return self
def _cancel_rename(self):
"""Cancel renaming operation."""
self._state.editing = None
return self
def _delete_node(self, node_id: str):
"""Delete a node (only if it has no children)."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
node = self._state.items[node_id]
if node.children:
raise ValueError(f"Cannot delete node {node_id} with children")
# Remove from parent's children list
if node.parent and node.parent in self._state.items:
parent = self._state.items[node.parent]
parent.children.remove(node_id)
# Remove from state
del self._state.items[node_id]
if node_id in self._state.opened:
self._state.opened.remove(node_id)
if self._state.selected == node_id:
self._state.selected = None
return self
def _select_node(self, node_id: str):
"""Select a node."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
self._state.selected = node_id
return self
def _render_action_buttons(self, node_id: str):
"""Render action buttons for a node (visible on hover)."""
return Div(
mk.icon(add_circle20_regular, command=self.commands.add_child(node_id)),
mk.icon(edit20_regular, command=self.commands.start_rename(node_id)),
mk.icon(delete20_regular, command=self.commands.delete_node(node_id)),
cls="mf-treenode-actions"
)
def _render_node(self, node_id: str, level: int = 0):
"""
Render a single node and its children recursively.
Args:
node_id: ID of node to render
level: Indentation level
Returns:
Div containing the node and its children
"""
node = self._state.items[node_id]
is_expanded = node_id in self._state.opened
is_selected = node_id == self._state.selected
is_editing = node_id == self._state.editing
has_children = len(node.children) > 0
# Toggle icon
toggle = mk.icon(
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
command=self.commands.toggle_node(node_id))
# Label or input for editing
if is_editing:
# TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
label_element = mk.mk(Input(
name="node_label",
value=node.label,
cls="mf-treenode-input input input-sm"
), command=self.commands.save_rename(node_id))
else:
label_element = mk.mk(
Span(node.label, cls="mf-treenode-label text-sm"),
command=self.commands.select_node(node_id)
)
# Node element
node_element = Div(
toggle,
label_element,
self._render_action_buttons(node_id),
cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
data_node_id=node_id,
style=f"padding-left: {level * 20}px"
)
# Children (if expanded)
children_elements = []
if is_expanded and has_children:
for child_id in node.children:
children_elements.append(
self._render_node(child_id, level + 1)
)
return Div(
node_element,
*children_elements,
cls="mf-treenode-container"
)
def render(self):
"""
Render the complete TreeView.
Returns:
Div: Complete TreeView HTML structure
"""
# Find root nodes (nodes without parent)
root_nodes = [
node_id for node_id, node in self._state.items.items()
if node.parent is None
]
return Div(
*[self._render_node(node_id) for node_id in root_nodes],
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
id=self._id,
cls="mf-treeview"
)
def __ft__(self):
"""FastHTML magic method for rendering."""
return self.render()

View File

@@ -15,6 +15,19 @@ class mk:
@staticmethod @staticmethod
def button(element, command: Command = None, binding: Binding = None, **kwargs): def button(element, command: Command = None, binding: Binding = None, **kwargs):
"""
Defines a static method for creating a Button object with specific configurations.
This method constructs a Button instance by wrapping an element with
additional configurations such as commands and bindings. Any extra keyword
arguments are passed when creating the Button.
:param element: The underlying widget or element to be wrapped in a Button.
:param command: An optional command to associate with the Button. Defaults to None.
:param binding: An optional event binding to associate with the Button. Defaults to None.
:param kwargs: Additional keyword arguments to further configure the Button.
:return: A fully constructed Button instance with the specified configurations.
"""
return mk.mk(Button(element, **kwargs), command=command, binding=binding) return mk.mk(Button(element, **kwargs), command=command, binding=binding)
@staticmethod @staticmethod
@@ -33,13 +46,33 @@ class mk:
) )
@staticmethod @staticmethod
def icon(icon, size=20, def icon(icon,
size=20,
can_select=True, can_select=True,
can_hover=False, can_hover=False,
cls='', cls='',
command: Command = None, command: Command = None,
binding: Binding = None, binding: Binding = None,
**kwargs): **kwargs):
"""
Generates an icon element with customizable properties for size, class, and interactivity.
This method creates an icon element wrapped in a container with optional classes
and event bindings. The icon can be styled and its behavior defined using the parameters
provided, allowing for dynamic and reusable UI components.
:param icon: The icon to display inside the container.
:param size: The size of the icon, specified in pixels. Defaults to 20.
:param can_select: Indicates whether the icon can be selected. Defaults to True.
:param can_hover: Indicates whether the icon reacts to hovering. Defaults to False.
:param cls: A string of custom CSS classes to be added to the icon container.
:param command: The command object defining the function to be executed on icon interaction.
:param binding: The binding object for configuring additional event listeners on the icon.
:param kwargs: Additional keyword arguments for configuring attributes and behaviors of the
icon element.
:return: A styled and interactive icon element embedded inside a container, configured
with the defined classes, size, and behaviors.
"""
merged_cls = merge_classes(f"mf-icon-{size}", merged_cls = merge_classes(f"mf-icon-{size}",
'icon-btn' if can_select else '', 'icon-btn' if can_select else '',
'mmt-btn' if can_hover else '', 'mmt-btn' if can_hover else '',

View File

@@ -10,6 +10,7 @@ from myfasthtml.core.utils import retrieve_user_info
class DbManager(SingleInstance): class DbManager(SingleInstance):
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True): def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
super().__init__(parent, auto_register=auto_register) super().__init__(parent, auto_register=auto_register)
self.db = DbEngine(root=root) self.db = DbEngine(root=root)
def save(self, entry, obj): def save(self, entry, obj):

View File

@@ -6,6 +6,8 @@ from fastcore.basics import NotStr
from myfasthtml.core.utils import quoted_str from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
MISSING_ATTR = "** MISSING **"
class Predicate: class Predicate:
def __init__(self, value): def __init__(self, value):
@@ -114,11 +116,55 @@ class AttributeForbidden(ChildrenPredicate):
return element return element
class TestObject:
def __init__(self, cls, **kwargs):
self.cls = cls
self.attrs = kwargs
class TestCommand(TestObject):
def __init__(self, name, **kwargs):
super().__init__("Command", **kwargs)
self.attrs = {"name": name} | kwargs # name should be first
@dataclass @dataclass
class DoNotCheck: class DoNotCheck:
desc: str = None desc: str = None
def _get_type(x):
if hasattr(x, "tag"):
return x.tag
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 hasattr(x, "attrs"):
return x.attrs.get(attr, MISSING_ATTR)
if not hasattr(x, attr):
return MISSING_ATTR
return getattr(x, attr, MISSING_ATTR)
def _get_attributes(x):
"""Get the attributes dict from an element."""
if hasattr(x, "attrs"):
return x.attrs
return {}
def _get_children(x):
"""Get the children list from an element."""
if hasattr(x, "children"):
return x.children
return []
class ErrorOutput: class ErrorOutput:
def __init__(self, path, element, expected): def __init__(self, path, element, expected):
self.path = path self.path = path
@@ -187,6 +233,16 @@ class ErrorOutput:
self.indent = self.indent[:-2] self.indent = self.indent[:-2]
self._add_to_output(")") self._add_to_output(")")
elif isinstance(self.expected, TestObject):
cls = _get_type(self.element)
attrs = {attr_name: _get_attr(self.element, attr_name) for attr_name in self.expected.attrs}
self._add_to_output(f"({cls} {_str_attrs(attrs)})")
# Try to show where the differences are
error_str = self._detect_error(self.element, self.expected)
if error_str:
self._add_to_output(error_str)
else: else:
self._add_to_output(str(self.element)) self._add_to_output(str(self.element))
# Try to show where the differences are # Try to show where the differences are
@@ -205,7 +261,7 @@ class ErrorOutput:
if hasattr(element, "tag"): if hasattr(element, "tag"):
# the attributes are compared to the expected element # the attributes are compared to the expected element
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in
[attr_name for attr_name in expected.attrs if attr_name is not None]} [attr_name for attr_name in expected.attrs if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items()) elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
@@ -226,21 +282,35 @@ class ErrorOutput:
return quoted_str(element) return quoted_str(element)
def _detect_error(self, element, expected): def _detect_error(self, element, expected):
if hasattr(expected, "tag") and hasattr(element, "tag"): """
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^") Detect errors between element and expected, returning a visual marker string.
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs} Unified version that handles both FT elements and TestObjects.
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if """
not self._matches(attr_value, expected.attrs[attr_name])] # For elements with structure (FT or TestObject)
if attrs_in_error: if hasattr(expected, "tag") or isinstance(expected, TestObject):
elt_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ") element_type = _get_type(element)
for name, value in elt_attrs.items()) expected_type = _get_type(expected)
error_str = f" {tag_str} {elt_attrs_error}" type_error = (" " if element_type == expected_type else "^") * len(element_type)
return error_str
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: else:
if not self._matches(element, expected): if not self._matches(element, expected):
return len(str(element)) * "^" return len(str(element)) * "^"
return None
return None
@staticmethod @staticmethod
def _matches(element, expected): def _matches(element, expected):
@@ -290,39 +360,200 @@ class ErrorComparisonOutput:
return "\n".join(output) return "\n".join(output)
def matches(actual, expected, path=""): class Matcher:
def print_path(p): """
return f"Path : '{p}'\n" if p else "" Matcher class for comparing actual and expected elements.
Provides flexible comparison with support for predicates, nested structures,
and detailed error reporting.
"""
def _type(x): def __init__(self):
return type(x) self.path = ""
def _debug(elt): def matches(self, actual, expected):
return str(elt) if elt else "None" """
Compare actual and expected elements.
Args:
actual: The actual element to compare
expected: The expected element or pattern
Returns:
True if elements match, raises AssertionError otherwise
"""
if actual is not None and expected is None:
self._assert_error("Actual is not None while expected is None", _actual=actual)
if isinstance(expected, DoNotCheck):
return True
if actual is None and expected is not None:
self._assert_error("Actual is None while expected is ", _expected=expected)
# set the path
current_path = self._get_current_path(actual)
original_path = self.path
self.path = self.path + "." + current_path if self.path else current_path
try:
self._match_elements(actual, expected)
finally:
# restore the original path for sibling comparisons
self.path = original_path
return True
def _debug_compare(a, b): def _match_elements(self, actual, expected):
actual_out = ErrorOutput(path, a, b) """Internal method that performs the actual comparison logic."""
expected_out = ErrorOutput(path, b, b) if isinstance(expected, TestObject) or hasattr(expected, "tag"):
self._match_element(actual, expected)
return
if isinstance(expected, Predicate):
assert expected.validate(actual), \
self._error_msg(f"The condition '{expected}' is not satisfied.",
_actual=actual,
_expected=expected)
return
assert _get_type(actual) == _get_type(expected), \
self._error_msg("The types are different.", _actual=actual, _expected=expected)
if isinstance(expected, (list, tuple)):
self._match_list(actual, expected)
elif isinstance(expected, dict):
self._match_dict(actual, expected)
elif isinstance(expected, NotStr):
self._match_notstr(actual, expected)
else:
assert actual == expected, self._error_msg("The values are different",
_actual=actual,
_expected=expected)
def _match_element(self, actual, expected):
"""Match a TestObject or FT element."""
# 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)]:
assert predicate.validate(actual), \
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.",
_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:
self.matches(actual_value, expected_value)
except AssertionError as e:
match = re.search(r"Error : (.+?)\n", str(e))
if match:
self._assert_error(f"{match.group(1)} for '{expected_attr}'.",
_actual=actual_value,
_expected=expected_value)
else:
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)
def _match_list(self, actual, expected):
"""Match list or tuple."""
if len(actual) < len(expected):
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
if len(actual) > len(expected):
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
for actual_child, expected_child in zip(actual, expected):
assert self.matches(actual_child, expected_child)
def _match_dict(self, actual, expected):
"""Match dictionary."""
if len(actual) < len(expected):
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
if len(actual) > len(expected):
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
for k, v in expected.items():
assert self.matches(actual[k], v)
def _match_notstr(self, actual, expected):
"""Match NotStr type."""
to_compare = actual.s.lstrip('\n').lstrip()
assert to_compare.startswith(expected.s), self._error_msg("Notstr values are different: ",
_actual=to_compare,
_expected=expected.s)
def _print_path(self):
"""Format the current path for error messages."""
return f"Path : '{self.path}'\n" if self.path else ""
def _debug_compare(self, a, b):
"""Generate a comparison debug output."""
actual_out = ErrorOutput(self.path, a, b)
expected_out = ErrorOutput(self.path, b, b)
comparison_out = ErrorComparisonOutput(actual_out, expected_out) comparison_out = ErrorComparisonOutput(actual_out, expected_out)
return comparison_out.render() return comparison_out.render()
def _error_msg(msg, _actual=None, _expected=None): def _error_msg(self, msg, _actual=None, _expected=None):
"""Generate an error message with debug information."""
if _actual is None and _expected is None: if _actual is None and _expected is None:
debug_info = "" debug_info = ""
elif _actual is None: elif _actual is None:
debug_info = _debug(_expected) debug_info = self._debug(_expected)
elif _expected is None: elif _expected is None:
debug_info = _debug(_actual) debug_info = self._debug(_actual)
else: else:
debug_info = _debug_compare(_actual, _expected) debug_info = self._debug_compare(_actual, _expected)
return f"{print_path(path)}Error : {msg}\n{debug_info}" return f"{self._print_path()}Error : {msg}\n{debug_info}"
def _assert_error(msg, _actual=None, _expected=None): def _assert_error(self, msg, _actual=None, _expected=None):
assert False, _error_msg(msg, _actual=_actual, _expected=_expected) """Raise an assertion error with formatted message."""
assert False, self._error_msg(msg, _actual=_actual, _expected=_expected)
@staticmethod
def _get_current_path(elt): def _get_current_path(elt):
"""Get the path representation of an element."""
if hasattr(elt, "tag"): if hasattr(elt, "tag"):
res = f"{elt.tag}" res = f"{elt.tag}"
if "id" in elt.attrs: if "id" in elt.attrs:
@@ -335,134 +566,119 @@ def matches(actual, expected, path=""):
else: else:
return elt.__class__.__name__ return elt.__class__.__name__
if actual is not None and expected is None: @staticmethod
_assert_error("Actual is not None while expected is None", _actual=actual) def _str_attrs(attrs: dict):
"""Format attributes as a string."""
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
if isinstance(expected, DoNotCheck): @staticmethod
return True def _debug(elt):
"""Format an element for debug output."""
if actual is None and expected is not None: return str(elt) if elt else "None"
_assert_error("Actual is None while expected is ", _expected=expected)
# set the path def matches(actual, expected, path=""):
path += "." + _get_current_path(actual) if path else _get_current_path(actual) """
Compare actual and expected elements.
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
_error_msg("The types are different: ", _actual=actual, _expected=expected) This is a convenience wrapper around the Matcher class.
if isinstance(expected, (list, tuple)): Args:
if len(actual) < len(expected): actual: The actual element to compare
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected) expected: The expected element or pattern
if len(actual) > len(expected): path: Optional initial path for error reporting
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
Returns:
for actual_child, expected_child in zip(actual, expected): True if elements match, raises AssertionError otherwise
assert matches(actual_child, expected_child, path=path) """
matcher = Matcher()
elif isinstance(expected, NotStr): matcher.path = path
to_compare = actual.s.lstrip('\n').lstrip() return matcher.matches(actual, expected)
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
_actual=to_compare,
_expected=expected.s)
elif hasattr(expected, "tag"):
# validate the tags names
assert actual.tag == expected.tag, _error_msg("The elements are different.",
_actual=actual.tag,
_expected=expected.tag)
# special conditions
for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]:
assert predicate.validate(actual), \
_error_msg(f"The condition '{predicate}' is not satisfied.",
_actual=actual,
_expected=predicate.to_debug(expected))
# compare the attributes
for expected_attr, expected_value in expected.attrs.items():
assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual.",
_actual=actual,
_expected=expected)
if isinstance(expected_value, Predicate):
assert expected_value.validate(actual.attrs[expected_attr]), \
_error_msg(f"The condition '{expected_value}' is not satisfied.",
_actual=actual,
_expected=expected)
else:
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
_error_msg(f"The values are different for '{expected_attr}': ",
_actual=actual,
_expected=expected)
# compare the children
expected_children = [c for c in expected.children if not isinstance(c, Predicate)]
if len(actual.children) < len(expected_children):
_assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected)
for actual_child, expected_child in zip(actual.children, expected_children):
assert matches(actual_child, expected_child, path=path)
else:
assert actual == expected, _error_msg("The values are different: ",
_actual=actual,
_expected=expected)
return True
def find(ft, expected): def find(ft, expected):
res = [] """
Find all occurrences of an expected element within a FastHTML tree.
def _type(x):
return type(x) Args:
ft: A FastHTML element or list of elements to search in
def _same(_ft, _expected): expected: The element pattern to find
if _ft == _expected:
Returns:
List of matching elements
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:
return True return True
if _ft.tag != _expected.tag: # Check if both are FT elements
if not (hasattr(actual, "tag") and hasattr(expected, "tag")):
return False return False
for attr in _expected.attrs: # Compare tags
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]: 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:
return False return False
for expected_child in _expected.children: # Compare children recursively
for ft_child in _ft.children: expected_children = _get_children(expected)
if _same(ft_child, expected_child): actual_children = _get_children(actual)
break
else: 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 False
return True return True
def _find(current, current_expected): def _search_tree(current, pattern):
"""Recursively search for pattern in the tree rooted at current."""
if _type(current) != _type(current_expected): # Type mismatch - can't be the same
if type(current) != type(pattern):
return [] return []
# For non-FT elements, simple equality check
if not hasattr(current, "tag"): if not hasattr(current, "tag"):
return [current] if current == current_expected else [] return [current] if current == pattern else []
_found = [] # Check if current element matches
if _same(current, current_expected): matches = []
_found.append(current) if _elements_match(current, pattern):
matches.append(current)
# look at the children
for child in current.children: # Recursively search in children
_found.extend(_find(child, current_expected)) for child in _get_children(current):
matches.extend(_search_tree(child, pattern))
return _found
return matches
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
# Normalize input to list
for current_ft in ft_as_list: elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft]
found = _find(current_ft, expected)
res.extend(found) # Search in all provided elements
all_matches = []
if len(res) == 0: 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}'") raise AssertionError(f"No element found for '{expected}'")
return res return all_matches
def _str_attrs(attrs: dict):
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())

View File

@@ -3,6 +3,11 @@ import pytest
from myfasthtml.core.instances import SingleInstance from myfasthtml.core.instances import SingleInstance
class RootInstanceForTests(SingleInstance):
def __init__(self, session):
super().__init__(None, session, _id="TestRoot")
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def session(): def session():
return { return {
@@ -20,4 +25,4 @@ def session():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def root_instance(session): def root_instance(session):
return SingleInstance(None, session, "TestRoot") return RootInstanceForTests(session=session)

View File

@@ -0,0 +1,482 @@
"""Unit tests for Layout component."""
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 .conftest import root_instance
@pytest.fixture(autouse=True)
def cleanup_db():
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
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
class TestLayoutRender:
"""Tests for Layout HTML rendering."""
def test_empty_layout_is_rendered(self, root_instance):
"""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
"""
layout = Layout(root_instance, app_name="Test App")
expected = Div(
Header(),
Div(),
Main(),
Div(),
Footer(),
Script(),
_id=layout._id,
cls="mf-layout"
)
assert matches(layout.render(), expected)
def test_header_with_drawer_icons_is_rendered(self, root_instance):
"""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
"""
layout = Layout(root_instance, app_name="Test App")
header = layout._mk_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"
)
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):
"""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()
expected = Main(
cls="mf-layout-main"
)
assert matches(main, expected)
def test_left_drawer_is_rendered_when_open(self, root_instance):
"""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 = Layout(root_instance, app_name="Test App")
drawer = layout._mk_left_drawer()
expected = Div(
_id=f"{layout._id}_ld",
cls=Contains("mf-layout-drawer"),
#cls=Contains("mf-layout-left-drawer"),
style=Contains("width: 250px")
)
assert matches(drawer, expected)
def test_left_drawer_has_collapsed_class_when_closed(self, root_instance):
"""Test that left drawer renders with collapsed class when closed.
Why these elements matter:
- _id: Required for targeting drawer in HTMX updates
- 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()
expected = Div(
_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):
"""Test that right 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-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()
expected = Div(
_id=f"{layout._id}_rd",
cls=Contains("mf-layout-drawer"),
#cls=Contains("mf-layout-right-drawer"),
style=Contains("width: 250px")
)
assert matches(drawer, expected)
def test_right_drawer_has_collapsed_class_when_closed(self, root_instance):
"""Test that right drawer renders with collapsed class when closed.
Why these elements matter:
- _id: Required for targeting drawer in HTMX updates
- 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()
expected = Div(
_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):
"""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()
expected = Div(
style=Contains("width: 300px")
)
assert matches(drawer, expected)
def test_left_drawer_has_resizer_element(self, root_instance):
"""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()
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):
"""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()
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):
"""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()
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):
"""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"

View File

@@ -0,0 +1,398 @@
"""Unit tests for TreeView component."""
import shutil
import pytest
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
from .conftest import root_instance
@pytest.fixture(autouse=True)
def cleanup_db():
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
class TestTreeviewBehaviour:
"""Tests for TreeView behavior and logic."""
def test_i_can_create_tree_node_with_auto_generated_id(self):
"""Test that TreeNode generates UUID automatically."""
node = TreeNode(label="Test Node", type="folder")
assert node.id is not None
assert isinstance(node.id, str)
assert len(node.id) > 0
def test_i_can_create_tree_node_with_default_values(self):
"""Test that TreeNode has correct default values."""
node = TreeNode()
assert node.label == ""
assert node.type == "default"
assert node.parent is None
assert node.children == []
def test_i_can_initialize_tree_view_state(self, root_instance):
"""Test that TreeViewState initializes with default values."""
tree_view = TreeView(root_instance)
state = tree_view._state
assert isinstance(state.items, dict)
assert len(state.items) == 0
assert state.opened == []
assert state.selected is None
assert state.editing is None
assert state.icon_config == {}
def test_i_can_create_empty_treeview(self, root_instance):
"""Test creating an empty TreeView."""
tree_view = TreeView(root_instance)
assert tree_view is not None
assert len(tree_view._state.items) == 0
def test_i_can_add_node_to_treeview(self, root_instance):
"""Test adding a root node to the TreeView."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Root Node", type="folder")
tree_view.add_node(node)
assert node.id in tree_view._state.items
assert tree_view._state.items[node.id].label == "Root Node"
assert tree_view._state.items[node.id].parent is None
def test_i_can_add_child_node(self, root_instance):
"""Test adding a child node to a parent."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
assert child.id in tree_view._state.items
assert child.id in parent.children
assert child.parent == parent.id
def test_i_can_set_icon_config(self, root_instance):
"""Test setting icon configuration."""
tree_view = TreeView(root_instance)
config = {
"folder": "fluent.folder",
"file": "fluent.document"
}
tree_view.set_icon_config(config)
assert tree_view._state.icon_config == config
assert tree_view._state.icon_config["folder"] == "fluent.folder"
def test_i_can_toggle_node(self, root_instance):
"""Test expand/collapse a node."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Node", type="folder")
tree_view.add_node(node)
# Initially closed
assert node.id not in tree_view._state.opened
# Toggle to open
tree_view._toggle_node(node.id)
assert node.id in tree_view._state.opened
# Toggle to close
tree_view._toggle_node(node.id)
assert node.id not in tree_view._state.opened
def test_i_can_expand_all_nodes(self, root_instance):
"""Test that expand_all opens all nodes with children."""
tree_view = TreeView(root_instance)
# Create hierarchy: root -> child1 -> grandchild
# -> child2 (leaf)
root = TreeNode(label="Root", type="folder")
child1 = TreeNode(label="Child 1", type="folder")
grandchild = TreeNode(label="Grandchild", type="file")
child2 = TreeNode(label="Child 2", type="file")
tree_view.add_node(root)
tree_view.add_node(child1, parent_id=root.id)
tree_view.add_node(grandchild, parent_id=child1.id)
tree_view.add_node(child2, parent_id=root.id)
# Initially all closed
assert len(tree_view._state.opened) == 0
# Expand all
tree_view.expand_all()
# Nodes with children should be opened
assert root.id in tree_view._state.opened
assert child1.id in tree_view._state.opened
# Leaf nodes should not be in opened list
assert grandchild.id not in tree_view._state.opened
assert child2.id not in tree_view._state.opened
def test_i_can_select_node(self, root_instance):
"""Test selecting a node."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Node", type="folder")
tree_view.add_node(node)
tree_view._select_node(node.id)
assert tree_view._state.selected == node.id
def test_i_can_start_rename_node(self, root_instance):
"""Test starting rename mode for a node."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
assert tree_view._state.editing == node.id
def test_i_can_save_rename_node(self, root_instance):
"""Test saving renamed node with new label."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view._save_rename(node.id, "New Name")
assert tree_view._state.items[node.id].label == "New Name"
assert tree_view._state.editing is None
def test_i_can_cancel_rename_node(self, root_instance):
"""Test canceling rename operation."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view._cancel_rename()
assert tree_view._state.editing is None
assert tree_view._state.items[node.id].label == "Name"
def test_i_can_delete_leaf_node(self, root_instance):
"""Test deleting a node without children."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
# Delete child (leaf node)
tree_view._delete_node(child.id)
assert child.id not in tree_view._state.items
assert child.id not in parent.children
def test_i_can_add_sibling_node(self, root_instance):
"""Test adding a sibling node."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child1 = TreeNode(label="Child 1", type="file")
tree_view.add_node(parent)
tree_view.add_node(child1, parent_id=parent.id)
# Add sibling to child1
tree_view._add_sibling(child1.id, new_label="Child 2")
assert len(parent.children) == 2
# Sibling should be after child1
assert parent.children.index(child1.id) < len(parent.children) - 1
def test_i_cannot_delete_node_with_children(self, root_instance):
"""Test that deleting a node with children raises an error."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
# Try to delete parent (has children)
with pytest.raises(ValueError, match="Cannot delete node.*with children"):
tree_view._delete_node(parent.id)
def test_i_cannot_add_sibling_to_root(self, root_instance):
"""Test that adding sibling to root node raises an error."""
tree_view = TreeView(root_instance)
root = TreeNode(label="Root", type="folder")
tree_view.add_node(root)
# Try to add sibling to root (no parent)
with pytest.raises(ValueError, match="Cannot add sibling to root node"):
tree_view._add_sibling(root.id)
def test_i_cannot_select_nonexistent_node(self, root_instance):
"""Test that selecting a nonexistent node raises an error."""
tree_view = TreeView(root_instance)
# Try to select node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._select_node("nonexistent_id")
def test_add_node_prevents_duplicate_children(self, root_instance):
"""Test that add_node prevents adding duplicate child IDs."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
# Try to add the same child again
tree_view.add_node(child, parent_id=parent.id)
# Child should appear only once in parent's children list
assert parent.children.count(child.id) == 1
def test_sibling_is_inserted_at_correct_position(self, root_instance):
"""Test that _add_sibling inserts sibling exactly after reference node."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child1 = TreeNode(label="Child 1", type="file")
child3 = TreeNode(label="Child 3", type="file")
tree_view.add_node(parent)
tree_view.add_node(child1, parent_id=parent.id)
tree_view.add_node(child3, parent_id=parent.id)
# Add sibling after child1
tree_view._add_sibling(child1.id, new_label="Child 2")
# Get the newly added sibling
sibling_id = parent.children[1]
# Verify order: child1, sibling (child2), child3
assert parent.children[0] == child1.id
assert tree_view._state.items[sibling_id].label == "Child 2"
assert parent.children[2] == child3.id
assert len(parent.children) == 3
def test_add_child_auto_expands_parent(self, root_instance):
"""Test that _add_child automatically expands the parent node."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
tree_view.add_node(parent)
# Parent should not be expanded initially
assert parent.id not in tree_view._state.opened
# Add child
tree_view._add_child(parent.id, new_label="Child")
# Parent should now be expanded
assert parent.id in tree_view._state.opened
def test_i_cannot_add_child_to_nonexistent_parent(self, root_instance):
"""Test that adding child to nonexistent parent raises error."""
tree_view = TreeView(root_instance)
# Try to add child to parent that doesn't exist
with pytest.raises(ValueError, match="Parent node.*does not exist"):
tree_view._add_child("nonexistent_parent_id")
def test_delete_node_clears_selection_if_selected(self, root_instance):
"""Test that deleting a selected node clears the selection."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
# Select the child
tree_view._select_node(child.id)
assert tree_view._state.selected == child.id
# Delete the selected child
tree_view._delete_node(child.id)
# Selection should be cleared
assert tree_view._state.selected is None
def test_delete_node_removes_from_opened_if_expanded(self, root_instance):
"""Test that deleting an expanded node removes it from opened list."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
# Expand the parent
tree_view._toggle_node(parent.id)
assert parent.id in tree_view._state.opened
# Delete the child (making parent a leaf)
tree_view._delete_node(child.id)
# Now delete the parent (now a leaf node)
# First remove it from root by creating a grandparent
grandparent = TreeNode(label="Grandparent", type="folder")
tree_view.add_node(grandparent)
parent.parent = grandparent.id
grandparent.children.append(parent.id)
tree_view._delete_node(parent.id)
# Parent should be removed from opened list
assert parent.id not in tree_view._state.opened
def test_i_cannot_start_rename_nonexistent_node(self, root_instance):
"""Test that starting rename on nonexistent node raises error."""
tree_view = TreeView(root_instance)
# Try to start rename on node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._start_rename("nonexistent_id")
def test_i_cannot_save_rename_nonexistent_node(self, root_instance):
"""Test that saving rename for nonexistent node raises error."""
tree_view = TreeView(root_instance)
# Try to save rename for node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._save_rename("nonexistent_id", "New Name")
def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance):
"""Test that adding sibling to nonexistent node raises error."""
tree_view = TreeView(root_instance)
# Try to add sibling to node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._add_sibling("nonexistent_id")
class TestTreeViewRender:
"""Tests for TreeView HTML rendering."""
def test_empty_treeview_is_rendered(self, root_instance):
"""Test that TreeView generates correct HTML structure."""
tree_view = TreeView(root_instance)
expected = Div(
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
_id=tree_view.get_id(),
cls="mf-treeview"
)
assert matches(tree_view.__ft__(), expected)
def test_node_action_buttons_are_rendered(self):
"""Test that action buttons are present in rendered HTML."""
# Signature only - implementation later
pass

View File

@@ -3,361 +3,440 @@ from fastcore.basics import NotStr
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \ from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
@pytest.mark.parametrize('actual, expected', [ class Dummy:
(None, None), def __init__(self, attr1, attr2=None):
(123, 123), self.attr1 = attr1
(Div(), Div()), self.attr2 = attr2
([Div(), Span()], [Div(), Span()]),
(Div(attr1="value"), Div(attr1="value")),
(Div(attr1="value", attr2="value"), Div(attr1="value")),
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
(Div(attr1="value"), Div(attr1=AnyValue())),
(None, DoNotCheck()),
(123, DoNotCheck()),
(Div(), DoNotCheck()),
([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())),
(Div(), Div(NoChildren())),
(Div(attr1="value"), Div(NoChildren())),
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
(Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))),
(Div(Span(123)), Div(DoNotCheck())),
])
def test_i_can_match(actual, expected):
assert matches(actual, expected)
@pytest.mark.parametrize('actual, expected, error_message', [ class Dummy2:
(None, Div(), "Actual is None"), def __init__(self, attr1, attr2):
(Div(), None, "Actual is not None"), self.attr1 = attr1
(123, Div(), "The types are different"), self.attr2 = attr2
(123, 124, "The values are different"),
([Div(), Span()], [], "Actual is bigger than expected"),
([], [Div(), Span()], "Actual is smaller than expected"),
("not a list", [Div(), Span()], "The types are different"),
([Div(), Span()], [Div(), 123], "The types are different"),
(Div(), Span(), "The elements are different"),
([Div(), Span()], [Div(), Div()], "The elements are different"),
(Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"),
(Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"),
(Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
(Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"),
(Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(), Div(Span()), "Actual is lesser than expected"),
(Div(), Div(123), "Actual is lesser than expected"),
(Div(Span()), Div(Div()), "The elements are different"),
(Div(123), Div(Div()), "The types are different"),
(Div(123), Div(456), "The values are different"),
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
])
def test_i_can_detect_errors(actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info:
matches(actual, expected)
assert error_message in str(exc_info.value)
@pytest.mark.parametrize('element, expected_path', [ class TestMatches:
(Div(), "Path : 'div"),
(Div(Span()), "Path : 'div.span"),
(Div(Span(Div())), "Path : 'div.span.div"),
(Div(id="div_id"), "Path : 'div#div_id"),
(Div(cls="div_class"), "Path : 'div[class=div_class]"),
(Div(name="div_class"), "Path : 'div[name=div_class]"),
(Div(attr="value"), "Path : 'div"),
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"),
])
def test_i_can_properly_show_path(element, expected_path):
def _construct_test_element(source, tail):
res = MyFT(source.tag, source.attrs)
if source.children:
res.children = [_construct_test_element(child, tail) for child in source.children]
else:
res.children = [tail]
return res
with pytest.raises(AssertionError) as exc_info: @pytest.mark.parametrize('actual, expected', [
actual = _construct_test_element(element, "Actual") (None, None),
expected = _construct_test_element(element, "Expected") (123, 123),
matches(actual, expected) (Div(), Div()),
([Div(), Span()], [Div(), Span()]),
({"key": Div(attr="value")}, {"key": Div(attr="value")}),
({"key": Dummy(attr1="value")}, {"key": TestObject(Dummy, attr1="value")}),
(Div(attr1="value"), Div(attr1="value")),
(Div(attr1="value", attr2="value"), Div(attr1="value")),
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
(Div(attr1="value"), Div(attr1=AnyValue())),
(None, DoNotCheck()),
(123, DoNotCheck()),
(Div(), DoNotCheck()),
([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())),
(Div(), Div(NoChildren())),
(Div(attr1="value"), Div(NoChildren())),
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
(Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))),
(Div(Span(123)), Div(DoNotCheck())),
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value")),
(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")),
])
def test_i_can_match(self, actual, expected):
assert matches(actual, expected)
assert expected_path in str(exc_info.value) @pytest.mark.parametrize('actual, expected, error_message', [
(None, Div(), "Actual is None"),
(Div(), None, "Actual is not None"),
def test_i_can_output_error_path(): (123, Div(), "The types are different"),
elt = Div() (123, 124, "The values are different"),
expected = Div() ([Div(), Span()], [], "Actual is bigger than expected"),
path = "div#div_id.div.span[class=span_class].p[name=p_name].div" ([], [Div(), Span()], "Actual is smaller than expected"),
error_output = ErrorOutput(path, elt, expected) ("not a list", [Div(), Span()], "The types are different"),
error_output.compute() ([Div(), Span()], [Div(), 123], "The types are different"),
assert error_output.output == ['(div "id"="div_id" ...', (Div(), Span(), "The types are different"),
' (div ...', ([Div(), Span()], [Div(), Div()], "The types are different"),
' (span "class"="span_class" ...', (Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
' (p "name"="p_name" ...', (Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"),
' (div )'] (Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"),
(Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"),
(Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
def test_i_can_output_error_attribute(): (Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
elt = Div(attr1="value1", attr2="value2") (Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
expected = elt (Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
path = "" (NotStr("456"), NotStr("123"), "Notstr values are different"),
error_output = ErrorOutput(path, elt, expected) (Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
error_output.compute() (Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"),
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")'] (Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(), Div(Span()), "Actual is lesser than expected"),
def test_i_can_output_error_attribute_missing_1(): (Div(), Div(123), "Actual is lesser than expected"),
elt = Div(attr2="value2") (Div(Span()), Div(Div()), "The types are different"),
expected = Div(attr1="value1", attr2="value2") (Div(123), Div(Div()), "The types are different"),
path = "" (Div(123), Div(456), "The values are different"),
error_output = ErrorOutput(path, elt, expected) (Div(Span(), Span()), Div(Span(), Div()), "The types are different"),
error_output.compute() (Div(Span(Div())), Div(Span(Span())), "The types are different"),
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")', (Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
' ^^^^^^^^^^^^^^^^^^^^^^^ '] (Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different"),
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr3="value3"), "'attr3' is not found in Actual"),
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The values are different for 'attr2'"),
def test_i_can_output_error_attribute_missing_2(): (Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different"),
elt = Div(attr1="value1") (Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
expected = Div(attr1="value1", attr2="value2") (Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
path = "" (Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different"),
error_output = ErrorOutput(path, elt, expected) (Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
error_output.compute()
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
' ^^^^^^^^^^^^^^^^^^^^^^^']
def test_i_can_output_error_attribute_wrong_value():
elt = Div(attr1="value3", attr2="value2")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
' ^^^^^^^^^^^^^^^^ ']
def test_i_can_output_error_constant():
elt = 123
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123']
def test_i_can_output_error_constant_wrong_value():
elt = 123
expected = 456
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123',
'^^^']
def test_i_can_output_error_when_predicate():
elt = "before value after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before value after"]
def test_i_can_output_error_when_predicate_wrong_value():
"""I can display error when the condition predicate is not satisfied."""
elt = "before after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before after",
"^^^^^^^^^^^^"]
def test_i_can_output_error_child_element():
"""I can display error when the element has children"""
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_text():
"""I can display error when the children is not a FT"""
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' "Hello world"',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_indicating_sub_children():
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1" ...)',
')',
]
def test_i_can_output_error_child_element_wrong_value():
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_2")',
' ^^^^^^^^^^^^^^',
')',
]
def test_i_can_output_error_fewer_elements():
elt = Div(P(id="p_id"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' ! ** MISSING ** !',
')',
]
def test_i_can_output_comparison():
actual = Div(P(id="p_id"), attr1="value1")
expected = actual
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out) ])
def test_i_can_detect_errors(self, actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info:
matches(actual, expected)
assert error_message in str(exc_info.value)
res = comparison_out.render() @pytest.mark.parametrize('element, expected_path', [
(Div(), "Path : 'div"),
(Div(Span()), "Path : 'div.span"),
(Div(Span(Div())), "Path : 'div.span.div"),
(Div(id="div_id"), "Path : 'div#div_id"),
(Div(cls="div_class"), "Path : 'div[class=div_class]"),
(Div(name="div_class"), "Path : 'div[name=div_class]"),
(Div(attr="value"), "Path : 'div"),
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"),
])
def test_i_can_properly_show_path(self, element, expected_path):
def _construct_test_element(source, tail):
res = MyFT(source.tag, source.attrs)
if source.children:
res.children = [_construct_test_element(child, tail) for child in source.children]
else:
res.children = [tail]
return res
with pytest.raises(AssertionError) as exc_info:
actual = _construct_test_element(element, "Actual")
expected = _construct_test_element(element, "Expected")
matches(actual, expected)
assert expected_path in str(exc_info.value)
class TestErrorOutput:
def test_i_can_output_error_path(self):
"""The output follows the representation of the given path"""
elt = Div()
expected = Div()
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "id"="div_id" ...',
' (div ...',
' (span "class"="span_class" ...',
' (p "name"="p_name" ...',
' (div )']
assert "\n" + res == ''' def test_i_can_output_error_attribute(self):
elt = Div(attr1="value1", attr2="value2")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
def test_i_can_output_error_attribute_missing_1(self):
elt = Div(attr2="value2")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
def test_i_can_output_error_attribute_missing_2(self):
elt = Div(attr1="value1")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
' ^^^^^^^^^^^^^^^^^^^^^^^']
def test_i_can_output_error_attribute_wrong_value(self):
elt = Div(attr1="value3", attr2="value2")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
' ^^^^^^^^^^^^^^^^ ']
def test_i_can_output_error_constant(self):
elt = 123
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123']
def test_i_can_output_error_constant_wrong_value(self):
elt = 123
expected = 456
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123',
'^^^']
def test_i_can_output_error_when_predicate(self):
elt = "before value after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before value after"]
def test_i_can_output_error_when_predicate_wrong_value(self):
"""I can display error when the condition predicate is not satisfied."""
elt = "before after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before after",
"^^^^^^^^^^^^"]
def test_i_can_output_error_child_element(self):
"""I can display error when the element has children"""
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_text(self):
"""I can display error when the children is not a FT"""
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' "Hello world"',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_indicating_sub_children(self):
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1" ...)',
')',
]
def test_i_can_output_error_child_element_wrong_value(self):
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_2")',
' ^^^^^^^^^^^^^^',
')',
]
def test_i_can_output_error_fewer_elements(self):
elt = Div(P(id="p_id"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' ! ** MISSING ** !',
')',
]
def test_i_can_output_error_test_object(self):
elt = TestObject(Dummy, attr1=123, attr2="value2")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(Dummy "attr1"="123" "attr2"="value2")']
def test_i_can_output_error_test_object_wrong_type(self):
elt = Div(attr1=123, attr2="value2")
expected = TestObject(Dummy, attr1=123, attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == [
'(div "attr1"="123" "attr2"="value2")',
' ^^^ '
]
def test_i_can_output_error_test_object_wrong_type_2(self):
elt = Dummy2(attr1=123, attr2="value2")
expected = TestObject(Dummy, attr1=123, attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == [
'(Dummy2 "attr1"="123" "attr2"="value2")',
' ^^^^^^ '
]
def test_i_can_output_error_test_object_wrong_type_3(self):
elt = Div(attr1=123, attr2="value2")
expected = TestObject("Dummy", attr1=123, attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == [
'(div "attr1"="123" "attr2"="value2")',
' ^^^ '
]
def test_i_can_output_error_test_object_wrong_value(self):
elt = Dummy(attr1="456", attr2="value2")
expected = TestObject(Dummy, attr1="123", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == [
'(Dummy "attr1"="456" "attr2"="value2")',
' ^^^^^^^^^^^^^ '
]
class TestErrorComparisonOutput:
def test_i_can_output_comparison(self):
actual = Div(P(id="p_id"), attr1="value1")
expected = actual
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="value1" | (div "attr1"="value1" (div "attr1"="value1" | (div "attr1"="value1"
(p "id"="p_id") | (p "id"="p_id") (p "id"="p_id") | (p "id"="p_id")
) | )''' ) | )'''
def test_i_can_output_comparison_with_path():
actual = Div(P(id="p_id"), attr1="value1")
expected = actual
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out) def test_i_can_output_comparison_with_path(self):
actual = Div(P(id="p_id"), attr1="value1")
res = comparison_out.render() expected = actual
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
assert "\n" + res == ''' expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "id"="div_id" ... | (div "id"="div_id" ... (div "id"="div_id" ... | (div "id"="div_id" ...
(span "class"="cls" ... | (span "class"="cls" ... (span "class"="cls" ... | (span "class"="cls" ...
(div "attr1"="value1" | (div "attr1"="value1" (div "attr1"="value1" | (div "attr1"="value1"
(p "id"="p_id") | (p "id"="p_id") (p "id"="p_id") | (p "id"="p_id")
) | )''' ) | )'''
def test_i_can_output_comparison_when_missing_attributes():
actual = Div(P(id="p_id"), attr1="value1")
expected = Div(P(id="p_id"), attr2="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out) def test_i_can_output_comparison_when_missing_attributes(self):
actual = Div(P(id="p_id"), attr1="value1")
res = comparison_out.render() expected = Div(P(id="p_id"), attr2="value1")
actual_out = ErrorOutput("", actual, expected)
assert "\n" + res == ''' expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr2"="** MISSING **" | (div "attr2"="value1" (div "attr2"="** MISSING **" | (div "attr2"="value1"
^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^ |
(p "id"="p_id") | (p "id"="p_id") (p "id"="p_id") | (p "id"="p_id")
) | )''' ) | )'''
def test_i_can_output_comparison_when_wrong_attributes():
actual = Div(P(id="p_id"), attr1="value2")
expected = Div(P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out) def test_i_can_output_comparison_when_wrong_attributes(self):
actual = Div(P(id="p_id"), attr1="value2")
res = comparison_out.render() expected = Div(P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
assert "\n" + res == ''' expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="value2" | (div "attr1"="value1" (div "attr1"="value2" | (div "attr1"="value1"
^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^ |
(p "id"="p_id") | (p "id"="p_id") (p "id"="p_id") | (p "id"="p_id")
) | )''' ) | )'''
def test_i_can_output_comparison_when_fewer_elements():
actual = Div(P(id="p_id"), attr1="value1")
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out) def test_i_can_output_comparison_when_fewer_elements(self):
actual = Div(P(id="p_id"), attr1="value1")
res = comparison_out.render() expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
assert "\n" + res == ''' expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="value1" | (div "attr1"="value1" (div "attr1"="value1" | (div "attr1"="value1"
(p "id"="p_id") | (span "id"="s_id") (p "id"="p_id") | (span "id"="s_id")
^ ^^^^^^^^^^^ | ^ ^^^^^^^^^^^ |
! ** MISSING ** ! | (p "id"="p_id") ! ** MISSING ** ! | (p "id"="p_id")
) | )''' ) | )'''
def test_i_can_see_the_diff_when_matching():
actual = Div(attr1="value1")
expected = Div(attr1=Contains("value2"))
with pytest.raises(AssertionError) as exc_info: def test_i_can_see_the_diff_when_matching(self):
matches(actual, expected) actual = Div(attr1="value1")
expected = Div(attr1=Contains("value2"))
debug_output = str(exc_info.value)
assert "\n" + debug_output == """ with pytest.raises(AssertionError) as exc_info:
matches(actual, expected)
debug_output = str(exc_info.value)
assert "\n" + debug_output == """
Path : 'div' Path : 'div'
Error : The condition 'Contains(value2)' is not satisfied. Error : The condition 'Contains(value2)' is not satisfied.
(div "attr1"="value1") | (div "attr1"="Contains(value2)") (div "attr1"="value1") | (div "attr1"="Contains(value2)")
^^^^^^^^^^^^^^^^ |""" ^^^^^^^^^^^^^^^^ |"""
def test_i_can_see_the_diff_with_test_object_when_wrong_type(self):
actual = Div(attr1=123, attr2="value2")
expected = TestObject(Dummy, attr1=123, attr2="value2")
actual_out = ErrorOutput("dummy", actual, expected)
expected_out = ErrorOutput("div", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
^^^ |'''