From bba4b0752ad6ea501344971e2c3e401b7b305fb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 16:54:20 +0000 Subject: [PATCH 1/3] Add ndi.util.datasetSummary and compareDatasetSummary utilities Extract dataset summary logic from symmetry tests into public ndi.util functions (mirroring MATLAB's ndi.util.datasetSummary and ndi.util.compareDatasetSummary). Simplify symmetry tests to use the new utilities instead of inline summary building and comparison. Add 14 unit tests covering both functions. https://claude.ai/code/session_01EctVW1VcbY2LzAdfZrGBB5 --- src/ndi/util/__init__.py | 4 + src/ndi/util/compare_dataset_summary.py | 119 +++++++ src/ndi/util/dataset_summary.py | 42 +++ src/ndi/util/ndi_matlab_python_bridge.yaml | 83 +++++ .../dataset/test_build_dataset.py | 27 +- .../dataset/test_download_ingested.py | 26 +- .../dataset/test_build_dataset.py | 70 +---- .../dataset/test_download_ingested.py | 94 ++---- tests/test_dataset_summary.py | 291 ++++++++++++++++++ 9 files changed, 581 insertions(+), 175 deletions(-) create mode 100644 src/ndi/util/compare_dataset_summary.py create mode 100644 src/ndi/util/dataset_summary.py create mode 100644 tests/test_dataset_summary.py diff --git a/src/ndi/util/__init__.py b/src/ndi/util/__init__.py index 96fca72..3d3b07c 100644 --- a/src/ndi/util/__init__.py +++ b/src/ndi/util/__init__.py @@ -14,7 +14,9 @@ """ from .classname import ndi_matlab_classname, ndi_python_classname +from .compare_dataset_summary import compareDatasetSummary from .compare_session_summary import compareSessionSummary +from .dataset_summary import datasetSummary from .datestamp2datetime import datestamp2datetime from .downsampleTimeseries import downsampleTimeseries from .getHexDiffFromFileObj import getHexDiffFromFileObj @@ -28,7 +30,9 @@ __all__ = [ "ndi_matlab_classname", "ndi_python_classname", + "compareDatasetSummary", "compareSessionSummary", + "datasetSummary", "datestamp2datetime", "downsampleTimeseries", "getHexDiffFromFileObj", diff --git a/src/ndi/util/compare_dataset_summary.py b/src/ndi/util/compare_dataset_summary.py new file mode 100644 index 0000000..e786982 --- /dev/null +++ b/src/ndi/util/compare_dataset_summary.py @@ -0,0 +1,119 @@ +"""Compare two dataset summaries and return a report of differences. + +MATLAB equivalent: ``ndi.util.compareDatasetSummary`` + +Compares two summary dicts (as produced by :func:`datasetSummary`) and +returns a list of human-readable difference strings. +""" + +from __future__ import annotations + +from typing import Any + +from .compare_session_summary import compareSessionSummary + + +def compareDatasetSummary( + summary1: dict[str, Any], + summary2: dict[str, Any], + *, + excludeFiles: list[str] | None = None, + excludeFields: list[str] | None = None, +) -> list[str]: + """Compare two dataset summaries and return a report. + + MATLAB equivalent: ``ndi.util.compareDatasetSummary(s1, s2, ...)`` + + Args: + summary1: First dataset summary dict. + summary2: Second dataset summary dict. + excludeFiles: Filenames to ignore when comparing file lists + within session summaries. + excludeFields: Field names to skip entirely during comparison. + + Returns: + List of difference strings. Empty list means summaries match. + """ + if excludeFiles is None: + excludeFiles = [] + if excludeFields is None: + excludeFields = [] + + report: list[str] = [] + + # 1. Compare numSessions + if "numSessions" not in excludeFields: + n1 = summary1.get("numSessions", 0) + n2 = summary2.get("numSessions", 0) + if n1 != n2: + report.append( + f"numSessions differs: {n1} vs {n2}" + ) + + # 2. Compare references + if "references" not in excludeFields: + refs1 = sorted(summary1.get("references", [])) + refs2 = sorted(summary2.get("references", [])) + if refs1 != refs2: + report.append( + f"references differ: {refs1} vs {refs2}" + ) + + # 3. Compare sessionIds + if "sessionIds" not in excludeFields: + ids1 = sorted(summary1.get("sessionIds", [])) + ids2 = sorted(summary2.get("sessionIds", [])) + if ids1 != ids2: + report.append( + f"sessionIds differ: {ids1} vs {ids2}" + ) + + # 4. Compare sessionSummaries + if "sessionSummaries" not in excludeFields: + ss1 = summary1.get("sessionSummaries", []) + ss2 = summary2.get("sessionSummaries", []) + + if len(ss1) != len(ss2): + report.append( + f"sessionSummaries count differs: {len(ss1)} vs {len(ss2)}" + ) + else: + # Match session summaries by sessionId when available, + # otherwise compare by index order. + ids1 = summary1.get("sessionIds", []) + ids2 = summary2.get("sessionIds", []) + + if len(ids1) == len(ss1) and len(ids2) == len(ss2): + # Build lookup by sessionId for summary2 + lookup2: dict[str, dict] = {} + for sid, ss in zip(ids2, ss2): + lookup2[sid] = ss + + for i, sid in enumerate(ids1): + match = lookup2.get(sid) + if match is None: + report.append( + f"sessionSummaries: session {sid} not found in summary2" + ) + continue + sub = compareSessionSummary( + ss1[i], + match, + excludeFiles=excludeFiles, + excludeFields=excludeFields, + ) + for s in sub: + report.append(f"sessionSummaries[{sid}]: {s}") + else: + # Fallback: compare by index + for i, (s1, s2) in enumerate(zip(ss1, ss2)): + sub = compareSessionSummary( + s1, + s2, + excludeFiles=excludeFiles, + excludeFields=excludeFields, + ) + for s in sub: + report.append(f"sessionSummaries[{i}]: {s}") + + return report diff --git a/src/ndi/util/dataset_summary.py b/src/ndi/util/dataset_summary.py new file mode 100644 index 0000000..c9a9552 --- /dev/null +++ b/src/ndi/util/dataset_summary.py @@ -0,0 +1,42 @@ +"""ndi_dataset summary utility for symmetry testing. + +MATLAB equivalent: ``ndi.util.datasetSummary`` + +Creates a summary dict of an ``ndi.dataset.Dataset`` object containing key +fields and properties, intended for symmetry testing between NDI language +implementations. +""" + +from __future__ import annotations + +from typing import Any + +from .session_summary import sessionSummary + + +def datasetSummary(dataset_obj: Any) -> dict[str, Any]: + """Create a summary structure of an ndi.dataset.Dataset object. + + MATLAB equivalent: ``ndi.util.datasetSummary(dataset_obj)`` + + Args: + dataset_obj: An NDI Dataset object. + + Returns: + Dict with keys: numSessions, references, sessionIds, + sessionSummaries. + """ + refs, session_ids, *_ = dataset_obj.session_list() + + # Build a session summary for each session in the dataset + session_summaries = [] + for sid in session_ids: + sess = dataset_obj.open_session(sid) + session_summaries.append(sessionSummary(sess)) + + return { + "numSessions": len(refs), + "references": refs, + "sessionIds": session_ids, + "sessionSummaries": session_summaries, + } diff --git a/src/ndi/util/ndi_matlab_python_bridge.yaml b/src/ndi/util/ndi_matlab_python_bridge.yaml index f257165..3f2b3b2 100644 --- a/src/ndi/util/ndi_matlab_python_bridge.yaml +++ b/src/ndi/util/ndi_matlab_python_bridge.yaml @@ -174,6 +174,89 @@ functions: Exact match. Replaces NDI sentinel strings for NaN, Infinity, and -Infinity in JSON text with Python-compatible representations. + - name: sessionSummary + type: function + matlab_path: "+ndi/+util/sessionSummary.m" + python_path: "ndi/util/session_summary.py" + input_arguments: + - name: session_obj + type_matlab: "ndi.session" + type_python: "Any" + output_arguments: + - name: summary + type_python: "dict[str, Any]" + decision_log: > + Exact match. Creates a summary dict of an ndi.session object + for symmetry testing. + + - name: compareSessionSummary + type: function + matlab_path: "+ndi/+util/compareSessionSummary.m" + python_path: "ndi/util/compare_session_summary.py" + input_arguments: + - name: summary1 + type_matlab: "struct" + type_python: "dict[str, Any]" + - name: summary2 + type_matlab: "struct" + type_python: "dict[str, Any]" + - name: excludeFiles + type_matlab: "cell array of char" + type_python: "list[str] | None" + default: "None" + - name: excludeFields + type_matlab: "cell array of char" + type_python: "list[str] | None" + default: "None" + output_arguments: + - name: report + type_python: "list[str]" + decision_log: > + Exact match. Compares two session summaries and returns a list + of difference strings. Empty list means summaries match. + + - name: datasetSummary + type: function + matlab_path: "+ndi/+util/datasetSummary.m" + python_path: "ndi/util/dataset_summary.py" + input_arguments: + - name: dataset_obj + type_matlab: "ndi.dataset.Dataset" + type_python: "Any" + output_arguments: + - name: summary + type_python: "dict[str, Any]" + decision_log: > + Exact match. Creates a summary dict of an ndi.dataset.Dataset + object for symmetry testing. + + - name: compareDatasetSummary + type: function + matlab_path: "+ndi/+util/compareDatasetSummary.m" + python_path: "ndi/util/compare_dataset_summary.py" + input_arguments: + - name: summary1 + type_matlab: "struct" + type_python: "dict[str, Any]" + - name: summary2 + type_matlab: "struct" + type_python: "dict[str, Any]" + - name: excludeFiles + type_matlab: "cell array of char" + type_python: "list[str] | None" + default: "None" + - name: excludeFields + type_matlab: "cell array of char" + type_python: "list[str] | None" + default: "None" + output_arguments: + - name: report + type_python: "list[str]" + decision_log: > + Exact match. Compares two dataset summaries and returns a list + of difference strings. Delegates session-level comparison to + compareSessionSummary. + - name: unwrapTableCellContent type: function matlab_path: "+ndi/+util/unwrapTableCellContent.m" diff --git a/tests/symmetry/make_artifacts/dataset/test_build_dataset.py b/tests/symmetry/make_artifacts/dataset/test_build_dataset.py index 327a76f..c31c77a 100644 --- a/tests/symmetry/make_artifacts/dataset/test_build_dataset.py +++ b/tests/symmetry/make_artifacts/dataset/test_build_dataset.py @@ -24,7 +24,7 @@ from ndi.document import Document from ndi.query import Query from ndi.session.dir import DirSession -from ndi.util import sessionSummary +from ndi.util import datasetSummary from tests.symmetry.conftest import PYTHON_ARTIFACTS ARTIFACT_DIR = PYTHON_ARTIFACTS / "dataset" / "buildDataset" / "testBuildDatasetArtifacts" @@ -46,29 +46,6 @@ def _add_doc_with_file(session: DirSession, doc_number: int) -> None: session.database_add(doc) -def _dataset_summary(dataset: Dataset) -> dict: - """Create a summary structure for a dataset. - - Mirrors MATLAB's ``ndi.symmetry.makeArtifacts.dataset.buildDataset`` - which writes: numSessions, references, sessionIds, sessionSummaries. - """ - refs, session_ids, *_ = dataset.session_list() - num_sessions = len(refs) - - # Build a session summary for each session in the dataset - session_summaries = [] - for sid in session_ids: - sess = dataset.open_session(sid) - session_summaries.append(sessionSummary(sess)) - - return { - "numSessions": num_sessions, - "references": refs, - "sessionIds": session_ids, - "sessionSummaries": session_summaries, - } - - class TestBuildDataset: """Mirror of ndi.symmetry.makeArtifacts.dataset.buildDataset.""" @@ -132,7 +109,7 @@ def test_build_dataset_artifacts(self): # Write datasetSummary.json – open from artifact_dir so the session # path lists files that are actually present (including jsonDocuments). - summary = _dataset_summary(artifact_dataset) + summary = datasetSummary(artifact_dataset) summary_json = json.dumps(summary, indent=2, allow_nan=True) summary_path = artifact_dir / "datasetSummary.json" summary_path.write_text(summary_json, encoding="utf-8") diff --git a/tests/symmetry/make_artifacts/dataset/test_download_ingested.py b/tests/symmetry/make_artifacts/dataset/test_download_ingested.py index 39c5ae6..1604a0f 100644 --- a/tests/symmetry/make_artifacts/dataset/test_download_ingested.py +++ b/tests/symmetry/make_artifacts/dataset/test_download_ingested.py @@ -24,7 +24,7 @@ from ndi.dataset import Dataset from ndi.query import Query -from ndi.util import sessionSummary +from ndi.util import datasetSummary from tests.symmetry.conftest import PYTHON_ARTIFACTS ARTIFACT_DIR = PYTHON_ARTIFACTS / "dataset" / "downloadIngested" / "testDownloadIngestedArtifacts" @@ -99,31 +99,17 @@ def test_download_ingested_artifacts(self): # Open the dataset dataset = Dataset(dataset_path) - # Get session list - ref_list, id_list, *_ = dataset.session_list() - num_sessions = len(ref_list) + # Build the dataset summary using the public utility + dataset_summary = datasetSummary(dataset) - # Build session summaries for each session - session_summaries = [] - for sid in id_list: - sess = dataset.open_session(sid) - session_summaries.append(sessionSummary(sess)) - - # Record document counts per session + # Record document counts per session (extra field for this test) + _ref_list, id_list, *_ = dataset.session_list() document_counts = [] for sid in id_list: sess = dataset.open_session(sid) docs = sess.database_search(Query("base.id").match("(.*)")) document_counts.append({"sessionId": sid, "count": len(docs)}) - - # Build the dataset summary - dataset_summary = { - "numSessions": num_sessions, - "references": ref_list, - "sessionIds": id_list, - "sessionSummaries": session_summaries, - "documentCounts": document_counts, - } + dataset_summary["documentCounts"] = document_counts # Write datasetSummary.json summary_json = json.dumps(dataset_summary, indent=2, allow_nan=True) diff --git a/tests/symmetry/read_artifacts/dataset/test_build_dataset.py b/tests/symmetry/read_artifacts/dataset/test_build_dataset.py index d1212e4..f442652 100644 --- a/tests/symmetry/read_artifacts/dataset/test_build_dataset.py +++ b/tests/symmetry/read_artifacts/dataset/test_build_dataset.py @@ -24,7 +24,7 @@ from ndi.dataset import Dataset from ndi.query import Query -from ndi.util import compareSessionSummary, sessionSummary +from ndi.util import compareDatasetSummary, datasetSummary from tests.symmetry.conftest import SOURCE_TYPES, SYMMETRY_BASE @@ -54,7 +54,7 @@ def _open_dataset(self, source_type): # -- tests ---------------------------------------------------------------- def test_build_dataset_summary(self, source_type): - """Verify session counts, references, and IDs match datasetSummary.json.""" + """Verify dataset summary matches datasetSummary.json.""" artifact_dir, dataset = self._open_dataset(source_type) summary_path = artifact_dir / "datasetSummary.json" @@ -62,65 +62,17 @@ def test_build_dataset_summary(self, source_type): pytest.skip(f"datasetSummary.json not found in {source_type} artifact directory.") expected = json.loads(summary_path.read_text(encoding="utf-8")) + actual = datasetSummary(dataset) - # Verify session list - refs, session_ids, *_ = dataset.session_list() - num_sessions = len(refs) - - expected_num = expected.get("numSessions", 0) - expected_ids = expected.get("sessionIds", []) - expected_refs = expected.get("references", []) - - assert ( - num_sessions == expected_num - ), f"Session count mismatch in {source_type}: got {num_sessions}, expected {expected_num}" - - for exp_id in expected_ids: - assert ( - exp_id in session_ids - ), f"Expected session ID {exp_id!r} not found in dataset from {source_type}" - - for exp_ref in expected_refs: - assert ( - exp_ref in refs - ), f"Expected session reference {exp_ref!r} not found in dataset from {source_type}" - - def test_build_dataset_session_summaries(self, source_type): - """Compare per-session summaries against those stored in datasetSummary.json.""" - artifact_dir, dataset = self._open_dataset(source_type) - - summary_path = artifact_dir / "datasetSummary.json" - if not summary_path.exists(): - pytest.skip(f"datasetSummary.json not found in {source_type} artifact directory.") - - expected = json.loads(summary_path.read_text(encoding="utf-8")) - expected_summaries = expected.get("sessionSummaries", []) - expected_ids = expected.get("sessionIds", []) - - if not expected_summaries: - pytest.skip(f"No sessionSummaries in {source_type} datasetSummary.json.") - - for i, sid in enumerate(expected_ids): - if i >= len(expected_summaries): - break - - sess = dataset.open_session(sid) - if sess is None: - pytest.fail(f"Could not open session {sid} from {source_type} dataset.") - - actual_summary = sessionSummary(sess) - expected_summary = expected_summaries[i] - - report = compareSessionSummary( - actual_summary, - expected_summary, - excludeFiles=["datasetSummary.json", "jsonDocuments"], - ) + report = compareDatasetSummary( + actual, + expected, + excludeFiles=["datasetSummary.json", "jsonDocuments"], + ) - assert len(report) == 0, ( - f"Session summary mismatch for session {sid} " - f"in {source_type} dataset:\n" + "\n".join(report) - ) + assert len(report) == 0, ( + f"Dataset summary mismatch in {source_type}:\n" + "\n".join(report) + ) def test_build_dataset_documents(self, source_type): """Verify that every exported JSON document can be loaded from session DBs. diff --git a/tests/symmetry/read_artifacts/dataset/test_download_ingested.py b/tests/symmetry/read_artifacts/dataset/test_download_ingested.py index eee7e1d..14ee27e 100644 --- a/tests/symmetry/read_artifacts/dataset/test_download_ingested.py +++ b/tests/symmetry/read_artifacts/dataset/test_download_ingested.py @@ -21,7 +21,7 @@ from ndi.dataset import Dataset from ndi.query import Query -from ndi.util import compareSessionSummary, sessionSummary +from ndi.util import compareDatasetSummary, datasetSummary from tests.symmetry.conftest import SOURCE_TYPES, SYMMETRY_BASE @@ -71,84 +71,36 @@ def _open_dataset(self, source_type): # -- tests ---------------------------------------------------------------- def test_download_ingested_summary(self, source_type): - """Verify session counts, references, and IDs match datasetSummary.json.""" + """Verify dataset summary matches datasetSummary.json.""" _artifact_dir, dataset, expected = self._open_dataset(source_type) - # Get session list from the dataset - ref_list, id_list, *_ = dataset.session_list() - num_sessions = len(ref_list) + actual = datasetSummary(dataset) - # Verify number of sessions - expected_num = expected.get("numSessions", 0) - assert num_sessions == expected_num, ( - f"Session count mismatch in {source_type}: " - f"got {num_sessions}, expected {expected_num}" - ) - - # Verify references - expected_refs = expected.get("references", []) - assert sorted(ref_list) == sorted( - expected_refs - ), f"Session references mismatch against {source_type} generated artifacts." - - # Verify session IDs - expected_ids = expected.get("sessionIds", []) - assert sorted(id_list) == sorted( - expected_ids - ), f"Session IDs mismatch against {source_type} generated artifacts." - - def test_download_ingested_session_summaries(self, source_type): - """Compare per-session summaries against datasetSummary.json.""" - _artifact_dir, dataset, expected = self._open_dataset(source_type) - - expected_summaries = expected.get("sessionSummaries", []) - _ref_list, id_list, *_ = dataset.session_list() - - if not expected_summaries: - pytest.skip(f"No sessionSummaries in {source_type} datasetSummary.json.") - - for sid in id_list: - sess = dataset.open_session(sid) - if sess is None: - pytest.fail(f"Could not open session {sid} from {source_type} dataset.") - - actual_summary = sessionSummary(sess) - - # Filter macOS resource fork files (._*) from file lists; - # these may be present in archives but MATLAB does not list them. - def _filter_dot_underscore(files: list[str]) -> list[str]: - return [f for f in files if not f.split("/")[-1].startswith("._")] + # Filter macOS resource fork files (._*) from file lists; + # these may be present in archives but MATLAB does not list them. + def _filter_dot_underscore(files: list[str]) -> list[str]: + return [f for f in files if not f.split("/")[-1].startswith("._")] + for ss in actual.get("sessionSummaries", []): for key in ("files", "filesInDotNDI"): - if key in actual_summary: - actual_summary[key] = _filter_dot_underscore(actual_summary[key]) - - # Find the expected summary with the matching sessionId - match = None - for es in expected_summaries: - if es.get("sessionId") == sid: - match = es - break - - assert match is not None, ( - f"No expected session summary found for session ID " f"{sid} in {source_type}" - ) + if key in ss: + ss[key] = _filter_dot_underscore(ss[key]) - # Also filter macOS resource fork files from the expected summary + for ss in expected.get("sessionSummaries", []): for key in ("files", "filesInDotNDI"): - if key in match: - match[key] = _filter_dot_underscore(match[key]) - - report = compareSessionSummary( - actual_summary, - match, - excludeFiles=["datasetSummary.json", "jsonDocuments"], - ) + if key in ss: + ss[key] = _filter_dot_underscore(ss[key]) + + report = compareDatasetSummary( + actual, + expected, + excludeFiles=["datasetSummary.json", "jsonDocuments"], + excludeFields=["documentCounts"], + ) - assert len(report) == 0, ( - f"Session summary mismatch for session {sid} " - f"in {source_type} dataset:\n" + "\n".join(report) - ) + assert len(report) == 0, ( + f"Dataset summary mismatch in {source_type}:\n" + "\n".join(report) + ) def test_download_ingested_document_counts(self, source_type): """Verify document counts per session match expected values.""" diff --git a/tests/test_dataset_summary.py b/tests/test_dataset_summary.py new file mode 100644 index 0000000..30197a7 --- /dev/null +++ b/tests/test_dataset_summary.py @@ -0,0 +1,291 @@ +"""Unit tests for ndi.util.datasetSummary and ndi.util.compareDatasetSummary.""" + +from unittest.mock import MagicMock, patch + +from ndi.util import compareDatasetSummary, datasetSummary + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mock_session(reference="ref1", session_id="sid1", path="/tmp/sess"): + """Create a mock session object with the fields sessionSummary expects.""" + sess = MagicMock() + sess.reference = reference + sess.id.return_value = session_id + sess.path = path + sess.daqsystem_load.return_value = [] + sess.getprobes.return_value = [] + return sess + + +def _mock_dataset(sessions): + """Create a mock Dataset whose session_list/open_session work with the given sessions.""" + ds = MagicMock() + refs = [s.reference for s in sessions] + ids = [s.id() for s in sessions] + ds.session_list.return_value = (refs, ids) + + def _open(sid): + for s in sessions: + if s.id() == sid: + return s + return None + + ds.open_session.side_effect = _open + return ds + + +# =========================================================================== +# datasetSummary +# =========================================================================== + + +class TestDatasetSummary: + def test_empty_dataset(self): + ds = MagicMock() + ds.session_list.return_value = ([], []) + result = datasetSummary(ds) + assert result["numSessions"] == 0 + assert result["references"] == [] + assert result["sessionIds"] == [] + assert result["sessionSummaries"] == [] + + def test_single_session(self): + sess = _mock_session("myref", "mysid", "/tmp/nonexistent") + ds = _mock_dataset([sess]) + result = datasetSummary(ds) + assert result["numSessions"] == 1 + assert result["references"] == ["myref"] + assert result["sessionIds"] == ["mysid"] + assert len(result["sessionSummaries"]) == 1 + assert result["sessionSummaries"][0]["reference"] == "myref" + assert result["sessionSummaries"][0]["sessionId"] == "mysid" + + def test_multiple_sessions(self): + s1 = _mock_session("ref_a", "id_a", "/tmp/a") + s2 = _mock_session("ref_b", "id_b", "/tmp/b") + ds = _mock_dataset([s1, s2]) + result = datasetSummary(ds) + assert result["numSessions"] == 2 + assert result["references"] == ["ref_a", "ref_b"] + assert result["sessionIds"] == ["id_a", "id_b"] + assert len(result["sessionSummaries"]) == 2 + + def test_returns_expected_keys(self): + sess = _mock_session() + ds = _mock_dataset([sess]) + result = datasetSummary(ds) + assert set(result.keys()) == { + "numSessions", + "references", + "sessionIds", + "sessionSummaries", + } + + +# =========================================================================== +# compareDatasetSummary +# =========================================================================== + + +class TestCompareDatasetSummary: + def test_identical_summaries(self): + summary = { + "numSessions": 1, + "references": ["ref1"], + "sessionIds": ["sid1"], + "sessionSummaries": [ + { + "reference": "ref1", + "sessionId": "sid1", + "files": [], + "filesInDotNDI": [], + "daqSystemNames": [], + "daqSystemDetails": [], + "probes": [], + } + ], + } + report = compareDatasetSummary(summary, summary) + assert report == [] + + def test_different_num_sessions(self): + s1 = {"numSessions": 1, "references": ["r"], "sessionIds": ["s"], "sessionSummaries": []} + s2 = {"numSessions": 2, "references": ["r"], "sessionIds": ["s"], "sessionSummaries": []} + report = compareDatasetSummary(s1, s2) + assert any("numSessions" in r for r in report) + + def test_different_references(self): + s1 = { + "numSessions": 1, + "references": ["ref_a"], + "sessionIds": ["s"], + "sessionSummaries": [], + } + s2 = { + "numSessions": 1, + "references": ["ref_b"], + "sessionIds": ["s"], + "sessionSummaries": [], + } + report = compareDatasetSummary(s1, s2) + assert any("references" in r for r in report) + + def test_different_session_ids(self): + s1 = { + "numSessions": 1, + "references": ["r"], + "sessionIds": ["id_a"], + "sessionSummaries": [], + } + s2 = { + "numSessions": 1, + "references": ["r"], + "sessionIds": ["id_b"], + "sessionSummaries": [], + } + report = compareDatasetSummary(s1, s2) + assert any("sessionIds" in r for r in report) + + def test_different_session_summary_count(self): + ss = { + "reference": "r", + "sessionId": "s", + "files": [], + "filesInDotNDI": [], + "daqSystemNames": [], + "daqSystemDetails": [], + "probes": [], + } + s1 = {"numSessions": 1, "references": ["r"], "sessionIds": ["s"], "sessionSummaries": [ss]} + s2 = { + "numSessions": 1, + "references": ["r"], + "sessionIds": ["s"], + "sessionSummaries": [ss, ss], + } + report = compareDatasetSummary(s1, s2) + assert any("sessionSummaries count" in r for r in report) + + def test_session_summary_field_diff(self): + ss1 = { + "reference": "ref1", + "sessionId": "sid1", + "files": ["a.txt"], + "filesInDotNDI": [], + "daqSystemNames": [], + "daqSystemDetails": [], + "probes": [], + } + ss2 = { + "reference": "ref1", + "sessionId": "sid1", + "files": ["b.txt"], + "filesInDotNDI": [], + "daqSystemNames": [], + "daqSystemDetails": [], + "probes": [], + } + s1 = { + "numSessions": 1, + "references": ["r"], + "sessionIds": ["sid1"], + "sessionSummaries": [ss1], + } + s2 = { + "numSessions": 1, + "references": ["r"], + "sessionIds": ["sid1"], + "sessionSummaries": [ss2], + } + report = compareDatasetSummary(s1, s2) + assert any("sessionSummaries" in r for r in report) + + def test_exclude_fields(self): + s1 = {"numSessions": 1, "references": ["r"], "sessionIds": ["s"], "sessionSummaries": []} + s2 = {"numSessions": 2, "references": ["x"], "sessionIds": ["y"], "sessionSummaries": []} + report = compareDatasetSummary( + s1, + s2, + excludeFields=["numSessions", "references", "sessionIds", "sessionSummaries"], + ) + assert report == [] + + def test_exclude_files_passed_to_session_compare(self): + ss1 = { + "reference": "r", + "sessionId": "s", + "files": ["keep.txt", "exclude.txt"], + "filesInDotNDI": [], + "daqSystemNames": [], + "daqSystemDetails": [], + "probes": [], + } + ss2 = { + "reference": "r", + "sessionId": "s", + "files": ["keep.txt"], + "filesInDotNDI": [], + "daqSystemNames": [], + "daqSystemDetails": [], + "probes": [], + } + s1 = { + "numSessions": 1, + "references": ["r"], + "sessionIds": ["s"], + "sessionSummaries": [ss1], + } + s2 = { + "numSessions": 1, + "references": ["r"], + "sessionIds": ["s"], + "sessionSummaries": [ss2], + } + report = compareDatasetSummary(s1, s2, excludeFiles=["exclude.txt"]) + assert report == [] + + def test_empty_summaries_match(self): + s1 = {"numSessions": 0, "references": [], "sessionIds": [], "sessionSummaries": []} + s2 = {"numSessions": 0, "references": [], "sessionIds": [], "sessionSummaries": []} + report = compareDatasetSummary(s1, s2) + assert report == [] + + def test_session_id_matching(self): + """Session summaries are matched by sessionId, not index order.""" + ss_a = { + "reference": "ra", + "sessionId": "id_a", + "files": ["a.txt"], + "filesInDotNDI": [], + "daqSystemNames": [], + "daqSystemDetails": [], + "probes": [], + } + ss_b = { + "reference": "rb", + "sessionId": "id_b", + "files": ["b.txt"], + "filesInDotNDI": [], + "daqSystemNames": [], + "daqSystemDetails": [], + "probes": [], + } + # summary1 has [a, b] order, summary2 has [b, a] order + s1 = { + "numSessions": 2, + "references": ["ra", "rb"], + "sessionIds": ["id_a", "id_b"], + "sessionSummaries": [ss_a, ss_b], + } + s2 = { + "numSessions": 2, + "references": ["ra", "rb"], + "sessionIds": ["id_b", "id_a"], + "sessionSummaries": [ss_b, ss_a], + } + report = compareDatasetSummary(s1, s2) + assert report == [] From 8ab19fc4ec70da2f00dea7f71ae6418998310910 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 16:56:13 +0000 Subject: [PATCH 2/3] Fix lint: apply black formatting and remove unused import https://claude.ai/code/session_01EctVW1VcbY2LzAdfZrGBB5 --- src/ndi/util/compare_dataset_summary.py | 20 +++++--------------- tests/test_dataset_summary.py | 3 +-- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/ndi/util/compare_dataset_summary.py b/src/ndi/util/compare_dataset_summary.py index e786982..d649d3c 100644 --- a/src/ndi/util/compare_dataset_summary.py +++ b/src/ndi/util/compare_dataset_summary.py @@ -46,27 +46,21 @@ def compareDatasetSummary( n1 = summary1.get("numSessions", 0) n2 = summary2.get("numSessions", 0) if n1 != n2: - report.append( - f"numSessions differs: {n1} vs {n2}" - ) + report.append(f"numSessions differs: {n1} vs {n2}") # 2. Compare references if "references" not in excludeFields: refs1 = sorted(summary1.get("references", [])) refs2 = sorted(summary2.get("references", [])) if refs1 != refs2: - report.append( - f"references differ: {refs1} vs {refs2}" - ) + report.append(f"references differ: {refs1} vs {refs2}") # 3. Compare sessionIds if "sessionIds" not in excludeFields: ids1 = sorted(summary1.get("sessionIds", [])) ids2 = sorted(summary2.get("sessionIds", [])) if ids1 != ids2: - report.append( - f"sessionIds differ: {ids1} vs {ids2}" - ) + report.append(f"sessionIds differ: {ids1} vs {ids2}") # 4. Compare sessionSummaries if "sessionSummaries" not in excludeFields: @@ -74,9 +68,7 @@ def compareDatasetSummary( ss2 = summary2.get("sessionSummaries", []) if len(ss1) != len(ss2): - report.append( - f"sessionSummaries count differs: {len(ss1)} vs {len(ss2)}" - ) + report.append(f"sessionSummaries count differs: {len(ss1)} vs {len(ss2)}") else: # Match session summaries by sessionId when available, # otherwise compare by index order. @@ -92,9 +84,7 @@ def compareDatasetSummary( for i, sid in enumerate(ids1): match = lookup2.get(sid) if match is None: - report.append( - f"sessionSummaries: session {sid} not found in summary2" - ) + report.append(f"sessionSummaries: session {sid} not found in summary2") continue sub = compareSessionSummary( ss1[i], diff --git a/tests/test_dataset_summary.py b/tests/test_dataset_summary.py index 30197a7..5b5fe09 100644 --- a/tests/test_dataset_summary.py +++ b/tests/test_dataset_summary.py @@ -1,10 +1,9 @@ """Unit tests for ndi.util.datasetSummary and ndi.util.compareDatasetSummary.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from ndi.util import compareDatasetSummary, datasetSummary - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- From 7331fccffa7bbce4c092db502f400536c891396d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 17:29:43 +0000 Subject: [PATCH 3/3] Fix black formatting in read_artifacts symmetry tests https://claude.ai/code/session_01EctVW1VcbY2LzAdfZrGBB5 --- tests/symmetry/read_artifacts/dataset/test_build_dataset.py | 4 +--- .../symmetry/read_artifacts/dataset/test_download_ingested.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/symmetry/read_artifacts/dataset/test_build_dataset.py b/tests/symmetry/read_artifacts/dataset/test_build_dataset.py index f442652..2d43607 100644 --- a/tests/symmetry/read_artifacts/dataset/test_build_dataset.py +++ b/tests/symmetry/read_artifacts/dataset/test_build_dataset.py @@ -70,9 +70,7 @@ def test_build_dataset_summary(self, source_type): excludeFiles=["datasetSummary.json", "jsonDocuments"], ) - assert len(report) == 0, ( - f"Dataset summary mismatch in {source_type}:\n" + "\n".join(report) - ) + assert len(report) == 0, f"Dataset summary mismatch in {source_type}:\n" + "\n".join(report) def test_build_dataset_documents(self, source_type): """Verify that every exported JSON document can be loaded from session DBs. diff --git a/tests/symmetry/read_artifacts/dataset/test_download_ingested.py b/tests/symmetry/read_artifacts/dataset/test_download_ingested.py index 14ee27e..c3cc5b4 100644 --- a/tests/symmetry/read_artifacts/dataset/test_download_ingested.py +++ b/tests/symmetry/read_artifacts/dataset/test_download_ingested.py @@ -98,9 +98,7 @@ def _filter_dot_underscore(files: list[str]) -> list[str]: excludeFields=["documentCounts"], ) - assert len(report) == 0, ( - f"Dataset summary mismatch in {source_type}:\n" + "\n".join(report) - ) + assert len(report) == 0, f"Dataset summary mismatch in {source_type}:\n" + "\n".join(report) def test_download_ingested_document_counts(self, source_type): """Verify document counts per session match expected values."""