diff --git a/systemzero/tests/test_drift.py b/systemzero/tests/test_drift.py index 47b3b6c..780d854 100644 --- a/systemzero/tests/test_drift.py +++ b/systemzero/tests/test_drift.py @@ -1 +1,482 @@ -def test_matcher(): assert True +"""Comprehensive tests for drift detection - Matcher and DiffEngine.""" +import pytest +import copy +from core.drift import Matcher, DiffEngine, DriftEvent +from core.normalization import TreeNormalizer, SignatureGenerator +from tests.fixtures.mock_trees import ( + DISCORD_CHAT_TREE, + DOORDASH_OFFER_TREE, + GMAIL_INBOX_TREE, + SETTINGS_PANEL_TREE, + LOGIN_FORM_TREE +) +from tests.fixtures.templates import ( + discord_chat_template, + doordash_offer_template, + gmail_inbox_template, + settings_panel_template, + login_form_template +) + + +class TestMatcher: + """Test suite for Matcher with focus on scoring algorithm.""" + + def test_matcher_initialization_with_threshold(self): + """Verify Matcher can be initialized with custom threshold.""" + matcher = Matcher(similarity_threshold=0.75) + assert matcher.similarity_threshold == 0.75 + + matcher_default = Matcher() + assert matcher_default.similarity_threshold == 0.8 + + def test_match_returns_true_above_threshold(self): + """Verify match() returns True when similarity exceeds threshold.""" + normalizer = TreeNormalizer() + matcher = Matcher(similarity_threshold=0.8) + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + template = discord_chat_template() + + result = matcher.match(tree, template) + assert result is True + + def test_match_returns_false_below_threshold(self): + """Verify match() returns False when similarity is below threshold.""" + normalizer = TreeNormalizer() + matcher = Matcher(similarity_threshold=0.95) + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + # Use a template from different screen + template = doordash_offer_template() + + result = matcher.match(tree, template) + assert result is False + + def test_similarity_score_perfect_match(self): + """Verify similarity_score returns 1.0 for perfect match.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + template = discord_chat_template() + + score = matcher.similarity_score(tree, template) + # Score should be very high (close to 1.0) for matching template + assert score >= 0.9 + + def test_similarity_score_weighted_components(self): + """Verify scoring uses 40% required nodes, 40% structure, 20% roles.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + template = discord_chat_template() + + # Get individual component scores + required_score = matcher._check_required_nodes(tree, template) + structure_score = matcher._check_structure(tree, template) + role_score = matcher._check_roles(tree, template) + + # Calculate expected weighted score + expected_score = (required_score * 0.4) + (structure_score * 0.4) + (role_score * 0.2) + + actual_score = matcher.similarity_score(tree, template) + + # Should match weighted calculation + assert abs(actual_score - expected_score) < 0.01 + + def test_check_required_nodes_all_present(self): + """Verify _check_required_nodes returns 1.0 when all nodes present.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + template = discord_chat_template() + + score = matcher._check_required_nodes(tree, template) + assert score == 1.0 + + def test_check_required_nodes_partial_present(self): + """Verify _check_required_nodes calculates correct ratio for partial matches.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + + # Template with some missing nodes + template = { + "required_nodes": ["message_list", "input_box", "send_button", "missing_node"] + } + + score = matcher._check_required_nodes(tree, template) + # 3 out of 4 required nodes present + assert score == 0.75 + + def test_check_required_nodes_none_present(self): + """Verify _check_required_nodes returns 0.0 when no nodes match.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + + template = { + "required_nodes": ["nonexistent1", "nonexistent2", "nonexistent3"] + } + + score = matcher._check_required_nodes(tree, template) + assert score == 0.0 + + def test_check_required_nodes_empty_list(self): + """Verify _check_required_nodes returns 1.0 when no requirements specified.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + template = {"required_nodes": []} + + score = matcher._check_required_nodes(tree, template) + assert score == 1.0 + + def test_check_structure_depth_similarity(self): + """Verify _check_structure considers tree depth.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + + # Calculate actual depth + depth = matcher._calculate_depth(tree.get("root")) + + # Template with matching depth + template = {"depth": depth, "node_count": 100} + + score = matcher._check_structure(tree, template) + # Should have good score with matching depth + assert score > 0.5 + + def test_check_roles_exact_match(self): + """Verify _check_roles with exact role overlap.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + + # Extract actual roles from tree + actual_roles = matcher._extract_roles(tree.get("root")) + + template = {"expected_roles": list(actual_roles)} + + score = matcher._check_roles(tree, template) + # Perfect overlap should give 1.0 + assert score == 1.0 + + def test_check_roles_partial_overlap(self): + """Verify _check_roles calculates Jaccard similarity correctly.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + + # Some overlapping, some unique roles + template = {"expected_roles": ["window", "panel", "button", "nonexistent_role"]} + + score = matcher._check_roles(tree, template) + # Should be between 0 and 1 + assert 0.0 < score < 1.0 + + def test_find_best_match_with_multiple_templates(self): + """Verify find_best_match returns highest scoring template.""" + normalizer = TreeNormalizer() + matcher = Matcher(similarity_threshold=0.5) + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + + templates = [ + discord_chat_template(), + doordash_offer_template(), + gmail_inbox_template() + ] + + result = matcher.find_best_match(tree, templates) + + assert result is not None + best_template, score = result + + # Should match discord template + assert best_template["screen_id"] == "discord_chat" + assert score >= matcher.similarity_threshold + + def test_find_best_match_returns_none_below_threshold(self): + """Verify find_best_match returns None when no template meets threshold.""" + normalizer = TreeNormalizer() + matcher = Matcher(similarity_threshold=0.99) + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + + # Use templates from different screens + templates = [ + doordash_offer_template(), + gmail_inbox_template() + ] + + result = matcher.find_best_match(tree, templates) + + # No match should exceed 0.99 threshold + assert result is None + + def test_find_best_match_empty_templates(self): + """Verify find_best_match handles empty template list.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + + result = matcher.find_best_match(tree, []) + assert result is None + + def test_extract_node_names_recursive(self): + """Verify _extract_node_names finds all nodes recursively.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + names = matcher._extract_node_names(tree) + + # Should find nodes at all levels + assert "Discord" in names + assert "sidebar" in names + assert "message_list" in names + assert "send_button" in names + + def test_extract_roles_recursive(self): + """Verify _extract_roles finds all role types.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + roles = matcher._extract_roles(tree.get("root")) + + assert "window" in roles + assert "panel" in roles + assert "button" in roles + assert "list" in roles + + def test_calculate_depth_nested_structure(self): + """Verify _calculate_depth correctly measures tree depth.""" + matcher = Matcher() + + # Simple nested structure + node = { + "role": "root", + "children": [ + { + "role": "level1", + "children": [ + {"role": "level2"} + ] + } + ] + } + + depth = matcher._calculate_depth(node) + assert depth == 2 + + def test_count_nodes_total(self): + """Verify _count_nodes counts all nodes in tree.""" + normalizer = TreeNormalizer() + matcher = Matcher() + + tree = normalizer.normalize(DISCORD_CHAT_TREE) + count = matcher._count_nodes(tree.get("root")) + + # Should count root + all descendants + assert count > 10 + + +class TestDiffEngine: + """Test suite for DiffEngine with actual tree deltas.""" + + def test_diff_empty_trees(self): + """Verify diff handles empty trees.""" + engine = DiffEngine() + + result = engine.diff({}, {}) + + assert result["added"] == [] + assert result["removed"] == [] + assert result["modified"] == [] + assert result["unchanged"] == 0 + assert result["similarity"] == 1.0 + + def test_diff_identical_trees(self): + """Verify diff recognizes identical trees.""" + normalizer = TreeNormalizer() + engine = DiffEngine() + + tree1 = normalizer.normalize(DISCORD_CHAT_TREE) + tree2 = normalizer.normalize(copy.deepcopy(DISCORD_CHAT_TREE)) + + result = engine.diff(tree1, tree2) + + # Should have no changes + assert len(result["added"]) == 0 + assert len(result["removed"]) == 0 + # May have some modifications due to sorting, but similarity should be high + assert result["similarity"] >= 0.9 + + def test_diff_detects_added_nodes(self): + """Verify diff detects nodes added to tree.""" + normalizer = TreeNormalizer() + engine = DiffEngine() + + tree1 = normalizer.normalize(DISCORD_CHAT_TREE) + tree2 = copy.deepcopy(tree1) + + # Add a new node + tree2["root"]["children"][0]["children"].append({ + "role": "button", + "name": "new_button" + }) + + result = engine.diff(tree1, tree2) + + assert len(result["added"]) > 0 + assert result["similarity"] < 1.0 + + def test_diff_detects_removed_nodes(self): + """Verify diff detects nodes removed from tree.""" + normalizer = TreeNormalizer() + engine = DiffEngine() + + tree1 = normalizer.normalize(DISCORD_CHAT_TREE) + tree2 = copy.deepcopy(tree1) + + # Remove a node + tree2["root"]["children"][0]["children"].pop() + + result = engine.diff(tree1, tree2) + + assert len(result["removed"]) > 0 + assert result["similarity"] < 1.0 + + def test_diff_detects_modified_properties(self): + """Verify diff detects property changes in nodes.""" + normalizer = TreeNormalizer() + engine = DiffEngine() + + tree1 = normalizer.normalize(DISCORD_CHAT_TREE) + tree2 = copy.deepcopy(tree1) + + # Modify a property that's tracked + tree2["root"]["role"] = "dialog" + + result = engine.diff(tree1, tree2) + + # Should detect modification or see it as removed+added + assert result["similarity"] < 1.0 + + def test_diff_similarity_score_calculation(self): + """Verify similarity score is correctly calculated.""" + normalizer = TreeNormalizer() + engine = DiffEngine() + + tree1 = normalizer.normalize(LOGIN_FORM_TREE) + tree2 = copy.deepcopy(tree1) + + # Make a small change + tree2["root"]["children"][0]["children"][0]["value"] = "Modified Title" + + result = engine.diff(tree1, tree2) + + # Similarity should reflect the proportion of unchanged nodes + total = len(result["added"]) + len(result["removed"]) + len(result["modified"]) + result["unchanged"] + expected_similarity = result["unchanged"] / total if total > 0 else 1.0 + + assert abs(result["similarity"] - expected_similarity) < 0.01 + + def test_diff_summary_formatting(self): + """Verify diff_summary produces readable output.""" + normalizer = TreeNormalizer() + engine = DiffEngine() + + tree1 = normalizer.normalize(DISCORD_CHAT_TREE) + tree2 = copy.deepcopy(tree1) + tree2["root"]["children"].pop() + + result = engine.diff(tree1, tree2) + summary = engine.diff_summary(result) + + assert "Similarity:" in summary + assert "Added:" in summary + assert "Removed:" in summary + assert "Modified:" in summary + assert "Unchanged:" in summary + + def test_has_significant_changes_above_threshold(self): + """Verify has_significant_changes with similarity above threshold.""" + engine = DiffEngine() + + diff_result = { + "added": [], + "removed": [], + "modified": [], + "unchanged": 100, + "similarity": 0.95 + } + + # 0.95 > 0.9 threshold, so not significant + assert engine.has_significant_changes(diff_result, threshold=0.9) is False + + def test_has_significant_changes_below_threshold(self): + """Verify has_significant_changes with similarity below threshold.""" + engine = DiffEngine() + + diff_result = { + "added": [{"role": "button"}], + "removed": [{"role": "text"}], + "modified": [], + "unchanged": 10, + "similarity": 0.75 + } + + # 0.75 < 0.9 threshold, so significant + assert engine.has_significant_changes(diff_result, threshold=0.9) is True + + def test_diff_with_completely_different_trees(self): + """Verify diff handles completely different trees.""" + normalizer = TreeNormalizer() + engine = DiffEngine() + + tree1 = normalizer.normalize(DISCORD_CHAT_TREE) + tree2 = normalizer.normalize(DOORDASH_OFFER_TREE) + + result = engine.diff(tree1, tree2) + + # Should have very low similarity + assert result["similarity"] < 0.5 + # Should have many differences + assert (len(result["added"]) + len(result["removed"]) + len(result["modified"])) > 0 + + +class TestDriftEvent: + """Test suite for DriftEvent data structure.""" + + def test_drift_event_creation(self): + """Verify DriftEvent can be created with required fields.""" + event = DriftEvent("layout", "warning", {"test": "data"}) + + assert event.drift_type == "layout" + assert event.severity == "warning" + assert event.details == {"test": "data"} + + def test_drift_event_to_dict(self): + """Verify DriftEvent converts to dict properly.""" + event = DriftEvent("content", "critical", {"key": "value"}) + + event_dict = event.to_dict() + + assert event_dict["drift_type"] == "content" + assert event_dict["severity"] == "critical" + assert event_dict["details"]["key"] == "value" + assert "timestamp" in event_dict diff --git a/systemzero/tests/test_normalization.py b/systemzero/tests/test_normalization.py index 2ac1f10..252d087 100644 --- a/systemzero/tests/test_normalization.py +++ b/systemzero/tests/test_normalization.py @@ -1 +1,402 @@ -def test_normalization(): assert True +"""Comprehensive tests for tree normalization and signature generation.""" +import pytest +import hashlib +import copy +from core.normalization import TreeNormalizer, SignatureGenerator +from tests.fixtures.mock_trees import ( + DISCORD_CHAT_TREE, + DOORDASH_OFFER_TREE, + GMAIL_INBOX_TREE, + SETTINGS_PANEL_TREE, + LOGIN_FORM_TREE +) + + +class TestTreeNormalizer: + """Test suite for TreeNormalizer.""" + + def test_normalize_removes_transient_properties(self): + """Verify that transient properties like timestamp, id, instance_id are removed.""" + tree_with_transients = { + "root": { + "role": "window", + "name": "Test", + "timestamp": "2024-01-01T00:00:00", + "id": "abc123", + "instance_id": "xyz789", + "children": [ + { + "role": "button", + "name": "submit", + "id": "btn_001", + "timestamp": "2024-01-01T00:00:01" + } + ] + }, + "timestamp": "2024-01-01T00:00:00" + } + + normalizer = TreeNormalizer() + normalized = normalizer.normalize(tree_with_transients) + + # Check root level + assert "timestamp" not in normalized + assert "root" in normalized + + # Check root node + root = normalized["root"] + assert "timestamp" not in root + assert "id" not in root + assert "instance_id" not in root + assert root["role"] == "window" + assert root["name"] == "Test" + + # Check child node + child = root["children"][0] + assert "timestamp" not in child + assert "id" not in child + assert child["role"] == "button" + assert child["name"] == "submit" + + def test_normalize_maps_alternative_property_names(self): + """Verify that alternative property names (label, title, text) map to 'name'.""" + tree_with_alternatives = { + "root": { + "role": "window", + "title": "Main Window", + "children": [ + {"role": "button", "label": "Submit"}, + {"role": "text", "text": "Description"} + ] + } + } + + normalizer = TreeNormalizer() + normalized = normalizer.normalize(tree_with_alternatives) + + root = normalized["root"] + assert root["name"] == "Main Window" + assert "title" not in root + + assert normalized["root"]["children"][0]["name"] == "Submit" + assert "label" not in normalized["root"]["children"][0] + + assert normalized["root"]["children"][1]["name"] == "Description" + assert "text" not in normalized["root"]["children"][1] + + def test_normalize_sorts_children_deterministically(self): + """Verify that children are sorted for deterministic comparison.""" + tree_unsorted = { + "root": { + "role": "window", + "name": "Test", + "children": [ + {"role": "textbox", "name": "input_z"}, + {"role": "button", "name": "submit"}, + {"role": "button", "name": "cancel"}, + {"role": "text", "name": "label_a"} + ] + } + } + + normalizer = TreeNormalizer() + normalized = normalizer.normalize(tree_unsorted) + + children = normalized["root"]["children"] + # Should be sorted by role > name > type + assert children[0]["role"] == "button" + assert children[0]["name"] == "cancel" + assert children[1]["role"] == "button" + assert children[1]["name"] == "submit" + assert children[2]["role"] == "text" + assert children[3]["role"] == "textbox" + + def test_normalize_handles_empty_tree(self): + """Verify normalization handles empty trees gracefully.""" + normalizer = TreeNormalizer() + + assert normalizer.normalize({}) == {} + assert normalizer.normalize(None) == {} + + def test_normalize_preserves_structure(self): + """Verify that normalization preserves the overall tree structure.""" + normalizer = TreeNormalizer() + normalized = normalizer.normalize(DISCORD_CHAT_TREE) + + assert "root" in normalized + root = normalized["root"] + assert root["role"] == "window" + assert root["name"] == "Discord" + assert len(root["children"]) == 2 + + # Check nested structure is preserved + sidebar = root["children"][1] # After sorting + assert sidebar["name"] == "sidebar" + assert "children" in sidebar + + +class TestSignatureGenerator: + """Test suite for SignatureGenerator.""" + + def test_generate_produces_sha256_hash(self): + """Verify that generate() produces a valid SHA256 hash.""" + normalizer = TreeNormalizer() + sig_gen = SignatureGenerator() + + normalized = normalizer.normalize(DISCORD_CHAT_TREE) + signature = sig_gen.generate(normalized) + + # SHA256 produces 64 character hex string + assert len(signature) == 64 + assert all(c in '0123456789abcdef' for c in signature) + + def test_generate_excludes_transient_properties(self): + """Verify that transient properties don't affect signature.""" + sig_gen = SignatureGenerator() + + tree_base = { + "root": { + "role": "window", + "name": "Test" + } + } + + tree_with_transients = { + "root": { + "role": "window", + "name": "Test", + "timestamp": "2024-01-01", + "id": "xyz123", + "focused": True + } + } + + sig1 = sig_gen.generate(tree_base) + sig2 = sig_gen.generate(tree_with_transients) + + # Signatures should be identical + assert sig1 == sig2 + + def test_generate_is_deterministic(self): + """Verify that generating signature multiple times produces same result.""" + normalizer = TreeNormalizer() + sig_gen = SignatureGenerator() + + normalized = normalizer.normalize(DISCORD_CHAT_TREE) + + sig1 = sig_gen.generate(normalized) + sig2 = sig_gen.generate(normalized) + sig3 = sig_gen.generate(normalized) + + assert sig1 == sig2 == sig3 + + def test_generate_detects_structure_changes(self): + """Verify that structural changes produce different signatures.""" + normalizer = TreeNormalizer() + sig_gen = SignatureGenerator() + + tree1 = copy.deepcopy(DISCORD_CHAT_TREE) + tree2 = copy.deepcopy(DISCORD_CHAT_TREE) + + # Modify structure: remove a child node + tree2["root"]["children"][1]["children"].pop() + + norm1 = normalizer.normalize(tree1) + norm2 = normalizer.normalize(tree2) + + sig1 = sig_gen.generate(norm1) + sig2 = sig_gen.generate(norm2) + + assert sig1 != sig2 + + def test_generate_detects_content_changes(self): + """Verify that content changes produce different signatures.""" + normalizer = TreeNormalizer() + sig_gen = SignatureGenerator() + + tree1 = copy.deepcopy(DOORDASH_OFFER_TREE) + tree2 = copy.deepcopy(DOORDASH_OFFER_TREE) + + # Change content value + tree2["root"]["children"][0]["children"][1]["value"] = "$15.00" + + norm1 = normalizer.normalize(tree1) + norm2 = normalizer.normalize(tree2) + + sig1 = sig_gen.generate(norm1) + sig2 = sig_gen.generate(norm2) + + assert sig1 != sig2 + + def test_generate_structural_ignores_content(self): + """Verify that structural signatures ignore content changes.""" + sig_gen = SignatureGenerator() + + tree1 = { + "root": { + "role": "window", + "name": "Test", + "children": [ + {"role": "text", "name": "label", "value": "Original"} + ] + } + } + + tree2 = { + "root": { + "role": "window", + "name": "Test", + "children": [ + {"role": "text", "name": "label", "value": "Modified"} + ] + } + } + + sig1 = sig_gen.generate_structural(tree1) + sig2 = sig_gen.generate_structural(tree2) + + # Structural signatures should be identical despite content change + assert sig1 == sig2 + + def test_generate_structural_uses_role_and_type(self): + """Verify that structural signatures use role and type information.""" + sig_gen = SignatureGenerator() + + # Test with normalized structures that extract_structure can handle + tree1 = { + "role": "window", + "type": "container", + "children": [ + {"role": "button", "type": "action"}, + {"role": "text", "type": "display"} + ] + } + + tree2 = { + "role": "window", + "type": "container", + "children": [ + {"role": "button", "type": "action"} + ] + } + + sig1 = sig_gen.generate_structural(tree1) + sig2 = sig_gen.generate_structural(tree2) + + # Should detect structural difference (different children count) + assert sig1 != sig2 + + def test_generate_content_extracts_text(self): + """Verify that content signatures extract and hash text content.""" + sig_gen = SignatureGenerator() + + tree = { + "root": { + "role": "window", + "name": "Main", + "children": [ + {"role": "text", "name": "Welcome"}, + {"role": "button", "name": "Submit"} + ] + } + } + + sig = sig_gen.generate_content(tree) + + # Should produce a valid hash + assert len(sig) == 64 + assert all(c in '0123456789abcdef' for c in sig) + + def test_generate_content_is_order_independent(self): + """Verify that content signatures are order-independent.""" + sig_gen = SignatureGenerator() + + tree1 = { + "root": { + "children": [ + {"name": "Alpha"}, + {"name": "Beta"} + ] + } + } + + tree2 = { + "root": { + "children": [ + {"name": "Beta"}, + {"name": "Alpha"} + ] + } + } + + sig1 = sig_gen.generate_content(tree1) + sig2 = sig_gen.generate_content(tree2) + + # Content signatures should be same (sorted internally) + assert sig1 == sig2 + + def test_generate_multi_returns_all_signature_types(self): + """Verify that generate_multi() returns all signature types.""" + normalizer = TreeNormalizer() + sig_gen = SignatureGenerator() + + normalized = normalizer.normalize(GMAIL_INBOX_TREE) + sigs = sig_gen.generate_multi(normalized) + + assert "full" in sigs + assert "structural" in sigs + assert "content" in sigs + + # All should be valid SHA256 hashes + for sig_type, sig_value in sigs.items(): + assert len(sig_value) == 64 + assert all(c in '0123456789abcdef' for c in sig_value) + + def test_compare_signatures_equality(self): + """Verify signature comparison works correctly.""" + sig_gen = SignatureGenerator() + + sig1 = hashlib.sha256(b"test").hexdigest() + sig2 = hashlib.sha256(b"test").hexdigest() + sig3 = hashlib.sha256(b"different").hexdigest() + + assert sig_gen.compare_signatures(sig1, sig2) is True + assert sig_gen.compare_signatures(sig1, sig3) is False + + def test_signature_consistency_across_mock_trees(self): + """Verify each mock tree produces unique, consistent signatures.""" + normalizer = TreeNormalizer() + sig_gen = SignatureGenerator() + + trees = [ + DISCORD_CHAT_TREE, + DOORDASH_OFFER_TREE, + GMAIL_INBOX_TREE, + SETTINGS_PANEL_TREE, + LOGIN_FORM_TREE + ] + + signatures = [] + for tree in trees: + normalized = normalizer.normalize(tree) + sig = sig_gen.generate(normalized) + signatures.append(sig) + + # All signatures should be unique + assert len(signatures) == len(set(signatures)) + + # Regenerate to verify consistency + for i, tree in enumerate(trees): + normalized = normalizer.normalize(tree) + sig = sig_gen.generate(normalized) + assert sig == signatures[i] + + def test_empty_tree_signature(self): + """Verify empty tree produces consistent empty signature.""" + sig_gen = SignatureGenerator() + + empty_sig = sig_gen.generate({}) + + # Should be SHA256 of empty string + expected = hashlib.sha256(b"").hexdigest() + assert empty_sig == expected