setQuery() restored the loaded SQL text into the line edit but never
restored the "Edit SQL query" checkbox, so a custom query (a join, a
subquery, a view other than project_summary_view) displayed correctly
while the widget stayed in built-in mode. queryStr() only consults that
checkbox, so accepting the dialog without touching anything silently
replaced the custom query with a freshly generated one.
Detect it instead of trusting a flag that was never set: after parsing
columns from the loaded query, rebuild the query those columns would
produce and compare it against what was loaded. A mismatch means the
widget cannot reconstruct it, so it must be user-written -- check the
box and keep the literal text.
Verified against both cases this has to get right, not just the one
in the report: a genuinely custom query (join) now round-trips through
an untouched accept+save byte-for-byte, and a plain built-in query
written before the #238 pos-ordering fix (examples/industrial.qet, no
ORDER BY clause) is classified as custom rather than silently gaining
an ORDER BY it didn't have -- it round-trips unchanged rather than
being corrupted, though its column picker is now disabled until a user
rebuilds it by hand.
Based on the patch attached to #885.
QETProject::m_uuid was created in the constructor and never written, so
a project got a new uuid every time it was opened. Inside a running
instance it is only used to name the SQLite connection, but nothing
outside the instance could tell which project a file belongs to.
Motivation
A .qet file is increasingly handled by tools outside QElectroTech: Git
repositories on GitHub or GitLab, cloud storage, key-value stores,
per-project locks. All of them need a stable key for "this project":
- The file name and path are not stable: files get renamed, moved,
checked out in different places.
- The project title is user-editable and not unique.
- Folio uuids (persisted separately) are only unique within their
project; keying folios globally needs a project identifier as well,
e.g. projects/{projectUuid}/folios/{folioUuid}.
Change
Write the uuid as an attribute of <project> and restore it in
QETProject::openFile(), right after parsing and before the project is
built from the XML. Older versions ignore the attribute, so files stay
readable in both directions.
The project database is not affected: it takes its connection name
from the uuid created at construction (m_uuid is declared before
m_data_base), before the file is read. Two open projects carrying the
same persisted uuid therefore still get distinct connections.
Files without a uuid: why not a random one
Keeping the random uuid created by the constructor and saving it
conflicts with #754 / #779: saving an unmodified project must give the
same bytes every time. Every example project predates the attribute.
Measured on the 24 example projects (resaved 3x each from the same
original, QT_HASH_SEED=0 so that QDom's attribute order is stable,
isolated HOME per run):
upstream master 23/24 byte-identical
persist, random uuid 0/24
persist, derived uuid (this) 23/24
The remaining project, schema_indus.qet, differs only in element uuids,
the known residual #779 leaves for elements; its project uuid is
stable.
Instead, a project file without a uuid gets a name-based (version 5)
uuid derived from the raw content of the file:
QUuid::createUuidV5(<fixed QET project namespace>,
"qet-project-legacy\n" + file content without CR)
- The same file always yields the same uuid, so resaving an unmodified
legacy project stays reproducible.
- Different projects practically never share a uuid, because any
difference in content gives a different one. This is unlike folios,
where only data such as title and position could be used; the raw
file bytes are stable input for the whole project.
- Carriage returns are dropped before hashing. QFile's Text mode already
strips them on Windows but not elsewhere, and git's autocrlf can
change them on checkout; either way the uuid is the same on every
platform.
- The uuid is derived once, at load time, and saved from then on. After
that it is read, never recomputed: renaming the project, editing it
or changing it in the same session as the migration does not change
it.
- Two people opening the same legacy file on different branches get the
same project uuid.
The namespace uuid is fixed in the code and must never change, or every
legacy project would get a different uuid.
Known limitations, open for discussion
- Copies share the uuid. Two byte-identical legacy files get the same
uuid (examples/cablage-eclairages_sikli-v5.qet and
câblage-éclairages-sikli-v5.qet are such a pair), and so does a
migrated file copied in the file manager or saved with "Save as".
That is what identity means for a copy, and the same happens with Git,
but a tool that treats the uuid as globally unique has to cope with
it. Regenerating the uuid on "Save as" could be a follow-up, if that
is the preferred behaviour.
- A legacy file that differs from another only in formatting (e.g.
re-indented) gets a different uuid. The two sides of a merge only
agree if they started from the same bytes, which is the normal case.
Tests (Qt 6.4, offscreen, qelectrotech --resave / --set-titleblock /
--info)
- 24 example projects, 3 resaves each from the same original: results
above; the project uuid is identical across runs. All 24 uuids are
distinct, except the byte-identical pair mentioned above.
- Resaving an already migrated file is byte-identical to the first
output.
- The same legacy file with CRLF line endings gets the same uuid as
with LF.
- Changing the project title in a migrated file keeps its uuid.
- Migrating and modifying in the same run (--set-titleblock on a legacy
file) gives the same uuid as a plain resave.
- Re-indenting a legacy file gives a different uuid (expected).
- A migrated file opened with upstream master loads normally; the
attribute is ignored and dropped on save.
- --info on a migrated file still works.
Refs #754, #779
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDyt4txaott5JyPNGQaeVp
Ctrl+V pasted in place, which put the copy exactly on top of the original.
Nothing appeared to happen: the only clue was a doubled outline, and the
copy had to be dragged off the original to be seen at all. The cursor was
ignored entirely.
Ctrl+V now starts a placement. The items appear under the cursor and follow
it until a left click or Return drops them; Escape or a right click takes
them away again. That is the same interaction as placing a new element, so
paste behaves like every other way of putting something on a folio, and the
copy lands where the user is looking.
Implemented as a DiagramEventInterface beside the existing add-element and
add-macro tools. The pasted items are the real ones from the start rather
than a preview: Diagram::fromXml creates them exactly as before, this class
moves them, and PasteDiagramCommand is pushed only once they are dropped.
PasteDiagramCommand's first redo() deliberately does not add items to the
scene -- it assumes fromXml already did -- so pushing it on commit adopts
them rather than duplicating them. One copy of the paste logic, and a
cancelled paste leaves nothing on the undo stack.
Conductors are not moved directly; they are drawn from their terminals and
follow the elements they attach to. On cancel they are removed before the
elements, so none is left in the scene holding a pointer to a freed
terminal.
Verified by counting elements in the saved file rather than by eye:
56 to start, 56 after paste-then-Escape, 57 after paste-then-drop, and 56
again after undo. Save determinism run against this build: pass, no
regressions against baseline. Tests 5/5 on Qt 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two claims in the previous comment were wrong.
"Qt implements F10 on Windows but not on X11" was inference I cannot test
here. What is measured is narrower and enough: QMenuBar given Key_F10
directly leaves it unaccepted, and sent to the window the key never reaches
the menu bar at all, because a key press goes to the focused child widget.
"&Édition takes É, which is not on a UK or US keyboard" was wrong outright.
It came from running an uninstalled binary, which cannot find its .qm files
and falls back to the French source strings. With translations loaded the
menus read File, Edit, Project, Display, Settings, Windows, Help, and Alt+E
opens Edit.
The comment now also says plainly that this is convenience rather than
access: Alt tap focuses the bar and Alt with a letter opens a menu, both
verified working, so the menus were already reachable without a mouse. F10
is the key people reach for out of habit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GtMZqGEiUMvDBqcFvVG2vb
F10 opens the menu bar in most applications and is the usual way to reach
the menus without a mouse. Qt provides this on Windows but not on X11, so on
Linux the key did nothing and the press fell through to whichever widget had
focus. It matters more here than it might elsewhere: "&Édition" takes É for
its own letter, which is not on a UK or US keyboard, so that menu has no
direct Alt route at all.
A window-context QShortcut rather than a key handler -- key presses go to
the focused child widget, so a keyPressEvent() on the window would never see
F10 while the canvas or a panel has focus.
tests/qttest/tst_menubarkeyboard.cpp covers three things: that Alt and a
letter opens a menu (the control), that plain F10 does nothing in Qt itself
(which is why the shortcut exists, and which will fail loudly if a future Qt
starts handling it), and that the shortcut mechanism opens the bar.
It uses QTest instead of driving a real X server for a specific reason.
xdotool on Xvfb delivers every function key with Alt held: a Qt key logger
shows Key_F10 arriving with modifiers == Qt::AltModifier. --clearmodifiers,
keydown/keyup pairs, --window targeting and flattening the keycode with
xmodmap all made no difference. Two rounds of GUI automation therefore gave
confident, wrong answers about F10 -- first that it was broken, then that
this very fix did not work. QTest posts the event straight to the widget, so
the key arrives as written.
What the test does not cover, since initCommonActions() calls
QETApp::instance() and constructing that pulls in the whole application: it
repeats the wiring rather than driving QETMainWindow. Confirming the real
window responds still needs someone to press F10 in a running QElectroTech.
Verified by breaking it: bound to F11 instead, the test fails. Qt 5 and Qt 6
both build clean, 6/6 tests on each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing the Menu key (or Shift+F10) on a folio produced a bare
Undo/Redo/Cut/Copy/Paste/Delete/Select All menu with almost everything
disabled, instead of the menu a right-click gives.
A keyboard-raised QContextMenuEvent carries no useful position -- Qt does
not aim it at the selection. contextMenuEvent() passed the event to
QGraphicsView first, which handed it to whichever item held focus; that
item answered with its own default menu and accepted the event, so the
early return fired and the folio's menu was never built. Even past that,
the itemAt() lookup below would have used an unrelated point.
A keyboard-raised menu is now built directly rather than offered to the
items first, and aimed at the centre of the selection, or at the middle of
the view when nothing is selected. The mouse path is unchanged.
Measured on the same branch with only this change applied: before, the
menu carried 7 actions, all but one disabled; after, 16, positioned on the
selected element. Builds clean on Qt 5 and Qt 6, tests 5/5 on both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nine "Ajouter" actions -- text field, image, PDF, line, rectangle,
ellipse, polyline, curve, terminal strip -- were only ever added to
m_add_item_tool_bar, and the automatic conductor break only to
diagram_tool_bar. None carried a shortcut. A toolbar button has no key,
so someone working without a mouse could not add anything at all to a
folio.
They now appear in an "Ajouter" submenu under Édition, and the conductor
break beside m_auto_conductor in Projet, the setting it pairs with. The
actions themselves are untouched: a QAction can sit in a menu and a
toolbar at once, which is what m_depth_action_group -- created a few lines
away, and added to both its toolbar and menu_edition -- has always done.
That contrast is why this reads as an oversight rather than a decision.
Verified by driving the menus with the keyboard alone under Xvfb: Alt+F
opens the File menu, Down then Right crosses to Édition, and Right again
opens the Ajouter submenu with all eight actions this build compiles
(add_pdf is behind QET_HAS_QTPDF and absent on Qt 5).
Found with tools/keyboard-audit in the qelectrotech-docker harness, which
reports these ten and now reports none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tab cycles the folio's items, which means focusNextPrevChild() has to
refuse the usual focus traversal. On its own that leaves someone working
without a mouse able to reach the drawing area and never leave it -- the
exact person the Tab cycling was added for.
Escape now steps back out in two stages: it drops the selection first,
then hands focus to the next widget. The one-shot m_releasing_focus flag
is what lets that second Escape through the override.
Verified under Xvfb: with an item selected, Escape clears it (193k pixels
change); a second Escape changes nothing visually; a Tab after that moves
widget focus in the toolbar (306 pixels) instead of selecting on the
canvas, which is the behaviour of a view that no longer holds focus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch was cut on 31 July, one day before ShortcutManager landed
in 5275fb44f, so the two actions it adds were written before the
convention existed and are the only members of the selection group not
registered: select_all, select_nothing and select_invert all are.
Without this they never appear in the shortcuts configuration page, so
a user cannot bind a key to either of them.
Registered with an empty default sequence. They are menu actions and
neither has an obvious default worth claiming; the point of registering
them is that a user can bind one if they want. ShortcutManager stores
an empty default without setting a shortcut, and the conflict checker
already skips empty sequences.
Master merged in first, because ShortcutManager does not exist at this
branch's original base.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the second pillar of #574: keyboard-driven selection on the
diagram canvas.
Tab / Shift+Tab select the next / previous item on the current
diagram, cycling through items() (z-order) and wrapping at either
end. If nothing is selected, Tab selects the first item and
Shift+Tab the last. Skipped while a text item has focus, for the
same reason arrow-key movement already guards on !focusItem().
Candidates use the same "what counts as a real selectable diagram
item" filter (QetGraphicsItem / DiagramTextItem / Conductor) already
established by Diagram::invertSelection(), so the cycling order
always matches what a user could reach by clicking.
Getting Tab to actually reach the scene needed two separate fixes,
each independently discovered by empirical testing rather than
assumption:
- QWidget (DiagramView) intercepts Tab/Backtab for widget focus-chain
traversal before generating a key event at all. Overriding
DiagramView::focusNextPrevChild() to return false disables that.
- QGraphicsScene (Diagram) has its own, separate item-focus-chain
traversal, checked before keyPressEvent() is ever reached. The
obvious fix -- overriding Diagram::focusNextPrevChild() the same
way -- silently does nothing on Qt 5, because
QGraphicsScene::focusNextPrevChild() only becomes virtual in Qt 6
(guarded by the QT6_VIRTUAL macro); a compile error surfaced this
immediately when attempted directly, rather than shipping a fix
that worked on Qt 6 and silently no-opped on Qt 5. Intercepting
QEvent::KeyPress in Diagram::event() instead is virtual on every Qt
version and sidesteps the scene's internal traversal entirely.
Also adds Diagram::selectAllConductors() / selectAllTextFields(),
wired up as two new actions in the existing select_all /
select_nothing / select_invert action group in
qetdiagrameditor.cpp, so they appear in the Edit menu and go through
the same QAction -> data() -> selectGroupTriggered() dispatch as the
existing selection commands.
Verified end-to-end in a real running session (Xvfb + xdotool) with
a multi-transistor schematic: Tab/Shift+Tab correctly move a single
selection forward/backward through elements and text fields
(confirmed via the properties panel updating to each new item and
the visual selection box moving on canvas); Tab/Shift+Tab from no
selection correctly select the first/last item; "Select all
conductors" and "Select all text fields" each correctly select every
matching item and deselect everything else.
See discussion #574.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers the crash fixed in #868: a file forwarded from a second instance was
opened inside SingleApplication's socket handler, so the backup prompt's
nested event loop ran while that handler was still on the stack.
Run by hand; no build system or CI changes.
tests/ipc-regression/run.sh --binary build/qelectrotech
Validated in both directions on Qt 6.10.2: ceda1e082 (before the fix) crashes
3 times in 3 with exit 139, 199444b6 (after) survives 3 times in 3.
Three requirements are not obvious and are documented in the script:
- Qt 6 only. An unfixed Qt 5 build survives every attempt, so the script
refuses to run on a Qt 5 binary rather than report a pass that cannot fail.
- A Debug build. The same unfixed commit survives every attempt built
-O3 -DNDEBUG; whether a use-after-free faults depends on what the allocator
does with the freed block.
- Dismissing the backup prompt is the step that triggers it. Left open, the
stack never unwinds and nothing fails, which is why the bug was twice
reported as not reproducible.
The test runs in its own sandbox on its own X display, works on a copy of the
project so backup files do not land in examples/, and cleans up after itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment sat one tab deeper than the code around it. Flagged in
review on PR #868. Whitespace only; no change to behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QETApp::receiveMessage() called openFiles() directly. That slot runs inside
SingleApplication's socket handling: SingleApplicationPrivate::
slotDataAvailable() emits receivedMessage synchronously from the readyRead
lambda (singleapplication_p.cpp:452). openFiles() then loads a project --
seconds of work on a large one -- and openAndAddProject() puts up a modal
BackupDialog whose exec() runs a nested event loop while the socket handler
is still on the stack.
During that nested loop the secondary instance exits, the connection closes
and the QLocalSocket is deleted. When the dialog is dismissed and the stack
unwinds, QMetaObject::activate() carries on emitting on the freed sender and
the process dies.
A zero-timer returns to the event loop first, so the socket stack is fully
unwound before any project is opened.
Found by scorpio810 while testing PR #861, with a backtrace showing no QET
frame above the crash. His second suggestion, looking for a delete that
should be deleteLater(), turned out to be already satisfied at
singleapplication_p.cpp:331 -- which is why the deferred delete is not enough
on its own once a nested loop is in play.
Dismissing the dialog is the step that makes it fail: two earlier attempts to
reproduce it left the dialog open, the stack never unwound, and nothing
crashed. With the dialog dismissed it segfaults twice out of two; with this
change it survives twice out of two, opens the project as before, and ctest
stays green. Qt 6.10.2 on X11/xcb -- also checked under a headless Wayland
compositor and under Qt 5.15.18, so it is neither Wayland-specific nor a Qt6
regression.
The crash needs PR #861 to be reachable at all: without it splitWithSpaces()
returns an empty list, no project opens, and nothing enters this path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SummaryQueryWidget::queryStr() built its ORDER BY from the columns the user
chose to display, in the order they chose them:
column += key;
order_by += key;
So a summary whose first column is Title came out sorted alphabetically by
title, and one starting with Author sorted by author. A table of contents
lists the folios of a project; its order is the project's order, not
whatever the first column happens to be.
It now orders by "pos", the folio position that project_summary_view already
exposes from diagram.pos. That column is an INTEGER, so the sort is numeric
and folio 10 does not land between folio 1 and folio 2. One row per folio
means pos fully determines the order, so no secondary key is needed.
Demonstrated against a stand-in view holding four folios:
ORDER BY title, pos Apple(2) Banana(3) Mango(10) Zebra(1)
ORDER BY pos Zebra(1) Apple(2) Banana(3) Mango(10)
The hand-written query path (m_edit_sql_query_cb) returns before this and is
untouched, so anyone wanting a different order still has one.
ctest 4/4, Qt 5.15.18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The File > Recently-opened submenu was filled once, at editor construction,
by copying the QActions that RecentFiles' menu happened to hold at that
moment:
recentfile->addActions(QETApp::projectsRecentFiles()->menu()->actions());
RecentFiles::buildMenu() runs on every fileWasOpened(), clears its menu and
creates fresh QActions. The editor's copy therefore never gained an entry,
and the list only ever looked correct after a restart.
The submenu is now the RecentFiles menu itself. QMenu::addMenu() adds the
submenu's menuAction() rather than reparenting it, so several editor windows
can share the one live menu, which is what an application-wide recent-files
list should do anyway.
Measured with a temporary probe comparing the live menu against what the
File menu actually shows, after one file had been opened in the same
session:
without the fix live=1 shownInFileMenu=0
with the fix live=1 shownInFileMenu=1
ctest 4/4, GUI starts clean with the menu bar intact. Qt 5.15.18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conductor::paint() drew every junction as a fixed 3.0-unit ellipse,
regardless of how wide the conductor carrying it is. The conductor width is
user-settable from 0.4 to 20.0, so at anything above about 3.0 the dot is
narrower than the line it sits on and disappears entirely -- exactly when a
junction most needs to be legible.
The dot now scales with m_properties.cond_size, floored at the historic 3.0
so nothing changes at or below the default width of 1.0. Only the wide
conductors the report is about are affected.
cond_size is used rather than the pen width because the pen is inflated by 4
while the mouse is over the conductor; the junction should not grow on
hover.
Measured with a temporary trace over examples/741.qet: at the default width
the diameter stays 3.00, and with condsize="5" it becomes 15.00. Visually,
a PNG export of that widened project shows two junctions that were invisible
under the line rendering as clear dots. ctest 4/4, Qt 5.15.18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QET::splitWithSpaces() split on QRegularExpression("[^\\]?(?:\\\\)* ").
That is not a valid pattern: "[^\\]" opens a character class whose "\\]" is
an escaped bracket, so the class is never closed. QRegularExpression
reported isValid() == false, QString::split() warned "invalid
QRegularExpression object", and the function returned an EMPTY list for
every input.
It is the receiving half of the SingleApplication handshake: a secondary
instance sends "launched-with-args: " + joinWithSpaces(args) (main.cpp) and
the running instance parses it in QETApp::receiveMessage() before calling
openFiles(). With the split always empty, the running instance received no
arguments at all -- so opening a project while QET was already running
silently did nothing.
The bug is reported against filenames containing spaces, which is how it
was noticed, but it is not limited to them: plain names failed identically.
A corrected regex is not available. The separator is a space preceded by an
even-length run of backslashes, and PCRE2 has no variable-length lookbehind,
so the run cannot be expressed in a lookbehind and anything that matches it
by consumption eats the character before the space -- which is what the
"[^\\]?" was for. Scanning the string explicitly is correct and easier to
read.
tests/qttest/tst_qetstrings.cpp asserts the round trip
splitWithSpaces(joinWithSpaces(x)) == x over plain names, embedded spaces,
embedded backslashes, a trailing backslash and a mixture, plus the specific
regression that a plain argument list does not come back empty.
Verified the test fails without the fix: 9 of 11 cases fail on the old
implementation and all 11 pass with it. Full suite 5/5, Qt 5.15.18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forum #3186 / issue #850: a user who has built up conductor and element
numbering rules in one project has no way to reuse them in the next one.
The only answer today is to open both .qet files in a text editor and copy
the XML across by hand.
Adds an "Import from another project..." button to the auto-numbering page
of the project properties dialog. It offers every numbering found in the
chosen file, per category, with names that already exist here unticked by
default and a "replace same-named numberings" option for when that is what
the user wants.
The source file is parsed as plain XML rather than opened as a QETProject.
Opening it would run the whole load path, including the modal dialog raised
for a file written by a different version of QElectroTech -- a dialog the
user has no reason to see, since nothing but the <newdiagrams> block is
being read.
Two supporting changes:
- readValuesFromProject() clears the three combo boxes before filling
them. It only ran once before; it now runs again after an import, and
without the clear every name appeared twice.
- FolioAutonumberingW::setContext() likewise replaces its list instead
of appending to it. It has a single caller, the line above.
This deliberately does not attempt the project-template feature also raised
on the forum thread. That needs decisions about where templates live and
what else they carry, and is better settled in a discussion first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A slave and a terminal are both routinely separately orderable hardware. A
circuit breaker can carry ten or twenty auxiliary blocks, each with its own
order code, and a terminal block is a purchased part in its own right.
Neither was reaching the bill of materials.
Decided in discussion #847: @IBSYSLevi -- "I would not expect that a defined
piece of hardware is excluded from BOM when not specifically defined as so" --
with use cases from @jozi332 covering Siemens breakers with ten to twenty
auxiliary blocks and PLC cards carrying per-channel data.
Two filters had to change, which is easy to miss: BomExport::defaultQuery()
and, upstream of it, the WHERE clause of element_nomenclature_view itself.
Changing only the query does nothing for slaves, because the view had already
removed them. Terminals were already in the view, so they appeared as soon as
the query allowed them -- which made a half-finished change look like it had
worked.
Measured on examples/industrial.qet, which holds 96 terminals and 41 slaves:
258 rows before, 354 with terminals, 395 with both. A slave given a
manufacturer and part number now appears in the export; previously it could
not, at any setting.
Nothing that should stay out of a bill of materials is newly included. The
folio report arrows and the conductor definition are still excluded because
they are not hardware, and anything else -- a relay's own auxiliary contact,
which is not orderable separately -- is kept out with exclude_from_bom, which
the view already honours and which #721 and #765 made settable on the symbol
itself.
tst_smart_device is updated rather than weakened. @enesgursoy6110 wrote it in
#830 to prove the filter works, inserting rows designated "Must not be
exported"; the slave and terminal rows now carry real designations and are
asserted present, and a folio report arrow takes over as the negative case,
so the test still proves filtering happens -- at the boundary we now want.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>