Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dependencies = [

[project.optional-dependencies]
test = [
"pytest < 5.0.0",
"pytest>=7,<9",
"pytest-cov[all]"
]
automation = [
Expand All @@ -48,3 +48,9 @@ automation = [
dev = [
"ruff>=0.14.0",
]


[tool.pytest.ini_options]
markers = [
"automation: optional/manual automation tests",
]
33 changes: 33 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -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`.
10 changes: 10 additions & 0 deletions tests/automation/README.md
Original file line number Diff line number Diff line change
@@ -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
```
7 changes: 7 additions & 0 deletions tests/automation/test_opentrons_simulation_optional.py
Original file line number Diff line number Diff line change
@@ -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.")
30 changes: 30 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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,
)
73 changes: 73 additions & 0 deletions tests/integration/test_full_build_happy_path.py
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions tests/integration/test_missing_lvl1_then_domestication.py
Original file line number Diff line number Diff line change
@@ -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}
5 changes: 5 additions & 0 deletions tests/stages/test_domestication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from buildcompiler.stages import DomesticationStage


def test_domestication_stage_importable():
assert DomesticationStage
5 changes: 5 additions & 0 deletions tests/stages/test_stage_assembly_lvl1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from buildcompiler.stages import AssemblyLvl1Stage


def test_assembly_lvl1_stage_importable():
assert AssemblyLvl1Stage
5 changes: 5 additions & 0 deletions tests/stages/test_stage_assembly_lvl2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from buildcompiler.stages import AssemblyLvl2Stage


def test_assembly_lvl2_stage_importable():
assert AssemblyLvl2Stage
13 changes: 10 additions & 3 deletions tests/unit/test_core_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading