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,137 @@
"""
Tests for BaseCompletionEngine.
Uses a mock implementation to test the abstract base class functionality.
"""
import pytest
from typing import Any
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
class MockProvider:
"""Mock metadata provider for testing."""
def get_style_presets(self) -> list[str]:
return ["custom_highlight"]
def get_format_presets(self) -> list[str]:
return ["CHF"]
class MockCompletionEngine(BaseCompletionEngine):
"""Mock completion engine for testing base class functionality."""
def __init__(self, provider: BaseMetadataProvider, suggestions: list[Suggestion] = None):
super().__init__(provider)
self._suggestions = suggestions or []
self._scope = None
self._context = "test_context"
def detect_scope(self, text: str, current_line: int) -> Any:
return self._scope
def detect_context(self, text: str, cursor: Position, scope: Any) -> Any:
return self._context
def get_suggestions(self, context: Any, scope: Any, prefix: str) -> list[Suggestion]:
return self._suggestions
# =============================================================================
# Filter Suggestions Tests
# =============================================================================
def test_i_can_filter_suggestions_by_prefix():
"""Test that _filter_suggestions filters case-insensitively."""
provider = MockProvider()
engine = MockCompletionEngine(provider)
suggestions = [
Suggestion("primary", "Primary color", "preset"),
Suggestion("error", "Error color", "preset"),
Suggestion("warning", "Warning color", "preset"),
Suggestion("Error", "Title case error", "preset"),
]
# Filter by "err" - should match "error" and "Error" (case-insensitive)
filtered = engine._filter_suggestions(suggestions, "err")
labels = [s.label for s in filtered]
assert "error" in labels
assert "Error" in labels
assert "primary" not in labels
assert "warning" not in labels
def test_i_can_filter_suggestions_empty_prefix():
"""Test that empty prefix returns all suggestions."""
provider = MockProvider()
engine = MockCompletionEngine(provider)
suggestions = [
Suggestion("a"),
Suggestion("b"),
Suggestion("c"),
]
filtered = engine._filter_suggestions(suggestions, "")
assert len(filtered) == 3
# =============================================================================
# Empty Result Tests
# =============================================================================
def test_i_can_get_empty_result():
"""Test that _empty_result returns a CompletionResult with no suggestions."""
provider = MockProvider()
engine = MockCompletionEngine(provider)
cursor = Position(line=5, ch=10)
result = engine._empty_result(cursor)
assert result.from_pos == cursor
assert result.to_pos == cursor
assert result.suggestions == []
assert result.is_empty is True
# =============================================================================
# Comment Skipping Tests
# =============================================================================
def test_i_can_skip_completion_in_comment():
"""Test that get_completions returns empty when cursor is in a comment."""
provider = MockProvider()
suggestions = [Suggestion("should_not_appear")]
engine = MockCompletionEngine(provider, suggestions)
text = "# This is a comment"
cursor = Position(line=0, ch=15) # Inside the comment
result = engine.get_completions(text, cursor)
assert result.is_empty is True
assert len(result.suggestions) == 0
def test_i_can_get_completions_outside_comment():
"""Test that get_completions works when cursor is not in a comment."""
provider = MockProvider()
suggestions = [Suggestion("style"), Suggestion("format")]
engine = MockCompletionEngine(provider, suggestions)
# Cursor at space (ch=5) so prefix is empty and all suggestions are returned
text = "text # comment"
cursor = Position(line=0, ch=5) # At empty space, before comment
result = engine.get_completions(text, cursor)
assert result.is_empty is False
assert len(result.suggestions) == 2

View File

