blob: b273909a199b09a5376c282affe480707cb31b36 [file] [log] [blame]
#
# 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()