| | |
| | |
| | |
| |
|
| | """Unit tests for the ArchReport and ArchSql modules.""" |
| | import FreeCAD |
| | import Arch |
| | import Draft |
| | import ArchSql |
| | import ArchReport |
| | from unittest.mock import patch |
| | from bimtests import TestArchBase |
| | from bimtests.fixtures.BimFixtures import create_test_model |
| |
|
| |
|
| | class TestArchReport(TestArchBase.TestArchBase): |
| |
|
| | def setUp(self): |
| | super().setUp() |
| | self.doc = self.document |
| |
|
| | self.wall_ext = Arch.makeWall(length=1000, name="Exterior Wall") |
| | self.wall_ext.IfcType = "Wall" |
| | self.wall_ext.Height = FreeCAD.Units.Quantity( |
| | 3000, "mm" |
| | ) |
| |
|
| | self.wall_int = Arch.makeWall(length=500, name="Interior partition wall") |
| | self.wall_int.IfcType = "Wall" |
| | self.wall_int.Height = FreeCAD.Units.Quantity(2500, "mm") |
| |
|
| | self.column = Arch.makeStructure(length=300, width=330, height=2000, name="Main Column") |
| | self.column.IfcType = "Column" |
| |
|
| | self.beam = Arch.makeStructure(length=2000, width=200, height=400, name="Main Beam") |
| | self.beam.IfcType = "Beam" |
| |
|
| | self.window = Arch.makeWindow(name="Living Room Window") |
| | self.window.IfcType = "Window" |
| |
|
| | self.part_box = self.doc.addObject( |
| | "Part::Box", "Generic Box" |
| | ) |
| |
|
| | |
| | self.test_objects_in_doc = [ |
| | self.wall_ext, |
| | self.wall_int, |
| | self.column, |
| | self.beam, |
| | self.window, |
| | self.part_box, |
| | ] |
| | self.test_object_labels = sorted([o.Label for o in self.test_objects_in_doc]) |
| |
|
| | |
| | self.spreadsheet = self.doc.addObject("Spreadsheet::Sheet", "ReportTarget") |
| | self.doc.recompute() |
| |
|
| | def _run_query_for_objects(self, query_string): |
| | """ |
| | Helper method to run a query using the public API and return filtered results. |
| | This version is simplified to directly use Arch.select(), avoiding the |
| | creation of a Report object and thus preventing the "still touched" error. |
| | """ |
| | |
| | |
| | try: |
| | headers, results_data_from_sql = Arch.select(query_string) |
| | except (ArchSql.BimSqlSyntaxError, ArchSql.SqlEngineError) as e: |
| | self.fail(f"The query '{query_string}' failed to execute with an exception: {e}") |
| |
|
| | self.assertIsInstance(headers, list, f"Headers should be a list for: {query_string}") |
| | self.assertIsInstance( |
| | results_data_from_sql, list, f"Results data should be a list for: {query_string}" |
| | ) |
| |
|
| | |
| | |
| | is_aggregate_query = any( |
| | agg in h for h in headers for agg in ["COUNT", "SUM", "MIN", "MAX"] |
| | ) |
| | if is_aggregate_query: |
| | return headers, results_data_from_sql |
| |
|
| | |
| | |
| | if headers == ["Object Label"]: |
| | extracted_labels = [row[0] for row in results_data_from_sql] |
| | |
| | filtered_labels = [ |
| | label for label in extracted_labels if label in self.test_object_labels |
| | ] |
| | return headers, filtered_labels |
| |
|
| | |
| | |
| | |
| | filtered_results_for_specific_columns = [] |
| | if results_data_from_sql and len(results_data_from_sql[0]) > 0: |
| | for row in results_data_from_sql: |
| | if row[0] in self.test_object_labels: |
| | filtered_results_for_specific_columns.append(row) |
| |
|
| | return headers, filtered_results_for_specific_columns |
| |
|
| | |
| | def test_makeReport_default(self): |
| | report = Arch.makeReport() |
| | self.assertIsNotNone(report, "makeReport failed to create an object.") |
| | self.assertEqual(report.Label, "Report", "Default report label is incorrect.") |
| |
|
| | def test_report_properties(self): |
| | report = Arch.makeReport() |
| | self.assertTrue( |
| | hasattr(report, "Statements"), "Report object is missing 'Statements' property." |
| | ) |
| | self.assertTrue(hasattr(report, "Target"), "Report object is missing 'Target' property.") |
| |
|
| | |
| | def test_select_all_from_document(self): |
| | """Test a 'SELECT * FROM document' query.""" |
| | headers, results_labels = self._run_query_for_objects("SELECT * FROM document") |
| |
|
| | self.assertEqual(headers, ["Object Label"]) |
| | self.assertCountEqual( |
| | results_labels, self.test_object_labels, "Should find all queryable objects." |
| | ) |
| |
|
| | def test_select_specific_columns_from_document(self): |
| | """Test a 'SELECT Label, IfcType, Height FROM document' query.""" |
| | query_string = 'SELECT Label, IfcType, Height FROM document WHERE IfcType = "Wall"' |
| | headers, results_data = self._run_query_for_objects(query_string) |
| |
|
| | self.assertEqual(headers, ["Label", "IfcType", "Height"]) |
| | self.assertEqual(len(results_data), 2) |
| |
|
| | expected_rows = [ |
| | ["Exterior Wall", "Wall", self.wall_ext.Height], |
| | ["Interior partition wall", "Wall", self.wall_int.Height], |
| | ] |
| | self.assertCountEqual(results_data, expected_rows, "Specific column data mismatch.") |
| |
|
| | |
| | def test_where_equals_string(self): |
| | _, results_labels = self._run_query_for_objects( |
| | 'SELECT * FROM document WHERE IfcType = "Wall"' |
| | ) |
| | self.assertEqual(len(results_labels), 2) |
| | self.assertCountEqual(results_labels, [self.wall_ext.Label, self.wall_int.Label]) |
| |
|
| | def test_where_not_equals_string(self): |
| | """Test a WHERE clause with a not-equals check.""" |
| | _, results_labels = self._run_query_for_objects( |
| | 'SELECT * FROM document WHERE IfcType != "Wall"' |
| | ) |
| | |
| | |
| | expected_labels = [self.column.Label, self.beam.Label, self.window.Label] |
| | self.assertEqual(len(results_labels), 3) |
| | self.assertCountEqual(results_labels, expected_labels) |
| |
|
| | def test_where_is_null(self): |
| | """Test a WHERE clause with an IS NULL check.""" |
| | _, results_labels = self._run_query_for_objects( |
| | "SELECT * FROM document WHERE IfcType IS NULL" |
| | ) |
| | |
| | self.assertEqual(len(results_labels), 1) |
| | self.assertEqual(results_labels[0], self.part_box.Label) |
| |
|
| | def test_where_is_not_null(self): |
| | _, results_labels = self._run_query_for_objects( |
| | "SELECT * FROM document WHERE IfcType IS NOT NULL" |
| | ) |
| | self.assertEqual(len(results_labels), 5) |
| | self.assertNotIn(self.part_box.Label, results_labels) |
| |
|
| | def test_where_like_case_insensitive(self): |
| | _, results_labels = self._run_query_for_objects( |
| | 'SELECT * FROM document WHERE Label LIKE "exterior wall"' |
| | ) |
| | self.assertEqual(len(results_labels), 1) |
| | self.assertEqual(results_labels[0], self.wall_ext.Label) |
| |
|
| | def test_where_like_wildcard_middle(self): |
| | _, results_labels = self._run_query_for_objects( |
| | 'SELECT * FROM document WHERE Label LIKE "%wall%"' |
| | ) |
| | self.assertEqual(len(results_labels), 2) |
| | self.assertCountEqual(results_labels, [self.wall_ext.Label, self.wall_int.Label]) |
| |
|
| | def test_null_equality_is_excluded(self): |
| | """Strict SQL: comparisons with NULL should be excluded; use IS NULL.""" |
| | _, results = self._run_query_for_objects("SELECT * FROM document WHERE IfcType = NULL") |
| | |
| | self.assertEqual(len(results), 0) |
| |
|
| | def test_null_inequality_excludes_nulls(self): |
| | """Strict SQL: IfcType != 'Wall' should exclude rows where IfcType is NULL.""" |
| | _, results_labels = self._run_query_for_objects( |
| | 'SELECT * FROM document WHERE IfcType != "Wall"' |
| | ) |
| | expected_labels = [self.column.Label, self.beam.Label, self.window.Label] |
| | self.assertCountEqual(results_labels, expected_labels) |
| |
|
| | def test_is_null_and_is_not_null_behaviour(self): |
| | _, isnull_labels = self._run_query_for_objects( |
| | "SELECT * FROM document WHERE IfcType IS NULL" |
| | ) |
| | self.assertIn(self.part_box.Label, isnull_labels) |
| |
|
| | _, isnotnull_labels = self._run_query_for_objects( |
| | "SELECT * FROM document WHERE IfcType IS NOT NULL" |
| | ) |
| | self.assertNotIn(self.part_box.Label, isnotnull_labels) |
| |
|
| | def test_where_like_wildcard_end(self): |
| | _, results_labels = self._run_query_for_objects( |
| | 'SELECT * FROM document WHERE Label LIKE "Exterior%"' |
| | ) |
| | self.assertEqual(len(results_labels), 1) |
| | self.assertEqual(results_labels[0], self.wall_ext.Label) |
| |
|
| | def test_where_boolean_and(self): |
| | query = 'SELECT * FROM document WHERE IfcType = "Wall" AND Label LIKE "%Exterior%"' |
| | _, results_labels = self._run_query_for_objects(query) |
| | self.assertEqual(len(results_labels), 1) |
| | self.assertEqual(results_labels[0], self.wall_ext.Label) |
| |
|
| | def test_where_boolean_or(self): |
| | query = 'SELECT * FROM document WHERE IfcType = "Window" OR IfcType = "Column"' |
| | _, results_labels = self._run_query_for_objects(query) |
| | self.assertEqual(len(results_labels), 2) |
| | self.assertCountEqual(results_labels, [self.window.Label, self.column.Label]) |
| |
|
| | |
| | def test_query_no_results(self): |
| | _, results_labels = self._run_query_for_objects( |
| | 'SELECT * FROM document WHERE Label = "NonExistentObject"' |
| | ) |
| | self.assertEqual(len(results_labels), 0) |
| |
|
| | @patch("FreeCAD.Console.PrintError") |
| | def test_query_invalid_syntax(self, mock_print_error): |
| | |
| | with self.assertRaises(Arch.BimSqlSyntaxError) as cm: |
| | Arch.select("SELECT FROM document WHERE") |
| | self.assertFalse( |
| | cm.exception.is_incomplete, "A syntax error should not be marked as incomplete." |
| | ) |
| |
|
| | |
| | count, error_str = Arch.count("SELECT FROM document WHERE") |
| | self.assertEqual(count, -1) |
| | self.assertIsInstance(error_str, str) |
| | self.assertIn("Syntax Error", error_str) |
| |
|
| | def test_incomplete_queries_are_handled_gracefully(self): |
| | incomplete_queries = [ |
| | "SELECT", |
| | "SELECT *", |
| | "SELECT * FROM", |
| | "SELECT * FROM document WHERE", |
| | "SELECT * FROM document WHERE Label =", |
| | "SELECT * FROM document WHERE Label LIKE", |
| | 'SELECT * FROM document WHERE Label like "%wa', |
| | ] |
| |
|
| | for query in incomplete_queries: |
| | with self.subTest(query=query): |
| | count, error = Arch.count(query) |
| | self.assertEqual( |
| | error, "INCOMPLETE", f"Query '{query}' should be marked as INCOMPLETE." |
| | ) |
| |
|
| | def test_invalid_partial_tokens_are_errors(self): |
| | invalid_queries = { |
| | "Mistyped keyword": "SELECT * FRM document", |
| | } |
| |
|
| | for name, query in invalid_queries.items(): |
| | with self.subTest(name=name, query=query): |
| | _, error = Arch.count(query) |
| | self.assertNotEqual( |
| | error, |
| | "INCOMPLETE", |
| | f"Query '{query}' should be a syntax error, not incomplete.", |
| | ) |
| | self.assertIsNotNone(error, f"Query '{query}' should have returned an error.") |
| |
|
| | def test_report_no_target(self): |
| | try: |
| | report = Arch.makeReport() |
| | |
| | self.assertIsNotNone(report.Target, "Report Target should be set on creation.") |
| | |
| | |
| | if hasattr(report, "Proxy"): |
| | |
| | report.Proxy.hydrate_live_statements(report) |
| |
|
| | if not getattr(report.Proxy, "live_statements", None): |
| | |
| | report.Statements = [ |
| | ArchReport.ReportStatement( |
| | description="Statement 1", query_string="SELECT * FROM document" |
| | ).dumps() |
| | ] |
| | report.Proxy.hydrate_live_statements(report) |
| | else: |
| | report.Proxy.live_statements[0].query_string = "SELECT * FROM document" |
| | report.Proxy.commit_statements() |
| | else: |
| | |
| | if not hasattr(report, "Statements") or not report.Statements: |
| | report.Statements = [ |
| | ArchReport.ReportStatement( |
| | description="Statement 1", query_string="SELECT * FROM document" |
| | ).dumps() |
| | ] |
| | else: |
| | |
| | report.Statements = [ |
| | ArchReport.ReportStatement( |
| | description="Statement 1", query_string="SELECT * FROM document" |
| | ).dumps() |
| | ] |
| | self.doc.recompute() |
| | except Exception as e: |
| | self.fail(f"Recomputing a report with no Target raised an unexpected exception: {e}") |
| |
|
| | |
| | |
| | |
| | self.assertIsNotNone( |
| | report.Target, "Report Target should be set after running with no pre-existing Target." |
| | ) |
| | self.assertEqual(getattr(report.Target, "ReportName", None), report.Name) |
| |
|
| | def test_group_by_ifctype_with_count(self): |
| | """Test GROUP BY with COUNT(*) to summarize objects by type.""" |
| | |
| | query = ( |
| | "SELECT IfcType, COUNT(*) FROM document " |
| | "WHERE TypeId != 'App::FeaturePython' AND TypeId != 'Spreadsheet::Sheet' " |
| | "GROUP BY IfcType" |
| | ) |
| | headers, results_data = self._run_query_for_objects(query) |
| |
|
| | self.assertEqual(headers, ["IfcType", "COUNT(*)"]) |
| |
|
| | |
| | |
| | results_dict = {row[0] if row[0] != "None" else None: int(row[1]) for row in results_data} |
| |
|
| | expected_counts = { |
| | "Wall": 2, |
| | "Column": 1, |
| | "Beam": 1, |
| | "Window": 1, |
| | None: 1, |
| | } |
| | self.assertDictEqual( |
| | results_dict, expected_counts, "The object counts per IfcType are incorrect." |
| | ) |
| |
|
| | def test_count_all_without_group_by(self): |
| | """Test COUNT(*) on the whole dataset without grouping.""" |
| | |
| | query = ( |
| | "SELECT COUNT(*) FROM document " |
| | "WHERE TypeId != 'App::FeaturePython' AND TypeId != 'Spreadsheet::Sheet'" |
| | ) |
| | headers, results_data = self._run_query_for_objects(query) |
| |
|
| | self.assertEqual(headers, ["COUNT(*)"]) |
| | self.assertEqual(len(results_data), 1, "Non-grouped aggregate should return a single row.") |
| | self.assertEqual( |
| | int(results_data[0][0]), |
| | len(self.test_objects_in_doc), |
| | "COUNT(*) did not return the total number of test objects.", |
| | ) |
| |
|
| | def test_group_by_with_sum(self): |
| | """Test GROUP BY with SUM() on a numeric property.""" |
| | |
| | query = ( |
| | "SELECT IfcType, SUM(Height) FROM document " |
| | "WHERE IfcType = 'Wall' OR IfcType = 'Column' " |
| | "GROUP BY IfcType" |
| | ) |
| | headers, results_data = self._run_query_for_objects(query) |
| |
|
| | self.assertEqual(headers, ["IfcType", "SUM(Height)"]) |
| | results_dict = {row[0]: float(row[1]) for row in results_data} |
| |
|
| | |
| | |
| | |
| | expected_sums = { |
| | "Wall": 5500.0, |
| | "Column": 2000.0, |
| | } |
| | self.assertDictEqual(results_dict, expected_sums) |
| | self.assertNotIn("Window", results_dict, "Groups excluded by WHERE should not appear.") |
| |
|
| | def test_min_and_max_functions(self): |
| | """Test MIN() and MAX() functions on a numeric property.""" |
| | query = "SELECT MIN(Length), MAX(Length) FROM document WHERE IfcType = 'Wall'" |
| | headers, results_data = self._run_query_for_objects(query) |
| |
|
| | self.assertEqual(headers, ["MIN(Length)", "MAX(Length)"]) |
| | self.assertEqual( |
| | len(results_data), 1, "Aggregate query without GROUP BY should return one row." |
| | ) |
| |
|
| | |
| | min_length = float(results_data[0][0]) |
| | max_length = float(results_data[0][1]) |
| |
|
| | self.assertAlmostEqual(min_length, 500.0) |
| | self.assertAlmostEqual(max_length, 1000.0) |
| |
|
| | def test_invalid_group_by_raises_error(self): |
| | """A SELECT column not in GROUP BY and not in an aggregate should fail validation.""" |
| | |
| | query = "SELECT Label, COUNT(*) FROM document GROUP BY IfcType" |
| |
|
| | |
| | with self.assertRaises(ArchSql.SqlEngineError) as cm: |
| | Arch.select(query) |
| |
|
| | |
| | self.assertIn( |
| | "must appear in the GROUP BY clause", |
| | str(cm.exception), |
| | "The validation error message is not descriptive enough.", |
| | ) |
| |
|
| | def test_non_grouped_sum_calculates_correctly(self): |
| | """ |
| | Tests the SUM() aggregate function without a GROUP BY clause in isolation. |
| | This test calls the SQL engine directly to ensure the summing logic is correct. |
| | """ |
| | |
| | |
| | query = "SELECT SUM(Height) FROM document WHERE IfcType = 'Wall'" |
| |
|
| | |
| | _, results_data = Arch.select(query) |
| |
|
| | |
| | |
| | self.assertEqual( |
| | len(results_data), 1, "A non-grouped aggregate query should return exactly one row." |
| | ) |
| |
|
| | |
| | actual_sum = float(results_data[0][0]) |
| | expected_sum = 5500.0 |
| | self.assertAlmostEqual( |
| | actual_sum, |
| | expected_sum, |
| | "The SUM() result is incorrect. The engine is not accumulating the values correctly.", |
| | ) |
| |
|
| | def test_non_grouped_query_with_mixed_extractors(self): |
| | """ |
| | Tests a non-grouped query with both a static value and a SUM() aggregate. |
| | """ |
| | query = "SELECT 'Total Height', SUM(Height) FROM document WHERE IfcType = 'Wall'" |
| |
|
| | |
| | _, results_data = Arch.select(query) |
| |
|
| | |
| | |
| | self.assertEqual( |
| | len(results_data), 1, "A non-grouped mixed query should return exactly one row." |
| | ) |
| |
|
| | |
| | |
| | self.assertEqual(results_data[0][0], "Total Height") |
| | |
| | actual_sum = float(results_data[0][1]) |
| | expected_sum = 5500.0 |
| | self.assertAlmostEqual( |
| | actual_sum, expected_sum, "The SUM() result in a mixed non-grouped query is incorrect." |
| | ) |
| |
|
| | def test_sum_of_space_area_is_correct_and_returns_float(self): |
| | """ |
| | Tests that SUM() on the 'Area' property of Arch.Space objects |
| | returns the correct numerical sum as a float. |
| | """ |
| | |
| |
|
| | |
| | base_box1 = self.doc.addObject("Part::Box", "BaseBox1") |
| | base_box1.Length = 1000 |
| | base_box1.Width = 2000 |
| | _ = Arch.makeSpace(base_box1, name="Office") |
| |
|
| | |
| | base_box2 = self.doc.addObject("Part::Box", "BaseBox2") |
| | base_box2.Length = 3000 |
| | base_box2.Width = 1500 |
| | _ = Arch.makeSpace(base_box2, name="Workshop") |
| |
|
| | self.doc.recompute() |
| |
|
| | query = "SELECT SUM(Area) FROM document WHERE IfcType = 'Space'" |
| |
|
| | |
| | _, results_data = Arch.select(query) |
| |
|
| | |
| | |
| | self.assertEqual( |
| | len(results_data), 1, "A non-grouped aggregate query should return exactly one row." |
| | ) |
| |
|
| | |
| | |
| | self.assertIsInstance(results_data[0][0], float, "The result of a SUM() should be a float.") |
| |
|
| | |
| | actual_sum = results_data[0][0] |
| | expected_sum = 6500000.0 |
| |
|
| | self.assertAlmostEqual( |
| | actual_sum, expected_sum, "The SUM(Area) for Space objects is incorrect." |
| | ) |
| |
|
| | def test_min_and_max_aggregates(self): |
| | """ |
| | Tests the MIN() and MAX() aggregate functions on a numeric property. |
| | """ |
| | |
| | |
| | |
| | query = "SELECT MIN(Length), MAX(Length) FROM document WHERE IfcType = 'Wall'" |
| |
|
| | _, results_data = Arch.select(query) |
| |
|
| | self.assertEqual(len(results_data), 1, "Aggregate query should return a single row.") |
| | self.assertIsInstance(results_data[0][0], float, "MIN() should return a float.") |
| | self.assertIsInstance(results_data[0][1], float, "MAX() should return a float.") |
| |
|
| | min_length = results_data[0][0] |
| | max_length = results_data[0][1] |
| |
|
| | self.assertAlmostEqual(min_length, 500.0) |
| | self.assertAlmostEqual(max_length, 1000.0) |
| |
|
| | def test_count_property_vs_count_star(self): |
| | """ |
| | Tests that COUNT(property) correctly counts only non-null values, |
| | while COUNT(*) counts all rows. |
| | """ |
| | |
| | |
| | |
| | unique_prop_name = "TestSpecificTag" |
| |
|
| | |
| | self.wall_ext.addProperty("App::PropertyString", unique_prop_name, "BIM") |
| | setattr(self.wall_ext, unique_prop_name, "Exterior") |
| |
|
| | self.column.addProperty("App::PropertyString", unique_prop_name, "BIM") |
| | setattr(self.column, unique_prop_name, "Structural") |
| |
|
| | self.doc.recompute() |
| |
|
| | |
| | |
| | query_count_prop = f"SELECT COUNT({unique_prop_name}) FROM document" |
| | headers_prop, results_prop = Arch.select(query_count_prop) |
| | self.assertEqual( |
| | int(results_prop[0][0]), |
| | 2, |
| | f"COUNT({unique_prop_name}) should count exactly the 2 objects where the property was added.", |
| | ) |
| |
|
| | |
| | |
| | |
| | |
| | labels_to_count = [ |
| | self.wall_ext.Label, |
| | self.wall_int.Label, |
| | self.column.Label, |
| | self.beam.Label, |
| | self.window.Label, |
| | self.part_box.Label, |
| | ] |
| |
|
| | |
| | where_conditions = " OR ".join([f"Label = '{label}'" for label in labels_to_count]) |
| | query_count_star = f"SELECT COUNT(*) FROM document WHERE {where_conditions}" |
| |
|
| | headers_star, results_star = Arch.select(query_count_star) |
| | self.assertEqual(int(results_star[0][0]), 6, "COUNT(*) should count all 6 test objects.") |
| |
|
| | def test_bundled_report_templates_are_valid(self): |
| | """ |
| | Performs an integration test to ensure all bundled report templates |
| | can be parsed and executed without errors against a sample model. |
| | """ |
| | |
| | report_presets = ArchReport._get_presets("report") |
| | self.assertGreater( |
| | len(report_presets), |
| | 0, |
| | "No bundled report templates were found. Check CMakeLists.txt and file paths.", |
| | ) |
| |
|
| | |
| | loaded_template_names = {preset["name"] for preset in report_presets.values()} |
| | self.assertIn("Room and Area Schedule", loaded_template_names) |
| | self.assertIn("Wall Quantities", loaded_template_names) |
| |
|
| | |
| | for filename, preset in report_presets.items(): |
| | |
| | if preset.get("is_user"): |
| | continue |
| |
|
| | template_name = preset["name"] |
| | statements = preset["data"].get("statements", []) |
| | self.assertGreater( |
| | len(statements), 0, f"Template '{template_name}' contains no statements." |
| | ) |
| |
|
| | for i, statement_data in enumerate(statements): |
| | query = statement_data.get("query_string") |
| | self.assertIsNotNone( |
| | query, f"Statement {i} in '{template_name}' is missing a 'query_string'." |
| | ) |
| |
|
| | with self.subTest(template=template_name, statement_index=i): |
| | |
| | try: |
| | headers, _ = Arch.select(query) |
| | self.assertIsInstance(headers, list) |
| | except Exception as e: |
| | self.fail( |
| | f"Query '{query}' from template '{template_name}' (file: {filename}) failed with an exception: {e}" |
| | ) |
| |
|
| | def test_bundled_query_presets_are_valid(self): |
| | """ |
| | Performs an integration test to ensure all bundled single-query presets |
| | are syntactically valid and executable. |
| | """ |
| | |
| | query_presets = ArchReport._get_presets("query") |
| | self.assertGreater( |
| | len(query_presets), |
| | 0, |
| | "No bundled query presets were found. Check CMakeLists.txt and file paths.", |
| | ) |
| |
|
| | |
| | loaded_preset_names = {preset["name"] for preset in query_presets.values()} |
| | self.assertIn("All Walls", loaded_preset_names) |
| | self.assertIn("Count by IfcType", loaded_preset_names) |
| |
|
| | |
| | for filename, preset in query_presets.items(): |
| | |
| | if preset.get("is_user"): |
| | continue |
| |
|
| | preset_name = preset["name"] |
| | query = preset["data"].get("query") |
| | self.assertIsNotNone(query, f"Preset '{preset_name}' is missing a 'query'.") |
| |
|
| | with self.subTest(preset=preset_name): |
| | |
| | try: |
| | headers, _ = Arch.select(query) |
| | self.assertIsInstance(headers, list) |
| | except Exception as e: |
| | self.fail( |
| | f"Query '{query}' from preset '{preset_name}' (file: {filename}) failed with an exception: {e}" |
| | ) |
| |
|
| | def test_where_in_clause(self): |
| | """ |
| | Tests the SQL 'IN' clause for filtering against a list of values. |
| | """ |
| | |
| | query = "SELECT * FROM document WHERE Label IN ('Exterior Wall', 'Interior partition wall')" |
| |
|
| | |
| | _, results_data = Arch.select(query) |
| |
|
| | |
| | |
| | self.assertEqual( |
| | len(results_data), 2, "The IN clause should have found exactly two matching objects." |
| | ) |
| |
|
| | |
| | returned_labels = sorted([row[0] for row in results_data]) |
| | expected_labels = sorted([self.wall_ext.Label, self.wall_int.Label]) |
| | self.assertListEqual( |
| | returned_labels, expected_labels, "The objects returned by the IN clause are incorrect." |
| | ) |
| |
|
| | def test_type_function(self): |
| | """ |
| | Tests the custom TYPE() function to ensure it returns the correct |
| | programmatic class name for both simple and proxy-based objects. |
| | """ |
| | |
| | |
| | query = "SELECT TYPE(*) FROM document WHERE Name IN ('Generic_Box', 'Wall')" |
| |
|
| | _, results_data = Arch.select(query) |
| |
|
| | |
| | |
| | self.assertEqual(len(results_data), 2, "Query should have found the two target objects.") |
| |
|
| | |
| | |
| | type_names = sorted([row[0] for row in results_data]) |
| |
|
| | |
| | |
| | self.assertIn("Part::Box", type_names, "TYPE() failed to identify the Part::Box.") |
| |
|
| | |
| | |
| | self.assertIn("Wall", type_names, "TYPE() failed to identify the ArchWall proxy class.") |
| |
|
| | def test_children_function(self): |
| | """ |
| | Tests the unified CHILDREN() function for both direct containment (.Group) |
| | and hosting relationships (.Hosts), including traversal of generic groups. |
| | """ |
| |
|
| | |
| | |
| | floor = Arch.makeBuildingPart(name="Ground Floor") |
| | |
| | floor.IfcType = "Building Storey" |
| |
|
| | |
| | host_wall = Arch.makeWall(name="Host Wall For Window") |
| |
|
| | |
| | space1 = Arch.makeSpace(name="Living Room") |
| | space2 = Arch.makeSpace(name="Kitchen") |
| | win_profile = Draft.makeRectangle(length=1000, height=1200) |
| | window = Arch.makeWindow(baseobj=win_profile, name="Living Room Window") |
| |
|
| | |
| | group = self.doc.addObject("App::DocumentObjectGroup", "Room Group") |
| |
|
| | |
| | floor.addObject(space1) |
| | floor.addObject(group) |
| | group.addObject(space2) |
| | Arch.addComponents(window, host=host_wall) |
| | |
| | self.doc.recompute() |
| |
|
| | |
| | with self.subTest(description="Direct containment with group traversal"): |
| | query = ( |
| | f"SELECT Label FROM CHILDREN(SELECT * FROM document WHERE Label = '{floor.Label}')" |
| | ) |
| | _, results = Arch.select(query) |
| |
|
| | returned_labels = sorted([row[0] for row in results]) |
| | |
| | |
| | expected_labels = sorted([space1.Label, space2.Label]) |
| | self.assertListEqual(returned_labels, expected_labels) |
| |
|
| | |
| | with self.subTest(description="Hosting relationship"): |
| | query = f"SELECT Label FROM CHILDREN(SELECT * FROM document WHERE Label = '{host_wall.Label}')" |
| | _, results = Arch.select(query) |
| |
|
| | self.assertEqual(len(results), 1) |
| | self.assertEqual(results[0][0], window.Label) |
| |
|
| | def test_order_by_label_desc(self): |
| | """Tests the ORDER BY clause to sort results alphabetically.""" |
| | query = "SELECT Label FROM document WHERE IfcType = 'Wall' ORDER BY Label DESC" |
| | _, results_data = Arch.select(query) |
| |
|
| | |
| | self.assertEqual(len(results_data), 2) |
| | returned_labels = [row[0] for row in results_data] |
| |
|
| | |
| | |
| | expected_order = sorted([self.wall_ext.Label, self.wall_int.Label], reverse=True) |
| |
|
| | self.assertListEqual( |
| | returned_labels, |
| | expected_order, |
| | "The results were not sorted by Label in descending order.", |
| | ) |
| |
|
| | def test_column_aliasing(self): |
| | """Tests renaming columns using the AS keyword.""" |
| | |
| | query = "SELECT Label AS 'Wall Name' FROM document WHERE IfcType = 'Wall' ORDER BY 'Wall Name' ASC" |
| | headers, results_data = Arch.select(query) |
| |
|
| | |
| | self.assertEqual(headers, ["Wall Name"]) |
| |
|
| | |
| | self.assertEqual(len(results_data), 2) |
| | returned_labels = [row[0] for row in results_data] |
| | |
| | expected_labels = sorted([self.wall_ext.Label, self.wall_int.Label]) |
| | self.assertListEqual(returned_labels, expected_labels) |
| |
|
| | def test_string_functions(self): |
| | """Tests the CONCAT, LOWER, and UPPER string functions.""" |
| | |
| | target_obj_name = self.column.Name |
| | target_obj_label = self.column.Label |
| | target_obj_ifctype = self.column.IfcType |
| |
|
| | with self.subTest(description="LOWER function"): |
| | query = f"SELECT LOWER(Label) FROM document WHERE Name = '{target_obj_name}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual(len(data), 1) |
| | self.assertEqual(data[0][0], target_obj_label.lower()) |
| |
|
| | with self.subTest(description="UPPER function"): |
| | query = f"SELECT UPPER(Label) FROM document WHERE Name = '{target_obj_name}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual(len(data), 1) |
| | self.assertEqual(data[0][0], target_obj_label.upper()) |
| |
|
| | with self.subTest(description="CONCAT function with properties and literals"): |
| | query = f"SELECT CONCAT(Label, ': ', IfcType) FROM document WHERE Name = '{target_obj_name}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual(len(data), 1) |
| | expected_string = f"{target_obj_label}: {target_obj_ifctype}" |
| | self.assertEqual(data[0][0], expected_string) |
| |
|
| | def test_meaningful_error_on_transformer_failure(self): |
| | """ |
| | Tests that a low-level VisitError from the transformer is converted |
| | into a high-level, user-friendly BimSqlSyntaxError. |
| | """ |
| | |
| | |
| | query = "SELECT TYPE(Label) FROM document" |
| |
|
| | with self.assertRaises(ArchSql.BimSqlSyntaxError) as cm: |
| | Arch.select(query) |
| |
|
| | |
| | |
| | |
| | error_message = str(cm.exception) |
| | self.assertIn("Transformer Error", error_message) |
| | self.assertIn("Failed to process rule 'function'", error_message) |
| | self.assertIn("requires exactly one argument: '*'", error_message) |
| |
|
| | def test_get_sql_keywords(self): |
| | """Tests the public API for retrieving all SQL keywords.""" |
| | keywords = Arch.getSqlKeywords() |
| | self.assertIsInstance(keywords, list, "get_sql_keywords should return a list.") |
| | self.assertGreater(len(keywords), 10, "Should be a significant number of keywords.") |
| |
|
| | |
| | self.assertIn("SELECT", keywords) |
| | self.assertIn("FROM", keywords) |
| | self.assertIn("WHERE", keywords) |
| | self.assertIn("ORDER", keywords, "The ORDER keyword should be present.") |
| | self.assertIn("BY", keywords, "The BY keyword should be present.") |
| | self.assertIn("AS", keywords) |
| | self.assertIn("COUNT", keywords, "Function names should be included as keywords.") |
| |
|
| | |
| | self.assertNotIn("WS", keywords, "Whitespace token should be filtered out.") |
| | self.assertNotIn("RPAR", keywords, "Punctuation tokens should be filtered out.") |
| | self.assertNotIn("CNAME", keywords, "Regex-based tokens should be filtered out.") |
| |
|
| | def test_function_in_where_clause(self): |
| | """Tests using a scalar function (LOWER) in the WHERE clause.""" |
| | |
| | query = f"SELECT Label FROM document WHERE LOWER(Label) = 'main column'" |
| | _, results_data = Arch.select(query) |
| |
|
| | self.assertEqual(len(results_data), 1, "Should find exactly one object.") |
| | self.assertEqual(results_data[0][0], self.column.Label, "Did not find the correct object.") |
| |
|
| | |
| | error_query = "SELECT Label FROM document WHERE COUNT(*) > 1" |
| |
|
| | |
| | with self.assertRaises(Arch.SqlEngineError) as cm: |
| | Arch.select(error_query) |
| | self.assertIn( |
| | "Aggregate functions (like COUNT, SUM) cannot be used in a WHERE clause", |
| | str(cm.exception), |
| | ) |
| |
|
| | |
| | count, error_str = Arch.count(error_query) |
| | self.assertEqual(count, -1) |
| | self.assertIn("Aggregate functions", error_str) |
| |
|
| | def test_null_as_operand(self): |
| | """Tests using NULL as a direct operand in a comparison like '= NULL'.""" |
| | |
| | |
| | |
| | |
| | query = "SELECT * FROM document WHERE IfcType = NULL" |
| | _, results_data = Arch.select(query) |
| | self.assertEqual( |
| | len(results_data), 0, "Comparing a column to NULL with '=' should return no rows." |
| | ) |
| |
|
| | def test_arithmetic_in_select_clause(self): |
| | """Tests arithmetic operations in the SELECT clause.""" |
| | |
| | target_name = self.wall_ext.Name |
| |
|
| | with self.subTest(description="Simple multiplication with Quantity"): |
| | |
| | query = f"SELECT Length * 2 FROM document WHERE Name = '{target_name}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual(len(data), 1) |
| | self.assertAlmostEqual(data[0][0], 2000.0) |
| |
|
| | with self.subTest(description="Operator precedence"): |
| | |
| | query = f"SELECT 100 + Length * 2 FROM document WHERE Name = '{target_name}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual(len(data), 1) |
| | self.assertAlmostEqual(data[0][0], 2100.0) |
| |
|
| | with self.subTest(description="Parentheses overriding precedence"): |
| | |
| | query = f"SELECT (100 + Length) * 2 FROM document WHERE Name = '{target_name}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual(len(data), 1) |
| | self.assertAlmostEqual(data[0][0], 2200.0) |
| |
|
| | with self.subTest(description="Arithmetic with unitless float property"): |
| | |
| | |
| | query = f"SELECT Shape.Volume / 1000000 FROM document WHERE Name = '{target_name}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual(len(data), 1) |
| | self.assertAlmostEqual(data[0][0], 600.0) |
| |
|
| | def test_convert_function(self): |
| | """Tests the CONVERT(value, 'unit') function.""" |
| | |
| | target_name = self.wall_ext.Name |
| |
|
| | |
| | |
| | query = f"SELECT CONVERT(Length, 'm') FROM document WHERE Name = '{target_name}'" |
| | _, data = Arch.select(query) |
| |
|
| | self.assertEqual(len(data), 1, "The query should return exactly one row.") |
| | self.assertEqual(len(data[0]), 1, "The row should contain exactly one column.") |
| | self.assertIsInstance(data[0][0], float, "The result of CONVERT should be a float.") |
| | self.assertAlmostEqual(data[0][0], 1.0, msg="1000mm should be converted to 1.0m.") |
| |
|
| | |
| | |
| | |
| | error_query = f"SELECT CONVERT(Length, 'kg') FROM document WHERE Name = '{target_name}'" |
| |
|
| | |
| | with self.assertRaises(Arch.SqlEngineError) as cm: |
| | Arch.select(error_query) |
| | self.assertIn("Unit conversion failed", str(cm.exception)) |
| |
|
| | |
| | count, error_str = Arch.count(error_query) |
| | self.assertEqual(count, -1) |
| | self.assertIsInstance(error_str, str) |
| | self.assertIn("Unit conversion failed", error_str) |
| |
|
| | def test_get_sql_api_documentation(self): |
| | """Tests the data structure returned by the SQL documentation API.""" |
| | api_data = Arch.getSqlApiDocumentation() |
| |
|
| | self.assertIsInstance(api_data, dict) |
| | self.assertIn("clauses", api_data) |
| | self.assertIn("functions", api_data) |
| |
|
| | |
| | self.assertIn("SELECT", api_data["clauses"]) |
| | self.assertIn("Aggregate", api_data["functions"]) |
| |
|
| | |
| | count_func = next( |
| | (f for f in api_data["functions"]["Aggregate"] if f["name"] == "COUNT"), None |
| | ) |
| | self.assertIsNotNone(count_func) |
| | self.assertIn("description", count_func) |
| | self.assertIn("snippet", count_func) |
| | self.assertGreater(len(count_func["description"]), 0) |
| |
|
| | |
| |
|
| | def test_count_with_group_by_is_correct_and_fast(self): |
| | """ |
| | Ensures that Arch.count() on a GROUP BY query returns the number of |
| | final groups (output rows), not the number of input objects. |
| | This validates the performance refactoring. |
| | """ |
| | |
| | |
| | query = "SELECT IfcType, COUNT(*) FROM document WHERE IfcType IS NOT NULL GROUP BY IfcType" |
| |
|
| | |
| | count, error = Arch.count(query) |
| |
|
| | self.assertIsNone(error, "The query should be valid.") |
| | self.assertEqual( |
| | count, 4, "Count should return the number of groups, not the number of objects." |
| | ) |
| |
|
| | def test_sql_comment_support(self): |
| | """Tests that single-line and multi-line SQL comments are correctly ignored.""" |
| |
|
| | with self.subTest(description="Single-line comments with --"): |
| | |
| | |
| | query = """ |
| | SELECT Label -- Select the object's label |
| | FROM document |
| | WHERE IfcType = 'Wall' -- Only select walls |
| | -- ORDER BY Label DESC |
| | """ |
| | _, data = Arch.select(query) |
| |
|
| | |
| | self.assertEqual(len(data), 2, "Should find the two wall objects.") |
| | |
| | found_labels = {row[0] for row in data} |
| | expected_labels = {self.wall_ext.Label, self.wall_int.Label} |
| | self.assertSetEqual(found_labels, expected_labels) |
| |
|
| | with self.subTest(description="Multi-line comments with /* ... */"): |
| | |
| | query = """ |
| | SELECT Label |
| | FROM document |
| | /* |
| | WHERE IfcType = 'Wall' |
| | ORDER BY Label |
| | */ |
| | """ |
| | _, data = Arch.select(query) |
| | |
| | |
| | |
| | |
| | self.assertEqual(len(data), len(self.doc.Objects)) |
| |
|
| | def test_query_with_non_ascii_property_name(self): |
| | """ |
| | Tests that the SQL engine can correctly handle non-ASCII (Unicode) |
| | characters in property names, which is crucial for international users. |
| | """ |
| | |
| | |
| | |
| | prop_name_unicode = "Fläche" |
| | self.column.addProperty("App::PropertyFloat", prop_name_unicode, "BIM") |
| | setattr(self.column, prop_name_unicode, 42.5) |
| | self.doc.recompute() |
| |
|
| | |
| | |
| | query = f"SELECT {prop_name_unicode} FROM document WHERE Name = '{self.column.Name}'" |
| |
|
| | |
| | |
| | |
| | try: |
| | headers, results_data = Arch.select(query) |
| | |
| | self.assertEqual( |
| | len(results_data), 1, "The query should find the single target object." |
| | ) |
| | self.assertEqual(headers, [prop_name_unicode]) |
| | self.assertAlmostEqual(results_data[0][0], 42.5) |
| |
|
| | except Arch.BimSqlSyntaxError as e: |
| | |
| | |
| | |
| | self.fail(f"Parser failed to handle Unicode identifier. Error: {e}") |
| |
|
| | def test_order_by_multiple_columns(self): |
| | """Tests sorting by multiple columns in the ORDER BY clause.""" |
| | |
| | |
| | |
| | query = """ |
| | SELECT Label, IfcType |
| | FROM document |
| | WHERE IfcType IN ('Wall', 'Column', 'Beam') |
| | ORDER BY IfcType, Label ASC |
| | """ |
| | _, data = Arch.select(query) |
| |
|
| | self.assertEqual(len(data), 4, "Should find the two walls, one column, and one beam.") |
| |
|
| | |
| | |
| | |
| | expected_order = [ |
| | [self.beam.Label, self.beam.IfcType], |
| | [self.column.Label, self.column.IfcType], |
| | [self.wall_ext.Label, self.wall_ext.IfcType], |
| | [self.wall_int.Label, self.wall_int.IfcType], |
| | ] |
| |
|
| | |
| | expected_order = sorted(expected_order, key=lambda x: (x[1], x[0])) |
| |
|
| | self.assertListEqual(data, expected_order) |
| |
|
| | def test_parent_function_and_chaining(self): |
| | """ |
| | Tests the PARENT(*) function with simple and chained calls, |
| | and verifies the logic for transparently skipping generic groups. |
| | """ |
| | |
| | site = Arch.makeSite(name="Test Site") |
| | building = Arch.makeBuilding(name="Test Building") |
| | floor = Arch.makeFloor(name="Test Floor") |
| | wall = Arch.makeWall(name="Test Wall") |
| | win_profile = Draft.makeRectangle(1000, 1000) |
| | window = Arch.makeWindow(win_profile, name="Test Window") |
| |
|
| | generic_group = self.doc.addObject("App::DocumentObjectGroup", "Test Generic Group") |
| | space_profile = Draft.makeRectangle(2000, 2000) |
| | space = Arch.makeSpace(space_profile, name="Test Space") |
| |
|
| | site.addObject(building) |
| | building.addObject(floor) |
| | floor.addObject(wall) |
| | floor.addObject(generic_group) |
| | generic_group.addObject(space) |
| | Arch.addComponents(window, wall) |
| | self.doc.recompute() |
| |
|
| | |
| |
|
| | |
| | |
| | with self.subTest(description="Skipping generic group"): |
| | query = f"SELECT PARENT(*).Label FROM document WHERE Label = '{space.Label}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual( |
| | data[0][0], floor.Label, "PARENT(Space) should skip the group and return the Floor." |
| | ) |
| |
|
| | |
| | |
| | with self.subTest(description="Chained PARENT of Wall"): |
| | query = f"SELECT PARENT(*).PARENT(*).Label FROM document WHERE Label = '{wall.Label}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual(data[0][0], building.Label) |
| |
|
| | |
| | |
| | with self.subTest(description="Chained PARENT of Window"): |
| | query = f"SELECT PARENT(*).PARENT(*).PARENT(*).Label FROM document WHERE Label = '{window.Label}'" |
| | _, data = Arch.select(query) |
| | self.assertEqual(data[0][0], building.Label) |
| |
|
| | |
| | |
| | |
| | with self.subTest(description="Filtering by logical grandparent"): |
| | query = ( |
| | f"SELECT Label FROM document WHERE PARENT(*).PARENT(*).Label = '{building.Label}'" |
| | ) |
| | _, data = Arch.select(query) |
| |
|
| | found_labels = sorted([row[0] for row in data]) |
| | expected_labels = sorted( |
| | [space.Label, wall.Label, generic_group.Label] |
| | ) |
| | self.assertListEqual( |
| | found_labels, |
| | expected_labels, |
| | "Query did not find all objects with the correct logical grandparent.", |
| | ) |
| |
|
| | def test_ppa_and_query_permutations(self): |
| | """ |
| | Runs a suite of integration tests against a complex model to |
| | validate Pythonic Property Access and other query features. |
| | """ |
| | |
| | |
| | model = create_test_model(self.document) |
| |
|
| | |
| | ground_floor = model["ground_floor"] |
| | upper_floor = model["upper_floor"] |
| | front_door = model["front_door"] |
| | living_window = model["living_window"] |
| | office_space = model["office_space"] |
| | living_space = model["living_space"] |
| | interior_wall = model["interior_wall"] |
| | exterior_wall = model["exterior_wall"] |
| |
|
| | |
| |
|
| | |
| | with self.subTest(description="PPA in SELECT clause"): |
| | query = ( |
| | f"SELECT PARENT(*).PARENT(*).Label FROM document WHERE Label = '{front_door.Label}'" |
| | ) |
| | _, data = Arch.select(query) |
| | self.assertEqual( |
| | data[0][0], ground_floor.Label, "Grandparent of Front Door should be Ground Floor" |
| | ) |
| |
|
| | |
| | with self.subTest(description="PPA in WHERE clause"): |
| | query = f"SELECT Label FROM document WHERE PARENT(*).PARENT(*).Label = '{ground_floor.Label}'" |
| | _, data = Arch.select(query) |
| | found_labels = sorted([row[0] for row in data]) |
| | expected_labels = sorted([front_door.Label, living_window.Label]) |
| | self.assertListEqual( |
| | found_labels, expected_labels, "Should find the Door and Window on the Ground Floor" |
| | ) |
| |
|
| | |
| | with self.subTest(description="PPA in ORDER BY clause"): |
| | |
| | upper_box = self.document.addObject("Part::Box", "UpperSpaceVolume") |
| | upper_box.Length, upper_box.Width, upper_box.Height = 1000.0, 1000.0, 3000.0 |
| |
|
| | upper_space = Arch.makeSpace(baseobj=upper_box, name="Upper Space") |
| | upper_floor.addObject(upper_space) |
| | self.document.recompute() |
| |
|
| | |
| | |
| | query = f"SELECT Label, PARENT(*).Label AS ParentLabel FROM document WHERE IfcType = 'Space' ORDER BY ParentLabel DESC" |
| | _, data = Arch.select(query) |
| |
|
| | |
| |
|
| | |
| | |
| | parent_label_of_first_result = data[0][1] |
| | self.assertEqual( |
| | parent_label_of_first_result, |
| | upper_floor.Label, |
| | "The first item in the sorted list should belong to the Upper Floor.", |
| | ) |
| |
|
| | |
| | with self.subTest(description="PPA with sub-property access"): |
| | |
| | query = f"SELECT Label FROM document WHERE PARENT(*).Placement.Base.z = 0.0 AND IfcType = 'Space'" |
| | _, data = Arch.select(query) |
| | found_labels = sorted([row[0] for row in data]) |
| | expected_labels = sorted([office_space.Label, living_space.Label]) |
| | self.assertListEqual( |
| | found_labels, |
| | expected_labels, |
| | "Should find spaces on the ground floor by parent's placement", |
| | ) |
| |
|
| | |
| |
|
| | with self.subTest(description="Permutation: GROUP BY on a PPA result"): |
| | query = "SELECT PARENT(*).Label AS FloorName, COUNT(*) FROM document WHERE IfcType = 'Space' GROUP BY PARENT(*).Label ORDER BY FloorName" |
| | _, data = Arch.select(query) |
| | |
| | self.assertEqual(len(data), 2) |
| | self.assertEqual(data[0][0], ground_floor.Label) |
| | self.assertEqual(data[0][1], 2) |
| | self.assertEqual(data[1][0], upper_floor.Label) |
| | self.assertEqual(data[1][1], 1) |
| |
|
| | with self.subTest(description="Permutation: GROUP BY on a Function result"): |
| | query = "SELECT TYPE(*) AS BimType, COUNT(*) FROM document WHERE IfcType IS NOT NULL GROUP BY TYPE(*) ORDER BY BimType" |
| | _, data = Arch.select(query) |
| | results_dict = {row[0]: row[1] for row in data} |
| | self.assertGreaterEqual(results_dict.get("Wall", 0), 2) |
| | self.assertGreaterEqual(results_dict.get("Space", 0), 2) |
| |
|
| | with self.subTest(description="Permutation: Complex WHERE with PPA and Functions"): |
| | query = f"SELECT Label FROM document WHERE TYPE(*) = 'Wall' AND LOWER(PARENT(*).Label) = 'ground floor' AND FireRating IS NOT NULL" |
| | _, data = Arch.select(query) |
| | self.assertEqual(len(data), 1) |
| | self.assertEqual(data[0][0], exterior_wall.Label) |
| |
|
| | with self.subTest(description="Permutation: Filtering by a custom property on a parent"): |
| | query = "SELECT Label FROM document WHERE PARENT(*).FireRating = '60 minutes' AND IfcType IN ('Door', 'Window')" |
| | _, data = Arch.select(query) |
| | found_labels = sorted([row[0] for row in data]) |
| | expected_labels = sorted([front_door.Label, living_window.Label]) |
| | self.assertListEqual(found_labels, expected_labels) |
| |
|
| | with self.subTest(description="Permutation: Arithmetic with parent properties"): |
| | |
| | query = ( |
| | f"SELECT Label FROM document WHERE TYPE(*) = 'Wall' AND Height < PARENT(*).Height" |
| | ) |
| | _, data = Arch.select(query) |
| | self.assertEqual(len(data), 1) |
| | self.assertEqual(data[0][0], interior_wall.Label) |
| |
|
| | def test_group_by_with_function_and_count(self): |
| | """ |
| | Tests that GROUP BY correctly partitions results based on a function (TYPE) |
| | and aggregates them with another function (COUNT). This is the canonical |
| | non-regression test for the core GROUP BY functionality. |
| | """ |
| | |
| | |
| | doc = self.document |
| | Arch.makeWall(name="Unit Test Wall 1") |
| | Arch.makeWall(name="Unit Test Wall 2") |
| | Arch.makeSpace(baseobj=doc.addObject("Part::Box"), name="Unit Test Space") |
| | doc.recompute() |
| |
|
| | |
| | query = "SELECT TYPE(*) AS BimType, COUNT(*) FROM document WHERE Label LIKE 'Unit Test %' AND IfcType IS NOT NULL GROUP BY TYPE(*)" |
| | _, data = Arch.select(query) |
| | engine_results_dict = {row[0]: row[1] for row in data} |
| |
|
| | |
| | |
| | expected_counts = { |
| | "Wall": 2, |
| | "Space": 1, |
| | } |
| |
|
| | |
| | |
| | self.assertDictContainsSubset(expected_counts, engine_results_dict) |
| |
|
| | def test_group_by_chained_parent_function(self): |
| | """ |
| | Tests GROUP BY on a complex expression involving a chained function |
| | call (PPA), ensuring the engine's signature generation and grouping |
| | logic can handle nested extractors. |
| | """ |
| | |
| | model = create_test_model(self.document) |
| | ground_floor = model["ground_floor"] |
| | upper_floor = model["upper_floor"] |
| |
|
| | |
| | upper_box = self.document.addObject("Part::Box", "UpperSpaceVolume2") |
| | upper_box.Length, upper_box.Width, upper_box.Height = 1000.0, 1000.0, 3000.0 |
| | upper_space2 = Arch.makeSpace(baseobj=upper_box, name="Upper Space 2") |
| | upper_floor.addObject(upper_space2) |
| | self.document.recompute() |
| |
|
| | |
| | query = """ |
| | SELECT PARENT(*).PARENT(*).Label AS FloorName, COUNT(*) |
| | FROM document |
| | WHERE IfcType IN ('Door', 'Window') |
| | GROUP BY PARENT(*).PARENT(*).Label |
| | """ |
| | _, data = Arch.select(query) |
| | results_dict = {row[0]: row[1] for row in data} |
| |
|
| | |
| | self.assertEqual(results_dict.get(ground_floor.Label), 2) |
| |
|
| | def test_group_by_multiple_mixed_columns(self): |
| | """ |
| | Tests GROUP BY with multiple columns of different types (a property |
| | and a function result) to verify multi-part key generation. |
| | """ |
| | |
| | Arch.makeStructure(length=300, width=330, height=2500, name="Second Column") |
| | self.document.recompute() |
| |
|
| | |
| | query = "SELECT IfcType, TYPE(*), COUNT(*) FROM document GROUP BY IfcType, TYPE(*)" |
| | _, data = Arch.select(query) |
| |
|
| | |
| | column_row = next((row for row in data if row[0] == "Column" and row[1] == "Column"), None) |
| | self.assertIsNotNone(column_row, "A group for (Column, Column) should exist.") |
| | self.assertEqual(column_row[2], 2, "The count for (Column, Column) should be 2.") |
| |
|
| | def test_invalid_group_by_with_aggregate_raises_error(self): |
| | """ |
| | Ensures the engine's validation correctly rejects an attempt to |
| | GROUP BY an aggregate function, which is invalid SQL. |
| | """ |
| | query = "SELECT IfcType, COUNT(*) FROM document GROUP BY COUNT(*)" |
| |
|
| | |
| | with self.assertRaisesRegex(ArchSql.SqlEngineError, "must appear in the GROUP BY clause"): |
| | Arch.select(query) |
| |
|
| | def test_where_clause_with_arithmetic(self): |
| | """ |
| | Tests that the WHERE clause can correctly filter rows based on an |
| | arithmetic calculation involving multiple properties. This verifies |
| | that the arithmetic engine is correctly integrated into the filtering |
| | logic. |
| | """ |
| | |
| | |
| | large_wall = Arch.makeWall(name="Unit Test Large Wall", length=1000, width=200) |
| | |
| | _ = Arch.makeWall(name="Unit Test Small Wall", length=500, width=200) |
| | self.document.recompute() |
| |
|
| | |
| | query = ( |
| | "SELECT Label FROM document WHERE Label LIKE 'Unit Test %' AND Length * Width > 150000" |
| | ) |
| | _, data = Arch.select(query) |
| | print(data) |
| |
|
| | |
| | self.assertEqual(len(data), 1, "The query should find exactly one matching wall.") |
| | self.assertEqual( |
| | data[0][0], f"{large_wall.Label}", "The found wall should be the large one." |
| | ) |
| |
|
| | def test_select_with_nested_functions(self): |
| | """ |
| | Tests the engine's ability to handle a function (CONCAT) whose |
| | arguments are a mix of properties, literals, and another function |
| | (TYPE). This is a stress test for the recursive expression evaluator |
| | and signature generator. |
| | """ |
| | |
| | Arch.makeWall(name="My Test Wall") |
| | self.document.recompute() |
| |
|
| | |
| | query = "SELECT CONCAT(Label, ' (Type: ', TYPE(*), ')') FROM document WHERE Label = 'My Test Wall'" |
| | _, data = Arch.select(query) |
| |
|
| | |
| | self.assertEqual(len(data), 1, "The query should have found the target object.") |
| | expected_string = "My Test Wall (Type: Wall)" |
| | self.assertEqual( |
| | data[0][0], |
| | expected_string, |
| | "The nested function expression was not evaluated correctly.", |
| | ) |
| |
|
| | def test_group_by_with_alias_is_not_supported(self): |
| | """ |
| | Tests that GROUP BY with a column alias is not supported, as per the |
| | dialect's known limitations. This test verifies that the engine's |
| | validation correctly rejects this syntax. |
| | """ |
| | |
| | Arch.makeWall(name="Test Wall For Alias") |
| | self.document.recompute() |
| |
|
| | |
| | query = "SELECT TYPE(*) AS BimType, COUNT(*) FROM document GROUP BY BimType" |
| |
|
| | |
| | |
| | |
| | with self.assertRaisesRegex(ArchSql.SqlEngineError, "must appear in the GROUP BY clause"): |
| | Arch.select(query) |
| |
|
| | def test_order_by_with_alias_is_supported(self): |
| | """ |
| | Tests the supported ORDER BY behavior: sorting by an alias of a |
| | function expression that is present in the SELECT list. |
| | """ |
| | |
| | Arch.makeWall(name="Wall_C") |
| | Arch.makeWall(name="wall_b") |
| | Arch.makeWall(name="WALL_A") |
| | self.document.recompute() |
| |
|
| | |
| | |
| | query = "SELECT Label, LOWER(Label) AS sort_key FROM document WHERE Label LIKE 'Wall_%' ORDER BY sort_key ASC" |
| | _, data = Arch.select(query) |
| |
|
| | |
| | sorted_labels = [row[0] for row in data] |
| |
|
| | |
| | expected_order = ["WALL_A", "wall_b", "Wall_C"] |
| | self.assertListEqual(sorted_labels, expected_order) |
| |
|
| | def test_order_by_with_raw_expression_is_not_supported(self): |
| | """ |
| | Tests the unsupported ORDER BY behavior, documenting that the engine |
| | correctly rejects a query that tries to sort by a raw expression |
| | not present in the SELECT list. |
| | """ |
| | |
| | Arch.makeWall(name="Test Wall") |
| | self.document.recompute() |
| |
|
| | |
| | query = "SELECT Label FROM document ORDER BY LOWER(Label) ASC" |
| |
|
| | |
| | |
| | with self.assertRaisesRegex( |
| | ArchSql.SqlEngineError, "ORDER BY expressions are not supported directly" |
| | ): |
| | Arch.select(query) |
| |
|
| | def test_core_engine_enhancements_for_pipeline(self): |
| | """ |
| | Tests the Stage 1 enhancements to the internal SQL engine. |
| | This test validates both regression (ensuring old functions still work) |
| | and the new ability to query against a pre-filtered list of objects. |
| | """ |
| | |
| | |
| | |
| | pipeline_source_objects = [self.wall_ext, self.wall_int, self.window] |
| | pipeline_source_labels = sorted([o.Label for o in pipeline_source_objects]) |
| | self.assertEqual( |
| | len(pipeline_source_objects), |
| | 3, |
| | "Pre-condition failed: Source object list should have 3 items.", |
| | ) |
| |
|
| | |
| | |
| | |
| | |
| | with self.subTest(description="Regression test for Arch.select"): |
| | _, results_data = Arch.select('SELECT Label FROM document WHERE IfcType = "Wall"') |
| | found_labels = sorted([row[0] for row in results_data]) |
| | self.assertListEqual(found_labels, sorted([self.wall_ext.Label, self.wall_int.Label])) |
| |
|
| | with self.subTest(description="Regression test for Arch.count"): |
| | count, error = Arch.count('SELECT * FROM document WHERE IfcType = "Wall"') |
| | self.assertIsNone(error) |
| | self.assertEqual(count, 2) |
| |
|
| | |
| | |
| | with self.subTest(description="Test _run_query with a source_objects list"): |
| | |
| | query = "SELECT * FROM document" |
| |
|
| | |
| | _, data_rows, resulting_objects = ArchSql._run_query( |
| | query, mode="full_data", source_objects=pipeline_source_objects |
| | ) |
| |
|
| | |
| | |
| | self.assertEqual( |
| | len(data_rows), |
| | 3, |
| | "_run_query did not return the correct number of rows for the provided source.", |
| | ) |
| |
|
| | |
| | found_labels = sorted([row[0] for row in data_rows]) |
| | self.assertListEqual( |
| | found_labels, |
| | pipeline_source_labels, |
| | "The data returned does not match the source objects.", |
| | ) |
| |
|
| | |
| | self.assertEqual( |
| | len(resulting_objects), 3, "The returned object list has the wrong size." |
| | ) |
| | self.assertIsInstance( |
| | resulting_objects[0], |
| | FreeCAD.DocumentObject, |
| | "The resulting_objects list should contain DocumentObject instances.", |
| | ) |
| | resulting_object_labels = sorted([o.Label for o in resulting_objects]) |
| | self.assertListEqual( |
| | resulting_object_labels, |
| | pipeline_source_labels, |
| | "The list of resulting objects is incorrect.", |
| | ) |
| |
|
| | with self.subTest(description="Test _run_query with filtering on a source_objects list"): |
| | |
| | query = "SELECT Label FROM document WHERE IfcType = 'Wall'" |
| |
|
| | _, data_rows, resulting_objects = ArchSql._run_query( |
| | query, mode="full_data", source_objects=pipeline_source_objects |
| | ) |
| |
|
| | |
| | self.assertEqual(len(data_rows), 2, "Filtering on the source object list failed.") |
| | found_labels = sorted([row[0] for row in data_rows]) |
| | expected_labels = sorted([self.wall_ext.Label, self.wall_int.Label]) |
| | self.assertListEqual( |
| | found_labels, |
| | expected_labels, |
| | "The data returned after filtering the source is incorrect.", |
| | ) |
| | self.assertEqual( |
| | len(resulting_objects), |
| | 2, |
| | "The object list returned after filtering the source is incorrect.", |
| | ) |
| |
|
| | def test_execute_pipeline_orchestrator(self): |
| | """ |
| | Tests the new `execute_pipeline` orchestrator function in ArchSql. |
| | """ |
| |
|
| | |
| |
|
| | |
| | stmt1 = ArchSql.ReportStatement( |
| | query_string="SELECT * FROM document WHERE IfcType = 'Wall'", is_pipelined=False |
| | ) |
| |
|
| | |
| | stmt2 = ArchSql.ReportStatement( |
| | query_string="SELECT * FROM document WHERE Label LIKE '%Exterior%'", is_pipelined=True |
| | ) |
| |
|
| | |
| | stmt3 = ArchSql.ReportStatement( |
| | query_string="SELECT * FROM document WHERE IfcType = 'Column'", is_pipelined=False |
| | ) |
| |
|
| | |
| | stmt4_failing = ArchSql.ReportStatement( |
| | query_string="SELECT * FROM document WHERE IfcType = 'NonExistentType'", |
| | is_pipelined=False, |
| | ) |
| | stmt5_piped_from_fail = ArchSql.ReportStatement( |
| | query_string="SELECT * FROM document", is_pipelined=True |
| | ) |
| |
|
| | |
| |
|
| | with self.subTest(description="Test a simple two-step pipeline"): |
| | statements = [stmt1, stmt2] |
| | results_generator = ArchSql.execute_pipeline(statements) |
| |
|
| | |
| | output_list = list(results_generator) |
| | self.assertEqual( |
| | len(output_list), 1, "A simple pipeline should only yield one final result." |
| | ) |
| |
|
| | |
| | result_stmt, _, result_data = output_list[0] |
| | self.assertIs( |
| | result_stmt, stmt2, "The yielded statement should be the last one in the chain." |
| | ) |
| | self.assertEqual( |
| | len(result_data), 1, "The final pipeline result should contain one row." |
| | ) |
| | self.assertEqual( |
| | result_data[0][0], |
| | self.wall_ext.Label, |
| | "The final result is not the expected 'Exterior Wall'.", |
| | ) |
| |
|
| | with self.subTest(description="Test a mixed report with pipeline and standalone"): |
| | statements = [stmt1, stmt2, stmt3] |
| | results_generator = ArchSql.execute_pipeline(statements) |
| |
|
| | |
| | |
| | output_list = list(results_generator) |
| | self.assertEqual(len(output_list), 2, "A mixed report should yield two results.") |
| |
|
| | |
| | self.assertEqual(output_list[0][2][0][0], self.wall_ext.Label) |
| | |
| | self.assertEqual(output_list[1][2][0][0], self.column.Label) |
| |
|
| | with self.subTest(description="Test a pipeline that runs dry"): |
| | statements = [stmt4_failing, stmt5_piped_from_fail] |
| | results_generator = ArchSql.execute_pipeline(statements) |
| | output_list = list(results_generator) |
| |
|
| | |
| | |
| | self.assertEqual(len(output_list), 1) |
| |
|
| | |
| | result_stmt, _, result_data = output_list[0] |
| | self.assertIs(result_stmt, stmt5_piped_from_fail) |
| | self.assertEqual( |
| | len(result_data), 0, "The final pipelined statement should yield 0 rows." |
| | ) |
| |
|
| | def test_public_api_for_pipelines(self): |
| | """ |
| | Tests the new and enhanced public API functions for Stage 3. |
| | """ |
| | |
| | with self.subTest(description="Test Arch.count with a source_objects list"): |
| | |
| | source_list = [self.wall_ext, self.wall_int] |
| |
|
| | |
| | query = "SELECT * FROM document WHERE IfcType = 'Column'" |
| |
|
| | |
| | count, error = ArchSql.count(query, source_objects=source_list) |
| |
|
| | self.assertIsNone(error) |
| | |
| | self.assertEqual(count, 0, "Arch.count failed to respect the source_objects list.") |
| |
|
| | |
| | with self.subTest(description="Test Arch.selectObjectsFromPipeline"): |
| | |
| | stmt1 = ArchSql.ReportStatement( |
| | query_string="SELECT * FROM document WHERE IfcType = 'Wall'", is_pipelined=False |
| | ) |
| | stmt2 = ArchSql.ReportStatement( |
| | query_string="SELECT * FROM document WHERE Label LIKE '%Exterior%'", |
| | is_pipelined=True, |
| | ) |
| |
|
| | |
| | resulting_objects = Arch.selectObjectsFromPipeline([stmt1, stmt2]) |
| |
|
| | |
| | self.assertIsInstance(resulting_objects, list) |
| | self.assertEqual( |
| | len(resulting_objects), 1, "Pipeline should result in one final object." |
| | ) |
| | self.assertIsInstance(resulting_objects[0], FreeCAD.DocumentObject) |
| | self.assertEqual( |
| | resulting_objects[0].Name, |
| | self.wall_ext.Name, |
| | "The final object from the pipeline is incorrect.", |
| | ) |
| |
|
| | def test_pipeline_with_children_function(self): |
| | """ |
| | Tests that the CHILDREN function correctly uses the input from a |
| | previous pipeline step instead of running its own subquery. |
| | """ |
| | |
| | |
| | floor = Arch.makeFloor(name="Pipeline Test Floor") |
| | wall = Arch.makeWall(name="Wall on Test Floor") |
| | floor.addObject(wall) |
| |
|
| | |
| | _ = Arch.makeWall(name="Unrelated Distractor Wall") |
| | self.doc.recompute() |
| |
|
| | |
| | |
| | stmt1 = ArchReport.ReportStatement( |
| | query_string="SELECT * FROM document WHERE Label = 'Pipeline Test Floor'", |
| | is_pipelined=False, |
| | ) |
| |
|
| | |
| | stmt2 = ArchReport.ReportStatement( |
| | query_string="SELECT * FROM CHILDREN(SELECT * FROM document) WHERE IfcType = 'Wall'", |
| | is_pipelined=True, |
| | ) |
| |
|
| | |
| | |
| | resulting_objects = Arch.selectObjectsFromPipeline([stmt1, stmt2]) |
| |
|
| | |
| | |
| | |
| | self.assertEqual( |
| | len(resulting_objects), |
| | 1, |
| | "The pipeline should have resulted in exactly one child object.", |
| | ) |
| | self.assertEqual( |
| | resulting_objects[0].Name, wall.Name, "The object found via the pipeline is incorrect." |
| | ) |
| |
|
| | def test_group_by_with_function_and_literal_argument(self): |
| | """ |
| | Tests that a GROUP BY clause with a function that takes a literal |
| | string argument (e.g., CONVERT(Area, 'm^2')) does not crash the |
| | validation engine. This is the non-regression test for the TypeError |
| | found in the _get_extractor_signature method. |
| | """ |
| | |
| | |
| | base_box = self.doc.addObject("Part::Box", "BaseBoxForConvertTest") |
| | base_box.Length = 1000 |
| | base_box.Width = 1000 |
| | space = Arch.makeSpace(base_box, name="SpaceForGroupByConvertTest") |
| | self.doc.recompute() |
| |
|
| | |
| | query = """ |
| | SELECT |
| | Label, |
| | CONVERT(Area, 'm^2') |
| | FROM |
| | document |
| | WHERE |
| | Label = 'SpaceForGroupByConvertTest' |
| | GROUP BY |
| | Label, CONVERT(Area, 'm^2') |
| | """ |
| |
|
| | |
| | headers, results_data = Arch.select(query) |
| |
|
| | |
| | self.assertEqual(len(results_data), 1, "The query should return exactly one row.") |
| | self.assertEqual(headers, ["Label", "CONVERT(Area, 'm^2')"]) |
| |
|
| | |
| | self.assertEqual(results_data[0][0], space.Label) |
| | |
| | self.assertAlmostEqual(results_data[0][1], 1.0, msg="The converted area should be 1.0 m^2.") |
| |
|
| | def test_traverse_finds_all_descendants(self): |
| | """ |
| | Tests that the basic recursive traversal finds all nested objects in a |
| | simple hierarchy, following both containment (.Group) and hosting (.Hosts) |
| | relationships. This is the first validation step for the new core |
| | traversal function. |
| | """ |
| | |
| | floor = Arch.makeFloor(name="TraversalTestFloor") |
| | wall = Arch.makeWall(name="TraversalTestWall") |
| | win_profile = Draft.makeRectangle(1000, 1000) |
| | window = Arch.makeWindow(win_profile, name="TraversalTestWindow") |
| |
|
| | |
| | floor.addObject(wall) |
| | Arch.addComponents(window, host=wall) |
| | self.doc.recompute() |
| |
|
| | |
| | |
| | results = ArchSql._traverse_architectural_hierarchy([floor]) |
| | result_labels = sorted([obj.Label for obj in results]) |
| |
|
| | |
| | expected_labels = sorted(["TraversalTestFloor", "TraversalTestWall", "TraversalTestWindow"]) |
| |
|
| | self.assertEqual(len(results), 3, "The traversal should have found 3 objects.") |
| | self.assertListEqual( |
| | result_labels, |
| | expected_labels, |
| | "The list of discovered objects does not match the expected hierarchy.", |
| | ) |
| |
|
| | def test_traverse_skips_generic_groups_in_results(self): |
| | """ |
| | Tests that the traversal function transparently navigates through |
| | generic App::DocumentObjectGroup objects but does not include them |
| | in the final result set, ensuring the output is architecturally |
| | significant. |
| | """ |
| | |
| | |
| | floor = Arch.makeFloor(name="GroupTestFloor") |
| | group = self.doc.addObject("App::DocumentObjectGroup", "GenericTestGroup") |
| | space_profile = Draft.makeRectangle(500, 500) |
| | space = Arch.makeSpace(space_profile, name="GroupTestSpace") |
| |
|
| | |
| | floor.addObject(group) |
| | group.addObject(space) |
| | self.doc.recompute() |
| |
|
| | |
| | |
| | results = ArchSql._traverse_architectural_hierarchy([floor], include_groups_in_result=False) |
| | result_labels = sorted([obj.Label for obj in results]) |
| |
|
| | |
| | |
| | expected_labels = sorted(["GroupTestFloor", "GroupTestSpace"]) |
| |
|
| | self.assertEqual( |
| | len(results), 2, "The traversal should have found 2 objects (and skipped the group)." |
| | ) |
| | self.assertListEqual( |
| | result_labels, |
| | expected_labels, |
| | "The traversal incorrectly included the generic group in its results.", |
| | ) |
| |
|
| | def test_traverse_respects_max_depth(self): |
| | """ |
| | Tests that the `max_depth` parameter correctly limits the depth of the |
| | hierarchical traversal. |
| | """ |
| | |
| | floor = Arch.makeFloor(name="DepthTestFloor") |
| | wall = Arch.makeWall(name="DepthTestWall") |
| | win_profile = Draft.makeRectangle(1000, 1000) |
| | window = Arch.makeWindow(win_profile, name="DepthTestWindow") |
| |
|
| | floor.addObject(wall) |
| | Arch.addComponents(window, host=wall) |
| | self.doc.recompute() |
| |
|
| | |
| |
|
| | |
| | with self.subTest(depth=1): |
| | results_depth_1 = ArchSql._traverse_architectural_hierarchy([floor], max_depth=1) |
| | labels_depth_1 = sorted([o.Label for o in results_depth_1]) |
| | expected_labels_1 = sorted(["DepthTestFloor", "DepthTestWall"]) |
| | self.assertListEqual( |
| | labels_depth_1, |
| | expected_labels_1, |
| | "With max_depth=1, should only find direct children.", |
| | ) |
| |
|
| | |
| | with self.subTest(depth=2): |
| | results_depth_2 = ArchSql._traverse_architectural_hierarchy([floor], max_depth=2) |
| | labels_depth_2 = sorted([o.Label for o in results_depth_2]) |
| | expected_labels_2 = sorted(["DepthTestFloor", "DepthTestWall", "DepthTestWindow"]) |
| | self.assertListEqual( |
| | labels_depth_2, expected_labels_2, "With max_depth=2, should find grandchildren." |
| | ) |
| |
|
| | |
| | with self.subTest(depth=0): |
| | results_depth_0 = ArchSql._traverse_architectural_hierarchy([floor], max_depth=0) |
| | labels_depth_0 = sorted([o.Label for o in results_depth_0]) |
| | expected_labels_0 = sorted(["DepthTestFloor", "DepthTestWall", "DepthTestWindow"]) |
| | self.assertListEqual( |
| | labels_depth_0, expected_labels_0, "With max_depth=0, should find all descendants." |
| | ) |
| |
|
| | def test_sql_children_and_children_recursive_functions(self): |
| | """ |
| | Performs a full integration test of the CHILDREN and CHILDREN_RECURSIVE |
| | SQL functions, ensuring they are correctly registered with the engine |
| | and call the traversal function with the correct parameters. |
| | """ |
| | |
| | |
| | building = Arch.makeBuilding(name="SQLFuncTestBuilding") |
| | floor = Arch.makeFloor(name="SQLFuncTestFloor") |
| | group = self.doc.addObject("App::DocumentObjectGroup", "SQLFuncTestGroup") |
| | wall = Arch.makeWall(name="SQLFuncTestWall") |
| | win_profile = Draft.makeRectangle(1000, 1000) |
| | window = Arch.makeWindow(win_profile, name="SQLFuncTestWindow") |
| |
|
| | building.addObject(floor) |
| | floor.addObject(group) |
| | group.addObject(wall) |
| | Arch.addComponents(window, host=wall) |
| | self.doc.recompute() |
| |
|
| | |
| | with self.subTest(function="CHILDREN"): |
| | query_children = """ |
| | SELECT Label FROM CHILDREN(SELECT * FROM document WHERE Label = 'SQLFuncTestBuilding') |
| | """ |
| | _, data = Arch.select(query_children) |
| | labels = sorted([row[0] for row in data]) |
| | |
| | self.assertListEqual(labels, ["SQLFuncTestFloor"]) |
| |
|
| | |
| | with self.subTest(function="CHILDREN_RECURSIVE"): |
| | query_recursive = """ |
| | SELECT Label FROM CHILDREN_RECURSIVE(SELECT * FROM document WHERE Label = 'SQLFuncTestBuilding') |
| | """ |
| | _, data = Arch.select(query_recursive) |
| | labels = sorted([row[0] for row in data]) |
| | |
| | expected = sorted(["SQLFuncTestFloor", "SQLFuncTestWall", "SQLFuncTestWindow"]) |
| | self.assertListEqual(labels, expected) |
| |
|
| | |
| | with self.subTest(function="CHILDREN_RECURSIVE with depth=2"): |
| | query_recursive_depth = """ |
| | SELECT Label FROM CHILDREN_RECURSIVE(SELECT * FROM document WHERE Label = 'SQLFuncTestBuilding', 2) |
| | """ |
| | _, data = Arch.select(query_recursive_depth) |
| | labels = sorted([row[0] for row in data]) |
| | |
| | |
| | expected = sorted(["SQLFuncTestFloor", "SQLFuncTestWall"]) |
| | self.assertListEqual(labels, expected) |
| |
|
| | def test_default_header_uses_internal_units(self): |
| | """ |
| | Tests that when a Quantity property is selected, the generated header |
| | uses the object's internal unit (e.g., 'mm') to match the raw data. |
| | This test temporarily changes the unit schema to ensure it is |
| | independent of user preferences. |
| | """ |
| | |
| | original_schema_index = FreeCAD.Units.getSchema() |
| |
|
| | try: |
| | |
| | schema_names = FreeCAD.Units.listSchemas() |
| | |
| | meter_schema_index = schema_names.index("MeterDecimal") |
| |
|
| | |
| | FreeCAD.Units.setSchema(meter_schema_index) |
| |
|
| | |
| | box = self.doc.addObject("Part::Box", "UnitHeaderTestBox") |
| | box.Length = 1500.0 |
| | self.doc.recompute() |
| |
|
| | report = Arch.makeReport(name="UnitHeaderTestReport") |
| | report.Proxy.live_statements[0].query_string = ( |
| | "SELECT Label, Length FROM document WHERE Name = 'UnitHeaderTestBox'" |
| | ) |
| | report.Proxy.commit_statements() |
| |
|
| | |
| | self.doc.recompute() |
| |
|
| | |
| | spreadsheet = report.Target |
| | self.assertIsNotNone(spreadsheet) |
| |
|
| | header_length = spreadsheet.get("B1") |
| |
|
| | self.assertEqual(header_length, "Length (mm)") |
| |
|
| | finally: |
| | |
| | FreeCAD.Units.setSchema(original_schema_index) |
| |
|
| | def test_numeric_comparisons_on_quantities(self): |
| | """ |
| | Tests that all numeric comparison operators (>, <, >=, <=, =, !=) |
| | work correctly on Quantity properties, independent of the current |
| | unit schema. This ensures numeric comparisons are not affected by |
| | string formatting or locales. |
| | """ |
| | |
| | original_schema_index = FreeCAD.Units.getSchema() |
| |
|
| | try: |
| | |
| | |
| | |
| | schema_names = FreeCAD.Units.listSchemas() |
| | mks_schema_index = schema_names.index("MKS") |
| | FreeCAD.Units.setSchema(mks_schema_index) |
| |
|
| | |
| | threshold = 8000.0 |
| | test_prefix = "NumericTestWall_" |
| | Arch.makeWall(name=test_prefix + "TallWall", height=threshold + 2000) |
| | Arch.makeWall(name=test_prefix + "ShortWall", height=threshold - 1000) |
| | Arch.makeWall(name=test_prefix + "ExactWall", height=threshold) |
| | self.doc.recompute() |
| |
|
| | test_cases = { |
| | ">": [test_prefix + "TallWall"], |
| | "<": [test_prefix + "ShortWall"], |
| | ">=": [test_prefix + "TallWall", test_prefix + "ExactWall"], |
| | "<=": [test_prefix + "ShortWall", test_prefix + "ExactWall"], |
| | "=": [test_prefix + "ExactWall"], |
| | "!=": [test_prefix + "TallWall", test_prefix + "ShortWall"], |
| | } |
| |
|
| | for op, expected_names in test_cases.items(): |
| | with self.subTest(operator=op): |
| | |
| | query = f"SELECT Label FROM document WHERE Label LIKE '{test_prefix}%' AND Height {op} {threshold}" |
| | _, results_data = Arch.select(query) |
| |
|
| | |
| | result_labels = [row[0] for row in results_data] |
| | self.assertCountEqual( |
| | result_labels, |
| | expected_names, |
| | f"Query with operator '{op}' returned incorrect objects.", |
| | ) |
| |
|
| | finally: |
| | |
| | FreeCAD.Units.setSchema(original_schema_index) |
| |
|