| # |
| # Copyright (c) 2025 Project CHIP Authors |
| # All rights reserved. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| import xml.etree.ElementTree as ElementTree |
| import zipfile |
| from importlib.abc import Traversable |
| |
| from jinja2 import Template |
| from mobly import asserts |
| |
| from matter.testing.matter_testing import MatterBaseTest, default_matter_test_main |
| from matter.testing.problem_notices import NamespacePathLocation, ProblemNotice, ProblemSeverity |
| from matter.testing.spec_parsing import (DataModelLevel, PrebuiltDataModelDirectory, build_xml_namespaces, get_data_model_directory, |
| parse_namespace) |
| |
| |
| class TestSpecParsingNamespace(MatterBaseTest): |
| def setup_class(self): |
| # Test data setup |
| self.namespace_id = 0x0001 |
| self.namespace_name = "Test Namespace" |
| self.tags = { |
| 0x0000: "Tag1", |
| 0x0001: "Tag2", |
| 0x0002: "Tag3" |
| } |
| |
| # Template for generating test XML |
| self.template = Template("""<?xml version="1.0"?> |
| <namespace xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
| xsi:schemaLocation="types types.xsd namespace namespace.xsd" |
| id="{{ namespace_id }}" |
| name="{{ namespace_name }}"> |
| <tags> |
| {% for id, name in tags.items() %} |
| <tag id="{{ "0x%04X" % id }}" name="{{ name }}"/> |
| {% endfor %} |
| </tags> |
| </namespace>""") |
| |
| def validate_namespace_xml(self, xml_file: Traversable) -> list[ProblemNotice]: |
| """Validate namespace XML file""" |
| problems: list[ProblemNotice] = [] |
| try: |
| with xml_file.open('r', encoding="utf8") as f: |
| root = ElementTree.parse(f).getroot() |
| |
| # Check for namespace ID and validate format |
| namespace_id = root.get('id') |
| if not namespace_id: |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Missing namespace ID in {xml_file.name}" |
| )) |
| else: |
| # Validate 16-bit hex format |
| try: |
| # Remove '0x' prefix if present and try to parse |
| id_value = int(namespace_id.replace('0x', ''), 16) |
| if id_value < 0 or id_value > 0xFFFF: |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Namespace ID {namespace_id} is not a valid 16-bit value in {xml_file.name}" |
| )) |
| # Check format starts with 0x |
| if not namespace_id.lower().startswith('0x'): |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Namespace ID {namespace_id} does not start with '0x' in {xml_file.name}" |
| )) |
| except ValueError: |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Invalid hex format for namespace ID {namespace_id} in {xml_file.name}" |
| )) |
| |
| # Check for namespace name |
| namespace_name = root.get('name', '').strip() |
| if not namespace_name: |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Missing or empty namespace name in {xml_file.name}" |
| )) |
| |
| # Check tags structure |
| tags_elem = root.find('tags') |
| if tags_elem is not None: |
| for tag in tags_elem.findall('tag'): |
| # Check tag ID and validate format |
| tag_id = tag.get('id') |
| if not tag_id: |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Missing tag ID in {xml_file.name}" |
| )) |
| else: |
| try: |
| # Remove '0x' prefix if present and try to parse |
| id_value = int(tag_id.replace('0x', ''), 16) |
| if id_value < 0 or id_value > 0xFFFF: |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Tag ID {tag_id} is not a valid 16-bit value in {xml_file.name}" |
| )) |
| # Check format starts with 0x |
| if not tag_id.lower().startswith('0x'): |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Tag ID {tag_id} does not start with '0x' in {xml_file.name}" |
| )) |
| except ValueError: |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Invalid hex format for tag ID {tag_id} in {xml_file.name}" |
| )) |
| |
| # Check tag name |
| tag_name = tag.get('name', '').strip() |
| if not tag_name: |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Missing or empty tag name in {xml_file.name}" |
| )) |
| |
| except Exception as e: |
| problems.append(ProblemNotice( |
| test_name="Validate Namespace XML", |
| location=NamespacePathLocation(), |
| severity=ProblemSeverity.WARNING, |
| problem=f"Failed to parse {xml_file.name}: {str(e)}" |
| )) |
| |
| return problems |
| |
| def test_namespace_parsing(self): |
| """Test basic namespace parsing with valid data""" |
| xml = self.template.render( |
| namespace_id=f"0x{self.namespace_id:04X}", |
| namespace_name=self.namespace_name, |
| tags=self.tags |
| ) |
| et = ElementTree.fromstring(xml) |
| namespace, problems = parse_namespace(et) |
| |
| asserts.assert_equal(len(problems), 0, "Unexpected problems parsing namespace") |
| asserts.assert_equal(namespace.id, self.namespace_id, "Incorrect namespace ID") |
| asserts.assert_equal(namespace.name, self.namespace_name, "Incorrect namespace name") |
| asserts.assert_equal(len(namespace.tags), len(self.tags), "Incorrect number of tags") |
| |
| for tag_id, tag_name in self.tags.items(): |
| asserts.assert_true(tag_id in namespace.tags, f"Tag ID 0x{tag_id:04X} not found") |
| asserts.assert_equal(namespace.tags[tag_id].name, tag_name, f"Incorrect name for tag 0x{tag_id:04X}") |
| |
| def test_bad_namespace_id(self): |
| """Test parsing with invalid namespace ID""" |
| xml = self.template.render( |
| namespace_id="", |
| namespace_name=self.namespace_name, |
| tags=self.tags |
| ) |
| et = ElementTree.fromstring(xml) |
| namespace, problems = parse_namespace(et) |
| asserts.assert_equal(len(problems), 1, "Namespace with blank ID did not generate a problem notice") |
| |
| def test_missing_namespace_name(self): |
| """Test parsing with missing namespace name""" |
| xml = self.template.render( |
| namespace_id=f"0x{self.namespace_id:04X}", |
| namespace_name="", |
| tags=self.tags |
| ) |
| et = ElementTree.fromstring(xml) |
| namespace, problems = parse_namespace(et) |
| asserts.assert_equal(len(problems), 1, "Namespace with no name did not generate a problem notice") |
| |
| def test_no_tags(self): |
| """Test parsing with no tags""" |
| xml = self.template.render( |
| namespace_id=f"0x{self.namespace_id:04X}", |
| namespace_name=self.namespace_name, |
| tags={} |
| ) |
| et = ElementTree.fromstring(xml) |
| namespace, problems = parse_namespace(et) |
| asserts.assert_equal(len(problems), 0, "Unexpected problems parsing empty namespace") |
| asserts.assert_equal(len(namespace.tags), 0, "Empty namespace should have no tags") |
| |
| def test_spec_files(self): |
| """Test parsing actual spec files from different versions""" |
| one_three, one_three_problems = build_xml_namespaces(PrebuiltDataModelDirectory.k1_3) |
| one_four, one_four_problems = build_xml_namespaces(PrebuiltDataModelDirectory.k1_4) |
| one_four_one, one_four_one_problems = build_xml_namespaces(PrebuiltDataModelDirectory.k1_4_1) |
| one_four_two, one_four_two_problems = build_xml_namespaces(PrebuiltDataModelDirectory.k1_4_2) |
| one_five, one_five_problems = build_xml_namespaces(PrebuiltDataModelDirectory.k1_5) |
| |
| asserts.assert_equal(len(one_three_problems), 0, "Problems found when parsing 1.3 spec") |
| asserts.assert_equal(len(one_four_problems), 0, "Problems found when parsing 1.4 spec") |
| asserts.assert_equal(len(one_four_one_problems), 0, "Problems found when parsing 1.4.1 spec") |
| asserts.assert_equal(len(one_four_two_problems), 0, "Problems found when parsing 1.4.2 spec") |
| asserts.assert_equal(len(one_five_problems), 0, "Problems found when parsing 1.5 spec") |
| |
| # Check version relationships |
| asserts.assert_greater(len(set(one_five.keys()) - set(one_three.keys())), |
| 0, "1.5 dir contains less namespaces than 1.3") |
| asserts.assert_greater(len(set(one_five.keys()) - set(one_four.keys())), |
| 0, "1.5 dir contains less namespaces than 1.4") |
| asserts.assert_greater(len(set(one_five.keys()) - set(one_four_one.keys())), |
| 0, "1.5 dir contains less namespaces than 1.4.1") |
| asserts.assert_greater(len(set(one_five.keys()) - set(one_four_two.keys())), |
| 0, "1.5 dir contains less namespaces than 1.4.2") |
| |
| # Complete namespace version checks for 1.3, 1.4, 1.4.1, 1.4.2, 1.5, known differences and relationships: |
| # 1.3: has Common Position |
| # 1.4/1.4.1: removed Common Position, added Common Area/Landmark/Relative Position |
| # 1.4.2: added back Common Position, kept new ones from 1.4/1.4.1 |
| # 1.5: added following new namespaces since 1.4.2: {'Closure Window', 'Commodity Tariff Chronology', 'Closure Cabinet', 'Closure', 'Commodity Tariff Commodity', 'Commodity Tariff Flow', 'Closure Covering', 'Closure Panel'} |
| |
| # Check changes from 1.3 to 1.4 |
| # Created issue https://github.com/project-chip/connectedhomeip/issues/40909 to track missing namespace from 1.3 to 1.4 |
| removed_1_3_to_1_4 = set(one_three.keys()) - set(one_four.keys()) |
| removed_names_1_3_to_1_4 = {one_three[id].name for id in removed_1_3_to_1_4} |
| expected_removed_1_3_to_1_4 = {"Common Position"} |
| asserts.assert_equal(removed_names_1_3_to_1_4, expected_removed_1_3_to_1_4, |
| f"Expected only 'Common Position' to be removed from 1.3 to 1.4, but got: {removed_names_1_3_to_1_4}") |
| |
| added_1_3_to_1_4 = set(one_four.keys()) - set(one_three.keys()) |
| added_names_1_3_to_1_4 = {one_four[id].name for id in added_1_3_to_1_4} |
| expected_added_1_3_to_1_4 = {"Common Area", "Common Landmark", "Common Relative Position"} |
| asserts.assert_equal( |
| added_names_1_3_to_1_4, |
| expected_added_1_3_to_1_4, |
| f"Expected 'Common Area', 'Common Landmark', 'Common Relative Position' to be added from 1.3 to 1.4, but got: {added_names_1_3_to_1_4}") |
| |
| # Check 1.4 to 1.4.1 (should be identical) |
| diff_1_4_to_1_4_1 = set(one_four.keys()) ^ set(one_four_one.keys()) |
| asserts.assert_equal(len(diff_1_4_to_1_4_1), 0, "1.4 and 1.4.1 should have identical namespaces") |
| |
| # Comprehensive checks: Compare 1.4.2 (latest) against all previous versions |
| # 1.4.2 vs 1.3 comparison |
| removed_1_4_2_vs_1_3 = set(one_three.keys()) - set(one_four_two.keys()) |
| removed_names_1_4_2_vs_1_3 = {one_three[id].name for id in removed_1_4_2_vs_1_3} |
| expected_removed_1_4_2_vs_1_3 = set() # No namespaces should be removed from 1.3 to 1.4.2 |
| asserts.assert_equal( |
| removed_names_1_4_2_vs_1_3, |
| expected_removed_1_4_2_vs_1_3, |
| f"Expected no namespaces to be removed from 1.3 to 1.4.2, but got: {removed_names_1_4_2_vs_1_3}") |
| |
| added_1_4_2_vs_1_3 = set(one_four_two.keys()) - set(one_three.keys()) |
| added_names_1_4_2_vs_1_3 = {one_four_two[id].name for id in added_1_4_2_vs_1_3} |
| expected_added_1_4_2_vs_1_3 = {"Common Area", "Common Landmark", "Common Relative Position"} |
| asserts.assert_equal( |
| added_names_1_4_2_vs_1_3, |
| expected_added_1_4_2_vs_1_3, |
| f"Expected 'Common Area', 'Common Landmark', 'Common Relative Position' to be added from 1.3 to 1.4.2, but got: {added_names_1_4_2_vs_1_3}") |
| |
| # 1.4.2 vs 1.4 comparison |
| removed_1_4_2_vs_1_4 = set(one_four.keys()) - set(one_four_two.keys()) |
| removed_names_1_4_2_vs_1_4 = {one_four[id].name for id in removed_1_4_2_vs_1_4} |
| expected_removed_1_4_2_vs_1_4 = set() # No namespaces should be removed from 1.4 to 1.4.2 |
| asserts.assert_equal( |
| removed_names_1_4_2_vs_1_4, |
| expected_removed_1_4_2_vs_1_4, |
| f"Expected no namespaces to be removed from 1.4 to 1.4.2, but got: {removed_names_1_4_2_vs_1_4}") |
| |
| added_1_4_2_vs_1_4 = set(one_four_two.keys()) - set(one_four.keys()) |
| added_names_1_4_2_vs_1_4 = {one_four_two[id].name for id in added_1_4_2_vs_1_4} |
| expected_added_1_4_2_vs_1_4 = {"Common Position"} |
| asserts.assert_equal(added_names_1_4_2_vs_1_4, expected_added_1_4_2_vs_1_4, |
| f"Expected only 'Common Position' to be added from 1.4 to 1.4.2, but got: {added_names_1_4_2_vs_1_4}") |
| |
| # Check changes from 1.4.1 to 1.4.2 |
| removed_1_4_1_to_1_4_2 = set(one_four_one.keys()) - set(one_four_two.keys()) |
| removed_names_1_4_1_to_1_4_2 = {one_four_one[id].name for id in removed_1_4_1_to_1_4_2} |
| expected_removed_1_4_1_to_1_4_2 = set() # No namespaces should be removed from 1.4.1 to 1.4.2 |
| asserts.assert_equal( |
| removed_names_1_4_1_to_1_4_2, |
| expected_removed_1_4_1_to_1_4_2, |
| f"Expected no namespaces to be removed from 1.4.1 to 1.4.2, but got: {removed_names_1_4_1_to_1_4_2}") |
| |
| added_1_4_2_vs_1_4_1 = set(one_four_two.keys()) - set(one_four_one.keys()) |
| added_names_1_4_2_vs_1_4_1 = {one_four_two[id].name for id in added_1_4_2_vs_1_4_1} |
| expected_added_1_4_2_vs_1_4_1 = {"Common Position"} |
| asserts.assert_equal( |
| added_names_1_4_2_vs_1_4_1, |
| expected_added_1_4_2_vs_1_4_1, |
| f"Expected only 'Common Position' to be added from 1.4.1 to 1.4.2, but got: {added_names_1_4_2_vs_1_4_1}") |
| |
| # Check changes from 1.4 to 1.5 |
| removed_1_4_to_1_5 = set(one_four.keys()) - set(one_five.keys()) |
| removed_names_1_4_to_1_5 = {one_four[id].name for id in removed_1_4_to_1_5} |
| expected_removed_1_4_to_1_5 = set() # No namespaces should be removed from 1.4 to 1.5 |
| asserts.assert_equal( |
| removed_names_1_4_to_1_5, |
| expected_removed_1_4_to_1_5, |
| f"Expected no namespaces to be removed from 1.4 to 1.5, but got: {removed_names_1_4_to_1_5}") |
| |
| added_1_4_to_1_5 = set(one_five.keys()) - set(one_four.keys()) |
| added_names_1_4_to_1_5 = {one_five[id].name for id in added_1_4_to_1_5} |
| expected_added_1_4_to_1_5 = {'Closure', 'Closure Window', 'Closure Covering', 'Commodity Tariff Commodity', |
| 'Closure Panel', 'Commodity Tariff Flow', 'Common Position', 'Commodity Tariff Chronology', 'Closure Cabinet'} |
| asserts.assert_equal(added_names_1_4_to_1_5, expected_added_1_4_to_1_5, |
| f"Expected only 'Closure', 'Closure Window', 'Closure Covering', 'Commodity Tariff Commodity', 'Closure Panel', 'Commodity Tariff Flow', 'Common Position', 'Commodity Tariff Chronology', 'Closure Cabinet' to be added from 1.4 to 1.5, but got: {added_names_1_4_to_1_5}") |
| |
| # Check changes from 1.4.1 to 1.5 |
| removed_1_4_1_to_1_5 = set(one_four_one.keys()) - set(one_five.keys()) |
| removed_names_1_4_1_to_1_5 = {one_four_one[id].name for id in removed_1_4_1_to_1_5} |
| expected_removed_1_4_1_to_1_5 = set() # No namespaces should be removed from 1.4.1 to 1.5 |
| asserts.assert_equal( |
| removed_names_1_4_1_to_1_5, |
| expected_removed_1_4_1_to_1_5, |
| f"Expected no namespaces to be removed from 1.4.1 to 1.5, but got: {removed_names_1_4_1_to_1_5}") |
| |
| added_1_4_1_to_1_5 = set(one_five.keys()) - set(one_four_one.keys()) |
| added_names_1_4_1_to_1_5 = {one_five[id].name for id in added_1_4_1_to_1_5} |
| expected_added_1_4_1_to_1_5 = {'Closure', 'Closure Window', 'Closure Covering', 'Commodity Tariff Commodity', |
| 'Closure Panel', 'Commodity Tariff Flow', 'Common Position', 'Commodity Tariff Chronology', 'Closure Cabinet'} |
| asserts.assert_equal(added_names_1_4_1_to_1_5, expected_added_1_4_1_to_1_5, |
| f"Expected only 'Closure', 'Closure Window', 'Closure Covering', 'Commodity Tariff Commodity', 'Closure Panel', 'Commodity Tariff Flow', 'Common Position', 'Commodity Tariff Chronology', 'Closure Cabinet' to be added from 1.4.1 to 1.5, but got: {added_names_1_4_1_to_1_5}") |
| |
| # Check changes from 1.4.2 to 1.5 |
| removed_1_4_2_to_1_5 = set(one_four_two.keys()) - set(one_five.keys()) |
| removed_names_1_4_2_to_1_5 = {one_four_two[id].name for id in removed_1_4_2_to_1_5} |
| expected_removed_1_4_2_to_1_5 = set() # No namespaces should be removed from 1.4.2 to 1.5 |
| asserts.assert_equal( |
| removed_names_1_4_2_to_1_5, |
| expected_removed_1_4_2_to_1_5, |
| f"Expected no namespaces to be removed from 1.4.2 to 1.5, but got: {removed_names_1_4_2_to_1_5}") |
| |
| added_1_4_2_to_1_5 = set(one_five.keys()) - set(one_four_two.keys()) |
| added_names_1_4_2_to_1_5 = {one_five[id].name for id in added_1_4_2_to_1_5} |
| expected_added_1_4_2_to_1_5 = {'Closure Window', 'Commodity Tariff Chronology', 'Closure Cabinet', |
| 'Closure', 'Commodity Tariff Commodity', 'Commodity Tariff Flow', 'Closure Covering', 'Closure Panel'} |
| asserts.assert_equal(added_names_1_4_2_to_1_5, expected_added_1_4_2_to_1_5, |
| f"Expected only 'Closure Window', 'Commodity Tariff Chronology', 'Closure Cabinet', 'Closure', 'Commodity Tariff Commodity', 'Commodity Tariff Flow', 'Closure Covering', 'Closure Panel' to be added from 1.4.2 to 1.5, but got: {added_names_1_4_2_to_1_5}") |
| |
| def test_all_namespace_files(self): |
| """Test all namespace XML files in the data model namespaces directories""" |
| for v in PrebuiltDataModelDirectory: |
| namespaces, problems = build_xml_namespaces(v) |
| |
| # We expect no problems for these versions of the spec files. |
| asserts.assert_equal(len(problems), 0, f"Unexpected problems parsing namespaces for version {v.dirname}") |
| |
| # Also verify that some namespaces were actually parsed. |
| asserts.assert_greater(len(namespaces), 0, f"No namespaces parsed for version {v.dirname}") |
| |
| # Verify that every XML file in the namespace directory was processed |
| top = get_data_model_directory(v, DataModelLevel.kNamespace) |
| |
| # Count XML files in the directory |
| xml_file_count = 0 |
| if isinstance(top, zipfile.Path): |
| xml_file_count = len([f for f in top.iterdir() if str(f).endswith('.xml')]) |
| else: |
| xml_file_count = len([f for f in top.iterdir() if f.name.endswith('.xml')]) |
| |
| # Verify that the number of namespaces parsed matches the number of XML files |
| asserts.assert_equal( |
| len(namespaces), |
| xml_file_count, |
| f"Version {v.dirname}: Expected {xml_file_count} XML files to be parsed, but got {len(namespaces)} namespaces" |
| ) |
| |
| def test_validate_namespace_xml_files(self): |
| """Test comprehensive validation of all namespace XML files""" |
| for v in PrebuiltDataModelDirectory: |
| namespace_dir = get_data_model_directory(v, DataModelLevel.kNamespace) |
| all_problems = [] |
| |
| # Handle both zip files and directories |
| if isinstance(namespace_dir, zipfile.Path): |
| xml_files = [f for f in namespace_dir.iterdir() if str(f).endswith('.xml')] |
| else: |
| xml_files = [f for f in namespace_dir.iterdir() if f.name.endswith('.xml')] |
| |
| for xml_file in xml_files: |
| problems = self.validate_namespace_xml(xml_file) |
| all_problems.extend(problems) |
| |
| # We expect no validation problems for these versions of the spec files |
| asserts.assert_equal( |
| len(all_problems), |
| 0, |
| f"Validation problems found in namespace XML files for version {v.dirname}:\n" + |
| "\n".join(f" - {p.problem}" for p in all_problems) |
| ) |
| |
| |
| if __name__ == "__main__": |
| default_matter_test_main() |