Working on Formating DSL completion

This commit is contained in:
2026-01-31 19:09:14 +01:00
parent 778e5ac69d
commit d7ec99c3d9
77 changed files with 7563 additions and 63 deletions

View File

View File

@@ -0,0 +1,770 @@
"""
Tests for formatting DSL autocompletion.
Tests for scope detection, context detection, suggestions, and engine integration.
"""
import pytest
from typing import Any
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
from myfasthtml.core.formatting.dsl.completion.contexts import (
Context,
DetectedScope,
detect_scope,
detect_context,
)
from myfasthtml.core.formatting.dsl.completion.suggestions import get_suggestions
from myfasthtml.core.formatting.dsl.completion.engine import (
FormattingCompletionEngine,
get_completions,
)
from myfasthtml.core.formatting.dsl.completion import presets
# =============================================================================
# Mock Provider Fixture
# =============================================================================
class MockProvider:
"""
Mock metadata provider for testing.
Provides predefined data for columns, values, and presets.
"""
def get_tables(self) -> list[str]:
return ["app.orders"]
def get_columns(self, table: str) -> list[str]:
return ["id", "amount", "status"]
def get_column_values(self, column: str) -> list[Any]:
if column == "status":
return ["draft", "pending", "approved"]
if column == "amount":
return [100, 250, 500]
return []
def get_row_count(self, table: str) -> int:
return 150
def get_style_presets(self) -> list[str]:
return ["custom_highlight"]
def get_format_presets(self) -> list[str]:
return ["CHF"]
@pytest.fixture
def provider():
"""Return a mock provider for tests."""
return MockProvider()
# =============================================================================
# Scope Detection Tests
# =============================================================================
def test_i_can_detect_column_scope():
"""Test detection of column scope."""
text = "column amount:\n style()"
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "column"
assert scope.column_name == "amount"
assert scope.row_index is None
def test_i_can_detect_column_scope_after_first_line():
"""Test detection of column scope."""
text = "column amount:\n style()"
scope = detect_scope(text, current_line=2)
assert scope.scope_type == "column"
assert scope.column_name == "amount"
assert scope.row_index is None
def test_i_can_detect_column_scope_quoted():
"""Test detection of column scope with quoted column name."""
text = 'column "total amount":\n style()'
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "column"
assert scope.column_name == "total amount"
def test_i_can_detect_row_scope():
"""Test detection of row scope."""
text = "row 5:\n style()"
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "row"
assert scope.row_index == 5
assert scope.column_name is None
def test_i_can_detect_cell_scope():
"""Test detection of cell scope."""
text = "cell (amount, 3):\n style()"
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "cell"
assert scope.column_name == "amount"
assert scope.row_index == 3
def test_i_can_detect_cell_scope_quoted():
"""Test detection of cell scope with quoted column name."""
text = 'cell ("total amount", 3):\n style()'
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "cell"
assert scope.column_name == "total amount"
assert scope.row_index == 3
def test_i_cannot_detect_scope_without_declaration():
"""Test that no scope is detected when there's no declaration."""
text = " style()"
scope = detect_scope(text, current_line=0)
assert scope.scope_type is None
def test_i_can_detect_scope_with_multiple_declarations():
"""Test that the most recent scope is detected."""
text = "column id:\n style()\ncolumn amount:\n format()"
scope = detect_scope(text, current_line=3)
assert scope.scope_type == "column"
assert scope.column_name == "amount"
# =============================================================================
# Context Detection - Scope Contexts
# =============================================================================
def test_context_scope_keyword_at_line_start():
"""Test SCOPE_KEYWORD context at start of non-indented line."""
text = ""
cursor = Position(line=0, ch=0)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.SCOPE_KEYWORD
def test_context_scope_keyword_partial():
"""Test SCOPE_KEYWORD context with partial keyword."""
text = "col"
cursor = Position(line=0, ch=3)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.SCOPE_KEYWORD
def test_context_column_name_after_column():
"""Test COLUMN_NAME context after 'column '."""
text = "column "
cursor = Position(line=0, ch=7)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.COLUMN_NAME
def test_context_row_index_after_row():
"""Test ROW_INDEX context after 'row '."""
text = "row "
cursor = Position(line=0, ch=4)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.ROW_INDEX
def test_context_cell_start_after_cell():
"""Test CELL_START context after 'cell '."""
text = "cell "
cursor = Position(line=0, ch=5)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.CELL_START
def test_context_cell_column_after_open_paren():
"""Test CELL_COLUMN context after 'cell ('."""
text = "cell ("
cursor = Position(line=0, ch=6)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.CELL_COLUMN
def test_context_cell_row_after_comma():
"""Test CELL_ROW context after 'cell (amount, '."""
text = "cell (amount, "
cursor = Position(line=0, ch=14)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.CELL_ROW
def test_context_cell_row_after_comma_quoted():
"""Test CELL_ROW context after 'cell ("column", '."""
text = 'cell ("amount", '
cursor = Position(line=0, ch=16)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.CELL_ROW
# =============================================================================
# Context Detection - Rule Contexts
# =============================================================================
def test_context_rule_start_on_indented_empty_line():
"""Test RULE_START context on empty indented line."""
text = "column amount:\n "
cursor = Position(line=1, ch=4)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.RULE_START
def test_context_rule_start_partial_keyword():
"""Test RULE_START context with partial keyword."""
text = "column amount:\n sty"
cursor = Position(line=1, ch=7)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.RULE_START
# =============================================================================
# Context Detection - Style Contexts
# =============================================================================
def test_context_style_args_after_open_paren():
"""Test STYLE_ARGS context after 'style('."""
text = "column amount:\n style("
cursor = Position(line=1, ch=10)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.STYLE_ARGS
def test_context_style_preset_inside_quotes():
"""Test STYLE_PRESET context inside style quotes."""
text = 'column amount:\n style("err'
cursor = Position(line=1, ch=14)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.STYLE_PRESET
def test_context_style_param_after_comma():
"""Test STYLE_PARAM context after comma in style()."""
text = 'column amount:\n style("error", '
cursor = Position(line=1, ch=21)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.STYLE_PARAM
def test_context_boolean_value_after_bold():
"""Test BOOLEAN_VALUE context after 'bold='."""
text = "column amount:\n style(bold="
cursor = Position(line=1, ch=15)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.BOOLEAN_VALUE
def test_context_boolean_value_after_italic():
"""Test BOOLEAN_VALUE context after 'italic='."""
text = "column amount:\n style(italic="
cursor = Position(line=1, ch=17)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.BOOLEAN_VALUE
def test_context_color_value_after_color():
"""Test COLOR_VALUE context after 'color='."""
text = "column amount:\n style(color="
cursor = Position(line=1, ch=16)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.COLOR_VALUE
def test_context_color_value_after_background_color():
"""Test COLOR_VALUE context after 'background_color='."""
text = "column amount:\n style(background_color="
cursor = Position(line=1, ch=27)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.COLOR_VALUE
# =============================================================================
# Context Detection - Format Contexts
# =============================================================================
def test_context_format_preset_inside_quotes():
"""Test FORMAT_PRESET context inside format quotes."""
text = 'column amount:\n format("EU'
cursor = Position(line=1, ch=15)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.FORMAT_PRESET
def test_context_format_type_after_dot():
"""Test FORMAT_TYPE context after 'format.'."""
text = "column amount:\n format."
cursor = Position(line=1, ch=11)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.FORMAT_TYPE
def test_context_format_param_date():
"""Test FORMAT_PARAM_DATE context inside format.date()."""
text = "column amount:\n format.date("
cursor = Position(line=1, ch=16)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.FORMAT_PARAM_DATE
def test_context_format_param_text():
"""Test FORMAT_PARAM_TEXT context inside format.text()."""
text = "column amount:\n format.text("
cursor = Position(line=1, ch=16)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.FORMAT_PARAM_TEXT
def test_context_date_format_value():
"""Test DATE_FORMAT_VALUE context after 'format=' in format.date."""
text = "column amount:\n format.date(format="
cursor = Position(line=1, ch=23)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.DATE_FORMAT_VALUE
def test_context_transform_value():
"""Test TRANSFORM_VALUE context after 'transform='."""
text = "column amount:\n format.text(transform="
cursor = Position(line=1, ch=26)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.TRANSFORM_VALUE
# =============================================================================
# Context Detection - After Style/Format
# =============================================================================
def test_context_after_style_or_format():
"""Test AFTER_STYLE_OR_FORMAT context after closing paren."""
text = 'column amount:\n style("error")'
cursor = Position(line=1, ch=19)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.AFTER_STYLE_OR_FORMAT
# =============================================================================
# Context Detection - Condition Contexts
# =============================================================================
def test_context_condition_start_after_if():
"""Test CONDITION_START context after 'if '."""
text = 'column amount:\n style("error") if '
cursor = Position(line=1, ch=23)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.CONDITION_START
def test_context_condition_after_not():
"""Test CONDITION_AFTER_NOT context after 'if not '."""
text = 'column amount:\n style("error") if not '
cursor = Position(line=1, ch=27)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.CONDITION_AFTER_NOT
def test_context_column_ref_after_col_dot():
"""Test COLUMN_REF context after 'col.'."""
text = 'column amount:\n style("error") if col.'
cursor = Position(line=1, ch=28)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.COLUMN_REF
def test_context_column_ref_quoted():
"""Test COLUMN_REF_QUOTED context after 'col."'."""
text = 'column amount:\n style("error") if col."'
cursor = Position(line=1, ch=29)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.COLUMN_REF_QUOTED
def test_context_operator_after_value():
"""Test OPERATOR context after 'value '."""
text = 'column amount:\n style("error") if value '
cursor = Position(line=1, ch=30)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.OPERATOR
def test_context_operator_value_after_equals():
"""Test OPERATOR_VALUE context after 'value == '."""
text = 'column amount:\n style("error") if value == '
cursor = Position(line=1, ch=33)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.OPERATOR_VALUE
def test_context_between_and():
"""Test BETWEEN_AND context after 'between 0 '."""
text = 'column amount:\n style("error") if value between 0 '
cursor = Position(line=1, ch=39)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.BETWEEN_AND
def test_context_between_value():
"""Test BETWEEN_VALUE context after 'between 0 and '."""
text = 'column amount:\n style("error") if value between 0 and '
cursor = Position(line=1, ch=43)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.BETWEEN_VALUE
def test_context_in_list_start():
"""Test IN_LIST_START context after 'in '."""
text = 'column status:\n style("error") if value in '
cursor = Position(line=1, ch=33)
scope = DetectedScope(scope_type="column", column_name="status")
context = detect_context(text, cursor, scope)
assert context == Context.IN_LIST_START
def test_context_in_list_value():
"""Test IN_LIST_VALUE context after 'in ['."""
text = 'column status:\n style("error") if value in ['
cursor = Position(line=1, ch=34)
scope = DetectedScope(scope_type="column", column_name="status")
context = detect_context(text, cursor, scope)
assert context == Context.IN_LIST_VALUE
def test_context_in_list_value_after_comma():
"""Test IN_LIST_VALUE context after comma in list."""
text = 'column status:\n style("error") if value in ["a", '
cursor = Position(line=1, ch=39)
scope = DetectedScope(scope_type="column", column_name="status")
context = detect_context(text, cursor, scope)
assert context == Context.IN_LIST_VALUE
# =============================================================================
# Context Detection - Special Cases
# =============================================================================
def test_context_none_in_comment():
"""Test NONE context when cursor is in comment."""
text = "column amount:\n # comment"
cursor = Position(line=1, ch=15)
scope = DetectedScope(scope_type="column", column_name="amount")
context = detect_context(text, cursor, scope)
assert context == Context.NONE
# =============================================================================
# Suggestions Tests
# =============================================================================
def test_suggestions_scope_keyword(provider):
"""Test suggestions for SCOPE_KEYWORD context."""
scope = DetectedScope()
suggestions = get_suggestions(Context.SCOPE_KEYWORD, scope, provider)
labels = [s.label for s in suggestions]
assert "column" in labels
assert "row" in labels
assert "cell" in labels
def test_suggestions_style_preset(provider):
"""Test suggestions for STYLE_PRESET context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.STYLE_PRESET, scope, provider)
labels = [s.label for s in suggestions]
assert "primary" in labels
assert "error" in labels
assert "warning" in labels
assert "custom_highlight" in labels # From provider
def test_suggestions_format_type(provider):
"""Test suggestions for FORMAT_TYPE context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.FORMAT_TYPE, scope, provider)
labels = [s.label for s in suggestions]
assert "number" in labels
assert "date" in labels
assert "boolean" in labels
assert "text" in labels
assert "enum" in labels
def test_suggestions_operators(provider):
"""Test suggestions for OPERATOR context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.OPERATOR, scope, provider)
labels = [s.label for s in suggestions]
assert "==" in labels
assert "<" in labels
assert "contains" in labels
assert "in" in labels
assert "between" in labels
def test_suggestions_boolean_value(provider):
"""Test suggestions for BOOLEAN_VALUE context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.BOOLEAN_VALUE, scope, provider)
labels = [s.label for s in suggestions]
assert "True" in labels
assert "False" in labels
def test_suggestions_color_value(provider):
"""Test suggestions for COLOR_VALUE context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.COLOR_VALUE, scope, provider)
labels = [s.label for s in suggestions]
assert "red" in labels
assert "blue" in labels
assert "var(--color-primary)" in labels
def test_suggestions_column_values(provider):
"""Test suggestions for OPERATOR_VALUE context with column scope."""
scope = DetectedScope(scope_type="column", column_name="status")
suggestions = get_suggestions(Context.OPERATOR_VALUE, scope, provider)
labels = [s.label for s in suggestions]
# Base suggestions
assert "col." in labels
assert "True" in labels
assert "False" in labels
# Column values from provider
assert '"draft"' in labels
assert '"pending"' in labels
assert '"approved"' in labels
def test_suggestions_rule_start(provider):
"""Test suggestions for RULE_START context."""
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.RULE_START, scope, provider)
labels = [s.label for s in suggestions]
assert "style(" in labels
assert "format(" in labels
assert "format." in labels
def test_suggestions_none_context(provider):
"""Test that NONE context returns empty suggestions."""
scope = DetectedScope()
suggestions = get_suggestions(Context.NONE, scope, provider)
assert suggestions == []
# =============================================================================
# Engine Integration Tests
# =============================================================================
def test_i_can_get_completions_for_style_preset(provider):
"""Test complete flow for style preset completion."""
text = 'column amount:\n style("'
cursor = Position(line=1, ch=11)
result = get_completions(text, cursor, provider)
assert not result.is_empty
labels = [s.label for s in result.suggestions]
assert "primary" in labels
assert "error" in labels
def test_i_can_get_completions_filters_by_prefix(provider):
"""Test that completions are filtered by prefix."""
text = 'column amount:\n style("err'
cursor = Position(line=1, ch=14)
result = get_completions(text, cursor, provider)
labels = [s.label for s in result.suggestions]
assert "error" in labels
assert "primary" not in labels
def test_i_can_get_completions_returns_correct_positions(provider):
"""Test that completion result has correct from/to positions."""
text = 'column amount:\n style("err'
cursor = Position(line=1, ch=14) # After "err"
result = get_completions(text, cursor, provider)
# from_pos should be at start of "err"
assert result.from_pos.line == 1
assert result.from_pos.ch == 11 # Start of "err"
# to_pos should be at end of "err"
assert result.to_pos.line == 1
assert result.to_pos.ch == 14 # End of "err"
def test_i_can_get_completions_at_scope_start(provider):
"""Test completions at the start of a new line (scope keywords)."""
text = ""
cursor = Position(line=0, ch=0)
result = get_completions(text, cursor, provider)
labels = [s.label for s in result.suggestions]
assert "column" in labels
assert "row" in labels
assert "cell" in labels
def test_i_can_get_completions_for_column_names(provider):
"""Test completions for column names."""
text = "column "
cursor = Position(line=0, ch=7)
result = get_completions(text, cursor, provider)
labels = [s.label for s in result.suggestions]
assert "id" in labels
assert "amount" in labels
assert "status" in labels
def test_i_can_get_completions_in_comment_returns_empty(provider):
"""Test that completions in comment are empty."""
text = "column amount:\n # comment"
cursor = Position(line=1, ch=15)
result = get_completions(text, cursor, provider)
assert result.is_empty
def test_i_can_create_formatting_completion_engine(provider):
"""Test that FormattingCompletionEngine can be instantiated."""
engine = FormattingCompletionEngine(provider)
assert engine.provider == provider
def test_i_can_use_engine_detect_scope(provider):
"""Test engine's detect_scope method."""
engine = FormattingCompletionEngine(provider)
text = "column amount:\n style()"
scope = engine.detect_scope(text, current_line=1)
assert scope.scope_type == "column"
assert scope.column_name == "amount"
def test_i_can_use_engine_detect_context(provider):
"""Test engine's detect_context method."""
engine = FormattingCompletionEngine(provider)
text = "column amount:\n style("
cursor = Position(line=1, ch=10)
scope = DetectedScope(scope_type="column", column_name="amount")
context = engine.detect_context(text, cursor, scope)
assert context == Context.STYLE_ARGS