@@ -0,0 +1,172 @@
"""Tests for lark_to_lezer module."""
import pytest
from myfasthtml.core.dsl.lark_to_lezer import (
extract_completions_from_grammar,
lark_to_lezer_grammar,
)
# Sample grammars for testing
SIMPLE_GRAMMAR = r'''
start: rule+
rule: "if" condition
condition: "value" operator literal
operator: "==" -> op_eq
| "!=" -> op_ne
| "contains" -> op_contains
literal: QUOTED_STRING -> string_literal
| BOOLEAN -> boolean_literal
QUOTED_STRING: /"[^"]*"/
BOOLEAN: "True" | "False"
'''
GRAMMAR_WITH_KEYWORDS = r'''
start: scope+
scope: "column" NAME ":" rule
| "row" INTEGER ":" rule
| "cell" cell_ref ":" rule
rule: style_expr condition?
condition: "if" "not"? comparison
comparison: operand "and" operand
| operand "or" operand
style_expr: "style" "(" args ")"
operand: "value" | literal
'''
GRAMMAR_WITH_TYPES = r'''
format_type: "number" -> fmt_number
| "date" -> fmt_date
| "boolean" -> fmt_boolean
| "text" -> fmt_text
| "enum" -> fmt_enum
'''
class TestExtractCompletions:
"""Tests for extract_completions_from_grammar function."""
def test_i_can_extract_keywords_from_grammar(self):
"""Test that keywords like if, not, and are extracted."""
completions = extract_completions_from_grammar(GRAMMAR_WITH_KEYWORDS)
assert "if" in completions["keywords"]
assert "not" in completions["keywords"]
assert "column" in completions["keywords"]
assert "row" in completions["keywords"]
assert "cell" in completions["keywords"]
assert "value" in completions["keywords"]
@pytest.mark.parametrize(
"operator",
["==", "!=", "contains"],
)
def test_i_can_extract_operators_from_grammar(self, operator):
"""Test that operators are extracted from grammar."""
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
assert operator in completions["operators"]
def test_i_can_extract_functions_from_grammar(self):
"""Test that function-like constructs are extracted."""
completions = extract_completions_from_grammar(GRAMMAR_WITH_KEYWORDS)
assert "style" in completions["functions"]
@pytest.mark.parametrize(
"type_name",
["number", "date", "boolean", "text", "enum"],
)
def test_i_can_extract_types_from_grammar(self, type_name):
"""Test that type names are extracted from format_type rule."""
completions = extract_completions_from_grammar(GRAMMAR_WITH_TYPES)
assert type_name in completions["types"]
@pytest.mark.parametrize("literal", [
"True",
"False"
])
def test_i_can_extract_literals_from_grammar(self, literal):
"""Test that literal values like True/False are extracted."""
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
assert literal in completions["literals"]
def test_i_can_extract_completions_returns_all_categories(self):
"""Test that all completion categories are present in result."""
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
assert "keywords" in completions
assert "operators" in completions
assert "functions" in completions
assert "types" in completions
assert "literals" in completions
def test_i_can_extract_completions_returns_sorted_lists(self):
"""Test that completion lists are sorted alphabetically."""
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
for category in completions.values():
assert category == sorted(category)
class TestLarkToLezerConversion:
"""Tests for lark_to_lezer_grammar function."""
def test_i_can_convert_simple_grammar_to_lezer(self):
"""Test that a simple Lark grammar is converted to Lezer format."""
lezer = lark_to_lezer_grammar(SIMPLE_GRAMMAR)
# Should have @top directive
assert "@top Start" in lezer
# Should have @tokens block
assert "@tokens {" in lezer
# Should have @skip directive
assert "@skip {" in lezer
def test_i_can_convert_rule_names_to_pascal_case(self):
"""Test that snake_case rule names become PascalCase."""
grammar = r'''
my_rule: other_rule
other_rule: "test"
'''
lezer = lark_to_lezer_grammar(grammar)
assert "MyRule" in lezer
assert "OtherRule" in lezer
def test_i_cannot_include_internal_rules_in_lezer(self):
"""Test that rules starting with _ are not included."""
grammar = r'''
start: rule _NL
rule: "test"
_NL: /\n/
'''
lezer = lark_to_lezer_grammar(grammar)
# Internal rules should not appear as Lezer rules
assert "Nl {" not in lezer
def test_i_can_convert_terminal_regex_to_lezer(self):
"""Test that terminal regex patterns are converted."""
grammar = r'''
NAME: /[a-zA-Z_][a-zA-Z0-9_]*/
'''
lezer = lark_to_lezer_grammar(grammar)
assert "NAME" in lezer
@pytest.mark.parametrize(
"terminal,pattern",
[
('BOOLEAN: "True" | "False"', "BOOLEAN"),
('KEYWORD: "if"', "KEYWORD"),
],
)
def test_i_can_convert_terminal_strings_to_lezer(self, terminal, pattern):
"""Test that terminal string literals are converted."""
grammar = f"start: test\n{terminal}"
lezer = lark_to_lezer_grammar(grammar)
assert pattern in lezer

View File

@@ -0,0 +1,145 @@
"""
Tests for DSL autocompletion types.
Tests for Position, Suggestion, CompletionResult, and WordRange dataclasses.
"""
import pytest
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult, WordRange
# =============================================================================
# Position Tests
# =============================================================================
def test_i_can_create_position():
"""Test that a Position can be created with line and ch."""
pos = Position(line=0, ch=5)
assert pos.line == 0
assert pos.ch == 5
def test_i_can_convert_position_to_dict():
"""Test that Position.to_dict() returns correct CodeMirror format."""
pos = Position(line=3, ch=12)
result = pos.to_dict()
assert result == {"line": 3, "ch": 12}
# =============================================================================
# Suggestion Tests
# =============================================================================
def test_i_can_create_suggestion_with_label_only():
"""Test that a Suggestion can be created with just a label."""
suggestion = Suggestion("text")
assert suggestion.label == "text"
assert suggestion.detail == ""
assert suggestion.kind == ""
def test_i_can_create_suggestion_with_all_fields():
"""Test that a Suggestion can be created with label, detail, and kind."""
suggestion = Suggestion(label="primary", detail="Primary theme color", kind="preset")
assert suggestion.label == "primary"
assert suggestion.detail == "Primary theme color"
assert suggestion.kind == "preset"
def test_i_can_convert_suggestion_to_dict_with_label_only():
"""Test that Suggestion.to_dict() works with label only."""
suggestion = Suggestion("text")
result = suggestion.to_dict()
assert result == {"label": "text"}
assert "detail" not in result
assert "kind" not in result
def test_i_can_convert_suggestion_to_dict_with_all_fields():
"""Test that Suggestion.to_dict() includes detail and kind when present."""
suggestion = Suggestion(label="error", detail="Error style", kind="preset")
result = suggestion.to_dict()
assert result == {"label": "error", "detail": "Error style", "kind": "preset"}
def test_i_can_convert_suggestion_to_dict_with_partial_fields():
"""Test that Suggestion.to_dict() includes only non-empty fields."""
suggestion = Suggestion(label="text", detail="Description")
result = suggestion.to_dict()
assert result == {"label": "text", "detail": "Description"}
assert "kind" not in result
# =============================================================================
# CompletionResult Tests
# =============================================================================
def test_i_can_create_completion_result():
"""Test that a CompletionResult can be created with positions and suggestions."""
from_pos = Position(line=0, ch=5)
to_pos = Position(line=0, ch=10)
suggestions = [Suggestion("option1"), Suggestion("option2")]
result = CompletionResult(from_pos=from_pos, to_pos=to_pos, suggestions=suggestions)
assert result.from_pos == from_pos
assert result.to_pos == to_pos
assert len(result.suggestions) == 2
def test_i_can_convert_completion_result_to_dict():
"""Test that CompletionResult.to_dict() returns CodeMirror-compatible format."""
from_pos = Position(line=1, ch=4)
to_pos = Position(line=1, ch=8)
suggestions = [Suggestion("primary", "Primary color", "preset")]
result = CompletionResult(from_pos=from_pos, to_pos=to_pos, suggestions=suggestions)
dict_result = result.to_dict()
assert dict_result == {
"from": {"line": 1, "ch": 4},
"to": {"line": 1, "ch": 8},
"suggestions": [{"label": "primary", "detail": "Primary color", "kind": "preset"}],
}
def test_completion_result_is_empty_when_no_suggestions():
"""Test that is_empty returns True when there are no suggestions."""
result = CompletionResult(
from_pos=Position(line=0, ch=0),
to_pos=Position(line=0, ch=0),
suggestions=[],
)
assert result.is_empty is True
def test_completion_result_is_not_empty_when_has_suggestions():
"""Test that is_empty returns False when there are suggestions."""
result = CompletionResult(
from_pos=Position(line=0, ch=0),
to_pos=Position(line=0, ch=5),
suggestions=[Suggestion("text")],
)
assert result.is_empty is False
# =============================================================================
# WordRange Tests
# =============================================================================
def test_i_can_create_word_range():
"""Test that a WordRange can be created with start, end, and text."""
word_range = WordRange(start=5, end=10, text="hello")
assert word_range.start == 5
assert word_range.end == 10
assert word_range.text == "hello"
def test_i_can_create_word_range_with_default_text():
"""Test that a WordRange has default empty text."""
word_range = WordRange(start=0, end=0)
assert word_range.text == ""

