2 Commits

Author SHA1 Message Date
659c4e1d4b Added Panel.py 2025-11-29 18:13:46 +01:00
3271aa0d61 Working on TreeView. Enhanced matches capabilities 2025-11-27 23:46:06 +01:00
15 changed files with 1595 additions and 750 deletions

View File

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

View File

@@ -15,10 +15,12 @@ Write comprehensive unit tests for existing code by:
### UTR-1: Test Analysis Before Implementation ### UTR-1: Test Analysis Before Implementation
Before writing any tests: Before writing any tests:
1. **Analyze the code thoroughly** - Read and understand the implementation 1. **Check for existing tests first** - Look for corresponding test file (e.g., `src/foo/bar.py``tests/foo/test_bar.py`)
2. **Identify all test scenarios** - List both success and failure cases 2. **Analyze the code thoroughly** - Read and understand the implementation
3. **Present test plan** - Describe what each test will verify 3. **If tests exist**: Identify what's already covered and what's missing
4. **Wait for validation** - Only proceed after explicit approval 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 ### UTR-2: Test Naming Conventions
@@ -200,11 +202,14 @@ class TestControlRender:
### UTR-11: Test Workflow ### UTR-11: Test Workflow
1. **Receive code to test** - User provides file path or code section 1. **Receive code to test** - User provides file path or code section
2. **Analyze code** - Read and understand implementation 2. **Check existing tests** - Look for corresponding test file and read it if it exists
3. **Propose test plan** - List all tests with brief explanations 3. **Analyze code** - Read and understand implementation
4. **Wait for approval** - User validates the test plan 4. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
5. **Implement tests** - Write all approved tests 5. **Propose test plan** - List new/missing tests with brief explanations
6. **Verify** - Ensure tests follow naming conventions and structure 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 ## Managing Rules

View File

@@ -14,6 +14,7 @@ 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
@@ -73,7 +74,7 @@ def create_sample_treeview(parent):
tree_view.add_node(todo, parent_id=documents.id) tree_view.add_node(todo, parent_id=documents.id)
# Expand all nodes to show the full structure # Expand all nodes to show the full structure
tree_view.expand_all() #tree_view.expand_all()
return tree_view return tree_view
@@ -98,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())

View File

@@ -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);
}

View File

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

View File

