Working on TreeView. Enhanced matches capabilities
This commit is contained in:
@@ -2,86 +2,235 @@
|
||||
|
||||
You are now in **Developer Mode** - the standard mode for writing code in the MyFastHtml project.
|
||||
|
||||
## Development Process
|
||||
## Primary Objective
|
||||
|
||||
**Code must always be testable**. Before writing any code:
|
||||
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
|
||||
|
||||
## Collaboration Style
|
||||
**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
|
||||
|
||||
## Communication
|
||||
### DEV-3: Communication Standards
|
||||
|
||||
**Conversations**: French or English (match user's language)
|
||||
**Code, documentation, comments**: English only
|
||||
|
||||
## Code Standards
|
||||
### 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
|
||||
|
||||
## Dependency Management
|
||||
### 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
|
||||
|
||||
## Unit Testing with pytest
|
||||
### 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"
|
||||
"""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)
|
||||
"""Test that creating a command with empty name raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
Command("", "description", lambda: None)
|
||||
```
|
||||
|
||||
## File Management
|
||||
### 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
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
### 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.
|
||||
@@ -89,4 +238,5 @@ 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
|
||||
|
||||
@@ -15,10 +15,12 @@ Write comprehensive unit tests for existing code by:
|
||||
### UTR-1: Test Analysis Before Implementation
|
||||
|
||||
Before writing any tests:
|
||||
1. **Analyze the code thoroughly** - Read and understand the implementation
|
||||
2. **Identify all test scenarios** - List both success and failure cases
|
||||
3. **Present test plan** - Describe what each test will verify
|
||||
4. **Wait for validation** - Only proceed after explicit approval
|
||||
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
|
||||
|
||||
@@ -200,11 +202,14 @@ class TestControlRender:
|
||||
### UTR-11: Test Workflow
|
||||
|
||||
1. **Receive code to test** - User provides file path or code section
|
||||
2. **Analyze code** - Read and understand implementation
|
||||
3. **Propose test plan** - List all tests with brief explanations
|
||||
4. **Wait for approval** - User validates the test plan
|
||||
5. **Implement tests** - Write all approved tests
|
||||
6. **Verify** - Ensure tests follow naming conventions and structure
|
||||
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
|
||||
|
||||
|
||||
@@ -465,4 +465,83 @@
|
||||
.mf-dropdown.is-visible {
|
||||
display: block;
|
||||
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;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class Dropdown(MultipleInstance):
|
||||
self._mk_content(),
|
||||
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()),
|
||||
id=self._id
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from myfasthtml.core.instances import 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)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
|
||||
@@ -8,388 +8,393 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.components import Div, Input, Span, Button
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
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.
|
||||
"""
|
||||
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] = {}
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def cancel_rename(self):
|
||||
"""Create command to cancel renaming."""
|
||||
return Command(
|
||||
"CancelRename",
|
||||
"Cancel rename",
|
||||
self._owner._cancel_rename
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
"""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):
|
||||
"""
|
||||
Interactive TreeView component with hierarchical data visualization.
|
||||
Initialize TreeView component.
|
||||
|
||||
Supports:
|
||||
- Expand/collapse nodes
|
||||
- Add child/sibling nodes
|
||||
- Inline rename
|
||||
- Delete nodes
|
||||
- Node selection
|
||||
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.
|
||||
|
||||
def __init__(self, parent, items: Optional[dict] = None, _id: Optional[str] = None):
|
||||
"""
|
||||
Initialize TreeView component.
|
||||
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:
|
||||
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):
|
||||
"""
|
||||
Add a node to the tree.
|
||||
|
||||
Args:
|
||||
node: TreeNode instance to add
|
||||
parent_id: Optional parent node ID (None for root)
|
||||
"""
|
||||
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:
|
||||
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)
|
||||
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:
|
||||
self._state.opened.append(node_id)
|
||||
return self.render()
|
||||
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.
|
||||
|
||||
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")
|
||||
Args:
|
||||
node_id: ID of node to render
|
||||
level: Indentation level
|
||||
|
||||
parent = self._state.items[parent_id]
|
||||
new_node = TreeNode(
|
||||
label=new_label or "New Node",
|
||||
type=parent.type,
|
||||
parent=parent_id
|
||||
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.
|
||||
|
||||
self._state.items[new_node.id] = new_node
|
||||
parent.children.append(new_node.id)
|
||||
|
||||
# Auto-expand parent
|
||||
if parent_id not in self._state.opened:
|
||||
self._state.opened.append(parent_id)
|
||||
|
||||
return self.render()
|
||||
|
||||
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,
|
||||
parent=node.parent
|
||||
)
|
||||
|
||||
self._state.items[new_node.id] = new_node
|
||||
|
||||
# Insert after current node
|
||||
idx = parent.children.index(node_id)
|
||||
parent.children.insert(idx + 1, new_node.id)
|
||||
|
||||
return self.render()
|
||||
|
||||
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.render()
|
||||
|
||||
def _save_rename(self, node_id: str, new_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 = new_label
|
||||
self._state.editing = None
|
||||
return self.render()
|
||||
|
||||
def _cancel_rename(self):
|
||||
"""Cancel renaming operation."""
|
||||
self._state.editing = None
|
||||
return self.render()
|
||||
|
||||
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.render()
|
||||
|
||||
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.render()
|
||||
|
||||
def _render_action_buttons(self, node_id: str):
|
||||
"""Render action buttons for a node (visible on hover)."""
|
||||
return Div(
|
||||
Button("+child", data_action="add_child"),
|
||||
Button("+sibling", data_action="add_sibling"),
|
||||
Button("rename", data_action="rename"),
|
||||
Button("delete", data_action="delete"),
|
||||
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 = Span(
|
||||
"▼" if is_expanded else "▶" if has_children else " ",
|
||||
cls="mf-treenode-toggle"
|
||||
)
|
||||
|
||||
# Label or input for editing
|
||||
if is_editing:
|
||||
label_element = Input(
|
||||
value=node.label,
|
||||
cls="mf-treenode-input"
|
||||
)
|
||||
else:
|
||||
label_element = Span(
|
||||
node.label,
|
||||
cls="mf-treenode-label"
|
||||
)
|
||||
|
||||
# Node element
|
||||
node_element = Div(
|
||||
toggle,
|
||||
label_element,
|
||||
self._render_action_buttons(node_id),
|
||||
cls=f"mf-treenode {'selected' if is_selected else ''}",
|
||||
data_node_id=node_id,
|
||||
data_level=str(level)
|
||||
)
|
||||
|
||||
# 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],
|
||||
id=self._id,
|
||||
cls="mf-treeview"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering."""
|
||||
return self.render()
|
||||
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()
|
||||
|
||||
@@ -15,6 +15,19 @@ class mk:
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
@@ -33,13 +46,33 @@ class mk:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def icon(icon, size=20,
|
||||
def icon(icon,
|
||||
size=20,
|
||||
can_select=True,
|
||||
can_hover=False,
|
||||
cls='',
|
||||
command: Command = None,
|
||||
binding: Binding = None,
|
||||
**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}",
|
||||
'icon-btn' if can_select else '',
|
||||
'mmt-btn' if can_hover else '',
|
||||
|
||||
@@ -6,6 +6,8 @@ from fastcore.basics import NotStr
|
||||
from myfasthtml.core.utils import quoted_str
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
MISSING_ATTR = "** MISSING **"
|
||||
|
||||
|
||||
class Predicate:
|
||||
def __init__(self, value):
|
||||
@@ -114,6 +116,18 @@ class AttributeForbidden(ChildrenPredicate):
|
||||
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
|
||||
class DoNotCheck:
|
||||
desc: str = None
|
||||
@@ -187,6 +201,16 @@ class ErrorOutput:
|
||||
|
||||
self.indent = self.indent[:-2]
|
||||
self._add_to_output(")")
|
||||
|
||||
elif isinstance(self.expected, TestObject):
|
||||
cls = _mytype(self.element)
|
||||
attrs = {attr_name: _mygetattr(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_2(self.element, self.expected)
|
||||
if error_str:
|
||||
self._add_to_output(error_str)
|
||||
|
||||
else:
|
||||
self._add_to_output(str(self.element))
|
||||
# Try to show where the differences are
|
||||
@@ -205,7 +229,7 @@ class ErrorOutput:
|
||||
|
||||
if hasattr(element, "tag"):
|
||||
# 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]}
|
||||
|
||||
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
|
||||
@@ -228,7 +252,7 @@ class ErrorOutput:
|
||||
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 "^")
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs}
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in expected.attrs}
|
||||
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
|
||||
not self._matches(attr_value, expected.attrs[attr_name])]
|
||||
if attrs_in_error:
|
||||
@@ -242,6 +266,35 @@ class ErrorOutput:
|
||||
|
||||
return None
|
||||
|
||||
def _detect_error_2(self, element, expected):
|
||||
"""
|
||||
Too lazy to refactor original _detect_error
|
||||
:param element:
|
||||
:param expected:
|
||||
:return:
|
||||
"""
|
||||
if hasattr(expected, "tag") or isinstance(expected, TestObject):
|
||||
element_cls = _mytype(element)
|
||||
expected_cls = _mytype(expected)
|
||||
str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls)
|
||||
|
||||
element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs}
|
||||
expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs}
|
||||
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
|
||||
not self._matches(attr_value, expected_attrs[attr_name])}
|
||||
str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in element_attrs.items())
|
||||
if str_attrs_error.strip() or str_tag_error.strip():
|
||||
return f" {str_tag_error} {str_attrs_error}"
|
||||
else:
|
||||
return None
|
||||
|
||||
else:
|
||||
if not self._matches(element, expected):
|
||||
return len(str(element)) * "^"
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _matches(element, expected):
|
||||
if element == expected:
|
||||
@@ -347,6 +400,20 @@ def matches(actual, expected, path=""):
|
||||
# set the path
|
||||
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
|
||||
|
||||
if isinstance(expected, TestObject):
|
||||
assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
for attr, value in expected.attrs.items():
|
||||
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
assert matches(getattr(actual, attr), value), _error_msg(f"The values are different for '{attr}': ",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
return True
|
||||
|
||||
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
||||
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
||||
|
||||
@@ -359,6 +426,14 @@ def matches(actual, expected, path=""):
|
||||
for actual_child, expected_child in zip(actual, expected):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
elif isinstance(expected, dict):
|
||||
if len(actual) < len(expected):
|
||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
for k, v in expected.items():
|
||||
assert matches(actual[k], v, path=f"{path}[{k}={v}]")
|
||||
|
||||
elif isinstance(expected, NotStr):
|
||||
to_compare = actual.s.lstrip('\n').lstrip()
|
||||
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
||||
@@ -404,6 +479,7 @@ def matches(actual, expected, path=""):
|
||||
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,
|
||||
@@ -466,3 +542,25 @@ def find(ft, expected):
|
||||
raise AssertionError(f"No element found for '{expected}'")
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _mytype(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 _mygetattr(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 _str_attrs(attrs: dict):
|
||||
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -26,24 +29,24 @@ class TestTreeviewBehaviour:
|
||||
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)
|
||||
@@ -240,15 +243,154 @@ class TestTreeviewBehaviour:
|
||||
# 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_treeview_renders_correctly(self):
|
||||
def test_i_can_render_empty_treeview(self, root_instance):
|
||||
"""Test that TreeView generates correct HTML structure."""
|
||||
# Signature only - implementation later
|
||||
pass
|
||||
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."""
|
||||
|
||||
@@ -3,361 +3,439 @@ from fastcore.basics import NotStr
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
|
||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren
|
||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
|
||||
@pytest.mark.parametrize('actual, expected', [
|
||||
(None, None),
|
||||
(123, 123),
|
||||
(Div(), Div()),
|
||||
([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)
|
||||
class Dummy:
|
||||
def __init__(self, attr1, attr2=None):
|
||||
self.attr1 = attr1
|
||||
self.attr2 = attr2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('actual, expected, error_message', [
|
||||
(None, Div(), "Actual is None"),
|
||||
(Div(), None, "Actual is not None"),
|
||||
(123, Div(), "The types are different"),
|
||||
(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)
|
||||
class Dummy2:
|
||||
def __init__(self, attr1, attr2):
|
||||
self.attr1 = attr1
|
||||
self.attr2 = attr2
|
||||
|
||||
|
||||
@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(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
|
||||
class TestMatches:
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
actual = _construct_test_element(element, "Actual")
|
||||
expected = _construct_test_element(element, "Expected")
|
||||
matches(actual, expected)
|
||||
@pytest.mark.parametrize('actual, expected', [
|
||||
(None, None),
|
||||
(123, 123),
|
||||
(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)
|
||||
|
||||
|
||||
def test_i_can_output_error_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 )']
|
||||
|
||||
|
||||
def test_i_can_output_error_attribute():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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)
|
||||
@pytest.mark.parametrize('actual, expected, error_message', [
|
||||
(None, Div(), "Actual is None"),
|
||||
(Div(), None, "Actual is not None"),
|
||||
(123, Div(), "The types are different"),
|
||||
(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"),
|
||||
(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'"),
|
||||
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different:"),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
|
||||
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different:"),
|
||||
|
||||
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"
|
||||
(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)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
def test_i_can_output_comparison_with_path(self):
|
||||
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)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "id"="div_id" ... | (div "id"="div_id" ...
|
||||
(span "class"="cls" ... | (span "class"="cls" ...
|
||||
(div "attr1"="value1" | (div "attr1"="value1"
|
||||
(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)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
def test_i_can_output_comparison_when_missing_attributes(self):
|
||||
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)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "attr2"="** MISSING **" | (div "attr2"="value1"
|
||||
^^^^^^^^^^^^^^^^^^^^^^^ |
|
||||
(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)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
def test_i_can_output_comparison_when_wrong_attributes(self):
|
||||
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)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "attr1"="value2" | (div "attr1"="value1"
|
||||
^^^^^^^^^^^^^^^^ |
|
||||
(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)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
def test_i_can_output_comparison_when_fewer_elements(self):
|
||||
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)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "attr1"="value1" | (div "attr1"="value1"
|
||||
(p "id"="p_id") | (span "id"="s_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:
|
||||
matches(actual, expected)
|
||||
|
||||
debug_output = str(exc_info.value)
|
||||
assert "\n" + debug_output == """
|
||||
def test_i_can_see_the_diff_when_matching(self):
|
||||
actual = Div(attr1="value1")
|
||||
expected = Div(attr1=Contains("value2"))
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
matches(actual, expected)
|
||||
|
||||
debug_output = str(exc_info.value)
|
||||
assert "\n" + debug_output == """
|
||||
Path : 'div'
|
||||
Error : The condition 'Contains(value2)' is not satisfied.
|
||||
(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")
|
||||
^^^ |'''
|
||||
|
||||
Reference in New Issue
Block a user