Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.18 (2026-06-06)

### Tests

- **Expanded `rule_engine` evaluator coverage** (8 → 44 tests). Validating the
`implement-rule-engine` skill found **no bug** — the evaluator is correct — but
the module had only ~8 tests for 361 lines, with many paths unexercised. Added
`tests/rule_engine/test_evaluator_coverage.py` locking in: every leaf operator
(`eq/ne/gt/ge/lt/le/in/not_in/regex`) including None/missing-field safety and a
type-mismatch surfacing, composite `and`/`or`/`not` (+ `not`-arity), `then` vs
`otherwise`, `set`/`increment`/nested-write/unsupported-action isolation, the
loud unknown-operator error, the disabled-rule skip, and `RuleSet` priority
ordering + cross-rule error isolation. No framework behavior changed.

---

## v26.06.17 (2026-06-06)

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.17-brightgreen" alt="Version: 26.06.17"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.18-brightgreen" alt="Version: 26.06.18"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
version = "26.6.17"
version = "26.6.18"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.17"
__version__ = "26.06.18"
219 changes: 219 additions & 0 deletions tests/rule_engine/test_evaluator_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# Copyright 2026 Firefly Software Foundation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Coverage for the rule evaluator's previously-untested paths (v26.06.18).

The module had ~8 tests for 361 lines; an audit confirmed every path correct but
many were unexercised. These tests lock in: each leaf operator (+ None/missing
safety + a type-mismatch surfacing), composite and/or/not (+ not-arity), then vs
otherwise, set/increment/log/nested-write/unsupported-action isolation, the
loud unknown-operator error, the disabled-rule skip, and RuleSet priority
ordering + cross-rule error isolation.
"""

from __future__ import annotations

import pytest

from pyfly.rule_engine import (
Action,
Condition,
EvaluationResult,
Rule,
RuleEvaluator,
RuleSet,
RuleSetEvaluator,
)


def _evaluate(when: Condition, ctx: dict) -> EvaluationResult:
return RuleEvaluator().evaluate(Rule(id="r", when=when), ctx)


class TestLeafOperators:
@pytest.mark.parametrize(
("op", "actual", "expected", "want"),
[
("eq", 5, 5, True),
("eq", 5, 6, False),
("ne", 5, 6, True),
("ne", 5, 5, False),
("gt", 10, 5, True),
("gt", 5, 5, False),
("ge", 5, 5, True),
("ge", 4, 5, False),
("lt", 3, 5, True),
("lt", 5, 5, False),
("le", 5, 5, True),
("le", 6, 5, False),
("in", "gold", ["gold", "silver"], True),
("in", "bronze", ["gold"], False),
("not_in", "bronze", ["gold"], True),
("not_in", "gold", ["gold"], False),
],
)
def test_operator(self, op: str, actual: object, expected: object, want: bool) -> None:
assert _evaluate(Condition(operator=op, field="x", value=expected), {"x": actual}).matched is want

def test_regex(self) -> None:
cond = Condition(operator="regex", field="code", value="^PARTNER")
assert _evaluate(cond, {"code": "PARTNER-7"}).matched is True
assert _evaluate(cond, {"code": "OTHER"}).matched is False

@pytest.mark.parametrize("op", ["gt", "ge", "lt", "le", "in"])
def test_missing_field_is_false_without_error(self, op: str) -> None:
value: object = [1, 2] if op == "in" else 5
res = _evaluate(Condition(operator=op, field="missing", value=value), {})
assert res.matched is False
assert res.error is None # None-guarded — no crash

def test_type_mismatch_is_surfaced_not_silent(self) -> None:
# str vs int comparison raises TypeError; it must be captured into the
# result (loud), not silently treated as a match or swallowed.
res = _evaluate(Condition(operator="gt", field="name", value=5), {"name": "abc"})
assert res.matched is False
assert res.error is not None


class TestCompositeConditions:
def test_and(self) -> None:
cond = Condition(
operator="and",
children=[
Condition(operator="gt", field="x", value=5),
Condition(operator="lt", field="x", value=100),
],
)
assert _evaluate(cond, {"x": 50}).matched is True
assert _evaluate(cond, {"x": 200}).matched is False

def test_or(self) -> None:
cond = Condition(
operator="or",
children=[
Condition(operator="eq", field="tier", value="gold"),
Condition(operator="gt", field="spend", value=1000),
],
)
assert _evaluate(cond, {"tier": "silver", "spend": 2000}).matched is True
assert _evaluate(cond, {"tier": "silver", "spend": 10}).matched is False

def test_not(self) -> None:
cond = Condition(operator="not", children=[Condition(operator="eq", field="blocked", value=True)])
assert _evaluate(cond, {"blocked": False}).matched is True
assert _evaluate(cond, {"blocked": True}).matched is False

def test_not_with_wrong_arity_is_surfaced(self) -> None:
cond = Condition(
operator="not",
children=[
Condition(operator="eq", field="a", value=1),
Condition(operator="eq", field="b", value=2),
],
)
res = _evaluate(cond, {})
assert res.matched is False
assert "exactly one child" in (res.error or "")


class TestThenOtherwise:
def test_then_runs_on_match(self) -> None:
rule = Rule(
id="r",
when=Condition(operator="gt", field="x", value=5),
then=[Action(type="set", target="hit", value="then")],
otherwise=[Action(type="set", target="hit", value="else")],
)
ctx: dict = {"x": 10}
res = RuleEvaluator().evaluate(rule, ctx)
assert res.matched is True
assert ctx["hit"] == "then"

def test_otherwise_runs_on_non_match(self) -> None:
rule = Rule(
id="r",
when=Condition(operator="gt", field="x", value=5),
then=[Action(type="set", target="hit", value="then")],
otherwise=[Action(type="set", target="hit", value="else")],
)
ctx: dict = {"x": 1}
res = RuleEvaluator().evaluate(rule, ctx)
assert res.matched is False
assert ctx["hit"] == "else"

def test_disabled_rule_is_skipped(self) -> None:
rule = Rule(id="r", when=Condition(operator="eq", field="x", value=1), enabled=False)
res = RuleEvaluator().evaluate(rule, {"x": 1})
assert res.matched is False


class TestActions:
def test_increment_defaults_to_one(self) -> None:
rule = Rule(id="r", then=[Action(type="increment", target="count")])
ctx: dict = {}
RuleEvaluator().evaluate(rule, ctx)
assert ctx["count"] == 1

def test_nested_write(self) -> None:
rule = Rule(id="r", then=[Action(type="set", target="flags.discount_pct", value=10)])
ctx: dict = {}
RuleEvaluator().evaluate(rule, ctx)
assert ctx["flags"]["discount_pct"] == 10

def test_unsupported_action_is_isolated(self) -> None:
# An unsupported 'call' action raises NotImplementedError; it is recorded
# in the result error while the sibling 'set' still runs.
rule = Rule(
id="r",
then=[
Action(type="call", target="svc"),
Action(type="set", target="ok", value=1),
],
)
ctx: dict = {}
res = RuleEvaluator().evaluate(rule, ctx)
assert ctx["ok"] == 1 # sibling executed despite the failing action
assert res.error is not None and "call" in res.error
assert [a.type for a in res.actions_executed] == ["set"]

def test_unknown_operator_is_surfaced(self) -> None:
res = _evaluate(Condition(operator="between", field="x", value=[1, 5]), {"x": 3})
assert res.matched is False
assert "unknown operator: between" in (res.error or "")


class TestRuleSet:
def test_priority_ordering_over_shared_context(self) -> None:
# Inserted low-then-high, but evaluated high-then-low (sorted by -priority).
# Both write the same path over the shared context, so the LAST writer (low)
# wins — proving high ran first.
low = Rule(id="low", priority=1, then=[Action(type="set", target="winner", value="low")])
high = Rule(id="high", priority=10, then=[Action(type="set", target="winner", value="high")])
ruleset = RuleSet(id="rs", rules=[low, high])
ctx: dict = {}

results = RuleSetEvaluator().evaluate(ruleset, ctx)

assert [r.rule_id for r in results] == ["high", "low"] # priority order
assert ctx["winner"] == "low" # high ran first, low overwrote

def test_one_failing_rule_does_not_abort_the_set(self) -> None:
bad = Rule(id="bad", priority=10, then=[Action(type="call")]) # unsupported -> error
good = Rule(id="good", priority=1, then=[Action(type="set", target="ran", value=True)])
ruleset = RuleSet(id="rs", rules=[bad, good])
ctx: dict = {}

results = RuleSetEvaluator().evaluate(ruleset, ctx)

assert ctx["ran"] is True # good ran despite bad's error
assert any(r.error for r in results)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading