Files
Timothy Kassis 2f2022de18 Update documentation and improve test structure for autoskill
- 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.
2026-07-26 09:54:14 -07:00

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()