Added TreeView and Panel
This commit is contained in:
242
.claude/commands/developer.md
Normal file
242
.claude/commands/developer.md
Normal 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
13
.claude/commands/reset.md
Normal 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
|
||||||
64
.claude/commands/technical-writer.md
Normal file
64
.claude/commands/technical-writer.md
Normal 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
|
||||||
230
.claude/commands/unit-tester.md
Normal file
230
.claude/commands/unit-tester.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# 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: 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
|
||||||
30
CLAUDE.md
30
CLAUDE.md
@@ -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
|
||||||
|
|||||||
1
Makefile
1
Makefile
@@ -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
|
||||||
|
|||||||
54
src/app.py
54
src/app.py
@@ -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())
|
||||||
|
|
||||||
@@ -63,12 +111,16 @@ 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")))
|
||||||
|
|||||||
@@ -466,3 +466,209 @@
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
102
src/myfasthtml/controls/Panel.py
Normal file
102
src/myfasthtml/controls/Panel.py
Normal 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()
|
||||||
400
src/myfasthtml/controls/TreeView.py
Normal file
400
src/myfasthtml/controls/TreeView.py
Normal 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()
|
||||||
@@ -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 '',
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,6 +116,18 @@ 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
|
||||||
@@ -187,6 +201,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 = _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:
|
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 +229,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())
|
||||||
@@ -228,7 +252,7 @@ class ErrorOutput:
|
|||||||
def _detect_error(self, element, expected):
|
def _detect_error(self, element, expected):
|
||||||
if hasattr(expected, "tag") and hasattr(element, "tag"):
|
if hasattr(expected, "tag") and hasattr(element, "tag"):
|
||||||
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
|
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
|
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
|
||||||
not self._matches(attr_value, expected.attrs[attr_name])]
|
not self._matches(attr_value, expected.attrs[attr_name])]
|
||||||
if attrs_in_error:
|
if attrs_in_error:
|
||||||
@@ -242,6 +266,35 @@ class ErrorOutput:
|
|||||||
|
|
||||||
return None
|
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
|
@staticmethod
|
||||||
def _matches(element, expected):
|
def _matches(element, expected):
|
||||||
if element == expected:
|
if element == expected:
|
||||||
@@ -347,6 +400,34 @@ def matches(actual, expected, path=""):
|
|||||||
# set the path
|
# set the path
|
||||||
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
|
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)
|
||||||
|
try:
|
||||||
|
matches(getattr(actual, attr), value)
|
||||||
|
except AssertionError as e:
|
||||||
|
match = re.search(r"Error : (.+?)\n", str(e))
|
||||||
|
if match:
|
||||||
|
assert False, _error_msg(f"{match.group(1)} for '{attr}':",
|
||||||
|
_actual=getattr(actual, attr),
|
||||||
|
_expected=value)
|
||||||
|
assert False, _error_msg(f"The values are different for '{attr}': ",
|
||||||
|
_actual=getattr(actual, attr),
|
||||||
|
_expected=value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(expected, Predicate):
|
||||||
|
assert expected.validate(actual), \
|
||||||
|
_error_msg(f"The condition '{expected}' is not satisfied.",
|
||||||
|
_actual=actual,
|
||||||
|
_expected=expected)
|
||||||
|
|
||||||
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
||||||
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
||||||
|
|
||||||
@@ -359,6 +440,14 @@ def matches(actual, expected, path=""):
|
|||||||
for actual_child, expected_child in zip(actual, expected):
|
for actual_child, expected_child in zip(actual, expected):
|
||||||
assert matches(actual_child, expected_child, path=path)
|
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):
|
elif isinstance(expected, NotStr):
|
||||||
to_compare = actual.s.lstrip('\n').lstrip()
|
to_compare = actual.s.lstrip('\n').lstrip()
|
||||||
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
||||||
@@ -404,8 +493,9 @@ def matches(actual, expected, path=""):
|
|||||||
for actual_child, expected_child in zip(actual.children, expected_children):
|
for actual_child, expected_child in zip(actual.children, expected_children):
|
||||||
assert matches(actual_child, expected_child, path=path)
|
assert matches(actual_child, expected_child, path=path)
|
||||||
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
assert actual == expected, _error_msg("The values are different: ",
|
assert actual == expected, _error_msg("The values are different",
|
||||||
_actual=actual,
|
_actual=actual,
|
||||||
_expected=expected)
|
_expected=expected)
|
||||||
|
|
||||||
@@ -466,3 +556,25 @@ def find(ft, expected):
|
|||||||
raise AssertionError(f"No element found for '{expected}'")
|
raise AssertionError(f"No element found for '{expected}'")
|
||||||
|
|
||||||
return res
|
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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
398
tests/controls/test_treeview.py
Normal file
398
tests/controls/test_treeview.py
Normal 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_i_can_render_empty_treeview(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
|
||||||
@@ -3,15 +3,31 @@ 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:
|
||||||
|
def __init__(self, attr1, attr2=None):
|
||||||
|
self.attr1 = attr1
|
||||||
|
self.attr2 = attr2
|
||||||
|
|
||||||
|
|
||||||
|
class Dummy2:
|
||||||
|
def __init__(self, attr1, attr2):
|
||||||
|
self.attr1 = attr1
|
||||||
|
self.attr2 = attr2
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatches:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('actual, expected', [
|
||||||
(None, None),
|
(None, None),
|
||||||
(123, 123),
|
(123, 123),
|
||||||
(Div(), Div()),
|
(Div(), Div()),
|
||||||
([Div(), Span()], [Div(), Span()]),
|
([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"), Div(attr1="value")),
|
||||||
(Div(attr1="value", attr2="value"), Div(attr1="value")),
|
(Div(attr1="value", attr2="value"), Div(attr1="value")),
|
||||||
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
|
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
|
||||||
@@ -30,12 +46,15 @@ from myfasthtml.test.testclient import MyFT
|
|||||||
(Div(123), Div(123)),
|
(Div(123), Div(123)),
|
||||||
(Div(Span(123)), Div(Span(123))),
|
(Div(Span(123)), Div(Span(123))),
|
||||||
(Div(Span(123)), Div(DoNotCheck())),
|
(Div(Span(123)), Div(DoNotCheck())),
|
||||||
])
|
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value")),
|
||||||
def test_i_can_match(actual, expected):
|
(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 matches(actual, expected)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('actual, expected, error_message', [
|
||||||
@pytest.mark.parametrize('actual, expected, error_message', [
|
|
||||||
(None, Div(), "Actual is None"),
|
(None, Div(), "Actual is None"),
|
||||||
(Div(), None, "Actual is not None"),
|
(Div(), None, "Actual is not None"),
|
||||||
(123, Div(), "The types are different"),
|
(123, Div(), "The types are different"),
|
||||||
@@ -67,14 +86,22 @@ def test_i_can_match(actual, expected):
|
|||||||
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
|
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
|
||||||
(Div(Span(Div())), Div(Span(Span())), "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(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
|
||||||
])
|
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different:"),
|
||||||
def test_i_can_detect_errors(actual, expected, error_message):
|
(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:"),
|
||||||
|
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
|
||||||
|
|
||||||
|
])
|
||||||
|
def test_i_can_detect_errors(self, actual, expected, error_message):
|
||||||
with pytest.raises(AssertionError) as exc_info:
|
with pytest.raises(AssertionError) as exc_info:
|
||||||
matches(actual, expected)
|
matches(actual, expected)
|
||||||
assert error_message in str(exc_info.value)
|
assert error_message in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('element, expected_path', [
|
||||||
@pytest.mark.parametrize('element, expected_path', [
|
|
||||||
(Div(), "Path : 'div"),
|
(Div(), "Path : 'div"),
|
||||||
(Div(Span()), "Path : 'div.span"),
|
(Div(Span()), "Path : 'div.span"),
|
||||||
(Div(Span(Div())), "Path : 'div.span.div"),
|
(Div(Span(Div())), "Path : 'div.span.div"),
|
||||||
@@ -83,8 +110,8 @@ def test_i_can_detect_errors(actual, expected, error_message):
|
|||||||
(Div(name="div_class"), "Path : 'div[name=div_class]"),
|
(Div(name="div_class"), "Path : 'div[name=div_class]"),
|
||||||
(Div(attr="value"), "Path : 'div"),
|
(Div(attr="value"), "Path : 'div"),
|
||||||
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].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 test_i_can_properly_show_path(self, element, expected_path):
|
||||||
def _construct_test_element(source, tail):
|
def _construct_test_element(source, tail):
|
||||||
res = MyFT(source.tag, source.attrs)
|
res = MyFT(source.tag, source.attrs)
|
||||||
if source.children:
|
if source.children:
|
||||||
@@ -101,7 +128,9 @@ def test_i_can_properly_show_path(element, expected_path):
|
|||||||
assert expected_path in str(exc_info.value)
|
assert expected_path in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_output_error_path():
|
class TestErrorOutput:
|
||||||
|
def test_i_can_output_error_path(self):
|
||||||
|
"""The output follows the representation of the given path"""
|
||||||
elt = Div()
|
elt = Div()
|
||||||
expected = Div()
|
expected = Div()
|
||||||
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
|
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
|
||||||
@@ -113,8 +142,7 @@ def test_i_can_output_error_path():
|
|||||||
' (p "name"="p_name" ...',
|
' (p "name"="p_name" ...',
|
||||||
' (div )']
|
' (div )']
|
||||||
|
|
||||||
|
def test_i_can_output_error_attribute(self):
|
||||||
def test_i_can_output_error_attribute():
|
|
||||||
elt = Div(attr1="value1", attr2="value2")
|
elt = Div(attr1="value1", attr2="value2")
|
||||||
expected = elt
|
expected = elt
|
||||||
path = ""
|
path = ""
|
||||||
@@ -122,8 +150,7 @@ def test_i_can_output_error_attribute():
|
|||||||
error_output.compute()
|
error_output.compute()
|
||||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
|
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
|
||||||
|
|
||||||
|
def test_i_can_output_error_attribute_missing_1(self):
|
||||||
def test_i_can_output_error_attribute_missing_1():
|
|
||||||
elt = Div(attr2="value2")
|
elt = Div(attr2="value2")
|
||||||
expected = Div(attr1="value1", attr2="value2")
|
expected = Div(attr1="value1", attr2="value2")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -132,8 +159,7 @@ def test_i_can_output_error_attribute_missing_1():
|
|||||||
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
|
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
|
||||||
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
|
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
|
||||||
|
|
||||||
|
def test_i_can_output_error_attribute_missing_2(self):
|
||||||
def test_i_can_output_error_attribute_missing_2():
|
|
||||||
elt = Div(attr1="value1")
|
elt = Div(attr1="value1")
|
||||||
expected = Div(attr1="value1", attr2="value2")
|
expected = Div(attr1="value1", attr2="value2")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -142,8 +168,7 @@ def test_i_can_output_error_attribute_missing_2():
|
|||||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
|
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
|
||||||
' ^^^^^^^^^^^^^^^^^^^^^^^']
|
' ^^^^^^^^^^^^^^^^^^^^^^^']
|
||||||
|
|
||||||
|
def test_i_can_output_error_attribute_wrong_value(self):
|
||||||
def test_i_can_output_error_attribute_wrong_value():
|
|
||||||
elt = Div(attr1="value3", attr2="value2")
|
elt = Div(attr1="value3", attr2="value2")
|
||||||
expected = Div(attr1="value1", attr2="value2")
|
expected = Div(attr1="value1", attr2="value2")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -152,8 +177,7 @@ def test_i_can_output_error_attribute_wrong_value():
|
|||||||
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
|
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
|
||||||
' ^^^^^^^^^^^^^^^^ ']
|
' ^^^^^^^^^^^^^^^^ ']
|
||||||
|
|
||||||
|
def test_i_can_output_error_constant(self):
|
||||||
def test_i_can_output_error_constant():
|
|
||||||
elt = 123
|
elt = 123
|
||||||
expected = elt
|
expected = elt
|
||||||
path = ""
|
path = ""
|
||||||
@@ -161,8 +185,7 @@ def test_i_can_output_error_constant():
|
|||||||
error_output.compute()
|
error_output.compute()
|
||||||
assert error_output.output == ['123']
|
assert error_output.output == ['123']
|
||||||
|
|
||||||
|
def test_i_can_output_error_constant_wrong_value(self):
|
||||||
def test_i_can_output_error_constant_wrong_value():
|
|
||||||
elt = 123
|
elt = 123
|
||||||
expected = 456
|
expected = 456
|
||||||
path = ""
|
path = ""
|
||||||
@@ -171,8 +194,7 @@ def test_i_can_output_error_constant_wrong_value():
|
|||||||
assert error_output.output == ['123',
|
assert error_output.output == ['123',
|
||||||
'^^^']
|
'^^^']
|
||||||
|
|
||||||
|
def test_i_can_output_error_when_predicate(self):
|
||||||
def test_i_can_output_error_when_predicate():
|
|
||||||
elt = "before value after"
|
elt = "before value after"
|
||||||
expected = Contains("value")
|
expected = Contains("value")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -180,8 +202,7 @@ def test_i_can_output_error_when_predicate():
|
|||||||
error_output.compute()
|
error_output.compute()
|
||||||
assert error_output.output == ["before value after"]
|
assert error_output.output == ["before value after"]
|
||||||
|
|
||||||
|
def test_i_can_output_error_when_predicate_wrong_value(self):
|
||||||
def test_i_can_output_error_when_predicate_wrong_value():
|
|
||||||
"""I can display error when the condition predicate is not satisfied."""
|
"""I can display error when the condition predicate is not satisfied."""
|
||||||
elt = "before after"
|
elt = "before after"
|
||||||
expected = Contains("value")
|
expected = Contains("value")
|
||||||
@@ -191,8 +212,7 @@ def test_i_can_output_error_when_predicate_wrong_value():
|
|||||||
assert error_output.output == ["before after",
|
assert error_output.output == ["before after",
|
||||||
"^^^^^^^^^^^^"]
|
"^^^^^^^^^^^^"]
|
||||||
|
|
||||||
|
def test_i_can_output_error_child_element(self):
|
||||||
def test_i_can_output_error_child_element():
|
|
||||||
"""I can display error when the element has children"""
|
"""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")
|
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||||
expected = elt
|
expected = elt
|
||||||
@@ -206,8 +226,7 @@ def test_i_can_output_error_child_element():
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_i_can_output_error_child_element_text(self):
|
||||||
def test_i_can_output_error_child_element_text():
|
|
||||||
"""I can display error when the children is not a FT"""
|
"""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")
|
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||||
expected = elt
|
expected = elt
|
||||||
@@ -221,8 +240,7 @@ def test_i_can_output_error_child_element_text():
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_i_can_output_error_child_element_indicating_sub_children(self):
|
||||||
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")
|
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
|
||||||
expected = elt
|
expected = elt
|
||||||
path = ""
|
path = ""
|
||||||
@@ -234,8 +252,7 @@ def test_i_can_output_error_child_element_indicating_sub_children():
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_i_can_output_error_child_element_wrong_value(self):
|
||||||
def test_i_can_output_error_child_element_wrong_value():
|
|
||||||
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
|
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
|
||||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -248,8 +265,7 @@ def test_i_can_output_error_child_element_wrong_value():
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_i_can_output_error_fewer_elements(self):
|
||||||
def test_i_can_output_error_fewer_elements():
|
|
||||||
elt = Div(P(id="p_id"), attr1="value1")
|
elt = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -261,8 +277,61 @@ def test_i_can_output_error_fewer_elements():
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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_comparison():
|
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")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = actual
|
expected = actual
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -277,8 +346,7 @@ def test_i_can_output_comparison():
|
|||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
|
def test_i_can_output_comparison_with_path(self):
|
||||||
def test_i_can_output_comparison_with_path():
|
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = actual
|
expected = actual
|
||||||
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
|
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
|
||||||
@@ -295,8 +363,7 @@ def test_i_can_output_comparison_with_path():
|
|||||||
(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(self):
|
||||||
def test_i_can_output_comparison_when_missing_attributes():
|
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = Div(P(id="p_id"), attr2="value1")
|
expected = Div(P(id="p_id"), attr2="value1")
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -312,8 +379,7 @@ def test_i_can_output_comparison_when_missing_attributes():
|
|||||||
(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(self):
|
||||||
def test_i_can_output_comparison_when_wrong_attributes():
|
|
||||||
actual = Div(P(id="p_id"), attr1="value2")
|
actual = Div(P(id="p_id"), attr1="value2")
|
||||||
expected = Div(P(id="p_id"), attr1="value1")
|
expected = Div(P(id="p_id"), attr1="value1")
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -329,8 +395,7 @@ def test_i_can_output_comparison_when_wrong_attributes():
|
|||||||
(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(self):
|
||||||
def test_i_can_output_comparison_when_fewer_elements():
|
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
|
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -347,8 +412,7 @@ def test_i_can_output_comparison_when_fewer_elements():
|
|||||||
! ** MISSING ** ! | (p "id"="p_id")
|
! ** MISSING ** ! | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
|
def test_i_can_see_the_diff_when_matching(self):
|
||||||
def test_i_can_see_the_diff_when_matching():
|
|
||||||
actual = Div(attr1="value1")
|
actual = Div(attr1="value1")
|
||||||
expected = Div(attr1=Contains("value2"))
|
expected = Div(attr1=Contains("value2"))
|
||||||
|
|
||||||
@@ -361,3 +425,18 @@ 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")
|
||||||
|
^^^ |'''
|
||||||
|
|||||||
Reference in New Issue
Block a user