Fixed Syntax validation and autocompletion. Fixed unit tests

This commit is contained in:
2026-02-15 18:32:34 +01:00
parent 789c06b842
commit 27f12b2c32
13 changed files with 532 additions and 640 deletions

View File

@@ -10,6 +10,9 @@ from myfasthtml.controls.datagrid_objects import DataGridColumnState
from myfasthtml.controls.helpers import icons, mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formula.dsl.completion.FormulaCompletionEngine import FormulaCompletionEngine
from myfasthtml.core.formula.dsl.parser import FormulaParser
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, chevron_left20_regular
@@ -37,11 +40,11 @@ class Commands(BaseCommands):
self._owner,
self._owner.show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML")
def update_column(self, col_id):
def save_column_details(self, col_id):
return Command(f"UpdateColumn",
f"Update column {col_id}",
self._owner,
self._owner.update_column,
self._owner.save_column_details,
kwargs={"col_id": col_id}
).htmx(target=f"#{self._id}", swap="innerHTML")
@@ -64,41 +67,36 @@ class DataGridColumnsManager(MultipleInstance):
self.commands = Commands(self)
self._new_column = False
conf = DslEditorConf(save_button=False, placeholder="{Column} * {OtherColumn}", line_numbers=False)
completion_engine = FormulaCompletionEngine(
self._parent._parent,
self._parent.get_table_name(),
)
conf = DslEditorConf(save_button=False, line_numbers=False, engine_id=completion_engine.get_id())
self._formula_editor = DataGridFormulaEditor(self, conf=conf, _id=f"{self._id}-formula-editor")
DslsManager.register(completion_engine, FormulaParser())
@property
def columns(self):
return self._parent.get_state().columns
def _get_col_def_from_col_id(self, col_id, updates=None, copy=True):
def _get_col_def_from_col_id(self, col_id, copy=True):
"""
Retrieves a column definition from the column ID, with optional updates
made using information provided by the client response.
:param col_id: The unique identifier of the column to retrieve.
:param updates: Optional dictionary containing updated values for
the column attributes provided by the client. If specified, the
attributes of the column definition will be updated accordingly.
:return: A copy of the column definition object with any updates applied,
or None if no column matching the provided ID is found.
:rtype: ColumnDefinition | None
"""
if updates:
updates["visible"] = "visible" in updates and updates["visible"] == "on"
cols_defs = [c for c in self.columns if c.col_id == col_id]
if not cols_defs:
col_def = DataGridColumnState(col_id, -1)
else:
col_def = cols_defs[0].copy() if copy else cols_defs[0]
return None
if updates:
return cols_defs[0].copy() if copy else cols_defs[0]
def _get_updated_col_def_from_col_id(self, col_id, updates=None, copy=True):
col_def = self._get_col_def_from_col_id(col_id, copy=copy)
if col_def is None:
col_def = DataGridColumnState(col_id, -1)
if updates is not None:
updates["visible"] = "visible" in updates and updates["visible"] == "on"
for k, v in [(k, v) for k, v in updates.items() if hasattr(col_def, k)]:
if k == "visible":
col_def.visible = v == "on"
elif k == "type":
if k == "type":
col_def.type = ColumnType(v)
elif k == "width":
col_def.width = int(v)
@@ -112,7 +110,7 @@ class DataGridColumnsManager(MultipleInstance):
def toggle_column(self, col_id):
logger.debug(f"toggle_column {col_id=}")
col_def = self._get_col_def_from_col_id(col_id)
col_def = self._get_col_def_from_col_id(col_id, copy=False)
if col_def is None:
logger.debug(f" column '{col_id}' is not found.")
return Div(f"Column '{col_id}' not found")
@@ -123,7 +121,7 @@ class DataGridColumnsManager(MultipleInstance):
def show_column_details(self, col_id):
logger.debug(f"show_column_details {col_id=}")
col_def = self._get_col_def_from_col_id(col_id)
col_def = self._get_updated_col_def_from_col_id(col_id)
if col_def is None:
logger.debug(f" column '{col_id}' is not found.")
return Div(f"Column '{col_id}' not found")
@@ -133,25 +131,21 @@ class DataGridColumnsManager(MultipleInstance):
def show_all_columns(self):
return self._mk_inner_content()
def update_column(self, col_id, client_response):
logger.debug(f"update_column {col_id=}, {client_response=}")
col_def = self._get_col_def_from_col_id(col_id, client_response, copy=False)
if col_def is None:
logger.debug(f" column '{col_id}' is not found.")
else:
# save the new values
self._parent.save_state()
def save_column_details(self, col_id, client_response):
logger.debug(f"save_column_details {col_id=}, {client_response=}")
self._get_updated_col_def_from_col_id(col_id, client_response, copy=False)
self._parent.save_state()
return self._mk_inner_content()
def on_new_column(self):
self._new_column = True
col_def = self._get_col_def_from_col_id("__new__")
col_def = self._get_updated_col_def_from_col_id("__new__")
return self.mk_column_details(col_def)
def on_column_type_changed(self, col_id, client_response):
logger.debug(f"on_column_type_changed {col_id=}, {client_response=}")
col_def = self._get_col_def_from_col_id(col_id, client_response)
col_def = self._get_updated_col_def_from_col_id(col_id, client_response)
return self.mk_column_details(col_def)
def _register_formula(self, col_def) -> None:
@@ -240,7 +234,7 @@ class DataGridColumnsManager(MultipleInstance):
legend="Column details",
cls="fieldset border-base-300 rounded-box"
),
mk.dialog_buttons(on_ok=self.commands.update_column(col_def.col_id),
mk.dialog_buttons(on_ok=self.commands.save_column_details(col_def.col_id),
on_cancel=self.commands.show_all_columns()),
cls="mb-1",
),

