Added classes to support formatting
This commit is contained in:
1
tests/core/formatting/__init__.py
Normal file
1
tests/core/formatting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests for formatting module
|
||||
233
tests/core/formatting/test_condition_evaluator.py
Normal file
233
tests/core/formatting/test_condition_evaluator.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator
|
||||
from myfasthtml.core.formatting.dataclasses import Condition
|
||||
|
||||
|
||||
class TestComparisonOperators:
|
||||
@pytest.mark.parametrize("operator,cell_value,compare_value,expected", [
|
||||
# ==
|
||||
("==", 5, 5, True),
|
||||
("==", 5, 10, False),
|
||||
("==", "hello", "hello", True),
|
||||
("==", "hello", "world", False),
|
||||
("==", "Hello", "hello", True), # case insensitive by default
|
||||
# !=
|
||||
("!=", 5, 10, True),
|
||||
("!=", 5, 5, False),
|
||||
# <
|
||||
("<", 5, 10, True),
|
||||
("<", 10, 5, False),
|
||||
("<", 5, 5, False),
|
||||
("<", 5.0, 10, True), # int/float mix
|
||||
# <=
|
||||
("<=", 5, 10, True),
|
||||
("<=", 5, 5, True),
|
||||
("<=", 10, 5, False),
|
||||
# >
|
||||
(">", 10, 5, True),
|
||||
(">", 5, 10, False),
|
||||
(">", 5, 5, False),
|
||||
# >=
|
||||
(">=", 10, 5, True),
|
||||
(">=", 5, 5, True),
|
||||
(">=", 5, 10, False),
|
||||
# type mismatch returns False
|
||||
(">=", 10, "5", False),
|
||||
])
|
||||
def test_comparison_operators(self, operator, cell_value, compare_value, expected):
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator=operator, value=compare_value)
|
||||
assert evaluator.evaluate(condition, cell_value) == expected
|
||||
|
||||
|
||||
class TestStringOperators:
|
||||
@pytest.mark.parametrize("operator,cell_value,compare_value,case_sensitive,expected", [
|
||||
# contains
|
||||
("contains", "Hello World", "World", False, True),
|
||||
("contains", "Hello World", "world", False, True),
|
||||
("contains", "Hello World", "world", True, False),
|
||||
("contains", "Hello World", "xyz", False, False),
|
||||
# startswith
|
||||
("startswith", "Hello World", "Hello", False, True),
|
||||
("startswith", "Hello World", "hello", False, True),
|
||||
("startswith", "Hello World", "hello", True, False),
|
||||
("startswith", "Hello World", "World", False, False),
|
||||
# endswith
|
||||
("endswith", "Hello World", "World", False, True),
|
||||
("endswith", "Hello World", "world", False, True),
|
||||
("endswith", "Hello World", "world", True, False),
|
||||
("endswith", "Hello World", "Hello", False, False),
|
||||
])
|
||||
def test_string_operators(self, operator, cell_value, compare_value, case_sensitive, expected):
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator=operator, value=compare_value, case_sensitive=case_sensitive)
|
||||
assert evaluator.evaluate(condition, cell_value) == expected
|
||||
|
||||
def test_string_operators_convert_non_strings(self):
|
||||
"""String operators should convert non-string values to strings."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="contains", value="23")
|
||||
assert evaluator.evaluate(condition, 123) is True
|
||||
|
||||
|
||||
class TestCollectionOperators:
|
||||
@pytest.mark.parametrize("operator,cell_value,compare_value,expected", [
|
||||
# in
|
||||
("in", "A", ["A", "B", "C"], True),
|
||||
("in", "D", ["A", "B", "C"], False),
|
||||
("in", 1, [1, 2, 3], True),
|
||||
("in", 4, [1, 2, 3], False),
|
||||
# between (inclusive)
|
||||
("between", 5, [1, 10], True),
|
||||
("between", 1, [1, 10], True),
|
||||
("between", 10, [1, 10], True),
|
||||
("between", 0, [1, 10], False),
|
||||
("between", 11, [1, 10], False),
|
||||
("between", 5.5, [1, 10], True),
|
||||
])
|
||||
def test_collection_operators(self, operator, cell_value, compare_value, expected):
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator=operator, value=compare_value)
|
||||
assert evaluator.evaluate(condition, cell_value) == expected
|
||||
|
||||
|
||||
class TestNullOperators:
|
||||
@pytest.mark.parametrize("operator,cell_value,expected", [
|
||||
# isempty
|
||||
("isempty", None, True),
|
||||
("isempty", "", True),
|
||||
("isempty", "hello", False),
|
||||
("isempty", 0, False),
|
||||
("isempty", False, False),
|
||||
# isnotempty
|
||||
("isnotempty", "hello", True),
|
||||
("isnotempty", 0, True),
|
||||
("isnotempty", False, True),
|
||||
("isnotempty", None, False),
|
||||
("isnotempty", "", False),
|
||||
])
|
||||
def test_null_operators(self, operator, cell_value, expected):
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator=operator)
|
||||
assert evaluator.evaluate(condition, cell_value) == expected
|
||||
|
||||
|
||||
class TestNegation:
|
||||
@pytest.mark.parametrize("operator,cell_value,compare_value,negate,expected", [
|
||||
("==", 5, 5, False, True),
|
||||
("==", 5, 5, True, False),
|
||||
("==", 5, 10, True, True),
|
||||
("in", "A", ["A", "B"], False, True),
|
||||
("in", "A", ["A", "B"], True, False),
|
||||
("in", "C", ["A", "B"], True, True),
|
||||
("contains", "hello", "ell", False, True),
|
||||
("contains", "hello", "ell", True, False),
|
||||
("isempty", None, None, False, True),
|
||||
("isempty", None, None, True, False),
|
||||
])
|
||||
def test_negation(self, operator, cell_value, compare_value, negate, expected):
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator=operator, value=compare_value, negate=negate)
|
||||
assert evaluator.evaluate(condition, cell_value) == expected
|
||||
|
||||
|
||||
class TestColumnReference:
|
||||
def test_i_can_evaluate_with_column_reference_in_value(self):
|
||||
"""Compare cell value with another column's value using value={"col": ...}."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator=">", value={"col": "budget"})
|
||||
row_data = {"budget": 100, "actual": 150}
|
||||
assert evaluator.evaluate(condition, cell_value=150, row_data=row_data) is True
|
||||
assert evaluator.evaluate(condition, cell_value=50, row_data=row_data) is False
|
||||
|
||||
def test_i_can_evaluate_with_col_parameter(self):
|
||||
"""Row-level condition: evaluate based on specific column using col parameter."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value="error", col="status")
|
||||
row_data = {"status": "error", "name": "Task 1"}
|
||||
# cell_value is ignored when col is set
|
||||
assert evaluator.evaluate(condition, cell_value="anything", row_data=row_data) is True
|
||||
|
||||
def test_i_can_evaluate_col_parameter_with_non_matching_value(self):
|
||||
"""Row-level condition returns False when column value doesn't match."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value="error", col="status")
|
||||
row_data = {"status": "ok", "name": "Task 1"}
|
||||
assert evaluator.evaluate(condition, cell_value="anything", row_data=row_data) is False
|
||||
|
||||
def test_col_parameter_missing_column_returns_false(self):
|
||||
"""When col parameter references non-existent column, return False."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value="error", col="missing")
|
||||
row_data = {"status": "error"}
|
||||
assert evaluator.evaluate(condition, cell_value="anything", row_data=row_data) is False
|
||||
|
||||
def test_col_parameter_without_row_data_returns_false(self):
|
||||
"""When col parameter is set but no row_data provided, return False."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value="error", col="status")
|
||||
assert evaluator.evaluate(condition, cell_value="anything", row_data=None) is False
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_null_cell_value_returns_false(self):
|
||||
"""None cell value returns False for comparison operators."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value=5)
|
||||
assert evaluator.evaluate(condition, cell_value=None) is False
|
||||
|
||||
def test_null_reference_returns_false(self):
|
||||
"""Column reference resolving to None returns False."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value={"col": "other"})
|
||||
row_data = {"other": None}
|
||||
assert evaluator.evaluate(condition, cell_value=5, row_data=row_data) is False
|
||||
|
||||
def test_missing_reference_column_returns_false(self):
|
||||
"""Missing column in row_data returns False."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value={"col": "missing"})
|
||||
row_data = {"other": 10}
|
||||
assert evaluator.evaluate(condition, cell_value=5, row_data=row_data) is False
|
||||
|
||||
def test_no_row_data_for_reference_returns_false(self):
|
||||
"""Column reference without row_data returns False."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value={"col": "other"})
|
||||
assert evaluator.evaluate(condition, cell_value=5, row_data=None) is False
|
||||
|
||||
@pytest.mark.parametrize("cell_value,compare_value", [
|
||||
("abc", 5),
|
||||
(5, "abc"),
|
||||
([1, 2], 5),
|
||||
({"a": 1}, 5),
|
||||
])
|
||||
def test_type_mismatch_returns_false(self, cell_value, compare_value):
|
||||
"""Type mismatch in comparison returns False."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="<", value=compare_value)
|
||||
assert evaluator.evaluate(condition, cell_value) is False
|
||||
|
||||
def test_unknown_operator_returns_false(self):
|
||||
"""Unknown operator returns False."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="unknown", value=5)
|
||||
assert evaluator.evaluate(condition, cell_value=5) is False
|
||||
|
||||
|
||||
class TestCaseSensitiveEquals:
|
||||
def test_case_sensitive_string_equals(self):
|
||||
"""Case sensitive string comparison with ==."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value="Hello", case_sensitive=True)
|
||||
assert evaluator.evaluate(condition, "Hello") is True
|
||||
assert evaluator.evaluate(condition, "hello") is False
|
||||
|
||||
def test_case_insensitive_string_equals_default(self):
|
||||
"""Case insensitive string comparison is default."""
|
||||
evaluator = ConditionEvaluator()
|
||||
condition = Condition(operator="==", value="Hello", case_sensitive=False)
|
||||
assert evaluator.evaluate(condition, "Hello") is True
|
||||
assert evaluator.evaluate(condition, "hello") is True
|
||||
assert evaluator.evaluate(condition, "HELLO") is True
|
||||
282
tests/core/formatting/test_engine.py
Normal file
282
tests/core/formatting/test_engine.py
Normal file
@@ -0,0 +1,282 @@
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.formatting.dataclasses import (
|
||||
Condition,
|
||||
Style,
|
||||
Formatter,
|
||||
NumberFormatter,
|
||||
FormatRule,
|
||||
)
|
||||
from myfasthtml.core.formatting.engine import FormattingEngine
|
||||
|
||||
|
||||
class TestApplyFormat:
|
||||
def test_apply_format_with_style_only(self):
|
||||
"""Rule with style only returns CSS string."""
|
||||
engine = FormattingEngine()
|
||||
rules = [FormatRule(style=Style(background_color="red", color="white"))]
|
||||
|
||||
css, formatted = engine.apply_format(rules, cell_value=42)
|
||||
|
||||
assert css is not None
|
||||
assert "background-color: red" in css
|
||||
assert "color: white" in css
|
||||
assert formatted is None
|
||||
|
||||
def test_apply_format_with_formatter_only(self):
|
||||
"""Rule with formatter only returns formatted value."""
|
||||
engine = FormattingEngine()
|
||||
rules = [FormatRule(formatter=NumberFormatter(precision=2, suffix=" €"))]
|
||||
|
||||
css, formatted = engine.apply_format(rules, cell_value=1234.5)
|
||||
|
||||
assert css is None
|
||||
assert formatted == "1234.50 €"
|
||||
|
||||
def test_apply_format_with_style_and_formatter(self):
|
||||
"""Rule with both style and formatter returns both."""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(
|
||||
style=Style(color="green"),
|
||||
formatter=NumberFormatter(precision=2)
|
||||
)
|
||||
]
|
||||
|
||||
css, formatted = engine.apply_format(rules, cell_value=42.567)
|
||||
|
||||
assert css is not None
|
||||
assert "color: green" in css
|
||||
assert formatted == "42.57"
|
||||
|
||||
def test_apply_format_condition_met(self):
|
||||
"""Conditional rule applies when condition is met."""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(
|
||||
condition=Condition(operator="<", value=0),
|
||||
style=Style(color="red")
|
||||
)
|
||||
]
|
||||
|
||||
css, formatted = engine.apply_format(rules, cell_value=-5)
|
||||
|
||||
assert css is not None
|
||||
assert "color: red" in css
|
||||
|
||||
def test_apply_format_condition_not_met(self):
|
||||
"""Conditional rule does not apply when condition is not met."""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(
|
||||
condition=Condition(operator="<", value=0),
|
||||
style=Style(color="red")
|
||||
)
|
||||
]
|
||||
|
||||
css, formatted = engine.apply_format(rules, cell_value=5)
|
||||
|
||||
assert css is None
|
||||
assert formatted is None
|
||||
|
||||
def test_empty_rules_returns_none(self):
|
||||
"""Empty rules list returns (None, None)."""
|
||||
engine = FormattingEngine()
|
||||
|
||||
css, formatted = engine.apply_format([], cell_value=42)
|
||||
|
||||
assert css is None
|
||||
assert formatted is None
|
||||
|
||||
|
||||
class TestConflictResolution:
|
||||
def test_unconditional_rule_always_applies(self):
|
||||
"""Unconditional rule (no condition) always applies."""
|
||||
engine = FormattingEngine()
|
||||
rules = [FormatRule(style=Style(color="gray"))]
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value="anything")
|
||||
|
||||
assert css is not None
|
||||
assert "color: gray" in css
|
||||
|
||||
def test_multiple_unconditional_rules_last_wins(self):
|
||||
"""Among unconditional rules, last one wins."""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(style=Style(color="red")),
|
||||
FormatRule(style=Style(color="blue")),
|
||||
FormatRule(style=Style(color="green")),
|
||||
]
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=42)
|
||||
|
||||
assert "color: green" in css
|
||||
assert "color: red" not in css
|
||||
assert "color: blue" not in css
|
||||
|
||||
def test_conditional_beats_unconditional(self):
|
||||
"""Conditional rule (higher specificity) beats unconditional."""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(style=Style(color="gray")), # unconditional, specificity=0
|
||||
FormatRule(
|
||||
condition=Condition(operator="<", value=0),
|
||||
style=Style(color="red") # conditional, specificity=1
|
||||
),
|
||||
]
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=-5)
|
||||
|
||||
assert "color: red" in css
|
||||
assert "color: gray" not in css
|
||||
|
||||
def test_conditional_not_met_falls_back_to_unconditional(self):
|
||||
"""When conditional doesn't match, unconditional applies."""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(style=Style(color="gray")), # unconditional
|
||||
FormatRule(
|
||||
condition=Condition(operator="<", value=0),
|
||||
style=Style(color="red") # doesn't match
|
||||
),
|
||||
]
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=5) # positive, condition not met
|
||||
|
||||
assert "color: gray" in css
|
||||
|
||||
def test_multiple_conditional_last_wins(self):
|
||||
"""Among conditional rules with same specificity, last wins."""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(
|
||||
condition=Condition(operator="<", value=10),
|
||||
style=Style(color="red")
|
||||
),
|
||||
FormatRule(
|
||||
condition=Condition(operator="<", value=10),
|
||||
style=Style(color="blue")
|
||||
),
|
||||
]
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=5)
|
||||
|
||||
assert "color: blue" in css
|
||||
assert "color: red" not in css
|
||||
|
||||
def test_spec_example_value_minus_5(self):
|
||||
"""
|
||||
Example from spec: value=-5
|
||||
Rule 1: unconditional gray
|
||||
Rule 2: <0 -> red
|
||||
Rule 3: ==-5 -> black
|
||||
|
||||
Both rule 2 and 3 match with same specificity (1).
|
||||
Rule 3 is last, so black wins.
|
||||
"""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(style=Style(color="gray")),
|
||||
FormatRule(
|
||||
condition=Condition(operator="<", value=0),
|
||||
style=Style(color="red")
|
||||
),
|
||||
FormatRule(
|
||||
condition=Condition(operator="==", value=-5),
|
||||
style=Style(color="black")
|
||||
),
|
||||
]
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=-5)
|
||||
|
||||
assert "color: black" in css
|
||||
|
||||
def test_spec_example_value_minus_3(self):
|
||||
"""
|
||||
Same rules as above but value=-3.
|
||||
Rule 3 (==-5) doesn't match.
|
||||
Rule 2 (<0) matches and beats rule 1 (unconditional).
|
||||
"""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(style=Style(color="gray")),
|
||||
FormatRule(
|
||||
condition=Condition(operator="<", value=0),
|
||||
style=Style(color="red")
|
||||
),
|
||||
FormatRule(
|
||||
condition=Condition(operator="==", value=-5),
|
||||
style=Style(color="black")
|
||||
),
|
||||
]
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=-3)
|
||||
|
||||
assert "color: red" in css
|
||||
|
||||
|
||||
class TestWithRowData:
|
||||
def test_condition_with_column_reference(self):
|
||||
"""Condition can reference another column."""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(
|
||||
condition=Condition(operator=">", value={"col": "budget"}),
|
||||
style=Style(color="red")
|
||||
)
|
||||
]
|
||||
row_data = {"budget": 100, "actual": 150}
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=150, row_data=row_data)
|
||||
|
||||
assert "color: red" in css
|
||||
|
||||
def test_condition_with_col_parameter(self):
|
||||
"""Row-level condition using col parameter."""
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(
|
||||
condition=Condition(operator="==", value="error", col="status"),
|
||||
style=Style(preset="error")
|
||||
)
|
||||
]
|
||||
row_data = {"status": "error", "value": 42}
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=42, row_data=row_data)
|
||||
|
||||
assert css is not None
|
||||
assert "background-color" in css
|
||||
|
||||
|
||||
class TestPresets:
|
||||
def test_style_preset(self):
|
||||
"""Style preset is resolved correctly."""
|
||||
engine = FormattingEngine()
|
||||
rules = [FormatRule(style=Style(preset="success"))]
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=42)
|
||||
|
||||
assert "var(--color-success)" in css
|
||||
|
||||
def test_formatter_preset(self):
|
||||
"""Formatter preset is resolved correctly."""
|
||||
engine = FormattingEngine()
|
||||
rules = [FormatRule(formatter=Formatter(preset="EUR"))]
|
||||
|
||||
_, formatted = engine.apply_format(rules, cell_value=1234.56)
|
||||
|
||||
assert formatted == "1 234,56 €"
|
||||
|
||||
def test_custom_presets(self):
|
||||
"""Custom presets can be injected."""
|
||||
custom_style_presets = {
|
||||
"custom": {"background-color": "purple", "color": "yellow"}
|
||||
}
|
||||
engine = FormattingEngine(style_presets=custom_style_presets)
|
||||
rules = [FormatRule(style=Style(preset="custom"))]
|
||||
|
||||
css, _ = engine.apply_format(rules, cell_value=42)
|
||||
|
||||
assert "background-color: purple" in css
|
||||
assert "color: yellow" in css
|
||||
274
tests/core/formatting/test_formatter_resolver.py
Normal file
274
tests/core/formatting/test_formatter_resolver.py
Normal file
@@ -0,0 +1,274 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.formatting.dataclasses import (
|
||||
Formatter,
|
||||
NumberFormatter,
|
||||
DateFormatter,
|
||||
BooleanFormatter,
|
||||
TextFormatter,
|
||||
EnumFormatter,
|
||||
)
|
||||
from myfasthtml.core.formatting.formatter_resolver import FormatterResolver, FORMAT_ERROR
|
||||
|
||||
|
||||
class TestNumberFormatter:
|
||||
@pytest.mark.parametrize("value,precision,thousands_sep,decimal_sep,prefix,suffix,multiplier,expected", [
|
||||
# Basic formatting
|
||||
(1234.567, 2, "", ".", "", "", 1.0, "1234.57"),
|
||||
(1234.5, 2, "", ".", "", "", 1.0, "1234.50"),
|
||||
(1234, 0, "", ".", "", "", 1.0, "1234"),
|
||||
# With thousands separator
|
||||
(1234567.89, 2, " ", ",", "", "", 1.0, "1 234 567,89"),
|
||||
(1234567, 0, ",", ".", "", "", 1.0, "1,234,567"),
|
||||
# With prefix/suffix
|
||||
(1234.56, 2, " ", ",", "", " €", 1.0, "1 234,56 €"),
|
||||
(1234.56, 2, ",", ".", "$", "", 1.0, "$1,234.56"),
|
||||
# With multiplier (percentage)
|
||||
(0.156, 1, "", ".", "", "%", 100, "15.6%"),
|
||||
(0.5, 0, "", ".", "", "%", 100, "50%"),
|
||||
# Negative numbers
|
||||
(-1234.56, 2, " ", ",", "", " €", 1.0, "-1 234,56 €"),
|
||||
# Zero
|
||||
(0, 2, "", ".", "", "", 1.0, "0.00"),
|
||||
# Small numbers
|
||||
(0.99, 2, "", ".", "", "", 1.0, "0.99"),
|
||||
])
|
||||
def test_number_formatting(self, value, precision, thousands_sep, decimal_sep, prefix, suffix, multiplier, expected):
|
||||
resolver = FormatterResolver()
|
||||
formatter = NumberFormatter(
|
||||
precision=precision,
|
||||
thousands_sep=thousands_sep,
|
||||
decimal_sep=decimal_sep,
|
||||
prefix=prefix,
|
||||
suffix=suffix,
|
||||
multiplier=multiplier,
|
||||
)
|
||||
assert resolver.resolve(formatter, value) == expected
|
||||
|
||||
def test_number_none_value(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = NumberFormatter(precision=2)
|
||||
assert resolver.resolve(formatter, None) == ""
|
||||
|
||||
def test_number_invalid_value_returns_error(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = NumberFormatter(precision=2)
|
||||
assert resolver.resolve(formatter, "not a number") == FORMAT_ERROR
|
||||
|
||||
|
||||
class TestDateFormatter:
|
||||
@pytest.mark.parametrize("value,format,expected", [
|
||||
(datetime(2024, 1, 15), "%Y-%m-%d", "2024-01-15"),
|
||||
(datetime(2024, 1, 15), "%d/%m/%Y", "15/01/2024"),
|
||||
(datetime(2024, 1, 15, 14, 30), "%Y-%m-%d %H:%M", "2024-01-15 14:30"),
|
||||
(datetime(2024, 12, 31), "%d %b %Y", "31 Dec 2024"),
|
||||
])
|
||||
def test_date_formatting(self, value, format, expected):
|
||||
resolver = FormatterResolver()
|
||||
formatter = DateFormatter(format=format)
|
||||
assert resolver.resolve(formatter, value) == expected
|
||||
|
||||
def test_date_from_iso_string(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = DateFormatter(format="%d/%m/%Y")
|
||||
assert resolver.resolve(formatter, "2024-01-15") == "15/01/2024"
|
||||
|
||||
def test_date_none_value(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = DateFormatter()
|
||||
assert resolver.resolve(formatter, None) == ""
|
||||
|
||||
def test_date_invalid_value_returns_error(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = DateFormatter()
|
||||
assert resolver.resolve(formatter, "not a date") == FORMAT_ERROR
|
||||
|
||||
|
||||
class TestBooleanFormatter:
|
||||
@pytest.mark.parametrize("value,true_val,false_val,null_val,expected", [
|
||||
(True, "Yes", "No", "-", "Yes"),
|
||||
(False, "Yes", "No", "-", "No"),
|
||||
(None, "Yes", "No", "-", "-"),
|
||||
(True, "Oui", "Non", "", "Oui"),
|
||||
(False, "Oui", "Non", "", "Non"),
|
||||
# Integer equivalents
|
||||
(1, "Yes", "No", "-", "Yes"),
|
||||
(0, "Yes", "No", "-", "No"),
|
||||
])
|
||||
def test_boolean_formatting(self, value, true_val, false_val, null_val, expected):
|
||||
resolver = FormatterResolver()
|
||||
formatter = BooleanFormatter(
|
||||
true_value=true_val,
|
||||
false_value=false_val,
|
||||
null_value=null_val,
|
||||
)
|
||||
assert resolver.resolve(formatter, value) == expected
|
||||
|
||||
|
||||
class TestTextFormatter:
|
||||
@pytest.mark.parametrize("value,transform,max_length,ellipsis,expected", [
|
||||
# Transform only
|
||||
("hello", "uppercase", None, "...", "HELLO"),
|
||||
("HELLO", "lowercase", None, "...", "hello"),
|
||||
("hello world", "capitalize", None, "...", "Hello world"),
|
||||
("hELLO", "capitalize", None, "...", "Hello"),
|
||||
# Truncation only
|
||||
("hello world", None, 5, "...", "hello..."),
|
||||
("hello world", None, 5, "~", "hello~"),
|
||||
("hi", None, 5, "...", "hi"), # No truncation needed
|
||||
# Transform + truncation
|
||||
("hello world", "uppercase", 5, "...", "HELLO..."),
|
||||
# None value
|
||||
(None, "uppercase", None, "...", ""),
|
||||
])
|
||||
def test_text_formatting(self, value, transform, max_length, ellipsis, expected):
|
||||
resolver = FormatterResolver()
|
||||
formatter = TextFormatter(
|
||||
transform=transform,
|
||||
max_length=max_length,
|
||||
ellipsis=ellipsis,
|
||||
)
|
||||
assert resolver.resolve(formatter, value) == expected
|
||||
|
||||
def test_text_non_string_converted(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = TextFormatter(transform="uppercase")
|
||||
assert resolver.resolve(formatter, 123) == "123"
|
||||
|
||||
|
||||
class TestEnumFormatter:
|
||||
def test_enum_mapping(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = EnumFormatter(
|
||||
source={
|
||||
"type": "mapping",
|
||||
"value": {
|
||||
"draft": "Brouillon",
|
||||
"pending": "En attente",
|
||||
"approved": "Approuv\u00e9",
|
||||
}
|
||||
}
|
||||
)
|
||||
assert resolver.resolve(formatter, "draft") == "Brouillon"
|
||||
assert resolver.resolve(formatter, "pending") == "En attente"
|
||||
assert resolver.resolve(formatter, "approved") == "Approuv\u00e9"
|
||||
|
||||
def test_enum_default_value(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = EnumFormatter(
|
||||
source={
|
||||
"type": "mapping",
|
||||
"value": {"a": "A", "b": "B"}
|
||||
},
|
||||
default="Unknown"
|
||||
)
|
||||
assert resolver.resolve(formatter, "unknown_key") == "Unknown"
|
||||
|
||||
def test_enum_none_value(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = EnumFormatter(
|
||||
source={"type": "mapping", "value": {"a": "A"}},
|
||||
default="N/A"
|
||||
)
|
||||
assert resolver.resolve(formatter, None) == "N/A"
|
||||
|
||||
def test_enum_with_lookup_resolver(self):
|
||||
def mock_lookup(grid_id, value_col, display_col):
|
||||
if grid_id == "categories_grid":
|
||||
return {1: "Electronics", 2: "Books", 3: "Clothing"}
|
||||
return {}
|
||||
|
||||
resolver = FormatterResolver(lookup_resolver=mock_lookup)
|
||||
formatter = EnumFormatter(
|
||||
source={
|
||||
"type": "datagrid",
|
||||
"value": "categories_grid",
|
||||
"value_column": "id",
|
||||
"display_column": "name",
|
||||
},
|
||||
default="Unknown category"
|
||||
)
|
||||
assert resolver.resolve(formatter, 1) == "Electronics"
|
||||
assert resolver.resolve(formatter, 2) == "Books"
|
||||
assert resolver.resolve(formatter, 99) == "Unknown category"
|
||||
|
||||
def test_enum_datagrid_without_resolver(self):
|
||||
resolver = FormatterResolver() # No lookup_resolver
|
||||
formatter = EnumFormatter(
|
||||
source={
|
||||
"type": "datagrid",
|
||||
"value": "some_grid",
|
||||
"value_column": "id",
|
||||
"display_column": "name",
|
||||
}
|
||||
)
|
||||
# Should fall back to string representation
|
||||
assert resolver.resolve(formatter, 1) == "1"
|
||||
|
||||
def test_enum_empty_source(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = EnumFormatter(source={})
|
||||
assert resolver.resolve(formatter, "value") == "value"
|
||||
|
||||
|
||||
class TestPresets:
|
||||
def test_eur_preset(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = Formatter(preset="EUR")
|
||||
assert resolver.resolve(formatter, 1234.56) == "1 234,56 \u20ac"
|
||||
|
||||
def test_usd_preset(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = Formatter(preset="USD")
|
||||
assert resolver.resolve(formatter, 1234.56) == "$1,234.56"
|
||||
|
||||
def test_percentage_preset(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = Formatter(preset="percentage")
|
||||
assert resolver.resolve(formatter, 0.156) == "15.6%"
|
||||
|
||||
def test_short_date_preset(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = Formatter(preset="short_date")
|
||||
assert resolver.resolve(formatter, datetime(2024, 1, 15)) == "15/01/2024"
|
||||
|
||||
def test_iso_date_preset(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = Formatter(preset="iso_date")
|
||||
assert resolver.resolve(formatter, datetime(2024, 1, 15)) == "2024-01-15"
|
||||
|
||||
def test_yes_no_preset(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = Formatter(preset="yes_no")
|
||||
assert resolver.resolve(formatter, True) == "Yes"
|
||||
assert resolver.resolve(formatter, False) == "No"
|
||||
|
||||
def test_unknown_preset_passthrough(self):
|
||||
resolver = FormatterResolver()
|
||||
formatter = Formatter(preset="unknown_preset")
|
||||
assert resolver.resolve(formatter, "value") == "value"
|
||||
|
||||
|
||||
class TestPresetOverride:
|
||||
def test_preset_with_custom_precision(self):
|
||||
"""NumberFormatter with preset can override precision."""
|
||||
resolver = FormatterResolver()
|
||||
formatter = NumberFormatter(preset="EUR", precision=3)
|
||||
# EUR preset has precision=2, but we override to 3
|
||||
assert resolver.resolve(formatter, 1234.5678) == "1 234,568 \u20ac"
|
||||
|
||||
def test_preset_with_custom_suffix(self):
|
||||
"""NumberFormatter with preset can override suffix."""
|
||||
resolver = FormatterResolver()
|
||||
formatter = NumberFormatter(preset="EUR", suffix=" euros")
|
||||
assert resolver.resolve(formatter, 1234.56) == "1 234,56 euros"
|
||||
|
||||
|
||||
class TestNoneFormatter:
|
||||
def test_none_formatter_returns_string(self):
|
||||
resolver = FormatterResolver()
|
||||
assert resolver.resolve(None, "value") == "value"
|
||||
assert resolver.resolve(None, 123) == "123"
|
||||
assert resolver.resolve(None, None) == ""
|
||||
143
tests/core/formatting/test_style_resolver.py
Normal file
143
tests/core/formatting/test_style_resolver.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.formatting.dataclasses import Style
|
||||
from myfasthtml.core.formatting.style_resolver import StyleResolver
|
||||
|
||||
|
||||
class TestResolve:
|
||||
def test_resolve_explicit_properties(self):
|
||||
"""Style with explicit properties only."""
|
||||
resolver = StyleResolver()
|
||||
style = Style(background_color="red", color="white", font_weight="bold")
|
||||
result = resolver.resolve(style)
|
||||
|
||||
assert result["background-color"] == "red"
|
||||
assert result["color"] == "white"
|
||||
assert result["font-weight"] == "bold"
|
||||
|
||||
def test_resolve_preset_with_override(self):
|
||||
"""Preset properties can be overridden by explicit values."""
|
||||
resolver = StyleResolver()
|
||||
# "success" preset has background and color defined
|
||||
style = Style(preset="success", color="black")
|
||||
result = resolver.resolve(style)
|
||||
|
||||
# background comes from preset
|
||||
assert result["background-color"] == "var(--color-success)"
|
||||
# color is overridden
|
||||
assert result["color"] == "black"
|
||||
|
||||
def test_resolve_unknown_preset_ignored(self):
|
||||
"""Unknown preset is ignored, only explicit properties returned."""
|
||||
resolver = StyleResolver()
|
||||
style = Style(preset="unknown_preset", color="blue")
|
||||
result = resolver.resolve(style)
|
||||
|
||||
assert "background-color" not in result
|
||||
assert result["color"] == "blue"
|
||||
|
||||
def test_resolve_custom_presets(self):
|
||||
"""Custom presets can be provided to the resolver."""
|
||||
custom_presets = {
|
||||
"custom": {
|
||||
"background-color": "purple",
|
||||
"color": "yellow",
|
||||
}
|
||||
}
|
||||
resolver = StyleResolver(style_presets=custom_presets)
|
||||
style = Style(preset="custom")
|
||||
result = resolver.resolve(style)
|
||||
|
||||
assert result["background-color"] == "purple"
|
||||
assert result["color"] == "yellow"
|
||||
|
||||
def test_resolve_empty_style(self):
|
||||
"""Empty style returns empty dict."""
|
||||
resolver = StyleResolver()
|
||||
style = Style()
|
||||
result = resolver.resolve(style)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_resolve_none_style(self):
|
||||
"""None style returns empty dict."""
|
||||
resolver = StyleResolver()
|
||||
result = resolver.resolve(None)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_resolve_converts_property_names(self):
|
||||
"""Python attribute names are converted to CSS property names."""
|
||||
resolver = StyleResolver()
|
||||
style = Style(
|
||||
background_color="red",
|
||||
font_weight="bold",
|
||||
font_style="italic",
|
||||
font_size="14px",
|
||||
text_decoration="underline"
|
||||
)
|
||||
result = resolver.resolve(style)
|
||||
|
||||
assert "background-color" in result
|
||||
assert "font-weight" in result
|
||||
assert "font-style" in result
|
||||
assert "font-size" in result
|
||||
assert "text-decoration" in result
|
||||
# Python names should not be in result
|
||||
assert "background_color" not in result
|
||||
assert "font_weight" not in result
|
||||
|
||||
def test_resolve_all_properties(self):
|
||||
"""All style properties are resolved correctly."""
|
||||
resolver = StyleResolver()
|
||||
style = Style(
|
||||
background_color="#ff0000",
|
||||
color="#ffffff",
|
||||
font_weight="bold",
|
||||
font_style="italic",
|
||||
font_size="12px",
|
||||
text_decoration="line-through"
|
||||
)
|
||||
result = resolver.resolve(style)
|
||||
|
||||
assert result["background-color"] == "#ff0000"
|
||||
assert result["color"] == "#ffffff"
|
||||
assert result["font-weight"] == "bold"
|
||||
assert result["font-style"] == "italic"
|
||||
assert result["font-size"] == "12px"
|
||||
assert result["text-decoration"] == "line-through"
|
||||
|
||||
|
||||
class TestToCssString:
|
||||
def test_to_css_string(self):
|
||||
"""Convert resolved style to CSS inline string."""
|
||||
resolver = StyleResolver()
|
||||
style = Style(background_color="red", color="white")
|
||||
result = resolver.to_css_string(style)
|
||||
|
||||
assert "background-color: red" in result
|
||||
assert "color: white" in result
|
||||
assert result.endswith(";")
|
||||
|
||||
def test_to_css_string_empty(self):
|
||||
"""Empty style returns empty string."""
|
||||
resolver = StyleResolver()
|
||||
style = Style()
|
||||
result = resolver.to_css_string(style)
|
||||
|
||||
assert result == ""
|
||||
|
||||
def test_to_css_string_none(self):
|
||||
"""None style returns empty string."""
|
||||
resolver = StyleResolver()
|
||||
result = resolver.to_css_string(None)
|
||||
|
||||
assert result == ""
|
||||
|
||||
def test_to_css_string_format(self):
|
||||
"""CSS string has correct format with semicolons."""
|
||||
resolver = StyleResolver()
|
||||
style = Style(color="blue")
|
||||
result = resolver.to_css_string(style)
|
||||
|
||||
assert result == "color: blue;"
|
||||
Reference in New Issue
Block a user