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 = ``; + 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 + ? `` + : _available.map(s => ``).join(''); + sessHtml += `
+ +
+ + +
+
`; + } + } + 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 ${opts} +
+ + +
+ `; + } + } + + 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