Compare commits

...

6 Commits

Author SHA1 Message Date
Jonathon Broughton 168a1f517a Robust rule validation and UX (#36)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
* Add minimum severity and hide skipped options

- Introduced `minimum_severity` to filter reported results.
- Added `hide_skipped` option to control visibility of skipped tests.
- Updated rule application logic to respect new parameters.

* Improve rule processing and validation

- Enhanced rule reading to group rules and handle validation messages.
- Added functions to process and validate rule numbers in the spreadsheet.
- Improved error handling when reading TSV files.

* Improve docstrings and clean up code

- Added docstring for `PropertyMatchMode` enum.
- Removed unnecessary blank lines in `FunctionInputs`.
- Commented out unused `property_match_mode` field with explanation.

* Improve rule validation logic

Added a new function to validate the structure of rule groups. Key updates include:
- Checks for required columns and conditions.
- Ensures rules start with "WHERE" and have at most one "CHECK".
- Validates that "CHECK" is the last condition if present.
- Raises descriptive errors for invalid structures.

* Refactor rule processing logic

- Added a new function to separate filters and final checks.
- Simplified condition evaluation by using the new function.
- Improved handling of empty rule groups and speckle objects.
- Enhanced clarity in filtering logic for better maintainability.

* Refactor number conversion logic in comparison

- Updated boolean comparison method call for clarity.
- Introduced a helper function to safely convert strings to numbers, handling whitespace and negative values.
- Simplified the type-checking process for string-to-float conversion.

* Update comparison logic in parameter checks

Flipped the comparison logic to match user expectations for parameter value checks. Updated docstrings for better clarity on UX perspective. Added error handling for non-numeric values to improve robustness.

* Refactor method names for clarity

Updated method names to remove leading underscores for better readability and consistency. Added a docstring to describe the purpose of the rules module.

* Add tests for comparison and rule processing

- Introduced a new test suite for value comparisons, covering numeric strings, boolean strings, case sensitivity in string comparisons, and float comparison with tolerance.
- Created a test suite for parameter handling functionality to validate the existence and retrieval of parameters in v2 and v3 objects.
- Added tests for rule processing functionality using both explicit CHECK format and legacy format rules.
2025-02-18 18:56:02 +00:00
Jonathon Broughton e49bf225ec Update main.yml (#35) 2025-02-18 09:23:57 +00:00
Jonathon Broughton f3987fced9 Update main.yml (#34) 2025-02-18 08:21:30 +00:00
Jonathon Broughton 1ae3372f42 Update main.yml (#33)
* Update main.yml

* Update Dockerfile
2025-02-18 08:07:09 +00:00
Jonathon Broughton b071380a4f Update main.yml (#32) 2025-02-18 00:07:30 +00:00
Jonathon Broughton 460b21772a Update Dockerfile (#31)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2025-02-17 23:57:56 +00:00
11 changed files with 842 additions and 478 deletions
+3 -1
View File
@@ -31,7 +31,9 @@ jobs:
- name: Extract functionInputSchema
id: extract_schema
run: |
python main.py generate_schema "${{ env.FUNCTION_SCHEMA_FILE_NAME }}"
python main.py generate_schema "${HOME}/${{ env.FUNCTION_SCHEMA_FILE_NAME }}"
echo "Checking if functionSchema.json exists after generation..."
ls -lah "${HOME}/${{ env.FUNCTION_SCHEMA_FILE_NAME }}"
# Step 5: Build and publish the Speckle function
- name: Speckle Automate Function - Build and Publish
+10 -11
View File
@@ -1,16 +1,15 @@
# We use the official Python 3.11 image as our base image and will add our code to it. For more details, see https://hub.docker.com/_/python
FROM python:3.13-slim
# Use the official Python 3.11 slim image as the base
FROM python:3.11-slim
# We install poetry to generate a list of dependencies which will be required by our application
RUN pip install poetry==1.8.4
# We set the working directory to be the /home/speckle directory; all of our files will be copied here.
# Set the working directory inside the container
WORKDIR /home/speckle
# Copy all of our code and assets from the local directory into the /home/speckle directory of the container.
# We also ensure that the user 'speckle' owns these files, so it can access them
# This assumes that the Dockerfile is in the same directory as the rest of the code
# Copy the application files to the working directory
COPY . /home/speckle
# Using poetry, we generate a list of requirements, save them to requirements.txt, and then use pip to install them
RUN poetry export --format requirements.txt --output /home/speckle/requirements.txt && pip install --requirement /home/speckle/requirements.txt
# Upgrade pip and install dependencies using requirements.txt
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r /home/speckle/requirements.txt
# Set the entrypoint for running the Speckle function
CMD ["python", "-u", "main.py", "run"]
+21 -11
View File
@@ -1,12 +1,13 @@
# This is the main function that will be executed when the automation is triggered.
# It will receive the inputs from the user, and the context of the run.
# It will then apply the rules to the objects in the model, and report back the results.
"""This is the main function that will be executed when the automation is triggered.
It will receive the inputs from the user, and the context of the run.
It will then apply the rules to the objects in the model, and report back the results.
"""
from pandas import DataFrame
from speckle_automate import AutomationContext
from specklepy.objects.base import Base
from src.helpers import flatten_base
from src.helpers import flatten_base, speckle_print
from src.inputs import FunctionInputs
from src.rule_processor import apply_rules_to_objects
from src.spreadsheet import read_rules_from_spreadsheet
@@ -38,16 +39,25 @@ def automate_function(
global VERSION
VERSION = getattr(version_root_object, "version", 2) # noqa: F841SION = getattr(version_root_object,"version", 2) # noqa: F841 # noqa: F841
# read the rules from the spreadsheet
rules: DataFrame = read_rules_from_spreadsheet(function_inputs.spreadsheet_url)
# Read and group rules
grouped_rules, messages = read_rules_from_spreadsheet(function_inputs.spreadsheet_url)
if (rules is None) or (len(rules) == 0):
automate_context.mark_run_exception("No rules defined")
# Handle any validation messages
for message in messages:
speckle_print(message) # or log them appropriately
grouped_rules = rules.groupby("Rule Number")
if grouped_rules is None:
automate_context.mark_run_exception("Failed to process rules")
return
# apply the rules to the objects
apply_rules_to_objects(flat_list_of_objects, grouped_rules, automate_context)
apply_rules_to_objects(
flat_list_of_objects,
grouped_rules,
automate_context,
minimum_severity=function_inputs.minimum_severity,
hide_skipped=function_inputs.hide_skipped,
)
# set the automation context view, to the original model / VERSION view
automate_context.set_context_view()
+33 -16
View File
@@ -5,9 +5,20 @@ from speckle_automate import AutomateBase
class PropertyMatchMode(Enum):
STRICT = "strict" # Exact parameter path match
FUZZY = "fuzzy" # Search all parameters ignoring hierarchy
MIXED = "mixed" # Exact match first, fuzzy fallback
"""Controls how strictly parameter names must match."""
STRICT = "strict" # Exact parameter path match
FUZZY = "fuzzy" # Search all parameters ignoring hierarchy
MIXED = "mixed" # Exact match first, fuzzy fallback
class MinimumSeverity(str, Enum):
"""Enum for minimum severity level to report."""
INFO = "Info"
WARNING = "Warning"
ERROR = "Error"
class FunctionInputs(AutomateBase):
"""These are function author defined values.
@@ -17,23 +28,29 @@ class FunctionInputs(AutomateBase):
https://docs.pydantic.dev/latest/usage/models/
"""
# In this exercise, we will move rules to an external source so not to hardcode them.
spreadsheet_url: str = Field(
title="Spreadsheet URL",
description="This is the URL of the spreadsheet to check. It should be a TSV format data source.",
)
property_match_mode: PropertyMatchMode = Field(
default=PropertyMatchMode.MIXED,
title="Property Match Mode",
description='Controls how strictly parameter names must match. ' +
'STRICT will only match exact parameter paths, ' +
'FUZZY will search all parameters ignoring hierarchy, ' +
'MIXED will exact match first, fuzzy fallback.'
minimum_severity: MinimumSeverity = Field(
default=MinimumSeverity.INFO,
title="Minimum Severity Level",
description="Only report test results with this severity level or higher. Info will show all results, Warning will show warnings and errors, Error will show only errors.",
)
hide_skipped: bool = Field(
default=False,
title="Hide Skipped Tests",
description="If enabled, tests that were skipped (no matching objects found) will not be reported.",
)
# property_match_mode: PropertyMatchMode = Field(
# default=PropertyMatchMode.MIXED,
# title="Property Match Mode",
# description='Controls how strictly parameter names must match. ' +
# 'STRICT will only match exact parameter paths, ' +
# 'FUZZY will search all parameters ignoring hierarchy, ' +
# 'MIXED will exact match first, fuzzy fallback.'
# )
+138 -32
View File
@@ -1,3 +1,5 @@
"""Module for processing rules against Speckle objects and updating the automate context with the results."""
from enum import Enum
from typing import Any
@@ -6,11 +8,52 @@ from pandas.core.groupby import DataFrameGroupBy
from speckle_automate import AutomationContext, ObjectResultLevel
from specklepy.objects.base import Base
from inputs import MinimumSeverity
from src.helpers import speckle_print
from src.predicates import PREDICATE_METHOD_MAP
from src.rules import PropertyRules
def validate_rule_structure(rule_group: pd.DataFrame) -> None:
"""Validates the structure and logic of a rule group.
Args:
rule_group: DataFrame containing the rule conditions
Raises:
ValueError: If rule structure is invalid
"""
if rule_group.empty:
return
# Validate Logic column exists
if "Logic" not in rule_group.columns:
raise ValueError("Rule must have a 'Logic' column")
# Get uppercase Logic values for case-insensitive comparison
logic_values = rule_group["Logic"].str.upper()
# Check if first condition is WHERE
if logic_values.iloc[0] != "WHERE":
raise ValueError(f"Rule {rule_group.iloc[0]['Rule Number']} must start with WHERE")
# Count CHECK conditions
check_count = sum(1 for value in logic_values if value == "CHECK")
if check_count > 1:
raise ValueError(f"Rule {rule_group.iloc[0]['Rule Number']} has multiple CHECK conditions")
# If CHECK exists, ensure it's the last condition
check_indices = logic_values[logic_values == "CHECK"].index
if check_count == 1 and check_indices[0] != rule_group.index[-1]:
raise ValueError(f"CHECK must be the last condition in rule {rule_group.iloc[0]['Rule Number']}")
# Validate Logic values
valid_values = {"WHERE", "AND", "CHECK"}
invalid_values = set(logic_values.unique()) - valid_values
if invalid_values:
raise ValueError(f"Invalid Logic values found: {invalid_values}")
def evaluate_condition(
speckle_object: Base, condition: pd.Series, rule_number: str | None = None, case_number: int | None = None
) -> bool:
@@ -42,12 +85,52 @@ def evaluate_condition(
method = getattr(PropertyRules, method_name, None)
if method:
check_answer = method(speckle_object, property_name, value)
return method(speckle_object, property_name, value)
return check_answer
return False
def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]:
"""Separates rule conditions into filters and final check.
Args:
rule_group: DataFrame containing rule conditions
Returns:
Tuple containing filter conditions and final check condition
"""
if rule_group.empty:
return pd.DataFrame(), pd.Series()
# Get uppercase Logic values for case-insensitive comparison
logic_values = rule_group["Logic"].str.upper()
# Look for explicit CHECK
check_conditions = rule_group[logic_values == "CHECK"]
has_explicit_check = not check_conditions.empty
if has_explicit_check:
# Use first CHECK condition as final check
final_check = check_conditions.iloc[0]
# All other conditions are filters
filters = rule_group[logic_values != "CHECK"]
else:
# Legacy behavior: use last AND as check if present
and_conditions = rule_group[logic_values == "AND"]
if not and_conditions.empty:
# Get the last AND as the check
final_check = and_conditions.iloc[-1]
# All conditions up to the last AND are filters
last_and_idx = and_conditions.index[-1]
filters = rule_group[rule_group.index < last_and_idx]
else:
# No AND conditions found, just use WHERE as filter
filters = rule_group
final_check = rule_group.iloc[0] # Default to first condition as check
return filters, final_check
def process_rule(
speckle_objects: list[Base], rule_group: pd.DataFrame
) -> tuple[list[Any], list[Any]] | tuple[list[Base], list[Base]]:
@@ -62,36 +145,47 @@ def process_rule(
Returns:
A tuple of lists containing objects that passed and failed the rule.
"""
# Extract the 'WHERE' condition and subsequent 'AND' conditions
filter_condition = rule_group.iloc[0]
subsequent_conditions = rule_group.iloc[1:]
# get the last row of the rule_group and get the Message and Report Severity
rule_info = rule_group.iloc[-1]
rule_number = rule_info["Rule Number"]
# Filter objects based on the 'WHERE' condition
filtered_objects = [
speckle_object for speckle_object in speckle_objects if evaluate_condition(speckle_object, filter_condition)
]
if not filtered_objects or len(list(filtered_objects)) == 0:
if not speckle_objects or rule_group.empty:
return [], []
# Initialize lists for passed and failed objects
pass_objects, fail_objects = [], []
try:
validate_rule_structure(rule_group)
except ValueError as e:
speckle_print(f"Rule validation error: {str(e)}")
return [], []
# Evaluate each filtered object against the 'AND' conditions
for speckle_object in filtered_objects:
if all(
evaluate_condition(
speckle_object=speckle_object, condition=condition, rule_number=rule_number, case_number=index
# Get filters and final check
filters, final_check = get_filters_and_check(rule_group)
# Start with all objects
filtered_objects = speckle_objects.copy()
rule_number = rule_group.iloc[0]["Rule Number"]
# Apply each filter condition sequentially
for index, (_, filter_condition) in enumerate(filters.iterrows()):
filtered_objects = [
obj
for obj in filtered_objects
if evaluate_condition(
speckle_object=obj, condition=filter_condition, rule_number=rule_number, case_number=index
)
for index, condition in subsequent_conditions.iterrows()
]
# Early exit if no objects pass filters
if not filtered_objects:
return [], []
# For remaining objects, evaluate the final check
pass_objects = []
fail_objects = []
for obj in filtered_objects:
if evaluate_condition(
speckle_object=obj, condition=final_check, rule_number=rule_number, case_number=len(filters)
):
pass_objects.append(speckle_object)
pass_objects.append(obj)
else:
fail_objects.append(speckle_object)
fail_objects.append(obj)
return pass_objects, fail_objects
@@ -100,6 +194,8 @@ def apply_rules_to_objects(
speckle_objects: list[Base],
grouped_rules: DataFrameGroupBy,
automate_context: AutomationContext,
minimum_severity: MinimumSeverity = MinimumSeverity.INFO,
hide_skipped: bool = False,
) -> dict[str, tuple[list[Base], list[Base]]]:
"""Applies defined rules to a list of objects and updates the automate context based on the results.
@@ -107,14 +203,16 @@ def apply_rules_to_objects(
speckle_objects (List[Base]): The list of objects to which rules are applied.
grouped_rules (pd.DataFrameGroupBy): The DataFrame containing rule definitions.
automate_context (Any): Context manager for attaching rule results.
minimum_severity: Minimum severity level to report
hide_skipped: Whether to hide skipped tests
"""
grouped_results = {}
rules_processed = 0
severity_levels = {MinimumSeverity.INFO: 0, MinimumSeverity.WARNING: 1, MinimumSeverity.ERROR: 2}
min_severity_level = severity_levels[minimum_severity]
for rule_id, rule_group in grouped_rules:
rule_id_str = str(rule_id) # Convert rule_id to string
rules_processed += 1
# Ensure rule_group has necessary columns
@@ -123,17 +221,25 @@ def apply_rules_to_objects(
pass_objects, fail_objects = process_rule(speckle_objects, rule_group)
attach_results(pass_objects, rule_group.iloc[-1], rule_id_str, automate_context, True)
attach_results(fail_objects, rule_group.iloc[-1], rule_id_str, automate_context, False)
# Get the severity level for this rule
rule_severity = get_severity(rule_group.iloc[-1])
rule_severity_level = severity_levels[MinimumSeverity(rule_severity.value)]
if len(pass_objects) == 0 and len(fail_objects) == 0:
# For passing objects, only attach if we're showing all levels (INFO)
if minimum_severity == MinimumSeverity.INFO:
attach_results(pass_objects, rule_group.iloc[-1], rule_id_str, automate_context, True)
# For failing objects, attach if they meet minimum severity threshold
if rule_severity_level >= min_severity_level:
attach_results(fail_objects, rule_group.iloc[-1], rule_id_str, automate_context, False)
if len(pass_objects) == 0 and len(fail_objects) == 0 and not hide_skipped:
automate_context.attach_info_to_objects(
category=f"Rule {rule_id_str} Skipped",
object_ids=["0"], # This is a hack to get a rule to report with no valid objects
message=f"No objects found for rule {rule_id_str}",
metadata={},
)
# pass
grouped_results[rule_id_str] = (pass_objects, fail_objects)
+37 -18
View File
@@ -1,3 +1,5 @@
"""A collection of rules for processing Speckle objects and their properties."""
import math
import re
from typing import Any
@@ -243,27 +245,39 @@ class PropertyRules:
@staticmethod
def is_parameter_value_greater_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool:
"""Checks if parameter value is greater than threshold."""
"""Checks if parameter value is greater than threshold.
Note: From a UX perspective, if someone writes 'height greater than 2401',
they mean "flag an error if height <= 2401". So we flip the comparison.
"""
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
if parameter_value is None:
return False
if not isinstance(parameter_value, int | float):
raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
return parameter_value > PropertyRules.parse_number_from_string(threshold)
return parameter_value <= PropertyRules.parse_number_from_string(threshold)
@staticmethod
def is_parameter_value_less_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool:
"""Checks if parameter value is less than threshold."""
"""Checks if parameter value is less than threshold.
Note: From a UX perspective, if someone writes 'height less than 2401',
they mean "flag an error if height >= 2401". So we flip the comparison.
"""
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
if parameter_value is None:
return False
if not isinstance(parameter_value, int | float):
raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
return parameter_value < PropertyRules.parse_number_from_string(threshold)
return parameter_value >= PropertyRules.parse_number_from_string(threshold)
@staticmethod
def is_parameter_value_in_range(speckle_object: Base, parameter_name: str, value_range: str) -> bool:
"""Checks if parameter value falls within range."""
"""Checks if parameter value falls outside specified range.
Note: From a UX perspective, if someone writes 'height in range 2401,3000',
they mean "flag an error if height < 2401 or height > 3000".
"""
min_value, max_value = value_range.split(",")
min_value = PropertyRules.parse_number_from_string(min_value)
max_value = PropertyRules.parse_number_from_string(max_value)
@@ -311,7 +325,7 @@ class PropertyRules:
return is_value_in_list(parameter_value, value_list)
@staticmethod
def _check_boolean_value(value: Any, values_to_match: tuple[str, ...]) -> bool:
def check_boolean_value(value: Any, values_to_match: tuple[str, ...]) -> bool:
"""Check if a value matches any target value in expected format."""
if isinstance(value, bool):
return value is (True if "true" in values_to_match else False)
@@ -325,13 +339,13 @@ class PropertyRules:
def is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool:
"""Check if parameter value represents true."""
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
return PropertyRules._check_boolean_value(parameter_value, ("yes", "true", "1"))
return PropertyRules.check_boolean_value(parameter_value, ("yes", "true", "1"))
@staticmethod
def is_parameter_value_false(speckle_object: Base, parameter_name: str) -> bool:
"""Check if parameter value represents false."""
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
return PropertyRules._check_boolean_value(parameter_value, ("no", "false", "0"))
return PropertyRules.check_boolean_value(parameter_value, ("no", "false", "0"))
@staticmethod
def has_category(speckle_object: Base) -> bool:
@@ -350,7 +364,7 @@ class PropertyRules:
return PropertyRules.get_parameter_value(speckle_object, "category")
@staticmethod
def _try_boolean_comparison(value1: Any, value2: Any, allow_yes_no: bool) -> tuple[bool, bool]:
def try_boolean_comparison(value1: Any, value2: Any, allow_yes_no: bool) -> tuple[bool, bool]:
"""Attempts to compare two values as booleans."""
def strict_convert_boolean(value: Any) -> Any:
@@ -380,7 +394,7 @@ class PropertyRules:
return False, False
@staticmethod
def _compare_values(
def compare_values(
value1: Any,
value2: Any,
case_sensitive: bool = False,
@@ -401,15 +415,20 @@ class PropertyRules:
bool: True if values are considered equal, False otherwise
"""
# Try boolean comparison first
can_compare, result = PropertyRules._try_boolean_comparison(value1, value2, allow_yes_no_bools)
can_compare, result = PropertyRules.try_boolean_comparison(value1, value2, allow_yes_no_bools)
if can_compare:
return result
# Handle case where one value is a string that can be interpreted as a number
if isinstance(value1, str) and value1.replace(".", "", 1).isdigit():
value1 = float(value1)
if isinstance(value2, str) and value2.replace(".", "", 1).isdigit():
value2 = float(value2)
def safe_convert_to_number(val):
if isinstance(val, str):
val = val.strip() # Remove whitespace
if val.replace(".", "", 1).replace("-", "", 1).isdigit(): # Handle negative numbers
return float(val)
return val
value1 = safe_convert_to_number(value1)
value2 = safe_convert_to_number(value2)
# For strings: Allow case insensitivity if specified
if isinstance(value1, str) and isinstance(value2, str):
@@ -450,7 +469,7 @@ class PropertyRules:
if parameter_value is None:
return False
return PropertyRules._compare_values(
return PropertyRules.compare_values(
parameter_value, value_to_match, case_sensitive, tolerance, allow_yes_no_bools=True
)
@@ -478,7 +497,7 @@ class PropertyRules:
if parameter_value is None:
return True # Non-existent parameters are considered not equal
return not PropertyRules._compare_values(
return not PropertyRules.compare_values(
parameter_value, value_to_match, case_sensitive, tolerance, allow_yes_no_bools=True
)
@@ -500,6 +519,6 @@ class PropertyRules:
if parameter_value is None:
return False
return PropertyRules._compare_values(
return PropertyRules.compare_values(
parameter_value, value_to_match, case_sensitive=True, tolerance=0, allow_yes_no_bools=False, use_exact=True
)
+119 -14
View File
@@ -1,38 +1,143 @@
"""Module for reading and processing rules from a cloud hosted TSV file."""
import pandas as pd
from pandas import DataFrame
from pandas.core.groupby import DataFrameGroupBy
def read_rules_from_spreadsheet(url: str) -> DataFrame | None:
"""Reads a TSV file from a provided URL and returns a DataFrame.
def process_rule_numbers(df: DataFrame) -> DataFrame:
"""Process rule numbers in a DataFrame.
1. Finding rule groups based on 'WHERE' logic
2. Using the first non-null rule number in each group
3. Handling duplicate rule numbers by incrementing
Args:
url (str): The URL to the TSV file.
df: DataFrame with columns including 'Rule Number' and 'Logic'
Returns:
DataFrame: Pandas DataFrame containing the TSV data.
DataFrame with processed rule numbers
"""
# Create a copy to avoid modifying original
df = df.copy()
# Initialize tracking variables
current_rule_num = None
used_rule_nums = set()
processed_rule_nums = []
# Find indices where Logic is 'WHERE' to identify rule group starts
where_indices = df[df["Logic"].str.upper() == "WHERE"].index
# Process each group
for i in range(len(where_indices)):
start_idx = where_indices[i]
end_idx = where_indices[i + 1] if i + 1 < len(where_indices) else len(df)
# Get slice of rows for this group
group_slice = df.iloc[start_idx:end_idx]
# Try to get rule number from first row
group_rule_num = group_slice["Rule Number"].iloc[0]
if pd.isna(group_rule_num):
# If no rule number, generate next available
if current_rule_num is None:
current_rule_num = 1
else:
current_rule_num += 1
group_rule_num = current_rule_num
else:
# Convert to int if it's a float or string
group_rule_num = int(group_rule_num)
# Handle duplicate rule numbers
while group_rule_num in used_rule_nums:
group_rule_num += 1
# Update tracking
current_rule_num = group_rule_num
used_rule_nums.add(group_rule_num)
# Fill rule numbers for this group
processed_rule_nums.extend([group_rule_num] * len(group_slice))
# Update DataFrame with processed rule numbers
df["Rule Number"] = processed_rule_nums
return df
def validate_rule_numbers(df: DataFrame) -> list[str]:
"""Validate rule numbers and return any warnings or errors.
Args:
df: DataFrame with processed rule numbers
Returns:
List of warning/error messages
"""
messages = []
# Check for missing rule numbers
if df["Rule Number"].isna().any():
messages.append("Warning: Some rules are missing rule numbers")
# Check for non-integer rule numbers
non_int_mask = df["Rule Number"].apply(lambda x: not pd.isna(x) and not float(x).is_integer())
if non_int_mask.any():
messages.append("Warning: Some rule numbers are not integers")
# Check for duplicate rule numbers in WHERE rows
where_rules = df[df["Logic"].str.upper() == "WHERE"]["Rule Number"]
duplicates = where_rules[where_rules.duplicated()]
if not duplicates.empty:
messages.append(f"Warning: Duplicate rule numbers found: {list(duplicates)}")
return messages
def read_rules_from_spreadsheet(url: str) -> tuple[DataFrameGroupBy, list[str]] | tuple[None, list[str]]:
"""Reads a TSV file from a provided URL, processes rule numbers, and returns grouped rules.
Args:
url (str): The URL to the TSV file
Returns:
Tuple containing:
- DataFrameGroupBy object with rules grouped by rule number (or None if error)
- List of validation messages/warnings
"""
try:
# Since the output is a TSV, we use `pd.read_csv` with `sep='\t'` to specify tab-separated values.
# Read the TSV file
df = pd.read_csv(url, sep="\t")
# Convert mixed type columns
df = convert_mixed_columns(df)
# Convert columns to appropriate types based on their content.
return df
# Process rule numbers
df = process_rule_numbers(df)
# Get validation messages
messages = validate_rule_numbers(df)
# Group by rule number
grouped_rules = df.groupby("Rule Number")
return grouped_rules, messages
except Exception as e:
print(f"Failed to read the TSV from the URL: {e}")
return None
return None, [f"Failed to read the TSV from the URL: {str(e)}"]
def convert_mixed_columns(df):
def convert_mixed_columns(df: DataFrame) -> DataFrame:
"""Converts columns in a DataFrame to appropriate types based on their content.
Args:
df (DataFrame): The DataFrame whose columns are to be converted.
df (DataFrame): The DataFrame whose columns are to be converted
Returns:
DataFrame: The DataFrame with columns converted to appropriate types.
DataFrame with columns converted to appropriate types
"""
df = df.apply(lambda c: c.astype(object) if any(str(x).replace(".", "", 1).isdigit() for x in c) else c.astype(str))
# df = df.apply(lambda c: c.astype(object) if any(str(x).replace(".", "", 1).isdigit() for x in c) else c.astype(str))
return df
+86
View File
@@ -0,0 +1,86 @@
from typing import Any
import pytest
from src.rules import PropertyRules
class TestValueComparison:
"""Test suite for value comparison functionality."""
@pytest.mark.parametrize(
"value1, value2",
[
# Basic numeric strings
("1400", 1400.0),
("1400.0", 1400),
("1400.00", 1400),
# Whitespace handling
(" 1400 ", 1400.0),
(" 1400 ", 1400.0),
("\t1400\n", 1400.0),
# Negative numbers
("-1400", -1400.0),
(" -1400 ", -1400.0),
("-1400.0", -1400),
# Zero handling
("0", 0.0),
("-0", 0.0),
("0.0", 0),
# Simple integers
("1", 1),
("1.0", 1),
],
)
def test_numeric_string_comparison(self, value1: Any, value2: Any):
"""Test comparison of numeric strings with numbers."""
assert PropertyRules.compare_values(value1, value2)
# Test reverse comparison
assert PropertyRules.compare_values(value2, value1)
@pytest.mark.parametrize(
"value1, value2, expected",
[
("Yes", True, True),
("No", False, True),
("yes", True, True),
("no", False, True),
("YES", True, True),
("NO", False, True),
("true", True, True),
("false", False, True),
("True", True, True),
("False", False, True),
],
)
def test_boolean_string_comparison(self, value1: str, value2: bool, expected: bool):
"""Test comparison of boolean strings with booleans."""
assert PropertyRules.compare_values(value1, value2) == expected
# Test reverse comparison
assert PropertyRules.compare_values(value2, value1) == expected
@pytest.mark.parametrize(
"value1, value2, case_sensitive, expected",
[
("hello", "HELLO", False, True),
("hello", "HELLO", True, False),
("Hello", "hello", False, True),
("Hello", "Hello", True, True),
],
)
def test_string_comparison(self, value1: str, value2: str, case_sensitive: bool, expected: bool):
"""Test string comparison with case sensitivity options."""
assert PropertyRules.compare_values(value1, value2, case_sensitive=case_sensitive) == expected
@pytest.mark.parametrize(
"value1, value2, tolerance, expected",
[
(1.0001, 1.0, 1e-3, True),
(1.0001, 1.0, 1e-6, False),
(1.00000001, 1.0, 1e-6, True),
(-1.0001, -1.0, 1e-3, True),
],
)
def test_float_comparison_tolerance(self, value1: float, value2: float, tolerance: float, expected: bool):
"""Test float comparison with different tolerance levels."""
assert PropertyRules.compare_values(value1, value2, tolerance=tolerance) == expected
+21 -21
View File
@@ -8,34 +8,34 @@ from speckle_automate import (
)
from speckle_automate.fixtures import * # noqa: F401, F403
from inputs import MinimumSeverity
from src.function import automate_function
from src.helpers import speckle_print
from src.inputs import FunctionInputs
def test_function_run(test_automation_run_data: AutomationRunData, test_automation_token: str):
"""Run an integration test for the automate function.
class TestFunction:
"""Test suite for the automate function."""
Args:
test_automation_run_data (AutomationRunData): The automation run data provided by sdk.
test_automation_token (str): The automation token.
def test_function_run(self, test_automation_run_data: AutomationRunData, test_automation_token: str):
"""Run an integration test for the automate function.
"""
speckle_print(str(test_automation_run_data))
speckle_print(str(test_automation_token))
Args:
test_automation_run_data (AutomationRunData): The automation run data provided by sdk.
test_automation_token (str): The automation token.
"""Run an integration test for the automate function."""
automation_context = AutomationContext.initialize(
test_automation_run_data, test_automation_token
)
default_url: str = (
"https://drive.google.com/uc?export=download&id=1hiPSw23eOaqd27QD_YsXvZg9PWm7_XBx"
)
"""
speckle_print(str(test_automation_run_data))
speckle_print(str(test_automation_token))
automate_sdk = run_function(
automation_context,
automate_function,
FunctionInputs(spreadsheet_url=default_url),
)
"""Run an integration test for the automate function."""
automation_context = AutomationContext.initialize(test_automation_run_data, test_automation_token)
default_url: str = "https://drive.google.com/uc?export=download&id=1hiPSw23eOaqd27QD_YsXvZg9PWm7_XBx"
assert automate_sdk.run_status == AutomationStatus.SUCCEEDED
automate_sdk = run_function(
automation_context,
automate_function,
FunctionInputs(spreadsheet_url=default_url, minimum_severity=MinimumSeverity.WARNING, hide_skipped=True),
)
assert automate_sdk.run_status == AutomationStatus.SUCCEEDED
+338 -354
View File
@@ -1,3 +1,5 @@
"""Test suite for parameter handling functionality."""
import os
from typing import Any
@@ -15,390 +17,372 @@ from helpers import speckle_print
from src.rules import PropertyRules
def load_test_objects(v2_wall: Any, v3_wall: Any) -> tuple[Base, Base]:
"""Load test objects from a Speckle server."""
client = SpeckleClient(host="https://app.speckle.systems", use_ssl=True)
class TestParameterHandling:
"""Test suite for parameter handling functionality."""
load_dotenv(dotenv_path="../.env")
@staticmethod
def load_test_objects(v2_wall: Any, v3_wall: Any) -> tuple[Base, Base]:
"""Load test objects from a Speckle server."""
client = SpeckleClient(host="https://app.speckle.systems", use_ssl=True)
client.authenticate_with_token(os.getenv("SPECKLE_TOKEN"))
load_dotenv(dotenv_path="../.env")
transport = ServerTransport(client=client, stream_id=os.getenv("SPECKLE_PROJECT_ID"))
client.authenticate_with_token(os.getenv("SPECKLE_TOKEN"))
speckle_print(v2_wall)
v2_obj = operations.receive("cdb18060dc48281909e94f0f1d8d3cc0", transport)
v3_obj = operations.receive("46f06fef727d64a0bbcbd7ced51e0cd2", transport)
transport = ServerTransport(client=client, stream_id=os.getenv("SPECKLE_PROJECT_ID"))
# return v2_wall, v3_wall
return v2_obj, v3_obj
speckle_print(v2_wall)
speckle_print(v3_wall)
v2_obj = operations.receive("cdb18060dc48281909e94f0f1d8d3cc0", transport)
v3_obj = operations.receive("46f06fef727d64a0bbcbd7ced51e0cd2", transport)
# return v2_wall, v3_wall
return v2_obj, v3_obj
@pytest.fixture
def test_objects(v2_wall: Any, v3_wall: Any) -> tuple[Base, Base]:
"""Pytest fixture to provide test objects."""
return load_test_objects(v2_wall, v3_wall)
@pytest.fixture
def test_objects(self, v2_wall: Any, v3_wall: Any) -> tuple[Base, Base]:
"""Pytest fixture to provide test objects."""
return self.load_test_objects(v2_wall, v3_wall)
def test_deserialization_structure(self, test_objects):
"""Test that objects are properly deserialized with correct structure."""
v2_obj, v3_obj = test_objects
def test_deserialization_structure(test_objects):
"""Test that objects are properly deserialized with correct structure."""
v2_obj, v3_obj = test_objects
# Check base class type
for obj in [v2_obj, v3_obj]:
assert isinstance(obj, Base), f"Expected {obj} to be an instance of Base"
# Check base class type
for obj in [v2_obj, v3_obj]:
assert isinstance(obj, Base), f"Expected {obj} to be an instance of Base"
# Check v2 structure
assert hasattr(v2_obj, "parameters"), "v2_obj should have 'parameters' attribute"
assert v2_obj["parameters"] is not None, "v2_obj['parameters'] should not be None"
# Check v2 structure
assert hasattr(v2_obj, "parameters"), "v2_obj should have 'parameters' attribute"
assert v2_obj["parameters"] is not None, "v2_obj['parameters'] should not be None"
# Check v3 structure
assert hasattr(v3_obj, "properties"), "v3_obj should have 'properties' attribute"
assert v3_obj["properties"] is not None, "v3_obj['properties'] should not be None"
assert "Parameters" in v3_obj["properties"], "'Parameters' key should exist in v3_obj['properties']"
# Check v3 structure
assert hasattr(v3_obj, "properties"), "v3_obj should have 'properties' attribute"
assert v3_obj["properties"] is not None, "v3_obj['properties'] should not be None"
assert "Parameters" in v3_obj["properties"], "'Parameters' key should exist in v3_obj['properties']"
@pytest.mark.parametrize(
"param_name, expected_result",
[
("category", True), # Test parameters that should exist
("WALL_ATTR_WIDTH_PARAM", True), # Test nested parameters
("WALL_ATTR_WIDTH_PARAM.value", True),
("WALL_ATTR_WIDTH_PARAM.id", True),
("WALL_ATTR_WIDTH_PARAM.units", True),
("non_existent_param", False), # Test non-existent parameters
],
)
def test_v2_parameter_exists(test_objects, param_name, expected_result):
"""Test parameter existence checking in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.has_parameter(v2_obj, param_name) == expected_result
@pytest.mark.parametrize(
"param_name",
[
"WALL_ATTR_WIDTH_PARAM.id",
"WALL_ATTR_WIDTH_PARAM.value",
"WALL_ATTR_WIDTH_PARAM",
"WALL_ATTR_WIDTH_PARAM.units",
],
)
def test_v2_parameter_value_retrieval(test_objects, param_name):
"""Test parameter value retrieval in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.get_parameter_value(v2_obj, param_name)
@pytest.mark.parametrize(
"param_name, expected_result",
[
("category", True), # Test parameters that should exist
("Width", True), # Test nested parameters
("non_existent_param", False), # Test non-existent parameters
],
)
def test_v3_parameter_exists(test_objects, param_name, expected_result):
"""Test parameter existence checking in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.has_parameter(v3_obj, param_name) == expected_result
@pytest.mark.parametrize(
"param_name_1, param_name_2",
[
(
"properties.Parameters.Instance Parameters.Dimensions.Length.value",
"Instance Parameters.Dimensions.Length",
),
],
)
def test_v3_parameter_search_equivalence(test_objects, param_name_1, param_name_2):
"""Test parameter existence checking equivalence in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.get_parameter_value(v3_obj, param_name_1) == PropertyRules.get_parameter_value(
v3_obj, param_name_2
@pytest.mark.parametrize(
"param_name, expected_result",
[
("category", True), # Test parameters that should exist
("WALL_ATTR_WIDTH_PARAM", True), # Test nested parameters
("WALL_ATTR_WIDTH_PARAM.value", True),
("WALL_ATTR_WIDTH_PARAM.id", True),
("WALL_ATTR_WIDTH_PARAM.units", True),
("non_existent_param", False), # Test non-existent parameters
],
)
def test_v2_parameter_exists(self, test_objects, param_name, expected_result):
"""Test parameter existence checking in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.has_parameter(v2_obj, param_name) == expected_result
@pytest.mark.parametrize(
"param_name",
[
"WALL_ATTR_WIDTH_PARAM.id",
"WALL_ATTR_WIDTH_PARAM.value",
"WALL_ATTR_WIDTH_PARAM",
"WALL_ATTR_WIDTH_PARAM.units",
],
)
def test_v2_parameter_value_retrieval(self, test_objects, param_name):
"""Test parameter value retrieval in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.get_parameter_value(v2_obj, param_name)
@pytest.mark.parametrize(
"obj_version, param_name, expected_value, default_value",
[
# Test direct parameters
("v2", "category", "Walls", None),
("v3", "category", "Walls", None),
# Test nested parameters - using both internal and friendly names
("v2", "WALL_ATTR_WIDTH_PARAM", 300, None),
("v3", "Construction.Width", 300, None),
# Test parameters with units
("v2", "CURVE_ELEM_LENGTH", 5300.000000000001, None),
("v3", "Instance Parameters.Dimensions.Length", 5300.000000000001, None),
# Test non-existent parameters with a default value
("v2", "parameters.non_existent", "default", "default"),
("v3", "properties.Parameters.non_existent", "default", "default"),
],
)
def test_parameter_value_retrieval(test_objects, obj_version, param_name, expected_value, default_value):
"""Test parameter value retrieval from both v2 and v3 objects."""
v2_obj, v3_obj = test_objects
obj = v2_obj if obj_version == "v2" else v3_obj
result = PropertyRules.get_parameter_value(obj, param_name, default_value=default_value)
assert result == expected_value
@pytest.mark.parametrize(
"param_name, expected_result",
[
("category", True), # Test parameters that should exist
("Width", True), # Test nested parameters
("non_existent_param", False), # Test non-existent parameters
],
)
def test_v3_parameter_exists(self, test_objects, param_name, expected_result):
"""Test parameter existence checking in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.has_parameter(v3_obj, param_name) == expected_result
@pytest.mark.parametrize(
"param_name_1, param_name_2",
[
(
"properties.Parameters.Instance Parameters.Dimensions.Length.value",
"Instance Parameters.Dimensions.Length",
),
],
)
def test_v3_parameter_search_equivalence(self, test_objects, param_name_1, param_name_2):
"""Test parameter existence checking equivalence in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.get_parameter_value(v3_obj, param_name_1) == PropertyRules.get_parameter_value(
v3_obj, param_name_2
)
@pytest.mark.parametrize(
"param_name, expected_value, expected_result",
[
("category", "Walls", True), # Test exact match
("WALL_ATTR_WIDTH_PARAM", 300, True), # Test numeric match
("category", "Windows", False), # Test non-match
],
)
def test_v2_parameter_value_matching(test_objects, param_name, expected_value, expected_result):
"""Test parameter value matching in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value(v2_obj, param_name, expected_value) == expected_result
@pytest.mark.parametrize(
"obj_version, param_name, expected_value, default_value",
[
# Test direct parameters
("v2", "category", "Walls", None),
("v3", "category", "Walls", None),
# Test nested parameters - using both internal and friendly names
("v2", "WALL_ATTR_WIDTH_PARAM", 300, None),
("v3", "Construction.Width", 300, None),
# Test parameters with units
("v2", "CURVE_ELEM_LENGTH", 5300.000000000001, None),
("v3", "Instance Parameters.Dimensions.Length", 5300.000000000001, None),
# Test non-existent parameters with a default value
("v2", "parameters.non_existent", "default", "default"),
("v3", "properties.Parameters.non_existent", "default", "default"),
],
)
def test_parameter_value_retrieval(self, test_objects, obj_version, param_name, expected_value, default_value):
"""Test parameter value retrieval from both v2 and v3 objects."""
v2_obj, v3_obj = test_objects
obj = v2_obj if obj_version == "v2" else v3_obj
result = PropertyRules.get_parameter_value(obj, param_name, default_value=default_value)
assert result == expected_value
@pytest.mark.parametrize(
"param_name, expected_value, expected_result",
[
("category", "Walls", True), # Test exact match
("WALL_ATTR_WIDTH_PARAM", 300, True), # Test numeric match
("category", "Windows", False), # Test non-match
],
)
def test_v2_parameter_value_matching(self, test_objects, param_name, expected_value, expected_result):
"""Test parameter value matching in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value(v2_obj, param_name, expected_value) == expected_result
@pytest.mark.parametrize(
"param_name, expected_value, expected_result",
[
("category", "Walls", True), # Test exact match
("Width", 300, True), # Test numeric match
("category", "Windows", False), # Test non-match
],
)
def test_v3_parameter_value_matching(test_objects, param_name, expected_value, expected_result):
"""Test parameter value matching in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.is_parameter_value(v3_obj, param_name, expected_value) == expected_result
@pytest.mark.parametrize(
"param_name, expected_value, expected_result",
[
("category", "Walls", True), # Test exact match
("Width", 300, True), # Test numeric match
("category", "Windows", False), # Test non-match
],
)
def test_v3_parameter_value_matching(self, test_objects, param_name, expected_value, expected_result):
"""Test parameter value matching in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.is_parameter_value(v3_obj, param_name, expected_value) == expected_result
@pytest.mark.parametrize(
"comparison_func, param_name, value, expected_result",
[
(PropertyRules.is_parameter_value_greater_than, "WALL_ATTR_WIDTH_PARAM", "200", True), # Test greater than
(PropertyRules.is_parameter_value_less_than, "WALL_ATTR_WIDTH_PARAM", "400", True), # Test less than
(PropertyRules.is_parameter_value_in_range, "WALL_ATTR_WIDTH_PARAM", "200,400", True), # Test in range
],
)
def test_v2_parameter_numeric_comparisons(self, test_objects, comparison_func, param_name, value, expected_result):
"""Test numeric parameter comparisons in v2 objects."""
v2_obj, _ = test_objects
assert comparison_func(v2_obj, param_name, value) == expected_result
@pytest.mark.parametrize(
"comparison_func, param_name, value, expected_result",
[
(PropertyRules.is_parameter_value_greater_than, "WALL_ATTR_WIDTH_PARAM", "200", True), # Test greater than
(PropertyRules.is_parameter_value_less_than, "WALL_ATTR_WIDTH_PARAM", "400", True), # Test less than
(PropertyRules.is_parameter_value_in_range, "WALL_ATTR_WIDTH_PARAM", "200,400", True), # Test in range
],
)
def test_v2_parameter_numeric_comparisons(test_objects, comparison_func, param_name, value, expected_result):
"""Test numeric parameter comparisons in v2 objects."""
v2_obj, _ = test_objects
assert comparison_func(v2_obj, param_name, value) == expected_result
@pytest.mark.parametrize(
"comparison_func, param_name, value, expected_result",
[
(PropertyRules.is_parameter_value_greater_than, "Width", "200", True), # Test greater than
(PropertyRules.is_parameter_value_less_than, "Width", "400", True), # Test less than
(PropertyRules.is_parameter_value_in_range, "Width", "200,400", True), # Test in range
],
)
def test_v3_parameter_numeric_comparisons(self, test_objects, comparison_func, param_name, value, expected_result):
"""Test numeric parameter comparisons in v3 objects."""
_, v3_obj = test_objects
assert comparison_func(v3_obj, param_name, value) == expected_result
@pytest.mark.parametrize(
"param_name, pattern, fuzzy, expected_result",
[
("category", "^Walls$", False, True), # Test exact pattern matches
("category", "Walls", True, True), # Test fuzzy matches
("category", "Wall", False, True), # Test partial pattern matches
("category", "^Windows$", False, False), # Test non-matches
],
)
def test_v2_parameter_value_like(self, test_objects, param_name, pattern, fuzzy, expected_result):
"""Test pattern matching on parameter values in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value_like(v2_obj, param_name, pattern, fuzzy=fuzzy) == expected_result
@pytest.mark.parametrize(
"comparison_func, param_name, value, expected_result",
[
(PropertyRules.is_parameter_value_greater_than, "Width", "200", True), # Test greater than
(PropertyRules.is_parameter_value_less_than, "Width", "400", True), # Test less than
(PropertyRules.is_parameter_value_in_range, "Width", "200,400", True), # Test in range
],
)
def test_v3_parameter_numeric_comparisons(test_objects, comparison_func, param_name, value, expected_result):
"""Test numeric parameter comparisons in v3 objects."""
_, v3_obj = test_objects
assert comparison_func(v3_obj, param_name, value) == expected_result
@pytest.mark.parametrize(
"param_name, pattern, fuzzy, expected_result",
[
("category", "^Walls$", False, True), # Test exact pattern matches
("category", "Walls", True, True), # Test fuzzy matches
("category", "Wall", False, True), # Test partial pattern matches
("category", "^Windows$", False, False), # Test non-matches
],
)
def test_v3_parameter_value_like(self, test_objects, param_name, pattern, fuzzy, expected_result):
"""Test pattern matching on parameter values in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.is_parameter_value_like(v3_obj, param_name, pattern, fuzzy=fuzzy) == expected_result
@pytest.mark.parametrize(
"param_name, valid_list, expected_result",
[
("category", ["Walls", "Windows", "Doors"], True), # Test value in list
("category", "Walls,Windows,Doors", True), # Test comma-separated string list
("category", ["Windows", "Doors"], False), # Test value not in list
],
)
def test_v2_parameter_lists(self, test_objects, param_name, valid_list, expected_result):
"""Test list-based parameter checks in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value_in_list(v2_obj, param_name, valid_list) == expected_result
@pytest.mark.parametrize(
"param_name, pattern, fuzzy, expected_result",
[
("category", "^Walls$", False, True), # Test exact pattern matches
("category", "Walls", True, True), # Test fuzzy matches
("category", "Wall", False, True), # Test partial pattern matches
("category", "^Windows$", False, False), # Test non-matches
],
)
def test_v2_parameter_value_like(test_objects, param_name, pattern, fuzzy, expected_result):
"""Test pattern matching on parameter values in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value_like(v2_obj, param_name, pattern, fuzzy=fuzzy) == expected_result
@pytest.mark.parametrize(
"param_name, valid_list, expected_result",
[
("category", ["Walls", "Windows", "Doors"], True), # Test value in list
("category", "Walls,Windows,Doors", True), # Test comma-separated string list
("category", ["Windows", "Doors"], False), # Test value not in list
],
)
def test_v3_parameter_lists(self, test_objects, param_name, valid_list, expected_result):
"""Test list-based parameter checks in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.is_parameter_value_in_list(v3_obj, param_name, valid_list) == expected_result
@pytest.mark.parametrize(
"param_name, expected_result",
[
("WALL_ATTR_ROOM_BOUNDING.value", True), # Test true values
("wall_top_is_attached", False), # Test false values
],
)
def test_v2_boolean_parameters(self, test_objects, param_name, expected_result):
"""Test boolean parameter checks in v2 objects."""
v2_obj, _ = test_objects
if expected_result:
assert PropertyRules.is_parameter_value_true(v2_obj, param_name)
else:
assert PropertyRules.is_parameter_value_false(v2_obj, param_name)
@pytest.mark.parametrize(
"param_name, pattern, fuzzy, expected_result",
[
("category", "^Walls$", False, True), # Test exact pattern matches
("category", "Walls", True, True), # Test fuzzy matches
("category", "Wall", False, True), # Test partial pattern matches
("category", "^Windows$", False, False), # Test non-matches
],
)
def test_v3_parameter_value_like(test_objects, param_name, pattern, fuzzy, expected_result):
"""Test pattern matching on parameter values in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.is_parameter_value_like(v3_obj, param_name, pattern, fuzzy=fuzzy) == expected_result
@pytest.mark.parametrize(
"param_name, expected_result",
[
("Room Bounding", True), # Test true values
("top is attached", False), # Test false values
("Top is Attached", False), # Case sensitivity test
],
)
def test_v3_boolean_parameters(self, test_objects, param_name, expected_result):
"""Test boolean parameter checks in v3 objects."""
_, v3_obj = test_objects
if expected_result:
assert PropertyRules.is_parameter_value_true(v3_obj, param_name)
else:
assert PropertyRules.is_parameter_value_false(v3_obj, param_name)
@pytest.mark.parametrize(
"param_name, expected_value, expected_result",
[
# Test numeric value comparisons
("WALL_ATTR_WIDTH_PARAM", 300, True),
("WALL_ATTR_WIDTH_PARAM.value", 300, True),
("baseLine.length", 5300.000000000002, True),
# Test string value comparisons
("STRUCTURAL_MATERIAL_PARAM.value", "Fc24", True),
("ee1f33e1-5506-4a64-b87b-7b98d30aea52.value", "W30", True),
# Test non-matches
("WALL_ATTR_WIDTH_PARAM", 301, False),
("nonexistent_param", "any_value", False),
],
)
def test_v2_parameter_value_comparisons(self, v2_wall, param_name, expected_value, expected_result):
"""Test value comparisons using v2 wall parameters."""
assert PropertyRules.is_equal_value(v2_wall, param_name, expected_value) == expected_result
@pytest.mark.parametrize(
"param_name, valid_list, expected_result",
[
("category", ["Walls", "Windows", "Doors"], True), # Test value in list
("category", "Walls,Windows,Doors", True), # Test comma-separated string list
("category", ["Windows", "Doors"], False), # Test value not in list
],
)
def test_v2_parameter_lists(test_objects, param_name, valid_list, expected_result):
"""Test list-based parameter checks in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value_in_list(v2_obj, param_name, valid_list) == expected_result
@pytest.mark.parametrize(
"attribute, value, expected",
[
# Test numeric value comparisons
("Type Parameters.Structure.Fc24 (0).thickness", 300, True),
("location.length", 5300.000000000002, True),
("location.length", 5300, True),
# Test string value comparisons
("Type Parameters.Text.符号.value", "W30", True),
("Instance Parameters.Structural.Structural.value", "Yes", True),
# Test non-matches
("Type Parameters.Structure.Fc24 (0).thickness", 301, False),
("nonexistent_param", "any_value", False),
],
)
def test_v3_parameter_value_comparisons(self, v3_wall, attribute, value, expected):
"""Test value comparisons using v3 wall parameters."""
assert PropertyRules.is_equal_value(v3_wall, attribute, value) == expected
@pytest.mark.parametrize(
"wall, attribute, value, expected",
[
# V2 wall tests
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", 300, True),
("v2_wall", "type", "W30(Fc24)", True),
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", 300.0001, False),
# V3 wall tests
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", 300, True),
("v3_wall", "type", "W30(Fc24)", True),
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", 300.0001, False),
("v3_wall", "location.length", 5300.000000000002, True),
("v3_wall", "location.length", 5300, False),
],
)
def test_identical_comparisons(self, request, wall, attribute, value, expected):
"""Test identical value comparisons on both wall versions."""
wall_instance = request.getfixturevalue(wall)
assert PropertyRules.is_identical_value(wall_instance, attribute, value) == expected
@pytest.mark.parametrize(
"param_name, valid_list, expected_result",
[
("category", ["Walls", "Windows", "Doors"], True), # Test value in list
("category", "Walls,Windows,Doors", True), # Test comma-separated string list
("category", ["Windows", "Doors"], False), # Test value not in list
],
)
def test_v3_parameter_lists(test_objects, param_name, valid_list, expected_result):
"""Test list-based parameter checks in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.is_parameter_value_in_list(v3_obj, param_name, valid_list) == expected_result
@pytest.mark.parametrize(
"wall, attribute, value",
[
# V2 wall tests
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", 301),
("v2_wall", "STRUCTURAL_MATERIAL_PARAM.value", "Fc25"),
("v2_wall", "nonexistent_param", "any_value"),
# V3 wall tests
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", 301),
("v3_wall", "Type Parameters.Text.符号.value", "W31"),
("v3_wall", "nonexistent_param", "any_value"),
],
)
def test_not_equal_comparisons(self, request, wall, attribute, value):
"""Test not equal comparisons on both wall versions."""
wall_instance = request.getfixturevalue(wall)
assert PropertyRules.is_not_equal_value(wall_instance, attribute, value)
@pytest.mark.parametrize(
"attribute, value, expected_equal, expected_identical",
[
# Test Yes/No conversion in equals (should convert)
("Instance Parameters.Structural.Structural.value", True, True, False), # Yes vs True
("Instance Parameters.Structural.Structural.value", "Yes", True, True), # Yes vs "Yes"
("Instance Parameters.Structural.Structural.value", "yes", True, False), # Yes vs "yes"
],
)
def test_boolean_conversions(self, v3_wall, attribute, value, expected_equal, expected_identical):
"""Test conversion of Yes/No strings to boolean values."""
assert PropertyRules.is_equal_value(v3_wall, attribute, value) == expected_equal
assert PropertyRules.is_identical_value(v3_wall, attribute, value) == expected_identical
@pytest.mark.parametrize(
"param_name, expected_result",
[
("WALL_ATTR_ROOM_BOUNDING.value", True), # Test true values
("wall_top_is_attached", False), # Test false values
],
)
def test_v2_boolean_parameters(test_objects, param_name, expected_result):
"""Test boolean parameter checks in v2 objects."""
v2_obj, _ = test_objects
if expected_result:
assert PropertyRules.is_parameter_value_true(v2_obj, param_name)
else:
assert PropertyRules.is_parameter_value_false(v2_obj, param_name)
@pytest.mark.parametrize(
"param_name, expected_result",
[
("Room Bounding", True), # Test true values
("top is attached", False), # Test false values
("Top is Attached", False), # Case sensitivity test
],
)
def test_v3_boolean_parameters(test_objects, param_name, expected_result):
"""Test boolean parameter checks in v3 objects."""
_, v3_obj = test_objects
if expected_result:
assert PropertyRules.is_parameter_value_true(v3_obj, param_name)
else:
assert PropertyRules.is_parameter_value_false(v3_obj, param_name)
@pytest.mark.parametrize(
"param_name, expected_value, expected_result",
[
# Test numeric value comparisons
("WALL_ATTR_WIDTH_PARAM", 300, True),
("WALL_ATTR_WIDTH_PARAM.value", 300, True),
("baseLine.length", 5300.000000000002, True),
# Test string value comparisons
("STRUCTURAL_MATERIAL_PARAM.value", "Fc24", True),
("ee1f33e1-5506-4a64-b87b-7b98d30aea52.value", "W30", True),
# Test non-matches
("WALL_ATTR_WIDTH_PARAM", 301, False),
("nonexistent_param", "any_value", False),
],
)
def test_v2_parameter_value_comparisons(v2_wall, param_name, expected_value, expected_result):
"""Test value comparisons using v2 wall parameters."""
assert PropertyRules.is_equal_value(v2_wall, param_name, expected_value) == expected_result
@pytest.mark.parametrize(
"attribute, value, expected",
[
# Test numeric value comparisons
("Type Parameters.Structure.Fc24 (0).thickness", 300, True),
("location.length", 5300.000000000002, True),
("location.length", 5300, True),
# Test string value comparisons
("Type Parameters.Text.符号.value", "W30", True),
("Instance Parameters.Structural.Structural.value", "Yes", True),
# Test non-matches
("Type Parameters.Structure.Fc24 (0).thickness", 301, False),
("nonexistent_param", "any_value", False),
],
)
def test_v3_parameter_value_comparisons(v3_wall, attribute, value, expected):
"""Test value comparisons using v3 wall parameters."""
assert PropertyRules.is_equal_value(v3_wall, attribute, value) == expected
@pytest.mark.parametrize(
"wall, attribute, value, expected",
[
# V2 wall tests
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", 300, True),
("v2_wall", "type", "W30(Fc24)", True),
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", 300.0001, False),
# V3 wall tests
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", 300, True),
("v3_wall", "type", "W30(Fc24)", True),
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", 300.0001, False),
("v3_wall", "location.length", 5300.000000000002, True),
("v3_wall", "location.length", 5300, False),
],
)
def test_identical_comparisons(request, wall, attribute, value, expected):
"""Test identical value comparisons on both wall versions."""
wall_instance = request.getfixturevalue(wall)
assert PropertyRules.is_identical_value(wall_instance, attribute, value) == expected
@pytest.mark.parametrize(
"wall, attribute, value",
[
# V2 wall tests
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", 301),
("v2_wall", "STRUCTURAL_MATERIAL_PARAM.value", "Fc25"),
("v2_wall", "nonexistent_param", "any_value"),
# V3 wall tests
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", 301),
("v3_wall", "Type Parameters.Text.符号.value", "W31"),
("v3_wall", "nonexistent_param", "any_value"),
],
)
def test_not_equal_comparisons(request, wall, attribute, value):
"""Test not equal comparisons on both wall versions."""
wall_instance = request.getfixturevalue(wall)
assert PropertyRules.is_not_equal_value(wall_instance, attribute, value)
@pytest.mark.parametrize(
"attribute, value, expected_equal, expected_identical",
[
# Test Yes/No conversion in equals (should convert)
("Instance Parameters.Structural.Structural.value", True, True, False), # Yes vs True
("Instance Parameters.Structural.Structural.value", "Yes", True, True), # Yes vs "Yes"
("Instance Parameters.Structural.Structural.value", "yes", True, False), # Yes vs "yes"
],
)
def test_boolean_conversions(v3_wall, attribute, value, expected_equal, expected_identical):
"""Test conversion of Yes/No strings to boolean values."""
assert PropertyRules.is_equal_value(v3_wall, attribute, value) == expected_equal
assert PropertyRules.is_identical_value(v3_wall, attribute, value) == expected_identical
@pytest.mark.parametrize(
"wall, attribute, expected_value",
[
# V2 wall tests
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", "300"),
("v2_wall", "baseLine.length", "5300.000000000002"),
# V3 wall tests
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", "300"),
("v3_wall", "location.length", "5300.000000000002"),
],
)
def test_numeric_string_handling(wall, attribute, expected_value, request):
"""Test handling of numeric strings in both wall versions."""
wall_instance = request.getfixturevalue(wall) # Retrieve fixture dynamically
assert PropertyRules.is_equal_value(wall_instance, attribute, expected_value)
@pytest.mark.parametrize(
"wall, attribute, expected_value",
[
# V2 wall tests
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", "300"),
("v2_wall", "baseLine.length", "5300.000000000002"),
# V3 wall tests
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", "300"),
("v3_wall", "location.length", "5300.000000000002"),
],
)
def test_numeric_string_handling(self, wall, attribute, expected_value, request):
"""Test handling of numeric strings in both wall versions."""
wall_instance = request.getfixturevalue(wall) # Retrieve fixture dynamically
assert PropertyRules.is_equal_value(wall_instance, attribute, expected_value)
+36
View File
@@ -0,0 +1,36 @@
"""Tests for rule processing functionality."""
import pandas as pd
import pytest
@pytest.fixture
def explicit_check_rule():
"""Create a rule using explicit CHECK format."""
return pd.DataFrame(
{
"Rule Number": [1, 1, 1],
"Logic": ["WHERE", "AND", "CHECK"],
"Property Name": ["category", "width", "material"],
"Predicate": ["matches", "greater than", "matches"],
"Value": ["Walls", "200", "Concrete"],
"Message": ["Test message", "", ""],
"Severity": ["Error", "", ""],
}
)
@pytest.fixture
def legacy_rule():
"""Create a rule using legacy format (last AND is implicit check)."""
return pd.DataFrame(
{
"Rule Number": [1, 1, 1],
"Logic": ["WHERE", "AND", "AND"],
"Property Name": ["category", "width", "material"],
"Predicate": ["matches", "greater than", "matches"],
"Value": ["Walls", "200", "Concrete"],
"Message": ["Test message", "", ""],
"Severity": ["Error", "", ""],
}
)