View File

@@ -0,0 +1,261 @@
"""
Tests for DSL autocompletion utilities.
Tests for line extraction, word boundaries, comment/string detection,
and indentation functions.
"""
import pytest
from myfasthtml.core.dsl.types import Position, WordRange
from myfasthtml.core.dsl.utils import (
get_line_at,
get_line_up_to_cursor,
get_lines_up_to,
find_word_boundaries,
get_prefix,
is_in_comment,
is_in_string,
get_indentation,
is_indented,
strip_quotes,
)
# =============================================================================
# Line Extraction Tests
# =============================================================================
def test_i_can_get_line_at_valid_index():
"""Test that get_line_at returns the correct line."""
text = "line0\nline1\nline2"
assert get_line_at(text, 0) == "line0"
assert get_line_at(text, 1) == "line1"
assert get_line_at(text, 2) == "line2"
def test_i_can_get_line_at_invalid_index():
"""Test that get_line_at returns empty string for invalid index."""
text = "line0\nline1"
assert get_line_at(text, -1) == ""
assert get_line_at(text, 5) == ""
def test_i_can_get_line_up_to_cursor():
"""Test that get_line_up_to_cursor truncates at cursor position."""
text = "hello world\nfoo bar"
cursor = Position(line=0, ch=5)
assert get_line_up_to_cursor(text, cursor) == "hello"
cursor = Position(line=1, ch=3)
assert get_line_up_to_cursor(text, cursor) == "foo"
def test_i_can_get_lines_up_to():
"""Test that get_lines_up_to returns lines 0..N."""
text = "line0\nline1\nline2\nline3"
assert get_lines_up_to(text, 0) == ["line0"]
assert get_lines_up_to(text, 2) == ["line0", "line1", "line2"]
# =============================================================================
# Word Boundaries Tests
# =============================================================================
def test_i_can_find_word_boundaries_in_middle():
"""Test word boundaries when cursor is in middle of word."""
line = "hello world"
result = find_word_boundaries(line, 3) # hel|lo
assert result.start == 0
assert result.end == 5
assert result.text == "hello"
def test_i_can_find_word_boundaries_at_start():
"""Test word boundaries when cursor is at start of word."""
line = "hello world"
result = find_word_boundaries(line, 0) # |hello
assert result.start == 0
assert result.end == 5
assert result.text == "hello"
def test_i_can_find_word_boundaries_at_end():
"""Test word boundaries when cursor is at end of word."""
line = "hello world"
result = find_word_boundaries(line, 5) # hello|
assert result.start == 0
assert result.end == 5
assert result.text == "hello"
def test_i_can_find_word_boundaries_with_delimiters():
"""Test word boundaries with delimiter characters like parentheses and quotes."""
line = 'style("error")'
result = find_word_boundaries(line, 10) # style("err|or")
assert result.start == 7
assert result.end == 12
assert result.text == "error"
def test_i_can_find_word_boundaries_empty_line():
"""Test word boundaries on empty line."""
line = ""
result = find_word_boundaries(line, 0)
assert result.start == 0
assert result.end == 0
assert result.text == ""
def test_i_can_get_prefix():
"""Test that get_prefix returns text from word start to cursor."""
line = "style"
prefix = get_prefix(line, 3) # sty|le
assert prefix == "sty"
def test_i_can_get_prefix_at_word_start():
"""Test that get_prefix returns empty at word start."""
line = "style"
prefix = get_prefix(line, 0) # |style
assert prefix == ""
# =============================================================================
# Comment Detection Tests
# =============================================================================
def test_i_can_detect_comment():
"""Test that cursor after # is detected as in comment."""
line = "text # comment"
assert is_in_comment(line, 12) is True # In "comment"
assert is_in_comment(line, 7) is True # Right after #
def test_i_cannot_detect_comment_before_hash():
"""Test that cursor before # is not detected as in comment."""
line = "text # comment"
assert is_in_comment(line, 4) is False # Before #
assert is_in_comment(line, 0) is False # At start
def test_i_cannot_detect_comment_hash_in_string():
"""Test that # inside a string is not detected as comment start."""
line = '"#hash" text'
assert is_in_comment(line, 9) is False # After the string
def test_i_can_detect_comment_hash_after_string():
"""Test that # after a string is detected as comment."""
line = '"text" # comment'
assert is_in_comment(line, 10) is True
# =============================================================================
# String Detection Tests
# =============================================================================
def test_i_can_detect_string_double_quote():
"""Test detection of cursor inside double-quoted string."""
line = 'style("error")'
in_string, quote_char = is_in_string(line, 10) # Inside "error"
assert in_string is True
assert quote_char == '"'
def test_i_can_detect_string_single_quote():
"""Test detection of cursor inside single-quoted string."""
line = "style('error')"
in_string, quote_char = is_in_string(line, 10) # Inside 'error'
assert in_string is True
assert quote_char == "'"
def test_i_cannot_detect_string_outside_quotes():
"""Test that cursor outside quotes is not detected as in string."""
line = 'style("error")'
in_string, quote_char = is_in_string(line, 3) # In "style"
assert in_string is False
assert quote_char is None
def test_i_cannot_detect_string_after_closing_quote():
"""Test that cursor after closing quote is not in string."""
line = '"text" other'
in_string, quote_char = is_in_string(line, 8)
assert in_string is False
assert quote_char is None
# =============================================================================
# Indentation Tests
# =============================================================================
def test_i_can_get_indentation_spaces():
"""Test that spaces are counted correctly."""
line = " text"
assert get_indentation(line) == 4
def test_i_can_get_indentation_tabs():
"""Test that tabs are converted to 4 spaces."""
line = "\ttext"
assert get_indentation(line) == 4
def test_i_can_get_indentation_mixed():
"""Test mixed spaces and tabs."""
line = " \t text" # 2 spaces + tab (4) + 2 spaces = 8
assert get_indentation(line) == 8
def test_i_can_detect_indented_line():
"""Test that indented line is detected."""
assert is_indented(" text") is True
assert is_indented("\ttext") is True
def test_i_cannot_detect_indented_for_non_indented():
"""Test that non-indented line is not detected as indented."""
assert is_indented("text") is False
def test_i_cannot_detect_indented_for_empty_line():
"""Test that empty line is not detected as indented."""
assert is_indented("") is False
# =============================================================================
# Quote Stripping Tests
# =============================================================================
def test_i_can_strip_quotes_double():
"""Test stripping double quotes."""
assert strip_quotes('"text"') == "text"
def test_i_can_strip_quotes_single():
"""Test stripping single quotes."""
assert strip_quotes("'text'") == "text"
def test_i_cannot_strip_quotes_unquoted():
"""Test that unquoted text is returned unchanged."""
assert strip_quotes("text") == "text"
def test_i_cannot_strip_quotes_mismatched():
"""Test that mismatched quotes are not stripped."""
assert strip_quotes('"text\'') == '"text\''
assert strip_quotes("'text\"") == "'text\""
def test_i_cannot_strip_quotes_too_short():
"""Test that text shorter than 2 chars is returned unchanged."""
assert strip_quotes('"') == '"'
assert strip_quotes("") == ""

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"