View File

@@ -128,8 +128,8 @@ class BaseCompletionEngine(ABC):
Generate suggestions for the given context.
Args:
context: The detected completion context
scope: The detected scope
context: The detected completion context (from the parser)
scope: The detected scope (table, column, row, cell...)
prefix: The current word prefix (for filtering)
Returns:

View File

@@ -0,0 +1,50 @@
"""
Common DSL exceptions shared across all DSL implementations.
"""
class DSLError(Exception):
"""Base exception for DSL errors."""
pass
class DSLSyntaxError(DSLError):
"""
Raised when a DSL input has syntax errors.
Attributes:
message: Error description
line: Line number where error occurred (1-based)
column: Column number where error occurred (1-based)
context: The problematic line or snippet
"""
def __init__(self, message: str, line: int = None, column: int = None, context: str = None):
self.message = message
self.line = line
self.column = column
self.context = context
super().__init__(self._format_message())
def _format_message(self) -> str:
parts = [self.message]
if self.line is not None:
parts.append(f"at line {self.line}")
if self.column is not None:
parts[1] = f"at line {self.line}, column {self.column}"
if self.context:
parts.append(f"\n {self.context}")
if self.column is not None:
parts.append(f"\n {' ' * (self.column - 1)}^")
return " ".join(parts[:2]) + "".join(parts[2:])
class DSLValidationError(DSLError):
"""
Raised when a DSL is syntactically correct but semantically invalid.
"""
def __init__(self, message: str, line: int = None):
self.message = message
self.line = line
super().__init__(f"{message}" + (f" at line {line}" if line else ""))

View File

@@ -1,55 +1,6 @@
"""
DSL-specific exceptions.
DSL-specific exceptions — re-exported from the common location.
"""
from myfasthtml.core.dsl.exceptions import DSLError, DSLSyntaxError, DSLValidationError
class DSLError(Exception):
"""Base exception for DSL errors."""
pass
class DSLSyntaxError(DSLError):
"""
Raised when the DSL input has syntax errors.
Attributes:
message: Error description
line: Line number where error occurred (1-based)
column: Column number where error occurred (1-based)
context: The problematic line or snippet
"""
def __init__(self, message: str, line: int = None, column: int = None, context: str = None):
self.message = message
self.line = line
self.column = column
self.context = context
super().__init__(self._format_message())
def _format_message(self) -> str:
parts = [self.message]
if self.line is not None:
parts.append(f"at line {self.line}")
if self.column is not None:
parts[1] = f"at line {self.line}, column {self.column}"
if self.context:
parts.append(f"\n {self.context}")
if self.column is not None:
parts.append(f"\n {' ' * (self.column - 1)}^")
return " ".join(parts[:2]) + "".join(parts[2:])
class DSLValidationError(DSLError):
"""
Raised when the DSL is syntactically correct but semantically invalid.
Examples:
- Unknown preset name
- Invalid parameter for formatter type
- Missing required parameter
"""
def __init__(self, message: str, line: int = None):
self.message = message
self.line = line
super().__init__(f"{message}" + (f" at line {line}" if line else ""))
__all__ = ["DSLError", "DSLSyntaxError", "DSLValidationError"]

View File

