- Enhanced AGENTS.md to clarify skill scope and directory structure, providing clearer guidelines for skill creation and updates. - Updated CONTRIBUTING.md to emphasize the separation of tests from skill directories, ensuring better organization and clarity for contributors. - Revised .gitignore to include .pytest_cache, improving project cleanliness. - Adjusted pyproject.toml to configure pytest for better test path management. - Incremented version number in SKILL.md for autoskill to reflect recent updates.
164 lines
5.5 KiB
Python
164 lines
5.5 KiB
Python
"""Deterministic tests for the PyLabRobot offline planning CLIs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stderr, redirect_stdout
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SKILL_ROOT = REPO_ROOT / "skills" / "pylabrobot"
|
|
TESTS_DIR = Path(__file__).resolve().parent
|
|
if str(SKILL_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
|
|
from scripts import ( # noqa: E402
|
|
check_deck_geometry,
|
|
generate_simulation_plan,
|
|
inspect_backends,
|
|
plan_transfers,
|
|
validate_manifest,
|
|
)
|
|
|
|
|
|
class CliTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.previous_cwd = Path.cwd()
|
|
os.chdir(REPO_ROOT)
|
|
|
|
def tearDown(self) -> None:
|
|
os.chdir(self.previous_cwd)
|
|
|
|
@staticmethod
|
|
def run_main(function, arguments: list[str]) -> tuple[int, str, str]:
|
|
stdout = io.StringIO()
|
|
stderr = io.StringIO()
|
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
code = function(arguments)
|
|
return code, stdout.getvalue(), stderr.getvalue()
|
|
|
|
def test_all_help_paths_are_dependency_free(self) -> None:
|
|
modules = (
|
|
validate_manifest,
|
|
check_deck_geometry,
|
|
plan_transfers,
|
|
generate_simulation_plan,
|
|
inspect_backends,
|
|
)
|
|
for module in modules:
|
|
output = io.StringIO()
|
|
with self.subTest(module=module.__name__), redirect_stdout(output):
|
|
with self.assertRaises(SystemExit) as raised:
|
|
module.main(["--help"])
|
|
self.assertEqual(raised.exception.code, 0)
|
|
self.assertIn("usage:", output.getvalue())
|
|
|
|
def test_manifest_and_geometry_pass(self) -> None:
|
|
code, stdout, stderr = self.run_main(
|
|
validate_manifest.main,
|
|
["--input", "tests/pylabrobot/fixtures/protocol_manifest.json"],
|
|
)
|
|
self.assertEqual((code, stderr), (0, ""))
|
|
self.assertTrue(json.loads(stdout)["ok"])
|
|
|
|
code, stdout, stderr = self.run_main(
|
|
check_deck_geometry.main,
|
|
["--input", "tests/pylabrobot/fixtures/protocol_manifest.json"],
|
|
)
|
|
self.assertEqual((code, stderr), (0, ""))
|
|
self.assertTrue(json.loads(stdout)["ok"])
|
|
|
|
def test_collision_fixture_is_rejected(self) -> None:
|
|
code, stdout, stderr = self.run_main(
|
|
check_deck_geometry.main,
|
|
["--input", "tests/pylabrobot/fixtures/collision_manifest.json"],
|
|
)
|
|
self.assertEqual(stderr, "")
|
|
self.assertEqual(code, 3)
|
|
report = json.loads(stdout)
|
|
self.assertFalse(report["ok"])
|
|
self.assertEqual(
|
|
report["collisions"],
|
|
[{"resource_a": "source_plate", "resource_b": "destination_plate"}],
|
|
)
|
|
|
|
def test_transfer_ledger_and_tip_plan(self) -> None:
|
|
code, stdout, stderr = self.run_main(
|
|
plan_transfers.main,
|
|
[
|
|
"--manifest",
|
|
"tests/pylabrobot/fixtures/protocol_manifest.json",
|
|
"--transfers",
|
|
"tests/pylabrobot/fixtures/transfers.csv",
|
|
],
|
|
)
|
|
self.assertEqual((code, stderr), (0, ""))
|
|
report = json.loads(stdout)
|
|
self.assertEqual(report["tip_summary"]["used"], 2)
|
|
self.assertEqual(
|
|
[operation["assigned_tip"] for operation in report["operations"]],
|
|
["tips:A1", "tips:B1"],
|
|
)
|
|
ledger = {
|
|
row["location"]: row["volume_uL"]
|
|
for row in report["final_volume_ledger"]
|
|
}
|
|
self.assertEqual(ledger["source_plate:A1"], 150.0)
|
|
self.assertEqual(ledger["source_plate:B1"], 125.0)
|
|
self.assertEqual(ledger["destination_plate:A1"], 50.0)
|
|
self.assertEqual(ledger["destination_plate:B1"], 25.0)
|
|
|
|
def test_generated_plan_cannot_enable_live_hardware(self) -> None:
|
|
code, stdout, stderr = self.run_main(
|
|
generate_simulation_plan.main,
|
|
[
|
|
"--manifest",
|
|
"tests/pylabrobot/fixtures/protocol_manifest.json",
|
|
"--transfers",
|
|
"tests/pylabrobot/fixtures/transfers.csv",
|
|
],
|
|
)
|
|
self.assertEqual((code, stderr), (0, ""))
|
|
report = json.loads(stdout)
|
|
self.assertEqual(report["plan_kind"], "offline_review_only")
|
|
self.assertFalse(report["live_backend_permitted"])
|
|
self.assertFalse(report["connection_attempted"])
|
|
self.assertFalse(report["serial_usb_network_access"])
|
|
self.assertEqual(len(report["steps"]), 8)
|
|
|
|
def test_duplicate_json_key_is_rejected(self) -> None:
|
|
with tempfile.TemporaryDirectory(dir=TESTS_DIR) as directory:
|
|
path = Path(directory) / "duplicate.json"
|
|
path.write_text('{"schema_version":"1.0","schema_version":"1.0"}', encoding="utf-8")
|
|
relative_path = path.relative_to(REPO_ROOT)
|
|
code, stdout, stderr = self.run_main(
|
|
validate_manifest.main,
|
|
["--input", str(relative_path)],
|
|
)
|
|
self.assertEqual((code, stdout), (2, ""))
|
|
self.assertIn("duplicate JSON key", json.loads(stderr)["message"])
|
|
|
|
def test_parent_directory_traversal_is_rejected(self) -> None:
|
|
code, stdout, stderr = self.run_main(
|
|
validate_manifest.main,
|
|
["--input", "tests/pylabrobot/fixtures/../fixtures/protocol_manifest.json"],
|
|
)
|
|
self.assertEqual((code, stdout), (2, ""))
|
|
self.assertIn("traversal", json.loads(stderr)["message"])
|
|
|
|
def test_backend_inspector_never_connects(self) -> None:
|
|
code, stdout, stderr = self.run_main(inspect_backends.main, [])
|
|
self.assertEqual((code, stderr), (0, ""))
|
|
report = json.loads(stdout)
|
|
self.assertFalse(report["connection_attempted"])
|
|
self.assertFalse(report["serial_usb_network_access"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|