From fcda052723c5f2f927493239fdecd78fd1b92884 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 5 Jul 2026 22:32:56 -0500 Subject: [PATCH] feat: copy selected traces to other sections Add a "Copy to sections..." action that copies the selected trace(s) onto other sections at the same field location. Each trace's field coordinates are re-projected through every target section's own inverse transform (computed once per section), so the traces land at the identical field x-y regardless of how each section is aligned. Sections with a non-invertible transform are skipped and reported rather than written to. Trace attributes are preserved and the source (current) section is left unchanged. Target sections are chosen by number or inclusive range in a small picker dialog; the parser validates tokens without materializing typed ranges (so a huge upper bound cannot hang the UI) and reports requested sections that do not exist instead of silently dropping them. The action is available at the field context-menu top level (next to Copy) and in the trace list. Includes tests for the parser and the data-model copy operation. --- PyReconstruct/modules/datatypes/series.py | 59 ++- PyReconstruct/modules/gui/dialog/__init__.py | 3 +- .../modules/gui/dialog/copy_to_sections.py | 154 ++++++++ .../modules/gui/main/context_menu_list.py | 11 + .../modules/gui/main/field_widget_2_trace.py | 61 ++- tests/test_copy_traces_to_sections.py | 359 ++++++++++++++++++ 6 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 PyReconstruct/modules/gui/dialog/copy_to_sections.py create mode 100644 tests/test_copy_traces_to_sections.py diff --git a/PyReconstruct/modules/datatypes/series.py b/PyReconstruct/modules/datatypes/series.py index 0985b4d7..16fc010c 100644 --- a/PyReconstruct/modules/datatypes/series.py +++ b/PyReconstruct/modules/datatypes/series.py @@ -1173,7 +1173,64 @@ def copyObjects(self, obj_names: list, series_states=None, log_event=True) -> li self.modified = True return [f"{obj}_copy" for obj in obj_names] - + + def copyTracesToSections(self, traces : list, section_numbers, series_states=None, log_event=True): + """Copy traces into multiple sections at the same field (x, y) location. + + The traces' points must be given in FIELD coordinates (i.e. already + mapped through a section transform, the same form the clipboard/paste + path uses). Each target section stores the points through its own + inverse transform, so the traces land at the identical field x-y on + every section regardless of how each section is aligned. + + An alignment lock protects a section's transform, not its trace + content, so traces are copied onto every chosen section regardless of + its lock status (just as traces can be drawn on a locked section). + + Params: + traces (list): the traces to copy, points in field coordinates + section_numbers (iterable): the target section numbers + series_states (dict): section number : SectionStates (GUI undo) + log_event (bool): True if the trace creation should be logged + Returns: + (tuple): (list of section numbers that received the traces, + list of section numbers skipped because their + transform is not invertible) + """ + targets = set(section_numbers) + copied_to = [] + skipped = [] + + for snum, section in self.enumerateSections( + message="Copying traces to sections...", + series_states=series_states + ): + if snum not in targets: + continue + + # obtain this section's inverse transform ONCE; a singular + # (non-invertible) transform cannot place the trace, so skip the + # section rather than crash or store garbage points + try: + inv_tform = section.tform.inverted() + except Exception: + skipped.append(snum) + continue + + for trace in traces: + new_trace = trace.copy() + # re-project the shared field coordinates through this section's + # own inverse transform so the trace occupies the same field x-y + new_trace.points = [inv_tform.map(*p) for p in trace.points] + section.addTrace(new_trace, log_event=log_event) + section.save() + copied_to.append(snum) + + if copied_to: + self.modified = True + + return copied_to, skipped + def deleteAllTraces(self, trace_name : str, tags : set = None, series_states=None): """Delete all traces with a certain name and tag set. diff --git a/PyReconstruct/modules/gui/dialog/__init__.py b/PyReconstruct/modules/gui/dialog/__init__.py index 02890795..85d16595 100644 --- a/PyReconstruct/modules/gui/dialog/__init__.py +++ b/PyReconstruct/modules/gui/dialog/__init__.py @@ -19,4 +19,5 @@ from .backup_comment import BackupCommentDialog from .table_columns import TableColumnsDialog from .import_series import ImportSeriesDialog -from .malformed_contours import MalformedContoursDialog \ No newline at end of file +from .malformed_contours import MalformedContoursDialog +from .copy_to_sections import CopyToSectionsDialog \ No newline at end of file diff --git a/PyReconstruct/modules/gui/dialog/copy_to_sections.py b/PyReconstruct/modules/gui/dialog/copy_to_sections.py new file mode 100644 index 00000000..200b9081 --- /dev/null +++ b/PyReconstruct/modules/gui/dialog/copy_to_sections.py @@ -0,0 +1,154 @@ +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QLabel, + QLineEdit, + QDialogButtonBox, +) + +from PyReconstruct.modules.gui.utils import notify + + +def _to_int(token : str): + """Convert a token to an int, or return None if it is not a plain decimal + number. + + str.isdecimal() (not isdigit()) is the correct gate: isdigit() accepts + characters like superscripts ("5²") and circled digits that int() then + refuses, which would raise ValueError. The try/except covers any remaining + edge case so a weird token is reported instead of crashing. + """ + if not token.isdecimal(): + return None + try: + return int(token) + except ValueError: + return None + + +def parse_section_spec(text : str, valid_sections) -> tuple: + """Parse a section spec string into a set of section numbers. + + Accepts comma/space-separated tokens, each either a single number ("12") + or an inclusive range ("10-15"). Returns the parsed numbers that exist in + valid_sections, a list of tokens that were malformed or entirely out of + range, and a list describing requested sections that do not exist (e.g. a + range that overhangs the series), so nothing is silently dropped. + + Params: + text (str): the spec, e.g. "10-15, 20, 22" + valid_sections (iterable): the section numbers that exist + Returns: + (tuple): (set of valid section numbers, list of bad tokens, + list of descriptions of nonexistent requested sections) + """ + valid = set(valid_sections) + chosen = set() + bad = [] + missing = [] + + for token in text.replace(",", " ").split(): + if "-" in token.strip("-"): # a range like 10-15 + parts = token.split("-") + if len(parts) != 2: + bad.append(token) + continue + lo, hi = _to_int(parts[0]), _to_int(parts[1]) + if lo is None or hi is None: + bad.append(token) + continue + if lo > hi: + lo, hi = hi, lo + # intersect the range with the existing sections WITHOUT + # materializing it: iterating range(lo, hi + 1) would hang the UI + # on a huge upper bound like "1-999999999" + matched = {n for n in valid if lo <= n <= hi} + if not matched: + bad.append(token) + continue + chosen.update(matched) + missing_count = (hi - lo + 1) - len(matched) + if missing_count: + missing.append(f"{missing_count} section(s) in {token}") + else: # a single section number + n = _to_int(token) + if n is None: + bad.append(token) + continue + if n in valid: + chosen.add(n) + else: + bad.append(token) + + return chosen, bad, missing + + +class CopyToSectionsDialog(QDialog): + """Pick the target sections to copy the selected trace(s) onto.""" + + def __init__(self, parent, series): + super().__init__(parent) + self.series = series + self.valid_sections = set(series.sections.keys()) + self.sections = None + + current = series.current_section + self.smin, self.smax = min(self.valid_sections), max(self.valid_sections) + smin, smax = self.smin, self.smax + + self.setWindowTitle("Copy to sections") + + vlayout = QVBoxLayout() + + info = QLabel(self, text=( + "Copy the selected trace(s) onto other sections at the same " + "location.\n" + f"The current section ({current}) is left unchanged.\n" + f"Enter section numbers or ranges from {smin} to {smax}, " + "e.g. \"10-20\" or \"5, 8, 11\"." + )) + vlayout.addWidget(info) + + self.spec_input = QLineEdit(self) + self.spec_input.setPlaceholderText("e.g. 10-20 or 5, 8, 11") + vlayout.addWidget(self.spec_input) + + buttonbox = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttonbox.accepted.connect(self.accept) + buttonbox.rejected.connect(self.reject) + vlayout.addSpacing(10) + vlayout.addWidget(buttonbox) + + self.setLayout(vlayout) + + def accept(self): + chosen, bad, missing = parse_section_spec( + self.spec_input.text(), self.valid_sections + ) + if bad: + notify("Invalid or out-of-range section(s): " + ", ".join(bad)) + return + if missing: + notify( + "Some requested sections do not exist: " + + "; ".join(missing) + "\n" + f"Sections in this series range from {self.smin} to {self.smax}." + ) + return + if not chosen: + notify("Please enter at least one valid section.") + return + self.sections = chosen + super().accept() + + def get(self): + """Run the dialog. + + Returns: + (tuple): (set of section numbers, confirmed bool) + """ + if self.exec(): + return self.sections, True + return None, False diff --git a/PyReconstruct/modules/gui/main/context_menu_list.py b/PyReconstruct/modules/gui/main/context_menu_list.py index 8fac77c9..ba829a3d 100644 --- a/PyReconstruct/modules/gui/main/context_menu_list.py +++ b/PyReconstruct/modules/gui/main/context_menu_list.py @@ -43,6 +43,7 @@ def get_field_menu_list(self): None, self.cut_act, self.copy_act, + ("copytosections_act", "Copy to sections...", "", self.field.copyTracesToSections), self.paste_act, self.pasteattributes_act, None, @@ -163,6 +164,16 @@ def get_context_menu_list_trace(self, is_in_field=True): context_menu = [ ("edittrace_act", "Edit attributes...", sc, self.traceDialog), + ] + + # "Copy to sections..." lives at the field context-menu top level (next to + # "Copy") when invoked in the field; in the trace list it appears here. + if not is_in_field: + context_menu.append( + ("copytosections_act", "Copy to sections...", "", self.copyTracesToSections) + ) + + context_menu += [ None, ("smoothtraces_act", "Smooth traces", "", self.smoothTraces), ("mergetraces_act", "Merge traces", sc, self.mergeTraces), diff --git a/PyReconstruct/modules/gui/main/field_widget_2_trace.py b/PyReconstruct/modules/gui/main/field_widget_2_trace.py index e2edf754..3a6e805a 100644 --- a/PyReconstruct/modules/gui/main/field_widget_2_trace.py +++ b/PyReconstruct/modules/gui/main/field_widget_2_trace.py @@ -12,6 +12,7 @@ TraceDialog, ShapesDialog, ObjectGroupDialog, + CopyToSectionsDialog, ) from PyReconstruct.modules.gui.utils import notify from PyReconstruct.modules.calc import ( @@ -877,7 +878,65 @@ def wrapper(self, *args, **kwargs): # call to update is handled by field_interaction decorator return wrapper - + + @trace_function + def copyTracesToSections(self, traces : list): + """Copy the selected trace(s) onto multiple chosen sections at the same + field (x, y) location.""" + if self.hide_trace_layer: + return False + + # convert the selection to field coordinates, exactly as the copy path + # does, so the traces can be re-projected onto each target section + tform = self.section.tform + field_traces = [] + for trace in traces: + field_trace = trace.copy() + field_trace.points = [tform.map(*p) for p in trace.points] + field_traces.append(field_trace) + + # choose the target sections + chosen, confirmed = CopyToSectionsDialog(self, self.series).get() + if not confirmed: + return False + + # never copy onto the source (current) section + current = self.series.current_section + excluded_current = current in chosen + chosen.discard(current) + + if not chosen: + notify("No other sections were selected to copy to.") + return False + + names = list(set(t.name for t in field_traces)) + + copied_to, skipped = self.series.copyTracesToSections( + field_traces, chosen, self.series_states + ) + + # refresh the object/trace lists and the field view (only if anything + # actually changed) + if copied_to: + self.table_manager.updateObjects(names) + self.reload() + + # report the outcome to the user + msgs = [] + if copied_to: + msgs.append(f"Copied trace(s) to {len(copied_to)} section(s).") + if skipped: + msgs.append( + "Skipped section(s) with a non-invertible transform: " + + ", ".join(str(n) for n in sorted(skipped)) + ) + if excluded_current: + msgs.append(f"The current section ({current}) was left unchanged.") + if msgs: + notify("\n".join(msgs)) + + return bool(copied_to) + @trace_function @field_interaction def traceDialog(self, traces : list): diff --git a/tests/test_copy_traces_to_sections.py b/tests/test_copy_traces_to_sections.py new file mode 100644 index 00000000..26eed619 --- /dev/null +++ b/tests/test_copy_traces_to_sections.py @@ -0,0 +1,359 @@ +"""Tests for the "Copy to sections" feature. + +Covers the data-model operation that places selected trace(s) onto multiple +sections at the same field (x, y) location: + + * Series.copyTracesToSections re-projects field coordinates through each + target section's own transform, so a trace lands at the identical field + x-y on every section regardless of that section's alignment. + * Trace attributes (name, color, closed, tags) are preserved verbatim. + * Alignment-locked target sections still receive the copied trace (a lock + protects the transform, not trace content). + +Also unit-tests the pure section-spec parser used by the picker dialog. +""" +import os +import shutil +import pytest + +FIXTURE = os.path.join( + os.path.dirname(__file__), "..", "PyReconstruct", "assets", + "checker", "files", "shapes1.jser", +) + +# a distinctive, easy-to-recognise field-coordinate shape (4 points, closed) +FIELD_PTS = [(11.5, 22.25), (33.0, 22.25), (33.0, 44.0), (11.5, 44.0)] + + +def _load_series(tmp_path): + if not os.path.exists(FIXTURE): + pytest.skip("fixture shapes1.jser not found") + fp = str(tmp_path / "shapes1.jser") + shutil.copyfile(FIXTURE, fp) + + from PySide6.QtWidgets import QApplication + QApplication.instance() or QApplication(["test"]) + from PyReconstruct.modules.datatypes.series import Series + from PyReconstruct.modules.datatypes.series_data import SeriesData + + series = Series.openJser(fp) + sd = SeriesData(series) + sd.refresh() + series.data = sd + return series + + +def _unlock(series, snum): + """Persist align_locked=False on a section so it can be copied onto.""" + section = series.loadSection(snum) + section.align_locked = False + section.save() + + +def _field_trace(series, snum, name): + """A copy of a real trace (for its attributes) with distinctive points and + a marker tag, its points already in FIELD coordinates.""" + section = series.loadSection(snum) + trace = section.contours[name].getTraces()[0].copy() + trace.points = list(FIELD_PTS) + trace.tags = set(trace.tags) + trace.tags.add("copytest") + return trace + + +def _mapped_matches(section, trace, field_pts, tol=1e-3): + """True if this section's transform maps the trace's stored points back to + the expected field points.""" + if len(trace.points) != len(field_pts): + return False + tform = section.tform + for stored, expected in zip(trace.points, field_pts): + mx, my = tform.map(*stored) + if abs(mx - expected[0]) > tol or abs(my - expected[1]) > tol: + return False + return True + + +# --------------------------------------------------------------------------- +# pure parser +# --------------------------------------------------------------------------- + +def test_parse_section_spec(): + from PyReconstruct.modules.gui.dialog.copy_to_sections import parse_section_spec + valid = {0, 1, 2, 3, 4} + + assert parse_section_spec("1-3, 4", valid) == ({1, 2, 3, 4}, [], []) + assert parse_section_spec("2", valid) == ({2}, [], []) + assert parse_section_spec("0 2 4", valid) == ({0, 2, 4}, [], []) + # reversed range is normalised + assert parse_section_spec("3-1", valid) == ({1, 2, 3}, [], []) + + # bad / out-of-range tokens are reported and excluded + chosen, bad, missing = parse_section_spec("7", valid) + assert chosen == set() and bad == ["7"] and missing == [] + chosen, bad, missing = parse_section_spec("abc", valid) + assert chosen == set() and bad == ["abc"] and missing == [] + chosen, bad, missing = parse_section_spec("1-", valid) + assert chosen == set() and bad == ["1-"] and missing == [] + chosen, bad, missing = parse_section_spec("1-2-3", valid) + assert chosen == set() and bad == ["1-2-3"] and missing == [] + # a whole range out of range is bad + chosen, bad, missing = parse_section_spec("10-20", valid) + assert chosen == set() and bad == ["10-20"] and missing == [] + # good and bad mixed + chosen, bad, missing = parse_section_spec("2, nope, 4", valid) + assert chosen == {2, 4} and bad == ["nope"] and missing == [] + + +def test_parse_section_spec_overhanging_range_is_surfaced(): + """A range that overhangs the valid set still yields the sections that + exist, but the nonexistent remainder is reported, not silently dropped.""" + from PyReconstruct.modules.gui.dialog.copy_to_sections import parse_section_spec + valid = {0, 1, 2, 3, 4} + + chosen, bad, missing = parse_section_spec("3-7", valid) + assert chosen == {3, 4} + assert bad == [] + assert len(missing) == 1 + # 5, 6, 7 do not exist + assert "3" in missing[0] and "7" in missing[0] and "3 " in missing[0] + + # a range interrupted by a gap in the section numbering is surfaced too + chosen, bad, missing = parse_section_spec("0-4", {0, 1, 3, 4}) + assert chosen == {0, 1, 3, 4} + assert bad == [] + assert len(missing) == 1 and "1 " in missing[0] + + +def test_parse_section_spec_huge_range_returns_fast(): + """A huge typed upper bound must not materialize the range (UI hang).""" + import time + from PyReconstruct.modules.gui.dialog.copy_to_sections import parse_section_spec + valid = set(range(100)) + + start = time.monotonic() + chosen, bad, missing = parse_section_spec("1-999999999", valid) + elapsed = time.monotonic() - start + + assert elapsed < 1.0, f"huge range took {elapsed:.2f}s (range materialized?)" + assert chosen == set(range(1, 100)) + assert bad == [] + assert len(missing) == 1 # the nonexistent 100..999999999 remainder + + +def test_parse_section_spec_exotic_unicode_digits_do_not_crash(): + """Characters where str.isdigit() is True but int() raises (superscripts, + circled digits) must be reported as bad tokens, not crash the parser.""" + from PyReconstruct.modules.gui.dialog.copy_to_sections import parse_section_spec + valid = {0, 1, 2, 3, 4} + + # single-number path + chosen, bad, missing = parse_section_spec("5²", valid) # "5²" + assert chosen == set() and bad == ["5²"] + chosen, bad, missing = parse_section_spec("①", valid) # "①" + assert chosen == set() and bad == ["①"] + + # range-endpoint path + chosen, bad, missing = parse_section_spec("1-5²", valid) + assert chosen == set() and bad == ["1-5²"] + chosen, bad, missing = parse_section_spec("³-4", valid) # "³-4" + assert chosen == set() and bad == ["³-4"] + + # good tokens alongside exotic ones still parse + chosen, bad, missing = parse_section_spec("2, 5²", valid) + assert chosen == {2} and bad == ["5²"] + + +# --------------------------------------------------------------------------- +# data-model copy +# --------------------------------------------------------------------------- + +def test_copy_preserves_field_xy_and_attributes(tmp_path): + series = _load_series(tmp_path) + + # targets have real, distinct, non-identity transforms in this fixture + for snum in (2, 4): + _unlock(series, snum) + + ft = _field_trace(series, 0, "star") + + before = {} + for snum in (2, 4): + before[snum] = len(series.loadSection(snum).contours["star"].getTraces()) + + copied_to, skipped = series.copyTracesToSections( + [ft], {2, 4}, log_event=False + ) + + assert sorted(copied_to) == [2, 4] + assert skipped == [] + + for snum in (2, 4): + section = series.loadSection(snum) + traces = section.contours["star"].getTraces() + # exactly one trace added + assert len(traces) == before[snum] + 1 + # the added trace sits at the SAME field x-y (re-projected through this + # section's own transform) and keeps every attribute + match = [t for t in traces if _mapped_matches(section, t, FIELD_PTS)] + assert len(match) == 1, f"no field-matching trace on section {snum}" + copied = match[0] + assert copied.name == "star" + assert tuple(copied.color) == tuple(ft.color) + assert copied.closed == ft.closed + assert "copytest" in copied.tags + + +def test_copies_onto_locked_sections(tmp_path): + """A lock protects a section's transform, not its trace content, so a + locked target section still receives the copied trace.""" + series = _load_series(tmp_path) + + # section 1 stays locked (fixture default); section 2 is unlocked + _unlock(series, 2) + assert series.loadSection(1).align_locked is True + + locked_before = len(series.loadSection(1).contours["star"].getTraces()) + unlocked_before = len(series.loadSection(2).contours["star"].getTraces()) + + ft = _field_trace(series, 0, "star") + + copied_to, skipped = series.copyTracesToSections( + [ft], {1, 2}, log_event=False + ) + + assert sorted(copied_to) == [1, 2] + assert skipped == [] + + # locked section received the trace at the shared field x-y, lock intact + locked_section = series.loadSection(1) + assert locked_section.align_locked is True + assert len(locked_section.contours["star"].getTraces()) == locked_before + 1 + assert any( + _mapped_matches(locked_section, t, FIELD_PTS) + for t in locked_section.contours["star"].getTraces() + ) + # unlocked section received the trace too + assert len(series.loadSection(2).contours["star"].getTraces()) == unlocked_before + 1 + + +def test_identity_transform_copies_points_verbatim(tmp_path): + """When source and target share the same transform, the stored points are + identical (the field re-projection collapses to a copy).""" + series = _load_series(tmp_path) + + # section 0 has the identity transform; give the target the identity too + target = series.loadSection(3) + from PyReconstruct.modules.datatypes.transform import Transform + target.tform = Transform([1, 0, 0, 0, 1, 0]) + target.align_locked = False + target.save() + + ft = _field_trace(series, 0, "star") # points already == FIELD_PTS + + copied_to, skipped = series.copyTracesToSections( + [ft], {3}, log_event=False + ) + assert copied_to == [3] + assert skipped == [] + + section = series.loadSection(3) + match = [ + t for t in section.contours["star"].getTraces() + if len(t.points) == len(FIELD_PTS) + and all(abs(a[0] - b[0]) < 1e-6 and abs(a[1] - b[1]) < 1e-6 + for a, b in zip(t.points, FIELD_PTS)) + ] + assert len(match) == 1 + + +def test_copy_lands_at_same_field_xy_under_rotation_and_scale(tmp_path): + """Targets with rotation and scale in their transforms still receive the + trace at the identical field x-y (re-projected through each section's own + inverse transform).""" + import math + from PyReconstruct.modules.datatypes.transform import Transform + + series = _load_series(tmp_path) + + # section 2: rotation by 30 degrees plus a translation + c, s = math.cos(math.radians(30)), math.sin(math.radians(30)) + rot = Transform([c, -s, 1.25, s, c, -0.75]) + # section 4: anisotropic scale plus rotation by -45 degrees + c2, s2 = math.cos(math.radians(-45)), math.sin(math.radians(-45)) + scl = Transform([1.5 * c2, -1.5 * s2, -2.0, 0.8 * s2, 0.8 * c2, 3.0]) + + for snum, tform in ((2, rot), (4, scl)): + section = series.loadSection(snum) + section.tform = tform + section.align_locked = False + section.save() + + ft = _field_trace(series, 0, "star") + + copied_to, skipped = series.copyTracesToSections( + [ft], {2, 4}, log_event=False + ) + assert sorted(copied_to) == [2, 4] + assert skipped == [] + + for snum in (2, 4): + section = series.loadSection(snum) + match = [ + t for t in section.contours["star"].getTraces() + if _mapped_matches(section, t, FIELD_PTS) + ] + assert len(match) == 1, ( + f"trace on section {snum} did not land at the shared field x-y" + ) + # the stored points differ from the field points (the transform is + # non-identity), proving a real re-projection happened + stored = match[0].points + assert any( + abs(a[0] - b[0]) > 1e-6 or abs(a[1] - b[1]) > 1e-6 + for a, b in zip(stored, FIELD_PTS) + ) + + +def test_non_invertible_target_transform_is_skipped(tmp_path): + """A singular (non-invertible) target transform cannot place the trace: + the section is skipped and reported, never crashed on or written to.""" + from PyReconstruct.modules.datatypes.transform import Transform + + series = _load_series(tmp_path) + + for snum in (2, 3): + _unlock(series, snum) + + # det = 1*1 - 1*1 = 0: singular + singular = Transform([1, 1, 0, 1, 1, 0]) + section = series.loadSection(3) + section.tform = singular + section.save() + + before = len(series.loadSection(3).contours["star"].getTraces()) + + ft = _field_trace(series, 0, "star") + + copied_to, skipped = series.copyTracesToSections( + [ft], {2, 3}, log_event=False + ) + + assert copied_to == [2] + assert skipped == [3] + # the singular section was not written to + assert len(series.loadSection(3).contours["star"].getTraces()) == before + + +def test_all_invalid_targets_noop(tmp_path): + """Requesting only nonexistent sections must no-op gracefully.""" + series = _load_series(tmp_path) + + ft = _field_trace(series, 0, "star") + + copied_to, skipped = series.copyTracesToSections( + [ft], {9998, 9999}, log_event=False + ) + + assert copied_to == [] + assert skipped == []