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 @@ + + + + + + DataGridFormattingManager — Visual Mockup + + + + + + + +
+

DataGridFormattingManager

+

+ Named rule presets combining formatters, styles and conditions. + Reference them with format("preset_name") + or style("preset_name"). +

+
+ + +
+ + +
+ + + + + + +
+ + +
+ + +
+
+
+
+
Select a preset to edit
+
+
+
+ +
+
Rules — DSL
+ +
+
+ +
+ + +
+
+ DEFAULT_RULE_PRESETS — core/formatting/presets.py +
+
# name + description + list of complete FormatRule descriptors.
+# Appears in format() suggestions if at least one rule has a formatter.
+# Appears in style()  suggestions if at least one rule has a style.
+
+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"