From 6d3ed8f9226d077bf65150d41d0809563e3aec67 Mon Sep 17 00:00:00 2001 From: ktro2828 Date: Mon, 17 Nov 2025 16:25:28 +0900 Subject: [PATCH] test: add unit testings for specific checkers Signed-off-by: ktro2828 --- t4_devkit/sanity/tier4/tiv001.py | 2 +- tests/sanity/test_format_checkers.py | 85 +++++++ tests/sanity/test_record_checkers.py | 163 +++++++++++++ tests/sanity/test_reference_checkers.py | 236 +++++++++++++++++++ tests/sanity/test_structure_checkers.py | 293 ++++++++++++++++++++++++ tests/sanity/test_tier4_checker.py | 61 +++++ 6 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 tests/sanity/test_format_checkers.py create mode 100644 tests/sanity/test_record_checkers.py create mode 100644 tests/sanity/test_reference_checkers.py create mode 100644 tests/sanity/test_structure_checkers.py create mode 100644 tests/sanity/test_tier4_checker.py diff --git a/t4_devkit/sanity/tier4/tiv001.py b/t4_devkit/sanity/tier4/tiv001.py index 036c866..db8e96d 100644 --- a/t4_devkit/sanity/tier4/tiv001.py +++ b/t4_devkit/sanity/tier4/tiv001.py @@ -34,7 +34,7 @@ def can_skip(self, context: SanityContext) -> Maybe[Reason]: return Maybe.from_value(Reason(f"'{x.as_posix()}' not found")) return Nothing case _: - return Nothing + return Maybe.from_value(Reason("Data root not found")) def check(self, context: SanityContext) -> list[Reason] | None: result = _load_tier4_safe(context) diff --git a/tests/sanity/test_format_checkers.py b/tests/sanity/test_format_checkers.py new file mode 100644 index 0000000..7875a84 --- /dev/null +++ b/tests/sanity/test_format_checkers.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from t4_devkit.sanity.context import SanityContext + +# Import all format field type checkers +from t4_devkit.sanity.format.fmt001 import FMT001 +from t4_devkit.sanity.format.fmt002 import FMT002 +from t4_devkit.sanity.format.fmt003 import FMT003 +from t4_devkit.sanity.format.fmt004 import FMT004 +from t4_devkit.sanity.format.fmt005 import FMT005 +from t4_devkit.sanity.format.fmt006 import FMT006 +from t4_devkit.sanity.format.fmt007 import FMT007 +from t4_devkit.sanity.format.fmt008 import FMT008 +from t4_devkit.sanity.format.fmt009 import FMT009 +from t4_devkit.sanity.format.fmt010 import FMT010 +from t4_devkit.sanity.format.fmt011 import FMT011 +from t4_devkit.sanity.format.fmt012 import FMT012 +from t4_devkit.sanity.format.fmt013 import FMT013 +from t4_devkit.sanity.format.fmt014 import FMT014 # optional (lidarseg) - missing in sample +from t4_devkit.sanity.format.fmt015 import FMT015 +from t4_devkit.sanity.format.fmt016 import FMT016 +from t4_devkit.sanity.format.fmt017 import FMT017 # optional (keypoint) - missing in sample +from t4_devkit.sanity.format.fmt018 import FMT018 + +# Root of the provided sample dataset (non-versioned) +SAMPLE_ROOT = Path(__file__).parent.parent.joinpath("sample", "t4dataset") + + +def _context() -> SanityContext: + return SanityContext.from_path(SAMPLE_ROOT.as_posix()) + + +# All checkers expected to PASS on the sample dataset. +# Optional schemas (lidarseg, keypoint) are absent but considered PASS (not skipped) by logic. +ALL_FORMAT_CHECKERS: list[type] = [ + FMT001, + FMT002, + FMT003, + FMT004, + FMT005, + FMT006, + FMT007, + FMT008, + FMT009, + FMT010, + FMT011, + FMT012, + FMT013, + FMT014, + FMT015, + FMT016, + FMT017, + FMT018, +] + + +@pytest.mark.parametrize("checker_cls", ALL_FORMAT_CHECKERS) +def test_format_checker_passes(checker_cls: type) -> None: + """ + Each format field type checker should pass against the sample dataset. + + Behavior expectations: + - Mandatory schema files exist and records convert cleanly => PASSED (no reasons). + - Optional schema files (missing) => treated as PASSED (no reasons). + """ + context = _context() + checker = checker_cls() + report = checker(context) + # Must be passed strictly (no failures or warnings with reasons) + assert report.is_passed(strict=True), f"{checker_cls.__name__} expected to pass" + # For passed reports reasons must be None + assert report.reasons is None, f"{checker_cls.__name__} should not produce reasons when passing" + + +def test_optional_missing_schemas_present_in_param_list() -> None: + """Sanity guard: ensure we explicitly covered optional missing schemas.""" + optional_missing = {"FMT014": FMT014, "FMT017": FMT017} + for name, cls in optional_missing.items(): + report = cls()(_context()) + assert report.is_passed(strict=True), f"{name} (missing optional file) should pass" + assert report.reasons is None diff --git a/tests/sanity/test_record_checkers.py b/tests/sanity/test_record_checkers.py new file mode 100644 index 0000000..5c3710f --- /dev/null +++ b/tests/sanity/test_record_checkers.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from t4_devkit.sanity.context import SanityContext +from t4_devkit.sanity.record.rec001 import REC001 +from t4_devkit.sanity.record.rec002 import REC002 +from t4_devkit.sanity.record.rec003 import REC003 +from t4_devkit.sanity.record.rec004 import REC004 +from t4_devkit.sanity.record.rec005 import REC005 +from t4_devkit.sanity.record.rec006 import REC006 + +# Base sample dataset root (contains all mandatory annotation json files with non-empty records) +SAMPLE_ROOT = Path(__file__).parent.parent.joinpath("sample", "t4dataset") +ANNOTATION_DIR = SAMPLE_ROOT / "annotation" + + +def _copy_dataset(dst_root: Path) -> Path: + """Copy the entire sample dataset tree into a destination root.""" + shutil.copytree(SAMPLE_ROOT, dst_root) + return dst_root + + +def _make_mutated_dataset(tmp_path: Path, mutations: dict[str, list[dict]]) -> Path: + """ + Create a dataset root copying the sample dataset and then apply record mutations. + + mutations: mapping of filename (e.g. 'scene.json') to new list[dict] content. + """ + dst_root = _copy_dataset(tmp_path / "mutated_dataset") + ann_dir = dst_root / "annotation" + for filename, new_records in mutations.items(): + target = ann_dir / filename + with target.open("w", encoding="utf-8") as f: + json.dump(new_records, f, indent=2) + return dst_root + + +def _context(root: Path) -> SanityContext: + return SanityContext.from_path(root.as_posix()) + + +# ---------------- REC001 (scene-single) ---------------- + + +def test_rec001_pass_single_scene() -> None: + checker = REC001() + report = checker(_context(SAMPLE_ROOT)) + assert report.is_passed(strict=True) + assert report.reasons is None + + +@pytest.mark.parametrize("num_scenes", [0, 2]) +def test_rec001_fail_scene_count(num_scenes: int, tmp_path: Path) -> None: + original = json.loads((ANNOTATION_DIR / "scene.json").read_text(encoding="utf-8")) + if num_scenes == 0: + mutated = [] + else: + # duplicate original first record to create 2 scenes (different token for uniqueness) + first = original[0] + duplicate = {**first, "token": "duplicate_scene_token"} + mutated = [first, duplicate] + root = _make_mutated_dataset(tmp_path, {"scene.json": mutated}) + checker = REC001() + report = checker(_context(root)) + assert not report.is_passed(strict=True) + assert report.reasons and report.reasons[0].startswith( + "'Scene' must contain exactly one element" + ) + + +# ---------------- REC002 (sample-not-empty) ---------------- + + +def test_rec002_pass_sample_not_empty() -> None: + checker = REC002() + report = checker(_context(SAMPLE_ROOT)) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_rec002_fail_sample_empty(tmp_path: Path) -> None: + root = _make_mutated_dataset(tmp_path, {"sample.json": []}) + checker = REC002() + report = checker(_context(root)) + assert not report.is_passed(strict=True) + assert report.reasons and report.reasons[0] == "'Sample' record must not be empty" + + +# ---------------- REC003 (sample-data-not-empty) ---------------- + + +def test_rec003_pass_sample_data_not_empty() -> None: + checker = REC003() + report = checker(_context(SAMPLE_ROOT)) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_rec003_fail_sample_data_empty(tmp_path: Path) -> None: + root = _make_mutated_dataset(tmp_path, {"sample_data.json": []}) + checker = REC003() + report = checker(_context(root)) + assert not report.is_passed(strict=True) + assert report.reasons and report.reasons[0] == "'SampleData' record must not be empty" + + +# ---------------- REC004 (ego-pose-not-empty) ---------------- + + +def test_rec004_pass_ego_pose_not_empty() -> None: + checker = REC004() + report = checker(_context(SAMPLE_ROOT)) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_rec004_fail_ego_pose_empty(tmp_path: Path) -> None: + root = _make_mutated_dataset(tmp_path, {"ego_pose.json": []}) + checker = REC004() + report = checker(_context(root)) + assert not report.is_passed(strict=True) + assert report.reasons and report.reasons[0] == "'EgoPose' record must not be empty" + + +# ---------------- REC005 (calibrated-sensor-not-empty) ---------------- + + +def test_rec005_pass_calibrated_sensor_not_empty() -> None: + checker = REC005() + report = checker(_context(SAMPLE_ROOT)) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_rec005_fail_calibrated_sensor_empty(tmp_path: Path) -> None: + root = _make_mutated_dataset(tmp_path, {"calibrated_sensor.json": []}) + checker = REC005() + report = checker(_context(root)) + assert not report.is_passed(strict=True) + assert report.reasons and report.reasons[0] == "'CalibratedSensor' record must not be empty" + + +# ---------------- REC006 (instance-not-empty) ---------------- + + +def test_rec006_pass_instance_not_empty() -> None: + checker = REC006() + report = checker(_context(SAMPLE_ROOT)) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_rec006_fail_instance_empty(tmp_path: Path) -> None: + root = _make_mutated_dataset(tmp_path, {"instance.json": []}) + checker = REC006() + report = checker(_context(root)) + assert not report.is_passed(strict=True) + assert report.reasons and report.reasons[0] == "'Instance' record must not be empty" diff --git a/tests/sanity/test_reference_checkers.py b/tests/sanity/test_reference_checkers.py new file mode 100644 index 0000000..74eb169 --- /dev/null +++ b/tests/sanity/test_reference_checkers.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from t4_devkit.sanity.context import SanityContext +from t4_devkit.sanity.reference.ref001 import REF001 +from t4_devkit.sanity.reference.ref002 import REF002 +from t4_devkit.sanity.reference.ref003 import REF003 +from t4_devkit.sanity.reference.ref004 import REF004 +from t4_devkit.sanity.reference.ref005 import REF005 +from t4_devkit.sanity.reference.ref006 import REF006 +from t4_devkit.sanity.reference.ref007 import REF007 +from t4_devkit.sanity.reference.ref008 import REF008 +from t4_devkit.sanity.reference.ref009 import REF009 +from t4_devkit.sanity.reference.ref010 import REF010 +from t4_devkit.sanity.reference.ref011 import REF011 +from t4_devkit.sanity.reference.ref012 import REF012 +from t4_devkit.sanity.reference.ref013 import REF013 +from t4_devkit.sanity.reference.ref014 import REF014 + +# Sample dataset root (non-versioned) +SAMPLE_ROOT = Path(__file__).parent.parent.joinpath("sample", "t4dataset") +ANNOTATION_DIR = SAMPLE_ROOT / "annotation" + + +def _copy_dataset(dst_root: Path) -> Path: + """Copy the entire sample dataset tree into a destination root.""" + shutil.copytree(SAMPLE_ROOT, dst_root) + return dst_root + + +def _load_json(path: Path) -> list[dict]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _dump_json(path: Path, records: list[dict]) -> None: + path.write_text(json.dumps(records, indent=2), encoding="utf-8") + + +def _context(root: Path) -> SanityContext: + return SanityContext.from_path(root.as_posix()) + + +# --------------------------------------------------------------------------- +# Helper mutations for record reference failures +# --------------------------------------------------------------------------- + + +def _mutate_single_value(records: list[dict], key: str, new_value: str) -> list[dict]: + """Replace the first record's key value with new_value (assuming key exists).""" + mutated = [dict(r) for r in records] + if mutated: + mutated[0][key] = new_value + return mutated + + +def _ensure_is_valid(records: list[dict]) -> list[dict]: + """Add 'is_valid': True to all records if missing (for REF005 additional condition).""" + out = [] + for r in records: + nr = dict(r) + nr.setdefault("is_valid", True) + out.append(nr) + return out + + +# --------------------------------------------------------------------------- +# PASS (valid references) tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "checker_cls", + [ + REF001, + REF002, + REF003, + REF004, + REF006, + REF007, + REF008, + REF009, + REF010, + REF011, + REF013, + REF014, + ], +) +def test_reference_checkers_pass(checker_cls: type) -> None: + """Check that reference-related checkers pass (or have no failures) on the sample dataset.""" + checker = checker_cls() + report = checker(_context(SAMPLE_ROOT)) + # All are ERROR severity; a passed report should have no reasons. + assert report.is_passed(strict=True), f"{checker_cls.__name__} expected to pass" + if not report.is_skipped(): + assert report.reasons is None, f"{checker_cls.__name__} should have no reasons when passed" + + +def test_ref005_pass_with_is_valid(tmp_path: Path) -> None: + """REF005 requires 'is_valid' field; augment sample_data.json.""" + # Copy dataset and inject is_valid=True to each sample_data record + root = _copy_dataset(tmp_path / "dataset_ref005_pass") + sd_path = root / "sample" / "t4dataset" / "annotation" / "sample_data.json" + # In copied layout _copy_dataset copies SAMPLE_ROOT into root; SAMPLE_ROOT already ends with 't4dataset' + # So annotation directory path is root/'annotation'. + if not sd_path.exists(): # adjust if directory structure differs + sd_path = root / "annotation" / "sample_data.json" + records = _load_json(sd_path) + _dump_json(sd_path, _ensure_is_valid(records)) + checker = REF005() + report = checker(_context(root)) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_ref012_skipped_on_missing_sources() -> None: + """REF012 should be skipped because lidarseg.json is optional and absent.""" + checker = REF012() + report = checker(_context(SAMPLE_ROOT)) + assert report.is_skipped(), "REF012 should be skipped when source/target file missing" + + +# --------------------------------------------------------------------------- +# FAIL (invalid references) tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "checker_cls, filename, key_to_mutate, invalid_token", + [ + (REF001, "scene.json", "log_token", "invalid_log_token"), + (REF002, "scene.json", "first_sample_token", "invalid_first_sample"), + (REF003, "scene.json", "last_sample_token", "invalid_last_sample"), + (REF004, "sample.json", "scene_token", "invalid_scene_token"), + (REF006, "sample_data.json", "ego_pose_token", "invalid_ego_pose_token"), + (REF007, "sample_data.json", "calibrated_sensor_token", "invalid_calibrated_sensor_token"), + (REF008, "calibrated_sensor.json", "sensor_token", "invalid_sensor_token"), + (REF009, "instance.json", "category_token", "invalid_category_token"), + (REF010, "instance.json", "first_annotation_token", "invalid_annotation_token"), + (REF011, "instance.json", "last_annotation_token", "invalid_annotation_token"), + ], +) +def test_reference_checkers_fail( + checker_cls: type, + filename: str, + key_to_mutate: str, + invalid_token: str, + tmp_path: Path, +) -> None: + """Create a mutated dataset with one invalid reference and assert the checker fails.""" + root = _copy_dataset(tmp_path / f"dataset_{checker_cls.__name__}_fail") + # Adjust annotation path depending on copy layout + ann_dir = root / "annotation" + if not ann_dir.exists(): + # If _copy_dataset nested t4dataset under root + ann_dir = root / "sample" / "t4dataset" / "annotation" + target_file = ann_dir / filename + records = _load_json(target_file) + mutated = _mutate_single_value(records, key_to_mutate, invalid_token) + # For sample_data invalid tests ensure is_valid exists (except those not involving REF005) + if filename == "sample_data.json": + mutated = _ensure_is_valid(mutated) + _dump_json(target_file, mutated) + + checker = checker_cls() + report = checker(_context(root)) + assert not report.is_passed( + strict=True + ), f"{checker_cls.__name__} should fail with invalid reference" + assert report.reasons, "Failed report must include reasons" + # Confirm invalid token mentioned somewhere + assert any(invalid_token in r for r in report.reasons), "Invalid token should appear in reasons" + + +def test_ref005_fail_invalid_sample_reference(tmp_path: Path) -> None: + """REF005 failure by breaking sample_data.sample_token while ensuring is_valid field exists.""" + root = _copy_dataset(tmp_path / "dataset_ref005_fail") + ann_dir = root / "annotation" + if not ann_dir.exists(): + ann_dir = root / "sample" / "t4dataset" / "annotation" + + sd_path = ann_dir / "sample_data.json" + records = _load_json(sd_path) + # ensure is_valid and mutate first sample_token + mutated = _ensure_is_valid(records) + if mutated: + mutated[0]["sample_token"] = "nonexistent_sample_token" + _dump_json(sd_path, mutated) + + checker = REF005() + report = checker(_context(root)) + assert not report.is_passed(strict=True) + assert report.reasons + assert any("nonexistent_sample_token" in r for r in report.reasons) + + +def test_ref013_fail_missing_filename(tmp_path: Path) -> None: + """Mutate sample_data.json to point to a missing file for REF013.""" + root = _copy_dataset(tmp_path / "dataset_ref013_fail") + ann_dir = root / "annotation" + if not ann_dir.exists(): + ann_dir = root / "sample" / "t4dataset" / "annotation" + sd_path = ann_dir / "sample_data.json" + records = _load_json(sd_path) + if records: + records[0]["filename"] = "data/CAM_FRONT/does_not_exist.jpg" + _dump_json(sd_path, records) + + checker = REF013() + report = checker(_context(root)) + assert not report.is_passed(strict=True) + assert report.reasons + assert any("does_not_exist.jpg" in r for r in report.reasons) + + +def test_ref014_fail_missing_info_filename(tmp_path: Path) -> None: + """Add an info_filename pointing to a non-existing file to trigger REF014 failure.""" + root = _copy_dataset(tmp_path / "dataset_ref014_fail") + ann_dir = root / "annotation" + if not ann_dir.exists(): + ann_dir = root / "sample" / "t4dataset" / "annotation" + sd_path = ann_dir / "sample_data.json" + records = _load_json(sd_path) + if records: + records[0]["info_filename"] = "data/CAM_FRONT/missing_info.json" + _dump_json(sd_path, records) + + checker = REF014() + report = checker(_context(root)) + assert not report.is_passed(strict=True) + assert report.reasons + assert any("missing_info.json" in r for r in report.reasons) diff --git a/tests/sanity/test_structure_checkers.py b/tests/sanity/test_structure_checkers.py new file mode 100644 index 0000000..be0a6d6 --- /dev/null +++ b/tests/sanity/test_structure_checkers.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from t4_devkit.sanity.context import SanityContext +from t4_devkit.sanity.structure.str001 import STR001 +from t4_devkit.sanity.structure.str002 import STR002 +from t4_devkit.sanity.structure.str003 import STR003 +from t4_devkit.sanity.structure.str004 import STR004 +from t4_devkit.sanity.structure.str005 import STR005 +from t4_devkit.sanity.structure.str006 import STR006 +from t4_devkit.sanity.structure.str007 import STR007 +from t4_devkit.sanity.structure.str008 import STR008 +from t4_devkit.sanity.structure.str009 import STR009 + +# Base sample dataset (no version dir, no input_bag, has annotation/data/map/status.json, lanelet2_map.osm, no pointcloud_map.pcd) +SAMPLE_ROOT = Path(__file__).parent.parent.joinpath("sample", "t4dataset") + + +def _copy_dataset(src: Path, dst: Path) -> None: + """Copy entire dataset tree from src to dst.""" + shutil.copytree(src, dst, dirs_exist_ok=True) + + +def _build_context(root: Path) -> SanityContext: + """Helper to build SanityContext from dataset root.""" + return SanityContext.from_path(root.as_posix()) + + +# ---------- STR001 (version-dir-presence) ---------- + + +def _make_versioned_dataset(tmp_path: Path) -> Path: + """Create a dataset root that has a numeric version subdirectory.""" + root = tmp_path / "versioned_dataset" + version_dir = root / "1" + version_dir.mkdir(parents=True, exist_ok=True) + # copy sample dataset contents into version_dir + _copy_dataset(SAMPLE_ROOT, version_dir) + return root + + +def test_str001_fail_without_version_dir() -> None: + checker = STR001() + context = _build_context(SAMPLE_ROOT) + report = checker(context) + assert not report.is_passed(strict=True) + assert report.reasons and "'version' directory" in report.reasons[0] + + +def test_str001_pass_with_version_dir(tmp_path: Path) -> None: + checker = STR001() + dataset_root = _make_versioned_dataset(tmp_path) + context = _build_context(dataset_root) + report = checker(context) + assert report.is_passed(strict=True) + assert report.reasons is None + + +# ---------- STR002 (annotation-dir-presence) ---------- + + +def _make_dataset_without_annotation(tmp_path: Path) -> Path: + root = tmp_path / "no_annotation" + root.mkdir(exist_ok=True) + # minimal files: data, map, status.json + shutil.copytree(SAMPLE_ROOT / "data", root / "data") + shutil.copytree(SAMPLE_ROOT / "map", root / "map") + shutil.copy(SAMPLE_ROOT / "status.json", root / "status.json") + return root + + +def test_str002_pass_annotation_present() -> None: + checker = STR002() + context = _build_context(SAMPLE_ROOT) + report = checker(context) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_str002_fail_annotation_missing(tmp_path: Path) -> None: + checker = STR002() + root = _make_dataset_without_annotation(tmp_path) + context = _build_context(root) + report = checker(context) + assert not report.is_passed(strict=True) + assert report.reasons and "annotation" in report.reasons[0] + + +# ---------- STR003 (data-dir-presence) ---------- + + +def _make_dataset_without_data(tmp_path: Path) -> Path: + root = tmp_path / "no_data" + root.mkdir(exist_ok=True) + shutil.copytree(SAMPLE_ROOT / "annotation", root / "annotation") + shutil.copytree(SAMPLE_ROOT / "map", root / "map") + shutil.copy(SAMPLE_ROOT / "status.json", root / "status.json") + return root + + +def test_str003_pass_data_present() -> None: + checker = STR003() + context = _build_context(SAMPLE_ROOT) + report = checker(context) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_str003_fail_data_missing(tmp_path: Path) -> None: + checker = STR003() + root = _make_dataset_without_data(tmp_path) + context = _build_context(root) + report = checker(context) + assert not report.is_passed(strict=True) + assert report.reasons and "data" in report.reasons[0] + + +# ---------- STR004 (map-dir-presence) ---------- + + +def _make_dataset_without_map(tmp_path: Path) -> Path: + root = tmp_path / "no_map" + root.mkdir(exist_ok=True) + shutil.copytree(SAMPLE_ROOT / "annotation", root / "annotation") + shutil.copytree(SAMPLE_ROOT / "data", root / "data") + shutil.copy(SAMPLE_ROOT / "status.json", root / "status.json") + return root + + +def test_str004_pass_map_present() -> None: + checker = STR004() + context = _build_context(SAMPLE_ROOT) + report = checker(context) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_str004_fail_map_missing(tmp_path: Path) -> None: + checker = STR004() + root = _make_dataset_without_map(tmp_path) + context = _build_context(root) + report = checker(context) + assert not report.is_passed(strict=True) + assert report.reasons and "map" in report.reasons[0] + + +# ---------- STR005 (bag-dir-presence) ---------- + + +def _make_dataset_with_bag(tmp_path: Path) -> Path: + root = tmp_path / "with_bag" + _copy_dataset(SAMPLE_ROOT, root) + (root / "input_bag").mkdir(exist_ok=True) + return root + + +def test_str005_fail_bag_missing() -> None: + checker = STR005() + context = _build_context(SAMPLE_ROOT) + report = checker(context) + # Warning severity; strict=True should treat as failure + assert not report.is_passed(strict=True) + assert report.reasons and "input_bag" in report.reasons[0] + + +def test_str005_pass_bag_present(tmp_path: Path) -> None: + checker = STR005() + root = _make_dataset_with_bag(tmp_path) + context = _build_context(root) + report = checker(context) + assert report.is_passed(strict=True) + assert report.reasons is None + + +# ---------- STR006 (status-json-presence) ---------- + + +def _make_dataset_without_status(tmp_path: Path) -> Path: + root = tmp_path / "no_status" + root.mkdir(exist_ok=True) + shutil.copytree(SAMPLE_ROOT / "annotation", root / "annotation") + shutil.copytree(SAMPLE_ROOT / "data", root / "data") + shutil.copytree(SAMPLE_ROOT / "map", root / "map") + return root + + +def test_str006_pass_status_present() -> None: + checker = STR006() + context = _build_context(SAMPLE_ROOT) + report = checker(context) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_str006_fail_status_missing(tmp_path: Path) -> None: + checker = STR006() + root = _make_dataset_without_status(tmp_path) + context = _build_context(root) + report = checker(context) + assert not report.is_passed(strict=True) + assert report.reasons and "status.json" in report.reasons[0] + + +# ---------- STR007 (schema-file-presence) ---------- + + +def _make_dataset_missing_schema(tmp_path: Path, missing: str) -> Path: + """Create dataset missing one mandatory schema file.""" + root = tmp_path / "missing_schema" + _copy_dataset(SAMPLE_ROOT, root) + to_remove = root / "annotation" / missing + if to_remove.exists(): + to_remove.unlink() + return root + + +@pytest.mark.parametrize("missing_file", ["sample.json", "scene.json", "sensor.json"]) +def test_str007_fail_missing_mandatory_schema(tmp_path: Path, missing_file: str) -> None: + checker = STR007() + root = _make_dataset_missing_schema(tmp_path, missing_file) + context = _build_context(root) + report = checker(context) + assert not report.is_passed(strict=True) + assert any(missing_file.replace(".json", "") in r for r in report.reasons) + + +def test_str007_pass_all_mandatory_present() -> None: + checker = STR007() + context = _build_context(SAMPLE_ROOT) + report = checker(context) + assert report.is_passed(strict=True) + assert report.reasons is None + + +# ---------- STR008 (lanelet-file-presence) ---------- + + +def _make_dataset_missing_lanelet(tmp_path: Path) -> Path: + root = tmp_path / "missing_lanelet" + _copy_dataset(SAMPLE_ROOT, root) + lanelet_file = root / "map" / "lanelet2_map.osm" + if lanelet_file.exists(): + lanelet_file.unlink() + return root + + +def test_str008_pass_lanelet_present() -> None: + checker = STR008() + context = _build_context(SAMPLE_ROOT) + report = checker(context) + assert report.is_passed(strict=True) + assert report.reasons is None + + +def test_str008_fail_lanelet_missing(tmp_path: Path) -> None: + checker = STR008() + root = _make_dataset_missing_lanelet(tmp_path) + context = _build_context(root) + report = checker(context) + assert not report.is_passed(strict=True) + assert report.reasons and "Lanelet2 map file not found" in report.reasons[0] + + +# ---------- STR009 (pointcloud-map-dir-presence) ---------- + + +def _make_dataset_with_pointcloud_map(tmp_path: Path) -> Path: + root = tmp_path / "with_pointcloud_map" + _copy_dataset(SAMPLE_ROOT, root) + # Create an empty file to satisfy existence check + (root / "map" / "pointcloud_map.pcd").write_text("") + return root + + +def test_str009_fail_pointcloud_map_missing() -> None: + checker = STR009() + context = _build_context(SAMPLE_ROOT) + report = checker(context) + assert not report.is_passed(strict=True) + assert report.reasons and "PCD map directory not found" in report.reasons[0] + + +def test_str009_pass_pointcloud_map_present(tmp_path: Path) -> None: + checker = STR009() + root = _make_dataset_with_pointcloud_map(tmp_path) + context = _build_context(root) + report = checker(context) + assert report.is_passed(strict=True) + assert report.reasons is None diff --git a/tests/sanity/test_tier4_checker.py b/tests/sanity/test_tier4_checker.py new file mode 100644 index 0000000..debdba3 --- /dev/null +++ b/tests/sanity/test_tier4_checker.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pathlib import Path + +from t4_devkit.sanity.context import SanityContext +from t4_devkit.sanity.tier4.tiv001 import TIV001 + +# Path to the provided sample dataset (non-versioned). +SAMPLE_ROOT = Path(__file__).parent.parent.joinpath("sample", "t4dataset") + + +def _context(path: Path) -> SanityContext: + """Helper to build a SanityContext from a path.""" + return SanityContext.from_path(path.as_posix()) + + +def test_tiv001_pass() -> None: + """ + TIV001 should PASS (Tier4 loads successfully) on the valid sample dataset. + """ + checker = TIV001() + report = checker(_context(SAMPLE_ROOT)) + assert report.is_passed(strict=True), "Expected TIV001 to pass on sample dataset" + assert not report.is_skipped(), "Sample dataset should not be skipped" + assert report.reasons is None, "Passed report must not contain reasons" + + +def test_tiv001_skip_missing_root(tmp_path: Path) -> None: + """ + TIV001 should be SKIPPED when the dataset root path does not exist. + """ + missing_root = tmp_path / "does_not_exist" # intentionally not created + checker = TIV001() + report = checker(_context(missing_root)) + assert report.is_skipped(), "Expected TIV001 to be skipped for missing root directory" + assert report.reasons, "Skipped report must include a reason" + + +def test_tiv001_fail_broken_dataset(tmp_path: Path) -> None: + """ + TIV001 should FAIL when Tier4 cannot be initialized due to broken dataset contents. + + We create a dataset root with an 'annotation' directory containing an invalid JSON file + to trigger a loading failure. + """ + broken_root = tmp_path / "broken_dataset" + annotation_dir = broken_root / "annotation" + annotation_dir.mkdir(parents=True) + # Create a mandatory schema file with invalid JSON to force a parsing error. + scene_file = annotation_dir / "scene.json" + scene_file.write_text("{ invalid json", encoding="utf-8") + + checker = TIV001() + report = checker(_context(broken_root)) + + assert not report.is_passed(strict=True), "Broken dataset should cause TIV001 to fail" + assert not report.is_skipped(), "Existing root should not trigger skip" + assert report.reasons, "Failed report must include reasons" + assert any( + "Failed to load Tier4" in r for r in report.reasons + ), "Failure reason should indicate Tier4 load issue"