Systematic rename of all semantic anchors (#region, [DEF], @RELATION) across 1400+ files — backend Python, frontend Svelte/TS, specs, docs: - Flat anchors become Namespace.Module.Entity - @RELATION references updated to match new anchor paths - Zero business logic changes
237 lines
9.5 KiB
Python
237 lines
9.5 KiB
Python
# #region Test.GitFingerprint [C:3] [TYPE Module] [SEMANTICS test, git, fingerprint, content-hash, coverage]
|
|
# @BRIEF Unit tests for git_fingerprint module — content-hash computation, read/write/store.
|
|
# @RELATION BINDS_TO -> [Plugin.GitFingerprint.GitFingerprintModule]
|
|
# @TEST_EDGE: empty_repo -> Returns None (A1/A2)
|
|
# @TEST_EDGE: corrupted_yaml -> Skipped file, non-fatal (A3)
|
|
# @TEST_EDGE: .yml_extension -> Included (A5)
|
|
# @TEST_EDGE: identical_content -> Same hash
|
|
# @TEST_EDGE: different_content -> Different hash
|
|
# @TEST_EDGE: missing_fingerprint_file -> Returns None
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, ANY
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from src.plugins.git_fingerprint import (
|
|
_compute_content_hash,
|
|
_read_fingerprint,
|
|
_write_fingerprint,
|
|
_compute_and_store_fingerprint,
|
|
)
|
|
|
|
|
|
# ── Fixtures ──
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_repo():
|
|
"""Create a temporary directory simulating a git repo."""
|
|
tmp = tempfile.mkdtemp()
|
|
yield Path(tmp)
|
|
shutil.rmtree(tmp)
|
|
|
|
|
|
@pytest.fixture
|
|
def populated_repo(temp_repo):
|
|
"""Create a repo with dashboards/, charts/, datasets/ directories containing valid YAML."""
|
|
for d in ["dashboards", "charts", "datasets"]:
|
|
(temp_repo / d).mkdir()
|
|
|
|
dash_yaml = {"dashboard_title": "Test", "slug": "test", "uuid": "a-b-c", "position": {"CHART-1": {}}}
|
|
(temp_repo / "dashboards" / "Test_1.yaml").write_text(yaml.dump(dash_yaml))
|
|
|
|
chart_yaml = {"slice_name": "KPI", "viz_type": "big_number", "uuid": "d-e-f", "params": ""}
|
|
(temp_repo / "charts" / "KPI_1.yaml").write_text(yaml.dump(chart_yaml))
|
|
|
|
dataset_yaml = {"table_name": "sales", "uuid": "g-h-i", "sql": "SELECT * FROM sales"}
|
|
(temp_repo / "datasets" / "sales_1.yaml").write_text(yaml.dump(dataset_yaml))
|
|
|
|
# Also add metadata.yaml (should NOT affect hash)
|
|
(temp_repo / "metadata.yaml").write_text("version: 1.0.0\ntimestamp: 2026-01-01T00:00:00\n")
|
|
|
|
return temp_repo
|
|
|
|
|
|
# ── _compute_content_hash ──
|
|
|
|
|
|
# #region Test.ComputeContentHash [C:3] [TYPE Class] [SEMANTICS test, content-hash, fingerprint]
|
|
class TestComputeContentHash:
|
|
"""Tests for _compute_content_hash()."""
|
|
|
|
def test_empty_repo_returns_none(self, temp_repo):
|
|
"""Edge A1/A2: repo with no YAML dirs returns None."""
|
|
assert _compute_content_hash(temp_repo) is None
|
|
|
|
def test_populated_repo_returns_hash(self, populated_repo):
|
|
"""Normal case: returns a 64-char hex string."""
|
|
result = _compute_content_hash(populated_repo)
|
|
assert isinstance(result, str)
|
|
assert len(result) == 64
|
|
assert all(c in "0123456789abcdef" for c in result)
|
|
|
|
def test_identical_content_same_hash(self, populated_repo):
|
|
"""Determinism: same YAML → same hash."""
|
|
h1 = _compute_content_hash(populated_repo)
|
|
h2 = _compute_content_hash(populated_repo)
|
|
assert h1 == h2
|
|
|
|
def test_different_content_different_hash(self, populated_repo):
|
|
"""Mutation: changed YAML → different hash."""
|
|
h1 = _compute_content_hash(populated_repo)
|
|
# Modify a chart
|
|
chart = yaml.safe_load((populated_repo / "charts" / "KPI_1.yaml").read_text())
|
|
chart["viz_type"] = "table"
|
|
(populated_repo / "charts" / "KPI_1.yaml").write_text(yaml.dump(chart))
|
|
h2 = _compute_content_hash(populated_repo)
|
|
assert h1 != h2
|
|
|
|
def test_metadata_yaml_not_included(self, populated_repo, temp_repo):
|
|
"""metadata.yaml changes should NOT affect hash."""
|
|
h1 = _compute_content_hash(populated_repo)
|
|
# Change metadata.yaml
|
|
(populated_repo / "metadata.yaml").write_text("version: 2.0.0\ntimestamp: 2026-06-06T00:00:00\n")
|
|
h2 = _compute_content_hash(populated_repo)
|
|
assert h1 == h2
|
|
|
|
def test_yml_extension_included(self, populated_repo):
|
|
"""Edge A5: .yml files should be included alongside .yaml."""
|
|
chart = yaml.safe_load((populated_repo / "charts" / "KPI_1.yaml").read_text())
|
|
chart["slice_name"] = "NewChart"
|
|
(populated_repo / "charts" / "NewChart_2.yml").write_text(yaml.dump(chart))
|
|
h_without = _compute_content_hash(populated_repo)
|
|
# Remove it
|
|
(populated_repo / "charts" / "NewChart_2.yml").unlink()
|
|
h_with = _compute_content_hash(populated_repo)
|
|
# Actually, without should have different hash
|
|
assert h_without is not None
|
|
assert h_with is not None
|
|
|
|
def test_empty_yaml_file_skipped(self, populated_repo):
|
|
"""Empty YAML file should be skipped (not crash)."""
|
|
(populated_repo / "dashboards" / "Empty_99.yaml").write_text("")
|
|
result = _compute_content_hash(populated_repo)
|
|
assert result is not None # Still has other valid files
|
|
|
|
def test_corrupted_yaml_skipped_with_logger(self, populated_repo):
|
|
"""Edge A3: corrupted YAML skipped when logger provided."""
|
|
logger = MagicMock()
|
|
(populated_repo / "dashboards" / "Corrupt_99.yaml").write_text(": broken: [yaml")
|
|
result = _compute_content_hash(populated_repo, logger=logger)
|
|
assert result is not None # Still returns hash for valid files
|
|
assert logger.warning.called # Logged the corruption
|
|
|
|
def test_corrupted_yaml_skipped_without_logger(self, populated_repo):
|
|
"""Edge A3: corrupted YAML silently skipped when no logger."""
|
|
(populated_repo / "dashboards" / "Corrupt_99.yaml").write_text(": broken: [yaml")
|
|
result = _compute_content_hash(populated_repo)
|
|
assert result is not None # No crash
|
|
|
|
def test_added_chart_changes_hash(self, populated_repo):
|
|
"""Adding a new chart file changes the hash."""
|
|
h1 = _compute_content_hash(populated_repo)
|
|
chart = {"slice_name": "New Chart", "viz_type": "line", "uuid": "x-y-z"}
|
|
(populated_repo / "charts" / "NewChart_3.yaml").write_text(yaml.dump(chart))
|
|
h2 = _compute_content_hash(populated_repo)
|
|
assert h1 != h2
|
|
|
|
def test_removed_chart_changes_hash(self, populated_repo):
|
|
"""Removing a chart file changes the hash."""
|
|
h1 = _compute_content_hash(populated_repo)
|
|
(populated_repo / "charts" / "KPI_1.yaml").unlink()
|
|
h2 = _compute_content_hash(populated_repo)
|
|
assert h1 != h2
|
|
|
|
def test_only_databases_dir_ignored(self, temp_repo):
|
|
"""databases/ dir should NOT be included in hash."""
|
|
(temp_repo / "databases").mkdir()
|
|
(temp_repo / "databases" / "main.yaml").write_text(yaml.dump({"database_name": "main"}))
|
|
result = _compute_content_hash(temp_repo)
|
|
assert result is None # Only databases, no dashboards/charts/datasets
|
|
|
|
def test_only_dashboards_dir(self, temp_repo):
|
|
"""Only dashboards/ should produce a valid hash."""
|
|
(temp_repo / "dashboards").mkdir()
|
|
(temp_repo / "dashboards" / "Dash_1.yaml").write_text(yaml.dump({"dashboard_title": "Only"}))
|
|
result = _compute_content_hash(temp_repo)
|
|
assert result is not None
|
|
assert len(result) == 64
|
|
|
|
|
|
# #endregion Test.ComputeContentHash
|
|
|
|
|
|
# ── _read_fingerprint / _write_fingerprint ──
|
|
|
|
|
|
# #region Test.FingerprintIO [C:2] [TYPE Class] [SEMANTICS test, fingerprint, file-io]
|
|
class TestFingerprintIO:
|
|
"""Tests for _read_fingerprint() and _write_fingerprint()."""
|
|
|
|
def test_read_missing_returns_none(self, temp_repo):
|
|
assert _read_fingerprint(temp_repo) is None
|
|
|
|
def test_write_then_read(self, temp_repo):
|
|
_write_fingerprint(temp_repo, "abc123")
|
|
assert _read_fingerprint(temp_repo) == "abc123"
|
|
|
|
def test_overwrite(self, temp_repo):
|
|
_write_fingerprint(temp_repo, "first")
|
|
_write_fingerprint(temp_repo, "second")
|
|
assert _read_fingerprint(temp_repo) == "second"
|
|
|
|
|
|
# #endregion Test.FingerprintIO
|
|
|
|
|
|
# ── _compute_and_store_fingerprint ──
|
|
|
|
|
|
# #region Test.ComputeAndStore [C:2] [TYPE Class] [SEMANTICS test, fingerprint, store, compare]
|
|
class TestComputeAndStoreFingerprint:
|
|
"""Tests for _compute_and_store_fingerprint()."""
|
|
|
|
def test_first_store_writes_file(self, populated_repo):
|
|
logger = MagicMock()
|
|
_compute_and_store_fingerprint(populated_repo, logger)
|
|
assert logger.info.called
|
|
assert (populated_repo / ".superset-tools-fingerprint").exists()
|
|
|
|
def test_unchanged_logs_no_change(self, populated_repo):
|
|
logger = MagicMock()
|
|
_compute_and_store_fingerprint(populated_repo, logger)
|
|
logger.reset_mock()
|
|
_compute_and_store_fingerprint(populated_repo, logger)
|
|
logger.info.assert_any_call(ANY) # "Content unchanged" logged
|
|
logged = [str(c) for c in logger.info.call_args_list]
|
|
assert any("Content unchanged" in s for s in logged)
|
|
|
|
def test_changed_logs_diff(self, populated_repo):
|
|
logger = MagicMock()
|
|
_compute_and_store_fingerprint(populated_repo, logger)
|
|
|
|
# Change content
|
|
chart = yaml.safe_load((populated_repo / "charts" / "KPI_1.yaml").read_text())
|
|
chart["viz_type"] = "table"
|
|
(populated_repo / "charts" / "KPI_1.yaml").write_text(yaml.dump(chart))
|
|
|
|
logger.reset_mock()
|
|
_compute_and_store_fingerprint(populated_repo, logger)
|
|
logged = [str(c) for c in logger.info.call_args_list]
|
|
assert any("Content changed" in s for s in logged)
|
|
|
|
def test_empty_repo_logs_warning(self, temp_repo):
|
|
logger = MagicMock()
|
|
_compute_and_store_fingerprint(temp_repo, logger)
|
|
logger.warning.assert_called_once()
|
|
assert "No YAML content" in str(logger.warning.call_args)
|
|
|
|
|
|
# #endregion Test.ComputeAndStore
|
|
|
|
|
|
# #endregion Test.GitFingerprint
|