@@ -8,6 +8,7 @@ Provides context-aware suggestions for:
- Keywords: ``if``, ``else``, ``and``, ``or``, ``not``, ``WHERE``
"""
import re
from dataclasses import dataclass, field
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
from myfasthtml.core.dsl.types import Position, Suggestion
@@ -22,6 +23,21 @@ FORMULA_KEYWORDS = [
]
@dataclass
class FormulaContext:
"""
Completion context for a formula expression.
Attributes:
kind: One of ``"column_ref"``, ``"cross_table"``,
``"function_or_keyword"``, ``"general"``.
cross_table_name: Table name extracted from ``{TableName.`` syntax.
Only relevant when ``kind == "cross_table"``.
"""
kind: str
cross_table_name: str = field(default="")
class FormulaCompletionEngine(BaseCompletionEngine):
"""
Context-aware completion engine for formula expressions.
@@ -42,7 +58,7 @@ class FormulaCompletionEngine(BaseCompletionEngine):
"""Formula has no scope — always the same single-expression scope."""
return None
def detect_context(self, text: str, cursor: Position, scope):
def detect_context(self, text: str, cursor: Position, scope) -> FormulaContext:
"""
Detect completion context based on cursor position in formula text.
@@ -52,129 +68,80 @@ class FormulaCompletionEngine(BaseCompletionEngine):
scope: Unused (formulas have no scopes).
Returns:
Context string: ``"column_ref"``, ``"cross_table"``,
``"function"``, ``"keyword"``, or ``"general"``.
FormulaContext describing the kind of completion expected.
"""
# Get text up to cursor
lines = text.split("\n")
line_idx = min(cursor.line, len(lines) - 1)
line_text = lines[line_idx]
line_text = lines[min(cursor.line, len(lines) - 1)]
text_before = line_text[:cursor.ch]
# Check if we are inside a { ... } reference
last_brace = text_before.rfind("{")
if last_brace >= 0:
inside = text_before[last_brace + 1:]
if "}" not in inside:
if "." in inside:
return "cross_table"
return "column_ref"
cross_table_name = inside[:inside.rfind(".")]
return FormulaContext("cross_table", cross_table_name)
return FormulaContext("column_ref")
# Check if we are typing a function name (alphanumeric at word start)
word_match = re.search(r"[a-z_][a-z0-9_]*$", text_before, re.IGNORECASE)
if word_match:
return "function_or_keyword"
if re.search(r"[a-z_][a-z0-9_]*$", text_before, re.IGNORECASE):
return FormulaContext("function_or_keyword")
return "general"
return FormulaContext("general")
def get_suggestions(self, text: str, cursor: Position, scope, context) -> list:
def get_suggestions(self, context: FormulaContext, scope, prefix: str) -> list[Suggestion]:
"""
Generate suggestions based on the detected context.
Args:
text: The full formula text.
cursor: Cursor position.
scope: Unused.
context: String from ``detect_context``.
context: FormulaContext from ``detect_context``.
scope: Unused (formulas have no scopes).
prefix: Current word prefix (filtering handled by base class).
Returns:
List of Suggestion objects.
"""
suggestions = []
if context == "column_ref":
# Suggest columns from the current table
suggestions += self._column_suggestions(self.table_name)
elif context == "cross_table":
# Get the table name prefix from text_before
lines = text.split("\n")
line_text = lines[min(cursor.line, len(lines) - 1)]
text_before = line_text[:cursor.ch]
last_brace = text_before.rfind("{")
inside = text_before[last_brace + 1:] if last_brace >= 0 else ""
dot_pos = inside.rfind(".")
table_prefix = inside[:dot_pos] if dot_pos >= 0 else ""
match context.kind:
case "column_ref":
return self._column_suggestions(self.table_name)
# Suggest columns from the referenced table
if table_prefix:
suggestions += self._column_suggestions(table_prefix)
else:
suggestions += self._table_suggestions()
elif context == "function_or_keyword":
suggestions += self._function_suggestions()
suggestions += self._keyword_suggestions()
else: # general
suggestions += self._function_suggestions()
suggestions += self._keyword_suggestions()
suggestions += [
Suggestion(
label="{",
detail="Column reference",
insert_text="{",
)
]
return suggestions
case "cross_table":
return self._column_suggestions(context.cross_table_name)
case "function_or_keyword":
return self._function_suggestions() + self._keyword_suggestions()
case _: # general
return (
self._function_suggestions()
+ self._keyword_suggestions()
+ [Suggestion("{", "Column reference", "keyword")]
)
# ==================== Private helpers ====================
def _column_suggestions(self, table_name: str) -> list:
def _column_suggestions(self, table_name: str) -> list[Suggestion]:
"""Get column name suggestions for a table."""
try:
columns = self.provider.list_columns(table_name)
return [
Suggestion(
label=col,
detail=f"Column from {table_name}",
insert_text=col,
)
for col in (columns or [])
]
return [Suggestion(col, f"Column from {table_name}", "column") for col in (columns or [])]
except Exception:
return []
def _table_suggestions(self) -> list:
def _table_suggestions(self) -> list[Suggestion]:
"""Get table name suggestions."""
try:
tables = self.provider.list_tables()
return [
Suggestion(
label=t,
detail="Table",
insert_text=t,
)
for t in (tables or [])
]
return [Suggestion(t, "Table", "table") for t in (tables or [])]
except Exception:
return []
def _function_suggestions(self) -> list:
def _function_suggestions(self) -> list[Suggestion]:
"""Get built-in function name suggestions."""
return [
Suggestion(
label=name,
detail="Function",
insert_text=f"{name}(",
)
Suggestion(f"{name}(", name, "function")
for name in sorted(BUILTIN_FUNCTIONS.keys())
]
def _keyword_suggestions(self) -> list:
def _keyword_suggestions(self) -> list[Suggestion]:
"""Get keyword suggestions."""
return [
Suggestion(label=kw, detail="Keyword", insert_text=kw)
for kw in FORMULA_KEYWORDS
]
return [Suggestion(kw, "Keyword", "keyword") for kw in FORMULA_KEYWORDS]

View File

@@ -3,25 +3,6 @@ class FormulaError(Exception):
pass
class FormulaSyntaxError(FormulaError):
"""Raised when the formula has syntax errors."""
def __init__(self, message, line=None, column=None, context=None):
self.message = message
self.line = line
self.column = column
self.context = context
super().__init__(self._format_message())
def _format_message(self):
parts = [self.message]
if self.line is not None:
parts.append(f"at line {self.line}")
if self.column is not None:
parts.append(f"col {self.column}")
return " ".join(parts)
class FormulaValidationError(FormulaError):
"""Raised when the formula is syntactically correct but semantically invalid."""
pass
@@ -29,7 +10,7 @@ class FormulaValidationError(FormulaError):
class FormulaCycleError(FormulaError):
"""Raised when formula dependencies contain a cycle."""
def __init__(self, cycle_nodes):
self.cycle_nodes = cycle_nodes
super().__init__(f"Circular dependency detected involving: {', '.join(cycle_nodes)}")

View File

@@ -6,7 +6,7 @@ No indentation handling needed — formulas are single-line expressions.
"""
from lark import Lark, UnexpectedInput
from .exceptions import FormulaSyntaxError
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from .grammar import FORMULA_GRAMMAR
@@ -39,7 +39,7 @@ class FormulaParser:
lark.Tree: The parsed AST.
Raises:
FormulaSyntaxError: If the text has syntax errors.
DSLSyntaxError: If the text has syntax errors.
"""
text = text.strip()
if not text:
@@ -52,7 +52,7 @@ class FormulaParser:
if hasattr(e, "get_context"):
context = e.get_context(text)
raise FormulaSyntaxError(
raise DSLSyntaxError(
message=self._format_error_message(e),
line=getattr(e, "line", None),
column=getattr(e, "column", None),

View File

@@ -34,7 +34,7 @@ def parse_formula(text: str) -> FormulaDefinition | None:
FormulaDefinition on success, None if text is empty.
Raises:
FormulaSyntaxError: If the formula text is syntactically invalid.
DSLSyntaxError: If the formula text is syntactically invalid.
"""
text = text.strip() if text else ""
if not text:
@@ -80,7 +80,7 @@ class FormulaEngine:
formula_text: The formula expression string.
Raises:
FormulaSyntaxError: If the formula is syntactically invalid.
DSLSyntaxError: If the formula is syntactically invalid.
FormulaCycleError: If the formula would create a circular dependency.
"""
formula_text = formula_text.strip() if formula_text else ""

View File

@@ -12,7 +12,7 @@ from starlette.routing import Mount
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.dsl.types import Position
from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formatting.dsl import DSLSyntaxError
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.test.MyFT import MyFT
utils_app, utils_rt = fast_app()

View File

@@ -1,469 +1,415 @@
import shutil
from dataclasses import dataclass, field
from unittest.mock import Mock
import pytest
from fasthtml.common import Div, Input, Label, Form, Fieldset, Select
from fasthtml.common import Div, FT, Input, Form, Fieldset, Select
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager, Commands
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.Search import Search
from myfasthtml.controls.datagrid_objects import DataGridColumnState
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.instances import InstancesManager, MultipleInstance
from myfasthtml.test.matcher import (
matches, find_one, find, Contains, TestIcon, TestObject
matches, find_one, find, Contains, TestIcon, TestObject
)
@dataclass
class MockDatagridState:
"""Mock state object that mimics DatagridState."""
columns: list = field(default_factory=list)
"""Mock state object that mimics DatagridState."""
columns: list = field(default_factory=list)
class MockDataGrid(MultipleInstance):
"""Mock DataGrid parent for testing DataGridColumnsManager."""
def __init__(self, parent, columns=None, _id=None):
super().__init__(parent, _id=_id)
self._state = MockDatagridState(columns=columns or [])
self._save_state_called = False
def get_state(self):
return self._state
def save_state(self):
self._save_state_called = True
"""Mock DataGrid parent for testing DataGridColumnsManager."""
def __init__(self, parent, columns=None, _id=None):
super().__init__(parent, _id=_id)
self._state = MockDatagridState(columns=columns or [])
self._save_state_called = False
def get_state(self):
return self._state
def save_state(self):
self._save_state_called = True
def get_table_name(self):
return "mock_table"
@pytest.fixture
def mock_datagrid(root_instance):
"""Create a mock DataGrid with sample columns."""
columns = [
DataGridColumnState(col_id="name", col_index=0, title="Name", type=ColumnType.Text, visible=True),
DataGridColumnState(col_id="age", col_index=1, title="Age", type=ColumnType.Number, visible=True),
DataGridColumnState(col_id="email", col_index=2, title="Email", type=ColumnType.Text, visible=False),
]
yield MockDataGrid(root_instance, columns=columns, _id="test-datagrid")
InstancesManager.reset()
"""Create a mock DataGrid with sample columns."""
columns = [
DataGridColumnState(col_id="name", col_index=0, title="Name", type=ColumnType.Text, visible=True),
DataGridColumnState(col_id="age", col_index=1, title="Age", type=ColumnType.Number, visible=True),
DataGridColumnState(col_id="email", col_index=2, title="Email", type=ColumnType.Text, visible=False),
]
yield MockDataGrid(root_instance, columns=columns, _id="test-datagrid")
InstancesManager.reset()
@pytest.fixture
def columns_manager(mock_datagrid):
"""Create a DataGridColumnsManager instance for testing."""
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
yield DataGridColumnsManager(mock_datagrid)
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
"""Create a DataGridColumnsManager instance for testing."""
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
yield DataGridColumnsManager(mock_datagrid)
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
class TestDataGridColumnsManagerBehaviour:
"""Tests for DataGridColumnsManager behavior and logic."""
# =========================================================================
# Initialization
# =========================================================================
def test_i_can_create_columns_manager(self, mock_datagrid):
"""Test that DataGridColumnsManager can be created with a DataGrid parent."""
cm = DataGridColumnsManager(mock_datagrid)
assert cm is not None
assert cm._parent == mock_datagrid
assert isinstance(cm.commands, Commands)
# =========================================================================
# Columns Property
# =========================================================================
def test_columns_property_returns_parent_state_columns(self, columns_manager, mock_datagrid):
"""Test that columns property returns columns from parent's state."""
columns = columns_manager.columns
assert columns == mock_datagrid.get_state().columns
assert len(columns) == 3
assert columns[0].col_id == "name"
# =========================================================================
# Get Column Definition
# =========================================================================
def test_i_can_get_existing_column_by_id(self, columns_manager):
"""Test finding an existing column by its ID."""
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def is not None
assert col_def.col_id == "name"
assert col_def.title == "Name"
def test_i_cannot_get_nonexistent_column(self, columns_manager):
"""Test that getting a nonexistent column returns None."""
col_def = columns_manager._get_col_def_from_col_id("nonexistent")
assert col_def is None
# =========================================================================
# Toggle Column Visibility
# =========================================================================
@pytest.mark.parametrize("col_id, initial_visible, expected_visible", [
("name", True, False), # visible -> hidden
("email", False, True), # hidden -> visible
])
def test_i_can_toggle_column_visibility(self, columns_manager, col_id, initial_visible, expected_visible):
"""Test toggling column visibility from visible to hidden and vice versa."""
col_def = columns_manager._get_col_def_from_col_id(col_id)
assert col_def.visible == initial_visible
columns_manager.toggle_column(col_id)
assert col_def.visible == expected_visible
def test_toggle_column_saves_state(self, columns_manager, mock_datagrid):
"""Test that toggle_column calls save_state on parent."""
mock_datagrid._save_state_called = False
columns_manager.toggle_column("name")
assert mock_datagrid._save_state_called is True
def test_toggle_column_returns_column_label(self, columns_manager):
"""Test that toggle_column returns the updated column label."""
result = columns_manager.toggle_column("name")
# Result should be a Div with the column label structure
assert result is not None
assert hasattr(result, 'tag')
def test_i_cannot_toggle_nonexistent_column(self, columns_manager):
"""Test that toggling a nonexistent column returns an error message."""
result = columns_manager.toggle_column("nonexistent")
expected = Div("Column 'nonexistent' not found")
assert matches(result, expected)
# =========================================================================
# Show Column Details
# =========================================================================
def test_i_can_show_column_details_for_existing_column(self, columns_manager):
"""Test that show_column_details returns the details form for an existing column."""
result = columns_manager.show_column_details("name")
# Should contain a Form - check by finding form tag in children
expected = Form()
del(expected.attrs["enctype"]) # hack. We don't know why enctype is added
forms = find(result, expected)
assert len(forms) == 1, "Should contain exactly one form"
def test_i_cannot_show_details_for_nonexistent_column(self, columns_manager):
"""Test that showing details for nonexistent column returns error message."""
result = columns_manager.show_column_details("nonexistent")
expected = Div("Column 'nonexistent' not found")
assert matches(result, expected)
# =========================================================================
# Show All Columns
# =========================================================================
def test_show_all_columns_returns_search_component(self, columns_manager):
"""Test that show_all_columns returns a Search component."""
result = columns_manager.show_all_columns()
assert isinstance(result, Search)
def test_show_all_columns_contains_all_columns(self, columns_manager):
"""Test that show_all_columns Search contains all columns."""
result = columns_manager.show_all_columns()
assert len(result.items) == 3
# =========================================================================
# Update Column
# =========================================================================
def test_i_can_update_column_title(self, columns_manager):
"""Test updating a column's title via client_response."""
client_response = {"title": "New Name"}
columns_manager.update_column("name", client_response)
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.title == "New Name"
def test_i_can_update_column_visibility_via_form(self, columns_manager):
"""Test updating column visibility via checkbox form value."""
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.visible is True
# Unchecked checkbox sends nothing, checked sends "on"
client_response = {"visible": "off"} # Not "on" means unchecked
columns_manager.update_column("name", client_response)
assert col_def.visible is False
# Check it back on
client_response = {"visible": "on"}
columns_manager.update_column("name", client_response)
assert col_def.visible is True
def test_i_can_update_column_type(self, columns_manager):
"""Test updating a column's type."""
client_response = {"type": "Number"}
columns_manager.update_column("name", client_response)
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.type == ColumnType.Number
def test_i_can_update_column_width(self, columns_manager):
"""Test updating a column's width."""
client_response = {"width": "200"}
columns_manager.update_column("name", client_response)
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.width == 200
def test_update_column_saves_state(self, columns_manager, mock_datagrid):
"""Test that update_column calls save_state on parent."""
mock_datagrid._save_state_called = False
columns_manager.update_column("name", {"title": "Updated"})
assert mock_datagrid._save_state_called is True
def test_update_column_ignores_unknown_attributes(self, columns_manager):
"""Test that update_column ignores attributes not in DataGridColumnState."""
col_def = columns_manager._get_col_def_from_col_id("name")
original_title = col_def.title
client_response = {"unknown_attr": "value", "title": "New Title"}
columns_manager.update_column("name", client_response)
# unknown_attr should be ignored, title should be updated
assert col_def.title == "New Title"
assert not hasattr(col_def, "unknown_attr")
def test_i_cannot_update_nonexistent_column(self, columns_manager):
"""Test that updating nonexistent column returns mk_all_columns result."""
result = columns_manager.update_column("nonexistent", {"title": "Test"})
# Should return the all columns view (Search component)
assert isinstance(result, Search)
"""Tests for DataGridColumnsManager behavior and logic."""
# =========================================================================
# Get Column Definition
# =========================================================================
def test_i_can_get_existing_column_by_id(self, columns_manager):
"""Test finding an existing column by its ID."""
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def is not None
assert col_def.col_id == "name"
assert col_def.title == "Name"
def test_i_cannot_get_nonexistent_column(self, columns_manager):
"""Test that getting a nonexistent column returns None."""
col_def = columns_manager._get_col_def_from_col_id("nonexistent")
assert col_def is None
def test_i_can_get_and_update_column_state(self, columns_manager):
"""Test that get_col_def_from_col_id updates the column state."""
updates = {"title": "New Name", "visible": "on", "type": "Number", "width": 200}
col_def = columns_manager._get_updated_col_def_from_col_id("name", updates)
assert col_def.title == "New Name"
assert col_def.visible is True
assert col_def.type == ColumnType.Number
assert col_def.width == 200
def test_i_can_get_and_update_column_state_visible_false(self, columns_manager):
"""Test that get_col_def_from_col_id updates the column state."""
updates = {} # visible is missing in the update => It must be set to False
col_def = columns_manager._get_updated_col_def_from_col_id("name", updates)
assert col_def.visible is False
# =========================================================================
# Toggle Column Visibility
# =========================================================================
@pytest.mark.parametrize("col_id, initial_visible, expected_visible", [
("name", True, False), # visible -> hidden
("email", False, True), # hidden -> visible
])
def test_i_can_toggle_column_visibility(self, columns_manager, col_id, initial_visible, expected_visible):
"""Test toggling column visibility from visible to hidden and vice versa."""
col_def = columns_manager._get_col_def_from_col_id(col_id)
assert col_def.visible == initial_visible
columns_manager.toggle_column(col_id)
col_def = columns_manager._get_col_def_from_col_id(col_id)
assert col_def.visible == expected_visible
def test_toggle_column_saves_state(self, columns_manager, mock_datagrid):
"""Test that toggle_column calls save_state on parent."""
mock_datagrid._save_state_called = False
columns_manager.toggle_column("name")
assert mock_datagrid._save_state_called is True
def test_toggle_column_returns_column_label(self, columns_manager):
"""Test that toggle_column returns an HTML element."""
result = columns_manager.toggle_column("name")
assert isinstance(result, FT)
def test_i_cannot_toggle_nonexistent_column(self, columns_manager):
"""Test that toggling a nonexistent column returns an error message."""
result = columns_manager.toggle_column("nonexistent")
expected = Div("Column 'nonexistent' not found")
assert matches(result, expected)
# =========================================================================
# Show All Columns
# =========================================================================
def test_show_all_columns_returns_search_component(self, columns_manager):
"""Test that mk_all_columns returns a Search component."""
result = columns_manager.mk_all_columns()
assert isinstance(result, Search)
def test_show_all_columns_returns_configured_search(self, columns_manager):
"""Test that mk_all_columns returns a correctly configured Search component."""
result = columns_manager.mk_all_columns()
assert result.items_names == "Columns"
assert len(result.items) == 3
col_def = result.items[0]
assert result.get_attr(col_def) == col_def.col_id
# =========================================================================
# Update Column
# =========================================================================
def test_i_can_update_column_title(self, columns_manager):
"""Test updating a column's title via client_response."""
columns_manager.save_column_details("name", {"title": "New Name"})
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.title == "New Name"
def test_i_can_update_column_visibility_via_form(self, columns_manager):
"""Test updating column visibility via checkbox form value."""
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.visible is True
# Unchecked checkbox sends nothing, checked sends "on"
columns_manager.save_column_details("name", {"visible": "off"}) # Not "on" means unchecked
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.visible is False
# Check it back on
columns_manager.save_column_details("name", {"visible": "on"})
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.visible is True
def test_i_can_update_column_type(self, columns_manager):
"""Test updating a column's type."""
columns_manager.save_column_details("name", {"type": "Number"})
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.type == ColumnType.Number
def test_i_can_update_column_width(self, columns_manager):
"""Test updating a column's width."""
columns_manager.save_column_details("name", {"width": "200"})
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.width == 200
def test_update_column_saves_state(self, columns_manager, mock_datagrid):
"""Test that save_column_details calls save_state on parent."""
mock_datagrid._save_state_called = False
columns_manager.save_column_details("name", {"title": "Updated"})
assert mock_datagrid._save_state_called is True
def test_update_column_ignores_unknown_attributes(self, columns_manager):
"""Test that save_column_details ignores attributes not in DataGridColumnState."""
columns_manager.save_column_details("name", {"unknown_attr": "value", "title": "New Title"})
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.title == "New Title"
assert not hasattr(col_def, "unknown_attr")
class TestDataGridColumnsManagerRender:
"""Tests for DataGridColumnsManager HTML rendering."""
"""Tests for DataGridColumnsManager HTML rendering."""
@pytest.fixture
def columns_manager(self, mock_datagrid):
"""Create a fresh DataGridColumnsManager for render tests."""
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
cm = DataGridColumnsManager(mock_datagrid)
yield cm
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
# =========================================================================
# Global Structure
# =========================================================================
def test_i_can_render_columns_manager_with_columns(self, columns_manager):
"""Test that DataGridColumnsManager renders with correct global structure.
@pytest.fixture
def columns_manager(self, mock_datagrid):
"""Create a fresh DataGridColumnsManager for render tests."""
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
cm = DataGridColumnsManager(mock_datagrid)
yield cm
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
Why these elements matter:
- id: Required for HTMX targeting in commands
- Contains Search component: Main content for column list
"""
html = columns_manager.render()
expected = Div(
TestObject(Search), # Search component (column list)
Div(), # New column button
id=columns_manager._id,
)
assert matches(html, expected)
# =========================================================================
# mk_column_label
# =========================================================================
def test_column_label_has_checkbox_and_details_navigation(self, columns_manager):
"""Test that column label contains checkbox and navigation to details.
# =========================================================================
# Global Structure
# =========================================================================
Why these elements matter:
- Checkbox (Input type=checkbox): Controls column visibility
- Label with column ID: Identifies the column
- Chevron icon: Indicates navigation to details
- id with tcolman_ prefix: Required for HTMX swap targeting
"""
col_def = columns_manager._get_col_def_from_col_id("name")
label = columns_manager.mk_column_label(col_def)
# Should have the correct ID pattern
expected = Div(
id=f"tcolman_{columns_manager._id}-name",
cls=Contains("flex"),
)
assert matches(label, expected)
# Should contain a checkbox
checkbox = find_one(label, Input(type="checkbox"))
assert checkbox is not None
# Should contain chevron icon for navigation
chevron = find_one(label, TestIcon("chevron_right20_regular"))
assert chevron is not None
def test_column_label_checkbox_is_checked_when_visible(self, columns_manager):
"""Test that checkbox is checked when column is visible.
def test_i_can_render_columns_manager_with_columns(self, columns_manager):
"""Test that DataGridColumnsManager renders with correct global structure.
Why this matters:
- checked attribute: Reflects current visibility state
- User can see which columns are visible
"""
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.visible is True
label = columns_manager.mk_column_label(col_def)
checkbox = find_one(label, Input(type="checkbox"))
# Checkbox should have checked attribute
assert checkbox.attrs.get("checked") is True
def test_column_label_checkbox_is_unchecked_when_hidden(self, columns_manager):
"""Test that checkbox is unchecked when column is hidden.
Why these elements matter:
- id: Required for HTMX targeting in commands
- Contains Search component: Main content for column list
"""
html = columns_manager.render()
Why this matters:
- No checked attribute: Indicates column is hidden
- Visual feedback for user
"""
col_def = columns_manager._get_col_def_from_col_id("email")
assert col_def.visible is False
label = columns_manager.mk_column_label(col_def)
checkbox = find_one(label, Input(type="checkbox"))
# Checkbox should not have checked attribute (or it should be False/None)
checked = checkbox.attrs.get("checked")
assert checked is None or checked is False
# =========================================================================
# mk_column_details
# =========================================================================
def test_column_details_contains_all_form_fields(self, columns_manager):
"""Test that column details form contains all required fields.
expected = Div(
TestObject(Search), # Search component
id=columns_manager._id,
)
Why these elements matter:
- col_id field (readonly): Shows column identifier
- title field: Editable column display name
- visible checkbox: Toggle visibility
- type select: Change column type
- width input: Set column width
"""
col_def = columns_manager._get_col_def_from_col_id("name")
details = columns_manager.mk_column_details(col_def)
# Should contain Form
form = Form()
del form.attrs["enctype"]
form = find_one(details, form)
assert form is not None
# Should contain all required input fields
col_id_input = find_one(form, Input(name="col_id"))
assert col_id_input is not None
assert col_id_input.attrs.get("readonly") is True
title_input = find_one(form, Input(name="title"))
assert title_input is not None
visible_checkbox = find_one(form, Input(name="visible", type="checkbox"))
assert visible_checkbox is not None
type_select = find_one(form, Select(name="type"))
assert type_select is not None
width_input = find_one(form, Input(name="width", type="number"))
assert width_input is not None
def test_column_details_has_back_button(self, columns_manager):
"""Test that column details has a back button to return to all columns.
assert matches(html, expected)
Why this matters:
- Back navigation: User can return to column list
- Chevron left icon: Visual indicator of back action
"""
col_def = columns_manager._get_col_def_from_col_id("name")
details = columns_manager.mk_column_details(col_def)
# Should contain back chevron icon
back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span"))
assert back_icon is not None
def test_column_details_form_has_fieldset_with_legend(self, columns_manager):
"""Test that column details form has a fieldset with legend.
# =========================================================================
# mk_column_label
# =========================================================================
Why this matters:
- Fieldset groups related fields
- Legend provides context ("Column details")
"""
col_def = columns_manager._get_col_def_from_col_id("name")
details = columns_manager.mk_column_details(col_def)
fieldset = find_one(details, Fieldset(legend="Column details"))
assert fieldset is not None
# =========================================================================
# show_column_details
# =========================================================================
def test_i_can_show_column_details_for_existing_column(self, columns_manager):
"""Test that show_column_details returns a form-based view for an existing column.
def test_column_label_has_checkbox_and_details_navigation(self, columns_manager):
"""Test that column label contains checkbox and navigation to details.
Why these elements matter:
- Form element: show_column_details must return an editable form view
- Exactly one form: Ensures the response is unambiguous (not multiple forms)
"""
result = columns_manager.show_column_details("name")
expected = Form()
del expected.attrs["enctype"]
forms = find(result, expected)
assert len(forms) == 1, "Should contain exactly one form"
# =========================================================================
# mk_all_columns
# =========================================================================
def test_all_columns_renders_all_column_labels(self, columns_manager):
"""Test that all columns render produces labels for all columns.
Why these elements matter:
- Checkbox (Input type=checkbox): Controls column visibility
- Label with column ID: Identifies the column
- Chevron icon: Indicates navigation to details
- id with tcolman_ prefix: Required for HTMX swap targeting
"""
col_def = columns_manager._get_col_def_from_col_id("name")
label = columns_manager.mk_column_label(col_def)
# Should have the correct ID pattern
expected = Div(
id=f"tcolman_{columns_manager._id}-name",
cls=Contains("flex"),
)
assert matches(label, expected)
# Should contain a checkbox
checkbox = find_one(label, Input(type="checkbox"))
assert checkbox is not None
# Should contain chevron icon for navigation
chevron = find_one(label, TestIcon("chevron_right20_regular"))
assert chevron is not None
def test_column_label_checkbox_is_checked_when_visible(self, columns_manager):
"""Test that checkbox is checked when column is visible.
Why this matters:
- checked attribute: Reflects current visibility state
- User can see which columns are visible
"""
col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.visible is True
label = columns_manager.mk_column_label(col_def)
checkbox = find_one(label, Input(type="checkbox"))
# Checkbox should have checked attribute
assert checkbox.attrs.get("checked") is True
def test_column_label_checkbox_is_unchecked_when_hidden(self, columns_manager):
"""Test that checkbox is unchecked when column is hidden.
Why this matters:
- No checked attribute: Indicates column is hidden
- Visual feedback for user
"""
col_def = columns_manager._get_col_def_from_col_id("email")
assert col_def.visible is False
label = columns_manager.mk_column_label(col_def)
checkbox = find_one(label, Input(type="checkbox"))
# Checkbox should not have checked attribute (or it should be False/None)
checked = checkbox.attrs.get("checked")
assert checked is None or checked is False
# =========================================================================
# mk_column_details
# =========================================================================
def test_column_details_contains_all_form_fields(self, columns_manager):
"""Test that column details form contains all required fields.
Why these elements matter:
- col_id field (readonly): Shows column identifier
- title field: Editable column display name
- visible checkbox: Toggle visibility
- type select: Change column type
- width input: Set column width
"""
col_def = columns_manager._get_col_def_from_col_id("name")
details = columns_manager.mk_column_details(col_def)
# Should contain Form
form = Form()
del form.attrs["enctype"]
form = find_one(details, form)
assert form is not None
# Should contain all required input fields
col_id_input = find_one(form, Input(name="col_id"))
assert col_id_input is not None
assert col_id_input.attrs.get("readonly") is True
title_input = find_one(form, Input(name="title"))
assert title_input is not None
visible_checkbox = find_one(form, Input(name="visible", type="checkbox"))
assert visible_checkbox is not None
type_select = find_one(form, Select(name="type"))
assert type_select is not None
width_input = find_one(form, Input(name="width", type="number"))
assert width_input is not None
def test_column_details_has_back_button(self, columns_manager):
"""Test that column details has a back button to return to all columns.
Why this matters:
- Back navigation: User can return to column list
- Chevron left icon: Visual indicator of back action
"""
col_def = columns_manager._get_col_def_from_col_id("name")
details = columns_manager.mk_column_details(col_def)
# Should contain back chevron icon
back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span"))
assert back_icon is not None
def test_column_details_form_has_fieldset_with_legend(self, columns_manager):
"""Test that column details form has a fieldset with legend.
Why this matters:
- Fieldset groups related fields
- Legend provides context ("Column details")
"""
col_def = columns_manager._get_col_def_from_col_id("name")
details = columns_manager.mk_column_details(col_def)
fieldset = find_one(details, Fieldset(legend="Column details"))
assert fieldset is not None
# =========================================================================
# mk_all_columns
# =========================================================================
def test_all_columns_uses_search_component(self, columns_manager):
"""Test that mk_all_columns returns a Search component.
Why this matters:
- Search component: Enables filtering columns by name
- items_names="Columns": Labels the search appropriately
"""
result = columns_manager.mk_all_columns()
assert isinstance(result, Search)
assert result.items_names == "Columns"
def test_all_columns_search_has_correct_configuration(self, columns_manager):
"""Test that Search component is configured correctly.
Why these elements matter:
- items: Contains all column definitions
- get_attr: Extracts col_id for search matching
- template: Uses mk_column_label for rendering
"""
result = columns_manager.mk_all_columns()
# Should have all 3 columns
assert len(result.items) == 3
# get_attr should return col_id
col_def = result.items[0]
assert result.get_attr(col_def) == col_def.col_id
def test_all_columns_renders_all_column_labels(self, columns_manager):
"""Test that all columns render produces labels for all columns.
Why this matters:
- All columns visible in list
- Each column has its label rendered
"""
search = columns_manager.mk_all_columns()
rendered = search.render()
# Should find 3 column labels in the results
results_div = find_one(rendered, Div(id=f"{search._id}-results"))
assert results_div is not None
# Each column should have a label with tcolman_ prefix
for col_id in ["name", "age", "email"]:
label = find_one(results_div, Div(id=f"tcolman_{columns_manager._id}-{col_id}"))
assert label is not None, f"Column label for '{col_id}' should be present"
Why this matters:
- All columns visible in list
- Each column has its label rendered
"""
search = columns_manager.mk_all_columns()
rendered = search.render()
# Should find 3 column labels in the results
results_div = find_one(rendered, Div(id=f"{search._id}-results"))
assert results_div is not None
# Each column should have a label with tcolman_ prefix
for col_id in ["name", "age", "email"]:
label = find_one(results_div, Div(id=f"tcolman_{columns_manager._id}-{col_id}"))
assert label is not None, f"Column label for '{col_id}' should be present"

View File

@@ -4,7 +4,8 @@ Tests for the FormulaEngine facade.
import numpy as np
import pytest
from myfasthtml.core.formula.dsl.exceptions import FormulaSyntaxError, FormulaCycleError
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.core.formula.dsl.exceptions import FormulaCycleError
from myfasthtml.core.formula.engine import FormulaEngine
@@ -72,9 +73,9 @@ class TestSetFormula:
assert totals[2] == 50
def test_i_cannot_set_invalid_formula(self):
"""Test that invalid formula syntax raises FormulaSyntaxError."""
"""Test that invalid formula syntax raises DSLSyntaxError."""
engine = make_engine()
with pytest.raises(FormulaSyntaxError):
with pytest.raises(DSLSyntaxError):
engine.set_formula("t", "col", "{Price} * * {Qty}")
def test_i_cannot_set_formula_with_cycle(self):

View File

@@ -11,7 +11,7 @@ from myfasthtml.core.formula.dataclasses import (
LiteralNode,
FormulaDefinition,
)
from myfasthtml.core.formula.dsl.exceptions import FormulaSyntaxError
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.core.formula.engine import parse_formula
@@ -177,12 +177,12 @@ def test_i_can_parse_nested_functions():
"123 + + 456", # double operator
])
def test_i_cannot_parse_invalid_syntax(formula_text):
"""Test that invalid syntax raises FormulaSyntaxError."""
with pytest.raises(FormulaSyntaxError):
"""Test that invalid syntax raises DSLSyntaxError."""
with pytest.raises(DSLSyntaxError):
parse_formula(formula_text)
def test_i_cannot_parse_unclosed_brace():
"""Test that an unclosed brace raises FormulaSyntaxError."""
with pytest.raises(FormulaSyntaxError):
"""Test that an unclosed brace raises DSLSyntaxError."""
with pytest.raises(DSLSyntaxError):
parse_formula("{Price")

View File

@@ -30,7 +30,9 @@ def session():
@pytest.fixture
def parent(session):
return SingleInstance(session=session, _id="test_parent_id")
instance = SingleInstance(session=session, _id="test_parent_id")
instance.get_formula_engine = lambda: None
return instance
@pytest.fixture