Improved auto-completion engine for formatting parameters and added support for absolute value in number formatting.
This commit is contained in:
5
.idea/MyFastHtml.iml
generated
5
.idea/MyFastHtml.iml
generated
@@ -4,6 +4,11 @@
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.myFastHtmlDb" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/benchmarks" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/examples" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/images" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/src/.myFastHtmlDb" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12.3 WSL (Ubuntu-24.04): (/home/kodjo/.virtualenvs/MyFastHtml/bin/python)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
@@ -330,6 +330,7 @@ Formats numbers, currencies, and percentages.
|
||||
| `decimal_sep` | string | `"."` | Decimal separator (e.g., ".", ",") |
|
||||
| `precision` | int | `0` | Number of decimal places |
|
||||
| `multiplier` | number | `1` | Multiply value before display (e.g., 100 for %) |
|
||||
| `absolute` | boolean | `False` | Display absolute value (no sign) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -349,6 +350,10 @@ column rate:
|
||||
# Large numbers with thousands separator
|
||||
column population:
|
||||
format.number(thousands_sep=",", precision=0)
|
||||
|
||||
# Absolute value - display variance without sign
|
||||
column variance:
|
||||
format.number(absolute=True, suffix=" €", precision=2)
|
||||
```
|
||||
|
||||
#### Date Formatter
|
||||
@@ -1085,6 +1090,14 @@ DaisyUI 5 color presets:
|
||||
|
||||
### Formatter Types
|
||||
|
||||
Possible formatter types :
|
||||
- `number`
|
||||
- `date`
|
||||
- `boolean`
|
||||
- `text`
|
||||
- `enum`
|
||||
- `constant`
|
||||
|
||||
#### Number Formatter
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
@@ -1095,6 +1108,7 @@ DaisyUI 5 color presets:
|
||||
| `decimal_sep` | string | `"."` | Decimal separator |
|
||||
| `precision` | int | `0` | Decimal places |
|
||||
| `multiplier` | number | `1` | Multiply before display |
|
||||
| `absolute` | boolean | `False` | Display absolute value (no sign) |
|
||||
|
||||
#### Date Formatter
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ and other common operations used by completion engines.
|
||||
from .types import Position, WordRange
|
||||
|
||||
# Delimiters used to detect word boundaries
|
||||
DELIMITERS = set('"\' ()[]{}=,:<>!\t\n\r')
|
||||
DELIMITERS = set('"\' ()[]{}=,:<>!.\t\n\r')
|
||||
|
||||
|
||||
def get_line_at(text: str, line_number: int) -> str:
|
||||
|
||||
@@ -79,6 +79,7 @@ class NumberFormatter(Formatter):
|
||||
decimal_sep: str = "."
|
||||
precision: int = 0
|
||||
multiplier: float = 1.0
|
||||
absolute: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -4,7 +4,9 @@ Completion engine for the formatting DSL.
|
||||
Implements the BaseCompletionEngine for DataGrid formatting rules.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from myfasthtml.core.dsl import utils
|
||||
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
|
||||
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
|
||||
from myfasthtml.core.utils import make_safe_id
|
||||
@@ -12,6 +14,17 @@ from . import presets
|
||||
from .contexts import Context, DetectedScope, detect_scope, detect_context
|
||||
from .provider import DatagridMetadataProvider
|
||||
|
||||
_PARAM_CONTEXTS = {
|
||||
Context.STYLE_ARGS,
|
||||
Context.STYLE_PARAM,
|
||||
Context.FORMAT_PARAM_NUMBER,
|
||||
Context.FORMAT_PARAM_DATE,
|
||||
Context.FORMAT_PARAM_BOOLEAN,
|
||||
Context.FORMAT_PARAM_TEXT,
|
||||
Context.FORMAT_PARAM_ENUM,
|
||||
Context.FORMAT_PARAM_CONSTANT,
|
||||
}
|
||||
|
||||
logger = logging.getLogger("FormattingCompletionEngine")
|
||||
|
||||
class FormattingCompletionEngine(BaseCompletionEngine):
|
||||
@@ -37,6 +50,55 @@ class FormattingCompletionEngine(BaseCompletionEngine):
|
||||
self.table_name: str = table_name # current table name
|
||||
self._id = "formatting_completion_engine#" + make_safe_id(table_name)
|
||||
|
||||
def get_completions(self, text: str, cursor: Position) -> CompletionResult:
|
||||
"""
|
||||
Get completions with parameter deduplication.
|
||||
|
||||
Extends the base implementation by filtering out parameters
|
||||
that are already present in the current style() or format.*() call.
|
||||
|
||||
Args:
|
||||
text: The full DSL document text
|
||||
cursor: Cursor position
|
||||
|
||||
Returns:
|
||||
CompletionResult with already-used parameters removed
|
||||
"""
|
||||
result = super().get_completions(text, cursor)
|
||||
|
||||
scope = self.detect_scope(text, cursor.line)
|
||||
context = self.detect_context(text, cursor, scope)
|
||||
|
||||
if context in _PARAM_CONTEXTS:
|
||||
line_to_cursor = utils.get_line_up_to_cursor(text, cursor)
|
||||
used_params = self._extract_used_params(line_to_cursor)
|
||||
if used_params:
|
||||
result.suggestions = [
|
||||
s for s in result.suggestions
|
||||
if s.label.rstrip("=") not in used_params
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
def _extract_used_params(self, line_to_cursor: str) -> set[str]:
|
||||
"""
|
||||
Extract parameter names already typed in the current function call.
|
||||
|
||||
Scans backwards from the last opening parenthesis to find all
|
||||
'name=' patterns already present.
|
||||
|
||||
Args:
|
||||
line_to_cursor: The current line up to the cursor position
|
||||
|
||||
Returns:
|
||||
Set of parameter names already used (without the '=')
|
||||
"""
|
||||
paren_pos = line_to_cursor.rfind("(")
|
||||
if paren_pos == -1:
|
||||
return set()
|
||||
args_text = line_to_cursor[paren_pos + 1:]
|
||||
return set(re.findall(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\s*=", args_text))
|
||||
|
||||
def detect_scope(self, text: str, current_line: int) -> DetectedScope:
|
||||
"""
|
||||
Detect the current scope by scanning previous lines.
|
||||
@@ -146,11 +208,23 @@ class FormattingCompletionEngine(BaseCompletionEngine):
|
||||
case Context.FORMAT_TYPE:
|
||||
return presets.FORMAT_TYPES
|
||||
|
||||
case Context.FORMAT_PARAM_NUMBER:
|
||||
return presets.FORMAT_PARAMS_NUMBER
|
||||
|
||||
case Context.FORMAT_PARAM_DATE:
|
||||
return presets.FORMAT_PARAMS_DATE
|
||||
|
||||
|
||||
case Context.FORMAT_PARAM_BOOLEAN:
|
||||
return presets.FORMAT_PARAMS_BOOLEAN
|
||||
|
||||
case Context.FORMAT_PARAM_TEXT:
|
||||
return presets.FORMAT_PARAMS_TEXT
|
||||
|
||||
case Context.FORMAT_PARAM_ENUM:
|
||||
return presets.FORMAT_PARAMS_ENUM
|
||||
|
||||
case Context.FORMAT_PARAM_CONSTANT:
|
||||
return presets.FORMAT_PARAMS_CONSTANT
|
||||
|
||||
# =================================================================
|
||||
# After style/format
|
||||
|
||||
@@ -45,8 +45,12 @@ class Context(Enum):
|
||||
# Format contexts
|
||||
FORMAT_PRESET = auto() # Inside format("): preset names
|
||||
FORMAT_TYPE = auto() # After "format.": number, date, etc.
|
||||
FORMAT_PARAM_NUMBER = auto() # Inside format.number(): prefix=, suffix=, etc.
|
||||
FORMAT_PARAM_DATE = auto() # Inside format.date(): format=
|
||||
FORMAT_PARAM_BOOLEAN = auto() # Inside format.boolean(): true_value=, false_value=, etc.
|
||||
FORMAT_PARAM_TEXT = auto() # Inside format.text(): transform=, etc.
|
||||
FORMAT_PARAM_ENUM = auto() # Inside format.enum(): source=, default=, etc.
|
||||
FORMAT_PARAM_CONSTANT = auto() # Inside format.constant(): value=
|
||||
|
||||
# After style/format
|
||||
AFTER_STYLE_OR_FORMAT = auto() # After ")": style(, format(, if
|
||||
@@ -234,17 +238,20 @@ def detect_context(text: str, cursor: Position, scope: DetectedScope) -> Context
|
||||
if re.search(r'style\s*\(\s*"[^"]*$', line_to_cursor):
|
||||
return Context.STYLE_PRESET
|
||||
|
||||
# After style( without quote - args (preset or params)
|
||||
if re.search(r"style\s*\(\s*$", line_to_cursor):
|
||||
# After style( without quote - args (preset or params), including partial word
|
||||
if re.search(r"style\s*\(\s*[a-zA-Z_]*$", line_to_cursor):
|
||||
return Context.STYLE_ARGS
|
||||
|
||||
# After comma in style() - params
|
||||
if re.search(r"style\s*\([^)]*,\s*$", line_to_cursor):
|
||||
|
||||
# After comma in style() - params, including partial word
|
||||
if re.search(r"style\s*\([^)]*,\s*[a-zA-Z_]*$", line_to_cursor):
|
||||
return Context.STYLE_PARAM
|
||||
|
||||
# After param= in style - check which param
|
||||
# After boolean param= in style or format.number
|
||||
if re.search(r"style\s*\([^)]*(?:bold|italic|underline|strikethrough)\s*=\s*$", line_to_cursor):
|
||||
return Context.BOOLEAN_VALUE
|
||||
|
||||
if re.search(r"format\s*\.\s*\w+\s*\([^)]*absolute\s*=\s*$", line_to_cursor):
|
||||
return Context.BOOLEAN_VALUE
|
||||
|
||||
if re.search(r"style\s*\([^)]*(?:color|background_color)\s*=\s*$", line_to_cursor):
|
||||
return Context.COLOR_VALUE
|
||||
@@ -253,29 +260,45 @@ def detect_context(text: str, cursor: Position, scope: DetectedScope) -> Context
|
||||
# Format contexts
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# After "format." - type
|
||||
if re.search(r"format\s*\.\s*$", line_to_cursor):
|
||||
# After "format." - type (cursor right after dot or while typing type name)
|
||||
if re.search(r"format\s*\.\s*[a-zA-Z]*$", line_to_cursor):
|
||||
return Context.FORMAT_TYPE
|
||||
|
||||
# Inside format(" - preset
|
||||
if re.search(r'format\s*\(\s*"[^"]*$', line_to_cursor):
|
||||
return Context.FORMAT_PRESET
|
||||
|
||||
# Inside format.date( - params
|
||||
if re.search(r"format\s*\.\s*date\s*\(\s*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_DATE
|
||||
|
||||
# After format= in format.date
|
||||
# After format= in format.date (checked before FORMAT_PARAM_DATE)
|
||||
if re.search(r"format\s*\.\s*date\s*\([^)]*format\s*=\s*$", line_to_cursor):
|
||||
return Context.DATE_FORMAT_VALUE
|
||||
|
||||
# Inside format.text( - params
|
||||
if re.search(r"format\s*\.\s*text\s*\(\s*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_TEXT
|
||||
|
||||
# After transform= in format.text
|
||||
|
||||
# After transform= in format.text (checked before FORMAT_PARAM_TEXT)
|
||||
if re.search(r"format\s*\.\s*text\s*\([^)]*transform\s*=\s*$", line_to_cursor):
|
||||
return Context.TRANSFORM_VALUE
|
||||
|
||||
# Inside format.number( - right after ( or after a comma, including partial word
|
||||
if re.search(r"format\s*\.\s*number\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_NUMBER
|
||||
|
||||
# Inside format.date( - right after ( or after a comma, including partial word
|
||||
if re.search(r"format\s*\.\s*date\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_DATE
|
||||
|
||||
# Inside format.boolean( - right after ( or after a comma, including partial word
|
||||
if re.search(r"format\s*\.\s*boolean\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_BOOLEAN
|
||||
|
||||
# Inside format.text( - right after ( or after a comma, including partial word
|
||||
if re.search(r"format\s*\.\s*text\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_TEXT
|
||||
|
||||
# Inside format.enum( - right after ( or after a comma, including partial word
|
||||
if re.search(r"format\s*\.\s*enum\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_ENUM
|
||||
|
||||
# Inside format.constant( - right after ( or after a comma, including partial word
|
||||
if re.search(r"format\s*\.\s*constant\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_CONSTANT
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# After style/format - if or more style/format
|
||||
|
||||
@@ -164,27 +164,56 @@ STYLE_PARAMS: list[Suggestion] = [
|
||||
# =============================================================================
|
||||
|
||||
FORMAT_TYPES: list[Suggestion] = [
|
||||
Suggestion("number", "Number formatting", "type"),
|
||||
Suggestion("date", "Date formatting", "type"),
|
||||
Suggestion("boolean", "Boolean formatting", "type"),
|
||||
Suggestion("text", "Text transformation", "type"),
|
||||
Suggestion("enum", "Value mapping", "type"),
|
||||
Suggestion("number(", "Number formatting", "type"),
|
||||
Suggestion("date(", "Date formatting", "type"),
|
||||
Suggestion("boolean(", "Boolean formatting", "type"),
|
||||
Suggestion("text(", "Text transformation", "type"),
|
||||
Suggestion("enum(", "Value mapping", "type"),
|
||||
Suggestion("constant(", "Constant value", "type"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Format Parameters by Type
|
||||
# =============================================================================
|
||||
|
||||
FORMAT_PARAMS_NUMBER: list[Suggestion] = [
|
||||
Suggestion("prefix=", "Text before the value", "parameter"),
|
||||
Suggestion("suffix=", "Text after the value", "parameter"),
|
||||
Suggestion("precision=", "Number of decimal places", "parameter"),
|
||||
Suggestion("thousands_sep=", "Thousands separator", "parameter"),
|
||||
Suggestion("decimal_sep=", "Decimal separator", "parameter"),
|
||||
Suggestion("multiplier=", "Multiply value before display", "parameter"),
|
||||
Suggestion("absolute=", "Display absolute value (no sign)", "parameter"),
|
||||
]
|
||||
|
||||
FORMAT_PARAMS_DATE: list[Suggestion] = [
|
||||
Suggestion("format=", "strftime pattern", "parameter"),
|
||||
]
|
||||
|
||||
FORMAT_PARAMS_BOOLEAN: list[Suggestion] = [
|
||||
Suggestion("true_value=", "Display text for True", "parameter"),
|
||||
Suggestion("false_value=", "Display text for False", "parameter"),
|
||||
Suggestion("null_value=", "Display text for null", "parameter"),
|
||||
]
|
||||
|
||||
FORMAT_PARAMS_TEXT: list[Suggestion] = [
|
||||
Suggestion("transform=", "Text transformation", "parameter"),
|
||||
Suggestion("max_length=", "Maximum length", "parameter"),
|
||||
Suggestion("ellipsis=", "Truncation suffix", "parameter"),
|
||||
]
|
||||
|
||||
FORMAT_PARAMS_ENUM: list[Suggestion] = [
|
||||
Suggestion("source=", "Column with labels", "parameter"),
|
||||
Suggestion("default=", "Fallback display value", "parameter"),
|
||||
Suggestion("allow_empty=", "Allow empty values", "parameter"),
|
||||
Suggestion("empty_label=", "Label for empty values", "parameter"),
|
||||
Suggestion("order_by=", "Sort order", "parameter"),
|
||||
]
|
||||
|
||||
FORMAT_PARAMS_CONSTANT: list[Suggestion] = [
|
||||
Suggestion("value=", "Constant value to display", "parameter"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Condition Keywords
|
||||
# =============================================================================
|
||||
|
||||
@@ -363,7 +363,7 @@ class DSLTransformer(Transformer):
|
||||
|
||||
def _filter_number_kwargs(self, kwargs: dict) -> dict:
|
||||
"""Filter kwargs for NumberFormatter."""
|
||||
valid_keys = {"prefix", "suffix", "thousands_sep", "decimal_sep", "precision", "multiplier"}
|
||||
valid_keys = {"prefix", "suffix", "thousands_sep", "decimal_sep", "precision", "multiplier", "absolute"}
|
||||
return {k: v for k, v in kwargs.items() if k in valid_keys}
|
||||
|
||||
def _filter_date_kwargs(self, kwargs: dict) -> dict:
|
||||
|
||||
@@ -39,8 +39,11 @@ class NumberFormatterResolver(BaseFormatterResolver):
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
# Convert to float and apply multiplier
|
||||
num = float(value) * formatter.multiplier
|
||||
# Convert to float, apply absolute value if requested, then multiplier
|
||||
num = float(value)
|
||||
if formatter.absolute:
|
||||
num = abs(num)
|
||||
num = num * formatter.multiplier
|
||||
|
||||
# Round to precision
|
||||
if formatter.precision > 0:
|
||||
@@ -97,6 +100,7 @@ class NumberFormatterResolver(BaseFormatterResolver):
|
||||
decimal_sep=formatter.decimal_sep if formatter.decimal_sep != "." else preset.get("decimal_sep", "."),
|
||||
precision=formatter.precision if formatter.precision != 0 else preset.get("precision", 0),
|
||||
multiplier=formatter.multiplier if formatter.multiplier != 1.0 else preset.get("multiplier", 1.0),
|
||||
absolute=formatter.absolute if formatter.absolute else preset.get("absolute", False),
|
||||
)
|
||||
else:
|
||||
return NumberFormatter(
|
||||
@@ -107,6 +111,7 @@ class NumberFormatterResolver(BaseFormatterResolver):
|
||||
decimal_sep=preset.get("decimal_sep", "."),
|
||||
precision=preset.get("precision", 0),
|
||||
multiplier=preset.get("multiplier", 1.0),
|
||||
absolute=preset.get("absolute", False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -431,14 +431,62 @@ def test_context_format_type_after_dot():
|
||||
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)
|
||||
def test_context_format_number_absolute_param_suggests_boolean():
|
||||
"""Test that 'absolute=' in format.number() triggers BOOLEAN_VALUE context."""
|
||||
text = "column amount:\n format.number(absolute="
|
||||
cursor = Position(line=1, ch=len(" format.number(absolute="))
|
||||
scope = DetectedScope(scope_type="column", column_name="amount")
|
||||
|
||||
context = detect_context(text, cursor, scope)
|
||||
assert context == Context.FORMAT_PARAM_DATE
|
||||
assert context == Context.BOOLEAN_VALUE
|
||||
|
||||
|
||||
def test_context_format_type_while_typing():
|
||||
"""Test FORMAT_TYPE context while typing a format type name after 'format.'."""
|
||||
text = "column amount:\n format.dat"
|
||||
cursor = Position(line=1, ch=14)
|
||||
scope = DetectedScope(scope_type="column", column_name="amount")
|
||||
|
||||
context = detect_context(text, cursor, scope)
|
||||
assert context == Context.FORMAT_TYPE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line,expected_context", [
|
||||
(' format.number(', Context.FORMAT_PARAM_NUMBER),
|
||||
(' format.date(', Context.FORMAT_PARAM_DATE),
|
||||
(' format.boolean(', Context.FORMAT_PARAM_BOOLEAN),
|
||||
(' format.text(', Context.FORMAT_PARAM_TEXT),
|
||||
(' format.enum(', Context.FORMAT_PARAM_ENUM),
|
||||
(' format.constant(', Context.FORMAT_PARAM_CONSTANT),
|
||||
])
|
||||
def test_i_can_detect_format_param_context_after_open_paren(line, expected_context):
|
||||
"""Test FORMAT_PARAM_* context right after the opening parenthesis."""
|
||||
text = f"column amount:\n{line}"
|
||||
cursor = Position(line=1, ch=len(line))
|
||||
scope = DetectedScope(scope_type="column", column_name="amount")
|
||||
|
||||
context = detect_context(text, cursor, scope)
|
||||
assert context == expected_context
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line,expected_context", [
|
||||
(' format.number(prefix="€ ",', Context.FORMAT_PARAM_NUMBER),
|
||||
(' format.date(format="%Y",', Context.FORMAT_PARAM_DATE),
|
||||
(' format.boolean(true_value="Yes",', Context.FORMAT_PARAM_BOOLEAN),
|
||||
(' format.text(transform="upper",', Context.FORMAT_PARAM_TEXT),
|
||||
(' format.enum(source="label",', Context.FORMAT_PARAM_ENUM),
|
||||
(' format.constant(value="N/A",', Context.FORMAT_PARAM_CONSTANT),
|
||||
])
|
||||
def test_i_can_detect_format_param_context_after_comma(line, expected_context):
|
||||
"""Test FORMAT_PARAM_* context after a comma (second parameter position)."""
|
||||
text = f"column amount:\n{line}"
|
||||
cursor = Position(line=1, ch=len(line))
|
||||
scope = DetectedScope(scope_type="column", column_name="amount")
|
||||
|
||||
context = detect_context(text, cursor, scope)
|
||||
assert context == expected_context
|
||||
|
||||
|
||||
|
||||
|
||||
def test_context_format_param_text():
|
||||
@@ -682,11 +730,12 @@ def test_suggestions_format_type(provider):
|
||||
suggestions = engine.get_suggestions(Context.FORMAT_TYPE, scope, "")
|
||||
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
|
||||
assert "number(" in labels
|
||||
assert "date(" in labels
|
||||
assert "boolean(" in labels
|
||||
assert "text(" in labels
|
||||
assert "enum(" in labels
|
||||
assert "constant(" in labels
|
||||
|
||||
|
||||
def test_suggestions_operators(provider):
|
||||
@@ -761,6 +810,62 @@ def test_suggestions_rule_start(provider):
|
||||
assert "format." in labels
|
||||
|
||||
|
||||
def test_suggestions_format_param_number(provider):
|
||||
"""Test suggestions for FORMAT_PARAM_NUMBER context."""
|
||||
engine = FormattingCompletionEngine(provider, "app.orders")
|
||||
scope = DetectedScope(scope_type="column", column_name="amount")
|
||||
|
||||
suggestions = engine.get_suggestions(Context.FORMAT_PARAM_NUMBER, scope, "")
|
||||
labels = [s.label for s in suggestions]
|
||||
|
||||
assert "prefix=" in labels
|
||||
assert "suffix=" in labels
|
||||
assert "precision=" in labels
|
||||
assert "thousands_sep=" in labels
|
||||
assert "decimal_sep=" in labels
|
||||
assert "multiplier=" in labels
|
||||
assert "absolute=" in labels
|
||||
|
||||
|
||||
def test_suggestions_format_param_boolean(provider):
|
||||
"""Test suggestions for FORMAT_PARAM_BOOLEAN context."""
|
||||
engine = FormattingCompletionEngine(provider, "app.orders")
|
||||
scope = DetectedScope(scope_type="column", column_name="amount")
|
||||
|
||||
suggestions = engine.get_suggestions(Context.FORMAT_PARAM_BOOLEAN, scope, "")
|
||||
labels = [s.label for s in suggestions]
|
||||
|
||||
assert "true_value=" in labels
|
||||
assert "false_value=" in labels
|
||||
assert "null_value=" in labels
|
||||
|
||||
|
||||
def test_suggestions_format_param_enum(provider):
|
||||
"""Test suggestions for FORMAT_PARAM_ENUM context."""
|
||||
engine = FormattingCompletionEngine(provider, "app.orders")
|
||||
scope = DetectedScope(scope_type="column", column_name="amount")
|
||||
|
||||
suggestions = engine.get_suggestions(Context.FORMAT_PARAM_ENUM, scope, "")
|
||||
labels = [s.label for s in suggestions]
|
||||
|
||||
assert "source=" in labels
|
||||
assert "default=" in labels
|
||||
assert "allow_empty=" in labels
|
||||
assert "empty_label=" in labels
|
||||
assert "order_by=" in labels
|
||||
|
||||
|
||||
def test_suggestions_format_param_constant(provider):
|
||||
"""Test suggestions for FORMAT_PARAM_CONSTANT context."""
|
||||
engine = FormattingCompletionEngine(provider, "app.orders")
|
||||
scope = DetectedScope(scope_type="column", column_name="amount")
|
||||
|
||||
suggestions = engine.get_suggestions(Context.FORMAT_PARAM_CONSTANT, scope, "")
|
||||
labels = [s.label for s in suggestions]
|
||||
|
||||
assert "value=" in labels
|
||||
|
||||
|
||||
def test_suggestions_none_context(provider):
|
||||
"""Test that NONE context returns empty suggestions."""
|
||||
engine = FormattingCompletionEngine(provider, "app.orders")
|
||||
@@ -853,6 +958,64 @@ def test_i_can_get_completions_in_comment_returns_empty(provider):
|
||||
assert result.is_empty
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,cursor_line,used_param,remaining_param", [
|
||||
(
|
||||
'column amount:\n format.number(prefix="€ ",',
|
||||
1, "prefix=", "suffix=",
|
||||
),
|
||||
(
|
||||
'column amount:\n format.number(prefix="€ ", suffix=" CHF",',
|
||||
1, "suffix=", "precision=",
|
||||
),
|
||||
(
|
||||
'column amount:\n style(bold=True,',
|
||||
1, "bold=", "italic=",
|
||||
),
|
||||
(
|
||||
'column amount:\n style(background_color="red",',
|
||||
1, "background_color=", "color=",
|
||||
),
|
||||
])
|
||||
def test_i_cannot_get_already_used_param_in_suggestions(provider, text, cursor_line, used_param, remaining_param):
|
||||
"""Test that already-used parameters are not suggested again."""
|
||||
cursor = Position(line=cursor_line, ch=len(text.split("\n")[cursor_line]))
|
||||
|
||||
result = get_completions(text, cursor, provider, "app.orders")
|
||||
|
||||
labels = [s.label for s in result.suggestions]
|
||||
assert used_param not in labels, f"'{used_param}' should not appear after being used"
|
||||
assert remaining_param in labels, f"'{remaining_param}' should still appear"
|
||||
|
||||
|
||||
def test_i_can_get_completions_while_typing_param_name(provider):
|
||||
"""Test that suggestions appear while typing a partial parameter name.
|
||||
|
||||
Typing 'pre' after a comma should suggest 'precision=' (filtered by prefix)
|
||||
and exclude 'prefix=' (already used).
|
||||
"""
|
||||
text = 'column amount:\n format.number(prefix="€ ", pre'
|
||||
cursor = Position(line=1, ch=len(text.split("\n")[1]))
|
||||
|
||||
result = get_completions(text, cursor, provider, "app.orders")
|
||||
|
||||
labels = [s.label for s in result.suggestions]
|
||||
assert "precision=" in labels
|
||||
assert "prefix=" not in labels
|
||||
|
||||
|
||||
def test_i_can_get_completions_condition_unaffected_by_param_filtering(provider):
|
||||
"""Test that condition suggestions are not affected by parameter filtering."""
|
||||
text = 'column amount:\n style(bold=True) if '
|
||||
cursor = Position(line=1, ch=len(text.split("\n")[1]))
|
||||
|
||||
result = get_completions(text, cursor, provider, "app.orders")
|
||||
|
||||
labels = [s.label for s in result.suggestions]
|
||||
assert "value" in labels
|
||||
assert "col." in labels
|
||||
assert "not" in labels
|
||||
|
||||
|
||||
def test_i_can_create_formatting_completion_engine(provider):
|
||||
"""Test that FormattingCompletionEngine can be instantiated."""
|
||||
engine = FormattingCompletionEngine(provider, "app.orders")
|
||||
@@ -882,3 +1045,64 @@ def test_i_can_use_engine_detect_context(provider):
|
||||
context = engine.detect_context(text, cursor, scope)
|
||||
|
||||
assert context == Context.STYLE_ARGS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("format_type,expected_params", [
|
||||
("number", ["prefix=", "suffix=", "precision="]),
|
||||
("date", ["format="]),
|
||||
("boolean", ["true_value=", "false_value=", "null_value="]),
|
||||
("text", ["transform=", "max_length=", "ellipsis="]),
|
||||
("enum", ["source=", "default=", "allow_empty="]),
|
||||
("constant", ["value="]),
|
||||
])
|
||||
def test_i_can_get_completions_for_format_params(provider, format_type, expected_params):
|
||||
"""Test that correct parameters are suggested after 'format.<type>('."""
|
||||
text = f"column amount:\n format.{format_type}("
|
||||
cursor = Position(line=1, ch=len(f" format.{format_type}("))
|
||||
|
||||
result = get_completions(text, cursor, provider, "app.orders")
|
||||
|
||||
assert not result.is_empty
|
||||
labels = [s.label for s in result.suggestions]
|
||||
for param in expected_params:
|
||||
assert param in labels, f"Expected '{param}' in suggestions for format.{format_type}("
|
||||
|
||||
|
||||
def test_i_can_get_completions_for_format_type_after_dot(provider):
|
||||
"""Test that format types are suggested after 'format.'
|
||||
|
||||
The dot must be treated as a word delimiter so that the prefix
|
||||
is empty after the dot, allowing all FORMAT_TYPE suggestions through.
|
||||
Without this, the prefix 'format.' would filter out 'number(', 'date(', etc.
|
||||
"""
|
||||
text = "column amount:\n format."
|
||||
cursor = Position(line=1, ch=11)
|
||||
|
||||
result = get_completions(text, cursor, provider, "app.orders")
|
||||
|
||||
assert not result.is_empty
|
||||
labels = [s.label for s in result.suggestions]
|
||||
assert "number(" in labels
|
||||
assert "date(" in labels
|
||||
assert "boolean(" in labels
|
||||
assert "text(" in labels
|
||||
assert "enum(" in labels
|
||||
assert "constant(" in labels
|
||||
|
||||
|
||||
def test_i_can_get_completions_for_format_type_filtered_by_prefix(provider):
|
||||
"""Test that format types are filtered by partial input after 'format.'
|
||||
|
||||
Typing 'format.dat' should only suggest 'date(', not the other types.
|
||||
The dot delimiter ensures the prefix is 'dat', not 'format.dat'.
|
||||
"""
|
||||
text = "column amount:\n format.dat"
|
||||
cursor = Position(line=1, ch=14)
|
||||
|
||||
result = get_completions(text, cursor, provider, "app.orders")
|
||||
|
||||
labels = [s.label for s in result.suggestions]
|
||||
assert "date(" in labels
|
||||
assert "number(" not in labels
|
||||
assert "boolean(" not in labels
|
||||
assert "text(" not in labels
|
||||
|
||||
@@ -47,6 +47,30 @@ class TestNumberFormatter:
|
||||
)
|
||||
assert resolver.resolve(formatter, value) == expected
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
(-42, "42"),
|
||||
(-1.5, "2"), # rounds abs(-1.5) = 1.5 → 2
|
||||
(10, "10"),
|
||||
(-0.0, "0"),
|
||||
])
|
||||
def test_i_can_format_number_with_absolute(self, value, expected):
|
||||
"""Test that absolute=True strips the sign before formatting."""
|
||||
resolver = FormatterResolver()
|
||||
formatter = NumberFormatter(absolute=True)
|
||||
assert resolver.resolve(formatter, value) == expected
|
||||
|
||||
def test_i_can_combine_absolute_with_prefix_suffix(self):
|
||||
"""Test that absolute=True works together with prefix and suffix."""
|
||||
resolver = FormatterResolver()
|
||||
formatter = NumberFormatter(absolute=True, prefix="$", suffix=" USD", precision=2)
|
||||
assert resolver.resolve(formatter, -1234.56) == "$1234.56 USD"
|
||||
|
||||
def test_i_can_format_negative_number_without_absolute(self):
|
||||
"""Test that absolute=False (default) preserves the sign."""
|
||||
resolver = FormatterResolver()
|
||||
formatter = NumberFormatter(absolute=False, precision=2)
|
||||
assert resolver.resolve(formatter, -42.5) == "-42.50"
|
||||
|
||||
def test_number_none_value(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = NumberFormatter(precision=2)
|
||||
|
||||
Reference in New Issue
Block a user