View File

@@ -0,0 +1,112 @@
import shutil
import pytest
from dbengine.handlers import handlers
from pandas import DataFrame
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry, DATAGRIDS_REGISTRY_ENTRY_KEY
from myfasthtml.core.dbengine_utils import DataFrameHandler
from myfasthtml.core.dbmanager import DbManager
from myfasthtml.core.instances import SingleInstance, InstancesManager
def clean_db_object(obj):
return {k: v for k, v in obj.items() if not k.startswith("__")}
@pytest.fixture(scope="session")
def session():
handlers.register_handler(DataFrameHandler())
return {
"user_info": {
"id": "test_tenant_id",
"email": "test@email.com",
"username": "test user",
"role": [],
}
}
@pytest.fixture
def parent(session):
return SingleInstance(session=session, _id="test_parent_id")
@pytest.fixture
def db_manager(parent):
shutil.rmtree("TestDb", ignore_errors=True)
db_manager_instance = DbManager(parent, root="TestDb", auto_register=True)
yield db_manager_instance
shutil.rmtree("TestDb", ignore_errors=True)
InstancesManager.reset()
@pytest.fixture
def dg(parent):
# the table must be created
data = {"name": ["john", "jane"], "id": [1, 2]}
df = DataFrame(data)
dgc = DatagridConf("namespace", "table_name")
datagrid = DataGrid(parent, conf=dgc, save_state=True)
datagrid.init_from_dataframe(df, init_state=True)
yield datagrid
datagrid.dispose()
@pytest.fixture
def dgr(parent, db_manager):
return DataGridsRegistry(parent)
def test_entry_is_created_at_startup(db_manager, dgr, ):
assert db_manager.exists_entry(DATAGRIDS_REGISTRY_ENTRY_KEY)
assert clean_db_object(db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY)) == {}
def test_i_can_put_a_table_in_registry(dgr):
dgr.put("namespace", "name", "datagrid_id")
dgr.put("namespace2", "name2", "datagrid_id2")
assert dgr.get_all_tables() == ["namespace.name", "namespace2.name2"]
def test_i_can_columns_names_for_a_table(dgr, dg):
expected = ["__row_index__", "name", "id"] if dg.get_state().row_index else ["name", "id"]
namespace, name = dg.get_settings().namespace, dg.get_settings().name
dgr.put(namespace, name, dg.get_id())
table_full_name = f"{namespace}.{name}"
assert dgr.get_columns(table_full_name) == expected
def test_i_can_get_columns_values(dgr, dg):
namespace, name = dg.get_settings().namespace, dg.get_settings().name
dgr.put(namespace, name, dg.get_id())
table_full_name = f"{namespace}.{name}"
assert dgr.get_column_values(table_full_name, "name") == ["john", "jane"]
def test_i_can_get_row_count(dgr, dg):
namespace, name = dg.get_settings().namespace, dg.get_settings().name
dgr.put(namespace, name, dg.get_id())
table_full_name = f"{namespace}.{name}"
assert dgr.get_row_count(table_full_name) == 2
def test_i_can_manage_when_table_name_does_not_exist(dgr):
assert dgr.get_columns("namespace.name") == []
assert dgr.get_row_count("namespace.name") == 0
def test_i_can_manage_when_column_does_not_exist(dgr, dg):
namespace, name = dg.get_settings().namespace, dg.get_settings().name
dgr.put(namespace, name, dg.get_id())
table_full_name = f"{namespace}.{name}"
assert len(dgr.get_columns(table_full_name)) > 0
assert dgr.get_column_values("namespace.name", "") == []

View File

@@ -1,4 +1,4 @@
from myfasthtml.core.network_utils import from_nested_dict, from_tree_with_metadata, from_parent_child_list
from myfasthtml.core.vis_network_utils import from_nested_dict, from_tree_with_metadata, from_parent_child_list
class TestFromNestedDict: