Working on Formating DSL completion
This commit is contained in:
0
tests/core/dsl/__init__.py
Normal file
0
tests/core/dsl/__init__.py
Normal file
137
tests/core/dsl/test_base_completion.py
Normal file
137
tests/core/dsl/test_base_completion.py
Normal 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
|
||||
172
tests/core/dsl/test_lark_to_lezer.py
Normal file
172
tests/core/dsl/test_lark_to_lezer.py
Normal 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
|
||||
145
tests/core/dsl/test_types.py
Normal file
145
tests/core/dsl/test_types.py
Normal 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 == ""
|
||||
261
tests/core/dsl/test_utils.py
Normal file
261
tests/core/dsl/test_utils.py
Normal 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("") == ""
|
||||
0
tests/core/formatting/dsl/__init__.py
Normal file
0
tests/core/formatting/dsl/__init__.py
Normal file
770
tests/core/formatting/dsl/test_completion.py
Normal file
770
tests/core/formatting/dsl/test_completion.py
Normal 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
|
||||
576
tests/core/formatting/test_dsl_parser.py
Normal file
576
tests/core/formatting/test_dsl_parser.py
Normal 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)
|
||||
105
tests/core/formatting/test_formatting_dsl_definition.py
Normal file
105
tests/core/formatting/test_formatting_dsl_definition.py
Normal 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"
|
||||
112
tests/core/test_datagrid_registry.py
Normal file
112
tests/core/test_datagrid_registry.py
Normal 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", "") == []
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user