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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 50 additions & 4 deletions gently/ui/web/static/js/operate.js
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,23 @@ const OperateManager = (function () {
catch (e) { toastFail(`Roles failed (${why(e)})`); }
}

// What `Start` will actually do, per mode. The button used to say "Start"
// for all four, while `startRun` branched into four different verbs — one
// of which (single) acquires a volume and finishes, and is not starting an
// experiment at all. The mode selector sits in a block above, so an
// operator who chose a mode and then looked away had nothing to read.
const RUN_VERB = {
single: 'Acquire one volume',
adaptive: 'Start timelapse',
library: 'Run tactic',
agent: 'Brief the agent',
};

function renderRunButton() {
const b = $('op-run-start');
if (b && !b.disabled) b.textContent = RUN_VERB[_mode] || 'Start';
}

function setMode(m) {
_mode = m;
document.querySelectorAll('#op-modes [data-mode]').forEach(b =>
Expand All @@ -1106,6 +1123,7 @@ const OperateManager = (function () {
if (p) p.hidden = k !== m;
});
if (m === 'library') loadLibrary();
renderRunButton();
renderSingle();
}

Expand Down Expand Up @@ -1136,15 +1154,40 @@ const OperateManager = (function () {
} catch (_) { host.innerHTML = '<div class="op-empty">Library unavailable</div>'; }
}

/**
* The embryos a run should image: everything not marked as a reference.
*
* There used to be a fallback — `subs.length ? subs : all` — meant to be
* kind to a roster with no roles assigned. It could never do that. An
* embryo with no role, or role 'test', or 'unassigned', already passes the
* filter, so `subs` is empty in exactly ONE case: every embryo is marked
* `calibration`. The fallback therefore fired only when the operator had
* said "these are all references", and answered by imaging all of them as
* subjects — the precise opposite of the instruction.
*
* Empty now, and the caller says so.
*/
function subjectIds() {
const subs = _embryos.filter(e => e.role !== 'calibration').map(e => e.id);
return subs.length ? subs : _embryos.map(e => e.id);
return _embryos.filter(e => e.role !== 'calibration').map(e => e.id);
}

// Every embryo marked as a reference means there is nothing to image. Say
// so once, here, rather than at each mode — and say which state it is in,
// because "no embryos" and "no subjects among your embryos" need
// different fixes.
function haveSubjects() {
if (!_embryos.length) { toastFail('No embryos registered yet'); return false; }
if (!subjectIds().length) {
toastFail('Every embryo is marked as a reference — assign at least one subject');
return false;
}
return true;
}

async function startRun() {
const b = $('op-run-start');
const done = () => { if (b) { b.disabled = false; b.textContent = 'Start'; } };
if (b) { b.disabled = true; b.textContent = 'Starting…'; }
const done = () => { if (b) { b.disabled = false; renderRunButton(); } };
if (b) { b.disabled = true; b.textContent = 'Working…'; }
try {
if (_mode === 'single') {
if (!_selected) { toastFail('Select an embryo first'); return; }
Expand All @@ -1166,6 +1209,7 @@ const OperateManager = (function () {
return;
}
if (_mode === 'adaptive') {
if (!haveSubjects()) return;
const interval = Math.max(1, Number(($('op-tl-interval') || {}).value) || 120);
const sel = ($('op-tl-stop') || {}).value || 'manual';
const val = Math.max(1, Number(($('op-tl-condval') || {}).value) || 1);
Expand All @@ -1187,6 +1231,7 @@ const OperateManager = (function () {
}
if (_mode === 'library') {
if (!_selectedLib) { toastFail('Pick a saved tactic'); return; }
if (!haveSubjects()) return;
const d = await postJSON('/api/operate/run-tactic',
{ library_id: _selectedLib, embryo_ids: subjectIds() });
if (d.success) { toast('Tactic started'); renderRun(); }
Expand Down Expand Up @@ -1560,6 +1605,7 @@ const OperateManager = (function () {
// surface would never get its zoom.
attachImageViews();
mountPanels();
renderRunButton();
if (_active) return;
_active = true;
showPaneInitial();
Expand Down
5 changes: 4 additions & 1 deletion gently/ui/web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,10 @@ <h2 class="devices-title"><span class="devices-title-script">Device</span> <em c
starts an Operation Plan for them.</p>
</div>

<button class="op-btn op-btn-primary op-btn-wide" id="op-run-start" type="button">Start</button>
<!-- Label is set per mode by renderRunButton(); this is single mode's verb
so there is no flash of the wrong one on load. -->
<button class="op-btn op-btn-primary op-btn-wide" id="op-run-start"
type="button">Acquire one volume</button>
</div>

<div class="op-col">
Expand Down
88 changes: 88 additions & 0 deletions tests/test_run_button_names_its_verb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""`Start` must say which of four things it will do.

`startRun` branches on `_mode` into four different verbs — `single` acquires
one volume and finishes, `adaptive` starts a timelapse, `library` runs a saved
tactic, `agent` hands over a prompt. The button said "Start" for all four, and
the mode selector lives in a block above it, so an operator who chose a mode
and then looked away had nothing on the button to read.

Two invariants worth pinning, because both rot silently:

1. Every mode has a verb. A mode added to `setMode`'s list but not to
`RUN_VERB` falls back to "Start" and quietly reintroduces the ambiguity.
2. `subjectIds()` keeps no fallback. The old `subs.length ? subs : all` could
only ever fire when EVERY embryo was marked `calibration` — an embryo with
no role, or 'test', or 'unassigned' already passes the filter. So it fired
exactly when the operator had said "these are all references" and answered
by imaging all of them as subjects.

ponytail: source assertions, because operate.js is an IIFE with no export
surface and CI runs no JavaScript. They check the shape, not the behaviour.
"""

from __future__ import annotations

import re
from pathlib import Path

OPERATE = (
Path(__file__).resolve().parents[1] / "gently" / "ui" / "web" / "static" / "js" / "operate.js"
)


def _src() -> str:
return OPERATE.read_text(encoding="utf-8")


def test_every_run_mode_has_a_verb() -> None:
src = _src()

block = src[src.index("const RUN_VERB = {") : src.index("function renderRunButton")]
verbs = set(re.findall(r"(\w+):\s*'", block))

assert re.search(r"\['single', 'adaptive', 'library', 'agent'\]", src), (
"setMode's mode list changed shape — update this test alongside it"
)
modes = {"single", "adaptive", "library", "agent"}

assert modes <= verbs, (
f"modes with no verb in RUN_VERB: {sorted(modes - verbs)} — the button "
"falls back to 'Start' for those and the ambiguity is back"
)


def test_single_mode_does_not_claim_to_start_anything() -> None:
"""It acquires one volume and finishes. The label must not imply a run."""
src = _src()
block = src[src.index("const RUN_VERB = {") : src.index("function renderRunButton")]
single = re.search(r"single:\s*'([^']+)'", block)
assert single, "single mode lost its verb"
label = single.group(1).lower()
assert "start" not in label, (
f"single mode's label is {label!r} — it acquires one volume and stops, "
"so it must not read as starting an experiment"
)


def test_subject_ids_keeps_no_fallback() -> None:
src = _src()
body = src[src.index("function subjectIds()") :]
body = body[: body.index("}")]
assert "_embryos.map" not in body, (
"subjectIds() has a fallback again — it can only fire when every embryo "
"is a reference, and it answers by imaging all of them as subjects"
)


def test_a_reference_only_roster_is_refused_rather_than_imaged() -> None:
src = _src()
assert "function haveSubjects()" in src
assert src.count("haveSubjects()") >= 3, (
"haveSubjects is defined but not guarding both roster-driven run modes"
)
# "no embryos" and "no subjects among your embryos" need different fixes,
# so the guard must distinguish them rather than emitting one message.
guard = src[src.index("function haveSubjects()") :]
guard = guard[: guard.index("\n }")]
assert "No embryos registered" in guard
assert "marked as a reference" in guard
Loading