diff --git a/examples/formatting_manager_mockup.html b/examples/formatting_manager_mockup.html
new file mode 100644
index 0000000..2e67faa
--- /dev/null
+++ b/examples/formatting_manager_mockup.html
@@ -0,0 +1,395 @@
+
+
+
+
+
+ DEFAULT_RULE_PRESETS — core/formatting/presets.py
+
+
+
+DEFAULT_RULE_PRESETS = {
+
+ "accounting": {
+ "description": "Negatives in parentheses (red), positives plain",
+ "rules": [
+ {
+ "condition": {"operator": "<", "value": 0},
+ "formatter": {"type": "number", "precision": 0,
+ "prefix": "(", "suffix": ")",
+ "absolute": True, "thousands_sep": " "},
+ "style": {"preset": "error"},
+ },
+ {
+ "condition": {"operator": ">", "value": 0},
+ "formatter": {"type": "number", "precision": 0,
+ "thousands_sep": " "},
+ },
+ ],
+ },
+
+ "traffic_light": {
+ "description": "Red / yellow / green style based on sign",
+ "rules": [
+ {"condition": {"operator": "<", "value": 0}, "style": {"preset": "error"}},
+ {"condition": {"operator": "==", "value": 0}, "style": {"preset": "warning"}},
+ {"condition": {"operator": ">", "value": 0}, "style": {"preset": "success"}},
+ ],
+ },
+
+ "budget_variance": {
+ "description": "% variance: negative=error, over 10%=warning, else plain",
+ "rules": [
+ {
+ "condition": {"operator": "<", "value": 0},
+ "formatter": {"type": "number", "precision": 1, "suffix": "%", "multiplier": 100},
+ "style": {"preset": "error"},
+ },
+ {
+ "condition": {"operator": ">", "value": 0.1},
+ "formatter": {"type": "number", "precision": 1, "suffix": "%", "multiplier": 100},
+ "style": {"preset": "warning"},
+ },
+ {
+ "formatter": {"type": "number", "precision": 1, "suffix": "%", "multiplier": 100},
+ },
+ ],
+ },
+
+}
+
+
+
+
+
diff --git a/src/myfasthtml/core/formatting/dataclasses.py b/src/myfasthtml/core/formatting/dataclasses.py
index 438c151..afd298f 100644
--- a/src/myfasthtml/core/formatting/dataclasses.py
+++ b/src/myfasthtml/core/formatting/dataclasses.py
@@ -6,164 +6,195 @@ from typing import Any
@dataclass
class Condition:
- """
- Represents a condition for conditional formatting.
+ """
+ Represents a condition for conditional formatting.
- Attributes:
- operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=",
- "contains", "startswith", "endswith", "in", "between",
- "isempty", "isnotempty")
- value: Value to compare against (literal, list, or {"col": "..."} for reference)
- negate: If True, inverts the condition result
- case_sensitive: If True, string comparisons are case-sensitive (default False)
- col: Column ID for row-level conditions (evaluate this column instead of current cell)
- row: Row index for column-level conditions (evaluate this row instead of current cell)
- """
- operator: str
- value: Any = None
- negate: bool = False
- case_sensitive: bool = False
- col: str = None
- row: int = None
+ Attributes:
+ operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=",
+ "contains", "startswith", "endswith", "in", "between",
+ "isempty", "isnotempty")
+ value: Value to compare against (literal, list, or {"col": "..."} for reference)
+ negate: If True, inverts the condition result
+ case_sensitive: If True, string comparisons are case-sensitive (default False)
+ col: Column ID for row-level conditions (evaluate this column instead of current cell)
+ row: Row index for column-level conditions (evaluate this row instead of current cell)
+ """
+ operator: str
+ value: Any = None
+ negate: bool = False
+ case_sensitive: bool = False
+ col: str = None
+ row: int = None
# === Style ===
@dataclass
class Style:
- """
- Represents style properties for cell formatting.
+ """
+ Represents style properties for cell formatting.
- Attributes:
- preset: Name of a style preset ("primary", "success", "error", etc.)
- background_color: Background color (hex, CSS name, or CSS variable)
- color: Text color
- font_weight: "normal" or "bold"
- font_style: "normal" or "italic"
- font_size: Font size ("12px", "0.9em")
- text_decoration: "none", "underline", or "line-through"
- """
- preset: str = None
- background_color: str = None
- color: str = None
- font_weight: str = None
- font_style: str = None
- font_size: str = None
- text_decoration: str = None
+ Attributes:
+ preset: Name of a style preset ("primary", "success", "error", etc.)
+ background_color: Background color (hex, CSS name, or CSS variable)
+ color: Text color
+ font_weight: "normal" or "bold"
+ font_style: "normal" or "italic"
+ font_size: Font size ("12px", "0.9em")
+ text_decoration: "none", "underline", or "line-through"
+ """
+ preset: str = None
+ background_color: str = None
+ color: str = None
+ font_weight: str = None
+ font_style: str = None
+ font_size: str = None
+ text_decoration: str = None
# === Formatters ===
@dataclass
class Formatter:
- """Base class for all formatters."""
- preset: str = None
+ """Base class for all formatters."""
+ preset: str = None
@dataclass
class NumberFormatter(Formatter):
- """
- Formatter for numbers, currencies, and percentages.
+ """
+ Formatter for numbers, currencies, and percentages.
- Attributes:
- prefix: Text before value (e.g., "$")
- suffix: Text after value (e.g., " EUR")
- thousands_sep: Thousands separator (e.g., ",", " ")
- decimal_sep: Decimal separator (e.g., ".", ",")
- precision: Number of decimal places
- multiplier: Multiply value before display (e.g., 100 for percentage)
- """
- prefix: str = ""
- suffix: str = ""
- thousands_sep: str = ""
- decimal_sep: str = "."
- precision: int = 0
- multiplier: float = 1.0
- absolute: bool = False
+ Attributes:
+ prefix: Text before value (e.g., "$")
+ suffix: Text after value (e.g., " EUR")
+ thousands_sep: Thousands separator (e.g., ",", " ")
+ decimal_sep: Decimal separator (e.g., ".", ",")
+ precision: Number of decimal places
+ multiplier: Multiply value before display (e.g., 100 for percentage)
+ """
+ prefix: str = ""
+ suffix: str = ""
+ thousands_sep: str = ""
+ decimal_sep: str = "."
+ precision: int = 0
+ multiplier: float = 1.0
+ absolute: bool = False
@dataclass
class DateFormatter(Formatter):
- """
- Formatter for dates and datetimes.
+ """
+ Formatter for dates and datetimes.
- Attributes:
- format: strftime format pattern (default: "%Y-%m-%d")
- """
- format: str = "%Y-%m-%d"
+ Attributes:
+ format: strftime format pattern (default: "%Y-%m-%d")
+ """
+ format: str = "%Y-%m-%d"
@dataclass
class BooleanFormatter(Formatter):
- """
- Formatter for boolean values.
+ """
+ Formatter for boolean values.
- Attributes:
- true_value: Display string for True
- false_value: Display string for False
- null_value: Display string for None/null
- """
- true_value: str = "true"
- false_value: str = "false"
- null_value: str = ""
+ Attributes:
+ true_value: Display string for True
+ false_value: Display string for False
+ null_value: Display string for None/null
+ """
+ true_value: str = "true"
+ false_value: str = "false"
+ null_value: str = ""
@dataclass
class TextFormatter(Formatter):
- """
- Formatter for text transformations.
+ """
+ Formatter for text transformations.
+
+ Attributes:
+ transform: Text transformation ("uppercase", "lowercase", "capitalize")
+ max_length: Maximum length before truncation
+ ellipsis: Suffix when truncated (default: "...")
+ """
+ transform: str = None
+ max_length: int = None
+ ellipsis: str = "..."
- Attributes:
- transform: Text transformation ("uppercase", "lowercase", "capitalize")
- max_length: Maximum length before truncation
- ellipsis: Suffix when truncated (default: "...")
- """
- transform: str = None
- max_length: int = None
- ellipsis: str = "..."
@dataclass
class ConstantFormatter(Formatter):
- value: str = None
+ value: str = None
+
@dataclass
class EnumFormatter(Formatter):
- """
- Formatter for mapping values to display labels.
+ """
+ Formatter for mapping values to display labels.
- Attributes:
- source: Data source dict with "type" and "value" keys
- - {"type": "mapping", "value": {"key": "label", ...}}
- - {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"}
- default: Label for unknown values
- allow_empty: Show empty option in Select dropdowns
- empty_label: Label for empty option
- order_by: Sort order ("source", "display", "value")
- """
- source: dict = field(default_factory=dict)
- default: str = ""
- allow_empty: bool = True
- empty_label: str = "-- Select --"
- order_by: str = "source"
+ Attributes:
+ source: Data source dict with "type" and "value" keys
+ - {"type": "mapping", "value": {"key": "label", ...}}
+ - {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"}
+ default: Label for unknown values
+ allow_empty: Show empty option in Select dropdowns
+ empty_label: Label for empty option
+ order_by: Sort order ("source", "display", "value")
+ """
+ source: dict = field(default_factory=dict)
+ default: str = ""
+ allow_empty: bool = True
+ empty_label: str = "-- Select --"
+ order_by: str = "source"
# === Format Rule ===
@dataclass
class FormatRule:
- """
- A formatting rule combining condition, style, and formatter.
+ """
+ A formatting rule combining condition, style, and formatter.
- Rules:
- - style and formatter can appear alone (unconditional formatting)
- - condition cannot appear alone - must be paired with style and/or formatter
- - If condition is present, style/formatter is applied only if condition is met
+ Rules:
+ - style and formatter can appear alone (unconditional formatting)
+ - condition cannot appear alone - must be paired with style and/or formatter
+ - If condition is present, style/formatter is applied only if condition is met
- Attributes:
- condition: Optional condition for conditional formatting
- style: Optional style to apply
- formatter: Optional formatter to apply
- """
- condition: Condition = None
- style: Style = None
- formatter: Formatter = None
+ Attributes:
+ condition: Optional condition for conditional formatting
+ style: Optional style to apply
+ formatter: Optional formatter to apply
+ """
+ condition: Condition = None
+ style: Style = None
+ formatter: Formatter = None
+
+
+# === Rule Preset ===
+
+@dataclass
+class RulePreset:
+ """
+ A named, reusable list of FormatRules.
+
+ Referenced in DSL as format("name") or style("name").
+ Appears in format() suggestions if at least one rule has a formatter.
+ Appears in style() suggestions if at least one rule has a style.
+
+ Attributes:
+ name: Unique identifier used in DSL (e.g. "accounting")
+ description: Human-readable description shown in the UI
+ rules: Ordered list of FormatRule to apply
+ """
+ name: str
+ description: str
+ rules: list[FormatRule] = field(default_factory=list)
+
+ def has_formatter(self) -> bool:
+ """Returns True if at least one rule defines a formatter."""
+ return any(r.formatter is not None for r in self.rules)
+
+ def has_style(self) -> bool:
+ """Returns True if at least one rule defines a style."""
+ return any(r.style is not None for r in self.rules)
diff --git a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py
index e093fe9..84e50a9 100644
--- a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py
+++ b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py
@@ -322,28 +322,41 @@ class FormattingCompletionEngine(BaseCompletionEngine):
def _get_style_preset_suggestions(self) -> list[Suggestion]:
"""Get style preset suggestions (without quotes)."""
suggestions = []
-
- # Add provider presets if available
+
+ # Add rule presets that have at least one style rule
+ try:
+ for rule_preset in self.provider.list_rule_presets_for_style():
+ suggestions.append(Suggestion(rule_preset.name, rule_preset.description, "rule_preset"))
+ except Exception:
+ pass
+
+ # Add provider custom style presets
try:
custom_presets = self.provider.list_style_presets()
for preset in custom_presets:
- # Check if it's already in default presets
if not any(s.label == preset for s in presets.STYLE_PRESETS):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
-
- # Add default presets (just the name, no quotes - we're inside quotes)
+
+ # Add default style presets (no quotes — we're inside quotes)
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
-
+
return suggestions
-
+
def _get_style_preset_suggestions_quoted(self) -> list[Suggestion]:
"""Get style preset suggestions with quotes."""
suggestions = []
-
- # Add provider presets if available
+
+ # Add rule presets that have at least one style rule
+ try:
+ for rule_preset in self.provider.list_rule_presets_for_style():
+ suggestions.append(Suggestion(f'"{rule_preset.name}"', rule_preset.description, "rule_preset"))
+ except Exception:
+ pass
+
+ # Add provider custom style presets
try:
custom_presets = self.provider.list_style_presets()
for preset in custom_presets:
@@ -351,18 +364,25 @@ class FormattingCompletionEngine(BaseCompletionEngine):
suggestions.append(Suggestion(f'"{preset}"', "Custom preset", "preset"))
except Exception:
pass
-
- # Add default presets with quotes
+
+ # Add default style presets with quotes
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(f'"{preset.label}"', preset.detail, preset.kind))
-
+
return suggestions
-
+
def _get_format_preset_suggestions(self) -> list[Suggestion]:
"""Get format preset suggestions (without quotes)."""
suggestions = []
-
- # Add provider presets if available
+
+ # Add rule presets that have at least one formatter rule
+ try:
+ for rule_preset in self.provider.list_rule_presets_for_format():
+ suggestions.append(Suggestion(rule_preset.name, rule_preset.description, "rule_preset"))
+ except Exception:
+ pass
+
+ # Add provider custom formatter presets
try:
custom_presets = self.provider.list_format_presets()
for preset in custom_presets:
@@ -370,11 +390,11 @@ class FormattingCompletionEngine(BaseCompletionEngine):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
-
- # Add default presets
+
+ # Add default formatter presets
for preset in presets.FORMAT_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
-
+
return suggestions
def _get_row_index_suggestions(self) -> list[Suggestion]:
diff --git a/src/myfasthtml/core/formatting/dsl/completion/provider.py b/src/myfasthtml/core/formatting/dsl/completion/provider.py
index ede2118..9b5e5df 100644
--- a/src/myfasthtml/core/formatting/dsl/completion/provider.py
+++ b/src/myfasthtml/core/formatting/dsl/completion/provider.py
@@ -11,7 +11,10 @@ from typing import Any, Optional
from myfasthtml.core.data.DataServicesManager import DataServicesManager
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
-from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS
+from myfasthtml.core.formatting.dataclasses import RulePreset
+from myfasthtml.core.formatting.presets import (
+ DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS, DEFAULT_RULE_PRESETS,
+)
from myfasthtml.core.instances import SingleInstance, InstancesManager
logger = logging.getLogger(__name__)
@@ -38,6 +41,7 @@ class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
super().__init__(parent, session, _id)
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
+ self.rule_presets: dict[str, RulePreset] = DEFAULT_RULE_PRESETS.copy()
self.all_tables_formats: list = []
# ------------------------------------------------------------------
@@ -132,6 +136,14 @@ class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
"""Return the names of all registered formatter presets."""
return list(self.formatter_presets.keys())
+ def list_rule_presets_for_format(self) -> list[RulePreset]:
+ """Return rule presets that have at least one rule with a formatter."""
+ return [p for p in self.rule_presets.values() if p.has_formatter()]
+
+ def list_rule_presets_for_style(self) -> list[RulePreset]:
+ """Return rule presets that have at least one rule with a style."""
+ return [p for p in self.rule_presets.values() if p.has_style()]
+
def get_style_presets(self) -> dict:
"""Return the full style presets dict."""
return self.style_presets
diff --git a/src/myfasthtml/core/formatting/engine.py b/src/myfasthtml/core/formatting/engine.py
index 02a471c..6e7f793 100644
--- a/src/myfasthtml/core/formatting/engine.py
+++ b/src/myfasthtml/core/formatting/engine.py
@@ -3,6 +3,7 @@ from typing import Any, Callable
from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator
from myfasthtml.core.formatting.dataclasses import FormatRule
from myfasthtml.core.formatting.formatter_resolver import FormatterResolver
+from myfasthtml.core.formatting.presets import DEFAULT_RULE_PRESETS
from myfasthtml.core.formatting.style_resolver import StyleResolver, StyleContainer
@@ -29,6 +30,7 @@ class FormattingEngine:
self,
style_presets: dict = None,
formatter_presets: dict = None,
+ rule_presets: dict = None,
lookup_resolver: Callable[[str, str, str], dict] = None
):
"""
@@ -37,11 +39,13 @@ class FormattingEngine:
Args:
style_presets: Custom style presets. If None, uses defaults.
formatter_presets: Custom formatter presets. If None, uses defaults.
+ rule_presets: Named rule presets (list of FormatRule dicts). If None, uses defaults.
lookup_resolver: Function for resolving enum datagrid sources.
"""
self._condition_evaluator = ConditionEvaluator()
self._style_resolver = StyleResolver(style_presets)
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
+ self._rule_presets = rule_presets if rule_presets is not None else DEFAULT_RULE_PRESETS
def apply_format(
self,
@@ -65,6 +69,9 @@ class FormattingEngine:
if not rules:
return None, None
+ # Expand rule preset references before evaluating
+ rules = self._expand_rule_presets(rules)
+
# Find all matching rules
matching_rules = self._get_matching_rules(rules, cell_value, row_data)
@@ -88,6 +95,37 @@ class FormattingEngine:
return style, formatted_value
+ def _expand_rule_presets(self, rules: list[FormatRule]) -> list[FormatRule]:
+ """
+ Replace any FormatRule that references a rule preset with the preset's rules.
+
+ A rule is a rule preset reference when its formatter has a preset name
+ that exists in rule_presets (and not in formatter_presets).
+
+ Args:
+ rules: Original list of FormatRule
+
+ Returns:
+ Expanded list with preset references replaced by their FormatRules
+ """
+ expanded = []
+ for rule in rules:
+ preset_name = self._get_rule_preset_name(rule)
+ if preset_name:
+ expanded.extend(self._rule_presets[preset_name].rules)
+ else:
+ expanded.append(rule)
+ return expanded
+
+ def _get_rule_preset_name(self, rule: FormatRule) -> str | None:
+ """Return the preset name if the rule's formatter references a rule preset, else None."""
+ if rule.formatter is None:
+ return None
+ preset = getattr(rule.formatter, "preset", None)
+ if preset and preset in self._rule_presets:
+ return preset
+ return None
+
def _get_matching_rules(
self,
rules: list[FormatRule],
diff --git a/src/myfasthtml/core/formatting/presets.py b/src/myfasthtml/core/formatting/presets.py
index 6b37621..2c764e2 100644
--- a/src/myfasthtml/core/formatting/presets.py
+++ b/src/myfasthtml/core/formatting/presets.py
@@ -1,3 +1,7 @@
+from myfasthtml.core.formatting.dataclasses import (
+ Condition, Style, FormatRule, RulePreset,
+ NumberFormatter, )
+
# === Style Presets (DaisyUI 5) ===
# Keys use CSS property names (with hyphens)
@@ -22,6 +26,56 @@ DEFAULT_STYLE_PRESETS = {
"white": {"__class__": "mf-formatting-white", },
}
+# === Rule Presets ===
+# Each RulePreset = name + description + list of FormatRule objects.
+# Referenced in DSL as: format("name") or style("name")
+
+DEFAULT_RULE_PRESETS: dict[str, RulePreset] = {
+ "accounting": RulePreset(
+ name="accounting",
+ description="Negatives in parentheses, positives plain",
+ rules=[
+ FormatRule(
+ condition=Condition(operator="<", value=0),
+ formatter=NumberFormatter(precision=0, prefix="(", suffix=")",
+ absolute=True, thousands_sep=" "),
+ ),
+ FormatRule(
+ condition=Condition(operator=">", value=0),
+ formatter=NumberFormatter(precision=0, thousands_sep=" "),
+ ),
+ ],
+ ),
+ "traffic_light": RulePreset(
+ name="traffic_light",
+ description="Red / yellow / green style based on sign",
+ rules=[
+ FormatRule(condition=Condition(operator="<", value=0), style=Style(preset="error")),
+ FormatRule(condition=Condition(operator="==", value=0), style=Style(preset="warning")),
+ FormatRule(condition=Condition(operator=">", value=0), style=Style(preset="success")),
+ ],
+ ),
+ "budget_variance": RulePreset(
+ name="budget_variance",
+ description="% variance: negative=error, over 10%=warning, else plain",
+ rules=[
+ FormatRule(
+ condition=Condition(operator="<", value=0),
+ formatter=NumberFormatter(precision=1, suffix="%", multiplier=100),
+ style=Style(preset="error"),
+ ),
+ FormatRule(
+ condition=Condition(operator=">", value=0.1),
+ formatter=NumberFormatter(precision=1, suffix="%", multiplier=100),
+ style=Style(preset="warning"),
+ ),
+ FormatRule(
+ formatter=NumberFormatter(precision=1, suffix="%", multiplier=100),
+ ),
+ ],
+ ),
+}
+
# === Formatter Presets ===
DEFAULT_FORMATTER_PRESETS = {
diff --git a/tests/core/formatting/test_rule_presets.py b/tests/core/formatting/test_rule_presets.py
new file mode 100644
index 0000000..509bfda
--- /dev/null
+++ b/tests/core/formatting/test_rule_presets.py
@@ -0,0 +1,167 @@
+"""Tests for DEFAULT_RULE_PRESETS expansion in FormattingEngine."""
+import pytest
+
+from myfasthtml.core.formatting.dataclasses import FormatRule, NumberFormatter, RulePreset
+from myfasthtml.core.formatting.engine import FormattingEngine
+from myfasthtml.core.formatting.style_resolver import StyleContainer
+
+
+# ============================================================
+# Helpers
+# ============================================================
+
+def make_preset_rule(preset_name: str) -> list[FormatRule]:
+ """Simulate what format("preset_name") produces from the DSL transformer."""
+ return [FormatRule(formatter=NumberFormatter(preset=preset_name))]
+
+
+# ============================================================
+# accounting preset
+# ============================================================
+
+def test_i_can_use_accounting_preset_with_negative_value():
+ """Negative value → parentheses format, no style."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("accounting")
+
+ css, formatted = engine.apply_format(rules, cell_value=-12500)
+
+ assert formatted == "(12 500)"
+ assert css is None
+
+
+def test_i_can_use_accounting_preset_with_positive_value():
+ """Positive value → plain number format, no style."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("accounting")
+
+ css, formatted = engine.apply_format(rules, cell_value=8340)
+
+ assert formatted == "8 340"
+ assert css is None
+
+
+def test_i_can_use_accounting_preset_with_zero():
+ """Zero does not match < 0 or > 0 → no formatter, no style."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("accounting")
+
+ css, formatted = engine.apply_format(rules, cell_value=0)
+
+ assert formatted is None
+ assert css is None
+
+
+# ============================================================
+# traffic_light preset
+# ============================================================
+
+def test_i_can_use_traffic_light_preset_with_negative_value():
+ """Negative value → error style (red)."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("traffic_light")
+
+ css, formatted = engine.apply_format(rules, cell_value=-1)
+
+ assert css is not None
+ assert css.cls == "mf-formatting-error"
+ assert formatted is None
+
+
+def test_i_can_use_traffic_light_preset_with_zero():
+ """Zero → warning style (yellow)."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("traffic_light")
+
+ css, formatted = engine.apply_format(rules, cell_value=0)
+
+ assert css is not None
+ assert css.cls == "mf-formatting-warning"
+
+
+def test_i_can_use_traffic_light_preset_with_positive_value():
+ """Positive value → success style (green)."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("traffic_light")
+
+ css, formatted = engine.apply_format(rules, cell_value=42)
+
+ assert css is not None
+ assert css.cls == "mf-formatting-success"
+
+
+# ============================================================
+# budget_variance preset
+# ============================================================
+
+def test_i_can_use_budget_variance_preset_with_negative_value():
+ """Negative variance → percentage format + error style."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("budget_variance")
+
+ css, formatted = engine.apply_format(rules, cell_value=-0.08)
+
+ assert formatted == "-8.0%"
+ assert css is not None
+ assert css.cls == "mf-formatting-error"
+
+
+def test_i_can_use_budget_variance_preset_over_threshold():
+ """Variance > 10% → percentage format + warning style."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("budget_variance")
+
+ css, formatted = engine.apply_format(rules, cell_value=0.15)
+
+ assert formatted == "15.0%"
+ assert css is not None
+ assert css.cls == "mf-formatting-warning"
+
+
+def test_i_can_use_budget_variance_preset_within_range():
+ """Variance within 0–10% → percentage format, no special style."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("budget_variance")
+
+ css, formatted = engine.apply_format(rules, cell_value=0.05)
+
+ assert formatted == "5.0%"
+ assert css is None
+
+
+# ============================================================
+# Unknown preset → no expansion, engine falls back gracefully
+# ============================================================
+
+def test_i_cannot_use_unknown_rule_preset():
+ """Unknown preset name is not expanded — treated as plain formatter preset (no crash)."""
+ engine = FormattingEngine()
+ rules = make_preset_rule("nonexistent_preset")
+
+ # Should not raise; returns defaults (no special formatting)
+ css, formatted = engine.apply_format(rules, cell_value=42)
+
+ assert css is None
+
+
+# ============================================================
+# Custom rule_presets override
+# ============================================================
+
+def test_i_can_pass_custom_rule_presets():
+ """Engine accepts custom rule_presets dict of RulePreset, overriding defaults."""
+ custom = {
+ "my_preset": RulePreset(
+ name="my_preset",
+ description="Custom test preset",
+ rules=[
+ FormatRule(formatter=NumberFormatter(precision=2, suffix=" pts")),
+ ],
+ )
+ }
+ engine = FormattingEngine(rule_presets=custom)
+ rules = make_preset_rule("my_preset")
+
+ _, formatted = engine.apply_format(rules, cell_value=9.5)
+
+ assert formatted == "9.50 pts"