diff --git a/.github/workflows/symmetry.yml b/.github/workflows/symmetry.yml new file mode 100644 index 0000000..ce2dba1 --- /dev/null +++ b/.github/workflows/symmetry.yml @@ -0,0 +1,95 @@ +name: Cross-Language Symmetry Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + symmetry: + name: MATLAB <-> Python symmetry tests + runs-on: ubuntu-latest + steps: + - name: Check out DID-python + uses: actions/checkout@v4 + + - name: Check out DID-matlab + uses: actions/checkout@v4 + with: + repository: VH-Lab/DID-matlab + path: DID-matlab + + # --- MATLAB setup --- + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v2 + with: + release: latest + cache: true + products: Statistics_and_Machine_Learning_Toolbox + + - name: Install MatBox + uses: ehennestad/matbox-actions/install-matbox@v1 + + - name: Install MATLAB dependencies (mksqlite etc.) + uses: matlab-actions/run-command@v2 + with: + command: | + addpath(genpath("DID-matlab/src")); + addpath(genpath("DID-matlab/tools")); + matbox.installRequirements(fullfile(pwd, "DID-matlab")); + + # --- Python setup --- + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + # --- Step 1: MATLAB makeArtifacts --- + - name: "Step 1: MATLAB makeArtifact tests" + uses: matlab-actions/run-command@v2 + with: + command: | + addpath(genpath("DID-matlab/src")); + addpath(genpath("DID-matlab/tests")); + addpath(genpath("DID-matlab/tools")); + import matlab.unittest.TestRunner; + import matlab.unittest.TestSuite; + runner = TestRunner.withTextOutput; + makeSuite = TestSuite.fromPackage("did.symmetry.makeArtifacts", "IncludingSubpackages", true); + makeResults = runner.run(makeSuite); + disp(table(makeResults)); + assert(all([makeResults.Passed]), "makeArtifacts tests failed"); + + # --- Step 2: Python makeArtifacts + readArtifacts --- + - name: "Step 2: Python makeArtifact tests" + run: pytest -m make_artifacts -v + + - name: "Step 2: Python readArtifact tests" + run: pytest -m read_artifacts -v + + # --- Step 3: MATLAB readArtifacts --- + - name: "Step 3: MATLAB readArtifact tests" + uses: matlab-actions/run-command@v2 + with: + command: | + addpath(genpath("DID-matlab/src")); + addpath(genpath("DID-matlab/tests")); + addpath(genpath("DID-matlab/tools")); + import matlab.unittest.TestRunner; + import matlab.unittest.TestSuite; + runner = TestRunner.withTextOutput; + readSuite = TestSuite.fromPackage("did.symmetry.readArtifacts", "IncludingSubpackages", true); + readResults = runner.run(readSuite); + disp(table(readResults)); + nFailed = sum([readResults.Failed]); + nPassed = sum([readResults.Passed]); + nSkipped = sum([readResults.Incomplete]); + fprintf("Results: %d passed, %d failed, %d skipped\n", nPassed, nFailed, nSkipped); + assert(nFailed == 0, "readArtifacts tests failed"); diff --git a/README.md b/README.md index 15cc414..080fff9 100644 --- a/README.md +++ b/README.md @@ -43,17 +43,55 @@ The `did` library provides a framework for managing and querying data that is or You can run the tests using either `pytest` (if you installed the development dependencies) or the standard `unittest` module. -**Using pytest (Recommended for development):** +**Run all tests (unit + symmetry):** ```bash pytest ``` -**Using unittest (Standard):** +**Run only the unit tests (excluding symmetry tests):** +```bash +pytest tests/ --ignore=tests/symmetry +``` + +**Run only the symmetry tests:** +```bash +pytest -m symmetry +``` + +**Run only the makeArtifact symmetry tests** (generate cross-language artifacts): +```bash +pytest -m make_artifacts +``` + +**Run only the readArtifact symmetry tests** (validate artifacts from Python and/or MATLAB): +```bash +pytest -m read_artifacts +``` + +**Using unittest (unit tests only):** ```bash python -m unittest discover tests ``` -Both commands will discover and run all the tests in the `tests` directory. +#### Symmetry Tests + +The `tests/symmetry/` directory contains cross-language symmetry tests that verify +DID databases created in Python can be read by MATLAB and vice versa: + +* **`make_artifacts/`** — Creates a DID database with multiple branches and + documents, then writes the database file and JSON summary artifacts to a + well-known temporary directory + (`/DID/symmetryTest/pythonArtifacts/`). +* **`read_artifacts/`** — Reads artifacts produced by either the Python or + MATLAB test suite, re-summarizes the live database, and compares the + result against the saved summary. Tests are parameterized over + `matlabArtifacts` and `pythonArtifacts` and skip gracefully when + artifacts from a given source are not available. + +The CI workflow runs the full cross-language cycle: +1. MATLAB `makeArtifact` tests create artifacts +2. Python `makeArtifact` and `readArtifact` tests run (reading MATLAB artifacts) +3. MATLAB `readArtifact` tests run (reading Python artifacts) ## Documentation diff --git a/pyproject.toml b/pyproject.toml index 5ff0f36..dd178c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,14 @@ dev = [ "pytest", ] +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "symmetry: cross-language symmetry tests (MATLAB <-> Python)", + "make_artifacts: tests that generate artifacts for symmetry testing", + "read_artifacts: tests that read and validate artifacts from another implementation", +] + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/did/datastructures.py b/src/did/datastructures.py index 72697d4..b400d96 100644 --- a/src/did/datastructures.py +++ b/src/did/datastructures.py @@ -267,8 +267,7 @@ def field_search(a, search_struct): b = True break elif op_lower == "or": - if isinstance(param1, dict) and isinstance(param2, dict): - b = field_search(a, param1) or field_search(a, param2) + b = field_search(a, param1) or field_search(a, param2) elif op_lower == "depends_on": # param1 = dependency name, param2 = dependency value if "depends_on" in a: diff --git a/src/did/document.py b/src/did/document.py index ab66a63..08fa340 100644 --- a/src/did/document.py +++ b/src/did/document.py @@ -16,10 +16,18 @@ def __init__(self, document_type="base", **kwargs): self.document_properties["base"]["datestamp"] = str(datetime.utcnow()) for key, value in kwargs.items(): - # This is a simplified way to set properties. A full implementation - # would need to handle nested properties like 'base.name'. - if key in self.document_properties: - self.document_properties[key] = value + path = key.split(".") + if len(path) == 1: + if key in self.document_properties: + self.document_properties[key] = value + else: + d = self.document_properties + for p in path[:-1]: + existing = d.get(p) + if not isinstance(existing, dict): + d[p] = {} + d = d[p] + d[path[-1]] = value self._reset_file_info() @@ -95,6 +103,8 @@ def read_blank_definition(json_file_location_string): # Ensure the 'base' key exists if "base" not in data: data["base"] = {} + # Convert flat classname/superclasses to document_class format + data = Document._normalize_to_document_class(data) return data # Fallback for base @@ -113,6 +123,21 @@ def read_blank_definition(json_file_location_string): f"Could not find definition for {json_file_location_string}" ) + @staticmethod + def _normalize_to_document_class(data): + """Convert flat schema format to MATLAB-compatible document_class format.""" + if "document_class" in data: + return data + class_name = data.pop("classname", "") + superclasses = data.pop("superclasses", []) + data["document_class"] = { + "class_name": class_name, + "property_list_name": class_name, + "class_version": 1, + "superclasses": superclasses, + } + return data + def dependency_value(self, dependency_name, error_if_not_found=True): if "depends_on" in self.document_properties: for dep in self.document_properties["depends_on"]: diff --git a/src/did/implementations/sqlitedb.py b/src/did/implementations/sqlitedb.py index 962e6c2..2a20520 100644 --- a/src/did/implementations/sqlitedb.py +++ b/src/did/implementations/sqlitedb.py @@ -261,11 +261,11 @@ def _normalize_loaded_props(props): """ dc = props.get("document_class", {}) sc = dc.get("superclasses") - if isinstance(sc, dict): + if sc is not None and not isinstance(sc, list): dc["superclasses"] = [sc] dep = props.get("depends_on") - if isinstance(dep, dict): + if dep is not None and not isinstance(dep, list): props["depends_on"] = [dep] return props diff --git a/src/did/util/__init__.py b/src/did/util/__init__.py new file mode 100644 index 0000000..a875053 --- /dev/null +++ b/src/did/util/__init__.py @@ -0,0 +1,4 @@ +from .database_summary import database_summary as database_summary +from .compare_database_summary import ( + compare_database_summary as compare_database_summary, +) diff --git a/src/did/util/compare_database_summary.py b/src/did/util/compare_database_summary.py new file mode 100644 index 0000000..5cc5830 --- /dev/null +++ b/src/did/util/compare_database_summary.py @@ -0,0 +1,132 @@ +"""Compare two database summaries and return a list of discrepancy messages. + +Mirrors MATLAB's did.util.compareDatabaseSummary for cross-language symmetry testing. +""" + + +def compare_database_summary(summary_a, summary_b): + """Compare two summary dicts and return a list of mismatch descriptions. + + Returns an empty list when the summaries are equivalent. + """ + report = [] + + branches_a = summary_a.get("branchNames", []) + branches_b = summary_b.get("branchNames", []) + + only_in_a = set(branches_a) - set(branches_b) + only_in_b = set(branches_b) - set(branches_a) + + for name in sorted(only_in_a): + report.append(f'Branch "{name}" exists only in summary A.') + for name in sorted(only_in_b): + report.append(f'Branch "{name}" exists only in summary B.') + + # Compare branch hierarchy + hier_a = summary_a.get("branchHierarchy", {}) + hier_b = summary_b.get("branchHierarchy", {}) + common_branches = sorted(set(branches_a) & set(branches_b)) + + for branch_name in common_branches: + if branch_name in hier_a and branch_name in hier_b: + parent_a = hier_a[branch_name].get("parent", "") + parent_b = hier_b[branch_name].get("parent", "") + if parent_a != parent_b: + report.append( + f'Branch "{branch_name}": parent mismatch ' + f'("{parent_a}" vs "{parent_b}").' + ) + + # Compare per-branch documents + br_a = summary_a.get("branches", {}) + br_b = summary_b.get("branches", {}) + + for branch_name in common_branches: + if branch_name not in br_a or branch_name not in br_b: + continue + + branch_a = br_a[branch_name] + branch_b = br_b[branch_name] + + if branch_a["docCount"] != branch_b["docCount"]: + report.append( + f'Branch "{branch_name}": doc count mismatch ' + f'({branch_a["docCount"]} vs {branch_b["docCount"]}).' + ) + + map_a = {d["id"]: d for d in branch_a.get("documents", [])} + map_b = {d["id"]: d for d in branch_b.get("documents", [])} + + missing_in_a = sorted(set(map_b) - set(map_a)) + missing_in_b = sorted(set(map_a) - set(map_b)) + + for doc_id in missing_in_a: + report.append( + f'Branch "{branch_name}": doc "{doc_id}" missing in summary A.' + ) + for doc_id in missing_in_b: + report.append( + f'Branch "{branch_name}": doc "{doc_id}" missing in summary B.' + ) + + for doc_id in sorted(set(map_a) & set(map_b)): + doc_a = map_a[doc_id] + doc_b = map_b[doc_id] + + if doc_a.get("className", "") != doc_b.get("className", ""): + report.append( + f'Branch "{branch_name}", doc "{doc_id}": class name mismatch ' + f'("{doc_a["className"]}" vs "{doc_b["className"]}").' + ) + + props_a = doc_a.get("properties", {}) + props_b = doc_b.get("properties", {}) + + for field in ("demoA", "demoB", "demoC"): + has_a = field in props_a + has_b = field in props_b + if has_a and has_b: + val_a = ( + props_a[field].get("value") + if isinstance(props_a[field], dict) + else props_a[field] + ) + val_b = ( + props_b[field].get("value") + if isinstance(props_b[field], dict) + else props_b[field] + ) + if val_a != val_b: + report.append( + f'Branch "{branch_name}", doc "{doc_id}": ' + f"{field}.value mismatch ({val_a} vs {val_b})." + ) + elif has_a != has_b: + report.append( + f'Branch "{branch_name}", doc "{doc_id}": ' + f'field "{field}" present in one summary but not the other.' + ) + + # Compare depends_on + deps_a = props_a.get("depends_on", []) + deps_b = props_b.get("depends_on", []) + if isinstance(deps_a, dict): + deps_a = [deps_a] + if isinstance(deps_b, dict): + deps_b = [deps_b] + norm_a = [ + (d.get("name", ""), d.get("value", "")) + for d in deps_a + if isinstance(d, dict) + ] + norm_b = [ + (d.get("name", ""), d.get("value", "")) + for d in deps_b + if isinstance(d, dict) + ] + if norm_a != norm_b: + report.append( + f'Branch "{branch_name}", doc "{doc_id}": depends_on mismatch.' + ) + + return report diff --git a/src/did/util/database_summary.py b/src/did/util/database_summary.py new file mode 100644 index 0000000..ad609b0 --- /dev/null +++ b/src/did/util/database_summary.py @@ -0,0 +1,77 @@ +"""Produce a summary dict of a DID database and its branches. + +Mirrors MATLAB's did.util.databaseSummary for cross-language symmetry testing. +""" + + +def database_summary(db): + """Return a dict summarizing every branch and document in *db*. + + The returned dict contains: + - ``branchNames``: sorted list of all branch IDs + - ``branchHierarchy``: dict mapping each branch name to its parent + - ``branches``: dict keyed by branch name, each containing: + - ``branchName`` + - ``docCount`` + - ``documents``: list of dicts with ``id``, ``className``, ``properties`` + - ``dbFilename``: empty string (caller should fill in) + + Documents within each branch are sorted by ID for determinism. + """ + summary = {} + summary["dbFilename"] = "" + + branch_names = db.all_branch_ids() + if isinstance(branch_names, str): + branch_names = [branch_names] + branch_names = sorted(branch_names) + summary["branchNames"] = branch_names + + # Branch hierarchy + branch_hierarchy = {} + for branch_name in branch_names: + parent = db.get_branch_parent(branch_name) + if parent is None: + parent = "" + branch_hierarchy[branch_name] = { + "branchName": branch_name, + "parent": parent, + } + summary["branchHierarchy"] = branch_hierarchy + + # Per-branch document summaries + branches = {} + for branch_name in branch_names: + doc_ids = db.get_doc_ids(branch_name) + if isinstance(doc_ids, str): + doc_ids = [doc_ids] + if not doc_ids: + doc_ids = [] + doc_ids = sorted(doc_ids) + + doc_summaries = [] + for doc_id in doc_ids: + doc = db.get_docs(doc_id) + props = doc.document_properties + + class_name = "" + dc = props.get("document_class", {}) + if isinstance(dc, dict): + class_name = dc.get("class_name", "") + + doc_summaries.append( + { + "id": doc_id, + "className": class_name, + "properties": props, + } + ) + + branches[branch_name] = { + "branchName": branch_name, + "docCount": len(doc_ids), + "documents": doc_summaries, + } + summary["branches"] = branches + + return summary diff --git a/tests/symmetry/__init__.py b/tests/symmetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/conftest.py b/tests/symmetry/conftest.py new file mode 100644 index 0000000..52bb6d6 --- /dev/null +++ b/tests/symmetry/conftest.py @@ -0,0 +1,29 @@ +"""Shared constants and fixtures for DID symmetry tests.""" + +import os +import tempfile + +import pytest + +SYMMETRY_BASE = os.path.join(tempfile.gettempdir(), "DID", "symmetryTest") +PYTHON_ARTIFACTS = os.path.join(SYMMETRY_BASE, "pythonArtifacts") +MATLAB_ARTIFACTS = os.path.join(SYMMETRY_BASE, "matlabArtifacts") +SOURCE_TYPES = ["matlabArtifacts", "pythonArtifacts"] + + +def pytest_collection_modifyitems(config, items): + """Auto-apply symmetry markers based on test file path.""" + for item in items: + path = str(item.fspath) + if "symmetry" in path: + item.add_marker(pytest.mark.symmetry) + if os.path.join("make_artifacts", "") in path: + item.add_marker(pytest.mark.make_artifacts) + if os.path.join("read_artifacts", "") in path: + item.add_marker(pytest.mark.read_artifacts) + + +@pytest.fixture(params=SOURCE_TYPES) +def source_type(request): + """Parameterized fixture that yields each artifact source type.""" + return request.param diff --git a/tests/symmetry/make_artifacts/__init__.py b/tests/symmetry/make_artifacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/make_artifacts/database/__init__.py b/tests/symmetry/make_artifacts/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/make_artifacts/database/test_build_database.py b/tests/symmetry/make_artifacts/database/test_build_database.py new file mode 100644 index 0000000..3161ce8 --- /dev/null +++ b/tests/symmetry/make_artifacts/database/test_build_database.py @@ -0,0 +1,110 @@ +"""makeArtifact symmetry test: build a DID database and export summary artifacts. + +Mirrors DID-matlab's tests/+did/+symmetry/+makeArtifacts/+database/buildDatabase.m. + +Artifacts are written to: + /DID/symmetryTest/pythonArtifacts/database/buildDatabase/testBuildDatabaseArtifacts/ + +The readArtifact counterpart (and the MATLAB readArtifact test) will later open +these artifacts and compare them against a live database summary. +""" + +import json +import os +import random +import shutil + +import numpy as np + +from did.implementations.sqlitedb import SQLiteDB +from did.util import compare_database_summary, database_summary +from tests.helpers import make_doc_tree +from tests.symmetry.conftest import PYTHON_ARTIFACTS + +DB_FILENAME = "symmetry_test.sqlite" + +ARTIFACT_DIR = os.path.join( + PYTHON_ARTIFACTS, + "database", + "buildDatabase", + "testBuildDatabaseArtifacts", +) + + +class TestBuildDatabase: + """Generate DID database artifacts for cross-language symmetry testing.""" + + def test_build_database_artifacts(self): + # Use fixed seeds for reproducibility + random.seed(0) + np.random.seed(0) + + # Clean previous artifacts + if os.path.isdir(ARTIFACT_DIR): + shutil.rmtree(ARTIFACT_DIR) + os.makedirs(ARTIFACT_DIR, exist_ok=True) + + # Step 1: Create the database + db_path = os.path.join(ARTIFACT_DIR, DB_FILENAME) + db = SQLiteDB(db_path) + + # Step 2: Create 3 branches in a simple hierarchy: + # branch_main + # +-- branch_dev + # +-- branch_feature + branch_names = ["branch_main", "branch_dev", "branch_feature"] + + # Root branch with documents + db.add_branch(branch_names[0]) + _, _, root_docs = make_doc_tree([3, 3, 3]) + db.add_docs(root_docs, branch_id=branch_names[0]) + + # branch_dev as child of branch_main + db.set_branch(branch_names[0]) + db.add_branch(branch_names[1]) + _, _, dev_docs = make_doc_tree([2, 2, 2]) + db.add_docs(dev_docs, branch_id=branch_names[1]) + + # branch_feature as child of branch_main + db.set_branch(branch_names[0]) + db.add_branch(branch_names[2]) + _, _, feature_docs = make_doc_tree([2, 1, 2]) + db.add_docs(feature_docs, branch_id=branch_names[2]) + + # Step 3: Generate summary + summary = database_summary(db) + summary["dbFilename"] = DB_FILENAME + + # Step 4: Write per-branch JSON files + json_branches_dir = os.path.join(ARTIFACT_DIR, "jsonBranches") + os.makedirs(json_branches_dir, exist_ok=True) + + for branch_name in branch_names: + branch_data = summary["branches"][branch_name] + branch_json_path = os.path.join( + json_branches_dir, f"branch_{branch_name}.json" + ) + with open(branch_json_path, "w") as f: + json.dump(branch_data, f, indent=2) + + # Write the full summary JSON + summary_path = os.path.join(ARTIFACT_DIR, "summary.json") + with open(summary_path, "w") as f: + json.dump(summary, f, indent=2) + + # Step 5: Verify artifacts were created + assert os.path.isfile(db_path), "Database file was not created." + assert os.path.isfile(summary_path), "summary.json was not created." + for branch_name in branch_names: + branch_file = os.path.join(json_branches_dir, f"branch_{branch_name}.json") + assert os.path.isfile( + branch_file + ), f"Branch JSON file missing for {branch_name}" + + # Step 6: Self-check -- re-summarize and compare + summary_check = database_summary(db) + summary_check["dbFilename"] = DB_FILENAME + self_report = compare_database_summary(summary, summary_check) + assert self_report == [], f"Self-check failed: {'; '.join(self_report)}" + + db._close_db() diff --git a/tests/symmetry/read_artifacts/__init__.py b/tests/symmetry/read_artifacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/read_artifacts/database/__init__.py b/tests/symmetry/read_artifacts/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/read_artifacts/database/test_build_database.py b/tests/symmetry/read_artifacts/database/test_build_database.py new file mode 100644 index 0000000..91a75df --- /dev/null +++ b/tests/symmetry/read_artifacts/database/test_build_database.py @@ -0,0 +1,109 @@ +"""readArtifact symmetry test: read and validate DID database artifacts. + +Mirrors DID-matlab's tests/+did/+symmetry/+readArtifacts/+database/buildDatabase.m. + +This test is parameterized over SOURCE_TYPES (matlabArtifacts, pythonArtifacts). +It reads artifacts produced by either the Python makeArtifact test above or by +the MATLAB makeArtifact test, then validates them against a live database summary. +""" + +import json +import os + +import pytest + +from did.implementations.sqlitedb import SQLiteDB +from did.util import compare_database_summary, database_summary +from tests.symmetry.conftest import SYMMETRY_BASE + + +class TestReadBuildDatabase: + """Read and validate DID database artifacts for cross-language symmetry testing.""" + + def test_build_database_artifacts(self, source_type): + artifact_dir = os.path.join( + SYMMETRY_BASE, + source_type, + "database", + "buildDatabase", + "testBuildDatabaseArtifacts", + ) + + if not os.path.isdir(artifact_dir): + pytest.skip( + f"Artifact directory from {source_type} does not exist: {artifact_dir}" + ) + + # Step 1: Load the saved summary + summary_file = os.path.join(artifact_dir, "summary.json") + if not os.path.isfile(summary_file): + pytest.skip(f"summary.json not found in {source_type} artifact directory.") + + with open(summary_file, "r") as f: + saved_summary = json.load(f) + + assert "branchNames" in saved_summary, "summary.json missing branchNames field." + assert "dbFilename" in saved_summary, "summary.json missing dbFilename field." + + # Step 2: Open the DID database and produce a live summary + db_path = os.path.join(artifact_dir, saved_summary["dbFilename"]) + if not os.path.isfile(db_path): + pytest.skip(f"Database file not found: {db_path}") + + db = SQLiteDB(db_path) + live_summary = database_summary(db) + + # Step 3: Compare the saved summary against the live database summary + report = compare_database_summary(saved_summary, live_summary) + assert ( + report == [] + ), f"Database summary mismatch for {source_type}: {'; '.join(report)}" + + # Step 4: Also verify per-branch JSON files match the live database + branch_names = saved_summary["branchNames"] + if isinstance(branch_names, str): + branch_names = [branch_names] + + json_branches_dir = os.path.join(artifact_dir, "jsonBranches") + if not os.path.isdir(json_branches_dir): + pytest.skip(f"jsonBranches directory not found in {source_type}") + + for branch_name in branch_names: + branch_json_file = os.path.join( + json_branches_dir, f"branch_{branch_name}.json" + ) + if not os.path.isfile(branch_json_file): + pytest.skip( + f"Branch JSON file missing for {branch_name} in {source_type}" + ) + + with open(branch_json_file, "r") as f: + saved_branch = json.load(f) + + # Verify document count matches the live database + actual_doc_ids = db.get_doc_ids(branch_name) + assert ( + len(actual_doc_ids) == saved_branch["docCount"] + ), f"Document count mismatch in branch {branch_name} from {source_type}" + + # Verify each saved document exists in the live database + saved_docs = saved_branch.get("documents", []) + for saved_doc in saved_docs: + expected_id = saved_doc["id"] + doc = db.get_docs(expected_id, OnMissing="ignore") + assert doc is not None, ( + f"Document {expected_id} from {source_type} " + f"not found in database branch {branch_name}" + ) + + if doc is not None: + actual_props = doc.document_properties + actual_class = actual_props.get("document_class", {}).get( + "class_name", "" + ) + assert actual_class == saved_doc["className"], ( + f"Class name mismatch for doc {expected_id} " + f"in branch {branch_name} from {source_type}" + ) + + db._close_db()