@@ -54,7 +54,7 @@ class Dropdown(MultipleInstance):
self._mk_content(), self._mk_content(),
cls="mf-dropdown-wrapper" cls="mf-dropdown-wrapper"
), ),
Keyboard(self, "-keyboard").add("esc", self.commands.close()), Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
Mouse(self, "-mouse").add("click", self.commands.click()), Mouse(self, "-mouse").add("click", self.commands.click()),
id=self._id id=self._id
) )

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,12 +8,16 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from fasthtml.components import Div, Input, Span, Button from fasthtml.components import Div, Input, Span
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance 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 @dataclass
@@ -67,7 +71,7 @@ class Commands(BaseCommands):
f"Toggle node {node_id}", f"Toggle node {node_id}",
self._owner._toggle_node, self._owner._toggle_node,
node_id node_id
) ).htmx(target=f"#{self._owner.get_id()}")
def add_child(self, parent_id: str): def add_child(self, parent_id: str):
"""Create command to add a child node.""" """Create command to add a child node."""
@@ -76,7 +80,7 @@ class Commands(BaseCommands):
f"Add child to {parent_id}", f"Add child to {parent_id}",
self._owner._add_child, self._owner._add_child,
parent_id parent_id
) ).htmx(target=f"#{self._owner.get_id()}")
def add_sibling(self, node_id: str): def add_sibling(self, node_id: str):
"""Create command to add a sibling node.""" """Create command to add a sibling node."""
@@ -85,7 +89,7 @@ class Commands(BaseCommands):
f"Add sibling to {node_id}", f"Add sibling to {node_id}",
self._owner._add_sibling, self._owner._add_sibling,
node_id node_id
) ).htmx(target=f"#{self._owner.get_id()}")
def start_rename(self, node_id: str): def start_rename(self, node_id: str):
"""Create command to start renaming a node.""" """Create command to start renaming a node."""
@@ -94,7 +98,7 @@ class Commands(BaseCommands):
f"Start renaming {node_id}", f"Start renaming {node_id}",
self._owner._start_rename, self._owner._start_rename,
node_id node_id
) ).htmx(target=f"#{self._owner.get_id()}")
def save_rename(self, node_id: str): def save_rename(self, node_id: str):
"""Create command to save renamed node.""" """Create command to save renamed node."""
@@ -103,7 +107,7 @@ class Commands(BaseCommands):
f"Save rename for {node_id}", f"Save rename for {node_id}",
self._owner._save_rename, self._owner._save_rename,
node_id node_id
) ).htmx(target=f"#{self._owner.get_id()}")
def cancel_rename(self): def cancel_rename(self):
"""Create command to cancel renaming.""" """Create command to cancel renaming."""
@@ -111,7 +115,7 @@ class Commands(BaseCommands):
"CancelRename", "CancelRename",
"Cancel rename", "Cancel rename",
self._owner._cancel_rename self._owner._cancel_rename
) ).htmx(target=f"#{self._owner.get_id()}")
def delete_node(self, node_id: str): def delete_node(self, node_id: str):
"""Create command to delete a node.""" """Create command to delete a node."""
@@ -120,7 +124,7 @@ class Commands(BaseCommands):
f"Delete node {node_id}", f"Delete node {node_id}",
self._owner._delete_node, self._owner._delete_node,
node_id node_id
) ).htmx(target=f"#{self._owner.get_id()}")
def select_node(self, node_id: str): def select_node(self, node_id: str):
"""Create command to select a node.""" """Create command to select a node."""
@@ -129,7 +133,7 @@ class Commands(BaseCommands):
f"Select node {node_id}", f"Select node {node_id}",
self._owner._select_node, self._owner._select_node,
node_id node_id
) ).htmx(target=f"#{self._owner.get_id()}")
class TreeView(MultipleInstance): class TreeView(MultipleInstance):
@@ -170,13 +174,15 @@ class TreeView(MultipleInstance):
""" """
self._state.icon_config = config self._state.icon_config = config
def add_node(self, node: TreeNode, parent_id: Optional[str] = None): def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None):
""" """
Add a node to the tree. Add a node to the tree.
Args: Args:
node: TreeNode instance to add node: TreeNode instance to add
parent_id: Optional parent node ID (None for root) 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 self._state.items[node.id] = node
node.parent = parent_id node.parent = parent_id
@@ -184,6 +190,9 @@ class TreeView(MultipleInstance):
if parent_id and parent_id in self._state.items: if parent_id and parent_id in self._state.items:
parent = self._state.items[parent_id] parent = self._state.items[parent_id]
if node.id not in parent.children: 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) parent.children.append(node.id)
def expand_all(self): def expand_all(self):
@@ -198,7 +207,7 @@ class TreeView(MultipleInstance):
self._state.opened.remove(node_id) self._state.opened.remove(node_id)
else: else:
self._state.opened.append(node_id) self._state.opened.append(node_id)
return self.render() return self
def _add_child(self, parent_id: str, new_label: Optional[str] = None): def _add_child(self, parent_id: str, new_label: Optional[str] = None):
"""Add a child node to a parent.""" """Add a child node to a parent."""
@@ -208,18 +217,16 @@ class TreeView(MultipleInstance):
parent = self._state.items[parent_id] parent = self._state.items[parent_id]
new_node = TreeNode( new_node = TreeNode(
label=new_label or "New Node", label=new_label or "New Node",
type=parent.type, type=parent.type
parent=parent_id
) )
self._state.items[new_node.id] = new_node self.add_node(new_node, parent_id=parent_id)
parent.children.append(new_node.id)
# Auto-expand parent # Auto-expand parent
if parent_id not in self._state.opened: if parent_id not in self._state.opened:
self._state.opened.append(parent_id) self._state.opened.append(parent_id)
return self.render() return self
def _add_sibling(self, node_id: str, new_label: Optional[str] = None): def _add_sibling(self, node_id: str, new_label: Optional[str] = None):
"""Add a sibling node next to a node.""" """Add a sibling node next to a node."""
@@ -234,17 +241,14 @@ class TreeView(MultipleInstance):
parent = self._state.items[node.parent] parent = self._state.items[node.parent]
new_node = TreeNode( new_node = TreeNode(
label=new_label or "New Node", label=new_label or "New Node",
type=node.type, type=node.type
parent=node.parent
) )
self._state.items[new_node.id] = new_node
# Insert after current node # Insert after current node
idx = parent.children.index(node_id) insert_idx = parent.children.index(node_id) + 1
parent.children.insert(idx + 1, new_node.id) self.add_node(new_node, parent_id=node.parent, insert_index=insert_idx)
return self.render() return self
def _start_rename(self, node_id: str): def _start_rename(self, node_id: str):
"""Start renaming a node (sets editing state).""" """Start renaming a node (sets editing state)."""
@@ -252,21 +256,21 @@ class TreeView(MultipleInstance):
raise ValueError(f"Node {node_id} does not exist") raise ValueError(f"Node {node_id} does not exist")
self._state.editing = node_id self._state.editing = node_id
return self.render() return self
def _save_rename(self, node_id: str, new_label: str): def _save_rename(self, node_id: str, node_label: str):
"""Save renamed node with new label.""" """Save renamed node with new label."""
if node_id not in self._state.items: if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist") raise ValueError(f"Node {node_id} does not exist")
self._state.items[node_id].label = new_label self._state.items[node_id].label = node_label
self._state.editing = None self._state.editing = None
return self.render() return self
def _cancel_rename(self): def _cancel_rename(self):
"""Cancel renaming operation.""" """Cancel renaming operation."""
self._state.editing = None self._state.editing = None
return self.render() return self
def _delete_node(self, node_id: str): def _delete_node(self, node_id: str):
"""Delete a node (only if it has no children).""" """Delete a node (only if it has no children)."""
@@ -292,7 +296,7 @@ class TreeView(MultipleInstance):
if self._state.selected == node_id: if self._state.selected == node_id:
self._state.selected = None self._state.selected = None
return self.render() return self
def _select_node(self, node_id: str): def _select_node(self, node_id: str):
"""Select a node.""" """Select a node."""
@@ -300,15 +304,14 @@ class TreeView(MultipleInstance):
raise ValueError(f"Node {node_id} does not exist") raise ValueError(f"Node {node_id} does not exist")
self._state.selected = node_id self._state.selected = node_id
return self.render() return self
def _render_action_buttons(self, node_id: str): def _render_action_buttons(self, node_id: str):
"""Render action buttons for a node (visible on hover).""" """Render action buttons for a node (visible on hover)."""
return Div( return Div(
Button("+child", data_action="add_child"), mk.icon(add_circle20_regular, command=self.commands.add_child(node_id)),
Button("+sibling", data_action="add_sibling"), mk.icon(edit20_regular, command=self.commands.start_rename(node_id)),
Button("rename", data_action="rename"), mk.icon(delete20_regular, command=self.commands.delete_node(node_id)),
Button("delete", data_action="delete"),
cls="mf-treenode-actions" cls="mf-treenode-actions"
) )
@@ -330,21 +333,22 @@ class TreeView(MultipleInstance):
has_children = len(node.children) > 0 has_children = len(node.children) > 0
# Toggle icon # Toggle icon
toggle = Span( toggle = mk.icon(
"" if is_expanded else "" if has_children else " ", chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
cls="mf-treenode-toggle" command=self.commands.toggle_node(node_id))
)
# Label or input for editing # Label or input for editing
if is_editing: if is_editing:
label_element = Input( # TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
label_element = mk.mk(Input(
name="node_label",
value=node.label, value=node.label,
cls="mf-treenode-input" cls="mf-treenode-input input input-sm"
) ), command=self.commands.save_rename(node_id))
else: else:
label_element = Span( label_element = mk.mk(
node.label, Span(node.label, cls="mf-treenode-label text-sm"),
cls="mf-treenode-label" command=self.commands.select_node(node_id)
) )
# Node element # Node element
@@ -352,9 +356,9 @@ class TreeView(MultipleInstance):
toggle, toggle,
label_element, label_element,
self._render_action_buttons(node_id), self._render_action_buttons(node_id),
cls=f"mf-treenode {'selected' if is_selected else ''}", cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
data_node_id=node_id, data_node_id=node_id,
data_level=str(level) style=f"padding-left: {level * 20}px"
) )
# Children (if expanded) # Children (if expanded)
@@ -386,6 +390,7 @@ class TreeView(MultipleInstance):
return Div( return Div(
*[self._render_node(node_id) for node_id in root_nodes], *[self._render_node(node_id) for node_id in root_nodes],
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
id=self._id, id=self._id,
cls="mf-treeview" cls="mf-treeview"
) )

View File

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

View File

@@ -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())

View File

@@ -2,8 +2,11 @@
import shutil import shutil
import pytest import pytest
from fasthtml.components import *
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.TreeView import TreeView, TreeNode from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.test.matcher import matches, TestObject, TestCommand
from .conftest import root_instance from .conftest import root_instance
@@ -241,14 +244,153 @@ class TestTreeviewBehaviour:
with pytest.raises(ValueError, match="Node.*does not exist"): with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._select_node("nonexistent_id") 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: class TestTreeViewRender:
"""Tests for TreeView HTML rendering.""" """Tests for TreeView HTML rendering."""
def test_treeview_renders_correctly(self): def test_i_can_render_empty_treeview(self, root_instance):
"""Test that TreeView generates correct HTML structure.""" """Test that TreeView generates correct HTML structure."""
# Signature only - implementation later tree_view = TreeView(root_instance)
pass 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): def test_node_action_buttons_are_rendered(self):
"""Test that action buttons are present in rendered HTML.""" """Test that action buttons are present in rendered HTML."""

View File

@@ -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")
^^^ |'''