diff --git a/docs/superpowers/PR-PLAN.md b/docs/superpowers/PR-PLAN.md
index aeb217d3..042c5e5c 100644
--- a/docs/superpowers/PR-PLAN.md
+++ b/docs/superpowers/PR-PLAN.md
@@ -14,7 +14,9 @@ Each PR diffs cleanly against its parent in the stack; integrate the chain when
| 3 | `feature/temp-change-tactic` (C) | Automated temp-change burst protocol (`wait_for_temperature_lock` + driver), burst-acquisition wiring, protocol events + agent tool | B1 | per-task + whole-branch review + fixes |
| 4 | `feature/operations-tab` (D) | **Operations: the agent-authored Operation Plan** — typed declare tool, store, route, execution-linkage (tactic_id + updater), plan-item seeding, operation-spine renderer + live binding | C | 10 tasks + whole-branch (opus) review + 6 fixes; 105 tests; Chrome-audited |
| 5 | `feature/tactics-library` (G) | Save / list / apply reusable typed tactics (on D's substrate), mirroring plan-templates; apply→Operation Plan | D | 3 tasks + whole-branch review + fixes; ~64 tests |
-| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | TBD (SDD) |
+| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | 4 tasks + whole-branch review + fix; 54 tests; Chrome-audited |
+| 7 | `feature/session-plan-linking` (F) | Session↔**plans** link/delink: multi-plan model (`unlink_plan_item_session` + reverse-query), link/delink endpoints, Plans-tab controls + session Linked-plans panel | D2 | 4 tasks + whole-branch review + fix; ~70 tests; both surfaces Chrome-audited |
+| 8 | `feature/manual-mode-dual-camera` (B2) | Dual-camera config + laser-preset browser + timelapse config form (extends B1 manual mode) | F | TBD (SDD) |
Notes:
- Each branch is the natural unit of "distinct enough to own a PR." Sub-parts (e.g. B1's safety fixes)
diff --git a/docs/superpowers/plans/2026-06-29-session-plan-linking.md b/docs/superpowers/plans/2026-06-29-session-plan-linking.md
new file mode 100644
index 00000000..f58b8a41
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-29-session-plan-linking.md
@@ -0,0 +1,39 @@
+# Session ↔ plans link/delink (F) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
+
+**Goal:** Let a session link to multiple plan items, with link/delink from both the Plans tab and the session view. Source of truth = `PlanItem.session_ids` (reverse-query for a session's plans). Base plans deferred.
+
+**Architecture:** Data-layer `unlink_plan_item_session` + `get_plan_items_for_session` (reverse query); link/delink HTTP endpoints; link/delink UI on the Plans-tab item detail + a "Linked plans" panel on the session/Operations view.
+
+## Global Constraints
+- Source of truth for session↔plan-item linkage = `PlanItem.session_ids` (a list; `link_plan_item_session` appends — `file_store.py:1415`). A session's plans = reverse query. Do NOT add a plan_item list to SessionIntent.
+- Campaign edge stays `SessionIntent.campaign_ids` (`link/unlink_session_campaign`). Linking a session to a plan item also `link_session_campaign(item.campaign_id)`. Delink (v1) only removes the plan-item edge.
+- Endpoints mirror `gently/ui/web/routes/campaigns.py` (the item-detail route at :212, PATCH at :324). UI mirrors `campaigns.js` item-detail rendering (~:789-802 Sessions section).
+- Repo/base plans DEFERRED (spec §4) — do not build.
+- Git hygiene: stage only your files by explicit path; never `git add -A`.
+
+---
+
+### Task 1: Data layer — unlink + reverse-query
+**Files:** Modify `gently/harness/memory/file_store.py` — add `unlink_plan_item_session(item_id, session_id)` (remove session from the item's `session_ids`, clear back-compat `session_id` if equal, persist to the campaign plan, fire `_notify_plan_change`; idempotent if absent) and `get_plan_items_for_session(session_id) -> list[PlanItem]` (iterate `get_active_campaigns()` → `get_plan_items(campaign_id)` → filter `session_id in (item.session_ids or [])`). Test: `tests/test_session_plan_linking_store.py`.
+- [ ] Confirm `link_plan_item_session` (:1415) — how it appends to session_ids + persists the plan item to `plan/current.yaml` — and mirror the persistence for unlink. Confirm `get_active_campaigns`/`get_plan_items`.
+- [ ] TDD: link a session to 2 items (different campaigns) → `get_plan_items_for_session` returns both; `unlink_plan_item_session` removes it from one (other remains); unlink absent → no-op; back-compat `session_id` cleared when it matched. `pytest tests/test_session_plan_linking_store.py -v`; `pytest -q` clean. Commit `feat(f): unlink_plan_item_session + get_plan_items_for_session`.
+
+### Task 2: Link/delink endpoints
+**Files:** Modify `gently/ui/web/routes/campaigns.py` — `POST /api/campaigns/{cid}/items/{item_id}/sessions` (body `{session_id}` → `link_plan_item_session` + `link_session_campaign`; return updated sessions) and `DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}` (→ `unlink_plan_item_session`); add `GET /api/sessions/{id}/plans` in `gently/ui/web/routes/sessions.py` (→ `get_plan_items_for_session`, return `[{id,title,campaign_id,status}]`). Test: `tests/test_session_plan_linking_routes.py`.
+- [ ] Mirror the campaigns route store resolution (`server.context_store`/`gently_store`) + the item-detail route (:212). The session-plans route mirrors `sessions.py` resolution. Graceful (404 item/session; empty list).
+- [ ] TDD (TestClient + mock store): POST links (store.link_plan_item_session + link_session_campaign called); DELETE delinks (unlink called); GET returns the session's plans; bad ids handled. `pytest tests/test_session_plan_linking_routes.py -v`; `pytest -q` clean. Commit `feat(f): session↔plan link/delink endpoints`.
+
+### Task 3: Plans-tab link/delink UI
+**Files:** Modify `gently/ui/web/static/js/campaigns.js` (the item-detail Sessions section ~:789-802 — add a `[+ link session]` picker (sessions from `/api/sessions`) + a `[delink]` button per session, calling the POST/DELETE endpoints then re-rendering the item) + `gently/ui/web/static/css/campaigns.css` (the control styles).
+- [ ] Render the link control + per-session delink in the item-detail Sessions list; wire to the endpoints; optimistic re-render / refetch the item on success; keep the "No linked sessions" empty state with the link control. `node --check`; build a Chrome-MCP harness (real campaigns.js + a stubbed item-detail + /api/sessions) for the controller to audit. Commit `feat(f): Plans-tab session link/delink controls`.
+
+### Task 4: Session "Linked plans" panel
+**Files:** Modify the session/Operations view JS (`gently/ui/web/static/js/experiment-overview.js` or the session view `review.js` — whichever renders the active session header; CONFIRM which surface) — add a "Linked plans" panel listing `/api/sessions/{id}/plans` rows (title · campaign · status · `[delink]`) + a `[+ link to a plan]` picker (plan items from active campaigns via `/api/campaigns` tree); wire to the same endpoints; CSS as needed.
+- [ ] Confirm the session-scoped render surface + how it knows the current session_id. Render the panel reading `/api/sessions/{id}/plans`; link/delink hit the same endpoints + refresh; symmetric with Task 3. Backward compat: no linked plans → a tidy empty state, no crash. `node --check`; Chrome-MCP harness audit. Commit `feat(f): session Linked-plans panel (link/delink)`.
+
+## Self-Review
+- Data→T1; endpoints→T2; Plans-tab UI→T3; session panel→T4. ✓
+- Open confirmations: link_plan_item_session persistence (T1), campaigns/sessions route patterns (T2), the campaigns.js item-detail render seam (T3), the session-scoped render surface + current session_id (T4).
+- Type consistency: plan-item linkage via `PlanItem.session_ids`; the endpoints + both UIs hit the same POST/DELETE/GET; a link from one surface appears on the other after refresh.
diff --git a/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md b/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md
new file mode 100644
index 00000000..4b3be8f6
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md
@@ -0,0 +1,64 @@
+# Design: Session ↔ plans link/delink (sub-project F)
+
+Status: design 2026-06-29 (after recon + user steering). Branch `feature/session-plan-linking` (off D2).
+Lets a session link to MULTIPLE plan items, with link/delink from BOTH the Plans tab and the session
+view. "Repo/base plans" is DEFERRED (user has ideas — separate follow-on).
+
+## 0. What exists (recon)
+- Session↔**campaign**: many-to-many (`SessionIntent.campaign_ids` list; `link/unlink_session_campaign`).
+- Session↔**plan-item**: `PlanItem.session_ids` is a LIST (a session can already appear under multiple
+ items in storage) — but a session's *own* notion of "its plans" is a single `active_plan_item_id`
+ pointer, and `SessionIntent` stores no plan-item ids.
+- `attach_session_to_plan` appends to `item.session_ids` (via `link_plan_item_session`) but overwrites
+ the single active pointer; `detach_session_from_plan` only clears the pointer (NOT a data delink).
+- Plans tab (`campaigns.js:789-802`) shows a per-item read-only Sessions list ("No linked sessions").
+- NO link/delink endpoint or UI anywhere; NO `unlink_plan_item_session`; session endpoint returns no linkage.
+
+## 1. The model — source of truth = `PlanItem.session_ids`
+A session's linked plan items = the reverse query over plan items whose `session_ids` includes the
+session. No new field on SessionIntent (avoids dual source of truth). Multi-plan falls out naturally
+(a session can be in many items' `session_ids`). The campaign edge stays on `SessionIntent.campaign_ids`.
+
+- **Link** session↔plan-item: `link_plan_item_session(item_id, session_id)` (exists, appends) +
+ `link_session_campaign(session_id, item.campaign_id)` (exists).
+- **Delink**: NEW `unlink_plan_item_session(item_id, session_id)` — remove the session from
+ `item.session_ids` (+ clear the back-compat `session_id` if it pointed there); fire `_notify_plan_change`.
+ Campaign edge: leave it unless no other item of that campaign links the session (refinement — for v1,
+ delink only touches the plan-item edge; campaign delink stays the existing separate control).
+- **Session's plans**: NEW `get_plan_items_for_session(session_id) -> list[PlanItem]` (reverse query
+ across `get_active_campaigns` → `get_plan_items` → filter `session_id in item.session_ids`).
+
+## 2. Endpoints (mirror `routes/campaigns.py`)
+- `POST /api/campaigns/{cid}/items/{item_id}/sessions` body `{session_id}` → link (link_plan_item_session
+ + link_session_campaign). Returns the updated item sessions.
+- `DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}` → delink (unlink_plan_item_session).
+- `GET /api/sessions/{id}/plans` → the session's linked plan items (id, title, campaign_id, status) via
+ `get_plan_items_for_session`. (A new sub-route; leaves the existing session payload untouched.)
+
+## 3. UI — both surfaces
+### 3.1 Plans tab item-detail (`campaigns.js` ~:789-802)
+The existing per-item Sessions list gains: a **[+ link session]** control (a picker of recent sessions
+from `/api/sessions`) and a **[delink]** button per listed session. Calls the POST/DELETE endpoints,
+re-renders the item detail. Empty state keeps "No linked sessions" + the link control.
+
+### 3.2 Session / Operations view — "Linked plans" panel
+A panel (in the Operations/experiment view header or a session detail strip) listing the session's
+linked plan items (from `/api/sessions/{id}/plans`): each row `plan item title · campaign · status ·
+[delink]`, plus **[+ link to a plan]** (a picker of plan items from the active campaigns). Symmetric
+with 3.1 — link/delink from either side; both hit the same endpoints + refresh.
+
+## 4. Out of scope (deferred)
+- **Repo/base plans** — the user has ideas; a separate follow-on (a repo plans library / seed). Noted,
+ not built here.
+- Campaign-edge auto-cleanup on plan delink (v1 leaves the campaign link; refine later).
+- Reworking `attach_session_to_plan`/`detach` agent tools beyond what's needed — the data-layer
+ delink (`unlink_plan_item_session`) is added; wiring a `detach` that calls it is a small optional add.
+
+## 5. Testing
+- Data layer: `unlink_plan_item_session` removes the session (+ back-compat session_id); idempotent on
+ absent; `get_plan_items_for_session` reverse-query returns the right items across campaigns; multi-plan
+ (a session under 2 items) round-trips.
+- Endpoints: POST links (item.session_ids gains it + campaign linked); DELETE delinks; GET returns the
+ session's plans; mirror `tests/test_*route*` with a mock store.
+- UI: link/delink controls on both surfaces (node --check + Chrome audit of the Plans-tab item detail +
+ the session "Linked plans" panel); link from one side shows on the other after refresh.
diff --git a/gently/app/agent.py b/gently/app/agent.py
index 47b2421d..3aa4989d 100644
--- a/gently/app/agent.py
+++ b/gently/app/agent.py
@@ -474,6 +474,7 @@ def exit_plan_mode(self) -> str:
from gently.app.tools.operation_plan_seed import (
seed_operation_plan_from_plan_item,
)
+
seed_operation_plan_from_plan_item(self.context_store, self.session_id)
except Exception:
logger.exception("operation-plan seeding failed")
@@ -650,9 +651,7 @@ def _init_timelapse_orchestrator(self):
store=self.store,
claude_client=self.claude,
temperature_provider=lambda: (
- self.temperature_sampler.latest
- if self.temperature_sampler
- else None
+ self.temperature_sampler.latest if self.temperature_sampler else None
),
)
except Exception as e:
diff --git a/gently/app/operation_plan_updater.py b/gently/app/operation_plan_updater.py
index d2e2b9e4..738fe10c 100644
--- a/gently/app/operation_plan_updater.py
+++ b/gently/app/operation_plan_updater.py
@@ -12,13 +12,14 @@
`tactic_id` or session → skip. Handler exceptions are caught so a bad event
never crashes the bus.
"""
+
from __future__ import annotations
import logging
-from typing import Callable
+from collections.abc import Callable
+from gently.core.event_bus import EventType, get_event_bus
from gently.core.service import Service
-from gently.core.event_bus import get_event_bus, EventType
logger = logging.getLogger(__name__)
diff --git a/gently/app/orchestration/role_scope.py b/gently/app/orchestration/role_scope.py
index b36bd89e..66c9b9d5 100644
--- a/gently/app/orchestration/role_scope.py
+++ b/gently/app/orchestration/role_scope.py
@@ -52,9 +52,7 @@ def resolve_scope_embryos(scope: dict | None, embryos: list[dict]) -> list[str]:
if mode == "role":
target_role = scope.get("role")
return [
- e["embryo_id"]
- for e in embryos
- if "embryo_id" in e and e.get("role") == target_role
+ e["embryo_id"] for e in embryos if "embryo_id" in e and e.get("role") == target_role
]
return []
diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py
index 115234f2..e25ee148 100644
--- a/gently/app/orchestration/temperature_protocol.py
+++ b/gently/app/orchestration/temperature_protocol.py
@@ -1,8 +1,12 @@
-import asyncio, logging
+import asyncio
+import logging
+
from gently.app.orchestration.exclusive import BurstAcquisition
from gently.core.event_bus import EventType
+
logger = logging.getLogger(__name__)
+
async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool:
"""Poll the controller until it reports a locked state, or timeout. Substring 'LOCKED'."""
loop = asyncio.get_event_loop()
@@ -11,7 +15,8 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.
try:
resp = await client.get_temperature()
except Exception as exc:
- logger.warning("wait_for_temperature_lock poll failed: %s", exc); resp = {}
+ logger.warning("wait_for_temperature_lock poll failed: %s", exc)
+ resp = {}
if "LOCKED" in str(resp.get("state", "")):
return True
if loop.time() - t0 >= timeout_s:
@@ -19,35 +24,66 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.
await asyncio.sleep(poll_s)
-async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *,
- frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1,
- lock_timeout_s=600.0, poll_s=2.0, burst_runner=None, tactic_id=None):
+async def run_temp_change_burst_protocol(
+ orchestrator,
+ embryo_id,
+ target_setpoint_c,
+ *,
+ frames=60,
+ mode="1hz",
+ num_slices=1,
+ bursts_before=1,
+ bursts_after=1,
+ lock_timeout_s=600.0,
+ poll_s=2.0,
+ burst_runner=None,
+ tactic_id=None,
+):
"""Scripted temp-change burst protocol: brightfield before/during/after a setpoint change."""
client = orchestrator.client
if burst_runner is None:
- async def burst_runner(b): await b.run(orchestrator)
+
+ async def burst_runner(b):
+ await b.run(orchestrator)
async def one_burst(phase):
- b = BurstAcquisition(embryo_id, frames=frames, mode=mode, num_slices=num_slices,
- temperature_provider=getattr(orchestrator, "_temperature_provider", None),
- laser_config="ALL OFF")
+ b = BurstAcquisition(
+ embryo_id,
+ frames=frames,
+ mode=mode,
+ num_slices=num_slices,
+ temperature_provider=getattr(orchestrator, "_temperature_provider", None),
+ laser_config="ALL OFF",
+ )
b._phase = phase
await burst_runner(b)
- locked = False; error = None; cancelled = False
+ locked = False
+ error = None
+ cancelled = False
try:
await client.set_laser_config("ALL OFF")
await client.set_led("Open")
- orchestrator._emit_event(EventType.TEMP_PROTOCOL_STARTED,
- {"embryo_id": embryo_id, "target_setpoint_c": target_setpoint_c,
- "frames": frames, "bursts_before": bursts_before, "bursts_after": bursts_after,
- "tactic_id": tactic_id})
+ orchestrator._emit_event(
+ EventType.TEMP_PROTOCOL_STARTED,
+ {
+ "embryo_id": embryo_id,
+ "target_setpoint_c": target_setpoint_c,
+ "frames": frames,
+ "bursts_before": bursts_before,
+ "bursts_after": bursts_after,
+ "tactic_id": tactic_id,
+ },
+ )
for _ in range(bursts_before):
await one_burst("before")
await client.set_temperature(target_setpoint_c)
- orchestrator._emit_event(EventType.TEMPERATURE_SETPOINT_CHANGED,
- {"embryo_id": embryo_id, "to": target_setpoint_c})
- loop = asyncio.get_event_loop(); t0 = loop.time()
+ orchestrator._emit_event(
+ EventType.TEMPERATURE_SETPOINT_CHANGED,
+ {"embryo_id": embryo_id, "to": target_setpoint_c},
+ )
+ loop = asyncio.get_event_loop()
+ t0 = loop.time()
while True:
await one_burst("during")
try:
@@ -55,17 +91,27 @@ async def one_burst(phase):
except Exception:
st = ""
if "LOCKED" in st:
- locked = True; break
+ locked = True
+ break
if loop.time() - t0 >= lock_timeout_s:
break
for _ in range(bursts_after):
await one_burst("after")
except asyncio.CancelledError:
- cancelled = True; raise
+ cancelled = True
+ raise
except Exception as exc:
- error = str(exc); logger.exception("temp-change burst protocol failed")
+ error = str(exc)
+ logger.exception("temp-change burst protocol failed")
finally:
- orchestrator._emit_event(EventType.TEMP_PROTOCOL_COMPLETED,
- {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error,
- "tactic_id": tactic_id})
+ orchestrator._emit_event(
+ EventType.TEMP_PROTOCOL_COMPLETED,
+ {
+ "embryo_id": embryo_id,
+ "locked": locked,
+ "cancelled": cancelled,
+ "error": error,
+ "tactic_id": tactic_id,
+ },
+ )
return {"locked": locked, "cancelled": cancelled, "error": error}
diff --git a/gently/app/temperature_sampler.py b/gently/app/temperature_sampler.py
index e762534c..0995d1f2 100644
--- a/gently/app/temperature_sampler.py
+++ b/gently/app/temperature_sampler.py
@@ -6,14 +6,15 @@
and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a
crash; with no active session the loop idles.
"""
+
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
+from gently.core.event_bus import EventType, get_event_bus
from gently.core.service import Service
-from gently.core.event_bus import get_event_bus, EventType
logger = logging.getLogger(__name__)
diff --git a/gently/app/tools/acquisition_tools.py b/gently/app/tools/acquisition_tools.py
index 6e029ad4..bfc892ea 100644
--- a/gently/app/tools/acquisition_tools.py
+++ b/gently/app/tools/acquisition_tools.py
@@ -213,10 +213,9 @@ async def acquire_volume(
},
}
from gently.app.temperature_sampler import temperature_stamp as _ts
+
_temp_stamp = _ts(
- getattr(
- getattr(agent, "temperature_sampler", None), "latest", None
- )
+ getattr(getattr(agent, "temperature_sampler", None), "latest", None)
)
if _temp_stamp is not None:
acq_metadata["temperature"] = _temp_stamp
diff --git a/gently/app/tools/operation_plan_seed.py b/gently/app/tools/operation_plan_seed.py
index 3878e704..3bf0caa1 100644
--- a/gently/app/tools/operation_plan_seed.py
+++ b/gently/app/tools/operation_plan_seed.py
@@ -50,9 +50,7 @@ def _resolve_plan_item_with_tactics(context_store, session_id: str):
items = context_store.get_plan_items(campaign_id=cid)
for item in items:
# Match items that list this session
- session_ids = item.session_ids or (
- [item.session_id] if item.session_id else []
- )
+ session_ids = item.session_ids or ([item.session_id] if item.session_id else [])
if session_id not in session_ids and item.session_id != session_id:
continue
# Must carry an imaging_spec with at least one tactic outline entry
diff --git a/gently/app/tools/tactic_library_tools.py b/gently/app/tools/tactic_library_tools.py
index 96a3eee5..78b141d5 100644
--- a/gently/app/tools/tactic_library_tools.py
+++ b/gently/app/tools/tactic_library_tools.py
@@ -124,7 +124,9 @@ async def list_tactics(
lines = [f"Tactic Library ({len(tactics)} template{'s' if len(tactics) != 1 else ''}):"]
for t in tactics:
- lines.append(f" [{t.get('id', '?')}] {t.get('name', '(unnamed)')} — kind: {t.get('kind', '?')}")
+ lines.append(
+ f" [{t.get('id', '?')}] {t.get('name', '(unnamed)')} — kind: {t.get('kind', '?')}"
+ )
return "\n".join(lines)
diff --git a/gently/app/tools/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py
index 3e79055f..80d6166a 100644
--- a/gently/app/tools/temperature_protocol_tools.py
+++ b/gently/app/tools/temperature_protocol_tools.py
@@ -8,7 +8,11 @@
import asyncio
-from gently.harness.tools.helpers import ctx_get, require_agent, require_microscope, require_timelapse_orchestrator
+from gently.harness.tools.helpers import (
+ require_agent,
+ require_microscope,
+ require_timelapse_orchestrator,
+)
from gently.harness.tools.registry import ToolCategory, ToolExample, tool
@@ -33,7 +37,12 @@
),
ToolExample(
"Cold-shock embryo_2 to 15 C with 2 bursts before and after",
- {"embryo_id": "embryo_2", "target_setpoint_c": 15.0, "bursts_before": 2, "bursts_after": 2},
+ {
+ "embryo_id": "embryo_2",
+ "target_setpoint_c": 15.0,
+ "bursts_before": 2,
+ "bursts_after": 2,
+ },
),
],
)
diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py
index 0b5db1cd..4df91bb4 100644
--- a/gently/core/event_bus.py
+++ b/gently/core/event_bus.py
@@ -129,7 +129,9 @@ class EventType(Enum):
# Temperature protocol events (Phase X / 10) — temp-change burst tactic
TEMPERATURE_SETPOINT_CHANGED = auto() # {embryo_id, to}
- TEMP_PROTOCOL_STARTED = auto() # {embryo_id, target_setpoint_c, frames, bursts_before, bursts_after}
+ TEMP_PROTOCOL_STARTED = (
+ auto()
+ ) # {embryo_id, target_setpoint_c, frames, bursts_before, bursts_after}
TEMP_PROTOCOL_COMPLETED = auto() # {embryo_id, locked, cancelled, error}
# Reactive control telemetry (Phase 5 / 10)
diff --git a/gently/core/file_store.py b/gently/core/file_store.py
index d9dafaaa..0f82a486 100644
--- a/gently/core/file_store.py
+++ b/gently/core/file_store.py
@@ -450,7 +450,8 @@ def append_temperature_sample(self, session_id: str, sample: dict) -> None:
_append_jsonl(sd / "temperature.jsonl", sample)
def read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]:
- """Return temperature samples for a session, optionally filtered to t >= since (ISO-UTC string).
+ """Return temperature samples for a session, optionally filtered to
+ t >= since (ISO-UTC string).
Reads lines tolerantly: a truncated trailing line (e.g. after a mid-append
crash) is silently skipped rather than raising a JSONDecodeError.
@@ -778,9 +779,7 @@ def get_volume_meta(self, session_id: str, embryo_id: str, timepoint: int) -> di
sd = self._session_dir(session_id)
if sd is None:
return None
- meta_path = (
- sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint)
- )
+ meta_path = sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint)
return _read_yaml(meta_path)
def list_volumes(self, session_id: str, embryo_id: str | None = None) -> list[VolumeInfo]:
diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py
index 1437a8a8..58746750 100644
--- a/gently/hardware/dispim/client.py
+++ b/gently/hardware/dispim/client.py
@@ -1014,9 +1014,7 @@ async def stream_lightsheet(self, timeout: float | None = None):
except Exception as exc:
logger.warning("Malformed lightsheet SSE payload skipped: %s", exc)
- async def set_lightsheet_live_params(
- self, galvo=None, piezo=None, exposure=None
- ) -> dict:
+ async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict:
"""POST live galvo/piezo/exposure to the device-layer lightsheet streamer."""
body = {}
if galvo is not None:
diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py
index 0e44ebfa..6173236f 100644
--- a/gently/hardware/dispim/device_layer.py
+++ b/gently/hardware/dispim/device_layer.py
@@ -171,14 +171,14 @@ def __init__(
# Lightsheet (SPIM) live stream — continuous sequence acquisition.
self._ls_subscribers: list[asyncio.Queue] = []
self._ls_task: asyncio.Task | None = None
- self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows
+ self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows
self._ls_target_max_dim: int = 512
self._ls_jpeg_quality: int = 70
self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0}
self._ls_seq_started: bool = False
- self._ls_applied: dict = {} # last-applied galvo/piezo/exposure
- self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values
- self._ls_spim_idle: bool = False # whether SPIM state machine was set Idle this session
+ self._ls_applied: dict = {} # last-applied galvo/piezo/exposure
+ self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values
+ self._ls_spim_idle: bool = False # whether SPIM state machine was set Idle this session
# Plans that hold MMCore for long performance-critical work.
# Anything in this set runs with state polling paused.
@@ -1022,10 +1022,7 @@ def _ensure_lightsheet_sequence_sync(self) -> None:
if cam is None:
raise RuntimeError("No lightsheet camera configured")
p = self._ls_params
- need_restart = (
- not self._ls_seq_started
- or self._ls_applied.get("exposure") != p["exposure"]
- )
+ need_restart = not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"]
if need_restart:
if core.isSequenceRunning():
core.stopSequenceAcquisition()
@@ -1039,8 +1036,8 @@ def _ensure_lightsheet_sequence_sync(self) -> None:
def _grab_lightsheet_frame_sync(self):
"""Park → ensure sequence running → peek the latest frame (never drain)."""
try:
- self._park_lightsheet_sync() # galvo/piezo applied live
- self._ensure_lightsheet_sequence_sync() # start / restart on exposure
+ self._park_lightsheet_sync() # galvo/piezo applied live
+ self._ensure_lightsheet_sequence_sync() # start / restart on exposure
try:
from gently.hardware.dispim.devices.acquisition import _safe_obtain
except (ImportError, AttributeError):
@@ -1083,7 +1080,9 @@ async def _lightsheet_streamer(self):
tick = time.monotonic()
img = await asyncio.to_thread(self._grab_lightsheet_frame_sync)
payload = (
- self._encode_frame_for_stream(img, self._ls_target_max_dim, self._ls_jpeg_quality)
+ self._encode_frame_for_stream(
+ img, self._ls_target_max_dim, self._ls_jpeg_quality
+ )
if img is not None
else None
)
diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py
index 6eaab365..2e4fb23c 100644
--- a/gently/harness/memory/file_store.py
+++ b/gently/harness/memory/file_store.py
@@ -1437,6 +1437,51 @@ def link_plan_item_session(
self._notify_plan_change(campaign_id)
return True
+ def unlink_plan_item_session(self, item_id: str, session_id: str) -> bool:
+ """Remove a session from a plan item's session_ids list.
+
+ Mirrors the load/persist/notify pattern of link_plan_item_session.
+ Clears the back-compat scalar session_id when it matched the removed
+ session (sets it to the most-recent remaining session_id, or None).
+ Idempotent: returns False without writing if the session isn't linked.
+ Returns True on successful removal.
+ """
+ loc = self._find_plan_item_location(item_id)
+ if not loc:
+ return False
+ campaign_id, items, idx = loc
+ item = items[idx]
+ sids = item.get("session_ids") or ([item["session_id"]] if item.get("session_id") else [])
+ if session_id not in sids:
+ return False
+ sids = [s for s in sids if s != session_id]
+ item["session_ids"] = sids
+ item["session_id"] = sids[-1] if sids else None # back-compat: most recent remaining
+ item["updated_at"] = self._now()
+ self._write_plan_items(campaign_id, items)
+ self._notify_plan_change(campaign_id)
+ return True
+
+ def get_plan_items_for_session(self, session_id: str) -> list["PlanItem"]:
+ """Return all plan items linked to a session.
+
+ Iterates active campaigns only (mirrors the normal read path).
+ Back-compat: matches items whose scalar session_id equals the query even
+ when session_ids is empty (old items written before the list field existed).
+ Deduplicates by item id.
+ """
+ seen: set[str] = set()
+ result: list[PlanItem] = []
+ for campaign in self.get_active_campaigns():
+ for item in self.get_plan_items(campaign.id):
+ if item.id in seen:
+ continue
+ # session_ids is already populated from back-compat in _dict_to_plan_item
+ if session_id in (item.session_ids or []) or item.session_id == session_id:
+ seen.add(item.id)
+ result.append(item)
+ return result
+
def skip_plan_item(self, item_id: str, reason: str | None = None):
self.update_plan_item(
item_id,
diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py
index caebd0ec..a881a0bb 100644
--- a/gently/ui/web/routes/__init__.py
+++ b/gently/ui/web/routes/__init__.py
@@ -13,14 +13,14 @@
from .context import create_router as create_context_router
from .data import create_router as create_data_router
from .experiments import create_router as create_experiments_router
-from .operation_plan import create_router as create_operation_plan_router
-from .roles import create_router as create_roles_router
-from .tactic_library import create_router as create_tactic_library_router
-from .temperature import create_router as create_temperature_router
from .images import create_router as create_images_router
from .notebook import create_router as create_notebook_router
+from .operation_plan import create_router as create_operation_plan_router
from .pages import create_router as create_pages_router
+from .roles import create_router as create_roles_router
from .sessions import create_router as create_sessions_router
+from .tactic_library import create_router as create_tactic_library_router
+from .temperature import create_router as create_temperature_router
from .volumes import create_router as create_volumes_router
from .websocket import create_router as create_websocket_router
diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py
index b0dbae11..4d9ee071 100644
--- a/gently/ui/web/routes/campaigns.py
+++ b/gently/ui/web/routes/campaigns.py
@@ -244,8 +244,12 @@ async def get_item_detail(campaign_id: str, item_id: str):
}
)
- # Sessions linked to this campaign
- sessions = cs.get_sessions_for_campaign(item.campaign_id)
+ # Sessions — return only those linked to this specific item (item.session_ids),
+ # not all campaign sessions. The frontend uses item.session_ids as the canonical
+ # list and this pool as metadata (name, created_at) for display.
+ item_sids = set(item.session_ids or [])
+ all_sessions = cs.get_sessions_for_campaign(item.campaign_id)
+ sessions = [s for s in all_sessions if s.session_id in item_sids]
return {
"item": _serialize(item),
@@ -254,6 +258,43 @@ async def get_item_detail(campaign_id: str, item_id: str):
"sessions": [_serialize(s) for s in sessions],
}
+ @router.post("/api/campaigns/{campaign_id}/items/{item_id}/sessions")
+ async def link_session_to_item(campaign_id: str, item_id: str, request: Request):
+ """Link a session to a plan item (appends) and record it against the campaign."""
+ cs = _get_store()
+ campaign = _resolve(cs, campaign_id)
+ item = cs.get_plan_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Plan item not found")
+
+ body = await request.json()
+ session_id = body.get("session_id")
+ if not session_id:
+ raise HTTPException(status_code=400, detail="session_id required")
+
+ cs.link_plan_item_session(item_id, session_id)
+ cs.link_session_campaign(session_id, campaign.id)
+
+ # Re-fetch item so session_ids reflects the just-added link; then filter
+ # exactly as get_item_detail does — POST and GET return the same scope.
+ item = cs.get_plan_item(item_id)
+ item_sids = set(item.session_ids or [])
+ all_sessions = cs.get_sessions_for_campaign(item.campaign_id)
+ sessions = [s for s in all_sessions if s.session_id in item_sids]
+ return {"sessions": [_serialize(s) for s in sessions]}
+
+ @router.delete("/api/campaigns/{campaign_id}/items/{item_id}/sessions/{session_id}")
+ async def unlink_session_from_item(campaign_id: str, item_id: str, session_id: str):
+ """Remove a session link from a plan item. Returns {unlinked: bool}."""
+ cs = _get_store()
+ _resolve(cs, campaign_id)
+ item = cs.get_plan_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Plan item not found")
+
+ unlinked = cs.unlink_plan_item_session(item_id, session_id)
+ return {"unlinked": unlinked}
+
@router.get("/api/campaigns/{campaign_id}/planned-sessions")
async def get_planned_sessions(campaign_id: str):
"""Planned sessions linked to a campaign."""
diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py
index eed3e5a9..fd9a559c 100644
--- a/gently/ui/web/routes/data.py
+++ b/gently/ui/web/routes/data.py
@@ -482,8 +482,10 @@ async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008
raise HTTPException(status_code=503, detail="Microscope not connected")
try:
res = await client.set_lightsheet_live_params(
- galvo=payload.get("galvo"), piezo=payload.get("piezo"),
- exposure=payload.get("exposure"))
+ galvo=payload.get("galvo"),
+ piezo=payload.get("piezo"),
+ exposure=payload.get("exposure"),
+ )
except Exception as exc:
logger.exception("lightsheet live params failed")
raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc
@@ -564,7 +566,7 @@ async def stage_move(payload: dict = Body(...)): # noqa: B008
try:
return await client.move_to_position(float(payload["x"]), float(payload["y"]))
except KeyError:
- raise HTTPException(status_code=400, detail="x and y required")
+ raise HTTPException(status_code=400, detail="x and y required") from None
except Exception as exc:
logger.exception("Stage move command failed")
raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc
diff --git a/gently/ui/web/routes/operation_plan.py b/gently/ui/web/routes/operation_plan.py
index 9e0d8c82..a5e636eb 100644
--- a/gently/ui/web/routes/operation_plan.py
+++ b/gently/ui/web/routes/operation_plan.py
@@ -16,7 +16,9 @@ def _resolve_session(session_id: str) -> str:
if session_id == "current":
store = getattr(server, "gently_store", None)
if store is None:
- raise HTTPException(status_code=503, detail="FileStore not configured on viz server")
+ raise HTTPException(
+ status_code=503, detail="FileStore not configured on viz server"
+ )
sessions = store.list_sessions()
if not sessions:
raise HTTPException(status_code=404, detail="No sessions in store")
diff --git a/gently/ui/web/routes/sessions.py b/gently/ui/web/routes/sessions.py
index ccbc3a93..505e7f0d 100644
--- a/gently/ui/web/routes/sessions.py
+++ b/gently/ui/web/routes/sessions.py
@@ -204,6 +204,29 @@ async def resume_session(session_id: str):
"rehydrated_projections": rehydrated,
}
+ @router.get("/api/sessions/{session_id}/plans")
+ async def get_session_plans(session_id: str):
+ """Plan items linked to a session, via the context store."""
+ cs = getattr(server, "context_store", None)
+ if cs is None:
+ return {"plans": []}
+ try:
+ items = cs.get_plan_items_for_session(session_id)
+ except Exception as e:
+ logger.warning("get_plan_items_for_session failed for %s: %s", session_id, e)
+ return {"plans": []}
+ return {
+ "plans": [
+ {
+ "id": item.id,
+ "title": item.title,
+ "campaign_id": item.campaign_id,
+ "status": item.status.value,
+ }
+ for item in items
+ ]
+ }
+
@router.get("/api/sessions/{session_id}")
async def get_session(session_id: str):
"""Get session state for review, from the live FileStore.
diff --git a/gently/ui/web/routes/temperature.py b/gently/ui/web/routes/temperature.py
index 1505714b..90916181 100644
--- a/gently/ui/web/routes/temperature.py
+++ b/gently/ui/web/routes/temperature.py
@@ -3,6 +3,7 @@
Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only.
Mirrors routes/experiments.py session resolution.
"""
+
import urllib.parse
from fastapi import APIRouter, HTTPException, Request
diff --git a/gently/ui/web/static/css/campaigns.css b/gently/ui/web/static/css/campaigns.css
index 88690dec..b874ab44 100644
--- a/gently/ui/web/static/css/campaigns.css
+++ b/gently/ui/web/static/css/campaigns.css
@@ -1435,6 +1435,109 @@
font-size: 0.62rem;
color: var(--text-muted);
}
+.detail-session-right {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.detail-session-empty {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ font-style: italic;
+}
+
+/* Session link button (header action) */
+.session-link-btn {
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-muted);
+ border-radius: 6px;
+ padding: 2px 8px;
+ font: inherit;
+ font-size: 0.65rem;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color 0.15s, border-color 0.15s;
+}
+.session-link-btn:hover {
+ color: var(--text);
+ border-color: var(--accent, #2f6df6);
+}
+
+/* Per-session delink button */
+.session-delink-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 1rem;
+ line-height: 1;
+ padding: 1px 4px;
+ border-radius: 4px;
+ opacity: 0.45;
+ transition: opacity 0.15s, color 0.15s, background 0.15s;
+}
+.session-delink-btn:hover {
+ opacity: 1;
+ color: var(--accent-orange, #f97316);
+ background: rgba(249,115,22,0.12);
+}
+
+/* Inline session picker */
+.session-picker {
+ margin-top: 8px;
+ padding: 10px;
+ background: rgba(0,0,0,0.18);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+.session-picker--loading {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ font-style: italic;
+}
+.session-picker-select {
+ width: 100%;
+ background: var(--bg-dark, #0f172a);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 8px;
+ font: inherit;
+ font-size: 0.75rem;
+ margin-bottom: 8px;
+ box-sizing: border-box;
+}
+.session-picker-actions {
+ display: flex;
+ gap: 6px;
+}
+.session-picker-link-btn {
+ flex: 1;
+ background: var(--accent, #2f6df6);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 5px 12px;
+ font: inherit;
+ font-size: 0.72rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+.session-picker-link-btn:hover { opacity: 0.85; }
+.session-picker-cancel-btn {
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 5px 12px;
+ font: inherit;
+ font-size: 0.72rem;
+ cursor: pointer;
+ transition: color 0.15s;
+}
+.session-picker-cancel-btn:hover { color: var(--text); }
/* ================================================================
SHARED COMPONENTS
diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css
index a9f89bd7..099b584b 100644
--- a/gently/ui/web/static/css/experiment.css
+++ b/gently/ui/web/static/css/experiment.css
@@ -1401,3 +1401,228 @@
border-color: rgba(139, 148, 158, 0.3);
}
/* Role-scoped badges use inline style (color/bg/border from /api/roles) */
+
+/* ============================================================
+ Linked-plans panel (ops-lp-*) — F / Task 4
+ Session ↔ plan-item link/delink from the Operations view.
+ Appended below the tactic spine + roster; matches ops-* aesthetic.
+ ============================================================ */
+
+.ops-lp {
+ margin-top: 28px;
+ border: 1px solid var(--border, #30363d);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+/* Section header row: "Linked plans" label + "+ link to a plan" button */
+.ops-lp-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 16px;
+ background: var(--bg-hover, #21262d);
+ border-bottom: 1px solid var(--border, #30363d);
+}
+
+.ops-lp-title {
+ font-family: var(--ops-mono);
+ font-size: 10px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ flex: 1;
+}
+
+/* "+ link to a plan" button — ghost style matching session-link-btn */
+.ops-lp-link-btn {
+ border: 1px solid var(--border, #30363d);
+ background: transparent;
+ color: var(--text-muted);
+ border-radius: 6px;
+ padding: 2px 9px;
+ font: inherit;
+ font-family: var(--ops-mono);
+ font-size: 10px;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color 0.15s, border-color 0.15s;
+}
+.ops-lp-link-btn:hover {
+ color: var(--text);
+ border-color: var(--ops-plan, #5aa9e6);
+}
+
+/* Empty state */
+.ops-lp-empty {
+ padding: 14px 16px;
+ font-family: var(--ops-mono);
+ font-size: 11px;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+/* Loading / transient state */
+.ops-lp-loading {
+ padding: 12px 16px;
+ font-family: var(--ops-mono);
+ font-size: 11px;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+/* List of linked plan-item rows */
+.ops-lp-list {
+ padding: 6px 0;
+}
+
+/* Single linked plan row: title · campaign · status · delink */
+.ops-lp-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 7px 14px;
+ border-bottom: 1px solid rgba(48, 54, 61, 0.5);
+ font-family: var(--ops-mono);
+ font-size: 11.5px;
+ transition: background 0.1s;
+}
+.ops-lp-row:last-child { border-bottom: none; }
+.ops-lp-row:hover { background: var(--bg-hover, #21262d); }
+
+.ops-lp-row-title {
+ font-size: 12px;
+ color: var(--text);
+ font-weight: 500;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ops-lp-row-campaign {
+ font-size: 10px;
+ color: var(--text-muted);
+ opacity: 0.8;
+ flex-shrink: 0;
+ max-width: 160px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ops-lp-row-status {
+ font-size: 9.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ padding: 2px 7px;
+ border-radius: 9px;
+ flex-shrink: 0;
+}
+
+/* Status colour variants mirroring ops- state palette */
+.ops-lp-status-planned {
+ background: rgba(90, 169, 230, 0.12);
+ color: var(--ops-plan, #5aa9e6);
+ border: 1px solid rgba(90, 169, 230, 0.3);
+}
+.ops-lp-status-active {
+ background: rgba(245, 166, 35, 0.12);
+ color: var(--ops-active, #f5a623);
+ border: 1px solid rgba(245, 166, 35, 0.3);
+}
+.ops-lp-status-done {
+ background: rgba(52, 211, 153, 0.1);
+ color: var(--ops-done, #34d399);
+ border: 1px solid rgba(52, 211, 153, 0.25);
+}
+
+/* Per-row delink (×) button */
+.ops-lp-delink {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 1rem;
+ line-height: 1;
+ padding: 1px 4px;
+ border-radius: 4px;
+ opacity: 0.4;
+ flex-shrink: 0;
+ transition: opacity 0.15s, color 0.15s, background 0.15s;
+}
+.ops-lp-delink:hover {
+ opacity: 1;
+ color: var(--accent-orange, #f97316);
+ background: rgba(249, 115, 22, 0.12);
+}
+
+/* Inline plan-item picker */
+.ops-lp-picker {
+ margin: 8px 14px 12px;
+ padding: 10px;
+ background: rgba(0, 0, 0, 0.18);
+ border: 1px solid var(--border, #30363d);
+ border-radius: 8px;
+}
+.ops-lp-picker--loading {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-style: italic;
+ font-family: var(--ops-mono);
+ background: none;
+ border: none;
+ padding: 10px 14px 12px;
+ margin: 0;
+}
+
+.ops-lp-picker-sel {
+ width: 100%;
+ background: var(--bg-dark, #0f172a);
+ color: var(--text);
+ border: 1px solid var(--border, #30363d);
+ border-radius: 6px;
+ padding: 6px 8px;
+ font: inherit;
+ font-size: 11.5px;
+ font-family: var(--ops-mono);
+ margin-bottom: 8px;
+ box-sizing: border-box;
+}
+
+.ops-lp-picker-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.ops-lp-picker-link-btn {
+ flex: 1;
+ background: var(--ops-plan, #5aa9e6);
+ color: #05101e;
+ border: none;
+ border-radius: 6px;
+ padding: 5px 12px;
+ font: inherit;
+ font-family: var(--ops-mono);
+ font-size: 11px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+.ops-lp-picker-link-btn:hover { opacity: 0.85; }
+
+.ops-lp-picker-cancel-btn {
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border, #30363d);
+ border-radius: 6px;
+ padding: 5px 12px;
+ font: inherit;
+ font-family: var(--ops-mono);
+ font-size: 11px;
+ cursor: pointer;
+ transition: color 0.15s;
+}
+.ops-lp-picker-cancel-btn:hover { color: var(--text); }
diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js
index 6a3c3799..d42cdbd4 100644
--- a/gently/ui/web/static/js/campaigns.js
+++ b/gently/ui/web/static/js/campaigns.js
@@ -67,6 +67,8 @@ const state = {
editingSpec: false, // inspector imaging-spec edit mode
_inspectorData: null, // last item-detail payload (for re-render on edit toggle)
_specError: '', // inline save error in the spec editor
+ _sessionPickerOpen: false, // whether the session link picker is visible
+ _availableSessions: null, // null = not yet loaded, [] = loaded (for link picker)
};
// ── DOM refs (cached on init) ────────────────────────────
@@ -141,6 +143,10 @@ function boot() {
case 'spec-edit': e.stopPropagation(); startSpecEdit(); break;
case 'spec-cancel': e.stopPropagation(); cancelSpecEdit(); break;
case 'spec-save': e.stopPropagation(); saveSpecEdit(); break;
+ case 'session-picker-open': e.stopPropagation(); openSessionPicker(); break;
+ case 'session-picker-cancel': e.stopPropagation(); cancelSessionPicker(); break;
+ case 'session-picker-link': e.stopPropagation(); submitSessionLink(); break;
+ case 'session-delink': e.stopPropagation(); handleSessionDelink(el.dataset.sessionId); break;
}
});
@@ -630,6 +636,8 @@ async function selectItem(itemId) {
if (itemId !== state.selectedItemId) {
state.editingSpec = false;
state._specError = '';
+ state._sessionPickerOpen = false;
+ state._availableSessions = null;
}
state.selectedItemId = itemId;
@@ -673,7 +681,13 @@ function renderInspector(data) {
const item = data.item;
const deps = data.dependencies || [];
const dnts = data.dependents || [];
- const sessions = data.sessions || [];
+ // Build a metadata map from the campaign-level sessions included in the payload,
+ // then derive the per-item sessions list from item.session_ids (the true source
+ // of truth). data.sessions is the campaign pool; we only show sessions that are
+ // actually linked to THIS item.
+ const _sessionMeta = {};
+ (data.sessions || []).forEach(s => { _sessionMeta[s.session_id || s.id] = s; });
+ const sessions = (item.session_ids || []).map(sid => _sessionMeta[sid] || { session_id: sid, id: sid });
if ($inspectorTitle) $inspectorTitle.textContent = item.title;
if ($inspectorStatus) {
@@ -786,20 +800,46 @@ function renderInspector(data) {
html += section('References', refHtml);
}
- // Sessions
+ // Sessions — item-scoped (item.session_ids), with link/delink controls.
+ const _linkBtn = `+ link session `;
+ let sessHtml = '';
if (sessions.length > 0) {
- let sessHtml = '';
sessions.forEach(s => {
+ const sid = s.session_id || s.id || '';
+ const name = s.name || s.planned_intent || sid || 'Session';
sessHtml += `
- ${esc(s.planned_intent || s.id || 'Session')}
- ${s.created_at ? `${formatDate(s.created_at)} ` : ''}
+ ${esc(name)}
+
+ ${s.created_at ? `${formatDate(s.created_at)} ` : ''}
+ ×
+
`;
});
- html += section('Sessions', sessHtml);
} else {
- html += section('Sessions',
- 'No linked sessions
');
+ sessHtml = 'No linked sessions
';
}
+ // Inline link picker — rendered when openSessionPicker() has set state flag + loaded data.
+ if (state._sessionPickerOpen) {
+ if (state._availableSessions === null) {
+ // Still loading — show spinner text; will re-render once fetch completes.
+ sessHtml += `Loading sessions…
`;
+ } else {
+ const _linkedIds = new Set(item.session_ids || []);
+ const _available = state._availableSessions.filter(s => !_linkedIds.has(s.session_id));
+ const _opts = _available.length === 0
+ ? `No other sessions available `
+ : _available.map(s => `${esc(s.name || s.session_id)} `).join('');
+ sessHtml += `
+
${_opts}
+
+ Link
+ Cancel
+
+
`;
+ }
+ }
+ html += section('Sessions', sessHtml, _linkBtn);
if ($inspectorBody) $inspectorBody.innerHTML = html;
}
@@ -902,6 +942,84 @@ async function saveSpecEdit() {
}
}
+// ── Session link / delink ─────────────────────────────────────────────────────
+
+// Open the inline session picker. Fetches /api/sessions and re-renders with the
+// picker shown. Two-phase: immediate re-render with loading state, then again
+// once the fetch resolves (mirrors the pattern of selectItem loading state).
+async function openSessionPicker() {
+ state._sessionPickerOpen = true;
+ state._availableSessions = null; // triggers loading display
+ if (state._inspectorData) renderInspector(state._inspectorData);
+ try {
+ const res = await fetch('/api/sessions');
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const body = await res.json();
+ state._availableSessions = body.sessions || [];
+ } catch (err) {
+ console.error('Failed to load sessions for picker:', err);
+ state._availableSessions = [];
+ }
+ if (state._sessionPickerOpen && state._inspectorData) {
+ renderInspector(state._inspectorData);
+ }
+}
+
+function cancelSessionPicker() {
+ state._sessionPickerOpen = false;
+ state._availableSessions = null;
+ if (state._inspectorData) renderInspector(state._inspectorData);
+}
+
+// Read the picker , POST to the link endpoint, then refresh the inspector.
+async function submitSessionLink() {
+ const select = document.getElementById('session-picker-select');
+ const sessionId = select && select.value;
+ if (!sessionId) return;
+ const data = state._inspectorData;
+ const item = data && data.item;
+ const campaignId = state.activeCampaignId;
+ if (!item || !campaignId) return;
+ // Close picker before the async call so a re-render doesn't reopen it.
+ state._sessionPickerOpen = false;
+ state._availableSessions = null;
+ try {
+ const res = await fetch(
+ `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(item.id)}/sessions`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ session_id: sessionId }),
+ },
+ );
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ selectItem(item.id).catch(() => {}); // re-fetch → re-render with updated session_ids
+ } catch (err) {
+ console.error('Failed to link session:', err);
+ if (state._inspectorData) renderInspector(state._inspectorData);
+ }
+}
+
+// DELETE the session→item edge then refresh the inspector.
+async function handleSessionDelink(sessionId) {
+ if (!sessionId) return;
+ const data = state._inspectorData;
+ const item = data && data.item;
+ const campaignId = state.activeCampaignId;
+ if (!item || !campaignId) return;
+ try {
+ const res = await fetch(
+ `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(item.id)}/sessions/${encodeURIComponent(sessionId)}`,
+ { method: 'DELETE' },
+ );
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ selectItem(item.id).catch(() => {});
+ } catch (err) {
+ console.error('Failed to delink session:', err);
+ renderInspector(state._inspectorData);
+ }
+}
+
// Hand a planned imaging item to the agent to execute. The agent resolves the
// item ref, applies its spec, and starts the timelapse (execute_plan_item). We
// open the chat so the user sees it pick up and can confirm/adjust.
diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js
index fac90551..ce59a4a8 100644
--- a/gently/ui/web/static/js/experiment-overview.js
+++ b/gently/ui/web/static/js/experiment-overview.js
@@ -21,6 +21,9 @@ const ExperimentOverview = {
_tempUpdateHandler: null,// stored handler ref so it can be off()'d if needed
_rosterEmbryos: [], // embryos from /api/embryos/positions (D2 roster lens)
_rolesMap: null, // Map(role name → registry obj) from /api/roles (D2 roster lens)
+ _currentSessionId: null, // session_id from /api/operation_plan/current (always, even idle)
+ _planPickerOpen: false, // whether the plan-link picker is visible
+ _pickerItems: null, // flat plan items for the picker (null=not loaded)
async init() {
console.log('[ExperimentOverview] init() called, view=', this.activeView);
@@ -105,6 +108,8 @@ const ExperimentOverview = {
// Fetch the agent-authored Operation Plan for the current session.
// Returns the plan object (plan.tactics etc.) or null when unavailable.
+ // Always captures data.session_id in _currentSessionId so the Linked-plans
+ // panel can work even when no operation plan is active.
async loadPlan() {
try {
const resp = await fetch('/api/operation_plan/current', { cache: 'no-store' });
@@ -113,6 +118,9 @@ const ExperimentOverview = {
return null;
}
const data = await resp.json();
+ // Capture session_id regardless of plan availability — used by the
+ // Linked-plans panel which is session-scoped, not plan-scoped.
+ this._currentSessionId = data.session_id || null;
if (!data.available) return null;
return data.plan || null;
} catch (e) {
@@ -197,6 +205,10 @@ const ExperimentOverview = {
}
// Tear down any prior ticker before we blow away the SVG it pointed at.
this._stopNowTicker();
+ // Reset plan-picker state on each full render so the picker doesn't persist
+ // across tactic-event-driven re-renders.
+ this._planPickerOpen = false;
+ this._pickerItems = null;
// Rules view requires the strategy snapshot; show an empty state when absent.
// Overview view uses this.activePlan — the null/empty case is handled inside
// _renderOperationSpine (it renders the idle state).
@@ -214,6 +226,12 @@ const ExperimentOverview = {
// The swimlane view is retired; this renders this.activePlan.
this._renderOperationSpine(root, this.activePlan);
}
+ // Kick off the async Linked-plans panel (overview tab only).
+ // Fire-and-forget: appends a placeholder immediately, fills after fetch.
+ if (this.activeView === 'overview') {
+ this._initLinkedPlansPanel(root).catch(e =>
+ console.warn('[ExperimentOverview] linked-plans panel error:', e));
+ }
console.log('[ExperimentOverview] rendered OK, view=', this.activeView);
} catch (err) {
console.error('[ExperimentOverview] render failed:', err);
@@ -233,6 +251,248 @@ const ExperimentOverview = {
},
+ // =================================================================
+ // Linked-plans panel — session ↔ plan items (F / Task 4)
+ // Symmetric with the Plans-tab Sessions section (campaigns.js).
+ // Endpoints: GET /api/sessions/{id}/plans
+ // POST /api/campaigns/{cid}/items/{iid}/sessions
+ // DELETE .../sessions/{session_id}
+ // =================================================================
+
+ // Initialise and append the Linked-plans panel to the ops-wrap in root.
+ // Async: appends a loading placeholder immediately, fills after fetch.
+ // No-op when no session_id is known (e.g. store not yet initialised).
+ async _initLinkedPlansPanel(root) {
+ if (!this._currentSessionId) return;
+ const wrap = root.querySelector('.ops-wrap');
+ if (!wrap) return;
+
+ // Append placeholder before the async fetch so layout is stable.
+ const panelEl = document.createElement('div');
+ panelEl.className = 'ops-lp';
+ panelEl.innerHTML = 'Loading linked plans…
';
+ wrap.appendChild(panelEl);
+
+ const sid = this._currentSessionId;
+ try {
+ const [linkedData, campaignsData] = await Promise.all([
+ fetch(`/api/sessions/${encodeURIComponent(sid)}/plans`, { cache: 'no-store' })
+ .then(r => r.ok ? r.json() : { plans: [] })
+ .catch(() => ({ plans: [] })),
+ fetch('/api/campaigns', { cache: 'no-store' })
+ .then(r => r.ok ? r.json() : { campaigns: [] })
+ .catch(() => ({ campaigns: [] })),
+ ]);
+ const plans = linkedData.plans || [];
+ const campaignNameMap = {};
+ this._flattenCampaignNames(campaignsData.campaigns || [], campaignNameMap);
+ this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sid);
+ } catch (e) {
+ console.warn('[ExperimentOverview] linked-plans init error:', e);
+ panelEl.innerHTML = 'Could not load linked plans.
';
+ }
+ },
+
+ // Build a map of campaign_id → display name from the /api/campaigns tree.
+ _flattenCampaignNames(trees, map) {
+ const walk = (tree) => {
+ const c = tree.campaign;
+ if (c && c.id) map[c.id] = c.description || c.shorthand || c.id;
+ for (const child of (tree.children || [])) walk(child);
+ };
+ for (const tree of (trees || [])) walk(tree);
+ },
+
+ // Build a flat list of {id, title, status, campaign_id, campaign_name}
+ // from the /api/campaigns tree, for the plan-item picker.
+ _flattenCampaignItems(trees, campaignNameMap) {
+ const items = [];
+ const walk = (tree, inheritedCid, inheritedName) => {
+ const c = tree.campaign;
+ const cid = (c && c.id) || inheritedCid;
+ const name = (c && (c.description || c.shorthand)) || inheritedName || cid;
+ for (const item of (tree.items || [])) {
+ items.push({
+ id: item.id,
+ title: item.title || item.id,
+ status: typeof item.status === 'string' ? item.status
+ : (item.status && item.status.value) || 'planned',
+ campaign_id: cid,
+ campaign_name: name,
+ });
+ }
+ for (const child of (tree.children || [])) walk(child, cid, name);
+ };
+ for (const tree of (trees || [])) walk(tree, null, null);
+ return items;
+ },
+
+ // Render the linked-plans panel HTML into panelEl and wire button events.
+ _fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId) {
+ const ESC = this._opsESC.bind(this);
+
+ // Header: section label + "+ link to a plan" button
+ let html = `
+ Linked plans
+ + link to a plan
+
`;
+
+ // Linked plan-item rows (title · campaign · status · delink)
+ if (plans.length > 0) {
+ html += '';
+ for (const p of plans) {
+ const cname = campaignNameMap[p.campaign_id] || p.campaign_id || '—';
+ const sCls = p.status === 'completed' ? 'done'
+ : p.status === 'in_progress' ? 'active' : 'planned';
+ html += `
+ ${ESC(p.title || p.id)}
+ ${ESC(cname)}
+ ${ESC(p.status || 'planned')}
+ ×
+
`;
+ }
+ html += '
';
+ } else {
+ html += 'Not linked to any plan
';
+ }
+
+ // Inline picker — shown when _planPickerOpen is set
+ if (this._planPickerOpen) {
+ if (this._pickerItems === null) {
+ // Still fetching — show loading state
+ html += 'Loading plan items…
';
+ } else {
+ const linkedIds = new Set(plans.map(p => p.id));
+ const available = this._pickerItems.filter(it => !linkedIds.has(it.id));
+ const opts = available.length === 0
+ ? 'No other plan items available '
+ : available.map(it =>
+ `${ESC(it.campaign_name)}: ${ESC(it.title)} `
+ ).join('');
+ html += `
+
${opts}
+
+ Link
+ Cancel
+
+
`;
+ }
+ }
+
+ panelEl.innerHTML = html;
+
+ // Wire events directly on the rendered buttons.
+ const sid = sessionId;
+ const linkBtn = panelEl.querySelector('#ops-lp-link-btn');
+ if (linkBtn) {
+ linkBtn.addEventListener('click', () =>
+ this._openPlanPickerInPanel(panelEl, plans, campaignNameMap, sid));
+ }
+ panelEl.querySelectorAll('.ops-lp-delink').forEach(btn => {
+ btn.addEventListener('click', () =>
+ this._delinkPlanItem(panelEl, btn.dataset.itemId, btn.dataset.campaignId, sid));
+ });
+ const submitBtn = panelEl.querySelector('#ops-lp-picker-link');
+ if (submitBtn) {
+ submitBtn.addEventListener('click', () => this._submitPlanLink(panelEl, sid));
+ }
+ const cancelBtn = panelEl.querySelector('#ops-lp-picker-cancel');
+ if (cancelBtn) {
+ cancelBtn.addEventListener('click', () => {
+ this._planPickerOpen = false;
+ this._pickerItems = null;
+ this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sid);
+ });
+ }
+ },
+
+ // Open the inline plan-item picker: set loading state, fetch /api/campaigns,
+ // flatten to items, re-render with the picker populated.
+ async _openPlanPickerInPanel(panelEl, plans, campaignNameMap, sessionId) {
+ this._planPickerOpen = true;
+ this._pickerItems = null; // show loading
+ this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId);
+ try {
+ const res = await fetch('/api/campaigns', { cache: 'no-store' });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ this._pickerItems = this._flattenCampaignItems(data.campaigns || [], campaignNameMap);
+ } catch (err) {
+ console.error('[ExperimentOverview] plan picker fetch error:', err);
+ this._pickerItems = [];
+ }
+ if (this._planPickerOpen) {
+ this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId);
+ }
+ },
+
+ // POST the selected plan item → session link, then refetch and re-render.
+ async _submitPlanLink(panelEl, sessionId) {
+ const select = panelEl.querySelector('#ops-lp-picker-sel');
+ const value = select && select.value;
+ if (!value || !value.includes('::')) return;
+ const [campaignId, itemId] = value.split('::', 2);
+ if (!campaignId || !itemId) return;
+
+ this._planPickerOpen = false;
+ this._pickerItems = null;
+ panelEl.innerHTML = 'Linking…
';
+ try {
+ const res = await fetch(
+ `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(itemId)}/sessions`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ session_id: sessionId }),
+ },
+ );
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ } catch (err) {
+ console.error('[ExperimentOverview] plan link error:', err);
+ }
+ await this._refetchLinkedPlans(panelEl, sessionId);
+ },
+
+ // DELETE the plan-item → session edge, then refetch and re-render.
+ async _delinkPlanItem(panelEl, itemId, campaignId, sessionId) {
+ panelEl.innerHTML = 'Unlinking…
';
+ try {
+ const res = await fetch(
+ `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(itemId)}/sessions/${encodeURIComponent(sessionId)}`,
+ { method: 'DELETE' },
+ );
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ } catch (err) {
+ console.error('[ExperimentOverview] plan delink error:', err);
+ }
+ await this._refetchLinkedPlans(panelEl, sessionId);
+ },
+
+ // Refetch linked plans + campaigns after a link/delink op, then re-render.
+ async _refetchLinkedPlans(panelEl, sessionId) {
+ try {
+ const [linkedData, campaignsData] = await Promise.all([
+ fetch(`/api/sessions/${encodeURIComponent(sessionId)}/plans`, { cache: 'no-store' })
+ .then(r => r.ok ? r.json() : { plans: [] })
+ .catch(() => ({ plans: [] })),
+ fetch('/api/campaigns', { cache: 'no-store' })
+ .then(r => r.ok ? r.json() : { campaigns: [] })
+ .catch(() => ({ campaigns: [] })),
+ ]);
+ const plans = linkedData.plans || [];
+ const campaignNameMap = {};
+ this._flattenCampaignNames(campaignsData.campaigns || [], campaignNameMap);
+ this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId);
+ } catch (e) {
+ console.warn('[ExperimentOverview] linked-plans refetch error:', e);
+ panelEl.innerHTML = 'Could not reload linked plans.
';
+ }
+ },
+
+
_renderRulesView(root, s) {
// Compact header echoing the session identity
diff --git a/tests/test_burst_laser_config.py b/tests/test_burst_laser_config.py
index 9e973715..079651a6 100644
--- a/tests/test_burst_laser_config.py
+++ b/tests/test_burst_laser_config.py
@@ -1,6 +1,5 @@
"""Task 2: BurstAcquisition laser_config threading tests."""
-import asyncio
-import pytest
+
from gently.app.orchestration.exclusive import BurstAcquisition
diff --git a/tests/test_burst_phase_snapshot.py b/tests/test_burst_phase_snapshot.py
index 1c2f28fe..3218f9a0 100644
--- a/tests/test_burst_phase_snapshot.py
+++ b/tests/test_burst_phase_snapshot.py
@@ -9,7 +9,6 @@
from datetime import datetime, timedelta
from pathlib import Path
-import pytest
import yaml
from gently.ui.web.strategy_snapshot import build_strategy_snapshot
@@ -39,9 +38,7 @@ def _write_session(session_dir: Path) -> None:
EMBRYO_ID: {"interval_seconds": 120},
},
}
- (session_dir / "timelapse.yaml").write_text(
- yaml.dump(timelapse), encoding="utf-8"
- )
+ (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8")
# timeline.jsonl
events = [
diff --git a/tests/test_embryo_strain.py b/tests/test_embryo_strain.py
index 7f2836eb..3a0c98a1 100644
--- a/tests/test_embryo_strain.py
+++ b/tests/test_embryo_strain.py
@@ -9,11 +9,9 @@
from unittest.mock import MagicMock
-import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
-
# ===========================================================================
# FileStore tests
# ===========================================================================
diff --git a/tests/test_lightsheet_client.py b/tests/test_lightsheet_client.py
index cf4a9538..c02f2129 100644
--- a/tests/test_lightsheet_client.py
+++ b/tests/test_lightsheet_client.py
@@ -1,4 +1,3 @@
-import pytest
from gently.hardware.dispim.client import DiSPIMMicroscope
@@ -29,6 +28,7 @@ def test_stream_lightsheet_is_async_generator():
# set_laser_config / get_laser_configs
# ---------------------------------------------------------------------------
+
async def test_set_laser_config_posts_correct_path_and_body():
"""set_laser_config posts to /api/laser/config with {"config": }."""
m = DiSPIMMicroscope.__new__(DiSPIMMicroscope)
diff --git a/tests/test_lightsheet_event.py b/tests/test_lightsheet_event.py
index 20271403..277b14f1 100644
--- a/tests/test_lightsheet_event.py
+++ b/tests/test_lightsheet_event.py
@@ -1,6 +1,6 @@
"""Tests for LIGHTSHEET_FRAME event type"""
-from gently.core.event_bus import EventType, EventBus, _NO_HISTORY_TYPES
+from gently.core.event_bus import _NO_HISTORY_TYPES, EventBus, EventType
def test_lightsheet_frame_event_exists():
diff --git a/tests/test_lightsheet_monitor.py b/tests/test_lightsheet_monitor.py
index f5cdf797..5f3c6bf9 100644
--- a/tests/test_lightsheet_monitor.py
+++ b/tests/test_lightsheet_monitor.py
@@ -6,10 +6,11 @@
"""
import asyncio
+
import pytest
-from gently.core.event_bus import EventType, get_event_bus
from gently.app.lightsheet_monitor import LightSheetStreamMonitor
+from gently.core.event_bus import EventType, get_event_bus
class FakeScope:
diff --git a/tests/test_lightsheet_routes.py b/tests/test_lightsheet_routes.py
index 0f7f172b..5cb9debe 100644
--- a/tests/test_lightsheet_routes.py
+++ b/tests/test_lightsheet_routes.py
@@ -7,13 +7,14 @@
- acquire/burst, acquire/volume
- require_control gate (403 without override)
"""
-from unittest.mock import MagicMock, AsyncMock
+
+from unittest.mock import AsyncMock, MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
-from gently.ui.web.routes.data import create_router
import gently.ui.web.auth as auth
+from gently.ui.web.routes.data import create_router
def _app(client=None, monitor=None):
@@ -36,6 +37,7 @@ def _app(client=None, monitor=None):
# live start / stop / status
# ---------------------------------------------------------------------------
+
def test_live_status_no_monitor():
"""GET status returns available=False when monitor is None."""
r = _app(monitor=None).get("/api/devices/lightsheet/live/status")
@@ -88,6 +90,7 @@ def test_live_stop_calls_monitor_stop():
# live/params
# ---------------------------------------------------------------------------
+
def test_live_params_forwards():
client = MagicMock()
client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}})
@@ -96,9 +99,7 @@ def test_live_params_forwards():
json={"galvo": 1.0, "piezo": 40.0, "exposure": 20.0},
)
assert r.status_code == 200
- client.set_lightsheet_live_params.assert_awaited_once_with(
- galvo=1.0, piezo=40.0, exposure=20.0
- )
+ client.set_lightsheet_live_params.assert_awaited_once_with(galvo=1.0, piezo=40.0, exposure=20.0)
def test_live_params_no_client_503():
@@ -118,6 +119,7 @@ def test_live_params_no_client_503():
# led/set
# ---------------------------------------------------------------------------
+
def test_led_set_forwards():
client = MagicMock()
client.set_led = AsyncMock(return_value={"success": True})
@@ -130,6 +132,7 @@ def test_led_set_forwards():
# laser/off — spec §2.7: must use Laser ALL OFF config, not power setpoint
# ---------------------------------------------------------------------------
+
def test_laser_off_calls_set_laser_config_all_off():
"""laser/off must call set_laser_config("ALL OFF") to gate every line off."""
client = MagicMock()
@@ -143,6 +146,7 @@ def test_laser_off_calls_set_laser_config_all_off():
# laser/configs — unguarded GET, no require_control
# ---------------------------------------------------------------------------
+
def test_laser_configs_forwards_to_client():
"""GET laser/configs must forward to client.get_laser_configs."""
client = MagicMock()
@@ -158,6 +162,7 @@ def test_laser_configs_forwards_to_client():
def test_laser_configs_no_require_control():
"""GET laser/configs is unguarded — available even without control override."""
from fastapi import FastAPI
+
from gently.ui.web.routes.data import create_router
server = MagicMock()
@@ -178,6 +183,7 @@ def test_laser_configs_no_require_control():
# camera/led_mode
# ---------------------------------------------------------------------------
+
def test_camera_led_mode_forwards():
client = MagicMock()
client.set_camera_led_mode = AsyncMock(return_value={"success": True})
@@ -190,6 +196,7 @@ def test_camera_led_mode_forwards():
# stage/move
# ---------------------------------------------------------------------------
+
def test_stage_move_forwards():
client = MagicMock()
client.move_to_position = AsyncMock(return_value={"success": True})
@@ -209,6 +216,7 @@ def test_stage_move_missing_xy_400():
# acquire/burst
# ---------------------------------------------------------------------------
+
def test_acquire_burst_forwards():
"""Basic burst forwards without optional params — no laser_config/piezo/galvo in body."""
client = MagicMock()
@@ -229,14 +237,25 @@ def test_acquire_burst_forwards_laser_config_and_focal_plane():
client.acquire_burst = AsyncMock(return_value={"success": True})
r = _app(client=client).post(
"/api/devices/acquire/burst",
- json={"frames": 10, "mode": "brightfield", "num_slices": 50,
- "exposure_ms": 20.0, "laser_config": "ALL OFF",
- "piezo_center": 55.0, "galvo_center": 1.5},
+ json={
+ "frames": 10,
+ "mode": "brightfield",
+ "num_slices": 50,
+ "exposure_ms": 20.0,
+ "laser_config": "ALL OFF",
+ "piezo_center": 55.0,
+ "galvo_center": 1.5,
+ },
)
assert r.status_code == 200
client.acquire_burst.assert_awaited_once_with(
- frames=10, mode="brightfield", num_slices=50, exposure_ms=20.0,
- laser_config="ALL OFF", piezo_center=55.0, galvo_center=1.5,
+ frames=10,
+ mode="brightfield",
+ num_slices=50,
+ exposure_ms=20.0,
+ laser_config="ALL OFF",
+ piezo_center=55.0,
+ galvo_center=1.5,
)
@@ -244,6 +263,7 @@ def test_acquire_burst_forwards_laser_config_and_focal_plane():
# acquire/volume
# ---------------------------------------------------------------------------
+
def test_acquire_volume_forwards():
"""Basic volume forwards without optional params."""
client = MagicMock()
@@ -262,13 +282,21 @@ def test_acquire_volume_forwards_laser_config_and_focal_plane():
client.acquire_volume = AsyncMock(return_value={"success": True})
r = _app(client=client).post(
"/api/devices/acquire/volume",
- json={"num_slices": 50, "exposure_ms": 20.0,
- "laser_config": "ALL OFF", "piezo_center": 55.0, "galvo_center": 1.5},
+ json={
+ "num_slices": 50,
+ "exposure_ms": 20.0,
+ "laser_config": "ALL OFF",
+ "piezo_center": 55.0,
+ "galvo_center": 1.5,
+ },
)
assert r.status_code == 200
client.acquire_volume.assert_awaited_once_with(
- num_slices=50, exposure_ms=20.0,
- laser_config="ALL OFF", piezo_center=55.0, galvo_center=1.5,
+ num_slices=50,
+ exposure_ms=20.0,
+ laser_config="ALL OFF",
+ piezo_center=55.0,
+ galvo_center=1.5,
)
@@ -276,6 +304,7 @@ def test_acquire_volume_forwards_laser_config_and_focal_plane():
# require_control gate — 403 WITHOUT override
# ---------------------------------------------------------------------------
+
def test_require_control_gate_403():
"""Without the dependency override, TestClient host is not loopback → 403."""
server = MagicMock()
diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py
index c36377a1..14b320f9 100644
--- a/tests/test_lightsheet_streamer.py
+++ b/tests/test_lightsheet_streamer.py
@@ -3,46 +3,89 @@
from unittest.mock import MagicMock
# Patch heavy hardware deps before importing device_layer
-for _mod in ("bluesky", "bluesky.run_engine", "ophyd", "ophyd.status",
- "pymmcore", "gently.hardware.console_ui"):
+for _mod in (
+ "bluesky",
+ "bluesky.run_engine",
+ "ophyd",
+ "ophyd.status",
+ "pymmcore",
+ "gently.hardware.console_ui",
+):
if _mod not in sys.modules:
sys.modules[_mod] = MagicMock()
# Patch bluesky.RunEngine specifically
-import bluesky as _bs
+import bluesky as _bs # noqa: E402
+
_bs.RunEngine = MagicMock(name="RunEngine")
-import asyncio, numpy as np, pytest
-from gently.hardware.dispim.device_layer import DeviceLayerServer
+import asyncio # noqa: E402
+
+import numpy as np # noqa: E402
+import pytest # noqa: E402
+
+from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402
class FakeCore:
def __init__(self):
- self.running = False; self.exposure = None; self.cam = None
+ self.running = False
+ self.exposure = None
+ self.cam = None
self._frame = np.full((64, 64), 1000, dtype=np.uint16)
- self.started = 0; self.stopped = 0
- def setCameraDevice(self, n): self.cam = n
- def getCameraDevice(self): return self.cam
- def setExposure(self, n, ms): self.exposure = ms
- def startContinuousSequenceAcquisition(self, interval): self.running = True; self.started += 1
- def stopSequenceAcquisition(self): self.running = False; self.stopped += 1
- def isSequenceRunning(self): return self.running
- def getLastImage(self): return self._frame
+ self.started = 0
+ self.stopped = 0
+
+ def setCameraDevice(self, n):
+ self.cam = n
+
+ def getCameraDevice(self):
+ return self.cam
+
+ def setExposure(self, n, ms):
+ self.exposure = ms
+
+ def startContinuousSequenceAcquisition(self, interval):
+ self.running = True
+ self.started += 1
+
+ def stopSequenceAcquisition(self):
+ self.running = False
+ self.stopped += 1
+
+ def isSequenceRunning(self):
+ return self.running
+
+ def getLastImage(self):
+ return self._frame
class FakeAxis:
- def __init__(self): self.pos = None
- def setPosition(self, v): self.pos = v
+ def __init__(self):
+ self.pos = None
+
+ def setPosition(self, v):
+ self.pos = v
class FakeScanner:
- def __init__(self): self.sa_offset_y = FakeAxis(); self.name = "Scanner"; self.state = None
- def set_spim_state(self, s): self.state = s
+ def __init__(self):
+ self.sa_offset_y = FakeAxis()
+ self.name = "Scanner"
+ self.state = None
+
+ def set_spim_state(self, s):
+ self.state = s
class FakePiezo(FakeAxis):
- def __init__(self): super().__init__(); self.name = "Piezo"; self.state = None
- def set_spim_state(self, s): self.state = s
+ def __init__(self):
+ super().__init__()
+ self.name = "Piezo"
+ self.state = None
+
+ def set_spim_state(self, s):
+ self.state = s
def _make_dl():
@@ -63,14 +106,16 @@ def _make_dl():
async def test_grab_parks_and_peeks(monkeypatch):
dl = _make_dl()
dl._state_pause_counter = 0
- dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70
+ dl._ls_target_max_dim = 512
+ dl._ls_jpeg_quality = 70
dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0}
- dl._ls_seq_started = False; dl._ls_applied = {}
+ dl._ls_seq_started = False
+ dl._ls_applied = {}
dl._ls_interval_sec = 0.0
img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync)
assert img is not None and img.shape == (64, 64)
- assert dl.system.core.running is True # sequence started
- assert dl.devices["piezo"].pos == 40.0 # piezo parked
+ assert dl.system.core.running is True # sequence started
+ assert dl.devices["piezo"].pos == 40.0 # piezo parked
assert dl.devices["scanner"].sa_offset_y.pos == 1.5 # galvo parked
@@ -78,13 +123,15 @@ async def test_grab_parks_and_peeks(monkeypatch):
async def test_exposure_change_restarts_sequence():
dl = _make_dl()
dl._state_pause_counter = 0
- dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70
+ dl._ls_target_max_dim = 512
+ dl._ls_jpeg_quality = 70
dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0}
- dl._ls_seq_started = False; dl._ls_applied = {}
+ dl._ls_seq_started = False
+ dl._ls_applied = {}
dl._ls_interval_sec = 0.0
await asyncio.to_thread(dl._grab_lightsheet_frame_sync)
starts = dl.system.core.started
- dl._ls_params["exposure"] = 30.0 # exposure change
+ dl._ls_params["exposure"] = 30.0 # exposure change
await asyncio.to_thread(dl._grab_lightsheet_frame_sync)
assert dl.system.core.stopped >= 1 and dl.system.core.started == starts + 1
assert dl.system.core.exposure == 30.0
diff --git a/tests/test_operation_plan_route.py b/tests/test_operation_plan_route.py
index c7c45d59..a197fc84 100644
--- a/tests/test_operation_plan_route.py
+++ b/tests/test_operation_plan_route.py
@@ -1,4 +1,3 @@
-from pathlib import Path
from unittest.mock import MagicMock
from fastapi import FastAPI
diff --git a/tests/test_operation_plan_seeding.py b/tests/test_operation_plan_seeding.py
index abe36911..f3526fa8 100644
--- a/tests/test_operation_plan_seeding.py
+++ b/tests/test_operation_plan_seeding.py
@@ -20,11 +20,8 @@
6. Plan item linked but imaging_spec.tactics is empty → returns None, no write
"""
-import pytest
-
from gently.app.tools.operation_plan_seed import seed_operation_plan_from_plan_item
-
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -328,8 +325,18 @@ def test_all_tactics_already_present_returns_none(self, file_context_store):
"title": "Full plan",
"goal": "full goal",
"tactics": [
- {"id": "t1", "name": "Baseline timelapse", "kind": "standing_timelapse", "state": "planned"},
- {"id": "t2", "name": "Hatching monitor", "kind": "reactive_monitor", "state": "planned"},
+ {
+ "id": "t1",
+ "name": "Baseline timelapse",
+ "kind": "standing_timelapse",
+ "state": "planned",
+ },
+ {
+ "id": "t2",
+ "name": "Hatching monitor",
+ "kind": "reactive_monitor",
+ "state": "planned",
+ },
],
"updated_at": "2026-06-28T09:00:00Z",
"updated_reason": "already seeded",
diff --git a/tests/test_operation_plan_store.py b/tests/test_operation_plan_store.py
index c656e410..a3b9123c 100644
--- a/tests/test_operation_plan_store.py
+++ b/tests/test_operation_plan_store.py
@@ -7,7 +7,6 @@
from gently.core.event_bus import EventType, on
-
# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------
@@ -26,9 +25,7 @@
"rationale": "Establish pre-ramp cadence",
"structure": {
"cadence_s": 120,
- "per_embryo": [
- {"embryo_id": "e1", "cadence_phase": "normal", "interval_s": 120}
- ],
+ "per_embryo": [{"embryo_id": "e1", "cadence_phase": "normal", "interval_s": 120}],
},
"live_bind": ["cadence"],
"relations": {},
diff --git a/tests/test_operation_plan_tool.py b/tests/test_operation_plan_tool.py
index ad6804d6..7698aab3 100644
--- a/tests/test_operation_plan_tool.py
+++ b/tests/test_operation_plan_tool.py
@@ -9,7 +9,6 @@
import pytest
-
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
diff --git a/tests/test_operation_plan_updater.py b/tests/test_operation_plan_updater.py
index 0fe50d7e..00afb7da 100644
--- a/tests/test_operation_plan_updater.py
+++ b/tests/test_operation_plan_updater.py
@@ -9,21 +9,21 @@
- Handler exception doesn't propagate to the caller
- on_stop unsubscribes (no further calls after stop)
"""
+
from __future__ import annotations
-import asyncio
from unittest.mock import MagicMock
import pytest
-from gently.core.event_bus import EventBus, EventType
from gently.app.operation_plan_updater import OperationPlanUpdater
-
+from gently.core.event_bus import EventBus, EventType
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
+
class FakeContextStore:
"""Records all transition_tactic calls."""
@@ -52,6 +52,7 @@ def make_event(event_type: EventType, data: dict):
# Fixtures
# ---------------------------------------------------------------------------
+
@pytest.fixture
def bus():
"""A fresh EventBus for each test (avoids cross-test leakage)."""
@@ -77,12 +78,11 @@ def updater(store, session_id):
# Tests
# ---------------------------------------------------------------------------
+
@pytest.mark.asyncio
async def test_burst_complete_transitions_tactic_to_done(updater, store, bus, monkeypatch):
"""BURST_COMPLETE fires → tactic transitions to done with bind values."""
- monkeypatch.setattr(
- "gently.app.operation_plan_updater.get_event_bus", lambda: bus
- )
+ monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus)
await updater.start()
bus.publish(
@@ -113,9 +113,7 @@ async def test_burst_complete_transitions_tactic_to_done(updater, store, bus, mo
@pytest.mark.asyncio
async def test_temp_protocol_completed_transitions_to_done(updater, store, bus, monkeypatch):
"""TEMP_PROTOCOL_COMPLETED fires → tactic done with locked/cancelled/error."""
- monkeypatch.setattr(
- "gently.app.operation_plan_updater.get_event_bus", lambda: bus
- )
+ monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus)
await updater.start()
bus.publish(
@@ -143,9 +141,7 @@ async def test_temp_protocol_completed_transitions_to_done(updater, store, bus,
@pytest.mark.asyncio
async def test_embryo_cadence_changed_bind_only(updater, store, bus, monkeypatch):
"""EMBRYO_CADENCE_CHANGED → bind-only (state=None), cadence values bound."""
- monkeypatch.setattr(
- "gently.app.operation_plan_updater.get_event_bus", lambda: bus
- )
+ monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus)
await updater.start()
bus.publish(
@@ -165,7 +161,7 @@ async def test_embryo_cadence_changed_bind_only(updater, store, bus, monkeypatch
assert len(store.calls) == 1
call = store.calls[0]
assert call["tactic_id"] == "t3"
- assert call["state"] is None # bind-only
+ assert call["state"] is None # bind-only
assert call["new_phase"] == "dense"
assert call["new_interval_s"] == 30
@@ -175,9 +171,7 @@ async def test_embryo_cadence_changed_bind_only(updater, store, bus, monkeypatch
@pytest.mark.asyncio
async def test_trigger_fired_bind_only_with_last_fired(updater, store, bus, monkeypatch):
"""TRIGGER_FIRED → bind-only with last_fired timestamp injected."""
- monkeypatch.setattr(
- "gently.app.operation_plan_updater.get_event_bus", lambda: bus
- )
+ monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus)
await updater.start()
bus.publish(
@@ -195,7 +189,7 @@ async def test_trigger_fired_bind_only_with_last_fired(updater, store, bus, monk
call = store.calls[0]
assert call["tactic_id"] == "t4"
assert call["state"] is None
- assert "last_fired" in call # timestamp injected by updater
+ assert "last_fired" in call # timestamp injected by updater
await updater.stop()
@@ -203,9 +197,7 @@ async def test_trigger_fired_bind_only_with_last_fired(updater, store, bus, monk
@pytest.mark.asyncio
async def test_event_without_tactic_id_is_skipped(updater, store, bus, monkeypatch):
"""An event with no tactic_id must not call transition_tactic."""
- monkeypatch.setattr(
- "gently.app.operation_plan_updater.get_event_bus", lambda: bus
- )
+ monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus)
await updater.start()
bus.publish(
@@ -226,9 +218,7 @@ async def test_event_without_tactic_id_is_skipped(updater, store, bus, monkeypat
@pytest.mark.asyncio
async def test_handler_exception_does_not_propagate(updater, bus, monkeypatch):
"""An exception inside _handle must be swallowed — bus caller not affected."""
- monkeypatch.setattr(
- "gently.app.operation_plan_updater.get_event_bus", lambda: bus
- )
+ monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus)
class BrokenStore:
def transition_tactic(self, *a, **kw):
@@ -250,9 +240,7 @@ def transition_tactic(self, *a, **kw):
@pytest.mark.asyncio
async def test_no_session_skips_transition(store, bus, monkeypatch):
"""If session_id_getter returns None, no transition_tactic call is made."""
- monkeypatch.setattr(
- "gently.app.operation_plan_updater.get_event_bus", lambda: bus
- )
+ monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus)
no_session_updater = OperationPlanUpdater(store, lambda: None)
await no_session_updater.start()
@@ -270,9 +258,7 @@ async def test_no_session_skips_transition(store, bus, monkeypatch):
@pytest.mark.asyncio
async def test_stop_unsubscribes_from_bus(store, bus, monkeypatch):
"""After stop(), further bus events produce no transition_tactic calls."""
- monkeypatch.setattr(
- "gently.app.operation_plan_updater.get_event_bus", lambda: bus
- )
+ monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus)
up = OperationPlanUpdater(store, lambda: "sess_z")
await up.start()
await up.stop()
diff --git a/tests/test_plan_item_tactics.py b/tests/test_plan_item_tactics.py
index b1cd91da..825963aa 100644
--- a/tests/test_plan_item_tactics.py
+++ b/tests/test_plan_item_tactics.py
@@ -7,9 +7,7 @@
import dataclasses
-import pytest
-
-from gently.harness.memory.model import ImagingSpec, PlanItem, PlanItemType
+from gently.harness.memory.model import ImagingSpec
TACTICS_OUTLINE = [
{
diff --git a/tests/test_role_scope.py b/tests/test_role_scope.py
index 53b8ee1a..045b5e85 100644
--- a/tests/test_role_scope.py
+++ b/tests/test_role_scope.py
@@ -16,7 +16,6 @@
import pytest
-
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -139,7 +138,8 @@ def __init__(self):
@pytest.mark.asyncio
async def test_role_scoped_tactic_passes_declare_validation():
- """A tactic with scope.mode='role' and a valid role key is accepted by declare_operation_plan."""
+ """A tactic with scope.mode='role' and a valid role key is accepted by
+ declare_operation_plan."""
from gently.app.tools.operation_plan_tools import declare_operation_plan
role_scoped_tactic = {
diff --git a/tests/test_roles_registry.py b/tests/test_roles_registry.py
index 9fc7181d..6e824507 100644
--- a/tests/test_roles_registry.py
+++ b/tests/test_roles_registry.py
@@ -11,15 +11,14 @@
import pytest
from gently.harness.roles import (
+ DEFAULT_ROLE,
REGISTRY,
EmbryoRole,
- DEFAULT_ROLE,
get_role,
is_valid_role,
list_roles,
)
-
# ---------------------------------------------------------------------------
# role_class field — all existing roles
# ---------------------------------------------------------------------------
@@ -159,7 +158,7 @@ def test_default_role_is_test():
def test_embryo_role_is_frozen():
role = REGISTRY["test"]
- with pytest.raises(Exception):
+ with pytest.raises(AttributeError):
role.name = "mutated" # type: ignore[misc]
diff --git a/tests/test_roles_route.py b/tests/test_roles_route.py
index c710673f..e81218c0 100644
--- a/tests/test_roles_route.py
+++ b/tests/test_roles_route.py
@@ -5,6 +5,7 @@
- Each entry has the expected fields.
- Never raises a 500 (graceful).
"""
+
from fastapi import FastAPI
from fastapi.testclient import TestClient
@@ -47,7 +48,14 @@ def test_roles_include_lineaging():
def test_roles_entry_has_all_required_fields():
"""Every role entry exposes the required fields."""
- required = {"name", "description", "role_class", "ui_color", "ui_icon", "default_cadence_seconds"}
+ required = {
+ "name",
+ "description",
+ "role_class",
+ "ui_color",
+ "ui_icon",
+ "default_cadence_seconds",
+ }
roles = _client().get("/api/roles").json()["roles"]
for role in roles:
missing = required - role.keys()
diff --git a/tests/test_session_plan_linking_routes.py b/tests/test_session_plan_linking_routes.py
new file mode 100644
index 00000000..19571227
--- /dev/null
+++ b/tests/test_session_plan_linking_routes.py
@@ -0,0 +1,283 @@
+"""Route tests: session ↔ plan link/delink HTTP endpoints (Task 2 of F).
+
+Endpoints under test:
+ POST /api/campaigns/{cid}/items/{item_id}/sessions → link session to item
+ DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{sid} → delink session from item
+ GET /api/sessions/{session_id}/plans → plan items for a session
+"""
+
+from datetime import datetime
+from unittest.mock import MagicMock
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from gently.harness.memory.model import PlanItem, PlanItemStatus, PlanItemType, SessionIntent
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_item(
+ item_id: str,
+ campaign_id: str,
+ title: str,
+ status: PlanItemStatus = PlanItemStatus.PLANNED,
+) -> PlanItem:
+ return PlanItem(
+ id=item_id,
+ campaign_id=campaign_id,
+ type=PlanItemType.BENCH,
+ title=title,
+ status=status,
+ )
+
+
+def _make_app(mock_cs):
+ """FastAPI test app with campaign + session routers wired to mock_cs."""
+ from gently.ui.web.routes.campaigns import create_router as campaign_router
+ from gently.ui.web.routes.sessions import create_router as session_router
+
+ app = FastAPI()
+
+ class _Server:
+ context_store = mock_cs
+ agent_bridge = None
+ gently_store = None
+
+ server = _Server()
+ app.include_router(campaign_router(server))
+ app.include_router(session_router(server))
+ return app
+
+
+def _mock_cs(item=None, sessions=None):
+ """Build a mock context store with sensible defaults."""
+ cs = MagicMock()
+ cs.resolve_campaign.return_value = MagicMock(id="c1")
+ cs.get_plan_item.return_value = item
+ cs.get_sessions_for_campaign.return_value = sessions if sessions is not None else []
+ cs.unlink_plan_item_session.return_value = True
+ cs.get_plan_items_for_session.return_value = []
+ return cs
+
+
+# ---------------------------------------------------------------------------
+# POST /api/campaigns/{cid}/items/{item_id}/sessions
+# ---------------------------------------------------------------------------
+
+
+class TestPostLinkSession:
+ def test_calls_link_plan_item_session(self):
+ item = _make_item("i1", "c1", "Step A")
+ cs = _mock_cs(item=item)
+ client = TestClient(_make_app(cs))
+
+ r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"})
+
+ assert r.status_code == 200
+ cs.link_plan_item_session.assert_called_once_with("i1", "s1")
+
+ def test_calls_link_session_campaign_with_resolved_id(self):
+ item = _make_item("i1", "c1", "Step A")
+ cs = _mock_cs(item=item)
+ cs.resolve_campaign.return_value = MagicMock(id="c1")
+ client = TestClient(_make_app(cs))
+
+ client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"})
+
+ cs.link_session_campaign.assert_called_once_with("s1", "c1")
+
+ def test_returns_sessions_list(self):
+ item = _make_item("i1", "c1", "Step A")
+ cs = _mock_cs(item=item, sessions=[])
+ client = TestClient(_make_app(cs))
+
+ r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"})
+
+ assert r.status_code == 200
+ body = r.json()
+ assert "sessions" in body
+ assert isinstance(body["sessions"], list)
+
+ def test_returns_serialized_session_intents(self):
+ # Item must have session_ids=["s1"] to simulate the post-link state
+ # (mock link_plan_item_session doesn't mutate the item; we configure
+ # get_plan_item to return the post-link view so the item-scoped filter works).
+ item = PlanItem(
+ id="i1",
+ campaign_id="c1",
+ type=PlanItemType.BENCH,
+ title="Step A",
+ status=PlanItemStatus.PLANNED,
+ session_ids=["s1"],
+ )
+ si = SessionIntent(
+ session_id="s1",
+ campaign_ids=["c1"],
+ created_at=datetime(2026, 6, 1),
+ )
+ cs = _mock_cs(item=item, sessions=[si])
+ client = TestClient(_make_app(cs))
+
+ r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"})
+
+ assert r.status_code == 200
+ sessions = r.json()["sessions"]
+ assert len(sessions) == 1
+ assert sessions[0]["session_id"] == "s1"
+ assert sessions[0]["campaign_ids"] == ["c1"]
+
+ def test_missing_campaign_is_404(self):
+ cs = _mock_cs()
+ cs.resolve_campaign.return_value = None
+ client = TestClient(_make_app(cs))
+
+ r = client.post("/api/campaigns/nope/items/i1/sessions", json={"session_id": "s1"})
+
+ assert r.status_code == 404
+
+ def test_missing_item_is_404(self):
+ cs = _mock_cs(item=None)
+ client = TestClient(_make_app(cs))
+
+ r = client.post("/api/campaigns/c1/items/nope/sessions", json={"session_id": "s1"})
+
+ assert r.status_code == 404
+
+ def test_missing_session_id_body_is_400(self):
+ item = _make_item("i1", "c1", "Step A")
+ cs = _mock_cs(item=item)
+ client = TestClient(_make_app(cs))
+
+ r = client.post("/api/campaigns/c1/items/i1/sessions", json={})
+
+ assert r.status_code == 400
+
+
+# ---------------------------------------------------------------------------
+# DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}
+# ---------------------------------------------------------------------------
+
+
+class TestDeleteUnlinkSession:
+ def test_calls_unlink_plan_item_session(self):
+ item = _make_item("i1", "c1", "Step A")
+ cs = _mock_cs(item=item)
+ cs.unlink_plan_item_session.return_value = True
+ client = TestClient(_make_app(cs))
+
+ r = client.delete("/api/campaigns/c1/items/i1/sessions/s1")
+
+ assert r.status_code == 200
+ cs.unlink_plan_item_session.assert_called_once_with("i1", "s1")
+
+ def test_returns_unlinked_true(self):
+ item = _make_item("i1", "c1", "Step A")
+ cs = _mock_cs(item=item)
+ cs.unlink_plan_item_session.return_value = True
+ client = TestClient(_make_app(cs))
+
+ r = client.delete("/api/campaigns/c1/items/i1/sessions/s1")
+
+ assert r.status_code == 200
+ assert r.json()["unlinked"] is True
+
+ def test_returns_unlinked_false_when_not_linked(self):
+ item = _make_item("i1", "c1", "Step A")
+ cs = _mock_cs(item=item)
+ cs.unlink_plan_item_session.return_value = False
+ client = TestClient(_make_app(cs))
+
+ r = client.delete("/api/campaigns/c1/items/i1/sessions/s99")
+
+ assert r.status_code == 200
+ assert r.json()["unlinked"] is False
+
+ def test_missing_item_is_404(self):
+ cs = _mock_cs(item=None)
+ client = TestClient(_make_app(cs))
+
+ r = client.delete("/api/campaigns/c1/items/nope/sessions/s1")
+
+ assert r.status_code == 404
+
+ def test_missing_campaign_is_404(self):
+ cs = _mock_cs()
+ cs.resolve_campaign.return_value = None
+ client = TestClient(_make_app(cs))
+
+ r = client.delete("/api/campaigns/nope/items/i1/sessions/s1")
+
+ assert r.status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# GET /api/sessions/{session_id}/plans
+# ---------------------------------------------------------------------------
+
+
+class TestGetSessionPlans:
+ def test_returns_mapped_plan_items(self):
+ cs = _mock_cs()
+ cs.get_plan_items_for_session.return_value = [
+ _make_item("i1", "c1", "Step A", PlanItemStatus.PLANNED),
+ _make_item("i2", "c2", "Step B", PlanItemStatus.IN_PROGRESS),
+ ]
+ client = TestClient(_make_app(cs))
+
+ r = client.get("/api/sessions/s1/plans")
+
+ assert r.status_code == 200
+ plans = r.json()["plans"]
+ assert len(plans) == 2
+ assert plans[0] == {
+ "id": "i1",
+ "title": "Step A",
+ "campaign_id": "c1",
+ "status": "planned",
+ }
+ assert plans[1] == {
+ "id": "i2",
+ "title": "Step B",
+ "campaign_id": "c2",
+ "status": "in_progress",
+ }
+
+ def test_empty_list_for_unknown_session(self):
+ cs = _mock_cs()
+ cs.get_plan_items_for_session.return_value = []
+ client = TestClient(_make_app(cs))
+
+ r = client.get("/api/sessions/ghost/plans")
+
+ assert r.status_code == 200
+ assert r.json()["plans"] == []
+
+ def test_calls_store_with_session_id(self):
+ cs = _mock_cs()
+ client = TestClient(_make_app(cs))
+
+ client.get("/api/sessions/sess_abc/plans")
+
+ cs.get_plan_items_for_session.assert_called_once_with("sess_abc")
+
+ def test_graceful_when_no_context_store(self):
+ from gently.ui.web.routes.sessions import create_router as session_router
+
+ app = FastAPI()
+
+ class _ServerNoCS:
+ context_store = None
+ agent_bridge = None
+ gently_store = None
+
+ server = _ServerNoCS()
+ app.include_router(session_router(server))
+ client = TestClient(app)
+
+ r = client.get("/api/sessions/s1/plans")
+
+ assert r.status_code == 200
+ assert r.json()["plans"] == []
diff --git a/tests/test_session_plan_linking_store.py b/tests/test_session_plan_linking_store.py
new file mode 100644
index 00000000..8dba4a10
--- /dev/null
+++ b/tests/test_session_plan_linking_store.py
@@ -0,0 +1,209 @@
+"""
+FileContextStore: session ↔ plan-item link/delink (Task 1 — data layer).
+
+Tests:
+- link a session to items in 2 different campaigns
+- get_plan_items_for_session returns both items
+- unlink_plan_item_session removes one (the other remains)
+- get_plan_items_for_session returns only the remaining item after unlink
+- unlinking a session that isn't linked → False, no side-effects
+- back-compat scalar session_id is cleared when the unlinked session matched it
+"""
+
+import pytest
+
+from gently.harness.memory.model import PlanItemType
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_item(store, campaign_id: str, title: str) -> str:
+ """Create a bench plan item and return its id."""
+ return store.create_plan_item(
+ campaign_id=campaign_id,
+ type=PlanItemType.BENCH.value,
+ title=title,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def two_campaigns(file_context_store):
+ """Two active campaigns, each with one plan item. Returns (store, cid1, cid2, item1, item2)."""
+ store = file_context_store
+ cid1 = store.create_campaign(description="Campaign Alpha")
+ cid2 = store.create_campaign(description="Campaign Beta")
+ item1 = _make_item(store, cid1, "Step A — bench prep")
+ item2 = _make_item(store, cid2, "Step B — gel run")
+ return store, cid1, cid2, item1, item2
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+class TestLinkAndReverseQuery:
+ def test_link_and_get_plan_items_for_session(self, two_campaigns):
+ """Linking a session to 2 items across 2 campaigns; reverse query returns both."""
+ store, _cid1, _cid2, item1, item2 = two_campaigns
+ session_id = "sess_abc"
+
+ store.link_plan_item_session(item1, session_id, set_in_progress=False)
+ store.link_plan_item_session(item2, session_id, set_in_progress=False)
+
+ items = store.get_plan_items_for_session(session_id)
+ ids = {it.id for it in items}
+ assert item1 in ids
+ assert item2 in ids
+ assert len(ids) == 2
+
+ def test_session_not_in_unrelated_item(self, two_campaigns):
+ """Items not linked to the session don't appear in the reverse query."""
+ store, _cid1, _cid2, item1, _item2 = two_campaigns
+ session_id = "sess_xyz"
+
+ store.link_plan_item_session(item1, session_id, set_in_progress=False)
+ items = store.get_plan_items_for_session(session_id)
+ assert len(items) == 1
+ assert items[0].id == item1
+
+ def test_unknown_session_returns_empty(self, two_campaigns):
+ """A session with no links → empty list, no error."""
+ store, *_ = two_campaigns
+ result = store.get_plan_items_for_session("sess_ghost")
+ assert result == []
+
+
+class TestUnlink:
+ def test_unlink_removes_from_one_campaign(self, two_campaigns):
+ """Unlinking a session from one item leaves the other item still linked."""
+ store, _cid1, _cid2, item1, item2 = two_campaigns
+ session_id = "sess_def"
+
+ store.link_plan_item_session(item1, session_id, set_in_progress=False)
+ store.link_plan_item_session(item2, session_id, set_in_progress=False)
+
+ result = store.unlink_plan_item_session(item1, session_id)
+ assert result is True
+
+ remaining = store.get_plan_items_for_session(session_id)
+ ids = {it.id for it in remaining}
+ assert item1 not in ids
+ assert item2 in ids
+
+ def test_unlink_idempotent_returns_false(self, two_campaigns):
+ """Unlinking a session that isn't linked returns False without crashing."""
+ store, _cid1, _cid2, item1, _item2 = two_campaigns
+ result = store.unlink_plan_item_session(item1, "sess_nothere")
+ assert result is False
+
+ def test_unlink_unknown_item_returns_false(self, two_campaigns):
+ """Unlinking from an item that doesn't exist returns False."""
+ store, *_ = two_campaigns
+ result = store.unlink_plan_item_session("item_ghost", "sess_x")
+ assert result is False
+
+ def test_unlink_no_side_effect_when_not_linked(self, two_campaigns):
+ """After a no-op unlink, the item's session_ids are unchanged."""
+ store, _cid1, _cid2, item1, _item2 = two_campaigns
+ session_id = "sess_linked"
+ store.link_plan_item_session(item1, session_id, set_in_progress=False)
+
+ store.unlink_plan_item_session(item1, "sess_other") # different session — no-op
+
+ item = store.get_plan_item(item1)
+ assert session_id in item.session_ids
+
+ def test_full_unlink_clears_all_sessions(self, two_campaigns):
+ """After unlinking all sessions, get_plan_items_for_session returns nothing."""
+ store, _cid1, _cid2, item1, _item2 = two_campaigns
+ session_id = "sess_ghi"
+
+ store.link_plan_item_session(item1, session_id, set_in_progress=False)
+ store.unlink_plan_item_session(item1, session_id)
+
+ remaining = store.get_plan_items_for_session(session_id)
+ assert remaining == []
+
+
+class TestBackCompatSessionId:
+ def test_back_compat_cleared_on_unlink_when_matched(self, two_campaigns):
+ """Back-compat scalar session_id is set to None when the last session is unlinked."""
+ store, _cid1, _cid2, item1, _item2 = two_campaigns
+ session_id = "sess_jkl"
+
+ store.link_plan_item_session(item1, session_id, set_in_progress=False)
+ item_before = store.get_plan_item(item1)
+ assert item_before.session_id == session_id # back-compat set by link
+
+ store.unlink_plan_item_session(item1, session_id)
+
+ item_after = store.get_plan_item(item1)
+ assert item_after.session_id is None
+ assert item_after.session_ids == []
+
+ def test_back_compat_set_to_most_recent_remaining(self, two_campaigns):
+ """When one of two sessions is unlinked, back-compat session_id = remaining session."""
+ store, _cid1, _cid2, item1, _item2 = two_campaigns
+ sid1 = "sess_first"
+ sid2 = "sess_second"
+
+ store.link_plan_item_session(item1, sid1, set_in_progress=False)
+ store.link_plan_item_session(item1, sid2, set_in_progress=False)
+
+ store.unlink_plan_item_session(item1, sid1)
+
+ item = store.get_plan_item(item1)
+ assert sid2 in item.session_ids
+ assert item.session_id == sid2 # most recent remaining
+ assert sid1 not in item.session_ids
+
+
+class TestBackCompatLegacyScalar:
+ """Back-compat path: items written before session_ids list existed (scalar only)."""
+
+ def _inject_legacy_item(self, store, campaign_id: str, item_id: str, session_id: str):
+ """Overwrite a plan item's YAML record to simulate legacy format:
+ session_id set but session_ids absent/empty — as written by pre-list code."""
+ loc = store._find_plan_item_location(item_id)
+ assert loc is not None, "item must exist before injecting legacy format"
+ cid, items, idx = loc
+ items[idx]["session_id"] = session_id
+ items[idx]["session_ids"] = [] # empty list = pre-list legacy state
+ store._write_plan_items(cid, items)
+
+ def test_legacy_scalar_found_by_reverse_query(self, two_campaigns):
+ """get_plan_items_for_session finds an item with only scalar session_id set."""
+ store, cid1, _cid2, item1, _item2 = two_campaigns
+ session_id = "sess_legacy"
+
+ # Inject legacy-format record (scalar session_id, empty session_ids)
+ self._inject_legacy_item(store, cid1, item1, session_id)
+
+ items = store.get_plan_items_for_session(session_id)
+ assert any(it.id == item1 for it in items), (
+ "expected item1 in results for legacy scalar session_id"
+ )
+
+ def test_legacy_scalar_unlinked_by_unlink(self, two_campaigns):
+ """unlink_plan_item_session removes a legacy-scalar item and returns True."""
+ store, cid1, _cid2, item1, _item2 = two_campaigns
+ session_id = "sess_legacy2"
+
+ self._inject_legacy_item(store, cid1, item1, session_id)
+
+ result = store.unlink_plan_item_session(item1, session_id)
+ assert result is True
+
+ # Must no longer appear in reverse query
+ remaining = store.get_plan_items_for_session(session_id)
+ assert not any(it.id == item1 for it in remaining), (
+ "item1 should be removed after unlinking legacy scalar session"
+ )
diff --git a/tests/test_tactic_library_store.py b/tests/test_tactic_library_store.py
index 28cd5867..125672a4 100644
--- a/tests/test_tactic_library_store.py
+++ b/tests/test_tactic_library_store.py
@@ -9,8 +9,6 @@
"""
import copy
-import pytest
-
# ---------------------------------------------------------------------------
# Shared tactic fixture
diff --git a/tests/test_tactic_library_tools.py b/tests/test_tactic_library_tools.py
index e1e33dff..e6ee147e 100644
--- a/tests/test_tactic_library_tools.py
+++ b/tests/test_tactic_library_tools.py
@@ -11,8 +11,8 @@
"""
import copy
-import pytest
+import pytest
# ---------------------------------------------------------------------------
# Shared tactic fixture
@@ -86,7 +86,9 @@ def get_operation_plan(self, session_id: str) -> dict | None:
def set_operation_plan(self, session_id: str, plan: dict) -> None:
self._plans[session_id] = copy.deepcopy(plan)
- self.set_operation_plan_calls.append({"session_id": session_id, "plan": copy.deepcopy(plan)})
+ self.set_operation_plan_calls.append(
+ {"session_id": session_id, "plan": copy.deepcopy(plan)}
+ )
# ---------------------------------------------------------------------------
@@ -109,13 +111,12 @@ def _make_context(agent):
# ---------------------------------------------------------------------------
-from gently.app.tools.tactic_library_tools import (
+from gently.app.tools.tactic_library_tools import ( # noqa: E402
apply_tactic,
list_tactics,
save_tactic,
)
-
# ---------------------------------------------------------------------------
# save_tactic tests
# ---------------------------------------------------------------------------
@@ -250,7 +251,7 @@ async def test_appends_tactic_to_existing_plan(self):
"tactics": [],
}
- result = await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent))
+ await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent))
assert len(cs.set_operation_plan_calls) == 1
saved_plan = cs.set_operation_plan_calls[0]["plan"]
@@ -281,7 +282,9 @@ async def test_appends_not_replaces(self):
"session_id": "sess_001",
"title": "",
"goal": "",
- "tactics": [{"id": "existing_t", "name": "Prior tactic", "kind": "oneshot", "state": "done"}],
+ "tactics": [
+ {"id": "existing_t", "name": "Prior tactic", "kind": "oneshot", "state": "done"}
+ ],
}
await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent))
diff --git a/tests/test_temp_protocol_driver.py b/tests/test_temp_protocol_driver.py
index aa112f03..cd2b5f30 100644
--- a/tests/test_temp_protocol_driver.py
+++ b/tests/test_temp_protocol_driver.py
@@ -1,37 +1,68 @@
import pytest
+
from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol
from gently.core.event_bus import EventType
class FakeClient:
- def __init__(self): self.laser=None; self.led=None; self.setpoint=None; self._poll=0
- async def set_laser_config(self, c): self.laser=c
- async def set_led(self, s): self.led=s
- async def set_temperature(self, t): self.setpoint=t
+ def __init__(self):
+ self.laser = None
+ self.led = None
+ self.setpoint = None
+ self._poll = 0
+
+ async def set_laser_config(self, c):
+ self.laser = c
+
+ async def set_led(self, s):
+ self.led = s
+
+ async def set_temperature(self, t):
+ self.setpoint = t
+
async def get_temperature(self):
self._poll += 1
return {"state": "[ SYSTEM LOCKED ]" if self._poll >= 2 else "[ HEATING ]"}
class FakeOrch:
- def __init__(self, client): self._client=client; self._temperature_provider=lambda: None; self.events=[]
+ def __init__(self, client):
+ self._client = client
+ self._temperature_provider = lambda: None
+ self.events = []
+
@property
- def client(self): return self._client
- def _emit_event(self, et, data): self.events.append((et, data))
+ def client(self):
+ return self._client
+
+ def _emit_event(self, et, data):
+ self.events.append((et, data))
@pytest.mark.asyncio
async def test_phase_order_and_brightfield(monkeypatch):
- client = FakeClient(); orch = FakeOrch(client)
+ client = FakeClient()
+ orch = FakeOrch(client)
bursts = []
- async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config})
+
+ async def runner(b):
+ bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config})
+
res = await run_temp_change_burst_protocol(
- orch, "emb1", 25.0, frames=3, bursts_before=1, bursts_after=1,
- lock_timeout_s=5.0, poll_s=0.001, burst_runner=runner)
+ orch,
+ "emb1",
+ 25.0,
+ frames=3,
+ bursts_before=1,
+ bursts_after=1,
+ lock_timeout_s=5.0,
+ poll_s=0.001,
+ burst_runner=runner,
+ )
assert client.laser == "ALL OFF" and client.led == "Open"
assert client.setpoint == 25.0
- assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield
- assert len(bursts) >= 3 # before + >=1 during + after
+ assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield
+ assert len(bursts) >= 3 # before + >=1 during + after
ets = [e[0] for e in orch.events]
assert EventType.TEMP_PROTOCOL_STARTED in ets
assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets
@@ -47,5 +78,9 @@ async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser"
idx_started = ets.index(EventType.TEMP_PROTOCOL_STARTED)
idx_changed = ets.index(EventType.TEMPERATURE_SETPOINT_CHANGED)
idx_completed = ets.index(EventType.TEMP_PROTOCOL_COMPLETED)
- assert idx_started < idx_changed, "TEMP_PROTOCOL_STARTED must precede TEMPERATURE_SETPOINT_CHANGED"
- assert idx_changed < idx_completed, "TEMPERATURE_SETPOINT_CHANGED must precede TEMP_PROTOCOL_COMPLETED"
+ assert idx_started < idx_changed, (
+ "TEMP_PROTOCOL_STARTED must precede TEMPERATURE_SETPOINT_CHANGED"
+ )
+ assert idx_changed < idx_completed, (
+ "TEMPERATURE_SETPOINT_CHANGED must precede TEMP_PROTOCOL_COMPLETED"
+ )
diff --git a/tests/test_temp_protocol_events.py b/tests/test_temp_protocol_events.py
index 7344a985..62883fae 100644
--- a/tests/test_temp_protocol_events.py
+++ b/tests/test_temp_protocol_events.py
@@ -12,6 +12,7 @@ def test_new_event_types_exist():
def test_timeline_maps_the_subtypes():
"""Verify timeline.py contains the mapping entries with correct subtypes"""
from gently.harness.session import timeline as tl
+
src = open(tl.__file__, encoding="utf-8").read()
for sub in ("temp_protocol_started", "temp_protocol_completed", "setpoint_changed"):
assert sub in src
diff --git a/tests/test_temp_protocol_snapshot.py b/tests/test_temp_protocol_snapshot.py
index 574940fe..2210c1f5 100644
--- a/tests/test_temp_protocol_snapshot.py
+++ b/tests/test_temp_protocol_snapshot.py
@@ -45,9 +45,7 @@ def _write_session(session_dir: Path) -> None:
EMBRYO_ID: {"interval_seconds": 120},
},
}
- (session_dir / "timelapse.yaml").write_text(
- yaml.dump(timelapse), encoding="utf-8"
- )
+ (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8")
# timeline.jsonl — the sequence under test
events = [
diff --git a/tests/test_temp_protocol_tool.py b/tests/test_temp_protocol_tool.py
index 519c4375..d9fe748e 100644
--- a/tests/test_temp_protocol_tool.py
+++ b/tests/test_temp_protocol_tool.py
@@ -4,12 +4,10 @@
TDD: write failing tests first, then implement the tool.
"""
-import asyncio
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import MagicMock, patch
import pytest
-
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
@@ -17,6 +15,7 @@
class FakeClient:
"""Minimal fake microscope client."""
+
async def set_temperature(self, t):
return {"success": True, "temperature_c": t, "state": "[ HEATING ]"}
@@ -26,6 +25,7 @@ async def get_temperature(self):
class FakeOrchestrator:
"""Minimal fake timelapse orchestrator with a client attribute."""
+
def __init__(self, client):
self.client = client
@@ -35,6 +35,7 @@ def _emit_event(self, *args, **kwargs):
class FakeAgent:
"""Minimal fake agent — carries timelapse_orchestrator."""
+
def __init__(self, orchestrator):
self.timelapse_orchestrator = orchestrator
@@ -177,8 +178,8 @@ def test_tool_is_registered():
@pytest.mark.asyncio
async def test_tool_refuses_during_active_timelapse():
"""If orchestrator._status == RUNNING, refuse without creating a task."""
- from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool
from gently.app.orchestration.timelapse_models import TimelapseStatus
+ from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool
context = _make_context(with_client=True, with_orchestrator=True)
# Simulate an active timelapse
@@ -201,4 +202,6 @@ def fake_create_task(coro, **kwargs):
assert len(created_tasks) == 0, "No task should be created when timelapse is running"
assert "refusing" in result.lower(), f"Expected refusal message, got: {result!r}"
- assert "timelapse" in result.lower(), f"Expected 'timelapse' in refusal message, got: {result!r}"
+ assert "timelapse" in result.lower(), (
+ f"Expected 'timelapse' in refusal message, got: {result!r}"
+ )
diff --git a/tests/test_temperature_event.py b/tests/test_temperature_event.py
index 388976aa..80e6601f 100644
--- a/tests/test_temperature_event.py
+++ b/tests/test_temperature_event.py
@@ -2,12 +2,12 @@
Test suite for TEMPERATURE_UPDATE event type
"""
-from gently.core.event_bus import EventType, EventBus
+from gently.core.event_bus import EventBus, EventType
def test_temperature_update_event_exists():
"""Verify TEMPERATURE_UPDATE enum member exists with correct name"""
- assert hasattr(EventType, 'TEMPERATURE_UPDATE')
+ assert hasattr(EventType, "TEMPERATURE_UPDATE")
event_type = EventType.TEMPERATURE_UPDATE
assert event_type.name == "TEMPERATURE_UPDATE"
diff --git a/tests/test_temperature_route.py b/tests/test_temperature_route.py
index 4cd02cf3..5e71a9ea 100644
--- a/tests/test_temperature_route.py
+++ b/tests/test_temperature_route.py
@@ -1,26 +1,41 @@
-from unittest.mock import MagicMock
from pathlib import Path
+from unittest.mock import MagicMock
+
from fastapi import FastAPI
from fastapi.testclient import TestClient
+
from gently.ui.web.routes.temperature import create_router
def _server(samples, sessions=(("sess-1", True),)):
store = MagicMock()
store.list_sessions.return_value = [{"session_id": sid} for sid, _ in sessions]
- store._session_dir.side_effect = lambda sid: Path("/x") if any(sid == s for s, _ in sessions) else None
+ store._session_dir.side_effect = lambda sid: (
+ Path("/x") if any(sid == s for s, _ in sessions) else None
+ )
store.read_temperature_log.return_value = samples
- srv = MagicMock(); srv.gently_store = store
+ srv = MagicMock()
+ srv.gently_store = store
return srv, store
def _client(server):
- app = FastAPI(); app.include_router(create_router(server))
+ app = FastAPI()
+ app.include_router(create_router(server))
return TestClient(app)
def test_history_returns_samples():
- srv, store = _server([{"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}])
+ srv, store = _server(
+ [
+ {
+ "t": "2026-06-27T10:00:00+00:00",
+ "water_c": 28.0,
+ "setpoint_c": 32.0,
+ "state": "heating",
+ }
+ ]
+ )
r = _client(srv).get("/api/temperature/sess-1/history")
assert r.status_code == 200
body = r.json()
diff --git a/tests/test_temperature_sampler.py b/tests/test_temperature_sampler.py
index 4c79bf76..8a221aef 100644
--- a/tests/test_temperature_sampler.py
+++ b/tests/test_temperature_sampler.py
@@ -5,11 +5,11 @@
`file_store.create_session(name="s")` is adapted to
`file_store.create_session(str(uuid.uuid4()), name="s")` to match the real API.
"""
-import asyncio
+
import uuid
-from gently.core.event_bus import EventBus, EventType
from gently.app.temperature_sampler import TemperatureSampler, temperature_stamp
+from gently.core.event_bus import EventBus, EventType
class FakeScope:
@@ -32,7 +32,9 @@ def _capture(bus):
async def test_tick_appends_emits_and_sets_latest(file_store):
sid = file_store.create_session(str(uuid.uuid4()), name="s")
- scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"})
+ scope = FakeScope(
+ {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}
+ )
bus = EventBus()
seen = _capture(bus)
s = TemperatureSampler(scope, file_store, lambda: sid)
@@ -68,15 +70,22 @@ async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store):
def test_temperature_stamp_shapes():
assert temperature_stamp(None) is None
- assert temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) == {
- "water_c": 28.4, "setpoint_c": 32.0, "state": "heating", "sampled_at": "2026-06-27T10:00:00+00:00",
+ assert temperature_stamp(
+ {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}
+ ) == {
+ "water_c": 28.4,
+ "setpoint_c": 32.0,
+ "state": "heating",
+ "sampled_at": "2026-06-27T10:00:00+00:00",
}
async def test_stale_latest_cleared_when_no_active_session(file_store):
"""After a successful tick, a subsequent tick with no active session resets latest to None."""
sid = file_store.create_session(str(uuid.uuid4()), name="s")
- scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"})
+ scope = FakeScope(
+ {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}
+ )
bus = EventBus()
# Successful first tick.
@@ -96,7 +105,9 @@ async def test_stale_latest_cleared_on_poll_failure(file_store):
bus = EventBus()
# Successful first tick.
- good_scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"})
+ good_scope = FakeScope(
+ {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}
+ )
s = TemperatureSampler(good_scope, file_store, lambda: sid)
await s._tick(bus)
assert s.latest is not None
diff --git a/tests/test_temperature_sampler_wiring.py b/tests/test_temperature_sampler_wiring.py
index 9564d775..3eec8148 100644
--- a/tests/test_temperature_sampler_wiring.py
+++ b/tests/test_temperature_sampler_wiring.py
@@ -4,7 +4,6 @@
heavy integration concern verified end-to-end in a later task. DeviceStateMonitor's
own wiring is likewise not unit-tested at this level.
"""
-from gently.app.temperature_sampler import TemperatureSampler
def test_agent_initializes_temperature_sampler_attribute():
@@ -22,9 +21,5 @@ def test_agent_initializes_temperature_sampler_attribute():
"Could not locate gently/app/agent.py via importlib"
)
text = Path(spec.origin).read_text(encoding="utf-8")
- assert "temperature_sampler" in text, (
- "agent.py has no 'temperature_sampler' attribute"
- )
- assert "TemperatureSampler(" in text, (
- "agent.py never constructs a TemperatureSampler"
- )
+ assert "temperature_sampler" in text, "agent.py has no 'temperature_sampler' attribute"
+ assert "TemperatureSampler(" in text, "agent.py never constructs a TemperatureSampler"
diff --git a/tests/test_temperature_stamp.py b/tests/test_temperature_stamp.py
index 4784880b..4b88fcdb 100644
--- a/tests/test_temperature_stamp.py
+++ b/tests/test_temperature_stamp.py
@@ -52,7 +52,9 @@ def test_volume_metadata_carries_temperature(file_store):
tifffile_mock = MagicMock()
with patch.dict(sys.modules, {"tifffile": tifffile_mock}):
with patch.object(file_store, "_generate_projection", return_value=None):
- file_store.put_volume(sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp})
+ file_store.put_volume(
+ sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp}
+ )
meta = file_store.get_volume_meta(sid, emb, 0)
assert meta["metadata"]["temperature"]["water_c"] == 28.4
@@ -77,9 +79,7 @@ def test_burst_stamp_writes_temperature(file_store):
embryo.num_slices = 2
embryo.exposure_ms = 50.0
- frames_data = [
- {"volume": np.zeros((2, 4, 4), dtype="uint16"), "acquired_at_epoch": None}
- ]
+ frames_data = [{"volume": np.zeros((2, 4, 4), dtype="uint16"), "acquired_at_epoch": None}]
tifffile_mock = MagicMock()
with patch.dict(sys.modules, {"tifffile": tifffile_mock}):
diff --git a/tests/test_temperature_store.py b/tests/test_temperature_store.py
index 9e5d5fbe..dfc36fd9 100644
--- a/tests/test_temperature_store.py
+++ b/tests/test_temperature_store.py
@@ -1,12 +1,11 @@
"""Tests for FileStore temperature log methods."""
-import pytest
-
def _new_session(file_store):
"""Create a test session and return its ID."""
# Use UUID-based session ID for uniqueness
import uuid
+
session_id = str(uuid.uuid4())
return file_store.create_session(session_id, name="temp-test")
@@ -14,8 +13,14 @@ def _new_session(file_store):
def test_append_and_read_roundtrip(file_store):
"""Test appending and reading temperature samples."""
sid = _new_session(file_store)
- file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"})
- file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"})
+ file_store.append_temperature_sample(
+ sid,
+ {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"},
+ )
+ file_store.append_temperature_sample(
+ sid,
+ {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"},
+ )
rows = file_store.read_temperature_log(sid)
assert [r["water_c"] for r in rows] == [28.0, 28.3]
@@ -23,8 +28,12 @@ def test_append_and_read_roundtrip(file_store):
def test_read_since_filters(file_store):
"""Test that read_temperature_log filters by since parameter."""
sid = _new_session(file_store)
- for i, t in enumerate(["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"]):
- file_store.append_temperature_sample(sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"})
+ for i, t in enumerate(
+ ["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"]
+ ):
+ file_store.append_temperature_sample(
+ sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"}
+ )
rows = file_store.read_temperature_log(sid, since="2026-06-27T10:00:01+00:00")
assert [r["water_c"] for r in rows] == [29.0, 30.0]
@@ -36,16 +45,20 @@ def test_read_unknown_session_is_empty(file_store):
def test_truncated_trailing_line_is_skipped(file_store, tmp_path):
"""A truncated trailing line (e.g. after a crash mid-append) is skipped gracefully."""
- import uuid
sid = _new_session(file_store)
# Append two valid samples via the normal API.
- file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.1, "setpoint_c": 32.0, "state": "heating"})
- file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:02+00:00", "water_c": 28.2, "setpoint_c": 32.0, "state": "heating"})
+ file_store.append_temperature_sample(
+ sid,
+ {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.1, "setpoint_c": 32.0, "state": "heating"},
+ )
+ file_store.append_temperature_sample(
+ sid,
+ {"t": "2026-06-27T10:00:02+00:00", "water_c": 28.2, "setpoint_c": 32.0, "state": "heating"},
+ )
# Append a raw truncated line directly to the JSONL file.
- from gently.core.file_store import FileStore
# Locate the temperature.jsonl via the store's internal path.
sd = file_store._session_dir(sid)
log_path = sd / "temperature.jsonl"
diff --git a/tests/test_tool_tactic_linkage.py b/tests/test_tool_tactic_linkage.py
index 6b56fa47..7b260bdb 100644
--- a/tests/test_tool_tactic_linkage.py
+++ b/tests/test_tool_tactic_linkage.py
@@ -16,8 +16,8 @@
"""
import asyncio
-import pytest
+import pytest
# ---------------------------------------------------------------------------
# Fakes
@@ -30,7 +30,7 @@ class FakeContextStore:
def __init__(self):
self.transitions: list[tuple] = []
- def transition_tactic(self, session_id: str, tactic_id: str, state: str = None, **bind):
+ def transition_tactic(self, session_id: str, tactic_id: str, state: str | None = None, **bind):
self.transitions.append((session_id, tactic_id, state))
return True
@@ -82,6 +82,7 @@ def __init__(self, *, cs=None, session_id="sess_t7", orchestrator=None):
class FakeMicroscope:
"""Satisfies the requires_microscope client check in the tool registry."""
+
pass
@@ -115,9 +116,9 @@ async def test_enable_monitoring_mode_with_tactic_id_calls_transition():
)
assert cs is not None
- assert any(
- t == ("sess_t7", "t1", "active") for t in cs.transitions
- ), f"Expected transition ('sess_t7', 't1', 'active'), got {cs.transitions}"
+ assert any(t == ("sess_t7", "t1", "active") for t in cs.transitions), (
+ f"Expected transition ('sess_t7', 't1', 'active'), got {cs.transitions}"
+ )
@pytest.mark.asyncio
@@ -157,9 +158,9 @@ async def test_queue_burst_with_tactic_id_calls_transition():
agent, cs, _orch = _make_agent()
await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True))
- assert any(
- t == ("sess_t7", "t2", "active") for t in cs.transitions
- ), f"Expected transition ('sess_t7', 't2', 'active'), got {cs.transitions}"
+ assert any(t == ("sess_t7", "t2", "active") for t in cs.transitions), (
+ f"Expected transition ('sess_t7', 't2', 'active'), got {cs.transitions}"
+ )
@pytest.mark.asyncio
@@ -197,8 +198,17 @@ async def test_queue_burst_soft_reject_does_not_transition():
from gently.app.tools.timelapse_tools import queue_burst
class RejectingOrchestrator(FakeOrchestrator):
- def queue_burst(self, embryo_id, *, frames=60, mode="1hz", num_slices=1,
- force=False, laser_config=None, tactic_id=None) -> str:
+ def queue_burst(
+ self,
+ embryo_id,
+ *,
+ frames=60,
+ mode="1hz",
+ num_slices=1,
+ force=False,
+ laser_config=None,
+ tactic_id=None,
+ ) -> str:
# Simulates "already has a queued burst" soft-reject
return f"Embryo '{embryo_id}' already has a queued burst."
@@ -226,9 +236,9 @@ async def test_stop_timelapse_with_tactic_id_calls_done():
agent, cs, _orch = _make_agent()
await stop_timelapse(tactic_id="t1", context=_ctx(agent, with_client=True))
- assert any(
- t == ("sess_t7", "t1", "done") for t in cs.transitions
- ), f"Expected transition to done, got {cs.transitions}"
+ assert any(t == ("sess_t7", "t1", "done") for t in cs.transitions), (
+ f"Expected transition to done, got {cs.transitions}"
+ )
@pytest.mark.asyncio
@@ -255,9 +265,9 @@ async def test_pause_timelapse_with_tactic_id_calls_paused():
agent, cs, _orch = _make_agent()
await pause_timelapse(tactic_id="t1", context=_ctx(agent, with_client=True))
- assert any(
- t == ("sess_t7", "t1", "paused") for t in cs.transitions
- ), f"Expected transition to paused, got {cs.transitions}"
+ assert any(t == ("sess_t7", "t1", "paused") for t in cs.transitions), (
+ f"Expected transition to paused, got {cs.transitions}"
+ )
@pytest.mark.asyncio
@@ -295,7 +305,6 @@ def test_burst_acquisition_default_tactic_id_none():
def test_burst_start_event_data_contains_tactic_id():
"""The BURST_START event data dict includes tactic_id."""
from gently.app.orchestration.exclusive import BurstAcquisition
- from gently.core import EventType
emitted: list[dict] = []
@@ -356,7 +365,7 @@ def _emit_event(self, event_type, data):
loop.close()
burst_starts = [(et, d) for et, d in emitted if et == EventType.BURST_START]
- assert burst_starts, f"BURST_START not emitted; all events: {[et for et,_ in emitted]}"
+ assert burst_starts, f"BURST_START not emitted; all events: {[et for et, _ in emitted]}"
_et, data = burst_starts[0]
assert "tactic_id" in data, f"tactic_id not in BURST_START data: {data}"
assert data["tactic_id"] == "t3"
@@ -395,7 +404,7 @@ def _emit_event(self, event_type, data):
loop.close()
burst_completes = [(et, d) for et, d in emitted if et == EventType.BURST_COMPLETE]
- assert burst_completes, f"BURST_COMPLETE not emitted; all events: {[et for et,_ in emitted]}"
+ assert burst_completes, f"BURST_COMPLETE not emitted; all events: {[et for et, _ in emitted]}"
_et, data = burst_completes[0]
assert "tactic_id" in data, f"tactic_id not in BURST_COMPLETE data: {data}"
assert data["tactic_id"] == "t3"
@@ -436,9 +445,9 @@ class FakeClient:
context=ctx,
)
- assert any(
- t == ("sess_t7", "t4", "active") for t in cs.transitions
- ), f"Expected active transition, got {cs.transitions}"
+ assert any(t == ("sess_t7", "t4", "active") for t in cs.transitions), (
+ f"Expected active transition, got {cs.transitions}"
+ )
@pytest.mark.asyncio
@@ -486,9 +495,15 @@ async def test_temp_protocol_started_event_carries_tactic_id():
emitted: list[tuple] = []
class FakeClient:
- async def set_laser_config(self, cfg): pass
- async def set_led(self, s): pass
- async def set_temperature(self, t): pass
+ async def set_laser_config(self, cfg):
+ pass
+
+ async def set_led(self, s):
+ pass
+
+ async def set_temperature(self, t):
+ pass
+
async def get_temperature(self):
return {"state": "LOCKED"}
@@ -504,8 +519,11 @@ async def fake_burst_runner(b):
bursts_run.append(b._phase)
await run_temp_change_burst_protocol(
- FakeOrc(), "emb1", 25.0,
- bursts_before=0, bursts_after=0,
+ FakeOrc(),
+ "emb1",
+ 25.0,
+ bursts_before=0,
+ bursts_after=0,
burst_runner=fake_burst_runner,
tactic_id="t5",
)
@@ -524,9 +542,15 @@ async def test_temp_protocol_completed_event_carries_tactic_id():
emitted: list[tuple] = []
class FakeClient:
- async def set_laser_config(self, cfg): pass
- async def set_led(self, s): pass
- async def set_temperature(self, t): pass
+ async def set_laser_config(self, cfg):
+ pass
+
+ async def set_led(self, s):
+ pass
+
+ async def set_temperature(self, t):
+ pass
+
async def get_temperature(self):
return {"state": "LOCKED"}
@@ -537,8 +561,11 @@ def _emit_event(self, event_type, data):
emitted.append((event_type, data))
await run_temp_change_burst_protocol(
- FakeOrc(), "emb1", 25.0,
- bursts_before=0, bursts_after=0,
+ FakeOrc(),
+ "emb1",
+ 25.0,
+ bursts_before=0,
+ bursts_after=0,
burst_runner=lambda b: asyncio.sleep(0),
tactic_id="t5",
)
@@ -557,9 +584,15 @@ async def test_temp_protocol_tactic_id_none_when_absent():
emitted: list[tuple] = []
class FakeClient:
- async def set_laser_config(self, cfg): pass
- async def set_led(self, s): pass
- async def set_temperature(self, t): pass
+ async def set_laser_config(self, cfg):
+ pass
+
+ async def set_led(self, s):
+ pass
+
+ async def set_temperature(self, t):
+ pass
+
async def get_temperature(self):
return {"state": "LOCKED"}
@@ -570,8 +603,11 @@ def _emit_event(self, event_type, data):
emitted.append((event_type, data))
await run_temp_change_burst_protocol(
- FakeOrc(), "emb1", 25.0,
- bursts_before=0, bursts_after=0,
+ FakeOrc(),
+ "emb1",
+ 25.0,
+ bursts_before=0,
+ bursts_after=0,
burst_runner=lambda b: asyncio.sleep(0),
)
diff --git a/tests/test_transition_tactic.py b/tests/test_transition_tactic.py
index 5b72648a..0064b8f3 100644
--- a/tests/test_transition_tactic.py
+++ b/tests/test_transition_tactic.py
@@ -5,10 +5,8 @@
True/False return values, no-op on missing plan or tactic.
"""
-import pytest
from gently.core.event_bus import EventType, on
-
# ---------------------------------------------------------------------------
# Plan fixture shared across tests
# ---------------------------------------------------------------------------
@@ -49,6 +47,7 @@
def _fresh_plan():
"""Deep-copy the fixture so mutations don't bleed between tests."""
import copy
+
return copy.deepcopy(_PLAN)
diff --git a/tests/test_wait_for_lock.py b/tests/test_wait_for_lock.py
index 6ee8fbe3..c8d7f854 100644
--- a/tests/test_wait_for_lock.py
+++ b/tests/test_wait_for_lock.py
@@ -1,15 +1,22 @@
from gently.app.orchestration.temperature_protocol import wait_for_temperature_lock
+
class FakeClient:
- def __init__(self, states): self.states = list(states); self.calls = 0
+ def __init__(self, states):
+ self.states = list(states)
+ self.calls = 0
+
async def get_temperature(self):
- i = min(self.calls, len(self.states) - 1); self.calls += 1
+ i = min(self.calls, len(self.states) - 1)
+ self.calls += 1
return {"state": self.states[i]}
+
async def test_returns_true_when_locked():
c = FakeClient(["[ IDLE ]", "[ HEATING ]", "[ SYSTEM LOCKED ]"])
assert await wait_for_temperature_lock(c, timeout_s=5.0, poll_s=0.001) is True
+
async def test_returns_false_on_timeout():
c = FakeClient(["[ HEATING ]"])
assert await wait_for_temperature_lock(c, timeout_s=0.02, poll_s=0.001) is False