diff --git a/AGENTS.md b/AGENTS.md
index a0635600..c26d56fb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -311,6 +311,7 @@ src/morphobricks_preprocessing/
and the overview; notebook_step_states.py each step's done/one-line state
notebook_names.py the names card's rows (conditions on step 4, replicates on step 5)
notebook_axes_quick.py the stepper's one-click Confirm these axes (Q3, PR 17b)
+ notebook_row_fold.py reopen: unchanged, answered rows fold; new or changed rows orange
notebook_channels_rows.py the tag grid's folder rows: pictures (colour for RGB), Enlarge
notebook_folder.py step 1: folder, check, issues, labels (_FolderStep)
notebook_design.py step 1: the experiment design card (_DesignCard)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 514d68ca..28c35d4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,14 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the
## Unreleased
+- **Notebook: unchanged rows fold on reopen (PR 17b-2).** When a saved experiment is reopened, a
+ row whose folders are found exactly as saved and whose question is answered folds behind one line
+ ("3 unchanged folders, answered as saved") with an **open** toggle: the pairing, the condition,
+ replicate and technical-repeat names, the image types, the tag blocks and the replicate blocks of
+ *Check and save*. A row with a new folder, a folder whose image count changed, or one whose
+ header channels no longer fit the saved channels, stays open and is marked orange ("new since
+ saved", "changed since saved"); a row with a warning never folds. Display only
+ (`notebook_row_fold`): the answers, the draft, the save and the run are unchanged.
- **Notebook: tags per header block, colour pictures, one-click axes (PR 17b-1).** *Tag channels*
shows folders whose image headers are identical, exactly (channel count, every raw header name
present and equal, colour flag), as one block tagged once for all its folders (owner decision Q5),
diff --git a/pyproject.toml b/pyproject.toml
index 876c5cfb..98bfc52e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -153,6 +153,7 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.notebook_description_saved" = "ui"
"morphobricks_preprocessing.notebook_description_view" = "ui"
"morphobricks_preprocessing.notebook_names" = "ui"
+"morphobricks_preprocessing.notebook_row_fold" = "ui"
"morphobricks_preprocessing.notebook_step_states" = "ui"
"morphobricks_preprocessing.notebook_stepper" = "ui"
"morphobricks_preprocessing.notebook_steps" = "ui"
diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py
index c6b69e39..24f15c8e 100644
--- a/src/morphobricks_preprocessing/notebook_channels_grid.py
+++ b/src/morphobricks_preprocessing/notebook_channels_grid.py
@@ -24,7 +24,7 @@
from __future__ import annotations
import html
-from collections.abc import Callable, Sequence
+from collections.abc import Callable, Mapping, Sequence
import ipywidgets as widgets
@@ -50,6 +50,7 @@
columns,
quoted,
)
+from .notebook_row_fold import RowFold, row_status
from .notebook_values import natural_key
@@ -90,6 +91,9 @@ def __init__(
self.split_buttons: dict[UnitKey, widgets.Button] = {}
# Folders the user took out of their block (this session; never saved).
self.alone: set[UnitKey] = set()
+ # The status of each unit against a saved experiment (None without one): rows fold.
+ self.reopen: Callable[[], Mapping[UnitKey, str] | None] = lambda: None
+ self.fold = RowFold("folder block")
self.message = ""
self._building = False
self.blocks = widgets.VBox()
@@ -265,13 +269,23 @@ def refresh(self) -> None:
)
blocks = self.block_list()
shared = [b[0].signature for b in blocks if named(b[0])]
- self.blocks.children = [header] + [
- self._block(units, width, shared.count(units[0].signature) > 1) for units in blocks
+ built = [
+ (
+ self._block(units, width, shared.count(units[0].signature) > 1),
+ self._status(units),
+ )
+ for units in blocks
]
+ self.blocks.children = [header, *self.fold.arrange(built)]
finally:
self._building = False
self._summary()
+ def _status(self, units: Sequence[UnitLayout]) -> str:
+ """Return whether a block's folders are as saved; it folds once fully answered."""
+ answered = remaining(units, self.store.answers().units, self.labels).done
+ return row_status(self.reopen(), [u.key for u in units], answered)
+
def _block(self, units: Sequence[UnitLayout], width: int, apart: bool) -> widgets.Widget:
"""Build one block: what its header says, its answer, and the rows of its folders."""
first = units[0]
diff --git a/src/morphobricks_preprocessing/notebook_channels_type.py b/src/morphobricks_preprocessing/notebook_channels_type.py
index 2ebc5ea5..26a0abec 100644
--- a/src/morphobricks_preprocessing/notebook_channels_type.py
+++ b/src/morphobricks_preprocessing/notebook_channels_type.py
@@ -12,7 +12,7 @@
from __future__ import annotations
import html
-from collections.abc import Callable, Sequence
+from collections.abc import Callable, Mapping, Sequence
import ipywidgets as widgets
@@ -23,6 +23,7 @@
from .notebook_channel_thumbs import Thumbnails
from .notebook_channels_rows import picture
from .notebook_channels_view import AnswerStore, blank_chip, quoted, unit_name
+from .notebook_row_fold import RowFold, row_status
from .notebook_values import natural_key
OPTIONS = [("separate channels", "channels"), ("colour (RGB)", "rgb")]
@@ -63,12 +64,12 @@ def cell(self, layout: UnitLayout) -> widgets.Widget:
options=OPTIONS, value=self.shown(layout), layout=widgets.Layout(width="99%")
)
radio.disabled = not layout.usable
- note = widgets.HTML(self._note(layout))
+ note = widgets.HTML(self.note(layout))
radio.observe(lambda change, u=layout: self._chosen(u, change["new"]), names="value")
self.radios[layout.key], self.notes[layout.key] = radio, note
return widgets.VBox([radio, note])
- def _note(self, layout: UnitLayout) -> str:
+ def note(self, layout: UnitLayout) -> str:
"""Return what is said under a unit's radio buttons."""
saved, shown = self.saved(layout.key), self.shown(layout)
if not layout.usable:
@@ -145,6 +146,9 @@ def __init__(
self, types: ImageTypeColumn, thumbs: Callable[[], Thumbnails | None] = lambda: None
) -> None:
self.types, self._thumbs = types, thumbs
+ # The status of each unit against a saved experiment (None without one): rows fold.
+ self.reopen: Callable[[], Mapping[UnitKey, str] | None] = lambda: None
+ self.fold = RowFold("folder")
self.rows = widgets.VBox()
self.status = widgets.HTML()
self.problem = widgets.HTML()
@@ -183,7 +187,10 @@ def refresh(self) -> None:
"""Rebuild the rows from the saved answers."""
self.types.open(self.types.layouts) # a fresh set of cells, built from the answers
ordered = sorted(self.types.layouts, key=lambda u: tuple(map(natural_key, u.key)))
- rows = [self._row(u) for u in ordered]
+ units = self.reopen()
+ rows = self.fold.arrange(
+ [(self._row(u), row_status(units, [u.key], self._settled(u))) for u in ordered]
+ )
proposed = [u.key for u in self.types.layouts if self.types.proposed(u)]
if proposed:
button = ui.button(
@@ -203,6 +210,11 @@ def refresh(self) -> None:
else ui.status("warn", f"Not finished: {usable - done} image type(s) to choose.")
)
+ def _settled(self, layout: UnitLayout) -> bool:
+ """Return True when a row may fold: answered, with no warning under its choice."""
+ saved = self.types.saved(layout.key) is not None
+ return layout.usable and saved and not self.types.note(layout)
+
def _confirm(self, keys: Sequence[UnitKey]) -> None:
self.types.confirm_header(keys)
diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py
index 13f3a5c1..4c193fde 100644
--- a/src/morphobricks_preprocessing/notebook_check_save.py
+++ b/src/morphobricks_preprocessing/notebook_check_save.py
@@ -54,6 +54,7 @@
todo_html,
)
from .notebook_description import _with_ids
+from .notebook_row_fold import RowFold, row_status
from .run import OutputLockedError
from .run.description_file import PublicationRefused
from .run.draft_file import publish_draft
@@ -240,6 +241,7 @@ def _candidate_check(self, answers: object) -> CandidateCheck | None:
def _show_replicates(self, applied: object, answers: object, labels: list[str]) -> None:
"""One box per replicate: folders, pictures, channels, and its own question."""
boxes, toggles = [], {}
+ units = self._reopen_units()
for view in replicates(applied, answers):
toggle = ui.ConfirmToggle(
SAME_WAY,
@@ -261,16 +263,20 @@ def _show_replicates(self, applied: object, answers: object, labels: list[str])
for m in view.members
]
body = [widgets.HTML(replicate_title(view)), *members, widgets.HTML(missing_html(view))]
- boxes.append(
- widgets.VBox(
- [*body, toggle.widget],
- layout=widgets.Layout(
- border="1px solid #E2E8F0", padding="8px 12px", margin="0 0 10px 0"
- ),
- )
+ box = widgets.VBox(
+ [*body, toggle.widget],
+ layout=widgets.Layout(
+ border="1px solid #E2E8F0", padding="8px 12px", margin="0 0 10px 0"
+ ),
)
+ keys = [u.key for m in view.members for u in m.units]
+ # A box with a warning (an acquisition lacking a label) never folds.
+ settled = view.confirmed_on is not None and not view.missing
+ boxes.append((box, row_status(units, keys, settled)))
self.same_way = toggles
- self.replicates_box.children = boxes
+ if not hasattr(self, "_replicate_fold"):
+ self._replicate_fold = RowFold("replicate")
+ self.replicates_box.children = self._replicate_fold.arrange(boxes)
def _member_picture(self, key: tuple[str, str]) -> widgets.Widget:
"""Return the picture of a folder's first image (channel 1, or in colour when RGB)."""
diff --git a/src/morphobricks_preprocessing/notebook_described.py b/src/morphobricks_preprocessing/notebook_described.py
index a1819d9a..3d9f77aa 100644
--- a/src/morphobricks_preprocessing/notebook_described.py
+++ b/src/morphobricks_preprocessing/notebook_described.py
@@ -24,6 +24,7 @@
from __future__ import annotations
+from collections.abc import Mapping
from dataclasses import replace
from datetime import date
from pathlib import Path
@@ -35,8 +36,9 @@
from .design_derive import derive_design
from .design_edit import DesignEdit, DesignEditError, apply_design_edit
from .draft import Draft, DraftError, draft_from_discovery
-from .draft_answers import DraftAnswers, apply_answers, carry_over
+from .draft_answers import DraftAnswers, UnitKey, apply_answers, carry_over
from .fingerprint import current_fingerprint
+from .notebook_row_fold import reopen_units
from .published_answers import published_answers, reopened, retired_ids
from .run import Preflight
@@ -66,6 +68,11 @@ def _published(self) -> PublishedState | None:
)
return self._published_cache
+ def _reopen_units(self) -> Mapping[UnitKey, str] | None:
+ """Return each unit's status against the saved file (None without one): rows fold."""
+ state = self._published() if self._describe else None
+ return None if state is None else reopen_units(state.reconciliation)
+
def _forget_published(self) -> None:
self._published_key = None
diff --git a/src/morphobricks_preprocessing/notebook_design.py b/src/morphobricks_preprocessing/notebook_design.py
index 681c3b2a..2c026496 100644
--- a/src/morphobricks_preprocessing/notebook_design.py
+++ b/src/morphobricks_preprocessing/notebook_design.py
@@ -23,6 +23,7 @@
from .discovery import check_output_folder, default_output_folder
from .notebook_options import _PAIRING_COLUMNS
from .notebook_render import _group_explanation, _group_question
+from .notebook_row_fold import RowFold, row_status
from .run import OutputLockedError
from .run.design_file import write_design
from .run.output_guard import OutputInsideMasksError
@@ -224,6 +225,8 @@ def _build_design_editor(self) -> None:
layout=_PAIRING_COLUMNS,
)
]
+ units = self._reopen_units()
+ folded: list[tuple[widgets.Widget, str]] = []
for row in design.rows if pairing else ():
dropdown = widgets.Dropdown(
options=options, value=row.repeat_group, layout=widgets.Layout(width="99%")
@@ -231,7 +234,8 @@ def _build_design_editor(self) -> None:
dropdown.observe(
lambda change, key=row.key: self._pair(key, change["new"]), names="value"
)
- rows.append(
+ keys = [k for k in units or () if k[:2] == row.key]
+ folded.append((
widgets.GridBox(
[
widgets.HTML(html.escape(row.condition)),
@@ -243,9 +247,12 @@ def _build_design_editor(self) -> None:
dropdown,
],
layout=_PAIRING_COLUMNS,
- )
- )
- self.pairing_grid.children = rows
+ ),
+ row_status(units, keys, answered=True),
+ )) # fmt: skip
+ if not hasattr(self, "_pairing_fold"):
+ self._pairing_fold = RowFold("folder")
+ self.pairing_grid.children = rows + self._pairing_fold.arrange(folded)
self.control_checks = {}
for condition in design.conditions:
check = widgets.Checkbox(
diff --git a/src/morphobricks_preprocessing/notebook_names.py b/src/morphobricks_preprocessing/notebook_names.py
index 92e5e858..d500f3bc 100644
--- a/src/morphobricks_preprocessing/notebook_names.py
+++ b/src/morphobricks_preprocessing/notebook_names.py
@@ -27,8 +27,16 @@
text_box,
why_no_proposal,
)
+from .notebook_row_fold import RowFold, row_status
from .notebook_values import natural_key
+FOLD_WORDS = {
+ "conditions": "condition",
+ "repeats": "replicate",
+ "technical_repeats": "technical-repeat folder",
+}
+_KEY_PART = {"conditions": 0, "repeats": 1, "technical_repeats": 2}
+
def _natural(item: tuple[str, object]) -> tuple:
"""Return the display order of a replicate's row: its folder label, numbers as numbers."""
@@ -83,8 +91,14 @@ def _build_names(self) -> None:
rows: list[widgets.Widget] = [
header_row(title, "ID", "Shown as (optional)" if asks_name else "", "")
]
- for entry in shown:
- rows += self._entry_row(entry, typed, asks_name=asks_name)
+ entry_rows = [
+ (
+ widgets.VBox(self._entry_row(entry, typed, asks_name=asks_name)),
+ self._entry_status(entry),
+ )
+ for entry in shown
+ ]
+ rows += self._name_fold(kind).arrange(entry_rows)
blocks.append(widgets.VBox(rows))
kinds.append(kind)
self.single_note.value = single_note(entries)
@@ -93,6 +107,26 @@ def _build_names(self) -> None:
self.condition_rows.children = [b for k, b in pairs if k == "conditions"]
self.replicate_rows.children = [b for k, b in pairs if k != "conditions"]
+ def _name_fold(self, kind: str) -> RowFold:
+ """Return the fold of one kind of names (kept while the notebook lives)."""
+ if not hasattr(self, "_name_folds"):
+ self._name_folds: dict[str, RowFold] = {}
+ return self._name_folds.setdefault(kind, RowFold(FOLD_WORDS[kind]))
+
+ def _entry_status(self, entry: Entry) -> str:
+ """Return whether a name's folders are as saved (it folds once its ID is given)."""
+ keys = [r for r in self._unit_keys() if r[_KEY_PART[entry.kind]] == entry.key]
+ if entry.kind == "repeats":
+ repeat = self.draft.repeats[entry.key]
+ keys = [(m.condition, m.source_repeat, u.source_folder) for m in repeat.members
+ for u in m.units] # fmt: skip
+ return row_status(self._reopen_units(), keys, entry.slot.confirmed is not None)
+
+ def _unit_keys(self) -> list[tuple[str, str, str]]:
+ return [
+ (r.condition, r.biological_repeat, r.technical_repeat) for r in self.draft.unit_rows()
+ ]
+
def _entry_row(self, entry: Entry, typed: dict, *, asks_name: bool) -> list[widgets.Widget]:
"""Build the grid row of one entry (and why it has no proposal, if so)."""
key = (entry.kind, entry.key)
diff --git a/src/morphobricks_preprocessing/notebook_row_fold.py b/src/morphobricks_preprocessing/notebook_row_fold.py
new file mode 100644
index 00000000..a77f80c4
--- /dev/null
+++ b/src/morphobricks_preprocessing/notebook_row_fold.py
@@ -0,0 +1,132 @@
+"""Folding the unchanged rows of a reopened experiment (owner decision, UX proposal 2026-11-09).
+
+When a saved experiment is reopened, a row whose folders are found exactly as saved and whose
+question is already answered folds behind one line ("4 unchanged folders, answered as saved")
+with an **open** toggle. A row with a new folder, or a folder whose image count changed, stays
+open and is marked orange. Without a saved experiment nothing folds. Display only: a folded row
+keeps its widgets and answers, and opening it shows them as they are.
+
+:func:`reopen_units` reads the status of every unit from the reconciliation of the published
+file with the folders (units matched by their exact folder labels); :func:`row_status` gives a
+row's status from the units it holds; :class:`RowFold` arranges a card's rows.
+"""
+
+from __future__ import annotations
+
+import html
+from collections.abc import Iterable, Mapping, Sequence
+
+import ipywidgets as widgets
+
+from . import notebook_style as ui
+from .description_reconcile import Reconciliation, Status
+from .draft_answers import UnitKey
+from .notebook_channels_view import ORANGE, ORANGE_FILL
+from .validation import plural
+
+UNCHANGED, NEW, CHANGED = "unchanged", "new", "changed"
+WORDS = {NEW: "new since saved", CHANGED: "changed since saved"}
+
+
+def reopen_units(rec: Reconciliation | None) -> Mapping[UnitKey, str] | None:
+ """Return each unit's status against the saved file, or None without a saved file.
+
+ ``unchanged`` for a unit found as saved, ``changed`` for one whose member's image count
+ changed or that the reconciliation gives a reason for (its header's channels no longer fit
+ the saved channels, or its folder was renamed), ``new`` for a unit the saved file does not
+ have.
+ """
+ if rec is None:
+ return None
+ counted = {(c.repeat_id, c.condition_id) for c in rec.counts}
+ out: dict[UnitKey, str] = {}
+ for unit in rec.units:
+ key = (unit.folders.condition, unit.folders.source_repeat, unit.folders.technical)
+ if unit.status == Status.NEW:
+ out[key] = NEW
+ elif unit.status == Status.UNCHANGED:
+ changed = (unit.repeat_id, unit.condition_id) in counted or bool(unit.reasons)
+ out[key] = CHANGED if changed else UNCHANGED
+ return out
+
+
+def row_status(units: Mapping[UnitKey, str] | None, keys: Iterable[UnitKey], answered: bool) -> str:
+ """Return a row's status: empty without a saved file, else unchanged, new or changed.
+
+ A row is ``unchanged`` (it folds) only when every unit it holds is unchanged and its
+ question is answered; an unknown unit counts as new.
+ """
+ if units is None:
+ return ""
+ found = [units.get(key, NEW) for key in keys]
+ if not found:
+ return ""
+ if all(s == UNCHANGED for s in found):
+ return UNCHANGED if answered else ""
+ return NEW if NEW in found else CHANGED
+
+
+def mark(widget: widgets.Widget, status: str) -> widgets.Widget:
+ """Return a row marked orange with what changed, or the row itself."""
+ if status not in WORDS:
+ return widget
+ word = widgets.HTML(
+ f""
+ f"{html.escape(WORDS[status])}"
+ )
+ return widgets.VBox(
+ [word, widget],
+ layout=widgets.Layout(border_left=f"3px solid {ORANGE}", padding="0 0 0 8px"),
+ )
+
+
+class RowFold:
+ """One card's fold: the unchanged rows behind a line and an open toggle.
+
+ Once opened it stays open for the card's later rebuilds.
+ """
+
+ def __init__(self, what: str) -> None:
+ # ``what`` is the singular noun of a row ("folder"); the line says "3 unchanged folders".
+ self.what = what
+ self.opened = False
+ self.toggle = ui.button(" open", icon="chevron-down", tooltip="Show the unchanged rows")
+ self.toggle.on_click(lambda _: self._flip())
+ self.folded = widgets.VBox()
+ self.line = widgets.HTML()
+ self.bar = widgets.HBox(
+ [self.line, self.toggle], layout=widgets.Layout(align_items="center")
+ )
+ self.count = 0
+
+ def _flip(self) -> None:
+ self.opened = not self.opened
+ self._show()
+
+ def _show(self) -> None:
+ ui.set_visible(self.folded, self.opened)
+ self.toggle.description = " close" if self.opened else " open"
+ self.toggle.icon = "chevron-up" if self.opened else "chevron-down"
+
+ def arrange(self, rows: Sequence[tuple[widgets.Widget, str]]) -> list[widgets.Widget]:
+ """Return the rows to show, then the fold line and the unchanged rows under it.
+
+ The other rows keep their order; new or changed ones are marked orange.
+ """
+ shown = [mark(row, status) for row, status in rows if status != UNCHANGED]
+ folded = [row for row, status in rows if status == UNCHANGED]
+ self.count = len(folded)
+ if not folded:
+ return shown
+ self.folded.children = folded
+ self.line.value = (
+ f"✓ "
+ f"{html.escape(plural(len(folded), 'unchanged ' + self.what))} "
+ "answered as saved"
+ )
+ self._show()
+ return [*shown, self.bar, self.folded]
+
+
+__all__ = ["CHANGED", "NEW", "UNCHANGED", "RowFold", "mark", "reopen_units", "row_status"]
diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py
index 72e63957..e14e3252 100644
--- a/src/morphobricks_preprocessing/notebook_steps.py
+++ b/src/morphobricks_preprocessing/notebook_steps.py
@@ -79,6 +79,7 @@ def steps(self) -> widgets.Widget:
for box in (self.folder_next, self.axis_next):
ui.set_visible(box, False) # Next and the strip say where to go
self.type_rows = ImageTypeRows(self.channel_types, lambda: self._channel_thumbs)
+ self.type_rows.reopen = self.channel_grid.reopen = self._reopen_units
self.overview_box = widgets.VBox()
self._build_quick_axes()
self.consistency_slot = widgets.VBox([self.consistency_card])
diff --git a/tests/test_notebook_row_fold.py b/tests/test_notebook_row_fold.py
new file mode 100644
index 00000000..5cd7a15d
--- /dev/null
+++ b/tests/test_notebook_row_fold.py
@@ -0,0 +1,174 @@
+"""Reopening folds the unchanged, answered rows; new or changed ones stay open, in orange (17b-2).
+
+Owner decision (UX proposal 2026-11-09): inside the pairing, the names, the image types, the tag
+blocks and the replicate blocks of *Check and save*, a row whose folders are found exactly as
+saved and whose question is answered folds behind one line with an **open** toggle. Display
+only: the answers and the draft are those of an unfolded page.
+"""
+
+from dataclasses import replace
+
+import pytest
+
+from morphobricks_preprocessing.description_reconcile import (
+ CountChange,
+ Reconciliation,
+ Status,
+ UnitChange,
+ UnitFolders,
+)
+from morphobricks_preprocessing.notebook_row_fold import (
+ CHANGED,
+ NEW,
+ UNCHANGED,
+ reopen_units,
+ row_status,
+)
+from test_notebook_check_save import add_folder, answer_all
+
+pytest.importorskip("ipywidgets")
+pytest.importorskip("matplotlib")
+
+from morphobricks_preprocessing import notebook_style as ui # noqa: E402
+from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402
+
+
+def test_a_row_folds_only_when_every_folder_is_as_saved_and_it_is_answered():
+ rec = Reconciliation(
+ (
+ UnitChange(UnitFolders("ctrl", "rep1", "single"), Status.UNCHANGED, "rep1", "ctrl"),
+ UnitChange(UnitFolders("drug", "rep1", "single"), Status.UNCHANGED, "rep1", "drug"),
+ UnitChange(UnitFolders("drug", "rep2", "single"), Status.NEW),
+ UnitChange(UnitFolders("ctrl", "rep2", "single"), Status.UNCHANGED, "rep2", "ctrl"),
+ ),
+ {},
+ (CountChange("rep2", "ctrl", 2, 3),),
+ )
+ units = reopen_units(rec)
+ assert units == {
+ ("ctrl", "rep1", "single"): UNCHANGED,
+ ("drug", "rep1", "single"): UNCHANGED,
+ ("drug", "rep2", "single"): NEW,
+ ("ctrl", "rep2", "single"): CHANGED, # its image count changed
+ }
+ rep1 = [("ctrl", "rep1", "single"), ("drug", "rep1", "single")]
+ assert row_status(units, rep1, answered=True) == UNCHANGED
+ assert row_status(units, rep1, answered=False) == "" # still to answer: not folded
+ assert row_status(units, [("ctrl", "rep2", "single")], True) == CHANGED
+ assert row_status(units, [("ctrl", "rep2", "single"), ("drug", "rep2", "single")], True) == NEW
+ assert row_status(units, [("other", "x", "single")], True) == NEW # unknown: never folded
+ assert row_status(None, rep1, True) == "" and reopen_units(None) is None
+
+
+def stepper(root, masks, out):
+ nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True)
+ nb.welcome()
+ nb.steps()
+ nb.masks_folder.value = str(masks)
+ nb.check_button.click()
+ nb.quick_confirm.click()
+ return nb
+
+
+@pytest.fixture
+def reopened(tmp_path):
+ """Three folders saved, then drug/rep2 added: a changed reopen."""
+ root, masks, out = tmp_path / "exp", tmp_path / "masks", tmp_path / "out"
+ for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1"]):
+ add_folder(root, masks, folder, i + 1)
+ first = stepper(root, masks, out)
+ answer_all(first)
+ assert first.save_experiment()
+ add_folder(root, masks, "drug/rep2", 9)
+ return stepper(root, masks, out)
+
+
+def _text(widget):
+ out = getattr(widget, "value", "")
+ out = out if isinstance(out, str) else ""
+ return out + "".join(_text(c) for c in getattr(widget, "children", ()))
+
+
+def test_a_changed_reopen_folds_the_unchanged_rows_and_marks_the_new_ones(reopened):
+ nb = reopened
+ grid, types = nb.channel_grid, nb.type_rows
+ assert grid.fold.count == 1 and not ui.is_visible(grid.fold.folded)
+ assert "1 unchanged folder block<" in grid.fold.line.value
+ assert "new since saved" in _text(grid.blocks) # drug/rep2's block stays open
+ assert types.fold.count == 3 and "new since saved" in _text(types.rows)
+ conditions = nb._name_folds["conditions"]
+ assert conditions.count == 1 # ctrl; drug gained a folder
+ assert nb._name_folds["repeats"].count == 1 # rep1; rep2 gained drug/rep2
+ assert nb._replicate_fold.count == 1 # rep1 is asked nothing again
+ assert "new since saved" in _text(nb.replicates_box)
+
+
+def test_opening_a_fold_shows_the_rows_and_changes_no_answer(reopened):
+ nb = reopened
+ before = (nb.draft_answers, {k: d.value for k, d in nb.channel_grid.cells.items()})
+ nb.channel_grid.fold.toggle.click()
+ assert ui.is_visible(nb.channel_grid.fold.folded)
+ assert "close" in nb.channel_grid.fold.toggle.description
+ nb.channel_grid.refresh() # stays open while the page is rebuilt
+ assert ui.is_visible(nb.channel_grid.fold.folded)
+ assert (nb.draft_answers, {k: d.value for k, d in nb.channel_grid.cells.items()}) == before
+
+
+def test_an_unsaved_experiment_folds_nothing(tmp_path):
+ root, masks, out = tmp_path / "exp", tmp_path / "masks", tmp_path / "out"
+ for i, folder in enumerate(["ctrl/rep1", "drug/rep1"]):
+ add_folder(root, masks, folder, i + 1)
+ nb = stepper(root, masks, out)
+ answer_all(nb)
+ assert nb.channel_grid.fold.count == 0 and nb.type_rows.fold.count == 0
+ assert nb._replicate_fold.count == 0
+ assert "unchanged" not in _text(nb.channel_grid.blocks)
+
+
+def test_a_folder_whose_header_channels_changed_is_changed_not_folded():
+ # Regression (17b-2 review): the reconciliation's reasons were ignored, so a folder whose
+ # header names no longer fit the saved channels folded as "unchanged".
+ rec = Reconciliation(
+ (
+ UnitChange(UnitFolders("ctrl", "rep1", "single"), Status.UNCHANGED, "rep1", "ctrl"),
+ UnitChange(
+ UnitFolders("drug", "rep1", "single"),
+ Status.UNCHANGED,
+ "rep1",
+ "drug",
+ reasons=("channels_changed",),
+ ),
+ ),
+ {"rep1": ("channels_changed",)},
+ (),
+ )
+ units = reopen_units(rec)
+ assert units[("ctrl", "rep1", "single")] == UNCHANGED
+ assert units[("drug", "rep1", "single")] == CHANGED
+ assert row_status(units, [("drug", "rep1", "single")], answered=True) == CHANGED
+
+
+def test_a_row_with_a_warning_never_folds(reopened, monkeypatch):
+ # Regression (17b-2 review): a folded row hid the warning under an image type (a type the
+ # header contradicts) and the orange A_SCOPE_INCOMPLETE line of a replicate block.
+ nb = reopened
+ key = ("ctrl", "rep1", "single")
+ warn = "The header says colour (RGB)"
+ plain = nb.channel_types.note
+ monkeypatch.setattr(nb.channel_types, "note", lambda u: warn if u.key == key else plain(u))
+ nb.type_rows.refresh()
+ assert nb.type_rows.fold.count == 2 # ctrl/rep1 stays open, with its warning
+ assert "The header says colour (RGB)" not in _text(nb.type_rows.fold.folded)
+
+ from morphobricks_preprocessing import notebook_check_save
+
+ real = notebook_check_save.replicates
+
+ def lacking(applied, answers):
+ views = real(applied, answers)
+ return [replace(v, missing=((key, "DNA"),)) if v.repeat_id == "rep1" else v for v in views]
+
+ monkeypatch.setattr(notebook_check_save, "replicates", lacking)
+ nb._refresh_check_save()
+ assert nb._replicate_fold.count == 0
+ assert "A_SCOPE_INCOMPLETE" in _text(nb.replicates_box)