diff --git a/CHANGELOG.md b/CHANGELOG.md index fb79697..9a8cf7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,23 @@ OME-XML description is parsed (which images are marked is unchanged; a malformed debug line without a traceback), and while the capture is attached the package's DEBUG records go to the "Technical details" panel only, with WARNING and above still reaching the root handlers. +### Notebook redesign 1: fixes from the owner's test of v0.5.0 (10 November 2026) + +- **Lists of files in a message draw no empty bullets.** `notebook_style.path_list` returns + plain lines in a box (scrolling above about ten lines) instead of a nested list, and a status + card joins a list of paths to the line before it. The same paths are shown, folded behind + "Show N more" as before. +- **"Is compared against" keeps its dropdown.** Choosing an option rebuilt every dropdown of the + card (twice), so the control vanished while the answer was saved. The rows are now built once + per set of conditions and updated in place; the answer, its refusal and the saved draft are + unchanged. Measured on 28 images of 2048 x 2048: 0.58 s to 0.35 s per choice (the rest is the + refresh of the other pages). +- **Every disabled action says what blocks it, in a visible line beside it** + (`notebook_render.blocked_line`): *Confirm axis roles* (the axis error by name, the preview + not drawn, the preview not ticked), *Yes, the preview looks right*, *Compute histograms*, + *Yes, save the experiment description*, *Make named-axis copies* and its roles question, and + the stains confirmation. Nothing about when an action is allowed changed. Roles that are not + a mapping at all (two X) now also disable *Confirm axis roles* at once. ### Normalization pinned at v0.4.0 (#262) - **`morphobricks-normalization` moves from v0.3.4 to v0.4.0** (tag `c952263`). It is diff --git a/src/morphobricks_preprocessing/notebook_axis_preview.py b/src/morphobricks_preprocessing/notebook_axis_preview.py index 3c35d2a..c8596f8 100644 --- a/src/morphobricks_preprocessing/notebook_axis_preview.py +++ b/src/morphobricks_preprocessing/notebook_axis_preview.py @@ -10,7 +10,7 @@ from .image_type import review_image_type from .notebook_options import IMAGE_TYPE_NAMES from .notebook_plots import plane_png -from .notebook_render import preview_caption +from .notebook_render import blocked_line, preview_caption from .planes import Plane, PlaneReadError, read_plane from .preview import PreviewRefused, check_unchanged, checked_fingerprint from .validation import plural @@ -101,6 +101,13 @@ def _gate_preview_tick(self) -> None: if not shown and not locked and self.preview_checked.value: self.preview_checked.set(False) # a tick says nothing about a preview not shown self.preview_checked.disabled = locked or not shown + self.preview_blocker.value = ( + "" + if locked or shown + else blocked_line(["no preview of these roles is drawn: click Show preview"]) + ) + if not locked: + self._update_confirm_button() def _draw_preview(self) -> None: """Draw the chosen plane with the roles shown, labelled confirmed or not (PP-10).""" diff --git a/src/morphobricks_preprocessing/notebook_axis_step.py b/src/morphobricks_preprocessing/notebook_axis_step.py index 5f9fd74..797855f 100644 --- a/src/morphobricks_preprocessing/notebook_axis_step.py +++ b/src/morphobricks_preprocessing/notebook_axis_step.py @@ -14,7 +14,7 @@ from .image_type import FLUORESCENCE from .notebook_axes import axis_check_html, header_marks from .notebook_options import IMAGE_TYPE_OPTIONS, ROLE_OPTIONS -from .notebook_render import issues_html, plane_summary_html, role_effect +from .notebook_render import blocked_line, issues_html, plane_summary_html, role_effect from .run import Preflight from .validation import plural @@ -40,6 +40,7 @@ def _build_axis_step(self) -> None: tooltip="Use these roles for every image of the experiment", main=True, ) + self.confirm_blocker = widgets.HTML() self.confirmed_status = widgets.HTML() self.axis_next = widgets.HTML() self.missing_as_singleton.observe(lambda _: self._mapping_changed()) @@ -102,6 +103,7 @@ def _build_preview_card(self) -> None: "Undo: the preview does not look right", ) self.preview_checked.disabled = True + self.preview_blocker = widgets.HTML() self.preview_card = ui.card( "Preview", [ @@ -115,6 +117,7 @@ def _build_preview_card(self) -> None: widgets.HBox([self.preview_image]), self.preview_caption, self.preview_checked.widget, + self.preview_blocker, ], ) @@ -184,6 +187,7 @@ def _refresh_axis_step(self) -> None: self.convert_card, self.preview_card, self.confirm_button, + self.confirm_blocker, self.confirmed_status, self.image_type_card, self.axis_next, @@ -322,6 +326,8 @@ def _mapping_changed(self) -> None: except ValueError as exc: self.axis_status.value = ui.status("error", str(exc)) self.preview_checked.disabled = True # the preview shown is not of these roles + self._review_ok, self._review_errors = False, () + self._update_confirm_button() return ranges = axis_length_ranges(self.mapping, self.preflight.metadata.files) marks = header_marks(self.mapping, self.preflight.metadata.files) @@ -354,6 +360,9 @@ def _mapping_changed(self) -> None: "Click Show preview and inspect a few planes before confirming.", ) self._review_ok = review.ok + self._review_errors = tuple( + dict.fromkeys(i.code.replace("_", " ") for i in review.issues if i.is_error) + ) self._refresh_convert_card(review) if self._preview_shown: self._configure_sliders(reset=False) @@ -361,13 +370,29 @@ def _mapping_changed(self) -> None: self._update_confirm_button() def _update_confirm_button(self) -> None: + """Enable *Confirm axis roles* when nothing blocks it; say beside it what does.""" if self.confirmed_mapping is not None: self.confirm_button.disabled = False + self.confirm_blocker.value = "" return - ready = getattr(self, "_review_ok", False) and self.preview_checked.value - self.confirm_button.disabled = not ready + blockers = self._confirm_blockers() + self.confirm_button.disabled = bool(blockers) + self.confirm_blocker.value = blocked_line(blockers) self.confirm_button.tooltip = ( - "Use these roles for every image of the experiment" - if ready - else "Fix the axis messages and check the preview first" + "Fix the axis messages and check the preview first" + if blockers + else "Use these roles for every image of the experiment" ) + + def _confirm_blockers(self) -> list[str]: + """Return what stops *Confirm axis roles* now, the first thing to do first.""" + blockers = [] + if not getattr(self, "_review_ok", False): + codes = getattr(self, "_review_errors", ()) + named = f" ({', '.join(codes)})" if codes else "" + blockers.append(f"these roles have an error{named}: see the red message above") + if not self._preview_of(self.mapping): + blockers.append("no preview of these roles is drawn: click Show preview") + elif not self.preview_checked.value: + blockers.append("the preview is not ticked: click Yes, the preview looks right") + return blockers diff --git a/src/morphobricks_preprocessing/notebook_channels_labels.py b/src/morphobricks_preprocessing/notebook_channels_labels.py index d4f59da..336dde7 100644 --- a/src/morphobricks_preprocessing/notebook_channels_labels.py +++ b/src/morphobricks_preprocessing/notebook_channels_labels.py @@ -35,6 +35,7 @@ ) from .notebook_channels_view import AnswerStore, chip from .notebook_description_view import plain +from .notebook_render import blocked_line ROW_COLUMNS = "minmax(150px, 1fr) minmax(150px, 1fr) minmax(200px, 2fr)" QUESTION = "Are these the labels you imaged?" @@ -265,6 +266,8 @@ def _validate(self) -> None: ui.status("error", "These labels cannot be used yet.", ["Fix the red entries."]) if by_slot else "" + if filled or self.confirm.value + else blocked_line(["no label is typed: name at least one stain"]) ) # Look-alike groups ------------------------------------------------------------------- diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index 4c584d9..9828cb9 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -60,6 +60,7 @@ todo_html, ) from .notebook_description import _with_ids +from .notebook_render import blocked_line from .notebook_row_fold import RowFold, row_status from .notebook_thumb_tile import ThumbTile, show_detail from .preview_crop import TilePictures @@ -121,6 +122,7 @@ def _build_check_save(self) -> None: self.save_note = widgets.HTML(layout=widgets.Layout(margin="0 0 0 12px")) ui.set_visible(self.save_note, False) self.save_row = widgets.HBox([self.save_button, self.save_note]) + self.save_blocker = widgets.HTML() self.save_status = widgets.HTML() self.check_card = ui.card( "Your experiment, in words", @@ -134,6 +136,7 @@ def _build_check_save(self) -> None: self.todo, self.checks, self.save_row, + self.save_blocker, self.save_status, ], visible=False, @@ -233,8 +236,18 @@ def _refresh_check_save(self) -> None: self.save_button.disabled = not ready or self._saved_current() self.save_note.value = "" if self.save_button.disabled else run_stop_html(self._check) ui.set_visible(self.save_note, bool(self.save_note.value)) + self.save_blocker.value = blocked_line(self._save_blockers(todo)) self._show_saved_state() + def _save_blockers(self, todo: tuple[list, list[str]]) -> list[str]: + """Return what stops the save button now (shown beside it), or nothing when saved.""" + if not self.save_button.disabled or self._saved_current(): + return [] # saved already (the green line says so): nothing is blocked + missing = len(todo[0]) + len(todo[1]) + if missing: + return [f"{plural(missing, 'answer')} still missing (listed above the button)"] + return ["these answers are refused: see the red message above the button"] + def _blocked(self, state: object) -> str: """Say what already stops the run while the candidate cannot be checked (answers missing). diff --git a/src/morphobricks_preprocessing/notebook_convert.py b/src/morphobricks_preprocessing/notebook_convert.py index 490e2f9..7b73c82 100644 --- a/src/morphobricks_preprocessing/notebook_convert.py +++ b/src/morphobricks_preprocessing/notebook_convert.py @@ -39,7 +39,7 @@ from .conversion_record import RECORD_FILENAME from .notebook_options import _WIDE from .notebook_output import _LockCard -from .notebook_render import issues_html +from .notebook_render import blocked_line, issues_html from .planes import format_bytes from .run.output_guard import recorded_masks_of from .validation import plural @@ -78,6 +78,8 @@ def _build_convert_card(self) -> None: "Undo: the roles are not right", ) self.convert_plan = widgets.HTML() + self.convert_tick_blocker = widgets.HTML() + self.convert_blocker = widgets.HTML() self.convert_button = ui.button( _LABEL, style="success", @@ -140,8 +142,10 @@ def _convert_children(self) -> list[widgets.Widget]: ), self.convert_roles, self.convert_roles_checked.widget, + self.convert_tick_blocker, self.convert_plan, self.convert_button, + self.convert_blocker, self.convert_progress, self.convert_status, self.convert_lock.card, @@ -170,6 +174,11 @@ def _convert_roles_changed(self) -> None: roles = self._copy_roles() confirmed = self.confirmed_mapping is not None self.convert_roles_checked.disabled = not self.preview_checked.value + self.convert_tick_blocker.value = blocked_line( + [] + if confirmed or self.preview_checked.value + else ["the preview is not ticked: click Yes, the preview looks right first"] + ) ui.set_visible( self.convert_roles_checked.widget, not confirmed and self.mapping is not None ) @@ -229,11 +238,22 @@ def _runs_masks_folder(self) -> Path | None: return masks.folder return recorded_masks_of(self._output_path(self.preflight.discovery.root)) + def _convert_blocker(self, preview: ConversionPreview | None) -> str: + """Return what stops *Make named-axis copies* (the plan above gives the details).""" + if preview is None: + if self._copy_roles() is None: + return "the roles of the copies are not confirmed" + return "no folder for the copies is given" + if not preview.ok: + return "the plan above has an error to fix first" + return "the folder for the copies has too little free space (see the plan above)" + def _update_convert_button(self) -> None: """Enable the button once the plan shows nothing that stops the conversion.""" preview = self._convert_preview ready = preview is not None and preview.ok and (preview.fits or preview.earlier_conversion) self.convert_button.disabled = not ready + self.convert_blocker.value = blocked_line([] if ready else [self._convert_blocker(preview)]) self.convert_button.tooltip = ( "Write the copies into the folder above (the originals are only read)" if ready diff --git a/src/morphobricks_preprocessing/notebook_description.py b/src/morphobricks_preprocessing/notebook_description.py index 1dbad56..2a40380 100644 --- a/src/morphobricks_preprocessing/notebook_description.py +++ b/src/morphobricks_preprocessing/notebook_description.py @@ -86,6 +86,7 @@ def _build_description_cards(self) -> None: self.name_hints: dict[tuple[str, str], widgets.HTML] = {} self.single_note = widgets.HTML() self.reference_boxes: dict[str, widgets.Dropdown] = {} + self.reference_notes: dict[str, widgets.HTML] = {} self._id_states: dict[tuple[str, str], widgets.HTML] = {} self.names_status = widgets.HTML() self.names_grid = widgets.VBox() @@ -330,13 +331,33 @@ def _confirm_ids(self) -> bool: # The references card ----------------------------------------------------------------- def _build_references(self) -> None: - """Rebuild one dropdown per condition from the draft's references.""" + """Show one dropdown per condition from the draft's references. + + The dropdowns are built once for the conditions there are and then updated in place: a + choice must not replace the control the user is using (owner test, 10 November 2026). + """ conditions = sorted(self.draft.conditions) + if list(self.reference_boxes) != conditions or not self.references_grid.children: + self._new_reference_rows(conditions) references = self.draft.references + for condition in conditions: + box = self.reference_boxes[condition] + options, chosen = self._reference_options(condition), self._chosen_reference(condition) + if list(box.options) != options: + box.options = options + if box.value != chosen: + box.value = chosen + users = sorted(c for c, target in references.items() if target == condition) + note = f"the reference of {', '.join(users)}" if users else "" + self.reference_notes[condition].value = html.escape(note) + self.references_status.value = self._references_refusal + + def _new_reference_rows(self, conditions: list[str]) -> None: + """Build the rows of the references card: a dropdown and a note per condition.""" rows: list[widgets.Widget] = [ header_row("Condition", "Is compared against", "", layout=REFERENCE_COLUMNS) ] - self.reference_boxes = {} + self.reference_boxes, self.reference_notes = {}, {} for condition in conditions: box = widgets.Dropdown( options=self._reference_options(condition), @@ -347,20 +368,18 @@ def _build_references(self) -> None: lambda change, c=condition: self._reference_chosen(c, change["new"]), names="value" ) self.reference_boxes[condition] = box - users = sorted(c for c, target in references.items() if target == condition) - note = f"the reference of {', '.join(users)}" if users else "" + self.reference_notes[condition] = widgets.HTML() rows.append( widgets.GridBox( [ widgets.HTML(f"{html.escape(condition)}"), box, - widgets.HTML(html.escape(note)), + self.reference_notes[condition], ], layout=REFERENCE_COLUMNS, ) ) self.references_grid.children = rows - self.references_status.value = self._references_refusal def _chosen_reference(self, condition: str) -> str | None: """Return the saved choice: None if not answered, ``""`` for no comparison, a folder.""" @@ -409,7 +428,8 @@ def _reference_chosen(self, condition: str, target: str | None) -> None: return self._draft_note = "" new = _updated(answers, "conditions", condition, reference=target) - if self._answer(new): + # One refresh, of the cards as they are: the dropdown chosen in stays on the page. + if self._answer(new, rebuild=False): self._references_refusal = "" else: self._references_refusal = refusal_card( diff --git a/src/morphobricks_preprocessing/notebook_render.py b/src/morphobricks_preprocessing/notebook_render.py index 48f3ce4..99beb1e 100644 --- a/src/morphobricks_preprocessing/notebook_render.py +++ b/src/morphobricks_preprocessing/notebook_render.py @@ -41,6 +41,18 @@ def issues_html(issues: Iterable[ValidationIssue]) -> str: return "".join(parts) +def blocked_line(reasons: Sequence[str]) -> str: + """Return the visible line beside a disabled action saying what blocks it (HTML), or "". + + Every disabled button has one (owner test, 10 November 2026): a tooltip alone is a hidden + refusal. ``reasons`` are short plain-text phrases; the first is the one to act on. + """ + if not reasons: + return "" + text = "; ".join(html.escape(reason) for reason in reasons) + return f"
🔒 Not available yet: {text}.
" + + def issue_count_chips(issues: Sequence[ValidationIssue]) -> str: """Red error and orange warning counts, shown outside a folded list of issues.""" errors = [i for i in issues if i.is_error] diff --git a/src/morphobricks_preprocessing/notebook_run.py b/src/morphobricks_preprocessing/notebook_run.py index c4a46aa..2d1cca2 100644 --- a/src/morphobricks_preprocessing/notebook_run.py +++ b/src/morphobricks_preprocessing/notebook_run.py @@ -26,7 +26,7 @@ VERIFY_CONTENT_HELP, ) from .notebook_plots import IntensityPanel -from .notebook_render import _bins_help, _label, change_lines, issues_html +from .notebook_render import _bins_help, _label, blocked_line, change_lines, issues_html from .planes import DEFAULT_MEMORY_LIMIT from .report import issue_counts, rebuild_line from .run import RESTART, Preflight, RunMode, RunResult, run_experiment @@ -57,6 +57,7 @@ def _build_run_step(self) -> None: value=0, min=0, max=1, bar_style="info", layout=widgets.Layout(width="99%") ) ui.set_visible(self.progress, False) + self.run_blocker = widgets.HTML() self.run_status = widgets.HTML() self.run_next = widgets.HTML() self.run_tiles = widgets.HTML() @@ -266,6 +267,10 @@ def _refresh_run_step(self) -> None: title = ui.status("ok", "Ready to run.") self.readiness.value = title + ui.checklist(items) self.run_button.disabled = bool(missing) + self.run_blocker.value = blocked_line( + [f"still to do: {text[0].lower()}{text[1:]}" for text in missing[:1]] + + ([f"and {len(missing) - 1} more (see Before you run)"] if len(missing) > 1 else []) + ) self.plan.value = self._plan_html(review if folder_ok else None) self._refresh_plan_card() self._refresh_mask_step() diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index e104451..0ae630b 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -149,6 +149,7 @@ def _run_page(self) -> list[widgets.Widget]: self.settings_card, self.plan_card, self.run_button, + self.run_blocker, self.progress, self.run_status, self.run_lock.card, diff --git a/src/morphobricks_preprocessing/notebook_style.py b/src/morphobricks_preprocessing/notebook_style.py index 9e5712b..f2ef6cc 100644 --- a/src/morphobricks_preprocessing/notebook_style.py +++ b/src/morphobricks_preprocessing/notebook_style.py @@ -41,6 +41,10 @@ margin: 8px 0; overflow-wrap: anywhere; } .mb-status ul { margin: 4px 0 0 0; padding-left: 20px; } .mb-status details { margin-top: 4px; } +.mb-paths { margin: 2px 0 2px 4px; max-height: 190px; overflow-y: auto; } +.mb-paths div { padding: 1px 0; overflow-wrap: anywhere; } +.mb-blocker { color: #92400E; font-size: 13px; line-height: 1.4; margin: 2px 0 6px 0; + overflow-wrap: anywhere; } .mb-next { background: #EFF6FF; border: 2px solid #93C5FD; border-radius: 8px; padding: 14px 16px; margin: 12px 0; overflow-wrap: anywhere; } .mb-next-title { color: #1E40AF; font-size: 16px; font-weight: 700; margin-bottom: 6px; } @@ -91,6 +95,7 @@ _STATUS_CLASSES = {"ok": "mb-ok", "info": "mb-info", "warn": "mb-warn", "error": "mb-error"} _MAX_LIST = 20 +_PATHS_OPEN = "
" def page(title: str, subtitle: str, children: Sequence[widgets.Widget]) -> widgets.VBox: @@ -161,7 +166,13 @@ def status(kind: str, title: str, lines: Iterable[str] = (), *, raw: bool = Fals raw Treat ``lines`` as HTML instead of plain text. """ - items = [line if raw else html.escape(line) for line in lines] + items: list[str] = [] + for line in lines: + text = line if raw else html.escape(line) + if raw and items and text.startswith(_PATHS_OPEN): + items[-1] += text # a list of paths belongs to the line before it: no bullet of its own + else: + items.append(text) body = f"" if items else "" return f"
{html.escape(title)}{body}
" @@ -183,15 +194,18 @@ def next_steps(action: str, detail: str = "") -> str: def path_list(paths: Sequence[str], limit: int = _MAX_LIST) -> str: - """HTML list of paths; long lists are folded behind a "show all" toggle.""" - escaped = [f"{html.escape(p)}" for p in paths] - if len(escaped) <= limit: - return "" - shown = "".join(f"
  • {p}
  • " for p in escaped[:limit]) - rest = "".join(f"
  • {p}
  • " for p in escaped[limit:]) + """HTML list of paths, one per line; long lists are folded behind a "show more" toggle. + + The paths are plain lines in a box, never list items: a list nested in a status card's + bullet drew an empty bullet before it, and the folded part drew bullets of its own. + """ + lines = [f"
    {html.escape(p)}
    " for p in paths] + shown = f"{_PATHS_OPEN}{''.join(lines[:limit])}
    " + if len(lines) <= limit: + return shown return ( - f"
    Show {len(escaped) - limit:,} more" - f"
    " + f"{shown}
    Show {len(lines) - limit:,} more" + f"{_PATHS_OPEN}{''.join(lines[limit:])}
    " ) diff --git a/src/morphobricks_preprocessing/notebook_ui.py b/src/morphobricks_preprocessing/notebook_ui.py index ad250ec..01f4017 100644 --- a/src/morphobricks_preprocessing/notebook_ui.py +++ b/src/morphobricks_preprocessing/notebook_ui.py @@ -392,6 +392,7 @@ def run_step(self) -> widgets.Widget: self.settings_card, self.plan_card, self.run_button, + self.run_blocker, self.progress, self.run_status, self.run_lock.card, diff --git a/tests/test_notebook_owner_fixes.py b/tests/test_notebook_owner_fixes.py new file mode 100644 index 0000000..5c2f68e --- /dev/null +++ b/tests/test_notebook_owner_fixes.py @@ -0,0 +1,256 @@ +"""Fixes from the owner's test of the v0.5.0 notebook (10 November 2026). + +- (g) A list of paths in a status card draws no empty bullet, folded or not. +- (i) Choosing what a condition is compared against keeps the dropdown on the page. +- (j) Every disabled action says, in a visible line beside it, what blocks it. +""" + +import re + +import numpy as np +import pytest +import tifffile + +from test_notebook_check_save import answer_all, exp # noqa: F401 + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +from morphobricks_preprocessing import notebook_style as ui # noqa: E402 +from morphobricks_preprocessing.notebook_render import blocked_line, issues_html # noqa: E402 +from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 +from morphobricks_preprocessing.validation import error # noqa: E402 +from test_notebook_axes_quick import stepper # noqa: E402 + + +def _text(html): + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html)).strip() + + +# --- (g) the list of paths ----------------------------------------------------------------------- + + +def _empty_items(html): + """Return the list items that hold no text of their own before a nested block or the end.""" + return re.findall(r"
  • \s*(?:
  • |.tif" for i in range(count)] + html = issues_html([error("different_channel_counts", "Counts differ.", paths, action="Fix.")]) + assert not _empty_items(html) + assert ")", html, flags=re.S) + assert all(_text(item) for item in bullets) # every bullet says something + + +def test_a_path_list_alone_keeps_its_fold_and_no_list_markup(): + html = ui.path_list([f"p{i}" for i in range(7)], limit=5) + assert "p") == 7 and "Show 2 more" in html + assert "") == 2 and not _empty_items(html) + assert ui.status("warn", "T", [ui.path_list(["a"])], raw=True).count("
  • ") == 1 + + +# --- (i) the "compared against" dropdown --------------------------------------------------------- + + +def test_choosing_a_reference_keeps_the_dropdown_and_shows_the_choice_at_once(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + boxes = dict(nb.reference_boxes) + rows = nb.references_grid.children + nb.reference_boxes["drug"].value = "ctrl" + assert ( + nb.reference_boxes["drug"] is boxes["drug"] and nb.reference_boxes["ctrl"] is boxes["ctrl"] + ) + assert nb.references_grid.children == rows # the same rows: nothing was rebuilt + assert boxes["drug"].value == "ctrl" + assert nb.draft_answers.conditions["drug"].reference == "ctrl" # saved as before + assert "the reference of drug" in nb.reference_notes["ctrl"].value + nb.reference_boxes["ctrl"].value = "" + assert nb.reference_boxes["ctrl"] is boxes["ctrl"] and boxes["ctrl"].value == "" + assert nb.draft_answers.conditions["ctrl"].reference == "" + + +def test_a_refused_reference_is_shown_and_the_dropdown_goes_back_in_place(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + boxes = dict(nb.reference_boxes) + nb.reference_boxes["drug"].value = "ctrl" + nb.reference_boxes["ctrl"].value = "drug" # a loop: refused by the draft's own rule + assert "This answer was not taken" in nb.references_status.value + assert nb.reference_boxes["ctrl"] is boxes["ctrl"] + assert boxes["ctrl"].value is None and boxes["drug"].value == "ctrl" + assert nb.draft_answers.conditions.get("ctrl") is None or ( + nb.draft_answers.conditions["ctrl"].reference is None + ) + + +def test_typing_a_condition_id_relabels_the_references_in_place(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + box = nb.reference_boxes["drug"] + box.value = "ctrl" + nb.id_inputs[("conditions", "ctrl")].value = "CONTROL" + assert nb.reference_boxes["drug"] is box and box.value == "ctrl" + assert any("CONTROL" in label for label, _ in box.options) + + +def test_a_new_check_with_other_conditions_builds_new_rows(exp, tmp_path): # noqa: F811 + nb = stepper(*exp) + old = dict(nb.reference_boxes) + other = tmp_path / "other" + for folder in ("a/rep1", "b/rep1", "c/rep1"): + (other / folder).mkdir(parents=True) + data = np.zeros((2, 8, 8), np.uint8) + tifffile.imwrite(other / folder / "x.tif", data, metadata={"axes": "CYX"}) + nb.masks_folder.value = "" + nb.folder.value = str(other) + nb.output.value = str(tmp_path / "out2") + nb.check_button.click() + assert sorted(nb.reference_boxes) == ["a", "b", "c"] + assert not set(map(id, nb.reference_boxes.values())) & set(map(id, old.values())) + + +# --- (j) what blocks a disabled action ----------------------------------------------------------- + + +def test_blocked_line_is_empty_without_reasons_and_escapes(): + assert blocked_line([]) == "" + line = blocked_line(["", "second"]) + assert "Not available yet" in line and "<first>; second." in line + + +def _mixed_channels(tmp_path): + root = tmp_path / "exp" + for folder, channels in (("ctrl/rep1", 3), ("ctrl/rep2", 4), ("drug/rep1", 3)): + path = root / folder / "a.tif" + path.parent.mkdir(parents=True) + data = np.zeros((channels, 16, 16), np.uint16) + tifffile.imwrite(path, data, photometric="minisblack", metadata={"axes": "CYX"}) + nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out")) + nb.steps() + nb.check_button.click() + return nb + + +def test_confirm_axis_roles_says_that_different_channel_counts_block_it(tmp_path): + nb = _mixed_channels(tmp_path) # the owner's data: 3 and 4 channels + nb.show_preview() + assert nb.confirm_button.disabled + said = _text(nb.confirm_blocker.value) + assert "Not available yet" in said and "different channel counts" in said + assert "red message above" in said and "preview is not ticked" in said + nb.preview_checked.button.click() + assert nb.confirm_button.disabled # the refusal stays: ticking the preview does not lift it + said = _text(nb.confirm_blocker.value) + assert "different channel counts" in said and "preview" not in said + assert nb.confirmed_mapping is None + + +def test_confirm_axis_roles_names_the_missing_preview_then_the_missing_tick(tmp_path): + root = tmp_path / "exp" + for folder in ("ctrl/rep1", "drug/rep1"): + (root / folder).mkdir(parents=True) + tifffile.imwrite( + root / folder / "a.tif", np.zeros((2, 8, 8), np.uint8), metadata={"axes": "TYX"} + ) + nb = PreprocessingNotebook(str(root)) + nb.folder_step() + nb.axis_step() + nb.check_button.click() + assert nb.confirm_button.disabled and nb.preview_checked.disabled + assert "click Show preview" in _text(nb.confirm_blocker.value) + assert "click Show preview" in _text(nb.preview_blocker.value) + nb.show_preview() + assert nb.preview_blocker.value == "" and not nb.preview_checked.disabled + assert "click Yes, the preview looks right" in _text(nb.confirm_blocker.value) + nb.preview_checked.button.click() + assert not nb.confirm_button.disabled and nb.confirm_blocker.value == "" + nb.confirm_button.click() + assert nb.confirmed_mapping is not None and nb.confirm_blocker.value == "" + nb.confirm_button.click() # Change axis roles: the tick is kept, the preview still drawn + assert nb.confirmed_mapping is None + + +def test_roles_that_cannot_be_a_mapping_block_the_confirmation_visibly(tmp_path): + root = tmp_path / "exp" + (root / "ctrl").mkdir(parents=True) + tifffile.imwrite( + root / "ctrl" / "a.tif", np.zeros((2, 8, 8), np.uint8), metadata={"axes": "TYX"} + ) + nb = PreprocessingNotebook(str(root)) + nb.check_button.click() + nb.show_preview() + nb.preview_checked.button.click() + assert not nb.confirm_button.disabled + nb.role_dropdowns[0].value = "X" # two X: not a mapping + assert nb.confirm_button.disabled + assert "these roles have an error" in _text(nb.confirm_blocker.value) + + +def test_the_run_button_says_what_is_still_to_do(exp): # noqa: F811 + nb = stepper(*exp) + assert nb.run_button.disabled + said = _text(nb.run_blocker.value) + assert ( + said.startswith("🔒 Not available yet: still to do: ") + and "more (see Before you run)" in said + ) + nb.quick_confirm.click() + answer_all(nb) + assert nb.save_experiment() + assert not nb.run_button.disabled and nb.run_blocker.value == "" + + +def test_the_save_button_says_how_many_answers_are_missing(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + assert nb.save_button.disabled + assert re.search(r"\d+ answers? still missing", _text(nb.save_blocker.value)) + answer_all(nb) + assert not nb.save_button.disabled and nb.save_blocker.value == "" + assert nb.save_experiment() + assert nb.save_button.disabled and nb.save_blocker.value == "" # saved: nothing is blocked + + +def test_the_stains_confirmation_says_when_no_label_is_typed(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + labels = nb.channel_labels + for row in labels.rows: + row.id_box.value = "" + assert labels.confirm.disabled and "no label is typed" in _text(labels.problem.value) + labels.rows[0].id_box.value = "DNA" + assert not labels.confirm.disabled and "no label" not in labels.problem.value + + +def test_the_named_axis_copies_button_says_what_it_waits_for(tmp_path): + root = tmp_path / "exp" + (root / "ctrl").mkdir(parents=True) + data = np.zeros((3, 16, 16), np.uint16) + tifffile.imwrite(root / "ctrl" / "a.tif", data, photometric="minisblack", metadata=None) + nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out")) + nb.check_button.click() + nb.role_dropdowns[0].value = "C" + assert ui.is_visible(nb.convert_card) and nb.convert_button.disabled + assert "roles of the copies are not confirmed" in _text(nb.convert_blocker.value) + assert "preview is not ticked" in _text(nb.convert_tick_blocker.value) + nb.show_preview() + nb.preview_checked.button.click() + assert nb.convert_tick_blocker.value == "" + nb.convert_roles_checked.button.click() + assert "no folder for the copies is given" in _text(nb.convert_blocker.value) + nb.convert_folder.value = str(tmp_path / "copies") + assert not nb.convert_button.disabled and nb.convert_blocker.value == ""