View File

@@ -0,0 +1,576 @@
"""
Tests for the DataGrid Formatting DSL parser.
Tests the parsing of DSL text into ScopedRule objects.
"""
import pytest
from myfasthtml.core.formatting.dsl import (
parse_dsl,
ColumnScope,
RowScope,
CellScope,
ScopedRule,
DSLSyntaxError,
)
from myfasthtml.core.formatting.dataclasses import (
Condition,
Style,
FormatRule,
NumberFormatter,
DateFormatter,
BooleanFormatter,
TextFormatter,
EnumFormatter,
)
# =============================================================================
# Scope Tests
# =============================================================================
class TestColumnScope:
"""Tests for column scope parsing."""
def test_i_can_parse_column_scope(self):
"""Test parsing a simple column scope."""
dsl = """
column amount:
style("error")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, ColumnScope)
assert rules[0].scope.column == "amount"
def test_i_can_parse_column_scope_with_quoted_name(self):
"""Test parsing a column scope with quoted name containing spaces."""
dsl = """
column "total amount":
style("error")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, ColumnScope)
assert rules[0].scope.column == "total amount"
class TestRowScope:
"""Tests for row scope parsing."""
def test_i_can_parse_row_scope(self):
"""Test parsing a row scope."""
dsl = """
row 0:
style("neutral")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, RowScope)
assert rules[0].scope.row == 0
def test_i_can_parse_row_scope_with_large_index(self):
"""Test parsing a row scope with a large index."""
dsl = """
row 999:
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert rules[0].scope.row == 999
class TestCellScope:
"""Tests for cell scope parsing."""
def test_i_can_parse_cell_scope_with_coords(self):
"""Test parsing a cell scope with coordinates."""
dsl = """
cell (amount, 3):
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, CellScope)
assert rules[0].scope.column == "amount"
assert rules[0].scope.row == 3
assert rules[0].scope.cell_id is None
def test_i_can_parse_cell_scope_with_quoted_column(self):
"""Test parsing a cell scope with quoted column name."""
dsl = """
cell ("total amount", 5):
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert rules[0].scope.column == "total amount"
assert rules[0].scope.row == 5
def test_i_can_parse_cell_scope_with_id(self):
"""Test parsing a cell scope with cell ID."""
dsl = """
cell tcell_grid1-3-2:
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, CellScope)
assert rules[0].scope.cell_id == "tcell_grid1-3-2"
assert rules[0].scope.column is None
assert rules[0].scope.row is None
# =============================================================================
# Style Tests
# =============================================================================
class TestStyleParsing:
"""Tests for style expression parsing."""
def test_i_can_parse_style_with_preset(self):
"""Test parsing style with preset only."""
dsl = """
column amount:
style("error")
"""
rules = parse_dsl(dsl)
assert rules[0].rule.style is not None
assert rules[0].rule.style.preset == "error"
def test_i_can_parse_style_without_preset(self):
"""Test parsing style without preset, with direct properties."""
dsl = """
column amount:
style(color="red", bold=True)
"""
rules = parse_dsl(dsl)
style = rules[0].rule.style
assert style.preset is None
assert style.color == "red"
assert style.font_weight == "bold"
def test_i_can_parse_style_with_preset_and_options(self):
"""Test parsing style with preset and additional options."""
dsl = """
column amount:
style("error", bold=True, italic=True)
"""
rules = parse_dsl(dsl)
style = rules[0].rule.style
assert style.preset == "error"
assert style.font_weight == "bold"
assert style.font_style == "italic"
@pytest.mark.parametrize("option,attr_name,attr_value", [
("bold=True", "font_weight", "bold"),
("italic=True", "font_style", "italic"),
("underline=True", "text_decoration", "underline"),
("strikethrough=True", "text_decoration", "line-through"),
])
def test_i_can_parse_style_options(self, option, attr_name, attr_value):
"""Test parsing individual style options."""
dsl = f"""
column amount:
style({option})
"""
rules = parse_dsl(dsl)
style = rules[0].rule.style
assert getattr(style, attr_name) == attr_value
# =============================================================================
# Format Tests
# =============================================================================
class TestFormatParsing:
"""Tests for format expression parsing."""
def test_i_can_parse_format_preset(self):
"""Test parsing format with preset."""
dsl = """
column amount:
format("EUR")
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert formatter is not None
assert formatter.preset == "EUR"
def test_i_can_parse_format_preset_with_options(self):
"""Test parsing format preset with options."""
dsl = """
column amount:
format("EUR", precision=3)
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert formatter.preset == "EUR"
assert formatter.precision == 3
@pytest.mark.parametrize("format_type,formatter_class", [
("number", NumberFormatter),
("date", DateFormatter),
("boolean", BooleanFormatter),
("text", TextFormatter),
("enum", EnumFormatter),
])
def test_i_can_parse_format_types(self, format_type, formatter_class):
"""Test parsing explicit format types."""
dsl = f"""
column amount:
format.{format_type}()
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, formatter_class)
def test_i_can_parse_format_number_with_options(self):
"""Test parsing format.number with all options."""
dsl = """
column amount:
format.number(precision=2, suffix=" EUR", thousands_sep=" ")
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, NumberFormatter)
assert formatter.precision == 2
assert formatter.suffix == " EUR"
assert formatter.thousands_sep == " "
def test_i_can_parse_format_date_with_options(self):
"""Test parsing format.date with format option."""
dsl = """
column created_at:
format.date(format="%d/%m/%Y")
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, DateFormatter)
assert formatter.format == "%d/%m/%Y"
def test_i_can_parse_format_boolean_with_options(self):
"""Test parsing format.boolean with all options."""
dsl = """
column active:
format.boolean(true_value="Oui", false_value="Non")
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, BooleanFormatter)
assert formatter.true_value == "Oui"
assert formatter.false_value == "Non"
def test_i_can_parse_format_enum_with_source(self):
"""Test parsing format.enum with source mapping."""
dsl = """
column status:
format.enum(source={"draft": "Brouillon", "published": "Publie"})
"""
rules = parse_dsl(dsl)
formatter = rules[0].rule.formatter
assert isinstance(formatter, EnumFormatter)
assert formatter.source == {"draft": "Brouillon", "published": "Publie"}
# =============================================================================
# Condition Tests
# =============================================================================
class TestConditionParsing:
"""Tests for condition parsing."""
@pytest.mark.parametrize("operator,dsl_op", [
("==", "=="),
("!=", "!="),
("<", "<"),
("<=", "<="),
(">", ">"),
(">=", ">="),
("contains", "contains"),
("startswith", "startswith"),
("endswith", "endswith"),
])
def test_i_can_parse_comparison_operators(self, operator, dsl_op):
"""Test parsing all comparison operators."""
dsl = f"""
column amount:
style("error") if value {dsl_op} 0
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition is not None
assert condition.operator == operator
@pytest.mark.parametrize("unary_op", ["isempty", "isnotempty"])
def test_i_can_parse_unary_conditions(self, unary_op):
"""Test parsing unary conditions (isempty, isnotempty)."""
dsl = f"""
column name:
style("neutral") if value {unary_op}
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.operator == unary_op
def test_i_can_parse_condition_in(self):
"""Test parsing 'in' condition with list."""
dsl = """
column status:
style("success") if value in ["approved", "validated"]
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.operator == "in"
assert condition.value == ["approved", "validated"]
def test_i_can_parse_condition_between(self):
"""Test parsing 'between' condition."""
dsl = """
column score:
style("warning") if value between 30 and 70
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.operator == "between"
assert condition.value == [30, 70]
def test_i_can_parse_condition_negation(self):
"""Test parsing negated condition."""
dsl = """
column status:
style("error") if not value == "approved"
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.negate is True
assert condition.operator == "=="
def test_i_can_parse_condition_case_sensitive(self):
"""Test parsing case-sensitive condition."""
dsl = """
column name:
style("error") if value == "Error" (case)
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.case_sensitive is True
# =============================================================================
# Literal Tests
# =============================================================================
class TestLiteralParsing:
"""Tests for literal value parsing in conditions."""
@pytest.mark.parametrize("literal,expected_value,expected_type", [
('"hello"', "hello", str),
("'world'", "world", str),
("42", 42, int),
("-10", -10, int),
("3.14", 3.14, float),
("-2.5", -2.5, float),
("True", True, bool),
("False", False, bool),
("true", True, bool),
("false", False, bool),
])
def test_i_can_parse_literals(self, literal, expected_value, expected_type):
"""Test parsing various literal types in conditions."""
dsl = f"""
column amount:
style("error") if value == {literal}
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.value == expected_value
assert isinstance(condition.value, expected_type)
# =============================================================================
# Reference Tests
# =============================================================================
class TestReferenceParsing:
"""Tests for cell reference parsing in conditions."""
def test_i_can_parse_column_reference(self):
"""Test parsing column reference in condition."""
dsl = """
column actual:
style("error") if value > col.budget
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.value == {"col": "budget"}
def test_i_can_parse_column_reference_with_quoted_name(self):
"""Test parsing column reference with quoted name."""
dsl = """
column actual:
style("error") if value > col."max budget"
"""
rules = parse_dsl(dsl)
condition = rules[0].rule.condition
assert condition.value == {"col": "max budget"}
# =============================================================================
# Complex Structure Tests
# =============================================================================
class TestComplexStructures:
"""Tests for complex DSL structures."""
def test_i_can_parse_multiple_rules_in_scope(self):
"""Test parsing multiple rules under one scope."""
dsl = """
column amount:
style("error") if value < 0
style("success") if value > 1000
format("EUR")
"""
rules = parse_dsl(dsl)
assert len(rules) == 3
# All rules share the same scope
for rule in rules:
assert isinstance(rule.scope, ColumnScope)
assert rule.scope.column == "amount"
def test_i_can_parse_multiple_scopes(self):
"""Test parsing multiple scopes."""
dsl = """
column amount:
format("EUR")
column status:
style("success") if value == "approved"
row 0:
style("neutral", bold=True)
"""
rules = parse_dsl(dsl)
assert len(rules) == 3
# First rule: column amount
assert isinstance(rules[0].scope, ColumnScope)
assert rules[0].scope.column == "amount"
# Second rule: column status
assert isinstance(rules[1].scope, ColumnScope)
assert rules[1].scope.column == "status"
# Third rule: row 0
assert isinstance(rules[2].scope, RowScope)
assert rules[2].scope.row == 0
def test_i_can_parse_style_and_format_combined(self):
"""Test parsing style and format on same line."""
dsl = """
column amount:
style("error") format("EUR") if value < 0
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
rule = rules[0].rule
assert rule.style is not None
assert rule.style.preset == "error"
assert rule.formatter is not None
assert rule.formatter.preset == "EUR"
assert rule.condition is not None
assert rule.condition.operator == "<"
def test_i_can_parse_comments(self):
"""Test that comments are ignored."""
dsl = """
# This is a comment
column amount:
# Another comment
style("error") if value < 0
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert rules[0].rule.style.preset == "error"
# =============================================================================
# Error Tests
# =============================================================================
class TestSyntaxErrors:
"""Tests for syntax error handling."""
def test_i_cannot_parse_invalid_syntax(self):
"""Test that invalid syntax raises DSLSyntaxError."""
dsl = """
column amount
style("error")
"""
with pytest.raises(DSLSyntaxError):
parse_dsl(dsl)
def test_i_cannot_parse_missing_indent(self):
"""Test that missing indentation raises DSLSyntaxError."""
dsl = """
column amount:
style("error")
"""
with pytest.raises(DSLSyntaxError):
parse_dsl(dsl)
def test_i_cannot_parse_empty_scope(self):
"""Test that empty scope raises DSLSyntaxError."""
dsl = """
column amount:
"""
with pytest.raises(DSLSyntaxError):
parse_dsl(dsl)
def test_i_cannot_parse_invalid_operator(self):
"""Test that invalid operator raises DSLSyntaxError."""
dsl = """
column amount:
style("error") if value <> 0
"""
with pytest.raises(DSLSyntaxError):
parse_dsl(dsl)

View File

@@ -0,0 +1,105 @@
"""Tests for FormattingDSL definition."""
import pytest
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.formatting.dsl.grammar import GRAMMAR
class TestFormattingDSL:
"""Tests for FormattingDSL class."""
def test_i_can_create_formatting_dsl(self):
"""Test that FormattingDSL can be instantiated."""
dsl = FormattingDSL()
assert dsl is not None
assert dsl.name == "Formatting DSL"
def test_i_can_get_formatting_dsl_grammar(self):
"""Test that get_grammar() returns the GRAMMAR constant."""
dsl = FormattingDSL()
grammar = dsl.get_grammar()
assert grammar == GRAMMAR
assert "scope" in grammar
assert "style_expr" in grammar
assert "format_expr" in grammar
@pytest.mark.parametrize(
"keyword",
["column", "row", "cell", "if", "not", "value", "and"],
)
def test_i_can_get_formatting_dsl_keywords(self, keyword):
"""Test that expected keywords are extracted from formatting DSL."""
dsl = FormattingDSL()
completions = dsl.completions
assert keyword in completions["keywords"]
@pytest.mark.parametrize(
"operator",
["==", "!=", "<=", "<", ">=", ">", "contains", "startswith", "endswith"],
)
def test_i_can_get_formatting_dsl_operators(self, operator):
"""Test that expected operators are extracted from formatting DSL."""
dsl = FormattingDSL()
completions = dsl.completions
assert operator in completions["operators"]
@pytest.mark.parametrize(
"function",
["style", "format"],
)
def test_i_can_get_formatting_dsl_functions(self, function):
"""Test that expected functions are extracted from formatting DSL."""
dsl = FormattingDSL()
completions = dsl.completions
assert function in completions["functions"]
@pytest.mark.parametrize(
"type_name",
["number", "date", "boolean", "text", "enum"],
)
def test_i_can_get_formatting_dsl_types(self, type_name):
"""Test that expected types are extracted from formatting DSL."""
dsl = FormattingDSL()
completions = dsl.completions
assert type_name in completions["types"]
def test_i_can_get_completions_is_cached(self):
"""Test that completions property is cached (same object returned)."""
dsl = FormattingDSL()
completions1 = dsl.completions
completions2 = dsl.completions
assert completions1 is completions2
def test_i_can_get_lezer_grammar_is_cached(self):
"""Test that lezer_grammar property is cached (same object returned)."""
dsl = FormattingDSL()
lezer1 = dsl.lezer_grammar
lezer2 = dsl.lezer_grammar
assert lezer1 is lezer2
def test_i_can_get_editor_config(self):
"""Test that get_editor_config() returns expected structure."""
dsl = FormattingDSL()
config = dsl.get_editor_config()
assert "name" in config
assert "lezerGrammar" in config
assert "completions" in config
assert config["name"] == "Formatting DSL"