Fixed Syntax validation and autocompletion. Fixed unit tests
This commit is contained in:
@@ -10,6 +10,9 @@ from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
|||||||
from myfasthtml.controls.helpers import icons, mk
|
from myfasthtml.controls.helpers import icons, mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.constants import ColumnType
|
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.core.instances import MultipleInstance
|
||||||
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, chevron_left20_regular
|
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, chevron_left20_regular
|
||||||
|
|
||||||
@@ -37,11 +40,11 @@ class Commands(BaseCommands):
|
|||||||
self._owner,
|
self._owner,
|
||||||
self._owner.show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML")
|
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",
|
return Command(f"UpdateColumn",
|
||||||
f"Update column {col_id}",
|
f"Update column {col_id}",
|
||||||
self._owner,
|
self._owner,
|
||||||
self._owner.update_column,
|
self._owner.save_column_details,
|
||||||
kwargs={"col_id": col_id}
|
kwargs={"col_id": col_id}
|
||||||
).htmx(target=f"#{self._id}", swap="innerHTML")
|
).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||||
|
|
||||||
@@ -64,41 +67,36 @@ class DataGridColumnsManager(MultipleInstance):
|
|||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self._new_column = False
|
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")
|
self._formula_editor = DataGridFormulaEditor(self, conf=conf, _id=f"{self._id}-formula-editor")
|
||||||
|
DslsManager.register(completion_engine, FormulaParser())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def columns(self):
|
def columns(self):
|
||||||
return self._parent.get_state().columns
|
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]
|
cols_defs = [c for c in self.columns if c.col_id == col_id]
|
||||||
if not cols_defs:
|
if not cols_defs:
|
||||||
col_def = DataGridColumnState(col_id, -1)
|
return None
|
||||||
else:
|
|
||||||
col_def = cols_defs[0].copy() if copy else cols_defs[0]
|
|
||||||
|
|
||||||
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)]:
|
for k, v in [(k, v) for k, v in updates.items() if hasattr(col_def, k)]:
|
||||||
|
if k == "type":
|
||||||
if k == "visible":
|
|
||||||
col_def.visible = v == "on"
|
|
||||||
elif k == "type":
|
|
||||||
col_def.type = ColumnType(v)
|
col_def.type = ColumnType(v)
|
||||||
elif k == "width":
|
elif k == "width":
|
||||||
col_def.width = int(v)
|
col_def.width = int(v)
|
||||||
@@ -112,7 +110,7 @@ class DataGridColumnsManager(MultipleInstance):
|
|||||||
|
|
||||||
def toggle_column(self, col_id):
|
def toggle_column(self, col_id):
|
||||||
logger.debug(f"toggle_column {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:
|
if col_def is None:
|
||||||
logger.debug(f" column '{col_id}' is not found.")
|
logger.debug(f" column '{col_id}' is not found.")
|
||||||
return Div(f"Column '{col_id}' not found")
|
return Div(f"Column '{col_id}' not found")
|
||||||
@@ -123,7 +121,7 @@ class DataGridColumnsManager(MultipleInstance):
|
|||||||
|
|
||||||
def show_column_details(self, col_id):
|
def show_column_details(self, col_id):
|
||||||
logger.debug(f"show_column_details {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:
|
if col_def is None:
|
||||||
logger.debug(f" column '{col_id}' is not found.")
|
logger.debug(f" column '{col_id}' is not found.")
|
||||||
return Div(f"Column '{col_id}' not found")
|
return Div(f"Column '{col_id}' not found")
|
||||||
@@ -133,25 +131,21 @@ class DataGridColumnsManager(MultipleInstance):
|
|||||||
def show_all_columns(self):
|
def show_all_columns(self):
|
||||||
return self._mk_inner_content()
|
return self._mk_inner_content()
|
||||||
|
|
||||||
def update_column(self, col_id, client_response):
|
def save_column_details(self, col_id, client_response):
|
||||||
logger.debug(f"update_column {col_id=}, {client_response=}")
|
logger.debug(f"save_column_details {col_id=}, {client_response=}")
|
||||||
col_def = self._get_col_def_from_col_id(col_id, client_response, copy=False)
|
self._get_updated_col_def_from_col_id(col_id, client_response, copy=False)
|
||||||
if col_def is None:
|
self._parent.save_state()
|
||||||
logger.debug(f" column '{col_id}' is not found.")
|
|
||||||
else:
|
|
||||||
# save the new values
|
|
||||||
self._parent.save_state()
|
|
||||||
|
|
||||||
return self._mk_inner_content()
|
return self._mk_inner_content()
|
||||||
|
|
||||||
def on_new_column(self):
|
def on_new_column(self):
|
||||||
self._new_column = True
|
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)
|
return self.mk_column_details(col_def)
|
||||||
|
|
||||||
def on_column_type_changed(self, col_id, client_response):
|
def on_column_type_changed(self, col_id, client_response):
|
||||||
logger.debug(f"on_column_type_changed {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)
|
return self.mk_column_details(col_def)
|
||||||
|
|
||||||
def _register_formula(self, col_def) -> None:
|
def _register_formula(self, col_def) -> None:
|
||||||
@@ -240,7 +234,7 @@ class DataGridColumnsManager(MultipleInstance):
|
|||||||
legend="Column details",
|
legend="Column details",
|
||||||
cls="fieldset border-base-300 rounded-box"
|
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()),
|
on_cancel=self.commands.show_all_columns()),
|
||||||
cls="mb-1",
|
cls="mb-1",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ class BaseCompletionEngine(ABC):
|
|||||||
Generate suggestions for the given context.
|
Generate suggestions for the given context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
context: The detected completion context
|
context: The detected completion context (from the parser)
|
||||||
scope: The detected scope
|
scope: The detected scope (table, column, row, cell...)
|
||||||
prefix: The current word prefix (for filtering)
|
prefix: The current word prefix (for filtering)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
50
src/myfasthtml/core/dsl/exceptions.py
Normal file
50
src/myfasthtml/core/dsl/exceptions.py
Normal 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 ""))
|
||||||
@@ -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
|
||||||
|
|
||||||
|
__all__ = ["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 ""))
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Provides context-aware suggestions for:
|
|||||||
- Keywords: ``if``, ``else``, ``and``, ``or``, ``not``, ``WHERE``
|
- Keywords: ``if``, ``else``, ``and``, ``or``, ``not``, ``WHERE``
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
|
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
|
||||||
from myfasthtml.core.dsl.types import Position, Suggestion
|
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):
|
class FormulaCompletionEngine(BaseCompletionEngine):
|
||||||
"""
|
"""
|
||||||
Context-aware completion engine for formula expressions.
|
Context-aware completion engine for formula expressions.
|
||||||
@@ -42,7 +58,7 @@ class FormulaCompletionEngine(BaseCompletionEngine):
|
|||||||
"""Formula has no scope — always the same single-expression scope."""
|
"""Formula has no scope — always the same single-expression scope."""
|
||||||
return None
|
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.
|
Detect completion context based on cursor position in formula text.
|
||||||
|
|
||||||
@@ -52,129 +68,80 @@ class FormulaCompletionEngine(BaseCompletionEngine):
|
|||||||
scope: Unused (formulas have no scopes).
|
scope: Unused (formulas have no scopes).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Context string: ``"column_ref"``, ``"cross_table"``,
|
FormulaContext describing the kind of completion expected.
|
||||||
``"function"``, ``"keyword"``, or ``"general"``.
|
|
||||||
"""
|
"""
|
||||||
# Get text up to cursor
|
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
line_idx = min(cursor.line, len(lines) - 1)
|
line_text = lines[min(cursor.line, len(lines) - 1)]
|
||||||
line_text = lines[line_idx]
|
|
||||||
text_before = line_text[:cursor.ch]
|
text_before = line_text[:cursor.ch]
|
||||||
|
|
||||||
# Check if we are inside a { ... } reference
|
|
||||||
last_brace = text_before.rfind("{")
|
last_brace = text_before.rfind("{")
|
||||||
if last_brace >= 0:
|
if last_brace >= 0:
|
||||||
inside = text_before[last_brace + 1:]
|
inside = text_before[last_brace + 1:]
|
||||||
if "}" not in inside:
|
if "}" not in inside:
|
||||||
if "." in inside:
|
if "." in inside:
|
||||||
return "cross_table"
|
cross_table_name = inside[:inside.rfind(".")]
|
||||||
return "column_ref"
|
return FormulaContext("cross_table", cross_table_name)
|
||||||
|
return FormulaContext("column_ref")
|
||||||
|
|
||||||
# Check if we are typing a function name (alphanumeric at word start)
|
if re.search(r"[a-z_][a-z0-9_]*$", text_before, re.IGNORECASE):
|
||||||
word_match = re.search(r"[a-z_][a-z0-9_]*$", text_before, re.IGNORECASE)
|
return FormulaContext("function_or_keyword")
|
||||||
if word_match:
|
|
||||||
return "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.
|
Generate suggestions based on the detected context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The full formula text.
|
context: FormulaContext from ``detect_context``.
|
||||||
cursor: Cursor position.
|
scope: Unused (formulas have no scopes).
|
||||||
scope: Unused.
|
prefix: Current word prefix (filtering handled by base class).
|
||||||
context: String from ``detect_context``.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of Suggestion objects.
|
List of Suggestion objects.
|
||||||
"""
|
"""
|
||||||
suggestions = []
|
match context.kind:
|
||||||
|
case "column_ref":
|
||||||
if context == "column_ref":
|
return self._column_suggestions(self.table_name)
|
||||||
# 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 ""
|
|
||||||
|
|
||||||
# Suggest columns from the referenced table
|
case "cross_table":
|
||||||
if table_prefix:
|
return self._column_suggestions(context.cross_table_name)
|
||||||
suggestions += self._column_suggestions(table_prefix)
|
|
||||||
else:
|
case "function_or_keyword":
|
||||||
suggestions += self._table_suggestions()
|
return self._function_suggestions() + self._keyword_suggestions()
|
||||||
|
|
||||||
elif context == "function_or_keyword":
|
case _: # general
|
||||||
suggestions += self._function_suggestions()
|
return (
|
||||||
suggestions += self._keyword_suggestions()
|
self._function_suggestions()
|
||||||
|
+ self._keyword_suggestions()
|
||||||
else: # general
|
+ [Suggestion("{", "Column reference", "keyword")]
|
||||||
suggestions += self._function_suggestions()
|
)
|
||||||
suggestions += self._keyword_suggestions()
|
|
||||||
suggestions += [
|
|
||||||
Suggestion(
|
|
||||||
label="{",
|
|
||||||
detail="Column reference",
|
|
||||||
insert_text="{",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return suggestions
|
|
||||||
|
|
||||||
# ==================== Private helpers ====================
|
# ==================== 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."""
|
"""Get column name suggestions for a table."""
|
||||||
try:
|
try:
|
||||||
columns = self.provider.list_columns(table_name)
|
columns = self.provider.list_columns(table_name)
|
||||||
return [
|
return [Suggestion(col, f"Column from {table_name}", "column") for col in (columns or [])]
|
||||||
Suggestion(
|
|
||||||
label=col,
|
|
||||||
detail=f"Column from {table_name}",
|
|
||||||
insert_text=col,
|
|
||||||
)
|
|
||||||
for col in (columns or [])
|
|
||||||
]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _table_suggestions(self) -> list:
|
def _table_suggestions(self) -> list[Suggestion]:
|
||||||
"""Get table name suggestions."""
|
"""Get table name suggestions."""
|
||||||
try:
|
try:
|
||||||
tables = self.provider.list_tables()
|
tables = self.provider.list_tables()
|
||||||
return [
|
return [Suggestion(t, "Table", "table") for t in (tables or [])]
|
||||||
Suggestion(
|
|
||||||
label=t,
|
|
||||||
detail="Table",
|
|
||||||
insert_text=t,
|
|
||||||
)
|
|
||||||
for t in (tables or [])
|
|
||||||
]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _function_suggestions(self) -> list:
|
def _function_suggestions(self) -> list[Suggestion]:
|
||||||
"""Get built-in function name suggestions."""
|
"""Get built-in function name suggestions."""
|
||||||
return [
|
return [
|
||||||
Suggestion(
|
Suggestion(f"{name}(", name, "function")
|
||||||
label=name,
|
|
||||||
detail="Function",
|
|
||||||
insert_text=f"{name}(",
|
|
||||||
)
|
|
||||||
for name in sorted(BUILTIN_FUNCTIONS.keys())
|
for name in sorted(BUILTIN_FUNCTIONS.keys())
|
||||||
]
|
]
|
||||||
|
|
||||||
def _keyword_suggestions(self) -> list:
|
def _keyword_suggestions(self) -> list[Suggestion]:
|
||||||
"""Get keyword suggestions."""
|
"""Get keyword suggestions."""
|
||||||
return [
|
return [Suggestion(kw, "Keyword", "keyword") for kw in FORMULA_KEYWORDS]
|
||||||
Suggestion(label=kw, detail="Keyword", insert_text=kw)
|
|
||||||
for kw in FORMULA_KEYWORDS
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -3,25 +3,6 @@ class FormulaError(Exception):
|
|||||||
pass
|
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):
|
class FormulaValidationError(FormulaError):
|
||||||
"""Raised when the formula is syntactically correct but semantically invalid."""
|
"""Raised when the formula is syntactically correct but semantically invalid."""
|
||||||
pass
|
pass
|
||||||
@@ -29,7 +10,7 @@ class FormulaValidationError(FormulaError):
|
|||||||
|
|
||||||
class FormulaCycleError(FormulaError):
|
class FormulaCycleError(FormulaError):
|
||||||
"""Raised when formula dependencies contain a cycle."""
|
"""Raised when formula dependencies contain a cycle."""
|
||||||
|
|
||||||
def __init__(self, cycle_nodes):
|
def __init__(self, cycle_nodes):
|
||||||
self.cycle_nodes = cycle_nodes
|
self.cycle_nodes = cycle_nodes
|
||||||
super().__init__(f"Circular dependency detected involving: {', '.join(cycle_nodes)}")
|
super().__init__(f"Circular dependency detected involving: {', '.join(cycle_nodes)}")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ No indentation handling needed — formulas are single-line expressions.
|
|||||||
"""
|
"""
|
||||||
from lark import Lark, UnexpectedInput
|
from lark import Lark, UnexpectedInput
|
||||||
|
|
||||||
from .exceptions import FormulaSyntaxError
|
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
|
||||||
from .grammar import FORMULA_GRAMMAR
|
from .grammar import FORMULA_GRAMMAR
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ class FormulaParser:
|
|||||||
lark.Tree: The parsed AST.
|
lark.Tree: The parsed AST.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FormulaSyntaxError: If the text has syntax errors.
|
DSLSyntaxError: If the text has syntax errors.
|
||||||
"""
|
"""
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -52,7 +52,7 @@ class FormulaParser:
|
|||||||
if hasattr(e, "get_context"):
|
if hasattr(e, "get_context"):
|
||||||
context = e.get_context(text)
|
context = e.get_context(text)
|
||||||
|
|
||||||
raise FormulaSyntaxError(
|
raise DSLSyntaxError(
|
||||||
message=self._format_error_message(e),
|
message=self._format_error_message(e),
|
||||||
line=getattr(e, "line", None),
|
line=getattr(e, "line", None),
|
||||||
column=getattr(e, "column", None),
|
column=getattr(e, "column", None),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def parse_formula(text: str) -> FormulaDefinition | None:
|
|||||||
FormulaDefinition on success, None if text is empty.
|
FormulaDefinition on success, None if text is empty.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FormulaSyntaxError: If the formula text is syntactically invalid.
|
DSLSyntaxError: If the formula text is syntactically invalid.
|
||||||
"""
|
"""
|
||||||
text = text.strip() if text else ""
|
text = text.strip() if text else ""
|
||||||
if not text:
|
if not text:
|
||||||
@@ -80,7 +80,7 @@ class FormulaEngine:
|
|||||||
formula_text: The formula expression string.
|
formula_text: The formula expression string.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FormulaSyntaxError: If the formula is syntactically invalid.
|
DSLSyntaxError: If the formula is syntactically invalid.
|
||||||
FormulaCycleError: If the formula would create a circular dependency.
|
FormulaCycleError: If the formula would create a circular dependency.
|
||||||
"""
|
"""
|
||||||
formula_text = formula_text.strip() if formula_text else ""
|
formula_text = formula_text.strip() if formula_text else ""
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from starlette.routing import Mount
|
|||||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||||
from myfasthtml.core.dsl.types import Position
|
from myfasthtml.core.dsl.types import Position
|
||||||
from myfasthtml.core.dsls import DslsManager
|
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
|
from myfasthtml.test.MyFT import MyFT
|
||||||
|
|
||||||
utils_app, utils_rt = fast_app()
|
utils_app, utils_rt = fast_app()
|
||||||
|
|||||||
@@ -1,469 +1,415 @@
|
|||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
import pytest
|
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.Search import Search
|
||||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
||||||
from myfasthtml.core.constants import ColumnType
|
from myfasthtml.core.constants import ColumnType
|
||||||
from myfasthtml.core.instances import InstancesManager, MultipleInstance
|
from myfasthtml.core.instances import InstancesManager, MultipleInstance
|
||||||
from myfasthtml.test.matcher import (
|
from myfasthtml.test.matcher import (
|
||||||
matches, find_one, find, Contains, TestIcon, TestObject
|
matches, find_one, find, Contains, TestIcon, TestObject
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MockDatagridState:
|
class MockDatagridState:
|
||||||
"""Mock state object that mimics DatagridState."""
|
"""Mock state object that mimics DatagridState."""
|
||||||
columns: list = field(default_factory=list)
|
columns: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class MockDataGrid(MultipleInstance):
|
class MockDataGrid(MultipleInstance):
|
||||||
"""Mock DataGrid parent for testing DataGridColumnsManager."""
|
"""Mock DataGrid parent for testing DataGridColumnsManager."""
|
||||||
|
|
||||||
def __init__(self, parent, columns=None, _id=None):
|
def __init__(self, parent, columns=None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self._state = MockDatagridState(columns=columns or [])
|
self._state = MockDatagridState(columns=columns or [])
|
||||||
self._save_state_called = False
|
self._save_state_called = False
|
||||||
|
|
||||||
def get_state(self):
|
def get_state(self):
|
||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
def save_state(self):
|
def save_state(self):
|
||||||
self._save_state_called = True
|
self._save_state_called = True
|
||||||
|
|
||||||
|
def get_table_name(self):
|
||||||
|
return "mock_table"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_datagrid(root_instance):
|
def mock_datagrid(root_instance):
|
||||||
"""Create a mock DataGrid with sample columns."""
|
"""Create a mock DataGrid with sample columns."""
|
||||||
columns = [
|
columns = [
|
||||||
DataGridColumnState(col_id="name", col_index=0, title="Name", type=ColumnType.Text, visible=True),
|
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="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),
|
DataGridColumnState(col_id="email", col_index=2, title="Email", type=ColumnType.Text, visible=False),
|
||||||
]
|
]
|
||||||
yield MockDataGrid(root_instance, columns=columns, _id="test-datagrid")
|
yield MockDataGrid(root_instance, columns=columns, _id="test-datagrid")
|
||||||
InstancesManager.reset()
|
InstancesManager.reset()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def columns_manager(mock_datagrid):
|
def columns_manager(mock_datagrid):
|
||||||
"""Create a DataGridColumnsManager instance for testing."""
|
"""Create a DataGridColumnsManager instance for testing."""
|
||||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
yield DataGridColumnsManager(mock_datagrid)
|
yield DataGridColumnsManager(mock_datagrid)
|
||||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
class TestDataGridColumnsManagerBehaviour:
|
class TestDataGridColumnsManagerBehaviour:
|
||||||
"""Tests for DataGridColumnsManager behavior and logic."""
|
"""Tests for DataGridColumnsManager behavior and logic."""
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Initialization
|
# Get Column Definition
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def test_i_can_create_columns_manager(self, mock_datagrid):
|
def test_i_can_get_existing_column_by_id(self, columns_manager):
|
||||||
"""Test that DataGridColumnsManager can be created with a DataGrid parent."""
|
"""Test finding an existing column by its ID."""
|
||||||
cm = DataGridColumnsManager(mock_datagrid)
|
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||||
|
|
||||||
assert cm is not None
|
assert col_def is not None
|
||||||
assert cm._parent == mock_datagrid
|
assert col_def.col_id == "name"
|
||||||
assert isinstance(cm.commands, Commands)
|
assert col_def.title == "Name"
|
||||||
|
|
||||||
# =========================================================================
|
def test_i_cannot_get_nonexistent_column(self, columns_manager):
|
||||||
# Columns Property
|
"""Test that getting a nonexistent column returns None."""
|
||||||
# =========================================================================
|
col_def = columns_manager._get_col_def_from_col_id("nonexistent")
|
||||||
|
|
||||||
def test_columns_property_returns_parent_state_columns(self, columns_manager, mock_datagrid):
|
assert col_def is None
|
||||||
"""Test that columns property returns columns from parent's state."""
|
|
||||||
columns = columns_manager.columns
|
def test_i_can_get_and_update_column_state(self, columns_manager):
|
||||||
|
"""Test that get_col_def_from_col_id updates the column state."""
|
||||||
assert columns == mock_datagrid.get_state().columns
|
updates = {"title": "New Name", "visible": "on", "type": "Number", "width": 200}
|
||||||
assert len(columns) == 3
|
col_def = columns_manager._get_updated_col_def_from_col_id("name", updates)
|
||||||
assert columns[0].col_id == "name"
|
assert col_def.title == "New Name"
|
||||||
|
assert col_def.visible is True
|
||||||
# =========================================================================
|
assert col_def.type == ColumnType.Number
|
||||||
# Get Column Definition
|
assert col_def.width == 200
|
||||||
# =========================================================================
|
|
||||||
|
def test_i_can_get_and_update_column_state_visible_false(self, columns_manager):
|
||||||
def test_i_can_get_existing_column_by_id(self, columns_manager):
|
"""Test that get_col_def_from_col_id updates the column state."""
|
||||||
"""Test finding an existing column by its ID."""
|
updates = {} # visible is missing in the update => It must be set to False
|
||||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
col_def = columns_manager._get_updated_col_def_from_col_id("name", updates)
|
||||||
|
|
||||||
assert col_def is not None
|
assert col_def.visible is False
|
||||||
assert col_def.col_id == "name"
|
|
||||||
assert col_def.title == "Name"
|
# =========================================================================
|
||||||
|
# Toggle Column Visibility
|
||||||
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")
|
@pytest.mark.parametrize("col_id, initial_visible, expected_visible", [
|
||||||
|
("name", True, False), # visible -> hidden
|
||||||
assert col_def is None
|
("email", False, True), # hidden -> visible
|
||||||
|
])
|
||||||
# =========================================================================
|
def test_i_can_toggle_column_visibility(self, columns_manager, col_id, initial_visible, expected_visible):
|
||||||
# Toggle Column Visibility
|
"""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
|
||||||
@pytest.mark.parametrize("col_id, initial_visible, expected_visible", [
|
|
||||||
("name", True, False), # visible -> hidden
|
columns_manager.toggle_column(col_id)
|
||||||
("email", False, True), # hidden -> visible
|
|
||||||
])
|
col_def = columns_manager._get_col_def_from_col_id(col_id)
|
||||||
def test_i_can_toggle_column_visibility(self, columns_manager, col_id, initial_visible, expected_visible):
|
assert col_def.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)
|
def test_toggle_column_saves_state(self, columns_manager, mock_datagrid):
|
||||||
assert col_def.visible == initial_visible
|
"""Test that toggle_column calls save_state on parent."""
|
||||||
|
mock_datagrid._save_state_called = False
|
||||||
columns_manager.toggle_column(col_id)
|
|
||||||
|
columns_manager.toggle_column("name")
|
||||||
assert col_def.visible == expected_visible
|
|
||||||
|
assert mock_datagrid._save_state_called is True
|
||||||
def test_toggle_column_saves_state(self, columns_manager, mock_datagrid):
|
|
||||||
"""Test that toggle_column calls save_state on parent."""
|
def test_toggle_column_returns_column_label(self, columns_manager):
|
||||||
mock_datagrid._save_state_called = False
|
"""Test that toggle_column returns an HTML element."""
|
||||||
|
result = columns_manager.toggle_column("name")
|
||||||
columns_manager.toggle_column("name")
|
|
||||||
|
assert isinstance(result, FT)
|
||||||
assert mock_datagrid._save_state_called is True
|
|
||||||
|
def test_i_cannot_toggle_nonexistent_column(self, columns_manager):
|
||||||
def test_toggle_column_returns_column_label(self, columns_manager):
|
"""Test that toggling a nonexistent column returns an error message."""
|
||||||
"""Test that toggle_column returns the updated column label."""
|
result = columns_manager.toggle_column("nonexistent")
|
||||||
result = columns_manager.toggle_column("name")
|
|
||||||
|
expected = Div("Column 'nonexistent' not found")
|
||||||
# Result should be a Div with the column label structure
|
assert matches(result, expected)
|
||||||
assert result is not None
|
|
||||||
assert hasattr(result, 'tag')
|
# =========================================================================
|
||||||
|
# Show All Columns
|
||||||
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")
|
def test_show_all_columns_returns_search_component(self, columns_manager):
|
||||||
|
"""Test that mk_all_columns returns a Search component."""
|
||||||
expected = Div("Column 'nonexistent' not found")
|
result = columns_manager.mk_all_columns()
|
||||||
assert matches(result, expected)
|
|
||||||
|
assert isinstance(result, Search)
|
||||||
# =========================================================================
|
|
||||||
# Show Column Details
|
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()
|
||||||
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."""
|
assert result.items_names == "Columns"
|
||||||
result = columns_manager.show_column_details("name")
|
assert len(result.items) == 3
|
||||||
|
col_def = result.items[0]
|
||||||
# Should contain a Form - check by finding form tag in children
|
assert result.get_attr(col_def) == col_def.col_id
|
||||||
expected = Form()
|
|
||||||
del(expected.attrs["enctype"]) # hack. We don't know why enctype is added
|
# =========================================================================
|
||||||
forms = find(result, expected)
|
# Update Column
|
||||||
assert len(forms) == 1, "Should contain exactly one form"
|
# =========================================================================
|
||||||
|
|
||||||
def test_i_cannot_show_details_for_nonexistent_column(self, columns_manager):
|
def test_i_can_update_column_title(self, columns_manager):
|
||||||
"""Test that showing details for nonexistent column returns error message."""
|
"""Test updating a column's title via client_response."""
|
||||||
result = columns_manager.show_column_details("nonexistent")
|
columns_manager.save_column_details("name", {"title": "New Name"})
|
||||||
|
|
||||||
expected = Div("Column 'nonexistent' not found")
|
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||||
assert matches(result, expected)
|
assert col_def.title == "New Name"
|
||||||
|
|
||||||
# =========================================================================
|
def test_i_can_update_column_visibility_via_form(self, columns_manager):
|
||||||
# Show All Columns
|
"""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
|
||||||
def test_show_all_columns_returns_search_component(self, columns_manager):
|
|
||||||
"""Test that show_all_columns returns a Search component."""
|
# Unchecked checkbox sends nothing, checked sends "on"
|
||||||
result = columns_manager.show_all_columns()
|
columns_manager.save_column_details("name", {"visible": "off"}) # Not "on" means unchecked
|
||||||
|
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||||
assert isinstance(result, Search)
|
assert col_def.visible is False
|
||||||
|
|
||||||
def test_show_all_columns_contains_all_columns(self, columns_manager):
|
# Check it back on
|
||||||
"""Test that show_all_columns Search contains all columns."""
|
columns_manager.save_column_details("name", {"visible": "on"})
|
||||||
result = columns_manager.show_all_columns()
|
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||||
|
assert col_def.visible is True
|
||||||
assert len(result.items) == 3
|
|
||||||
|
def test_i_can_update_column_type(self, columns_manager):
|
||||||
# =========================================================================
|
"""Test updating a column's type."""
|
||||||
# Update Column
|
columns_manager.save_column_details("name", {"type": "Number"})
|
||||||
# =========================================================================
|
|
||||||
|
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||||
def test_i_can_update_column_title(self, columns_manager):
|
assert col_def.type == ColumnType.Number
|
||||||
"""Test updating a column's title via client_response."""
|
|
||||||
client_response = {"title": "New Name"}
|
def test_i_can_update_column_width(self, columns_manager):
|
||||||
|
"""Test updating a column's width."""
|
||||||
columns_manager.update_column("name", client_response)
|
columns_manager.save_column_details("name", {"width": "200"})
|
||||||
|
|
||||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||||
assert col_def.title == "New Name"
|
assert col_def.width == 200
|
||||||
|
|
||||||
def test_i_can_update_column_visibility_via_form(self, columns_manager):
|
def test_update_column_saves_state(self, columns_manager, mock_datagrid):
|
||||||
"""Test updating column visibility via checkbox form value."""
|
"""Test that save_column_details calls save_state on parent."""
|
||||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
mock_datagrid._save_state_called = False
|
||||||
assert col_def.visible is True
|
|
||||||
|
columns_manager.save_column_details("name", {"title": "Updated"})
|
||||||
# Unchecked checkbox sends nothing, checked sends "on"
|
|
||||||
client_response = {"visible": "off"} # Not "on" means unchecked
|
assert mock_datagrid._save_state_called is True
|
||||||
columns_manager.update_column("name", client_response)
|
|
||||||
|
def test_update_column_ignores_unknown_attributes(self, columns_manager):
|
||||||
assert col_def.visible is False
|
"""Test that save_column_details ignores attributes not in DataGridColumnState."""
|
||||||
|
columns_manager.save_column_details("name", {"unknown_attr": "value", "title": "New Title"})
|
||||||
# Check it back on
|
|
||||||
client_response = {"visible": "on"}
|
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||||
columns_manager.update_column("name", client_response)
|
assert col_def.title == "New Title"
|
||||||
|
assert not hasattr(col_def, "unknown_attr")
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDataGridColumnsManagerRender:
|
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
|
Why these elements matter:
|
||||||
def columns_manager(self, mock_datagrid):
|
- id: Required for HTMX targeting in commands
|
||||||
"""Create a fresh DataGridColumnsManager for render tests."""
|
- Contains Search component: Main content for column list
|
||||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
"""
|
||||||
cm = DataGridColumnsManager(mock_datagrid)
|
html = columns_manager.render()
|
||||||
yield cm
|
|
||||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
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.
|
||||||
|
|
||||||
# =========================================================================
|
Why these elements matter:
|
||||||
# Global Structure
|
- 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):
|
Why this matters:
|
||||||
"""Test that DataGridColumnsManager renders with correct global structure.
|
- 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:
|
Why this matters:
|
||||||
- id: Required for HTMX targeting in commands
|
- No checked attribute: Indicates column is hidden
|
||||||
- Contains Search component: Main content for column list
|
- Visual feedback for user
|
||||||
"""
|
"""
|
||||||
html = columns_manager.render()
|
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(
|
Why these elements matter:
|
||||||
TestObject(Search), # Search component
|
- col_id field (readonly): Shows column identifier
|
||||||
id=columns_manager._id,
|
- 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.
|
||||||
|
|
||||||
# =========================================================================
|
Why this matters:
|
||||||
# mk_column_label
|
- 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):
|
Why these elements matter:
|
||||||
"""Test that column label contains checkbox and navigation to details.
|
- 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:
|
Why this matters:
|
||||||
- Checkbox (Input type=checkbox): Controls column visibility
|
- All columns visible in list
|
||||||
- Label with column ID: Identifies the column
|
- Each column has its label rendered
|
||||||
- Chevron icon: Indicates navigation to details
|
"""
|
||||||
- id with tcolman_ prefix: Required for HTMX swap targeting
|
search = columns_manager.mk_all_columns()
|
||||||
"""
|
rendered = search.render()
|
||||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
|
||||||
label = columns_manager.mk_column_label(col_def)
|
# Should find 3 column labels in the results
|
||||||
|
results_div = find_one(rendered, Div(id=f"{search._id}-results"))
|
||||||
# Should have the correct ID pattern
|
assert results_div is not None
|
||||||
expected = Div(
|
|
||||||
id=f"tcolman_{columns_manager._id}-name",
|
# Each column should have a label with tcolman_ prefix
|
||||||
cls=Contains("flex"),
|
for col_id in ["name", "age", "email"]:
|
||||||
)
|
label = find_one(results_div, Div(id=f"tcolman_{columns_manager._id}-{col_id}"))
|
||||||
assert matches(label, expected)
|
assert label is not None, f"Column label for '{col_id}' should be present"
|
||||||
|
|
||||||
# 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"
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ Tests for the FormulaEngine facade.
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
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
|
from myfasthtml.core.formula.engine import FormulaEngine
|
||||||
|
|
||||||
|
|
||||||
@@ -72,9 +73,9 @@ class TestSetFormula:
|
|||||||
assert totals[2] == 50
|
assert totals[2] == 50
|
||||||
|
|
||||||
def test_i_cannot_set_invalid_formula(self):
|
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()
|
engine = make_engine()
|
||||||
with pytest.raises(FormulaSyntaxError):
|
with pytest.raises(DSLSyntaxError):
|
||||||
engine.set_formula("t", "col", "{Price} * * {Qty}")
|
engine.set_formula("t", "col", "{Price} * * {Qty}")
|
||||||
|
|
||||||
def test_i_cannot_set_formula_with_cycle(self):
|
def test_i_cannot_set_formula_with_cycle(self):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from myfasthtml.core.formula.dataclasses import (
|
|||||||
LiteralNode,
|
LiteralNode,
|
||||||
FormulaDefinition,
|
FormulaDefinition,
|
||||||
)
|
)
|
||||||
from myfasthtml.core.formula.dsl.exceptions import FormulaSyntaxError
|
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
|
||||||
from myfasthtml.core.formula.engine import parse_formula
|
from myfasthtml.core.formula.engine import parse_formula
|
||||||
|
|
||||||
|
|
||||||
@@ -177,12 +177,12 @@ def test_i_can_parse_nested_functions():
|
|||||||
"123 + + 456", # double operator
|
"123 + + 456", # double operator
|
||||||
])
|
])
|
||||||
def test_i_cannot_parse_invalid_syntax(formula_text):
|
def test_i_cannot_parse_invalid_syntax(formula_text):
|
||||||
"""Test that invalid syntax raises FormulaSyntaxError."""
|
"""Test that invalid syntax raises DSLSyntaxError."""
|
||||||
with pytest.raises(FormulaSyntaxError):
|
with pytest.raises(DSLSyntaxError):
|
||||||
parse_formula(formula_text)
|
parse_formula(formula_text)
|
||||||
|
|
||||||
|
|
||||||
def test_i_cannot_parse_unclosed_brace():
|
def test_i_cannot_parse_unclosed_brace():
|
||||||
"""Test that an unclosed brace raises FormulaSyntaxError."""
|
"""Test that an unclosed brace raises DSLSyntaxError."""
|
||||||
with pytest.raises(FormulaSyntaxError):
|
with pytest.raises(DSLSyntaxError):
|
||||||
parse_formula("{Price")
|
parse_formula("{Price")
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ def session():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def parent(session):
|
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
|
@pytest.fixture
|
||||||
|
|||||||
Reference in New Issue
Block a user