diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..56005a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - full_build + +jobs: + core: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package + run: | + python -m pip install -U pip + python -m pip install -e ".[test]" + python -m pip install ruff + - name: Ruff check + run: ruff check tests/conftest.py tests/unit tests/stages tests/integration + - name: Core tests + run: pytest tests/unit tests/stages tests/integration diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..64647c1 --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,11 @@ +# Release checklist + +- [ ] Core CI (`pytest tests/unit tests/stages tests/integration`) is green. +- [ ] `ruff check .` is green. +- [ ] `ruff format --check .` is green. +- [ ] Happy-path integration fixture passes (or has explicit xfail with blocking issue). +- [ ] Missing-lvl1-then-domestication integration fixture passes (or has explicit xfail with blocking issue). +- [ ] Optional automation tests are documented and kept manual. +- [ ] Core tests do not import optional automation dependencies. +- [ ] README testing commands remain accurate. +- [ ] Known upstream blockers are documented with issue references. diff --git a/pyproject.toml b/pyproject.toml index f7c72fc..27cf445 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ [project.optional-dependencies] test = [ - "pytest < 5.0.0", + "pytest>=7,<9", "pytest-cov[all]" ] automation = [ @@ -48,3 +48,9 @@ automation = [ dev = [ "ruff>=0.14.0", ] + + +[tool.pytest.ini_options] +markers = [ + "automation: optional/manual automation tests", +] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..b48a94d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,33 @@ +# Testing guide + +## Core (required) checks + +Core CI is compiler-only and offline (except fixture-local SBOL files). + +```bash +ruff check . +ruff format --check . +pytest tests/unit tests/stages tests/integration +``` + +Core tests must not require SynBioHub, PUDU, Opentrons, or SBOLInventory. + +## Optional automation checks + +```bash +python -m pip install -e '.[automation,test]' +pytest tests/automation +``` + +Automation tests are marked with `@pytest.mark.automation` and are manual/optional. + +## Skip vs xfail guidance + +- Use `skip` for intentionally manual checks (e.g., hardware simulation). +- Use `xfail` only for known core blockers and include explicit issue references. + +## Fixtures + +- `tests/fixtures/sbol/`: fixture-local SBOL files. +- `tests/fixtures/data/`: small deterministic JSON/data fixtures. +- Shared fixture helpers are in `tests/conftest.py`. diff --git a/tests/automation/README.md b/tests/automation/README.md new file mode 100644 index 0000000..b083daf --- /dev/null +++ b/tests/automation/README.md @@ -0,0 +1,10 @@ +# Automation test suite (optional/manual) + +These tests are intentionally separated from core CI. + +Run only when optional dependencies are installed: + +```bash +python -m pip install -e '.[automation,test]' +pytest tests/automation +``` diff --git a/tests/automation/test_opentrons_simulation_optional.py b/tests/automation/test_opentrons_simulation_optional.py new file mode 100644 index 0000000..014ddec --- /dev/null +++ b/tests/automation/test_opentrons_simulation_optional.py @@ -0,0 +1,7 @@ +import pytest + +pytestmark = pytest.mark.automation + + +def test_opentrons_simulation_manual_only(): + pytest.skip("Manual/optional automation validation only. TODO(#67): wire real simulation checks in automation environment.") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..18e616f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import sbol2 +import pytest + +from buildcompiler.api import BuildOptions +from buildcompiler.domain import BuildRequest, BuildStage, DesignKind + + +@pytest.fixture +def default_build_options() -> BuildOptions: + options = BuildOptions() + options.execution.max_iterations = 5 + return options + + +@pytest.fixture +def minimal_sbol_document() -> sbol2.Document: + return sbol2.Document() + + +@pytest.fixture +def minimal_lvl2_request() -> BuildRequest: + return BuildRequest( + id="req-lvl2-1", + stage=BuildStage.ASSEMBLY_LVL2, + source_identity="https://example.org/module/main", + source_display_id="main", + source_kind=DesignKind.MODULE_DEFINITION, + ) diff --git a/tests/integration/test_full_build_happy_path.py b/tests/integration/test_full_build_happy_path.py new file mode 100644 index 0000000..2dddbff --- /dev/null +++ b/tests/integration/test_full_build_happy_path.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import sys + +from buildcompiler.domain import ( + BuildRequest, + BuildStage, + BuildStatus, + DesignKind, + IndexedPlasmid, + MaterialState, + StageResult, + StageStatus, +) +from buildcompiler.execution import BuildContext, FullBuildExecutor +from buildcompiler.planning import BuildPlan +from buildcompiler.inventory import Inventory +from buildcompiler.sbol import SbolResolver + + +class FakeStage: + def __init__(self, result_factory): + self.result_factory = result_factory + + def run(self, request, *, source_document, target_document): + return self.result_factory(request) + + +def _product(identity: str) -> IndexedPlasmid: + return IndexedPlasmid( + identity=identity, + display_id=identity.rsplit("/", 1)[-1], + state=MaterialState.GENERATED, + ) + + +def test_full_build_happy_path_offline(default_build_options, minimal_sbol_document): + lvl2_request = BuildRequest( + id="req-lvl2", + stage=BuildStage.ASSEMBLY_LVL2, + source_identity="https://example.org/module/target", + source_display_id="target", + source_kind=DesignKind.MODULE_DEFINITION, + ) + ctx = BuildContext( + sbol=SbolResolver(minimal_sbol_document), + inventory=Inventory(), + build_document=minimal_sbol_document, + options=default_build_options, + ) + executor = FullBuildExecutor( + context=ctx, + lvl2_stage=FakeStage( + lambda request: StageResult( + id="res-lvl2", + stage=BuildStage.ASSEMBLY_LVL2, + status=StageStatus.SUCCESS, + request_ids=[request.id], + products=[_product("https://example.org/plasmid/lvl2_target")], + ) + ), + lvl1_stage=FakeStage(lambda request: StageResult(id="unused1", stage=request.stage, status=StageStatus.BLOCKED, request_ids=[request.id])), + domestication_stage=FakeStage(lambda request: StageResult(id="unused2", stage=request.stage, status=StageStatus.BLOCKED, request_ids=[request.id])), + ) + + result = executor.execute(BuildPlan(lvl2_requests=[lvl2_request])) + + assert result.status == BuildStatus.SUCCESS + assert result.summary is not None + assert result.final_products + assert "pudupy" not in sys.modules + assert "opentrons" not in sys.modules + assert "SBOLInventory" not in sys.modules diff --git a/tests/integration/test_missing_lvl1_then_domestication.py b/tests/integration/test_missing_lvl1_then_domestication.py new file mode 100644 index 0000000..a0a91a6 --- /dev/null +++ b/tests/integration/test_missing_lvl1_then_domestication.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from buildcompiler.domain import ( + BuildRequest, + BuildStage, + BuildStatus, + DesignKind, + IndexedPlasmid, + MaterialState, + MissingBuildInput, + StageResult, + StageStatus, +) +from buildcompiler.execution import BuildContext, FullBuildExecutor +from buildcompiler.planning import BuildPlan +from buildcompiler.inventory import Inventory +from buildcompiler.sbol import SbolResolver + + +class FakeStage: + def __init__(self, fn): + self.fn = fn + + def run(self, request, *, source_document, target_document): + return self.fn(request) + + +def plasmid(identity: str) -> IndexedPlasmid: + return IndexedPlasmid( + identity=identity, + display_id=identity.rsplit("/", 1)[-1], + state=MaterialState.GENERATED, + ) + + +def test_missing_lvl1_promotes_domestication_and_retries( + default_build_options, minimal_sbol_document +): + lvl1_attempts = {"n": 0} + + def lvl1_fn(request): + lvl1_attempts["n"] += 1 + if lvl1_attempts["n"] == 1: + return StageResult( + id="lvl1-blocked", + stage=BuildStage.ASSEMBLY_LVL1, + status=StageStatus.BLOCKED, + request_ids=[request.id], + missing_inputs=[ + MissingBuildInput( + source_stage=BuildStage.ASSEMBLY_LVL1, + source_design_identity=request.source_identity, + missing_identity="https://example.org/part/promoterA", + missing_display_id="promoterA", + missing_kind="promoter", + required_stage=BuildStage.DOMESTICATION, + reason="missing part", + ) + ], + ) + return StageResult( + id="lvl1-success", + stage=BuildStage.ASSEMBLY_LVL1, + status=StageStatus.SUCCESS, + request_ids=[request.id], + products=[plasmid("https://example.org/plasmid/lvl1_after_dom")], + ) + + def domestication_fn(request): + return StageResult( + id="dom-success", + stage=BuildStage.DOMESTICATION, + status=StageStatus.SUCCESS, + request_ids=[request.id], + products=[plasmid("https://example.org/plasmid/dom_promoterA")], + ) + + context = BuildContext( + sbol=SbolResolver(minimal_sbol_document), + inventory=Inventory(), + build_document=minimal_sbol_document, + options=default_build_options, + ) + executor = FullBuildExecutor( + context=context, + lvl2_stage=FakeStage( + lambda request: StageResult( + id="unused-lvl2", + stage=request.stage, + status=StageStatus.BLOCKED, + request_ids=[request.id], + ) + ), + lvl1_stage=FakeStage(lvl1_fn), + domestication_stage=FakeStage(domestication_fn), + ) + + plan = BuildPlan( + lvl1_requests=[ + BuildRequest( + id="req-lvl1", + stage=BuildStage.ASSEMBLY_LVL1, + source_identity="https://example.org/engineered/region1", + source_display_id="region1", + source_kind=DesignKind.COMPONENT_DEFINITION, + ) + ] + ) + + result = executor.execute(plan) + + assert lvl1_attempts["n"] >= 2 + assert any(sr.stage == BuildStage.DOMESTICATION for sr in result.stage_results) + assert any(p.display_id == "dom_promoterA" for p in result.final_products) + assert result.status in {BuildStatus.SUCCESS, BuildStatus.PARTIAL_SUCCESS} diff --git a/tests/stages/test_domestication.py b/tests/stages/test_domestication.py new file mode 100644 index 0000000..75394d1 --- /dev/null +++ b/tests/stages/test_domestication.py @@ -0,0 +1,5 @@ +from buildcompiler.stages import DomesticationStage + + +def test_domestication_stage_importable(): + assert DomesticationStage diff --git a/tests/stages/test_stage_assembly_lvl1.py b/tests/stages/test_stage_assembly_lvl1.py new file mode 100644 index 0000000..df4b638 --- /dev/null +++ b/tests/stages/test_stage_assembly_lvl1.py @@ -0,0 +1,5 @@ +from buildcompiler.stages import AssemblyLvl1Stage + + +def test_assembly_lvl1_stage_importable(): + assert AssemblyLvl1Stage diff --git a/tests/stages/test_stage_assembly_lvl2.py b/tests/stages/test_stage_assembly_lvl2.py new file mode 100644 index 0000000..e2fd0eb --- /dev/null +++ b/tests/stages/test_stage_assembly_lvl2.py @@ -0,0 +1,5 @@ +from buildcompiler.stages import AssemblyLvl2Stage + + +def test_assembly_lvl2_stage_importable(): + assert AssemblyLvl2Stage diff --git a/tests/unit/test_core_imports.py b/tests/unit/test_core_imports.py index f5440d5..8f86fa9 100644 --- a/tests/unit/test_core_imports.py +++ b/tests/unit/test_core_imports.py @@ -3,16 +3,23 @@ def test_core_imports_do_not_load_optional_automation_dependencies(): import buildcompiler - from buildcompiler.adapters.opentrons import OpentronsSimulationAdapter from buildcompiler.adapters.pudu import ( + assembly_route_to_pudu_json, plating_to_pudu_json, transformation_to_pudu_json, ) - from buildcompiler.api import BuildOptions + from buildcompiler.api import BuildCompiler, BuildOptions + from buildcompiler.execution import FullBuildExecutor + from buildcompiler.reporting import BuildGraph, BuildReport, BuildSummary assert buildcompiler + assert BuildCompiler assert BuildOptions - assert OpentronsSimulationAdapter + assert FullBuildExecutor + assert BuildSummary + assert BuildReport + assert BuildGraph + assert assembly_route_to_pudu_json assert transformation_to_pudu_json assert plating_to_pudu_json